// Package settings provides global persisted settings access the telemetry.
//
// Logic for interacting with on-disk configuration and result files,
// as well as remote URL endpoings, is all contained here.
package settings

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"net/http"
	"os"
	"os/exec"
	"os/user"
	"path"
	"path/filepath"
	"runtime/trace"
	"sync"
	"time"

	"github.com/10XDev/cellranger/lib/go/telemetry/config"
)

const (
	configCheckSentinel  = "config-update-check"
	configFileName       = "config.json"
	disableFile          = "disable"
	disableUploadFile    = "disable_upload"
	disableUpdateFile    = "disable_update"
	configUpdateInterval = time.Hour
	DisableEnvVar        = "TENX_DISABLE_TELEMETRY"

	configDownloadUrlBase = "https://config.telemetry.10xgenomics.com/"
	telemetryUploadUrl    = "https://upload.telemetry.10xgenomics.com/api/v1/event"
	PrivacyStatementUrl   = "https://10xgen.com/pipeline-telemetry"
)

// Attempts to find the directory containing the root of the pipeline
// deployment.
var PipelineRoot = sync.OnceValue(getPipelineRoot)

// Check that any of the expected files exist in that directory.
func isPipelineRoot(dir string) bool {
	if _, err := os.Stat(filepath.Join(dir,
		".version")); err == nil {
		return true
	}
	if _, err := os.Stat(filepath.Join(dir,
		"external", "martian")); err == nil {
		return true
	}
	if _, err := os.Stat(filepath.Join(dir,
		"lib", "bin")); err == nil {
		return true
	}
	if _, err := os.Stat(filepath.Join(dir,
		"bin", "tenkit")); err == nil {
		return true
	}
	return false
}

func getPipelineRoot() string {
	// There are several ways to find the path to an executable.  In some
	// cases it is important not to resolve symlinks, for example if the
	// executable is in a `runfiles` tree generated by bazel.
	if exe, err := exec.LookPath(os.Args[0]); err == nil || errors.Is(err, exec.ErrDot) {
		if dir, err := filepath.Abs(filepath.Dir(exe)); err == nil {
			// It is expected that this binary is in $ROOT/lib/bin.
			dir = filepath.Dir(filepath.Dir(dir))
			// dir should be the root directory.
			if isPipelineRoot(dir) {
				return dir
			}
		}
	}
	// If the executable had been symlinked into another location, or if it was
	// run with for example `exec -a` so that args[0] isn't actually the name of
	// the binary, use an alternate method to find the executable.
	// On linux, this will use /proc/self/exe, which resolves all symlinks.
	exe, err := os.Executable()
	if err != nil {
		panic(err)
	}
	if dir, err := filepath.Abs(filepath.Dir(exe)); err == nil {
		// It is expected that this binary is in $ROOT/lib/bin
		dir = filepath.Dir(filepath.Dir(dir))
		// dir should be the root directory.
		if isPipelineRoot(dir) {
			return dir
		}
	}
	exe, err = filepath.EvalSymlinks(exe)
	if err != nil {
		panic(err)
	}
	return filepath.Dir(filepath.Dir(filepath.Dir(exe)))
}

// ConfigDir returns the path to the configuration settings directory.
func ConfigDir() (string, error) {
	c, err := os.UserConfigDir()
	if err != nil {
		// os.UserConfigDir can fail if $HOME isn't set, but there's another
		// way to get the user's home directory if you're willing to take a
		// dependency on libc, which we are, here.
		if u, err := user.Current(); err == nil && u.HomeDir != "" {
			return path.Join(u.HomeDir, ".config", "tenx", "telemetry"), nil
		}
		return "", err
	}
	return path.Join(c, "tenx", "telemetry"), nil
}

type TelemetryStatus struct {
	DisableAll          bool
	DisableConfigUpdate bool
	DisableUpload       bool
	HasRun              bool
}

func getProduct() (string, error) {
	if p := os.Getenv("TENX_PRODUCT"); p != "" {
		return p, nil
	}
	// Find parent process.
	if exe, err := executableForPid(os.Getppid()); err != nil {
		return "", fmt.Errorf(
			"could not detect product; error getting parent executable: %w",
			err)
	} else {
		// in some situations, this process may have been reparented to
		// an init process.  We don't want to treat that as the product,
		// so we reject those cases.
		switch bn := path.Base(exe); bn {
		case "systemd", "init":
			return "", fmt.Errorf(
				"could not detect product; parent executable was %s",
				bn)
		default:
			return bn, nil
		}
	}
}

var noPipelineRoot = errors.New("failed to locate pipeline root")

func getVersion() (string, error) {
	if v := os.Getenv("TENX_VERSION"); v != "" {
		return v, nil
	}
	r := PipelineRoot()
	if r == "" {
		return "", fmt.Errorf("could not detect version: %w", noPipelineRoot)
	}
	b, err := os.ReadFile(path.Join(r, ".version"))
	if err != nil {
		return "", fmt.Errorf("could not read version file: %w", err)
	}
	b = bytes.TrimSpace(b)
	if len(b) == 0 {
		return "", errors.New("empty version file")
	}
	return string(b), nil
}

// GetTelemetryStatus returns whether telemetry is disabled, and whether this
// user has run telemetry before.
func GetTelemetryStatus() TelemetryStatus {
	defer trace.StartRegion(context.TODO(), "GetTelemetryStatus").End()
	// Check environment first, since it's faster.
	if os.Getenv(DisableEnvVar) != "" {
		return TelemetryStatus{
			DisableAll:          true,
			DisableConfigUpdate: true,
			DisableUpload:       true,
			HasRun:              true,
		}
	}
	var status TelemetryStatus
	if os.Getenv(DisableEnvVar+"_UPLOAD") != "" {
		status.DisableUpload = true
		status.HasRun = true
	}
	if os.Getenv(DisableEnvVar+"_UPDATE") != "" {
		status.DisableConfigUpdate = true
		status.HasRun = true
	}
	if c, _ := ConfigDir(); c != "" {
		configFiles, err := os.ReadDir(c)
		if err == nil {
			status.HasRun = true
			for _, entry := range configFiles {
				switch entry.Name() {
				case disableFile:
					return TelemetryStatus{
						DisableAll:          true,
						DisableConfigUpdate: true,
						DisableUpload:       true,
						HasRun:              true,
					}
				case disableUploadFile:
					status.DisableUpload = true
				case disableUpdateFile:
					status.DisableConfigUpdate = true
				}
			}
		}
	}
	if !status.DisableConfigUpdate {
		if _, err := os.Stat("/etc/tenx/telemetry/" + disableUpdateFile); err == nil {
			status.DisableConfigUpdate = true
		}
	}
	if !status.DisableUpload {
		if _, err := os.Stat("/etc/tenx/telemetry/" + disableUploadFile); err == nil {
			status.DisableUpload = true
			// This user hasn't necessarily run before, but it's not necessary
			// to warn them if the upload is disabled globally.
			status.HasRun = true
		}
	}
	if !status.HasRun {
		if c, _ := CacheDir(); c != "" {
			if _, err := os.Stat(c); err == nil {
				status.HasRun = true
			}
		}
	}
	return status
}

func lastConfigUpdateCheck(product, version string) time.Time {
	c, _ := ConfigDir()
	if c == "" {
		return time.Time{}
	}
	info, err := os.Stat(path.Join(c, product, version, configCheckSentinel))
	if err != nil {
		return time.Time{}
	}
	return info.ModTime()
}

// Print a message warning the user about telemetry upload and informing them
// about how to opt out.
func printWarningMessage() {
	dir, err := ProductCacheDir()
	if err != nil {
		dir = `~/.cache/tenx/telemetry/`
	}
	fmt.Fprintf(os.Stderr,
		`Thank you for using cellranger. To help us improve our product,
anonymized telemetry data has been collected and sent to 10X Genomics.
This data helps us understand usage patterns, diagnose issues,
and prioritize improvements.

You can inspect the telemetry metrics sent by looking in
%s

For more details on what data is collected and how it's used, please visit
`+PrivacyStatementUrl+`

You can disable telemetry at any time by running the following command:
	cellranger telemetry disable
`, dir)
}

func (status *TelemetryStatus) maybeWarn() {
	if !status.HasRun {
		printWarningMessage()
		status.HasRun = true
	}
}

func configFilePath(product, version string) string {
	if c, _ := ConfigDir(); c != "" {
		return path.Join(c, product, version, configFileName)
	} else {
		return ""
	}
}

func downloadConfig(ctx context.Context, product, version string, verbose bool) []byte {
	defer trace.StartRegion(ctx, "downloadConfig").End()
	ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel()
	confUrl := configDownloadUrlBase + product + "/" + version + "/config.json"
	r, err := http.NewRequestWithContext(ctx, http.MethodGet,
		confUrl, nil)
	if err != nil {
		if verbose {
			fmt.Fprintln(os.Stderr, "Error creating config download request:", err)
		}
		return nil
	}
	r.Header.Set("Accept", "application/json")
	resp, err := http.DefaultClient.Do(r)
	if err != nil {
		if verbose {
			fmt.Fprintf(os.Stderr, "Error downloading from %s: %v\n",
				confUrl, err)
		}
		return nil
	}
	defer resp.Body.Close()
	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
		if verbose {
			fmt.Fprintf(os.Stderr, "Error downloading from %s: %d %s\n",
				confUrl, resp.StatusCode, resp.Status)
			if b, _ := io.ReadAll(resp.Body); len(bytes.TrimSpace(b)) > 0 {
				fmt.Fprintln(os.Stderr, string(b))
			}
		}
		return nil
	}
	b, err := io.ReadAll(resp.Body)
	if err != nil || len(b) == 0 {
		if verbose {
			fmt.Fprintf(os.Stderr, "Error downloading from %s: %v\n",
				confUrl, err)
		}
		return nil
	}
	if c := configFilePath(product, version); c != "" {
		_ = os.MkdirAll(path.Dir(c), 0755)
		// Don't touch the config file if it is unchanged.
		oldB, _ := os.ReadFile(c)
		if bytes.Equal(oldB, b) || (os.WriteFile(c+".tmp", b, 0644) == nil &&
			os.Rename(c+".tmp", c) == nil) {
			if err := os.WriteFile(
				path.Join(path.Dir(c), configCheckSentinel),
				nil, 0644); err != nil && verbose {
				fmt.Fprintf(os.Stderr, "Error saving config file from %s: %v\n",
					confUrl, err)
			}
		}
	}
	return b
}

func readConfigFile(product, version string) ([]byte, error) {
	if c := configFilePath(product, version); c != "" {
		b, err := os.ReadFile(c)
		if err == nil {
			return b, nil
		}
	}
	r := PipelineRoot()
	if r == "" {
		return nil, fmt.Errorf(
			"could not find fallback config: %w", noPipelineRoot)
	}
	// Fall back to file in the deployment tarball.
	return os.ReadFile(path.Join(r, "etc", product+"_telemetry.json"))
}

// GetConfig loads the most appropriate version of the config file.
//
// Errors from this pathway are ignored unless verbose logging is enabled,
// because they are not interesting to a user.
func (status *TelemetryStatus) GetConfig(
	ctx context.Context,
	verbose bool,
) (*config.Config, error) {
	defer trace.StartRegion(ctx, "GetConfig").End()
	if status.DisableAll {
		return nil, nil
	}
	product, err := getProduct()
	if err != nil {
		return nil, err
	}
	if err := ctx.Err(); err != nil {
		return nil, err
	}
	version, err := getVersion()
	if err != nil {
		return nil, err
	}
	if err := ctx.Err(); err != nil {
		return nil, err
	}
	var confBytes []byte
	if !status.DisableConfigUpdate &&
		time.Since(lastConfigUpdateCheck(product, version)) > configUpdateInterval {
		status.maybeWarn()
		confBytes = downloadConfig(ctx, product, version, verbose)
	}
	if err := ctx.Err(); err != nil {
		return nil, err
	}
	if len(confBytes) == 0 {
		confBytes, err = readConfigFile(product, version)
		if err != nil {
			return nil, err
		}
	}
	var conf config.Config
	err = json.Unmarshal(confBytes, &conf)
	if err != nil {
		return nil, fmt.Errorf("parsing config file: %w", err)
	}
	conf.Product = product
	conf.Version = version
	if !status.DisableUpload {
		status.maybeWarn()
	}
	return &conf, nil
}

// DisableAll writes a file which disables telemetry collection for this user.
//
// Errors from this should be displayed visibly to the user to ensure that they
// are aware that telemetry has been disabled.
func Disable(all, upload, update bool) error {
	c, err := ConfigDir()
	if err != nil {
		return fmt.Errorf("could not locate configuration directory: %w", err)
	}
	if err := os.MkdirAll(c, 0755); err != nil {
		return fmt.Errorf("could not create configuration directory: %w", err)
	}
	if all {
		if err := os.WriteFile(path.Join(c, disableFile), nil, 0644); err != nil {
			return fmt.Errorf(
				"could not write telemetry disable sentinel file: %w", err)
		}
		return nil
	}
	var errs []error
	if upload {
		if err := os.WriteFile(path.Join(c, disableUploadFile), nil, 0644); err != nil {
			errs = append(errs, fmt.Errorf(
				"could not write telemetry disable sentinel file: %w", err))
		}
	}
	if update {
		if err := os.WriteFile(path.Join(c, disableUpdateFile), nil, 0644); err != nil {
			errs = append(errs, fmt.Errorf(
				"could not write telemetry disable sentinel file: %w", err))
		}
	}
	return errors.Join(errs...)
}

func Enable(all, upload, update bool) error {
	c, err := ConfigDir()
	if err != nil {
		return fmt.Errorf("could not locate configuration directory: %w", err)
	}
	if _, err := os.Stat(c); errors.Is(err, fs.ErrNotExist) {
		return nil
	}
	var errs []error
	if all {
		if err := os.Remove(path.Join(c, disableFile)); err != nil &&
			!errors.Is(err, fs.ErrNotExist) {
			errs = append(errs, fmt.Errorf(
				"could not remove telemetry disable sentinel file: %w", err))
		}
	}
	if upload || all {
		if err := os.Remove(path.Join(c, disableUploadFile)); err != nil &&
			!errors.Is(err, fs.ErrNotExist) {
			errs = append(errs, fmt.Errorf(
				"could not remove telemetry disable sentinel file: %w", err))
		}
	}
	if update || all {
		if err := os.Remove(path.Join(c, disableUpdateFile)); err != nil &&
			!errors.Is(err, fs.ErrNotExist) {
			errs = append(errs, fmt.Errorf(
				"could not remove telemetry disable sentinel file: %w", err))
		}
	}
	return errors.Join(errs...)
}
