package cli

import (
	"bytes"
	"context"
	"crypto/x509"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/netip"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"reflect"
	"runtime"
	"runtime/debug"
	"sort"
	"strconv"
	"strings"
	"sync/atomic"
	"time"
	"unicode"

	"github.com/Masterminds/semver/v3"
	"github.com/cuonglm/osinfo"
	"github.com/go-playground/validator/v10"
	"github.com/kardianos/service"
	"github.com/miekg/dns"
	"github.com/pelletier/go-toml/v2"
	"github.com/spf13/cobra"
	"github.com/spf13/viper"
	"go.uber.org/zap"
	"tailscale.com/logtail/backoff"
	"tailscale.com/net/netmon"

	"github.com/Control-D-Inc/ctrld"
	"github.com/Control-D-Inc/ctrld/internal/controld"
	ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
)

// selfCheckInternalTestDomain is used for testing ctrld self response to clients.
const selfCheckInternalTestDomain = "ctrld" + loopTestDomain
const (
	windowsForwardersFilename = ".forwarders.txt"
	oldBinSuffix              = "_previous"
	oldLogSuffix              = ".1"
	msgExit                   = "$$EXIT$$"
)

var (
	version = "dev"
	commit  = "none"
)

var (
	v                    = viper.NewWithOptions(viper.KeyDelimiter("::"))
	defaultConfigFile    = "ctrld.toml"
	rootCertPool         *x509.CertPool
	errSelfCheckNoAnswer = errors.New("no response from ctrld listener. You can try to re-launch with flag --skip_self_checks")
	// Store version once during init to avoid repeated calls to curVersion()
	appVersion = curVersion()
)

var basicModeFlags = []string{"listen", "primary_upstream", "secondary_upstream", "domains"}

// isNoConfigStart checks if the command is using no-config start mode
func isNoConfigStart(cmd *cobra.Command) bool {
	for _, flagName := range basicModeFlags {
		if cmd.Flags().Lookup(flagName).Changed {
			return true
		}
	}
	return false
}

// cliName is this client's identity: the command name Cobra prints in help and
// in "--version" output, the file the release pipeline publishes
// (scripts/build.sh executable_name), and therefore the file the self-upgrader
// downloads.
//
// Referenced rather than repeated because "--version" output is parsed as well as
// printed: binaryVersion builds its expected prefix from this, and a literal on
// either side would let a rename break rollback silently.
const cliName = "ctrld-client"

const rootShortDesc = `
        __         .__       .___
  _____/  |________|  |    __| _/
_/ ___\   __\_  __ \  |   / __ |
\  \___|  |  |  | \/  |__/ /_/ |
 \___  >__|  |__|  |____/\____ |
     \/ dns forwarding proxy  \/
`

// curVersion returns the current version string
func curVersion() string {
	// Ensure version has proper "v" prefix for semantic versioning
	// This is needed because some build systems may provide version without the "v" prefix
	if version != "dev" && !strings.HasPrefix(version, "v") {
		version = "v" + version
	}
	// Return version directly if it's not empty and not a dev build
	// This avoids unnecessary commit hash concatenation for release versions
	if version != "" && version != "dev" {
		return displayVersion(version)
	}
	// Truncate commit hash to 7 characters for readability
	// Git commit hashes are typically 40 characters, but 7 is sufficient for identification
	if len(commit) > 7 {
		commit = commit[:7]
	}
	return fmt.Sprintf("%s-%s", version, commit)
}

// displayVersion converts the build tag into the version the client reports.
//
// master carries v2.x.x tags so its releases can be tracked alongside the v1.x.x
// line still cut from the v1.0 branch, but the client presents itself as v1.x.x:
// a v2.0.0 tag reads as v1.0.0, v2.3.1 as v1.3.1. Only master ever carries a
// major >= 2, so the split tag spaces scope this to master by themselves and the
// binary needs no build-time signal for which branch produced it - the v1.0
// branch's tags have major 1 and pass through untouched, as does anything that is
// not a semantic version ("dev", commit-suffixed builds).
//
// Minor, patch, prerelease and build metadata are preserved, so a v2.1.0-rc1 tag
// stays a release candidate at v1.1.0-rc1 and isStableVersion still classifies it
// the same way.
func displayVersion(v string) string {
	sv, err := semver.NewVersion(v)
	if err != nil || sv.Major() < 2 {
		return v
	}
	shifted := semver.New(sv.Major()-1, sv.Minor(), sv.Patch(), sv.Prerelease(), sv.Metadata())
	return "v" + shifted.String()
}

func initCLI() *cobra.Command {
	// Enable opening via explorer.exe on Windows.
	// See: https://github.com/spf13/cobra/issues/844.
	cobra.MousetrapHelpText = ""
	cobra.EnableCommandSorting = false

	rootCmd := &cobra.Command{
		Use:     cliName,
		Short:   strings.TrimLeft(rootShortDesc, "\n"),
		Version: appVersion,
		PersistentPreRun: func(cmd *cobra.Command, args []string) {
			initConsoleLogging()
		},
	}

	rootCmd.PersistentFlags().CountVarP(
		&verbose,
		"verbose",
		"v",
		`verbose log output, "-v" basic logging, "-vv" debug logging`,
	)
	rootCmd.PersistentFlags().BoolVarP(
		&silent,
		"silent",
		"s",
		false,
		`do not write any log output`,
	)
	rootCmd.SetHelpCommand(&cobra.Command{Hidden: true})
	rootCmd.CompletionOptions.HiddenDefaultCmd = true

	InitRunCmd(rootCmd)
	InitServiceCmd(rootCmd)
	InitClientsCmd(rootCmd)
	InitUpgradeCmd(rootCmd)
	InitLogCmd(rootCmd)
	InitDiagCmd(rootCmd)

	return rootCmd
}

// isMobile reports whether the current OS is a mobile platform.
func isMobile() bool {
	return runtime.GOOS == "android" || runtime.GOOS == "ios"
}

func updateConfigInterceptMode(cfg *ctrld.Config, mode string) bool {
	desired := ""
	switch mode {
	case "dns", "hard":
		desired = mode
	case "off":
		desired = ""
	case "":
		return false
	default:
		return false
	}
	if cfg.Service.InterceptMode == desired {
		return false
	}
	cfg.Service.InterceptMode = desired
	return true
}

// isAndroid reports whether the current OS is Android.
func isAndroid() bool {
	return runtime.GOOS == "android"
}

// isStableVersion reports whether vs is a stable semantic version.
func isStableVersion(vs string) bool {
	v, err := semver.NewVersion(vs)
	if err != nil {
		return false
	}
	return v.Prerelease() == ""
}

// RunCobraCommand runs ctrld cli.
func RunCobraCommand(cmd *cobra.Command) {
	noConfigStart = isNoConfigStart(cmd)
	firewallModeFlagChanged = cmd.Flags().Changed("firewall-mode")
	if !checkStrFlagEmpty(cmd, cdUidFlagName) {
		return
	}
	if !checkStrFlagEmpty(cmd, cdOrgFlagName) {
		return
	}
	run(nil, make(chan struct{}))
}

// RunMobile runs the ctrld cli on mobile platforms.
func RunMobile(appConfig *AppConfig, appCallback *AppCallback, stopCh chan struct{}) {
	if appConfig == nil {
		panic("appConfig is nil")
	}
	initConsoleLogging()
	noConfigStart = false
	homedir = appConfig.HomeDir
	verbose = appConfig.Verbose
	if appConfig.ProvisionID != "" {
		cdOrg = appConfig.ProvisionID
	}
	if appConfig.CustomHostname != "" {
		customHostname = appConfig.CustomHostname
	}
	if appConfig.CdUID != "" {
		cdUID = appConfig.CdUID
	}
	cdUpstreamProto = appConfig.UpstreamProto
	logPath = appConfig.LogPath
	run(appCallback, stopCh)
}

// CheckDeactivationPin checks if deactivation pin is valid
func CheckDeactivationPin(pin int64, stopCh chan struct{}) int {
	deactivationPin = pin
	if err := checkDeactivationPin(nil, stopCh); isCheckDeactivationPinErr(err) {
		return deactivationPinInvalidExitCode
	}
	return 0
}

// run runs ctrld cli with given app callback and stop channel.
func run(appCallback *AppCallback, stopCh chan struct{}) {
	if stopCh == nil {
		failRunUnclassified(mainLog.Load().Error(), "run() called with a nil stop channel", nil)
		return
	}
	waitCh := make(chan struct{})
	p := &prog{
		waitCh:           waitCh,
		stopCh:           stopCh,
		pinCodeValidCh:   make(chan struct{}, 1),
		reloadCh:         make(chan struct{}),
		reloadDoneCh:     make(chan struct{}),
		dnsWatcherStopCh: make(chan struct{}),
		apiReloadCh:      make(chan *ctrld.Config),
		apiForceReloadCh: make(chan struct{}),
		cfg:              &cfg,
		appCallback:      appCallback,
	}
	p.logger.Store(mainLog.Load())
	if homedir == "" {
		if dir, err := userHomeDir(); err == nil {
			homedir = dir
		}
	}
	sockDir := homedir
	if d, err := socketDir(); err == nil {
		sockDir = d
	}
	sockPath := filepath.Join(sockDir, ctrldLogUnixSock)
	hlc := newHTTPLogClient(sockPath)

	// Test if HTTP log server is available
	if err := hlc.Ping(); err != nil {
		if !errLogServerUnavailable(err) {
			p.Warn().Err(err).Msg("Unable to ping log server")
		}
	} else {
		// Server is available, use HTTP log client
		consoleWriter = newHumanReadableZapCore(io.MultiWriter(os.Stdout, hlc), consoleWriterLevel)
		p.logConn = hlc
	}

	if daemon && runtime.GOOS == "windows" {
		failRunUnclassified(p.Error(), "cannot run in daemon mode; please install a Windows service", p.notifyExitToLogServer)
		return
	}

	if !daemon {
		// We need to call s.Run() as soon as possible to response to the OS manager, so it
		// can see ctrld is running and don't mark ctrld as failed service.
		go func() {
			svcCmd := NewServiceCommand()
			svcConfig := svcCmd.createServiceConfig()
			s, err := svcCmd.newService(p, svcConfig)
			if err != nil {
				failRunUnclassified(p.Error().Err(err), fmt.Sprintf("failed to create new service: %v", err), p.notifyExitToLogServer)
				return
			}
			if err := s.Run(); err != nil {
				p.Error().Err(err).Msg("Failed to start service")
			}
		}()
	}
	writeDefaultConfig := !noConfigStart && configBase64 == ""
	tryReadingConfig(writeDefaultConfig)

	if err := readBase64Config(configBase64); err != nil {
		failRunUnclassified(p.Error().Err(err), fmt.Sprintf("failed to read base64 config: %v", err), p.notifyExitToLogServer)
		return
	}
	processNoConfigFlags(noConfigStart)

	// After s.Run() was called, if ctrld is going to be terminated for any reason,
	// write msgExit to p.logConn so others (like "ctrld start") won't have to wait for timeout.
	p.mu.Lock()
	unmarshalErr := v.Unmarshal(&cfg)
	p.mu.Unlock()
	if unmarshalErr != nil {
		failRunUnclassified(p.Error(), fmt.Sprintf("failed to unmarshal config: %v", unmarshalErr), p.notifyExitToLogServer)
		return
	}

	processLogAndCacheFlags(v, &cfg)

	// Log config do not have thing to validate, so it's safe to init log here,
	// so it's able to log information in processCDFlags.
	p.initLogging(true)

	p.Info().Msgf("Starting ctrld %s", curVersion())
	p.Info().Msgf("OS: %s", osVersion())

	// Drop enforcement left behind by a previous ctrld process before doing anything
	// that needs the network. A previous run that died without cleaning up can leave
	// machine-wide block filters installed (Firewall Mode blocks all non-allowlisted
	// outbound traffic), which would deny this process's own API bootstrap below and
	// leave it retrying forever - never reaching the cleanup that lives inside
	// intercept startup. No-op when no stale state exists.
	cleanupStaleDNSInterceptState()

	// Wait for network up.
	if !ctrldnet.Up() {
		failRunUnclassified(p.Error(), "network is not up yet", p.notifyExitToLogServer)
		return
	}

	cs, err := newControlServer(filepath.Join(sockDir, ControlSocketName()))
	if err != nil {
		p.Warn().Err(err).Msg("Could not create control server")
	}
	p.cs = cs

	oldLogPath := cfg.Service.LogPath
	if uid := cdUIDFromProvToken(); uid != "" {
		cdUID = uid
	}
	if cdUID != "" {
		if !validateCdUpstreamProtocol(p.notifyExitToLogServer) {
			return
		}
		// Bound API preflight by the service lifetime. Without this, a stop request
		// arriving while the API is unreachable leaves this retry/backoff loop running
		// after "Service stopped" was logged, so the process keeps working on behalf of
		// a service the OS considers stopped.
		pf := runAPIPreflight(p.stopCh, &cfg)
		switch {
		case pf.stopRequested:
			// Stop requested during preflight, whether or not the fetch itself
			// succeeded. A successful fetch does not entitle startup to continue: the
			// operator asked for a stop, and carrying on would set up listeners and
			// interception for a service the OS already considers stopping.
			//
			// Exit the way a normal stop does: no Fatal, so the OS service manager does
			// not see a failed start and apply its restart policy to a service the
			// operator just asked to stop.
			p.Notice().Msg("Stop requested while fetching resolver config — shutting down")
			p.notifyExitToLogServer()
			return
		case pf.err != nil:
			if isMobile() {
				appCallback.Exit(pf.err.Error())
				return
			}

			handleAPIPreflightFailure(p, pf.err, p.notifyExitToLogServer)
			return
		default:
			p.mu.Lock()
			p.rc = pf.rc
			p.mu.Unlock()
		}
	}

	updated := updateListenerConfig(&cfg, p.notifyExitToLogServer)

	// Bootstrap and listener binding both succeeded, so an earlier run's
	// recorded failure no longer describes this install.
	clearProvisionResult()

	if cdUID != "" {
		processLogAndCacheFlags(v, &cfg)
	}

	// Keep config and the explicit CLI/service mode in sync. In particular, "off"
	// must clear a previously persisted dns/hard value or the next service start
	// would silently re-enable interception from config.
	if updateConfigInterceptMode(&cfg, interceptMode) {
		updated = true
		p.Info().Msgf("writing intercept_mode = %q to config (requested %q)", cfg.Service.InterceptMode, interceptMode)
	}

	// Persist firewall_mode to config only when provided via CLI flag.
	// The flag defaults to "off" for help output, so use Changed() to avoid
	// clobbering a config-file value of firewall_mode = "on" on normal starts.
	if firewallModeFlagChanged {
		if !validateFirewallModeFlag(true, firewallMode, p.notifyExitToLogServer) {
			return
		}
		if cfg.Service.FirewallMode != firewallMode {
			cfg.Service.FirewallMode = firewallMode
			updated = true
			p.Info().Msgf("writing firewall_mode = %q to config", firewallMode)
		}
	} else if cfg.Service.FirewallMode != "" {
		// If firewall_mode is set in config but not via flag, use config value.
		firewallMode = cfg.Service.FirewallMode
	} else {
		firewallMode = "off"
	}

	if updated {
		if err := writeConfigFile(&cfg); err != nil {
			failRunUnclassified(p.Error().Err(err), fmt.Sprintf("failed to write config file: %v", err), p.notifyExitToLogServer)
			return
		}
		p.Info().Msg("Writing config file to: " + defaultConfigFile)
	}

	if newLogPath := cfg.Service.LogPath; newLogPath != "" && oldLogPath != newLogPath {
		// After processCDFlags, log config may change, so reset mainLog and re-init logging.
		l := zap.NewNop()
		mainLog.Store(&ctrld.Logger{Logger: l})

		// Copy logs written so far to new log file if possible.
		if buf, err := os.ReadFile(oldLogPath); err == nil {
			if err := os.WriteFile(newLogPath, buf, os.FileMode(0o600)); err != nil {
				p.Warn().Err(err).Msg("Could not copy old log file")
			}
		}
		initLoggingWithBackup(false)
		p.logger.Store(mainLog.Load())
	}

	if err := validateConfig(&cfg); err != nil {
		failRunUnclassified(p.Error().Err(err), fmt.Sprintf("invalid config: %v", err), p.notifyExitToLogServer)
		return
	}
	initCache()

	if daemon {
		exe, err := os.Executable()
		if err != nil {
			failRunUnclassified(p.Error().Err(err), "failed to find the binary", p.notifyExitToLogServer)
			return
		}
		curDir, err := os.Getwd()
		if err != nil {
			failRunUnclassified(p.Error().Err(err), "failed to get current working directory", p.notifyExitToLogServer)
			return
		}
		// If running as daemon, re-run the command in background, with daemon off.
		cmd := exec.Command(exe, append(os.Args[1:], "-d=false")...)
		cmd.Dir = curDir
		if err := cmd.Start(); err != nil {
			failRunUnclassified(p.Error().Err(err), "failed to start process as daemon", p.notifyExitToLogServer)
			return
		}
		p.Info().Int("pid", cmd.Process.Pid).Msg("DNS proxy started")
		os.Exit(0)
	}

	p.onStarted = append(p.onStarted, func() {
		for _, lc := range p.cfg.Listener {
			if shouldAllocateLoopbackIP(lc.IP) {
				if err := allocateIP(lc.IP); err != nil {
					p.Error().Err(err).Msgf("Could not allocate ip: %s", lc.IP)
				}
			}
		}
		// Configure Windows service failure actions
		_ = ConfigureWindowsServiceFailureActions(ctrldServiceName)
	})
	p.onStopped = append(p.onStopped, func() {
		for _, lc := range p.cfg.Listener {
			if shouldAllocateLoopbackIP(lc.IP) {
				if err := deAllocateIP(lc.IP); err != nil {
					p.Error().Err(err).Msgf("Could not de-allocate ip: %s", lc.IP)
				}
			}
		}
	})
	p.onStopped = append(p.onStopped, func() {
		// restore static DNS settings or DHCP
		p.resetDNS(false, true)
		restoreSavedStaticDNS("", false)
	})

	close(waitCh)
	<-stopCh
}

// writeConfigFile writes the configuration to a file
func writeConfigFile(cfg *ctrld.Config) error {
	mainLog.Load().Debug().Msg("Writing configuration file")

	if cfu := v.ConfigFileUsed(); cfu != "" {
		defaultConfigFile = cfu
	} else if configPath != "" {
		defaultConfigFile = configPath
	}

	mainLog.Load().Debug().Str("config_file", defaultConfigFile).Msg("Opening configuration file for writing")

	f, err := os.OpenFile(defaultConfigFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(0o644))
	if err != nil {
		mainLog.Load().Error().Err(err).Str("config_file", defaultConfigFile).Msg("Failed to open configuration file")
		return err
	}
	defer f.Close()
	if cdUID != "" {
		if _, err := f.WriteString("# AUTO-GENERATED VIA CD FLAG - DO NOT MODIFY\n\n"); err != nil {
			mainLog.Load().Error().Err(err).Msg("Failed to write CD header to configuration file")
			return err
		}
	}
	enc := toml.NewEncoder(f).SetIndentTables(true)
	if err := enc.Encode(&cfg); err != nil {
		mainLog.Load().Error().Err(err).Str("config_file", defaultConfigFile).Msg("Failed to encode configuration")
		return err
	}
	if err := f.Close(); err != nil {
		mainLog.Load().Error().Err(err).Str("config_file", defaultConfigFile).Msg("Failed to close configuration file")
		return err
	}

	mainLog.Load().Debug().Str("config_file", defaultConfigFile).Msg("Configuration file written successfully")
	return nil
}

// readConfigFile reads in config file.
//
// - It writes default config file if config file not found if writeDefaultConfig is true.
// - It emits notice message to user if notice is true.
func readConfigFile(writeDefaultConfig, notice bool) bool {
	// If err == nil, there's a config supplied via `--config`, no default config written.
	err := v.ReadInConfig()
	if err == nil {
		if notice {
			mainLog.Load().Notice().Msg("Reading config: " + v.ConfigFileUsed())
		}
		mainLog.Load().Info().Msg("Loading config file from: " + v.ConfigFileUsed())
		defaultConfigFile = v.ConfigFileUsed()
		return true
	}

	if !writeDefaultConfig {
		return false
	}

	// If error is viper.ConfigFileNotFoundError, write default config.
	if errors.As(err, &viper.ConfigFileNotFoundError{}) {
		if err := v.Unmarshal(&cfg); err != nil {
			mainLog.Load().Fatal().Msgf("Failed to unmarshal default config: %v", err)
		}
		_, _ = tryUpdateListenerConfig(&cfg, func() {}, true)
		addExtraSplitDnsRule(&cfg)
		if err := writeConfigFile(&cfg); err != nil {
			mainLog.Load().Fatal().Msgf("Failed to write default config file: %v", err)
		} else {
			fp, err := filepath.Abs(defaultConfigFile)
			if err != nil {
				mainLog.Load().Fatal().Msgf("Failed to get default config file path: %v", err)
			}
			if cdUID == "" && nextdns == "" {
				mainLog.Load().Notice().Msg("Generating controld default config: " + fp)
			}
			mainLog.Load().Info().Msg("Writing default config file to: " + fp)
		}
		return false
	}

	// If error is viper.ConfigParseError, emit details line and column number.
	if errors.As(err, &viper.ConfigParseError{}) {
		if de := decoderErrorFromTomlFile(v.ConfigFileUsed()); de != nil {
			row, col := de.Position()
			mainLog.Load().Fatal().Msgf("Failed to decode config file at line: %d, column: %d, error: %v", row, col, err)
		}
	}

	// Otherwise, report fatal error and exit.
	mainLog.Load().Fatal().Msgf("Failed to decode config file: %v", err)
	return false
}

// decoderErrorFromTomlFile parses the invalid toml file, returning the details decoder error.
func decoderErrorFromTomlFile(cf string) *toml.DecodeError {
	if f, _ := os.Open(cf); f != nil {
		defer f.Close()
		var i any
		var de *toml.DecodeError
		if err := toml.NewDecoder(f).Decode(&i); err != nil && errors.As(err, &de) {
			return de
		}
	}
	return nil
}

// readBase64Config reads ctrld config from the base64 input string.
func readBase64Config(configBase64 string) error {
	if configBase64 == "" {
		return nil
	}

	mainLog.Load().Debug().Msg("Reading base64 encoded configuration")

	configStr, err := base64.StdEncoding.DecodeString(configBase64)
	if err != nil {
		mainLog.Load().Error().Err(err).Msg("Failed to decode base64 configuration")
		return fmt.Errorf("invalid base64 config: %w", err)
	}

	mainLog.Load().Debug().Int("config_length", len(configStr)).Msg("Base64 configuration decoded successfully")

	// readBase64Config is called when:
	//
	//  - "--base64_config" flag set.
	//  - Reading custom config when "--cd" flag set.
	//
	// So we need to re-create viper instance to discard old one.
	v = viper.NewWithOptions(viper.KeyDelimiter("::"))
	v.SetConfigType("toml")

	mainLog.Load().Debug().Msg("Parsing base64 configuration as TOML")

	if err := v.ReadConfig(bytes.NewReader(configStr)); err != nil {
		mainLog.Load().Error().Err(err).Msg("Failed to parse base64 configuration as TOML")
		return err
	}

	mainLog.Load().Debug().Msg("Base64 configuration processed successfully")
	return nil
}

// processNoConfigFlags processes flags for no-config mode
func processNoConfigFlags(noConfigStart bool) {
	if !noConfigStart {
		return
	}

	mainLog.Load().Debug().Msg("Processing no-config mode flags")

	if listenAddress == "" || primaryUpstream == "" {
		mainLog.Load().Fatal().Msg(`"listen" and "primary_upstream" flags must be set in no config mode`)
	}
	processListenFlag()

	endpointAndTyp := func(endpoint string) (string, string) {
		mainLog.Load().Debug().Str("endpoint", endpoint).Msg("Processing endpoint for resolver type")
		typ := ctrld.ResolverTypeFromEndpoint(endpoint)
		endpoint = strings.TrimPrefix(endpoint, "quic://")
		if after, found := strings.CutPrefix(endpoint, "h3://"); found {
			endpoint = "https://" + after
		}
		mainLog.Load().Debug().Str("endpoint", endpoint).Str("type", typ).Msg("Endpoint processed")
		return endpoint, typ
	}
	pEndpoint, pType := endpointAndTyp(primaryUpstream)
	puc := &ctrld.UpstreamConfig{
		Name:     pEndpoint,
		Endpoint: pEndpoint,
		Type:     pType,
		Timeout:  5000,
	}
	loggerCtx := ctrld.LoggerCtx(context.Background(), mainLog.Load())
	puc.Init(loggerCtx)
	upstream := map[string]*ctrld.UpstreamConfig{"0": puc}
	if secondaryUpstream != "" {
		sEndpoint, sType := endpointAndTyp(secondaryUpstream)
		suc := &ctrld.UpstreamConfig{
			Name:     sEndpoint,
			Endpoint: sEndpoint,
			Type:     sType,
			Timeout:  5000,
		}
		suc.Init(loggerCtx)
		upstream["1"] = suc
		rules := make([]ctrld.Rule, 0, len(domains))
		for _, domain := range domains {
			rules = append(rules, ctrld.Rule{domain: []string{"upstream.1"}})
		}
		lc := v.Get("listener").(map[string]*ctrld.ListenerConfig)["0"]
		lc.Policy = &ctrld.ListenerPolicyConfig{Name: "My Policy", Rules: rules}
	}
	v.Set("upstream", upstream)
}

// defaultDeactivationPin is the default value for cdDeactivationPin.
// If cdDeactivationPin equals to this default, it means the pin code is not set from Control D API.
const defaultDeactivationPin = -1

// cdDeactivationPin is used in cd mode to decide whether stop and uninstall commands can be run.
var cdDeactivationPin atomic.Int64

// Brute-force protection for the deactivation PIN endpoint on the control socket.
// After deactivationMaxFailedAttempts consecutive wrong PINs, further attempts are
// rejected for deactivationLockoutSeconds. Counter resets on a correct PIN.
const (
	deactivationMaxFailedAttempts = 5
	deactivationLockoutSeconds    = 60
)

var (
	deactivationFailedAttempts atomic.Int64
	deactivationLockedUntil    atomic.Int64
)

func init() {
	cdDeactivationPin.Store(defaultDeactivationPin)
}

// deactivationPinSet indicates if cdDeactivationPin is non-default..
func deactivationPinSet() bool {
	return cdDeactivationPin.Load() != defaultDeactivationPin
}

// fetchResolverConfig is a test seam for the ControlD resolver-config API call.
var fetchResolverConfig = controld.FetchResolverConfig

// apiPreflight is the outcome of the API preflight fetch: the resolver config, the
// error if any, and whether the service was asked to stop while it ran.
type apiPreflight struct {
	rc            *controld.ResolverConfig
	err           error
	stopRequested bool
}

// runAPIPreflight fetches the ControlD resolver config bounded by the service
// lifetime, and reports whether a stop was requested while it ran.
//
// The distinction matters because the caller does very different things with it: a stop
// exits quietly, while a failure self-uninstalls a deleted device, surfaces the error to
// a mobile app, and reports a failed start to the service manager.
//
// stopRequested must not be derived from the context once it has been cancelled.
// context.CancelFunc sets ctx.Err() unconditionally, so reading it after the cancel
// classifies *every* failure - a deleted device, an exhausted retry, a mobile caller
// with no stop channel - as an operator stop. Reading the stop channel directly is also
// independent of whether the context's watcher goroutine has been scheduled yet.
func runAPIPreflight(stopCh <-chan struct{}, cfg *ctrld.Config) apiPreflight {
	rc, err := fetchCDConfigBoundedBy(stopCh, cfg)
	return apiPreflight{rc: rc, err: err, stopRequested: stopRequested(stopCh)}
}

// permanentAPIRejection reports whether err is the API refusing this request in a way
// that a restart cannot change, and returns the rejection when it is.
//
// The type alone does not answer this. controld builds an *ErrorResponse for *any*
// non-200 whose body decodes, so a 502 from a load balancer and a 404 for a deleted
// device arrive as the same Go type. Treating both as permanent would let a few minutes
// of API trouble stop ctrld on every host with no service-manager retry behind it, which
// is strictly worse than the abnormal exit it replaced.
//
// So the HTTP status decides, and only a client-error status counts:
//
//   - 4xx: the API examined this request and refused it - a deleted device, a revoked
//     token, a malformed UID. The same request will be refused again.
//   - 408 and 429 are the exceptions: they are the API asking for another attempt later.
//   - 5xx, or no recorded status, says nothing about this configuration. Retry.
func permanentAPIRejection(err error) (*controld.ErrorResponse, bool) {
	var uer *controld.ErrorResponse
	if !errors.As(err, &uer) {
		return nil, false
	}
	switch uer.StatusCode {
	case http.StatusRequestTimeout, http.StatusTooManyRequests:
		return nil, false
	}
	if uer.StatusCode < 400 || uer.StatusCode >= 500 {
		return nil, false
	}
	return uer, true
}

// apiFailureCode maps a bootstrap preflight error to its provisioning code.
// A deleted device gets its own code because it triggers self-uninstall; a
// permanent rejection with a known token reason gets its own code; other
// permanent rejections are generic; anything else counts as reachability
// trouble worth retrying.
func apiFailureCode(err error) (provisionFailureCode, bool) {
	if err == nil {
		return "", false
	}
	var uer *controld.ErrorResponse
	if errors.As(err, &uer) && uer.ErrorField.Code == controld.InvalidConfigCode {
		return provisionCodeAPIDeviceInvalid, true
	}
	if rejection, ok := permanentAPIRejection(err); ok {
		if code, ok := tokenFailureCodeForReason(rejection.ErrorField.Metadata.Reason); ok {
			return code, true
		}
		return provisionCodeAPIRejected, true
	}
	return provisionCodeAPIUnreachable, true
}

// tokenFailureCodeForReason maps error.metadata.reason on a provisioning-token
// rejection to its failure code. An empty or unrecognized reason is not
// classified here; the caller falls back to the generic rejection code.
func tokenFailureCodeForReason(reason string) (provisionFailureCode, bool) {
	switch reason {
	case controld.ReasonTokenInvalid:
		return provisionCodeTokenInvalid, true
	case controld.ReasonTokenExpired:
		return provisionCodeTokenExpired, true
	case controld.ReasonTokenLimitReached:
		return provisionCodeTokenLimitReached, true
	case controld.ReasonTokenDisabled:
		return provisionCodeTokenDisabled, true
	default:
		return "", false
	}
}

// tokenRejectionMessage returns the human message for a TOKEN_* code: what is
// wrong with the provisioning code and one concrete next action. Empty for
// any other code, so callers know to fall back to a generic message. Never
// includes the token itself.
func tokenRejectionMessage(code provisionFailureCode) string {
	switch code {
	case provisionCodeTokenInvalid:
		return "the provisioning code is not valid; check the code and re-enter it exactly as given"
	case provisionCodeTokenExpired:
		return "the provisioning code has expired; get a new provisioning code from your administrator"
	case provisionCodeTokenLimitReached:
		return "the provisioning code reached its device limit; free up a device slot or use a different code"
	case provisionCodeTokenDisabled:
		return "the provisioning code was invalidated; download a profile from an active provisioning code"
	default:
		return ""
	}
}

// provisionTokenFailureMessage returns the result-file message for a failed
// provision-token exchange: a token-specific message when the API gave a
// known reason, the generic exchange-failure summary otherwise.
func provisionTokenFailureMessage(code provisionFailureCode, err error) string {
	if msg := tokenRejectionMessage(code); msg != "" {
		return msg
	}
	return fmt.Sprintf("provision token exchange failed: %v", err)
}

// apiRejectionSummary reports the HTTP status only. The API's raw error body
// can echo back the value the caller sent, so it stays out of the artifact.
func apiRejectionSummary(statusCode int) string {
	return fmt.Sprintf("ControlD API rejected this configuration (HTTP status %d)", statusCode)
}

// provisionSecrets lists every secret-bearing value to strip from provisioning
// artifacts, including both parts of a composite "<uid>/<clientID>" --cd
// value, which the API may echo back separately.
func provisionSecrets() []string {
	uid, clientID := controld.ParseRawUID(cdUID)
	return []string{cdUID, cdOrg, uid, clientID}
}

// uninstallInvalidCdUIDFn is a var so tests can observe the self-uninstall
// without driving the OS service manager.
var uninstallInvalidCdUIDFn = uninstallInvalidCdUID

// handleAPIPreflightFailure reports a failed resolver-config fetch. A deleted
// device self-uninstalls; it and any other permanent rejection return cleanly
// so a config problem cannot burn the service manager's restart budget (on
// Windows those restarts are what bring enforcement back after a real crash).
// Anything else exits nonzero through failProvision so the manager retries.
func handleAPIPreflightFailure(p *prog, err error, notify func()) {
	logger := p.logger.Load()
	if logger == nil {
		logger = mainLog.Load()
	}
	cdLogger := logger.With().Str("mode", "cd")
	code, _ := apiFailureCode(err)
	var uer *controld.ErrorResponse
	if errors.As(err, &uer) && uer.ErrorField.Code == controld.InvalidConfigCode {
		r := newProvisionResult(code, apiRejectionSummary(uer.StatusCode), nil, provisionSecrets()...)
		if werr := writeProvisionResult(r); werr != nil {
			cdLogger.Warn().Err(werr).Msg("could not persist provision result")
		}
		_ = uninstallInvalidCdUIDFn(p, cdLogger, false)
		cdLogger.Error().Err(err).Int("status", uer.StatusCode).Msg("Failed to fetch resolver config; the device no longer exists")
		cdLogger.Error().Msg(r.failureLine())
		notify()
		return
	}
	if rejection, ok := permanentAPIRejection(err); ok {
		r := newProvisionResult(code, apiRejectionSummary(rejection.StatusCode), nil, provisionSecrets()...)
		if werr := writeProvisionResult(r); werr != nil {
			cdLogger.Warn().Err(werr).Msg("could not persist provision result")
		}
		cdLogger.Error().Err(err).Int("status", rejection.StatusCode).Msg("Failed to fetch resolver config; the API rejected this configuration")
		cdLogger.Error().Msg(r.failureLine())
		notify()
		return
	}
	cdLogger.Error().Err(err).Msg("Failed to fetch resolver config")
	failProvision(newProvisionResult(code, fmt.Sprintf("failed to fetch resolver config: %v", err), nil, provisionSecrets()...), notify)
}

// processCDFlagsFn is the API fetch, indirected so the lifetime binding around it can be
// tested without reaching the network.
var processCDFlagsFn = processCDFlags

// fetchCDConfigBoundedBy runs the API fetch bounded by stopCh, so a fetch that cannot
// reach the API stops when the service is asked to stop instead of working on behalf of a
// service the OS already considers stopped. The derived context is always cancelled, which
// releases the goroutine watching stopCh.
func fetchCDConfigBoundedBy(stopCh <-chan struct{}, cfg *ctrld.Config) (*controld.ResolverConfig, error) {
	ctx, cancel := contextFromStopCh(stopCh)
	defer cancel()
	return processCDFlagsFn(ctx, cfg)
}

// fetchCDConfigBoundedByLifetime is the reload path's fetch. Reload binds the same stop
// primitives as startup - it used to wire them up itself, where a dropped cancel or the
// wrong channel would have failed nothing.
func (p *prog) fetchCDConfigBoundedByLifetime(cfg *ctrld.Config) (*controld.ResolverConfig, error) {
	return fetchCDConfigBoundedBy(p.stopCh, cfg)
}

// stopRequested reports whether stopCh has been closed. A nil channel - mobile passes
// none - blocks forever, so the default case is taken and it reads as "no stop".
func stopRequested(stopCh <-chan struct{}) bool {
	select {
	case <-stopCh:
		return true
	default:
		return false
	}
}

// contextFromStopCh returns a context that is cancelled when stopCh closes, so
// long-running startup work stops as soon as the service is asked to stop. The
// returned cancel func must be called to release the watcher goroutine.
func contextFromStopCh(stopCh <-chan struct{}) (context.Context, context.CancelFunc) {
	ctx, cancel := context.WithCancel(context.Background())
	if stopCh == nil {
		return ctx, cancel
	}
	go func() {
		select {
		case <-stopCh:
			cancel()
		case <-ctx.Done():
		}
	}()
	return ctx, cancel
}

// processCDFlags fetches the ControlD configuration for cdUID and applies it to cfg.
//
// ctx bounds the whole operation, including the bootstrap-DNS retry loop below. That
// loop retries indefinitely by design (a device with no network yet must eventually
// come up), so it must be cancellable: otherwise a stop request during preflight is
// ignored and the process keeps retrying after the service reports itself stopped.
func processCDFlags(ctx context.Context, cfg *ctrld.Config) (*controld.ResolverConfig, error) {
	logger := mainLog.Load().With().Str("mode", "cd")
	logger.Info().Msgf("Fetching Controld D configuration from API: %s", cdUID)
	bo := backoff.NewBackoff("processCDFlags", logf, 30*time.Second)
	bo.LogLongerThan = 30 * time.Second
	if ctx == nil {
		ctx = context.Background()
	}
	ctx = ctrld.LoggerCtx(ctx, logger)
	req := &controld.ResolverConfigRequest{
		RawUID:   cdUID,
		Version:  appVersion,
		Metadata: ctrld.SystemMetadata(ctx),
	}
	resolverConfig, err := fetchResolverConfig(ctx, req, cdDev)

	// Retry logic for network errors using bootstrap DNS
	// This is needed because the initial DNS resolution might fail due to network issues
	// or DNS server unavailability, but bootstrap DNS can provide alternative resolution
	for {
		if ctxErr := ctx.Err(); ctxErr != nil {
			logger.Debug().Msg("Resolver config fetch cancelled")
			return nil, ctxErr
		}
		if errUrlNetworkError(err) {
			bo.BackOff(ctx, err)
			if ctxErr := ctx.Err(); ctxErr != nil {
				logger.Debug().Msg("Resolver config fetch cancelled during backoff")
				return nil, ctxErr
			}
			logger.Warn().Msg("Could not fetch resolver using bootstrap DNS, retrying...")
			resolverConfig, err = fetchResolverConfig(ctx, req, cdDev)
			continue
		}
		break
	}
	if err != nil {
		if isMobile() {
			return nil, err
		}
		logger.Warn().Err(err).Msg("Could not fetch resolver config")
		return nil, err
	}

	if resolverConfig.DeactivationPin != nil {
		logger.Debug().Msg("Saving deactivation pin")
		cdDeactivationPin.Store(*resolverConfig.DeactivationPin)
	}

	logger.Info().Msg("Generating ctrld config from Control-D configuration")

	// Reset config to ensure clean state before applying Control-D settings
	// This prevents mixing of old configuration with new Control-D settings
	*cfg = ctrld.Config{}
	// Fetch config, unmarshal to cfg.
	if resolverConfig.Ctrld.CustomConfig != "" {
		logger.Info().Msg("Using defined custom config of Control-D resolver")
		var cfgErr error
		if cfgErr = validateCdRemoteConfig(resolverConfig, cfg); cfgErr == nil {
			setListenerDefaultValue(cfg)
			setNetworkDefaultValue(cfg)
			if cfgErr = validateConfig(cfg); cfgErr == nil {
				return resolverConfig, nil
			}
		}
		// cfgErr, not err: err is the resolver-config fetch error from above, which is
		// nil on every path that reaches here, so logging it said nothing about why the
		// custom config was rejected.
		mainLog.Load().Warn().Err(cfgErr).Msg("Disregarding invalid custom config")
	}

	bootstrapIP := func(endpoint string) string {
		u, err := url.Parse(endpoint)
		if err != nil {
			logger.Warn().Err(err).Msgf("No bootstrap ip for invalid endpoint: %s", endpoint)
			return ""
		}
		switch {
		case dns.IsSubDomain(ctrld.FreeDnsDomain, u.Host):
			return ctrld.FreeDNSBoostrapIP
		case dns.IsSubDomain(ctrld.PremiumDnsDomain, u.Host):
			return ctrld.PremiumDNSBoostrapIP
		}
		return ""
	}

	// Initialize upstream configuration with Control-D resolver settings
	// This creates the primary DNS resolver configuration for the proxy
	cfg.Upstream = make(map[string]*ctrld.UpstreamConfig)
	cfg.Upstream["0"] = &ctrld.UpstreamConfig{
		BootstrapIP: bootstrapIP(resolverConfig.DOH),
		Endpoint:    resolverConfig.DOH,
		Type:        cdUpstreamProto,
		Timeout:     5000,
	}

	// Create exclusion rules for domains that should bypass Control-D
	// These domains will be resolved using the system's default DNS servers
	rules := make([]ctrld.Rule, 0, len(resolverConfig.Exclude))
	for _, domain := range resolverConfig.Exclude {
		rules = append(rules, ctrld.Rule{domain: []string{}})
	}

	// Initialize listener configuration with policy rules
	// This sets up the DNS proxy listener with the exclusion policy
	cfg.Listener = make(map[string]*ctrld.ListenerConfig)
	lc := &ctrld.ListenerConfig{
		Policy: &ctrld.ListenerPolicyConfig{
			Name:  "My Policy",
			Rules: rules,
		},
	}
	cfg.Listener["0"] = lc

	// Set default value.
	setListenerDefaultValue(cfg)
	setNetworkDefaultValue(cfg)

	return resolverConfig, nil
}

// setListenerDefaultValue sets the default value for cfg.Listener if none existed.
func setListenerDefaultValue(cfg *ctrld.Config) {
	if len(cfg.Listener) == 0 {
		cfg.Listener = map[string]*ctrld.ListenerConfig{
			"0": {IP: "", Port: 0},
		}
	}
}

// setListenerDefaultValue sets the default value for cfg.Listener if none existed.
func setNetworkDefaultValue(cfg *ctrld.Config) {
	if len(cfg.Network) == 0 {
		cfg.Network = map[string]*ctrld.NetworkConfig{
			"0": {
				Name:  "Network 0",
				Cidrs: []string{"0.0.0.0/0"},
			},
		}
	}
}

// validateCdRemoteConfig validates the custom config from ControlD if defined.
// This only validate the config syntax. To validate the config rules, calling
// validateConfig with the cfg after calling this function.
func validateCdRemoteConfig(rc *controld.ResolverConfig, cfg *ctrld.Config) error {
	if rc.Ctrld.CustomConfig == "" {
		return nil
	}
	if err := readBase64Config(rc.Ctrld.CustomConfig); err != nil {
		return err
	}
	return v.Unmarshal(&cfg)
}

// processListenFlag processes the listen flag
func processListenFlag() {
	if listenAddress == "" {
		return
	}
	mainLog.Load().Debug().Str("listen_address", listenAddress).Msg("Processing listen flag")

	host, portStr, err := net.SplitHostPort(listenAddress)
	if err != nil {
		mainLog.Load().Fatal().Msgf("Invalid listener address: %v", err)
	}
	port, err := strconv.Atoi(portStr)
	if err != nil {
		mainLog.Load().Fatal().Msgf("Invalid port number: %v", err)
	}
	lc := &ctrld.ListenerConfig{
		IP:   host,
		Port: port,
	}
	v.Set("listener", map[string]*ctrld.ListenerConfig{
		"0": lc,
	})

	mainLog.Load().Debug().Str("host", host).Int("port", port).Msg("Listen flag processed successfully")
}

// processLogAndCacheFlags processes log and cache related flags
func processLogAndCacheFlags(v *viper.Viper, cfg *ctrld.Config) {
	mainLog.Load().Debug().Msg("Processing log and cache flags")

	if logPath != "" {
		cfg.Service.LogPath = logPath
		mainLog.Load().Debug().Str("log_path", logPath).Msg("Log path flag processed")
	}
	if logPath != "" && cfg.Service.LogLevel == "" {
		cfg.Service.LogLevel = "debug"
		mainLog.Load().Debug().Msg("Log level set to debug")
	}

	if cacheSize != 0 {
		cfg.Service.CacheEnable = true
		cfg.Service.CacheSize = cacheSize
		mainLog.Load().Debug().Int("cache_size", cacheSize).Msg("Cache flag processed")
	}
	v.Set("service", cfg.Service)

	mainLog.Load().Debug().Msg("Log and cache flags processed successfully")
}

// netInterface returns the network interface by name
func netInterface(ifaceName string) (*net.Interface, error) {
	if ifaceName == autoIface {
		ifaceName = defaultIfaceName()
	}
	var iface *net.Interface
	err := netmon.ForeachInterface(func(i netmon.Interface, prefixes []netip.Prefix) {
		if i.Name == ifaceName {
			iface = i.Interface
		}
	})
	if iface == nil {
		return nil, errors.New("interface not found")
	}
	if _, err := patchNetIfaceName(iface); err != nil {
		return nil, err
	}
	return iface, err
}

// defaultIfaceName returns the default interface name
func defaultIfaceName() string {
	dri, err := netmon.DefaultRouteInterface()
	if err != nil {
		// On WSL 1, the route table does not have any default route. But the fact that
		// it only uses /etc/resolv.conf for setup DNS, so we can use "lo" here.
		if oi := osinfo.New(); strings.Contains(oi.String(), "Microsoft") {
			return "lo"
		}
		// On linux, it could be either resolvconf or systemd which is managing DNS settings,
		// so the interface name does not matter if there's no default route interface.
		if runtime.GOOS == "linux" {
			return "lo"
		}
		mainLog.Load().Debug().Err(err).Msg("No default route interface found")
		return ""
	}
	return dri
}

// selfCheckStatus performs the end-to-end DNS test by sending query to ctrld listener.
// It returns a boolean to indicate whether the check is succeeded, the actual status
// of ctrld service, and an additional error if any.
//
// We perform two tests:
//
// - Internal testing, ensuring query could be sent from client -> ctrld.
// - External testing, ensuring query could be sent from ctrld -> upstream.
//
// Self-check is considered success only if both tests are ok.
func selfCheckStatus(ctx context.Context, s service.Service, sockDir string) (bool, service.Status, error) {
	status, err := s.Status()
	if err != nil {
		mainLog.Load().Warn().Err(err).Msg("Could not get service status")
		return false, service.StatusUnknown, err
	}
	// If ctrld is not running, do nothing, just return the status as-is.
	if status != service.StatusRunning {
		return false, status, nil
	}
	// Skip self checks if set.
	if skipSelfChecks {
		return true, status, nil
	}

	mainLog.Load().Debug().Msg("Waiting for ctrld listener to be ready")
	cc := newSocketControlClient(ctx, s, sockDir)
	if cc == nil {
		return false, status, errors.New("could not connect to control server")
	}

	v = viper.NewWithOptions(viper.KeyDelimiter("::"))
	if configPath != "" {
		v.SetConfigFile(configPath)
	} else {
		v.SetConfigFile(defaultConfigFile)
	}
	if err := v.ReadInConfig(); err != nil {
		mainLog.Load().Error().Err(err).Msgf("Failed to re-read configuration file: %s", v.ConfigFileUsed())
		return false, status, err
	}

	cfg = ctrld.Config{}
	if err := v.Unmarshal(&cfg); err != nil {
		mainLog.Load().Error().Err(err).Msg("Failed to update new config")
		return false, status, err
	}

	selfCheckExternalDomain := cfg.FirstUpstream().VerifyDomain()
	if selfCheckExternalDomain == "" {
		// Nothing to do, return the status as-is.
		return true, status, nil
	}

	mainLog.Load().Debug().Msg("Ctrld listener is ready")

	lc := cfg.FirstListener()
	addr := net.JoinHostPort(lc.IP, strconv.Itoa(lc.Port))

	mainLog.Load().Debug().Msgf("Performing listener test, sending queries to %s", addr)

	if err := selfCheckResolveDomain(context.TODO(), addr, "internal", selfCheckInternalTestDomain); err != nil {
		return false, status, err
	}
	if err := selfCheckResolveDomain(context.TODO(), addr, "external", selfCheckExternalDomain); err != nil {
		return false, status, err
	}
	return true, status, nil
}

// selfCheckResolveDomain performs DNS test query against ctrld listener.
func selfCheckResolveDomain(ctx context.Context, addr, scope string, domain string) error {
	bo := backoff.NewBackoff("self-check", logf, 10*time.Second)
	bo.LogLongerThan = 500 * time.Millisecond
	maxAttempts := 10
	c := new(dns.Client)

	var (
		lastAnswer *dns.Msg
		lastErr    error
	)

	oi := osinfo.New()
	for i := 0; i < maxAttempts; i++ {
		if domain == "" {
			return errors.New("empty test domain")
		}
		m := new(dns.Msg)
		m.SetQuestion(domain+".", dns.TypeA)
		m.RecursionDesired = true
		r, _, exErr := exchangeContextWithTimeout(c, 5*time.Second, m, addr)
		if r != nil && r.Rcode == dns.RcodeSuccess && len(r.Answer) > 0 {
			mainLog.Load().Debug().Msgf("%s self-check against %q succeeded", scope, domain)
			return nil
		}
		// Return early if this is a connection refused.
		if errConnectionRefused(exErr) {
			return exErr
		}
		// Return early if this is MacOS 15.0 and error is timeout error.
		var e net.Error
		if oi.Name == "darwin" && oi.Version == "15.0" && errors.As(exErr, &e) && e.Timeout() {
			mainLog.Load().Warn().Msg("MacOS 15.0 Sequoia has a bug with the firewall which may prevent ctrld from starting. Disable the MacOS firewall and try again")
			return exErr
		}
		lastAnswer = r
		lastErr = exErr
		bo.BackOff(ctx, fmt.Errorf("ExchangeContext: %w", exErr))
	}
	mainLog.Load().Debug().Msgf("Self-check against %q failed", domain)
	loggerCtx := ctrld.LoggerCtx(ctx, mainLog.Load())
	// Ping all upstreams to provide better error message to users.
	for name, uc := range cfg.Upstream {
		if err := uc.ErrorPing(loggerCtx); err != nil {
			mainLog.Load().Err(err).Msgf("Failed to connect to upstream.%s, endpoint: %s", name, uc.Endpoint)
		}
	}
	marker := strings.Repeat("=", 32)
	mainLog.Load().Debug().Msg(marker)

	mainLog.Load().Debug().Msgf("Listener address       : %s", addr)
	mainLog.Load().Debug().Msgf("Last error             : %v", lastErr)
	if lastAnswer != nil {
		mainLog.Load().Debug().Msgf("Last answer from ctrld :")
		mainLog.Load().Debug().Msg(marker)
		for _, s := range strings.Split(lastAnswer.String(), "\n") {
			mainLog.Load().Debug().Msgf("%s", s)
		}
	}
	return errSelfCheckNoAnswer
}

// userHomeDir returns the user's home directory
func userHomeDir() (string, error) {
	// Mobile platform should provide a rw dir path for this.
	if isMobile() {
		return homedir, nil
	}
	return ctrld.UserHomeDir()
}

// serviceHomeDir returns the directory where a ctrld service with root or
// administrator rights keeps its files. Unlike userHomeDir, it has no
// fallback to the home directory of the current user, so a caller without
// root that only reads looks where the service wrote.
func serviceHomeDir() (string, error) {
	if isMobile() {
		return homedir, nil
	}
	return ctrld.ServiceHomeDir()
}

// absHomeDir returns the absolute path of filename in the ctrld home
// directory. A custom homedir wins, so a file lands next to the log and
// the config that obey the same override.
func absHomeDir(filename string) string {
	if homedir != "" {
		return filepath.Join(homedir, filename)
	}
	dir, err := userHomeDir()
	if err != nil {
		return filename
	}
	return filepath.Join(dir, filename)
}

// socketDir returns directory to use for the controlServer socket.
func socketDir() (string, error) {
	switch {
	case runtime.GOOS == "windows", isMobile():
		return userHomeDir()
	}
	dir := "/var/run"
	if ok, _ := dirWritable(dir); !ok {
		return userHomeDir()
	}
	return dir, nil
}

// tryReadingConfig is like tryReadingConfigWithNotice, with notice set to false.
func tryReadingConfig(writeDefaultConfig bool) {
	tryReadingConfigWithNotice(writeDefaultConfig, false)
}

// tryReadingConfigWithNotice tries reading in config files, either specified by user or from default
// locations. If notice is true, emitting a notice message to user which config file was read.
func tryReadingConfigWithNotice(writeDefaultConfig, notice bool) {
	// --config is specified.
	if configPath != "" {
		v.SetConfigFile(configPath)
		readConfigFile(false, notice)
		return
	}
	// no config start or base64 config mode.
	if !writeDefaultConfig {
		return
	}
	readConfigWithNotice(writeDefaultConfig, notice)
}

// readConfig calls readConfigWithNotice with notice set to false.
func readConfig(writeDefaultConfig bool) {
	readConfigWithNotice(writeDefaultConfig, false)
}

// readConfigWithNotice calls readConfigFile with config file set to ctrld.toml
// or config.toml for compatible with earlier versions of ctrld.
func readConfigWithNotice(writeDefaultConfig, notice bool) {
	configs := []struct {
		name    string
		written bool
	}{
		// For compatibility, we check for config.toml first, but only read it if exists.
		{"config", false},
		{"ctrld", writeDefaultConfig},
	}

	dir, err := userHomeDir()
	if err != nil {
		mainLog.Load().Fatal().Msgf("Failed to get user home dir: %v", err)
	}
	for _, config := range configs {
		ctrld.SetConfigNameWithPath(v, config.name, dir)
		v.SetConfigFile(configPath)
		if readConfigFile(config.written, notice) {
			break
		}
	}
}

func uninstall(p *prog, s service.Service) {
	if _, err := s.Status(); err != nil && errors.Is(err, service.ErrNotInstalled) {
		mainLog.Load().Error().Msg(err.Error())
		return
	}
	tasks := []task{
		{s.Stop, false, "Stop"},
		{s.Uninstall, true, "Uninstall"},
	}
	initInteractiveLogging()
	if doTasks(tasks) {
		// restore static DNS settings or DHCP
		p.resetDNS(false, true)
		restoreSavedStaticDNS(p.runningIface, true)

		mainLog.Load().Notice().Msg("Service uninstalled")
		return
	}
}

// restoreSavedStaticDNS restores DNS from saved static config files on physical interfaces.
func restoreSavedStaticDNS(excludeIfaceName string, removeSaved bool) {
	withEachPhysicalInterfaces(excludeIfaceName, "restore static DNS", func(i *net.Interface) error {
		file := ctrld.SavedStaticDnsSettingsFilePath(i)
		if _, err := os.Stat(file); err == nil {
			if err := restoreDNS(i); err != nil {
				mainLog.Load().Error().Err(err).Msgf("Could not restore static DNS on interface %s", i.Name)
			} else {
				mainLog.Load().Debug().Msgf("Restored static DNS on interface %s successfully", i.Name)
				if removeSaved {
					if err := os.Remove(file); err != nil {
						mainLog.Load().Debug().Err(err).Msgf("Could not remove saved static DNS file for interface %s", i.Name)
					}
				}
			}
		}
		return nil
	})
}

func validateConfig(cfg *ctrld.Config) error {
	mainLog.Load().Debug().Msg("Validating configuration")

	if err := ctrld.ValidateConfig(validator.New(), cfg); err != nil {
		var ve validator.ValidationErrors
		if errors.As(err, &ve) {
			for _, fe := range ve {
				mainLog.Load().Error().Msgf("Invalid config: %s: %s", fe.Namespace(), fieldErrorMsg(fe))
			}
		}
		mainLog.Load().Error().Err(err).Msg("Configuration validation failed")
		return err
	}

	mainLog.Load().Debug().Msg("Configuration validation completed successfully")
	return nil
}

// NOTE: Add more case here once new validation tag is used in ctrld.Config struct.
func fieldErrorMsg(fe validator.FieldError) string {
	switch fe.Tag() {
	case "oneof":
		return fmt.Sprintf("must be one of: %q", fe.Param())
	case "min":
		if fe.Kind() == reflect.Map || fe.Kind() == reflect.Slice {
			return fmt.Sprintf("must define at least %s element", fe.Param())
		}
		return fmt.Sprintf("minimum value: %q", fe.Param())
	case "max":
		if fe.Kind() == reflect.Map || fe.Kind() == reflect.Slice {
			return fmt.Sprintf("exceeded maximum number of elements: %s", fe.Param())
		}
		return fmt.Sprintf("maximum value: %q", fe.Param())
	case "len":
		if fe.Kind() == reflect.Slice {
			return fmt.Sprintf("must have at least %s element", fe.Param())
		}
		return fmt.Sprintf("minimum len: %q", fe.Param())
	case "gte":
		return fmt.Sprintf("must be greater than or equal to: %s", fe.Param())
	case "cidr":
		return fmt.Sprintf("invalid value: %s", fe.Value())
	case "required_unless", "required":
		return "value is required"
	case "dnsrcode":
		return fmt.Sprintf("invalid DNS rcode value: %s", fe.Value())
	case "ipstack":
		ipStacks := []string{ctrld.IpStackV4, ctrld.IpStackV6, ctrld.IpStackSplit, ctrld.IpStackBoth}
		return fmt.Sprintf("must be one of: %q", strings.Join(ipStacks, " "))
	case "iporempty":
		return fmt.Sprintf("invalid IP format: %s", fe.Value())
	case "file":
		return fmt.Sprintf("filed does not exist: %s", fe.Value())
	case "http_url":
		return fmt.Sprintf("invalid http/https url: %s", fe.Value())
	}
	return ""
}

func isLoopback(ipStr string) bool {
	ip := net.ParseIP(ipStr)
	if ip == nil {
		return false
	}
	return ip.IsLoopback()
}

func shouldAllocateLoopbackIP(ipStr string) bool {
	ip := net.ParseIP(ipStr)
	if ip == nil || ip.To4() == nil {
		return false
	}
	return ip.IsLoopback() && ip.String() != "127.0.0.1"
}

type listenerConfigCheck struct {
	IP   bool
	Port bool
}

// mobileListenerPort returns hardcoded port for mobile platforms.
func mobileListenerPort() int {
	if isAndroid() {
		return 5354
	}
	return 53
}

// mobileListenerIp returns hardcoded listener ip for mobile platforms
func mobileListenerIp() string {
	if isAndroid() {
		return "0.0.0.0"
	}
	return "127.0.0.1"
}

// updateListenerConfig updates the config for listeners if not defined,
// or defined but invalid to be used, e.g: using loopback address other
// than 127.0.0.1 with systemd-resolved.
func updateListenerConfig(cfg *ctrld.Config, notifyToLogServerFunc func()) bool {
	updated, _ := tryUpdateListenerConfig(cfg, notifyToLogServerFunc, true)
	if addExtraSplitDnsRule(cfg) {
		updated = true
	}
	return updated
}

// tryUpdateListenerConfigIntercept handles listener binding for dns-intercept mode on macOS.
// In intercept mode, pf redirects all outbound port-53 traffic to ctrld's listener,
// so ctrld can safely listen on a non-standard port if port 53 is unavailable
// (e.g., mDNSResponder holds *:53).
//
// Flow:
//  1. If config has explicit (non-default) IP:port → use exactly that, no fallback
//  2. Otherwise → try 127.0.0.1:53, then 127.0.0.1:5354, then fatal
func tryUpdateListenerConfigIntercept(cfg *ctrld.Config, notifyFunc func(), fatal bool) (updated, ok bool) {
	ok = true
	lc := cfg.FirstListener()
	if lc == nil {
		return false, true
	}

	hasExplicitConfig := isExplicitInterceptListener(lc.IP, lc.Port)
	if !hasExplicitConfig {
		// Set defaults for intercept mode
		if lc.IP == "" || lc.IP == "0.0.0.0" {
			lc.IP = "127.0.0.1"
			updated = true
		}
		if lc.Port == 0 {
			lc.Port = 53
			updated = true
		}
	}

	// bindAttempts feeds the provisioning result detail. newProvisionResult
	// caps it, so it grows freely here.
	var bindAttempts []provisionBindAttempt
	recordBindAttempt := func(addr, proto string, err error) {
		if err != nil {
			bindAttempts = append(bindAttempts, provisionBindAttempt{Addr: addr, Proto: proto, OSError: err.Error()})
		}
	}

	tryListen := func(ip string, port int) bool {
		addr := net.JoinHostPort(ip, strconv.Itoa(port))
		udpLn, udpErr := net.ListenPacket("udp", addr)
		if udpLn != nil {
			udpLn.Close()
		}
		recordBindAttempt(addr, "udp", udpErr)
		tcpLn, tcpErr := net.Listen("tcp", addr)
		if tcpLn != nil {
			tcpLn.Close()
		}
		recordBindAttempt(addr, "tcp", tcpErr)
		return udpErr == nil && tcpErr == nil
	}

	addr := net.JoinHostPort(lc.IP, strconv.Itoa(lc.Port))
	if tryListen(lc.IP, lc.Port) {
		mainLog.Load().Debug().Msgf("DNS intercept: listener available at %s", addr)
		return updated, true
	}

	mainLog.Load().Info().Msgf("DNS intercept: cannot bind %s", addr)

	if hasExplicitConfig {
		// User specified explicit address — don't guess, just fail
		if fatal {
			msg := fmt.Sprintf("DNS intercept: cannot listen on configured address %s", addr)
			mainLog.Load().Error().Msg(msg)
			failProvision(newProvisionResult(provisionCodeListenerAddrUnavail, msg, bindAttempts, provisionSecrets()...), notifyFunc)
			return updated, false
		}
		return updated, false
	}

	// Fallback: try port 5354 (mDNSResponder likely holds *:53)
	if tryListen("127.0.0.1", 5354) {
		mainLog.Load().Info().Msg("DNS intercept: port 53 unavailable (likely mDNSResponder), using 127.0.0.1:5354")
		lc.IP = "127.0.0.1"
		lc.Port = 5354
		return true, true
	}

	if fatal {
		const msg = "DNS intercept: cannot bind 127.0.0.1:53 or 127.0.0.1:5354"
		mainLog.Load().Error().Msg(msg)
		failProvision(newProvisionResult(provisionCodeListenerBindFailed, msg, bindAttempts, provisionSecrets()...), notifyFunc)
		return updated, false
	}
	return updated, false
}

func isExplicitInterceptListener(ip string, port int) bool {
	if ip == "" || ip == "0.0.0.0" || port == 0 {
		return false
	}
	// 127.0.0.1:53 is the default macOS DNS-intercept listener. It can appear
	// in generated/custom Control D configs, but it should still be allowed to
	// fall back to 127.0.0.1:5354 when mDNSResponder already owns port 53.
	return !(ip == "127.0.0.1" && port == 53)
}

// listenerInterceptMode resolves the mode that selects the listener binding
// strategy. An explicit "off" is final here, the same as in setDNS. A fallback
// to the config value would select the intercept strategy from a stale
// persisted mode on the first start after a revert to standard mode.
func listenerInterceptMode(cfg *ctrld.Config) string {
	if interceptMode == "" {
		return cfg.Service.InterceptMode
	}
	return interceptMode
}

// tryUpdateListenerConfig tries updating listener config with a working one.
// If fatal is true, and there's listen address conflicted, the function do
// fatal error.
func tryUpdateListenerConfig(cfg *ctrld.Config, notifyFunc func(), fatal bool) (updated, ok bool) {
	// In intercept mode (macOS), pf redirects all port-53 traffic to ctrld's listener,
	// so ctrld can safely listen on a non-standard port. Use a simple two-attempt flow:
	// 1. If config has explicit non-default IP:port, use exactly that
	// 2. Otherwise: try 127.0.0.1:53, then 127.0.0.1:5354, then fatal
	// This bypasses the full cd-mode listener probing loop entirely.
	// dnsIntercept bool is derived later in prog.run(), but we need to know
	// the intercept mode here to select the right listener probing strategy.
	im := listenerInterceptMode(cfg)
	if (im == "dns" || im == "hard") && runtime.GOOS == "darwin" {
		return tryUpdateListenerConfigIntercept(cfg, notifyFunc, fatal)
	}
	ok = true
	lcc := make(map[string]*listenerConfigCheck)
	cdMode := cdUID != ""
	nextdnsMode := nextdns != ""
	isDesktop := ctrld.IsDesktopPlatform()
	for n, listener := range cfg.Listener {
		lcc[n] = &listenerConfigCheck{}
		if listener.IP == "" {
			listener.IP = "0.0.0.0"
			// For desktop clients, also stick the listener to the local IP only.
			// Listening on 0.0.0.0 would expose it to the entire local network, potentially
			// creating security vulnerabilities (such as DNS amplification or abusing).
			if isDesktop {
				listener.IP = "127.0.0.1"
			}
			lcc[n].IP = true
		}
		if listener.Port == 0 {
			listener.Port = 53
			lcc[n].Port = true
		}
		// In cd mode, we always try to pick an ip:port pair to work.
		// Same if nextdns resolver is used.
		if cdMode || nextdnsMode {
			lcc[n].IP = true
			lcc[n].Port = true
		}
		updated = updated || lcc[n].IP || lcc[n].Port
	}

	il := mainLog.Load()
	if isMobile() {
		// On Mobile, only use first listener, ignore others.
		// This is needed because mobile platforms have limited resources and
		// multiple listeners can cause conflicts with system DNS services and
		// likely don't work anyway.
		firstLn := cfg.FirstListener()
		for k := range cfg.Listener {
			if cfg.Listener[k] != firstLn {
				delete(cfg.Listener, k)
			}
		}
		if cdMode {
			// Use mobile-specific listener settings for Control-D mode
			// Mobile platforms require specific IP/port combinations to avoid permission issues.
			firstLn.IP = mobileListenerIp()
			firstLn.Port = mobileListenerPort()
			clear(lcc)
			updated = true
		}
	}
	var closers []io.Closer
	defer func() {
		for _, closer := range closers {
			_ = closer.Close()
		}
	}()
	// bindAttempts feeds the provisioning result detail. newProvisionResult
	// caps it, so it grows freely here.
	var bindAttempts []provisionBindAttempt
	recordBindAttempt := func(addr, proto string, err error) {
		if err != nil {
			bindAttempts = append(bindAttempts, provisionBindAttempt{Addr: addr, Proto: proto, OSError: err.Error()})
		}
	}

	// tryListen attempts to listen on given udp and tcp address.
	// Created listeners will be kept in listeners slice above, and close
	// before function finished.
	tryListen := func(addr string) error {
		udpLn, udpErr := net.ListenPacket("udp", addr)
		if udpLn != nil {
			closers = append(closers, udpLn)
		}
		recordBindAttempt(addr, "udp", udpErr)
		tcpLn, tcpErr := net.Listen("tcp", addr)
		if tcpLn != nil {
			closers = append(closers, tcpLn)
		}
		recordBindAttempt(addr, "tcp", tcpErr)
		return errors.Join(udpErr, tcpErr)
	}

	listenerMsg := func(listenerNum int, format string, v ...any) string {
		return fmt.Sprintf("listener.%d %s", listenerNum, fmt.Sprintf(format, v...))
	}
	logMsg := func(e *ctrld.LogEvent, listenerNum int, format string, v ...any) {
		e.MsgFunc(func() string {
			return listenerMsg(listenerNum, format, v...)
		})
	}

	listeners := make([]int, 0, len(cfg.Listener))
	for k := range cfg.Listener {
		n, err := strconv.Atoi(k)
		if err != nil {
			continue
		}
		listeners = append(listeners, n)
	}
	sort.Ints(listeners)

	for _, n := range listeners {
		listener := cfg.Listener[strconv.Itoa(n)]
		check := lcc[strconv.Itoa(n)]
		oldIP := listener.IP
		oldPort := listener.Port
		isZeroIP := listener.IP == "0.0.0.0" || listener.IP == "::"

		// Check if we could listen on the current IP + Port, if not, try following thing, pick first one success:
		//    - Try 127.0.0.1:53
		//    - Pick a random port until success.
		localhostIP := func(ipStr string) string {
			if ip := net.ParseIP(ipStr); ip != nil && ip.To4() == nil {
				return "::1"
			}
			return "127.0.0.1"
		}

		// On firewalla, we don't need to check localhost, because the lo interface is excluded in dnsmasq
		// config, so we can always listen on localhost port 53, but no traffic could be routed there.
		tryLocalhost := !isLoopback(listener.IP)
		tryAllPort53 := true
		if isZeroIP && listener.Port == 53 {
			tryAllPort53 = false
		}

		attempts := 0
		maxAttempts := 10
		for {
			if attempts == maxAttempts {
				logMsg(mainLog.Load().Error(), n, "could not find available listen ip and port")
				msg := listenerMsg(n, "could not find available listen ip and port")
				failProvision(newProvisionResult(provisionCodeListenerBindFailed, msg, bindAttempts, provisionSecrets()...), notifyFunc)
				return updated, false
			}
			addr := net.JoinHostPort(listener.IP, strconv.Itoa(listener.Port))
			err := tryListen(addr)
			if err == nil {
				break
			}

			logMsg(il.Debug().Err(err), n, "error listening on address: %s", addr)

			if !check.IP && !check.Port {
				if fatal {
					logMsg(mainLog.Load().Error(), n, "failed to listen: %v", err)
					msg := listenerMsg(n, "failed to listen: %v", err)
					failProvision(newProvisionResult(provisionCodeListenerAddrUnavail, msg, bindAttempts, provisionSecrets()...), notifyFunc)
					return updated, false
				}
				ok = false
				break
			}

			// Try standard port 53 first for better compatibility
			// This is the most common DNS port and has the highest chance of working
			if tryAllPort53 {
				tryAllPort53 = false
				if check.IP {
					listener.IP = "0.0.0.0"
				}
				if check.Port {
					listener.Port = 53
				}
				if check.IP {
					logMsg(il.Info(), n, "could not listen on address: %s, trying: %s", addr, net.JoinHostPort(listener.IP, strconv.Itoa(listener.Port)))
				}
				continue
			}

			// Try localhost as fallback for security and compatibility
			// Localhost is often available even when other addresses are blocked
			if tryLocalhost {
				tryLocalhost = false
				if check.IP {
					listener.IP = localhostIP(listener.IP)
				}
				if check.Port {
					listener.Port = 53
				}
				if check.IP {
					logMsg(il.Info(), n, "could not listen on address: %s, trying localhost: %s", addr, net.JoinHostPort(listener.IP, strconv.Itoa(listener.Port)))
				}
				continue
			}

			// Try random IP/port combinations as last resort
			// This ensures the service can start even in constrained environments
			if check.IP && !isZeroIP { // for "0.0.0.0" or "::", we only need to try new port.
				listener.IP = randomLocalIP()
			} else {
				listener.IP = oldIP
			}
			if check.Port {
				listener.Port = randomPort()
			} else {
				listener.Port = oldPort
			}
			if listener.IP == oldIP && listener.Port == oldPort {
				if fatal {
					triedAddr := net.JoinHostPort(listener.IP, strconv.Itoa(listener.Port))
					logMsg(mainLog.Load().Error(), n, "could not listen on %s: %v", triedAddr, err)
					msg := listenerMsg(n, "could not listen on %s: %v", triedAddr, err)
					failProvision(newProvisionResult(provisionCodeListenerBindFailed, msg, bindAttempts, provisionSecrets()...), notifyFunc)
					return updated, false
				}
				ok = false
				break
			}
			logMsg(il.Info(), n, "could not listen on address: %s, pick a random ip+port", addr)
			attempts++
		}
	}
	if !ok {
		return
	}

	// Specific case for systemd-resolved.
	// systemd-resolved has specific requirements for DNS forwarding that we must handle
	if useSystemdResolved {
		if listener := cfg.FirstListener(); listener != nil && listener.Port == 53 {
			n := listeners[0]
			// systemd-resolved does not allow forwarding DNS queries from 127.0.0.53 to loopback
			// ip address, other than "127.0.0.1", so trying to listen on default route interface
			// address instead.
			if ip := net.ParseIP(listener.IP); ip != nil && ip.IsLoopback() && ip.String() != "127.0.0.1" {
				logMsg(il.Info(), n, "using loopback interface do not work with systemd-resolved")
				found := false
				if netIface, _ := net.InterfaceByName(defaultIfaceName()); netIface != nil {
					addrs, _ := netIface.Addrs()
					for _, addr := range addrs {
						if netIP, ok := addr.(*net.IPNet); ok && netIP.IP.To4() != nil {
							addr := net.JoinHostPort(netIP.IP.String(), strconv.Itoa(listener.Port))
							if err := tryListen(addr); err == nil {
								found = true
								listener.IP = netIP.IP.String()
								logMsg(il.Info(), n, "use %s as listener address", listener.IP)
								break
							}
						}
					}
				}
				if !found {
					logMsg(mainLog.Load().Error(), n, "could not use %q as DNS nameserver with systemd resolved", listener.IP)
					msg := listenerMsg(n, "could not use %q as DNS nameserver with systemd resolved", listener.IP)
					failProvision(newProvisionResult(provisionCodeListenerAddrUnavail, msg, bindAttempts, provisionSecrets()...), notifyFunc)
					return updated, false
				}
			}
		}
	}
	return
}

// dirWritable checks if a directory is writable
func dirWritable(dir string) (bool, error) {
	f, err := os.CreateTemp(dir, "")
	if err != nil {
		return false, err
	}
	defer os.Remove(f.Name())
	return true, f.Close()
}

// osVersion returns the operating system version
func osVersion() string {
	oi := osinfo.New()
	if runtime.GOOS == "freebsd" {
		if ver, _, found := strings.Cut(oi.String(), ":"); found {
			return ver
		}
	}
	return oi.String()
}

// provisionTokenMinLen, provisionTokenMaxLen, and provisionTokenExpectedPrefix
// describe the shape of a --cd-org value the input stage checks before any
// network call.
const (
	provisionTokenMinLen         = 6
	provisionTokenMaxLen         = 64
	provisionTokenExpectedPrefix = "org-v1-"
)

// provisionTokenShapeValid reports whether token looks like something the API
// could parse: a length between provisionTokenMinLen and provisionTokenMaxLen,
// with no whitespace or control characters. It does not require the
// "org-v1-" prefix - legacy provisioning codes predate that convention and
// are handled with a warning, not a failure.
func provisionTokenShapeValid(token string) bool {
	if len(token) < provisionTokenMinLen || len(token) > provisionTokenMaxLen {
		return false
	}
	for _, r := range token {
		if unicode.IsSpace(r) || unicode.IsControl(r) {
			return false
		}
	}
	return true
}

// validateProvisionTokenShape classifies an obviously malformed --cd-org
// value (PROVISION_TOKEN_MALFORMED) before any network call, so a typo or a
// pasted URL fails fast instead of going through a doomed API round trip. A
// token missing the "org-v1-" prefix only gets a logged warning: legacy
// provisioning codes may lack it and still work.
func validateProvisionTokenShape() bool {
	if !provisionTokenShapeValid(cdOrg) {
		msg := fmt.Sprintf("--cd-org provisioning code is malformed: must be %d-%d characters with no whitespace or control characters", provisionTokenMinLen, provisionTokenMaxLen)
		failProvision(newProvisionResult(provisionCodeProvisionTokenMalformed, msg, nil, provisionSecrets()...), nil)
		return false
	}
	if !strings.HasPrefix(cdOrg, provisionTokenExpectedPrefix) {
		mainLog.Load().Warn().Msg("--cd-org provisioning code does not have the expected org-v1- prefix; continuing, since legacy codes may lack it")
	}
	return true
}

// validateCustomHostnameFlag classifies an invalid --custom-hostname value
// (CUSTOM_HOSTNAME_INVALID) before any network call. ctrld's accept/reject
// rule (validHostname) is unchanged - this only names the field, the
// offending character(s), and the allowed format instead of a bare fatal
// exit. ControlD's device-name formatting folds or strips some characters
// ctrld still accepts (dot, space, plus), so a hostname using one of those
// gets a one-line notice instead: the registered device name may differ from
// what was requested.
func validateCustomHostnameFlag() bool {
	if customHostname == "" {
		return true
	}
	if !validHostname(customHostname) {
		failProvision(newProvisionResult(provisionCodeCustomHostnameInvalid, customHostnameFailureMessage(customHostname), nil, provisionSecrets()...), nil)
		return false
	}
	if hostnameMayBeFoldedByServer(customHostname) {
		mainLog.Load().Notice().Msgf("device name %q contains characters ControlD may fold or strip when it registers the device; the registered name may differ", customHostname)
	}
	return true
}

// validateInterceptModeFlag classifies an invalid --intercept-mode value
// (INTERCEPT_MODE_INVALID) on the provisioning boundary, so a typo fails with
// a stable code instead of a bare fatal exit an installer wrapper cannot tell
// apart from any other crash. An empty mode means the flag was not set.
func validateInterceptModeFlag(mode string) bool {
	if mode == "" || validInterceptMode(mode) {
		return true
	}
	msg := fmt.Sprintf("--intercept-mode %q is not valid: must be 'off', 'dns', or 'hard'", mode)
	failProvision(newProvisionResult(provisionCodeInterceptModeInvalid, msg, nil, provisionSecrets()...), nil)
	return false
}

// validateFirewallModeFlag classifies an invalid --firewall-mode value on the
// provisioning boundary. Unlike --intercept-mode, no dedicated code exists
// for this flag, so a bad value falls back to UNCLASSIFIED rather than a bare
// fatal exit. changed must be the flag's Cobra Changed() state: the default
// value ("off") is not itself invalid, so an unset flag always proceeds.
// notify unblocks a waiting "ctrld start"; pass nil when there is none.
func validateFirewallModeFlag(changed bool, mode string, notify func()) bool {
	if !changed || validFirewallMode(mode) {
		return true
	}
	msg := fmt.Sprintf("--firewall-mode %q is not valid: must be 'off' or 'on'", mode)
	failProvisionUnclassified(msg, notify)
	return false
}

// cdUIDFromProvToken fetch UID from ControlD API using provision token.
func cdUIDFromProvToken() string {
	// --cd flag supersedes --cd-org, ignore it if both are supplied.
	if cdUID != "" {
		return ""
	}
	// --cd-org is empty, nothing to do.
	if cdOrg == "" {
		return ""
	}
	if !validateProvisionTokenShape() {
		return ""
	}
	if !validateCustomHostnameFlag() {
		return ""
	}
	loggerCtx := ctrld.LoggerCtx(context.Background(), mainLog.Load())
	req := &controld.UtilityOrgRequest{
		ProvToken: cdOrg,
		Hostname:  customHostname,
		Metadata:  ctrld.SystemMetadata(loggerCtx),
	}
	// Process provision token if provided.
	resolverConfig, err := fetchResolverUIDFn(loggerCtx, req, appVersion, cdDev)
	if err != nil {
		// The token exchange is the first API call of an org/MDM install, so
		// its failure must carry a code like every other bootstrap failure.
		code, _ := apiFailureCode(err)
		mainLog.Load().Error().Msgf("Failed to fetch resolver uid with provision token: %s: %s",
			redactToken(cdOrg), redactSecrets(err.Error(), provisionSecrets()...))
		failProvision(newProvisionResult(code, provisionTokenFailureMessage(code, err), nil, provisionSecrets()...), nil)
		return ""
	}
	return resolverConfig.UID
}

// fetchResolverUIDFn is a var so tests can drive token-exchange failures
// without reaching the network.
var fetchResolverUIDFn = controld.FetchResolverUID

// removeOrgFlagsFromArgs removes organization flags from command line arguments.
// The flags are:
//
// - "--cd-org"
// - "--custom-hostname"
//
// This is necessary because "ctrld run" only need a valid UID, which could be fetched
// using "--cd-org". So if "ctrld start" have already been called with "--cd-org", we
// already have a valid UID to pass to "ctrld run", so we don't have to force "ctrld run"
// to re-do the already done job.
func removeOrgFlagsFromArgs(sc *service.Config) {
	a := sc.Arguments[:0]
	skip := false
	for _, x := range sc.Arguments {
		if skip {
			skip = false
			continue
		}
		// For "--cd-org XXX"/"--custom-hostname XXX", skip them and mark next arg skipped.
		if x == "--"+cdOrgFlagName || x == "--"+customHostnameFlagName {
			skip = true
			continue
		}
		// For "--cd-org=XXX"/"--custom-hostname=XXX", just skip them.
		if strings.HasPrefix(x, "--"+cdOrgFlagName+"=") ||
			strings.HasPrefix(x, "--"+customHostnameFlagName+"=") {
			continue
		}
		a = append(a, x)
	}
	sc.Arguments = a
}

// newSocketControlClient returns new control client after control server was started.
func newSocketControlClient(ctx context.Context, s service.Service, dir string) *controlClient {
	return newSocketControlClientWithTimeout(ctx, s, dir, dialSocketControlServerTimeout)
}

// newSocketControlClientWithTimeout returns new control client after control server was started.
// The timeoutDuration controls how long to wait for the server.
func newSocketControlClientWithTimeout(ctx context.Context, s service.Service, dir string, timeoutDuration time.Duration) *controlClient {
	// Return early if service is not running.
	if status, err := s.Status(); err != nil || status != service.StatusRunning {
		return nil
	}
	bo := backoff.NewBackoff("self-check", logf, 10*time.Second)
	bo.LogLongerThan = 10 * time.Second

	cc := newControlClient(filepath.Join(dir, ctrldControlUnixSock))
	timeout := time.NewTimer(timeoutDuration)
	defer timeout.Stop()

	// The socket control server may not start yet, so attempt to ping
	// it until we got a response.
	for {
		_, err := cc.post(startedPath, nil)
		if err == nil {
			// Server was started, stop pinging.
			break
		}
		// The socket control server is not ready yet, backoff for waiting it to be ready.
		bo.BackOff(ctx, err)

		select {
		case <-timeout.C:
			return nil
		case <-ctx.Done():
			return nil
		default:
		}
	}

	return cc
}

func newSocketControlClientMobile(dir string, stopCh chan struct{}) *controlClient {
	bo := backoff.NewBackoff("self-check", logf, 3*time.Second)
	bo.LogLongerThan = 3 * time.Second
	ctx := context.Background()
	cc := newControlClient(filepath.Join(dir, ControlSocketName()))
	for {
		select {
		case <-stopCh:
			return nil
		default:
			_, err := cc.post("/", nil)
			if err == nil {
				return cc
			} else {
				bo.BackOff(ctx, err)
			}
		}
	}
}

// checkStrFlagEmpty validates if a string flag was set to an empty string.
// An explicit empty --cd-org is a malformed provisioning token, classified on
// the provisioning boundary; every other flag keeps the bare fatal, since
// this helper is shared with flags outside that boundary. Returns false when
// the caller must stop: provisionExit is stubbed out under test, so nothing
// else stops execution from falling through.
func checkStrFlagEmpty(cmd *cobra.Command, flagName string) bool {
	fl := cmd.Flags().Lookup(flagName)
	if !fl.Changed || fl.Value.Type() != "string" || fl.Value.String() != "" {
		return true
	}
	if flagName == cdOrgFlagName {
		msg := fmt.Sprintf("--%s provisioning code is malformed: value must be non-empty", cdOrgFlagName)
		failProvision(newProvisionResult(provisionCodeProvisionTokenMalformed, msg, nil, provisionSecrets()...), nil)
		return false
	}
	mainLog.Load().Fatal().Msgf(`flag "--%s" value must be non-empty`, fl.Name)
	return false
}

// validateCdUpstreamProtocol validates the Control D upstream protocol,
// classified on the provisioning boundary since it only runs once --cd is
// set. notify unblocks a waiting "ctrld start" on the daemon side; the parent
// process, which has no one waiting, passes nil. Returns false when the
// caller must stop.
func validateCdUpstreamProtocol(notify func()) bool {
	if cdUID == "" {
		return true
	}
	switch cdUpstreamProto {
	case ctrld.ResolverTypeDOH, ctrld.ResolverTypeDOH3:
		return true
	default:
		msg := fmt.Sprintf("--proto %q is not valid: must be 'doh' or 'doh3'", cdUpstreamProto)
		failProvision(newProvisionResult(provisionCodeInvalidFlagCombination, msg, nil, provisionSecrets()...), notify)
		return false
	}
}

// validateCdAndNextDNSFlags validates that Control D and NextDNS flags are
// not used together, classified on the provisioning boundary since it only
// fires once --cd or --cd-org is set. Returns false when the caller must
// stop.
func validateCdAndNextDNSFlags() bool {
	if (cdUID != "" || cdOrg != "") && nextdns != "" {
		msg := fmt.Sprintf("--%s/--%s cannot be used together with --%s", cdUidFlagName, cdOrgFlagName, nextdnsFlagName)
		failProvision(newProvisionResult(provisionCodeInvalidFlagCombination, msg, nil, provisionSecrets()...), nil)
		return false
	}
	return true
}

// removeNextDNSFromArgs removes the --nextdns from command line arguments.
func removeNextDNSFromArgs(sc *service.Config) {
	a := sc.Arguments[:0]
	skip := false
	for _, x := range sc.Arguments {
		if skip {
			skip = false
			continue
		}
		// For "--nextdns XXX", skip it and mark next arg skipped.
		if x == "--"+nextdnsFlagName {
			skip = true
			continue
		}
		// For "--nextdns=XXX", just skip it.
		if strings.HasPrefix(x, "--"+nextdnsFlagName+"=") {
			continue
		}
		a = append(a, x)
	}
	sc.Arguments = a
}

// doGenerateNextDNSConfig generates a working config with nextdns resolver.
func doGenerateNextDNSConfig(uid string) error {
	if uid == "" {
		return nil
	}
	mainLog.Load().Notice().Msgf("Generating nextdns config: %s", defaultConfigFile)
	generateNextDNSConfig(uid)
	updateListenerConfig(&cfg, func() {})
	return writeConfigFile(&cfg)
}

// noticeWritingControlDConfig logs on notice level that a Control D config is being written
func noticeWritingControlDConfig() error {
	if cdUID != "" {
		mainLog.Load().Notice().Msgf("Generating controld config: %s", defaultConfigFile)
	}
	return nil
}

// deactivationPinInvalidExitCode indicates exit code due to invalid pin code.
const deactivationPinInvalidExitCode = 126

// errInvalidDeactivationPin indicates that the deactivation pin is invalid.
var errInvalidDeactivationPin = errors.New("deactivation pin is invalid")

// errRequiredDeactivationPin indicates that the deactivation pin is required but not provided by users.
var errRequiredDeactivationPin = errors.New("deactivation pin is required to stop or uninstall the service")

// errTooManyDeactivationPin represents an error indicating excessive deactivation PIN request attempts.
var errTooManyDeactivationPin = errors.New("too many request attempts")

// checkDeactivationPin validates if the deactivation pin matches one in ControlD config.
func checkDeactivationPin(s service.Service, stopCh chan struct{}) error {
	mainLog.Load().Debug().Msg("Checking deactivation pin")
	dir, err := socketDir()
	if err != nil {
		mainLog.Load().Err(err).Msg("Could not check deactivation pin")
		return err
	}
	mainLog.Load().Debug().Msg("Creating control client")
	var cc *controlClient
	if s == nil {
		cc = newSocketControlClientMobile(dir, stopCh)
	} else {
		cc = newSocketControlClient(context.TODO(), s, dir)
	}
	mainLog.Load().Debug().Msg("Control client done")
	if cc == nil {
		return nil // ctrld is not running.
	}
	data, _ := json.Marshal(&deactivationRequest{Pin: deactivationPin})
	mainLog.Load().Debug().Msg("Posting deactivation request")
	resp, err := cc.post(deactivationPath, bytes.NewReader(data))
	mainLog.Load().Debug().Msg("Posting deactivation request done")
	if resp != nil {
		switch resp.StatusCode {
		case http.StatusBadRequest:
			mainLog.Load().Error().Msg(errRequiredDeactivationPin.Error())
			return errRequiredDeactivationPin // pin is required
		case http.StatusTooManyRequests:
			mainLog.Load().Error().Msg(errTooManyDeactivationPin.Error())
			return errTooManyDeactivationPin
		case http.StatusOK:
			return nil // valid pin
		case http.StatusNotFound:
			return nil // the server is running older version of ctrld
		}
	}
	mainLog.Load().Error().Err(err).Msg(errInvalidDeactivationPin.Error())
	return errInvalidDeactivationPin
}

// isCheckDeactivationPinErr reports whether there is an error during check deactivation pin process.
func isCheckDeactivationPinErr(err error) bool {
	return errors.Is(err, errInvalidDeactivationPin) ||
		errors.Is(err, errRequiredDeactivationPin) ||
		errors.Is(err, errTooManyDeactivationPin)
}

// ensureUninstall ensures that s.Uninstall will remove ctrld service from system completely.
func ensureUninstall(s service.Service) error {
	maxAttempts := 10
	var err error
	for i := 0; i < maxAttempts; i++ {
		err = s.Uninstall()
		if _, err := s.Status(); errors.Is(err, service.ErrNotInstalled) {
			return nil
		}
		time.Sleep(time.Second)
	}
	return errors.Join(err, errors.New("uninstall failed"))
}

// exchangeContextWithTimeout wraps c.ExchangeContext with the given timeout.
func exchangeContextWithTimeout(c *dns.Client, timeout time.Duration, msg *dns.Msg, addr string) (*dns.Msg, time.Duration, error) {
	ctx, cancel := context.WithTimeout(context.Background(), timeout)
	defer cancel()
	return c.ExchangeContext(ctx, msg, addr)
}

// curCdUID returns the current ControlD UID used by running ctrld process.
func curCdUID() string {
	svcCmd := NewServiceCommand()
	if s, _, _ := svcCmd.initializeServiceManager(); s != nil {
		// Configure Windows service failure actions
		if err := ConfigureWindowsServiceFailureActions(ctrldServiceName); err != nil {
			mainLog.Load().Debug().Err(err).Msgf("Failed to configure windows service %s failure actions", ctrldServiceName)
		}
		if dir, _ := socketDir(); dir != "" {
			cc := newSocketControlClient(context.TODO(), s, dir)
			if cc != nil {
				resp, _ := cc.post(cdPath, nil)
				if resp != nil {
					defer resp.Body.Close()
					buf, _ := io.ReadAll(resp.Body)
					return string(buf)
				}
			}
		}
	}
	return ""
}

// goArm returns the GOARM value for the binary.
func goArm() string {
	if runtime.GOARCH != "arm" {
		return ""
	}
	if bi, ok := debug.ReadBuildInfo(); ok {
		for _, setting := range bi.Settings {
			if setting.Key == "GOARM" {
				return setting.Value
			}
		}
	}
	// Use ARM v5 as a fallback, since it works on all others.
	return "5"
}

// upgradeUrl builds the download URL for this platform's binary.
//
// The path carries no version segment: the v1 line publishes as "ctrld" and this
// one as "ctrld-client", so the file name distinguishes them and they can share
// one path.
func upgradeUrl(baseUrl string) string {
	dlPath := fmt.Sprintf("%s-%s/%s", runtime.GOOS, runtime.GOARCH, cliName)
	// Use arm version set during build time, v5 binary can be run on higher arm version system.
	if armVersion := goArm(); armVersion != "" {
		dlPath = fmt.Sprintf("%s-%sv%s/%s", runtime.GOOS, runtime.GOARCH, armVersion, cliName)
	}
	// linux/amd64 has nocgo version, to support systems that missing some libc (like openwrt).
	if !cgoEnabled && runtime.GOOS == "linux" && runtime.GOARCH == "amd64" {
		dlPath = fmt.Sprintf("%s-%s-nocgo/%s", runtime.GOOS, runtime.GOARCH, cliName)
	}
	dlUrl := fmt.Sprintf("%s/%s", baseUrl, dlPath)
	if runtime.GOOS == "windows" {
		dlUrl += ".exe"
	}
	return dlUrl
}

// runningIface returns the value of the iface variable used by ctrld process which is running.
func runningIface(s service.Service) *ifaceResponse {
	if sockDir, err := socketDir(); err == nil {
		if cc := newSocketControlClient(context.TODO(), s, sockDir); cc != nil {
			resp, err := cc.post(ifacePath, nil)
			if err != nil {
				return nil
			}
			defer resp.Body.Close()
			res := &ifaceResponse{}
			if err := json.NewDecoder(resp.Body).Decode(res); err != nil {
				return nil
			}
			return res
		}
	}
	return nil
}

// apiRejectionMessage returns the result-file message for a permanent API
// rejection: the HTTP status for a device-invalid or generic rejection, or
// the raw error for anything else (network trouble, an unreachable API).
func apiRejectionMessage(err error) string {
	var uer *controld.ErrorResponse
	if errors.As(err, &uer) && uer.ErrorField.Code == controld.InvalidConfigCode {
		return apiRejectionSummary(uer.StatusCode)
	}
	if rejection, ok := permanentAPIRejection(err); ok {
		return apiRejectionSummary(rejection.StatusCode)
	}
	return fmt.Sprintf("failed to fetch resolver config: %v", err)
}

// doValidateCdRemoteConfig fetches and validates custom config for cdUID.
// fatal distinguishes the two callers: the direct "--cd <uid>" install path
// passes true and classifies a fetch failure on the provisioning boundary;
// the restart path passes false and gets the error back to decide for
// itself, with no process exit.
func doValidateCdRemoteConfig(cdUID string, fatal bool) error {
	loggerCtx := ctrld.LoggerCtx(context.Background(), mainLog.Load())
	req := &controld.ResolverConfigRequest{
		RawUID:   cdUID,
		Version:  appVersion,
		Metadata: ctrld.SystemMetadata(loggerCtx),
	}
	rc, err := fetchResolverConfig(loggerCtx, req, cdDev)
	if err != nil {
		if !fatal {
			mainLog.Load().Warn().Err(err).Msgf("Failed to fetch resolver config for %s", redactToken(cdUID))
			return err
		}
		code, _ := apiFailureCode(err)
		mainLog.Load().Error().Err(err).Msgf("Failed to fetch resolver config for %s", redactToken(cdUID))
		failProvision(newProvisionResult(code, apiRejectionMessage(err), nil, provisionSecrets()...), nil)
		return err
	}

	// return earlier if there's no custom config.
	if rc.Ctrld.CustomConfig == "" {
		return nil
	}

	// validateCdRemoteConfig clobbers v, saving it here to restore later.
	oldV := v
	var cfgErr error
	remoteCfg := &ctrld.Config{}
	if cfgErr = validateCdRemoteConfig(rc, remoteCfg); cfgErr == nil {
		setListenerDefaultValue(remoteCfg)
		setNetworkDefaultValue(remoteCfg)
		cfgErr = validateConfig(remoteCfg)
	} else {
		if errors.As(cfgErr, &viper.ConfigParseError{}) {
			if configStr, _ := base64.StdEncoding.DecodeString(rc.Ctrld.CustomConfig); len(configStr) > 0 {
				errorLogged := false
				// Write remote config to a uniquely named temporary file to get detailed error.
				if tmpFile, tmpErr := os.CreateTemp("", "ctrld-*.toml"); tmpErr == nil {
					tmpConfFile := tmpFile.Name()
					if _, err := tmpFile.Write(configStr); err != nil {
						mainLog.Load().Error().Err(err).Msg("failed to write temporary config file")
					}
					if err := tmpFile.Close(); err != nil {
						mainLog.Load().Error().Err(err).Msg("failed to save temporary config file")

					}
					if de := decoderErrorFromTomlFile(tmpConfFile); de != nil {
						row, col := de.Position()
						mainLog.Load().Error().Msgf("Failed to parse custom config at line: %d, column: %d, error: %s", row, col, de.Error())
						errorLogged = true
					}
					if err := os.Remove(tmpConfFile); err != nil {
						mainLog.Load().Error().Err(err).Msg("failed to remove temporary config file")
					}
				}
				// If we could not log details error, emit what we have already got.
				if !errorLogged {
					mainLog.Load().Error().Msgf("Failed to parse custom config: %v", cfgErr)
				}
			}
		} else {
			mainLog.Load().Error().Msgf("Failed to unmarshal custom config: %v", err)
		}
	}
	if cfgErr != nil {
		mainLog.Load().Warn().Msg("Disregarding invalid custom config")
	}
	v = oldV
	return nil
}

// ensureRunningIfaceForInvalidUninstall populates p.runningIface before the
// invalid-device self-uninstall resets DNS. This path can run early during
// service startup (e.g. right after a reboot) before the running interface is
// otherwise known. resetDNS, via resetDNSForRunningIface, silently skips DNS
// restoration when p.runningIface is empty, which would leave the OS pointed at
// ctrld's local listener after the service is removed. See issue-556.
func ensureRunningIfaceForInvalidUninstall(p *prog, s service.Service) {
	if iface == "" {
		iface = autoIface
	}
	p.preRun()
	if ir := runningIface(s); ir != nil {
		p.runningIface = ir.Name
		p.requiredMultiNICsConfig = ir.All
	}
}

// uninstallInvalidCdUID performs self-uninstallation because the ControlD device does not exist.
func uninstallInvalidCdUID(p *prog, logger *ctrld.Logger, doStop bool) bool {
	svcCmd := NewServiceCommand()
	s, _, err := svcCmd.initializeServiceManager()
	if err != nil {
		logger.Warn().Err(err).Msg("Failed to create new service")
		return false
	}
	ensureRunningIfaceForInvalidUninstall(p, s)
	// restore static DNS settings or DHCP
	p.resetDNS(false, true)
	// The invalid-device path may run early during service startup before runningIface
	// is known. Restore every saved static DNS file so uninstalling does not leave the
	// OS pointed at ctrld's local listener after the service is removed.
	restoreSavedStaticDNS("", true)

	tasks := []task{{s.Uninstall, true, "Uninstall"}}
	if doTasks(tasks) {
		logger.Info().Msg("Uninstalled service")
		if doStop {
			_ = s.Stop()
		}
		return true
	}
	return false
}

// redactToken returns the first 4 characters of a token followed by ***,
// or just *** if the token is 4 characters or shorter.
func redactToken(s string) string {
	if len(s) <= 4 {
		return "***"
	}
	return s[:4] + "***"
}
