package cli

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"
	"unicode/utf8"

	"github.com/Control-D-Inc/ctrld"
)

// A terminal provisioning failure reports the same stable code on three
// surfaces: a persisted result file, one fixed-format output line, and a
// stage-scoped process exit code. docs/provisioning-failure-codes.md maps
// each code to its scenario and must stay in sync with the constants below.
// Codes are append-only once released; renaming or reusing one breaks the
// support contract.

type provisionStage string

const (
	provisionStageInput     provisionStage = "input"
	provisionStageBootstrap provisionStage = "bootstrap"
	provisionStageListener  provisionStage = "listener"
	provisionStageService   provisionStage = "service"
)

type provisionFailureCode string

const (
	provisionCodeProvisionTokenMalformed provisionFailureCode = "PROVISION_TOKEN_MALFORMED"
	provisionCodeCustomHostnameInvalid   provisionFailureCode = "CUSTOM_HOSTNAME_INVALID"
	provisionCodeInterceptModeInvalid    provisionFailureCode = "INTERCEPT_MODE_INVALID"
	provisionCodeInvalidFlagCombination  provisionFailureCode = "INVALID_FLAG_COMBINATION"
	provisionCodeAPIUnreachable          provisionFailureCode = "API_UNREACHABLE"
	provisionCodeAPIRejected             provisionFailureCode = "API_REJECTED"
	provisionCodeAPIDeviceInvalid        provisionFailureCode = "API_DEVICE_INVALID"
	provisionCodeTokenInvalid            provisionFailureCode = "TOKEN_INVALID"
	provisionCodeTokenExpired            provisionFailureCode = "TOKEN_EXPIRED"
	provisionCodeTokenLimitReached       provisionFailureCode = "TOKEN_LIMIT_REACHED"
	provisionCodeTokenDisabled           provisionFailureCode = "TOKEN_DISABLED"
	provisionCodeListenerBindFailed      provisionFailureCode = "LISTENER_BIND_FAILED"
	provisionCodeListenerAddrUnavail     provisionFailureCode = "LISTENER_CONFIGURED_ADDR_UNAVAILABLE"
	provisionCodeServiceInstall          provisionFailureCode = "SERVICE_INSTALL_FAILED"
	provisionCodeServiceStartFailed      provisionFailureCode = "SERVICE_START_FAILED"
	provisionCodeServiceSelfCheck        provisionFailureCode = "SERVICE_SELFCHECK_FAILED"
	provisionCodeUnclassified            provisionFailureCode = "UNCLASSIFIED"
)

var allProvisionFailureCodes = []provisionFailureCode{
	provisionCodeProvisionTokenMalformed,
	provisionCodeCustomHostnameInvalid,
	provisionCodeInterceptModeInvalid,
	provisionCodeInvalidFlagCombination,
	provisionCodeAPIUnreachable,
	provisionCodeAPIRejected,
	provisionCodeAPIDeviceInvalid,
	provisionCodeTokenInvalid,
	provisionCodeTokenExpired,
	provisionCodeTokenLimitReached,
	provisionCodeTokenDisabled,
	provisionCodeListenerBindFailed,
	provisionCodeListenerAddrUnavail,
	provisionCodeServiceInstall,
	provisionCodeServiceStartFailed,
	provisionCodeServiceSelfCheck,
	provisionCodeUnclassified,
}

var provisionStageForCode = map[provisionFailureCode]provisionStage{
	provisionCodeProvisionTokenMalformed: provisionStageInput,
	provisionCodeCustomHostnameInvalid:   provisionStageInput,
	provisionCodeInterceptModeInvalid:    provisionStageInput,
	provisionCodeInvalidFlagCombination:  provisionStageInput,
	provisionCodeAPIUnreachable:          provisionStageBootstrap,
	provisionCodeAPIRejected:             provisionStageBootstrap,
	provisionCodeAPIDeviceInvalid:        provisionStageBootstrap,
	provisionCodeTokenInvalid:            provisionStageBootstrap,
	provisionCodeTokenExpired:            provisionStageBootstrap,
	provisionCodeTokenLimitReached:       provisionStageBootstrap,
	provisionCodeTokenDisabled:           provisionStageBootstrap,
	provisionCodeListenerBindFailed:      provisionStageListener,
	provisionCodeListenerAddrUnavail:     provisionStageListener,
	provisionCodeServiceInstall:          provisionStageService,
	provisionCodeServiceStartFailed:      provisionStageService,
	provisionCodeServiceSelfCheck:        provisionStageService,
	provisionCodeUnclassified:            provisionStageService,
}

// Exit codes are grouped by stage (input 20-29, bootstrap 30-39, listener
// 40-49, service 50-59) so the exit code alone names the failed stage. 0-3
// belong to "ctrld status" and 126 to the deactivation pin check; never
// reuse those.
var provisionExitCodeForCode = map[provisionFailureCode]int{
	provisionCodeProvisionTokenMalformed: 21,
	provisionCodeCustomHostnameInvalid:   22,
	provisionCodeInterceptModeInvalid:    23,
	provisionCodeInvalidFlagCombination:  24,
	provisionCodeAPIUnreachable:          30,
	provisionCodeAPIRejected:             31,
	provisionCodeAPIDeviceInvalid:        32,
	provisionCodeTokenInvalid:            33,
	provisionCodeTokenExpired:            34,
	provisionCodeTokenLimitReached:       35,
	provisionCodeTokenDisabled:           36,
	provisionCodeListenerBindFailed:      41,
	provisionCodeListenerAddrUnavail:     42,
	provisionCodeServiceInstall:          51,
	provisionCodeServiceStartFailed:      52,
	provisionCodeServiceSelfCheck:        53,
	provisionCodeUnclassified:            59,
}

const (
	provisionResultFileName = "provision_result.json"
	// Detail identifies a failure, it is not a log. Caps keep the artifact
	// small and predictable.
	maxProvisionBindAttempts = 12
	maxProvisionStringLen    = 256
)

type provisionBindAttempt struct {
	Addr    string `json:"addr"`
	Proto   string `json:"proto"`
	OSError string `json:"os_error"`
}

type provisionDetail struct {
	Attempts []provisionBindAttempt `json:"attempts,omitempty"`
}

type provisionResult struct {
	Version   int              `json:"version"`
	Timestamp string           `json:"timestamp"`
	Stage     string           `json:"stage"`
	Code      string           `json:"code"`
	ExitCode  int              `json:"exit_code"`
	Message   string           `json:"message"`
	Detail    *provisionDetail `json:"detail,omitempty"`
}

// provisionResultPath is a var so tests can point it at a temp dir.
var provisionResultPath = func() string {
	return absHomeDir(provisionResultFileName)
}

// provisionExit is a var so tests can observe the exit code instead of dying.
var provisionExit = os.Exit

// newProvisionResult builds a result with every field bounded and the given
// secrets stripped. The artifact reaches installer logs and support tickets,
// so callers pass every secret in scope (provision token, cd UID).
func newProvisionResult(code provisionFailureCode, message string, attempts []provisionBindAttempt, secrets ...string) *provisionResult {
	sanitize := func(s string) string {
		s = redactSecrets(s, secrets...)
		if len(s) > maxProvisionStringLen {
			// Cut on a rune boundary so a localized OS error does not end in
			// a broken multi-byte sequence.
			cut := maxProvisionStringLen
			for cut > 0 && !utf8.RuneStart(s[cut]) {
				cut--
			}
			s = s[:cut]
		}
		return s
	}
	r := &provisionResult{
		Version:   1,
		Timestamp: time.Now().UTC().Format(time.RFC3339),
		Stage:     string(provisionStageForCode[code]),
		Code:      string(code),
		ExitCode:  provisionExitCodeForCode[code],
		Message:   sanitize(message),
	}
	if len(attempts) > 0 {
		if len(attempts) > maxProvisionBindAttempts {
			attempts = attempts[:maxProvisionBindAttempts]
		}
		detail := &provisionDetail{Attempts: make([]provisionBindAttempt, 0, len(attempts))}
		for _, a := range attempts {
			detail.Attempts = append(detail.Attempts, provisionBindAttempt{
				Addr:    sanitize(a.Addr),
				Proto:   sanitize(a.Proto),
				OSError: sanitize(a.OSError),
			})
		}
		r.Detail = detail
	}
	return r
}

// redactSecrets removes every non-empty secret from s.
func redactSecrets(s string, secrets ...string) string {
	for _, secret := range secrets {
		if secret == "" {
			continue
		}
		s = strings.ReplaceAll(s, secret, "[redacted]")
	}
	return s
}

// provisionResultTrusted rejects a result whose code, stage, or exit code is
// not part of the known contract, so a corrupt or planted file cannot drive
// what "ctrld start" logs and exits with.
func provisionResultTrusted(r *provisionResult) bool {
	code := provisionFailureCode(r.Code)
	stage, ok := provisionStageForCode[code]
	if !ok {
		return false
	}
	return r.Stage == string(stage) && r.ExitCode == provisionExitCodeForCode[code]
}

func (r *provisionResult) failureLine() string {
	return fmt.Sprintf("provisioning failed: stage=%s code=%s (exit %d)", r.Stage, r.Code, r.ExitCode)
}

// writeProvisionResult persists the result atomically (temp file + rename in
// the same directory) so a reader never sees a partial file.
func writeProvisionResult(r *provisionResult) error {
	path := provisionResultPath()
	buf, err := json.MarshalIndent(r, "", "  ")
	if err != nil {
		return err
	}
	tmp, err := os.CreateTemp(filepath.Dir(path), provisionResultFileName+".tmp*")
	if err != nil {
		return err
	}
	tmpName := tmp.Name()
	if _, err := tmp.Write(buf); err != nil {
		_ = tmp.Close()
		_ = os.Remove(tmpName)
		return err
	}
	if err := tmp.Close(); err != nil {
		_ = os.Remove(tmpName)
		return err
	}
	if err := os.Chmod(tmpName, 0o600); err != nil {
		_ = os.Remove(tmpName)
		return err
	}
	if err := os.Rename(tmpName, path); err != nil {
		_ = os.Remove(tmpName)
		return err
	}
	return nil
}

func readProvisionResult() (*provisionResult, error) {
	return readProvisionResultAt(provisionResultPath())
}

func readProvisionResultAt(path string) (*provisionResult, error) {
	buf, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	r := &provisionResult{}
	if err := json.Unmarshal(buf, r); err != nil {
		return nil, err
	}
	return r, nil
}

// clearProvisionResult removes a stale result once provisioning succeeds, so
// support never diagnoses a healthy install from an old failure.
func clearProvisionResult() {
	if err := os.Remove(provisionResultPath()); err != nil && !os.IsNotExist(err) {
		mainLog.Load().Debug().Err(err).Msg("could not remove provision result file")
	}
}

// failProvision persists the result, prints the identifier line, unblocks a
// waiting "ctrld start" via notify, then exits with the stage code. The write
// comes first so the file survives even if logging or notify misbehaves.
func failProvision(r *provisionResult, notify func()) {
	if err := writeProvisionResult(r); err != nil {
		mainLog.Load().Warn().Err(err).Msg("could not persist provision result")
	}
	mainLog.Load().Error().Msg(r.failureLine())
	if notify != nil {
		notify()
	}
	provisionExit(r.ExitCode)
}

// failProvisionUnclassified persists an UNCLASSIFIED result (service stage,
// exit 59) and exits. It is the fallback for a terminal path that predates a
// stable code - a config unmarshal, a file-system or environment failure -
// so support still gets a result file and a stage-scoped exit code instead
// of a bare crash with nothing to read. No terminal path on the
// provisioning boundary may bypass this or an existing classified code.
func failProvisionUnclassified(message string, notify func()) {
	failProvision(newProvisionResult(provisionCodeUnclassified, message, nil, provisionSecrets()...), notify)
}

// failRunUnclassified logs msg on ev, then fails provisioning as UNCLASSIFIED
// and unblocks a waiting "ctrld start" via notify (nil if none). Callers
// return immediately after this call: provisionExit is stubbed out under
// test, so nothing stops execution from falling through otherwise. ev
// carries whatever the caller already chained onto it (for example .Err()),
// so each call site keeps its own log fields.
func failRunUnclassified(ev *ctrld.LogEvent, msg string, notify func()) {
	ev.Msg(msg)
	failProvisionUnclassified(msg, notify)
}
