// Copyright 2023 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package server

import (
	"context"
	"fmt"
	"math/rand"
	"os"
	"path/filepath"
	"strconv"
	"testing"
	"time"

	"golang.org/x/telemetry"
	"golang.org/x/telemetry/counter"
	"golang.org/x/tools/gopls/internal/protocol"
	"golang.org/x/tools/internal/event"
)

// promptTimeout is the amount of time we wait for an ongoing prompt before
// prompting again. This gives the user time to reply. However, at some point
// we must assume that the client is not displaying the prompt, the user is
// ignoring it, or the prompt has been disrupted in some way (e.g. by a gopls
// crash).
const promptTimeout = 24 * time.Hour

// gracePeriod is the amount of time we wait before sufficient telemetry data
// is accumulated in the local directory, so users can have time to review
// what kind of information will be collected and uploaded when prompting starts.
const gracePeriod = 7 * 24 * time.Hour

// samplesPerMille is the prompt probability.
// Token is an integer between [1, 1000] and is assigned when maybePromptForTelemetry
// is called first time. Only the user with a token ∈ [1, samplesPerMille]
// will be considered for prompting.
const samplesPerMille = 10 // 1% sample rate

// The following constants are used for testing telemetry integration.
const (
	TelemetryPromptWorkTitle    = "Checking telemetry prompt"     // progress notification title, for awaiting in tests
	GoplsConfigDirEnvvar        = "GOPLS_CONFIG_DIR"              // overridden for testing
	FakeTelemetryModefileEnvvar = "GOPLS_FAKE_TELEMETRY_MODEFILE" // overridden for testing
	FakeSamplesPerMille         = "GOPLS_FAKE_SAMPLES_PER_MILLE"  // overridden for testing
	TelemetryYes                = "Yes, I'd like to help."
	TelemetryNo                 = "No, thanks."
)

// The following environment variables may be set by the client.
// Exported for testing telemetry integration.
const (
	GoTelemetryGoplsClientStartTimeEnvvar = "GOTELEMETRY_GOPLS_CLIENT_START_TIME" // telemetry start time recorded in client
	GoTelemetryGoplsClientTokenEnvvar     = "GOTELEMETRY_GOPLS_CLIENT_TOKEN"      // sampling token
)

// getenv returns the effective environment variable value for the provided
// key, looking up the key in the session environment before falling back on
// the process environment.
func (s *server) getenv(key string) string {
	if v, ok := s.Options().Env[key]; ok {
		return v
	}
	return os.Getenv(key)
}

// telemetryMode returns the current effective telemetry mode.
// By default this is x/telemetry.Mode(), but it may be overridden for tests.
func (s *server) telemetryMode() string {
	if fake := s.getenv(FakeTelemetryModefileEnvvar); fake != "" {
		if data, err := os.ReadFile(fake); err == nil {
			return string(data)
		}
		return "local"
	}
	return telemetry.Mode()
}

// setTelemetryMode sets the current telemetry mode.
// By default this calls x/telemetry.SetMode, but it may be overridden for
// tests.
func (s *server) setTelemetryMode(mode string) error {
	if fake := s.getenv(FakeTelemetryModefileEnvvar); fake != "" {
		return os.WriteFile(fake, []byte(mode), 0666)
	}
	return telemetry.SetMode(mode)
}

// maybePromptForTelemetry checks for the right conditions, and then prompts
// the user to ask if they want to enable Go telemetry uploading. If the user
// responds 'Yes', the telemetry mode is set to "on".
//
// The actual conditions for prompting are defensive, erring on the side of not
// prompting.
// If enabled is false, this will not prompt the user in any condition,
// but will send work progress reports to help testing.
func (s *server) maybePromptForTelemetry(ctx context.Context, enabled bool) {
	if s.Options().VerboseWorkDoneProgress {
		work := s.progress.Start(ctx, TelemetryPromptWorkTitle, "Checking if gopls should prompt about telemetry...", nil, nil)
		defer work.End(ctx, "Done.")
	}

	errorf := func(format string, args ...any) {
		err := fmt.Errorf(format, args...)
		event.Error(ctx, "telemetry prompt failed", err)
	}

	// Only prompt if we can read/write the prompt config file.
	configDir := s.getenv(GoplsConfigDirEnvvar) // set for testing
	if configDir == "" && testing.Testing() {
		// Unless tests set GoplsConfigDirEnvvar, the prompt is a no op.
		// We don't want tests to interact with os.UserConfigDir().
		return
	}
	if configDir == "" {
		userDir, err := os.UserConfigDir()
		if err != nil {
			errorf("unable to determine user config dir: %v", err)
			return
		}
		configDir = filepath.Join(userDir, "gopls")
	}

	// Read the current prompt file.

	var (
		promptDir  = filepath.Join(configDir, "prompt")    // prompt configuration directory
		promptFile = filepath.Join(promptDir, "telemetry") // telemetry prompt file
	)

	// prompt states, stored in the prompt file
	const (
		pUnknown  = ""        // first time
		pNotReady = "-"       // user is not asked yet (either not sampled or not past the grace period)
		pYes      = "yes"     // user said yes
		pNo       = "no"      // user said no
		pPending  = "pending" // current prompt is still pending
		pFailed   = "failed"  // prompt was asked but failed
	)
	validStates := map[string]bool{
		pNotReady: true,
		pYes:      true,
		pNo:       true,
		pPending:  true,
		pFailed:   true,
	}

	// Parse the current prompt file.
	var (
		state    = pUnknown
		attempts = 0 // number of times we've asked already

		// the following are recorded after gopls v0.17+.
		token        = 0   // valid token is [1, 1000]
		creationTime int64 // unix time sec
	)
	if content, err := os.ReadFile(promptFile); err == nil {
		if n, _ := fmt.Sscanf(string(content), "%s %d %d %d", &state, &attempts, &creationTime, &token); (n == 2 || n == 4) && validStates[state] {
			// successfully parsed!
			//  ~ v0.16: must have only two fields, state and attempts.
			//  v0.17 ~: must have all four fields.
		} else {
			state, attempts, creationTime, token = pUnknown, 0, 0, 0
			// TODO(hyangah): why do we want to present this as an error to user?
			errorf("malformed prompt result %q", string(content))
		}
	} else if !os.IsNotExist(err) {
		errorf("reading prompt file: %v", err)
		// Something went wrong. Since we don't know how many times we've asked the
		// prompt, err on the side of not asking.
		//
		// But record this in telemetry, in case some users enable telemetry by
		// other means.
		counter.New("gopls/telemetryprompt/corrupted").Inc()
		return
	}

	counter.New(fmt.Sprintf("gopls/telemetryprompt/attempts:%d", attempts)).Inc()

	// Check terminal conditions.

	if state == pYes {
		// Prompt has been accepted.
		//
		// We record this counter for every gopls session, rather than when the
		// prompt actually accepted below, because if we only recorded it in the
		// counter file at the time telemetry is enabled, we'd never upload it,
		// because we exclude any counter files that overlap with a time period
		// that has telemetry uploading is disabled.
		counter.New("gopls/telemetryprompt/accepted").Inc()
		return
	}
	if state == pNo {
		// Prompt has been declined. In most cases, this means we'll never see the
		// counter below, but it's possible that the user may enable telemetry by
		// other means later on. If we see a significant number of users that have
		// accepted telemetry but declined the prompt, it may be an indication that
		// the prompt is not working well.
		counter.New("gopls/telemetryprompt/declined").Inc()
		return
	}
	if attempts >= 5 { // pPending or pFailed
		// We've tried asking enough; give up. Record that the prompt expired, in
		// case the user decides to enable telemetry by other means later on.
		// (see also the pNo case).
		counter.New("gopls/telemetryprompt/expired").Inc()
		return
	}

	// We only check enabled after (1) the work progress is started, and (2) the
	// prompt file has been read. (1) is for testing purposes, and (2) is so that
	// we record the "gopls/telemetryprompt/accepted" counter for every session.
	if !enabled {
		return // prompt is disabled
	}

	if s.telemetryMode() == "on" || s.telemetryMode() == "off" {
		// Telemetry is already on or explicitly off -- nothing to ask about.
		return
	}

	// Transition: pUnknown -> pNotReady
	if state == pUnknown {
		// First time; we need to make the prompt dir.
		if err := os.MkdirAll(promptDir, 0777); err != nil {
			errorf("creating prompt dir: %v", err)
			return
		}
		state = pNotReady
	}

	// Correct missing values.
	if creationTime == 0 {
		creationTime = time.Now().Unix()
		if v := s.getenv(GoTelemetryGoplsClientStartTimeEnvvar); v != "" {
			if sec, err := strconv.ParseInt(v, 10, 64); err == nil && sec > 0 {
				creationTime = sec
			}
		}
	}
	if token == 0 {
		token = rand.Intn(1000) + 1
		if v := s.getenv(GoTelemetryGoplsClientTokenEnvvar); v != "" {
			if tok, err := strconv.Atoi(v); err == nil && 1 <= tok && tok <= 1000 {
				token = tok
			}
		}
	}

	// Transition: pNotReady -> pPending if sampled
	if state == pNotReady {
		threshold := samplesPerMille
		if v := s.getenv(FakeSamplesPerMille); v != "" {
			if t, err := strconv.Atoi(v); err == nil {
				threshold = t
			}
		}
		if token <= threshold && time.Now().Unix()-creationTime > gracePeriod.Milliseconds()/1000 {
			state = pPending
		}
	}

	// Acquire the lock and write the updated state to the prompt file before actually
	// prompting.
	//
	// This ensures that the prompt file is writeable, and that we increment the
	// attempt counter before we prompt, so that we don't end up in a failure
	// mode where we keep prompting and then failing to record the response.

	release, ok, err := acquireLockFile(promptFile)
	if err != nil {
		errorf("acquiring prompt: %v", err)
		return
	}
	if !ok {
		// Another process is making decision.
		return
	}
	defer release()

	if state != pNotReady { // pPending or pFailed
		attempts++
	}

	pendingContent := fmt.Appendf(nil, "%s %d %d %d", state, attempts, creationTime, token)
	if err := os.WriteFile(promptFile, pendingContent, 0666); err != nil {
		errorf("writing pending state: %v", err)
		return
	}

	if state == pNotReady {
		return
	}

	var prompt = `Go telemetry helps us improve Go by periodically sending anonymous metrics and crash reports to the Go team. Learn more at https://go.dev/doc/telemetry.

Would you like to enable Go telemetry?
`
	if s.Options().LinkifyShowMessage {
		prompt = `Go telemetry helps us improve Go by periodically sending anonymous metrics and crash reports to the Go team. Learn more at [go.dev/doc/telemetry](https://go.dev/doc/telemetry).

Would you like to enable Go telemetry?
`
	}
	// TODO(rfindley): investigate a "tell me more" action in combination with ShowDocument.
	action, err := showMessageRequest(ctx, s.client, protocol.Info, prompt, TelemetryYes, TelemetryNo)
	if err != nil {
		errorf("ShowMessageRequest failed: %v", err)
	}

	message := func(typ protocol.MessageType, msg string) {
		if !showMessage(ctx, s.client, typ, msg) {
			// Make sure we record that "telemetry prompt failed".
			errorf("showMessage failed: %v", err)
		}
	}

	result := pFailed
	if action == "" {
		// e.g. dialog was dismissed
		errorf("no response")
	} else {
		// Response matches MessageActionItem.Title.
		switch action {
		case TelemetryYes:
			result = pYes
			if err := s.setTelemetryMode("on"); err == nil {
				message(protocol.Info, telemetryOnMessage(s.Options().LinkifyShowMessage))
			} else {
				errorf("enabling telemetry failed: %v", err)
				msg := fmt.Sprintf("Failed to enable Go telemetry: %v\nTo enable telemetry manually, please run `go run golang.org/x/telemetry/cmd/gotelemetry@latest on`", err)
				message(protocol.Error, msg)
			}

		case TelemetryNo:
			result = pNo
		default:
			errorf("unrecognized response %q", action)
			message(protocol.Error, fmt.Sprintf("Unrecognized response %q", action))
		}
	}
	resultContent := fmt.Appendf(nil, "%s %d %d %d", result, attempts, creationTime, token)
	if err := os.WriteFile(promptFile, resultContent, 0666); err != nil {
		errorf("error writing result state to prompt file: %v", err)
	}
}

// showMessageRequest causes the client to show a plain-text prompt that the user can respond to.
// It returns the title of the action the user selected, or "" if the user dismissed the prompt.
func showMessageRequest(ctx context.Context, cli protocol.Client, typ protocol.MessageType, message string, actions ...string) (string, error) {
	var actionItems []protocol.MessageActionItem
	for _, action := range actions {
		actionItems = append(actionItems, protocol.MessageActionItem{Title: action})
	}
	params := &protocol.ShowMessageRequestParams{
		Type:    typ,
		Message: message,
		Actions: actionItems,
	}
	// Timeout used to wait for the user to respond to a message request.
	const timeout = 15 * time.Second
	ctx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()

	result, err := cli.ShowMessageRequest(ctx, params)

	if err != nil {
		if cause := context.Cause(ctx); cause == context.DeadlineExceeded || cause == context.Canceled {
			return "", nil
		}
		return "", err
	}
	if result == nil {
		return "", nil // User dismissed the notification
	}
	return result.Title, nil
}

func telemetryOnMessage(linkify bool) string {
	format := `Thank you. Telemetry uploading is now enabled.

To disable telemetry uploading, run %s.
`
	var runCmd = "`go run golang.org/x/telemetry/cmd/gotelemetry@latest local`"
	if linkify {
		runCmd = "[gotelemetry local](https://golang.org/x/telemetry/cmd/gotelemetry)"
	}
	return fmt.Sprintf(format, runCmd)
}

// acquireLockFile attempts to "acquire a lock" for writing to path.
//
// This is achieved by creating an exclusive lock file at <path>.lock. Lock
// files expire after a period, at which point acquireLockFile will remove and
// recreate the lock file.
//
// acquireLockFile fails if path is in a directory that doesn't exist.
func acquireLockFile(path string) (func(), bool, error) {
	lockpath := path + ".lock"
	fi, err := os.Stat(lockpath)
	if err == nil {
		if time.Since(fi.ModTime()) > promptTimeout {
			_ = os.Remove(lockpath) // ignore error
		} else {
			return nil, false, nil
		}
	} else if !os.IsNotExist(err) {
		return nil, false, fmt.Errorf("statting lockfile: %v", err)
	}

	f, err := os.OpenFile(lockpath, os.O_CREATE|os.O_EXCL, 0666)
	if err != nil {
		if os.IsExist(err) {
			return nil, false, nil
		}
		return nil, false, fmt.Errorf("creating lockfile: %v", err)
	}
	fi, err = f.Stat()
	if err != nil {
		return nil, false, err
	}
	release := func() {
		_ = f.Close() // ignore error
		fi2, err := os.Stat(lockpath)
		if err == nil && os.SameFile(fi, fi2) {
			// Only clean up the lockfile if it's the same file we created.
			// Otherwise, our lock has expired and something else has the lock.
			//
			// There's a race here, in that the file could have changed since the
			// stat above; but given that we've already waited 24h this is extremely
			// unlikely, and acceptable.
			_ = os.Remove(lockpath)
		}
	}
	return release, true, nil
}
