package upgrade

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"runtime"
	"strconv"
	"strings"
	"sync"
	"syscall"
	"time"

	"github.com/tidbcloud/AgentTeam/desktop/internal/daemoncontrol"
	"github.com/tidbcloud/AgentTeam/desktop/internal/installpath"
	"github.com/tidbcloud/AgentTeam/desktop/internal/profile"
	"github.com/tidbcloud/AgentTeam/desktop/internal/version"
	"github.com/tidbcloud/AgentTeam/x/proc"
)

const (
	DefaultStableURL         = "https://static.pingcap.com/agentteam/releases/desktop/stable.json"
	LegacyStableURL          = "https://static.pingcap.com/agentteam/releases/daemon/stable.json"
	DefaultInstallScriptURL  = "https://static.pingcap.com/agentteam/client/install.sh"
	ExpectedProduct          = "agentteam-desktop"
	LegacyExpectedProduct    = "agentteam-daemon"
	checkTimeout             = 10 * time.Second
	installerDownloadTimeout = 30 * time.Second
	helperDelay              = time.Second
)

// SupportsHeadlessUpgrade reports whether the Go-owned updater is part of the
// release contract for an operating system. macOS and Windows application
// updates are owned by Electron so their signed app, renderer, sidecar, and
// managed runtime move as one unit.
func SupportsHeadlessUpgrade(goos string) bool {
	return strings.EqualFold(strings.TrimSpace(goos), "linux")
}

type UpdateMethod string

const (
	UpdateMethodAutomatic     UpdateMethod = "automatic"
	UpdateMethodManualCommand UpdateMethod = "manual_command"
	UpdateMethodUnsupported   UpdateMethod = "unsupported"
)

const (
	clientAssetPurposeRuntime = "runtime"
	clientAssetKindTarball    = "tarball"
)

var versionPattern = regexp.MustCompile(`^v[0-9A-Za-z][0-9A-Za-z.+\-]*$`)

func validSHA256(value string) bool {
	value = strings.TrimSpace(value)
	if len(value) != sha256.Size*2 {
		return false
	}
	_, err := hex.DecodeString(value)
	return err == nil
}

type StableManifest struct {
	SchemaVersion string `json:"schema_version"`
	Product       string `json:"product"`
	Channel       string `json:"channel"`
	Current       string `json:"current_version"`
	PublishedAt   string `json:"published_at"`
	ManifestURL   string `json:"manifest_url"`
}

type VersionManifest struct {
	SchemaVersion string         `json:"schema_version"`
	Product       string         `json:"product"`
	Version       string         `json:"version"`
	PublishedAt   string         `json:"published_at"`
	ManifestURL   string         `json:"manifest_url"`
	Client        ClientManifest `json:"client"`
}

type ClientManifest struct {
	InstallScript string        `json:"install_script"`
	Checksums     string        `json:"checksums"`
	Assets        []ClientAsset `json:"assets,omitempty"`
}

type ClientAsset struct {
	Platform string `json:"platform,omitempty"`
	OS       string `json:"os,omitempty"`
	Arch     string `json:"arch,omitempty"`
	Purpose  string `json:"purpose,omitempty"`
	Kind     string `json:"kind,omitempty"`
	URL      string `json:"url"`
	Label    string `json:"label,omitempty"`
	SHA256   string `json:"sha256,omitempty"`
}

type CheckResult struct {
	Current             string       `json:"current"`
	Latest              string       `json:"latest"`
	HasUpdate           bool         `json:"has_update"`
	ManifestURL         string       `json:"manifest_url,omitempty"`
	PublishedAt         string       `json:"published_at,omitempty"`
	Platform            string       `json:"platform,omitempty"`
	PlatformSupported   bool         `json:"platform_supported"`
	UpdateMethod        UpdateMethod `json:"update_method,omitempty"`
	CanAutoUpgrade      bool         `json:"can_auto_upgrade"`
	InstallScriptURL    string       `json:"install_script_url,omitempty"`
	ChecksumsURL        string       `json:"checksums_url,omitempty"`
	RuntimeAssetURL     string       `json:"runtime_asset_url,omitempty"`
	RuntimeAssetKind    string       `json:"runtime_asset_kind,omitempty"`
	ManualCommand       string       `json:"manual_command,omitempty"`
	IneligibilityReason string       `json:"ineligibility_reason,omitempty"`
	ServerVersion       string       `json:"server_version,omitempty"`
	MinDesktopVersion   string       `json:"min_desktop_version,omitempty"`
}

// ServerRecommendation carries version guidance from the connected AgentTeam server.
// When present, CheckVersion uses RecommendedDesktopVersion as the "latest"
// target instead of the global CDN stable manifest, so private deployments can
// pin desktop versions that are compatible with their server.
type ServerRecommendation struct {
	ServerVersion             string
	RecommendedDesktopVersion string
	MinDesktopVersion         string
}

// RecommendationRefresher fetches server-driven upgrade guidance for a given
// server key. The AgentTeam channel registers a concrete implementation that calls
// the workspace-scoped daemon upgrade-info endpoint; the upgrade package stays
// free of channel/HTTP wiring details.
type RecommendationRefresher func(ctx context.Context, serverKey string) (*ServerRecommendation, error)

// ServerKey identifies a connected AgentTeam server for cache binding. It is the
// normalized serverURL + workspaceID, both available before any fetch so the
// key can be determined at connect time.
type ServerKey string

// NormalizeServerKey builds a cache key from a server URL and workspace ID.
// Both are trimmed; the server URL is trailing-slash normalized.
func NormalizeServerKey(serverURL, workspaceID string) ServerKey {
	return ServerKey(strings.TrimRight(strings.TrimSpace(serverURL), "/") + "|" + strings.TrimSpace(workspaceID))
}

type cachedRecommendation struct {
	rec       *ServerRecommendation
	fetchedAt time.Time
	serverKey ServerKey
}

var (
	serverRecMu     sync.Mutex
	serverRecs      = make(map[ServerKey]*cachedRecommendation)
	activeServerKey ServerKey
	serverRefresher RecommendationRefresher
	serverInflight  = make(map[ServerKey]chan struct{})
)

// RegisterRecommendationRefresher installs the fetch callback used by
// EnsureServerRecommendation. The AgentTeam channel calls this once at startup.
// Pass nil to unregister.
func RegisterRecommendationRefresher(refresher RecommendationRefresher) {
	serverRecMu.Lock()
	defer serverRecMu.Unlock()
	serverRefresher = refresher
}

// SetActiveServer records which server the currently-connected channel talks
// to. CheckVersion uses this to look up the right cached recommendation.
// Setting an empty key clears the active pointer (e.g. on disconnect).
func SetActiveServer(key ServerKey) {
	serverRecMu.Lock()
	defer serverRecMu.Unlock()
	activeServerKey = key
}

// SetServerRecommendation caches guidance received from a successful fetch.
// The caller (channel) passes the server key it fetched for so guidance from
// one server is never reused for another.
func SetServerRecommendation(key ServerKey, rec *ServerRecommendation) {
	serverRecMu.Lock()
	defer serverRecMu.Unlock()
	if rec == nil {
		delete(serverRecs, key)
		return
	}
	serverRecs[key] = &cachedRecommendation{
		rec:       rec,
		fetchedAt: time.Now(),
		serverKey: key,
	}
}

// ClearServerRecommendation removes cached guidance for a server key. Called
// on disconnect so a subsequent CDN-only check cannot reuse a stale
// recommendation from a different server.
func ClearServerRecommendation(key ServerKey) {
	serverRecMu.Lock()
	defer serverRecMu.Unlock()
	delete(serverRecs, key)
}

// EnsureServerRecommendation returns the cached recommendation for the active
// server, or fetches it on demand when a refresher is registered and no cache
// entry exists yet. Concurrent callers for the same key share a single
// in-flight fetch. Returns nil (without error) when there is no active server
// or no refresher — CheckVersion then falls back to the CDN stable manifest.
func EnsureServerRecommendation(ctx context.Context) (*ServerRecommendation, error) {
	serverRecMu.Lock()
	key := activeServerKey
	if key == "" {
		serverRecMu.Unlock()
		return nil, nil
	}
	cached := serverRecs[key]
	if cached != nil {
		serverRecMu.Unlock()
		return cached.rec, nil
	}
	refresher := serverRefresher
	// Reuse an in-flight fetch for the same key, or start one.
	if wait, ok := serverInflight[key]; ok {
		serverRecMu.Unlock()
		select {
		case <-wait:
		case <-ctx.Done():
			return nil, ctx.Err()
		}
		serverRecMu.Lock()
		cached := serverRecs[key]
		serverRecMu.Unlock()
		if cached != nil {
			return cached.rec, nil
		}
		return nil, nil
	}
	if refresher == nil {
		serverRecMu.Unlock()
		return nil, nil
	}
	done := make(chan struct{})
	serverInflight[key] = done
	serverRecMu.Unlock()

	// Run the refresher in a goroutine so the caller can bail out on context
	// cancellation. The goroutine always completes and cleans up the in-flight
	// entry, even if the caller already gave up.
	type fetchResult struct {
		rec *ServerRecommendation
		err error
	}
	resultCh := make(chan fetchResult, 1)
	go func() {
		rec, err := refresher(ctx, string(key))
		resultCh <- fetchResult{rec: rec, err: err}
	}()

	var fr fetchResult
	select {
	case fr = <-resultCh:
	case <-ctx.Done():
		// Wait for the refresher goroutine to finish so it does not leak
		// mid-flight. The refresher itself should respect ctx cancellation;
		// this just avoids returning before the goroutine exits.
		go func() { <-resultCh }()
		serverRecMu.Lock()
		delete(serverInflight, key)
		// Signal completion only after the in-flight entry is removed so
		// waiters waking from <-done cannot re-seize the lock and observe a
		// missing cache entry mid-store.
		close(done)
		serverRecMu.Unlock()
		return nil, ctx.Err()
	}

	serverRecMu.Lock()
	delete(serverInflight, key)
	if fr.err != nil || fr.rec == nil {
		close(done)
		serverRecMu.Unlock()
		return nil, fr.err
	}
	serverRecs[key] = &cachedRecommendation{
		rec:       fr.rec,
		fetchedAt: time.Now(),
		serverKey: key,
	}
	result := serverRecs[key].rec
	close(done)
	serverRecMu.Unlock()
	return result, nil
}

type LaunchOptions struct {
	Version          string
	InstallScriptURL string
	ChecksumsURL     string
	ArchiveURL       string
	DaemonPID        int
	DaemonPort       int
	DaemonExecutable string
}

type LaunchResult struct {
	Version string `json:"version"`
	LogPath string `json:"log_path"`
	PID     int    `json:"pid"`
}

type daemonHealthPayload struct {
	OK bool `json:"ok"`
}

type ApplyOptions struct {
	Version          string
	InstallScriptURL string
	ChecksumsURL     string
	ArchiveURL       string
	DaemonPID        int
}

type DaemonController interface {
	IsRunning() (bool, int, error)
	Stop() error
	Start() error
	IsInstalled() bool
	WaitStop(timeout time.Duration)
}

type upgradePlan struct {
	Platform          string
	PlatformSupported bool
	Method            UpdateMethod
	RuntimeAssetURL   string
	RuntimeAssetKind  string
	ManualCommand     string
	Reason            string
}

type platformInfo struct {
	Key       string
	Supported bool
}

var currentPlatform = detectCurrentPlatform
var managedInstallDirWritable = func() bool {
	return isDirWritable(filepath.Dir(installpath.ManagedBinaryPath()))
}

type applyReleaseDeps struct {
	goos                  string
	runInstallScript      func(context.Context, string, string, string, string, string, string) error
	newServiceController  func(string) DaemonController
	snapshotManagedBinary func(string) (*managedBinaryBackup, error)
	restartManagedService func(string) error
	processExists         func(int) bool
	stopProcess           func(int) error
	waitForProcessExit    func(int, time.Duration) error
	startDetachedDaemon   func(string) (int, error)
	waitForDaemonHealthy  func(time.Duration) error
}

type stableManifestSource struct {
	url             string
	expectedProduct string
}

func CheckVersion(ctx context.Context, stableURL string) (*CheckResult, error) {
	// Resolve server-driven guidance before touching the CDN stable manifest.
	// When a server is connected, this either returns the cached recommendation
	// or triggers (and waits for) a fetch, so the upgrade target always reflects
	// the connected server's intent rather than racing with a background fetch.
	// Returns nil when no server is connected or no refresher is registered;
	// CheckVersion then falls back to the CDN stable manifest as before.
	rec, recErr := EnsureServerRecommendation(ctx)
	if recErr != nil {
		// A failed guidance fetch is non-fatal: fall back to CDN latest.
		rec = nil
	}

	sources := []stableManifestSource{{url: strings.TrimSpace(stableURL)}}
	if sources[0].url == "" {
		sources = []stableManifestSource{
			{url: DefaultStableURL, expectedProduct: ExpectedProduct},
			{url: LegacyStableURL, expectedProduct: LegacyExpectedProduct},
		}
	}
	return checkVersionFromSources(ctx, sources, rec)
}

func checkVersionFromSources(ctx context.Context, sources []stableManifestSource, rec *ServerRecommendation) (*CheckResult, error) {
	var failures []error
	for _, source := range sources {
		result, err := checkVersionAtURL(ctx, source, rec)
		if err == nil {
			return result, nil
		}
		failures = append(failures, err)
	}
	return nil, fmt.Errorf("check release manifests: %w", errors.Join(failures...))
}

func checkVersionAtURL(ctx context.Context, source stableManifestSource, rec *ServerRecommendation) (*CheckResult, error) {
	stableURL := source.url
	var stable StableManifest
	if err := fetchJSON(ctx, stableURL, &stable); err != nil {
		return nil, fmt.Errorf("fetch stable manifest %q: %w", stableURL, err)
	}
	if err := validateStableManifest(stable, source.expectedProduct); err != nil {
		return nil, err
	}

	current := version.BuildVersion()

	latest := strings.TrimSpace(stable.Current)
	manifestURL := strings.TrimSpace(stable.ManifestURL)
	if rec != nil && strings.TrimSpace(rec.RecommendedDesktopVersion) != "" {
		latest = ensureVersionPrefix(strings.TrimSpace(rec.RecommendedDesktopVersion))
		manifestURL = manifestURLForVersion(stable.ManifestURL, latest)
	}

	result := &CheckResult{
		Current:           current,
		Latest:            latest,
		HasUpdate:         compareVersions(latest, current) > 0,
		ManifestURL:       manifestURL,
		PublishedAt:       strings.TrimSpace(stable.PublishedAt),
		PlatformSupported: true,
	}
	if rec != nil {
		result.ServerVersion = strings.TrimSpace(rec.ServerVersion)
		result.MinDesktopVersion = strings.TrimSpace(rec.MinDesktopVersion)
	}

	if result.ManifestURL == "" {
		return nil, fmt.Errorf("stable manifest missing manifest_url for version %q", latest)
	}

	var manifest VersionManifest
	if err := fetchJSON(ctx, result.ManifestURL, &manifest); err != nil {
		return nil, fmt.Errorf("fetch version manifest %q: %w", result.ManifestURL, err)
	}
	if err := validateVersionManifest(manifest, latest, stable.Product); err != nil {
		return nil, err
	}
	result.InstallScriptURL = strings.TrimSpace(manifest.Client.InstallScript)
	if result.InstallScriptURL == "" {
		result.InstallScriptURL = DefaultInstallScriptURL
	}
	result.ChecksumsURL = strings.TrimSpace(manifest.Client.Checksums)

	plan := resolveUpgradePlan(manifest, latest)
	result.Platform = plan.Platform
	result.PlatformSupported = plan.PlatformSupported
	result.UpdateMethod = plan.Method
	result.CanAutoUpgrade = plan.Method == UpdateMethodAutomatic
	result.RuntimeAssetURL = plan.RuntimeAssetURL
	result.RuntimeAssetKind = plan.RuntimeAssetKind
	result.ManualCommand = plan.ManualCommand
	result.IneligibilityReason = plan.Reason
	return result, nil
}

// manifestURLForVersion derives the per-version manifest URL from the stable
// manifest's manifest_url by replacing the trailing <version>.json segment.
// Stable manifest URL pattern: <base>/agentteam/releases/desktop/stable.json
// Version manifest URL pattern: <base>/agentteam/releases/desktop/<version>.json
func manifestURLForVersion(stableManifestURL string, version string) string {
	stableManifestURL = strings.TrimSpace(stableManifestURL)
	version = strings.TrimSpace(version)
	if stableManifestURL == "" || version == "" {
		return stableManifestURL
	}
	idx := strings.LastIndex(stableManifestURL, "/")
	if idx < 0 {
		return stableManifestURL
	}
	return stableManifestURL[:idx+1] + version + ".json"
}

func ensureVersionPrefix(version string) string {
	trimmed := strings.TrimSpace(version)
	if trimmed == "" {
		return ""
	}
	if strings.HasPrefix(trimmed, "v") || strings.HasPrefix(trimmed, "V") {
		return trimmed
	}
	return "v" + trimmed
}

func StartBackgroundUpgrade(opts LaunchOptions) (*LaunchResult, error) {
	if !SupportsHeadlessUpgrade(runtime.GOOS) {
		return nil, fmt.Errorf("Go self-upgrade is supported only on Linux; %s application updates are owned by Electron", runtime.GOOS)
	}
	if err := validateVersion(opts.Version); err != nil {
		return nil, err
	}
	installScriptURL := strings.TrimSpace(opts.InstallScriptURL)
	if installScriptURL == "" {
		installScriptURL = DefaultInstallScriptURL
	}

	exePath, err := os.Executable()
	if err != nil {
		return nil, fmt.Errorf("resolve current executable: %w", err)
	}
	realExe, err := filepath.EvalSymlinks(exePath)
	if err != nil {
		return nil, fmt.Errorf("resolve executable symlink %q: %w", exePath, err)
	}
	helperExe, err := installpath.EnsureManagedBinary(realExe)
	if err != nil {
		return nil, fmt.Errorf("prepare managed agentteam binary for upgrade helper: %w", err)
	}

	logPath := filepath.Join(profile.ConfigDir(), "upgrade.log")
	if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil {
		return nil, fmt.Errorf("create upgrade log directory: %w", err)
	}
	logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
	if err != nil {
		return nil, fmt.Errorf("open upgrade log %q: %w", logPath, err)
	}
	defer logFile.Close()

	args := []string{"upgrade", "apply", "--version", opts.Version, "--install-script-url", installScriptURL}
	if strings.TrimSpace(opts.ChecksumsURL) != "" {
		args = append(args, "--checksums-url", strings.TrimSpace(opts.ChecksumsURL))
	}
	if strings.TrimSpace(opts.ArchiveURL) != "" {
		args = append(args, "--archive-url", strings.TrimSpace(opts.ArchiveURL))
	}
	if opts.DaemonPID > 0 {
		args = append(args, "--daemon-pid", strconv.Itoa(opts.DaemonPID))
	}
	cmd := exec.Command(helperExe, args...)
	cmd.Stdout = logFile
	cmd.Stderr = logFile
	cmd.Stdin = nil
	configureDetachedCommand(cmd)
	if err := cmd.Start(); err != nil {
		return nil, fmt.Errorf("start detached upgrade helper: %w", err)
	}

	return &LaunchResult{
		Version: opts.Version,
		LogPath: logPath,
		PID:     cmd.Process.Pid,
	}, nil
}

func upgradeLockAvailable() bool {
	lock, err := acquireUpgradeLock(profile.ConfigDir())
	if err != nil {
		return false
	}
	_ = lock.Close()
	return true
}

func ApplyRelease(ctx context.Context, opts ApplyOptions) error {
	return applyRelease(ctx, opts, applyReleaseDeps{
		goos:                  runtime.GOOS,
		runInstallScript:      runInstallScript,
		newServiceController:  NewServiceController,
		snapshotManagedBinary: snapshotManagedBinary,
		restartManagedService: restartManagedService,
		processExists:         processExists,
		stopProcess:           stopProcess,
		waitForProcessExit:    waitForProcessExit,
		startDetachedDaemon:   startDetachedDaemon,
		waitForDaemonHealthy:  waitForDaemonHealthy,
	})
}

func applyRelease(ctx context.Context, opts ApplyOptions, deps applyReleaseDeps) error {
	if !SupportsHeadlessUpgrade(deps.goos) {
		return fmt.Errorf("Go self-upgrade is supported only on Linux; %s application updates are owned by Electron", deps.goos)
	}
	lock, err := acquireUpgradeLock(profile.ConfigDir())
	if err != nil {
		return err
	}
	defer lock.Close()
	if err := validateVersion(opts.Version); err != nil {
		return err
	}
	installScriptURL := strings.TrimSpace(opts.InstallScriptURL)
	if installScriptURL == "" {
		installScriptURL = DefaultInstallScriptURL
	}
	checksumsURL := strings.TrimSpace(opts.ChecksumsURL)
	archiveURL := strings.TrimSpace(opts.ArchiveURL)

	if _, err := fmt.Fprintf(os.Stdout, "agentteam upgrade helper: preparing upgrade to %s\n", opts.Version); err != nil {
		return fmt.Errorf("write upgrade log header: %w", err)
	}
	if helperDelay > 0 {
		timer := time.NewTimer(helperDelay)
		defer timer.Stop()
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-timer.C:
		}
	}

	managedExe := installpath.ManagedBinaryPath()
	installDir := filepath.Dir(managedExe)
	binaryName := filepath.Base(managedExe)
	backup, err := deps.snapshotManagedBinary(managedExe)
	if err != nil {
		return err
	}
	defer backup.cleanup()

	installerPath, cleanup, err := downloadInstallScript(ctx, installScriptURL)
	if err != nil {
		return err
	}
	defer cleanup()

	if err := deps.runInstallScript(ctx, installerPath, opts.Version, installDir, binaryName, archiveURL, checksumsURL); err != nil {
		return restoreManagedBinaryAfterFailure(backup, err)
	}

	controller := deps.newServiceController(managedExe)
	serviceInstalled := controller.IsInstalled()
	serviceRunning, _, _ := controller.IsRunning()

	if serviceInstalled && serviceRunning {
		fmt.Fprintln(os.Stdout, "agentteam upgrade helper: restarting managed service")
		if err := deps.restartManagedService(managedExe); err != nil {
			return restoreManagedServiceAfterRestartFailure(controller, backup, err)
		}
		if err := deps.waitForDaemonHealthy(20 * time.Second); err != nil {
			healthErr := fmt.Errorf("managed service health check failed after upgrade: %w", err)
			return restoreManagedServiceAfterHealthFailure(controller, backup, healthErr)
		}
		fmt.Fprintln(os.Stdout, "agentteam upgrade helper: managed service restarted")
		return nil
	}

	if opts.DaemonPID > 0 && deps.processExists(opts.DaemonPID) {
		fmt.Fprintf(os.Stdout, "agentteam upgrade helper: restarting daemon pid %d\n", opts.DaemonPID)
		if err := deps.stopProcess(opts.DaemonPID); err != nil {
			return restoreManagedBinaryAfterFailure(backup, err)
		}
		if err := deps.waitForProcessExit(opts.DaemonPID, 15*time.Second); err != nil {
			return restoreManagedBinaryAfterFailure(backup, err)
		}
		newPID, err := deps.startDetachedDaemon(managedExe)
		if err != nil {
			return deps.restoreDaemonAfterStartFailure(backup, err, managedExe)
		}
		if err := deps.waitForDaemonHealthy(20 * time.Second); err != nil {
			if newPID > 0 {
				_ = deps.stopProcess(newPID)
				_ = deps.waitForProcessExit(newPID, 5*time.Second)
			}
			healthErr := fmt.Errorf("daemon health check failed after upgrade: %w", err)
			if restoreErr := backup.restore(); restoreErr != nil {
				return rollbackFailureError(healthErr, restoreErr)
			}
			if _, restartErr := deps.startDetachedDaemon(managedExe); restartErr != nil {
				return fmt.Errorf("%w; rollback restart failed: %v", healthErr, restartErr)
			}
			return healthErr
		}
		fmt.Fprintln(os.Stdout, "agentteam upgrade helper: daemon restarted")
	}

	return nil
}

func restoreManagedBinaryAfterFailure(backup *managedBinaryBackup, cause error) error {
	if restoreErr := backup.restore(); restoreErr != nil {
		return rollbackFailureError(cause, restoreErr)
	}
	return cause
}

func restoreManagedServiceAfterRestartFailure(controller DaemonController, backup *managedBinaryBackup, cause error) error {
	if restoreErr := backup.restore(); restoreErr != nil {
		return rollbackFailureError(cause, restoreErr)
	}
	if running, _, _ := controller.IsRunning(); running {
		return cause
	}
	if restartErr := controller.Start(); restartErr != nil {
		return fmt.Errorf("%w; rollback restart failed: %v", cause, restartErr)
	}
	return cause
}

func restoreManagedServiceAfterHealthFailure(controller DaemonController, backup *managedBinaryBackup, cause error) error {
	if err := controller.Stop(); err != nil {
		if running, _, _ := controller.IsRunning(); running {
			return fmt.Errorf("%w; rollback stop failed: %v", cause, err)
		}
	}
	controller.WaitStop(15 * time.Second)
	if running, _, _ := controller.IsRunning(); running {
		return fmt.Errorf("%w; rollback stop timed out", cause)
	}
	if restoreErr := backup.restore(); restoreErr != nil {
		return rollbackFailureError(cause, restoreErr)
	}
	if restartErr := controller.Start(); restartErr != nil {
		return fmt.Errorf("%w; rollback restart failed: %v", cause, restartErr)
	}
	return cause
}

func (d applyReleaseDeps) restoreDaemonAfterStartFailure(backup *managedBinaryBackup, cause error, managedExe string) error {
	if restoreErr := backup.restore(); restoreErr != nil {
		return rollbackFailureError(cause, restoreErr)
	}
	if _, restartErr := d.startDetachedDaemon(managedExe); restartErr != nil {
		return fmt.Errorf("%w; rollback restart failed: %v", cause, restartErr)
	}
	return cause
}

func rollbackFailureError(cause, restoreErr error) error {
	return fmt.Errorf("%w; rollback failed: %v", cause, restoreErr)
}

func restartManagedService(realExe string) error {
	controller := NewServiceController(realExe)
	if err := controller.Stop(); err != nil {
		return fmt.Errorf("stop managed service after install: %w", err)
	}
	controller.WaitStop(15 * time.Second)
	if err := controller.Start(); err != nil {
		return fmt.Errorf("start managed service after install: %w", err)
	}
	return nil
}

func downloadInstallScript(ctx context.Context, scriptURL string) (string, func(), error) {
	ctx, cancel := context.WithTimeout(ctx, installerDownloadTimeout)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, scriptURL, nil)
	if err != nil {
		return "", nil, fmt.Errorf("create install script request: %w", err)
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", nil, fmt.Errorf("download install script %q: %w", scriptURL, err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return "", nil, fmt.Errorf("download install script %q returned HTTP %d", scriptURL, resp.StatusCode)
	}

	f, err := os.CreateTemp("", "agentteam-install-*.sh")
	if err != nil {
		return "", nil, fmt.Errorf("create temp install script: %w", err)
	}
	if _, err := io.Copy(f, io.LimitReader(resp.Body, 2<<20)); err != nil {
		f.Close()
		os.Remove(f.Name())
		return "", nil, fmt.Errorf("write temp install script: %w", err)
	}
	if err := f.Close(); err != nil {
		os.Remove(f.Name())
		return "", nil, fmt.Errorf("close temp install script: %w", err)
	}
	if err := os.Chmod(f.Name(), 0755); err != nil {
		os.Remove(f.Name())
		return "", nil, fmt.Errorf("chmod temp install script: %w", err)
	}
	cleanup := func() { _ = os.Remove(f.Name()) }
	return f.Name(), cleanup, nil
}

func runInstallScript(ctx context.Context, scriptPath, versionTag, installDir, binaryName, archiveURL, checksumsURL string) error {
	fmt.Fprintf(os.Stdout, "agentteam upgrade helper: running installer for %s\n", versionTag)
	cmd := exec.CommandContext(ctx, "bash", scriptPath,
		"--version", versionTag,
		"--install-dir", installDir,
		"--binary", binaryName,
	)
	if archiveURL != "" {
		cmd.Args = append(cmd.Args, "--archive-url", archiveURL)
	}
	if checksumsURL != "" {
		cmd.Args = append(cmd.Args, "--checksums-url", checksumsURL)
	}
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	cmd.Stdin = nil
	if err := cmd.Run(); err != nil {
		return fmt.Errorf("install version %q to %q failed: %w", versionTag, filepath.Join(installDir, binaryName), err)
	}
	return nil
}

func processExists(pid int) bool {
	if pid <= 0 {
		return false
	}
	process, err := os.FindProcess(pid)
	if err != nil {
		return false
	}
	err = process.Signal(syscall.Signal(0))
	return err == nil || errors.Is(err, syscall.EPERM)
}

func stopProcess(pid int) error {
	if pid <= 0 {
		return nil
	}
	process, err := os.FindProcess(pid)
	if err != nil {
		return fmt.Errorf("find daemon process %d: %w", pid, err)
	}
	if err := process.Signal(syscall.SIGTERM); err != nil && !errors.Is(err, os.ErrProcessDone) {
		return fmt.Errorf("signal daemon process %d: %w", pid, err)
	}
	return nil
}

func pollUntil(timeout, interval time.Duration, condition func() bool) bool {
	if condition() {
		return true
	}
	if timeout <= 0 {
		return false
	}
	timer := time.NewTimer(timeout)
	defer timer.Stop()
	ticker := time.NewTicker(interval)
	defer ticker.Stop()

	for {
		select {
		case <-ticker.C:
			if condition() {
				return true
			}
		case <-timer.C:
			return false
		}
	}
}

func waitForProcessExit(pid int, timeout time.Duration) error {
	if pollUntil(timeout, 300*time.Millisecond, func() bool {
		return !processExists(pid)
	}) {
		return nil
	}
	return fmt.Errorf("daemon process %d did not exit within %s", pid, timeout)
}

func startDetachedDaemon(exePath string) (int, error) {
	cmd := exec.Command(exePath, "daemon")
	cmd.Stdout = nil
	cmd.Stderr = nil
	cmd.Stdin = nil
	configureDetachedCommand(cmd)
	if err := cmd.Start(); err != nil {
		return 0, fmt.Errorf("start upgraded daemon %q: %w", exePath, err)
	}
	return cmd.Process.Pid, nil
}

type managedBinaryBackup struct {
	targetPath        string
	backupPath        string
	existed           bool
	runtimeTargetPath string
	runtimeBackupPath string
	runtimeExisted    bool
}

func snapshotManagedBinary(targetPath string) (*managedBinaryBackup, error) {
	backup := &managedBinaryBackup{
		targetPath:        targetPath,
		runtimeTargetPath: filepath.Join(filepath.Dir(targetPath), "runtimes"),
	}
	info, err := os.Stat(targetPath)
	if errors.Is(err, os.ErrNotExist) {
		if err := backup.snapshotRuntimeTree(); err != nil {
			return nil, err
		}
		return backup, nil
	}
	if err != nil {
		return nil, fmt.Errorf("stat managed binary before upgrade: %w", err)
	}
	if !info.Mode().IsRegular() {
		return nil, fmt.Errorf("managed binary %q is not a regular file", targetPath)
	}

	tempFile, err := os.CreateTemp(filepath.Dir(targetPath), ".agentteam-upgrade-backup-*")
	if err != nil {
		return nil, fmt.Errorf("create managed binary backup: %w", err)
	}
	backupPath := tempFile.Name()
	if err := tempFile.Close(); err != nil {
		_ = os.Remove(backupPath)
		return nil, fmt.Errorf("close managed binary backup: %w", err)
	}
	if err := copyFileMode(targetPath, backupPath, info.Mode()); err != nil {
		_ = os.Remove(backupPath)
		return nil, fmt.Errorf("copy managed binary backup: %w", err)
	}

	backup.backupPath = backupPath
	backup.existed = true
	if err := backup.snapshotRuntimeTree(); err != nil {
		backup.cleanup()
		return nil, err
	}
	return backup, nil
}

func (b *managedBinaryBackup) snapshotRuntimeTree() error {
	info, err := os.Stat(b.runtimeTargetPath)
	if errors.Is(err, os.ErrNotExist) {
		return nil
	}
	if err != nil {
		return fmt.Errorf("stat managed runtimes before upgrade: %w", err)
	}
	if !info.IsDir() {
		return fmt.Errorf("managed runtimes %q is not a directory", b.runtimeTargetPath)
	}

	backupPath, err := os.MkdirTemp(filepath.Dir(b.targetPath), ".agentteam-upgrade-runtimes-*")
	if err != nil {
		return fmt.Errorf("create managed runtimes backup: %w", err)
	}
	if err := copyDirectory(b.runtimeTargetPath, backupPath); err != nil {
		_ = os.RemoveAll(backupPath)
		return fmt.Errorf("copy managed runtimes backup: %w", err)
	}
	b.runtimeBackupPath = backupPath
	b.runtimeExisted = true
	return nil
}

func (b *managedBinaryBackup) restore() error {
	if b == nil || strings.TrimSpace(b.targetPath) == "" {
		return nil
	}
	if !b.existed {
		if err := os.Remove(b.targetPath); err != nil && !errors.Is(err, os.ErrNotExist) {
			return fmt.Errorf("remove failed new managed binary: %w", err)
		}
		return b.restoreRuntimeTree()
	}
	info, err := os.Stat(b.backupPath)
	if err != nil {
		return fmt.Errorf("stat managed binary backup: %w", err)
	}
	if err := copyFileMode(b.backupPath, b.targetPath, info.Mode()); err != nil {
		return fmt.Errorf("restore previous managed binary: %w", err)
	}
	return b.restoreRuntimeTree()
}

func (b *managedBinaryBackup) restoreRuntimeTree() error {
	if strings.TrimSpace(b.runtimeTargetPath) == "" {
		return nil
	}
	if err := os.RemoveAll(b.runtimeTargetPath); err != nil {
		return fmt.Errorf("remove upgraded managed runtimes: %w", err)
	}
	if !b.runtimeExisted {
		return nil
	}
	if err := copyDirectory(b.runtimeBackupPath, b.runtimeTargetPath); err != nil {
		return fmt.Errorf("restore previous managed runtimes: %w", err)
	}
	return nil
}

func (b *managedBinaryBackup) cleanup() {
	if b != nil && b.backupPath != "" {
		_ = os.Remove(b.backupPath)
	}
	if b != nil && b.runtimeBackupPath != "" {
		_ = os.RemoveAll(b.runtimeBackupPath)
	}
}

func copyFileMode(sourcePath, targetPath string, mode os.FileMode) error {
	source, err := os.Open(sourcePath)
	if err != nil {
		return fmt.Errorf("open %q: %w", sourcePath, err)
	}
	defer source.Close()

	target, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
	if err != nil {
		return fmt.Errorf("open %q: %w", targetPath, err)
	}
	if _, err := io.Copy(target, source); err != nil {
		_ = target.Close()
		return fmt.Errorf("copy %q to %q: %w", sourcePath, targetPath, err)
	}
	if err := target.Chmod(mode); err != nil {
		_ = target.Close()
		return fmt.Errorf("chmod %q: %w", targetPath, err)
	}
	if err := target.Close(); err != nil {
		return fmt.Errorf("close %q: %w", targetPath, err)
	}
	return nil
}

func copyDirectory(sourceDir, targetDir string) error {
	sourceInfo, err := os.Stat(sourceDir)
	if err != nil {
		return err
	}
	if !sourceInfo.IsDir() {
		return fmt.Errorf("%q is not a directory", sourceDir)
	}
	if err := os.MkdirAll(targetDir, sourceInfo.Mode().Perm()|0755); err != nil {
		return err
	}

	return filepath.WalkDir(sourceDir, func(path string, entry fs.DirEntry, walkErr error) error {
		if walkErr != nil {
			return walkErr
		}
		rel, err := filepath.Rel(sourceDir, path)
		if err != nil {
			return err
		}
		if rel == "." {
			return nil
		}
		targetPath := filepath.Join(targetDir, rel)
		info, err := entry.Info()
		if err != nil {
			return err
		}
		mode := info.Mode()
		if entry.IsDir() {
			if err := os.MkdirAll(targetPath, mode.Perm()|0755); err != nil {
				return err
			}
			return os.Chmod(targetPath, mode.Perm()|0755)
		}
		if mode&os.ModeSymlink != 0 {
			linkTarget, err := os.Readlink(path)
			if err != nil {
				return err
			}
			return os.Symlink(linkTarget, targetPath)
		}
		if !mode.IsRegular() {
			return fmt.Errorf("unsupported runtime artifact %q with mode %s", path, mode)
		}
		return copyFileMode(path, targetPath, mode.Perm())
	})
}

func waitForDaemonHealthy(timeout time.Duration) error {
	if pollUntil(timeout, 500*time.Millisecond, daemonHealthOK) {
		return nil
	}
	return fmt.Errorf("daemon did not become healthy within %s", timeout)
}

func daemonHealthOK() bool {
	cfg, err := profile.Load(profile.Path())
	if err != nil || cfg == nil {
		cfg = profile.DefaultConfig()
	}
	host := strings.TrimSpace(cfg.Host)
	switch host {
	case "", "0.0.0.0", "::", "[::]":
		host = profile.DefaultHost
	}

	client := &http.Client{Timeout: 2 * time.Second}
	resp, err := client.Get(fmt.Sprintf("http://%s:%d/api/health", host, cfg.Port))
	if err != nil {
		return false
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK || !strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "application/json") {
		return false
	}
	var payload daemonHealthPayload
	if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
		return false
	}
	return payload.OK
}

func fetchJSON(ctx context.Context, url string, target any) error {
	ctx, cancel := context.WithTimeout(ctx, checkTimeout)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return fmt.Errorf("create request: %w", err)
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return fmt.Errorf("http get: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("http %d", resp.StatusCode)
	}
	if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(target); err != nil {
		return fmt.Errorf("decode json: %w", err)
	}
	return nil
}

func isValidProduct(product string) bool {
	switch strings.TrimSpace(product) {
	case ExpectedProduct, LegacyExpectedProduct:
		return true
	default:
		return false
	}
}

func validateStableManifest(stable StableManifest, expectedProduct string) error {
	if strings.TrimSpace(stable.SchemaVersion) == "" {
		return fmt.Errorf("stable manifest missing schema_version")
	}
	if !isValidProduct(stable.Product) {
		return fmt.Errorf(
			"stable manifest product %q is not one of %q or %q",
			stable.Product,
			ExpectedProduct,
			LegacyExpectedProduct,
		)
	}
	if expectedProduct != "" && strings.TrimSpace(stable.Product) != expectedProduct {
		return fmt.Errorf("stable manifest product %q does not match source product %q", stable.Product, expectedProduct)
	}
	if err := validateVersion(stable.Current); err != nil {
		return fmt.Errorf("stable manifest current_version: %w", err)
	}
	if strings.TrimSpace(stable.ManifestURL) == "" {
		return fmt.Errorf("stable manifest missing manifest_url")
	}
	return nil
}

func validateVersionManifest(manifest VersionManifest, expectedVersion string, expectedProduct string) error {
	if strings.TrimSpace(manifest.SchemaVersion) == "" {
		return fmt.Errorf("version manifest missing schema_version")
	}
	if strings.TrimSpace(manifest.Product) != strings.TrimSpace(expectedProduct) {
		return fmt.Errorf("version manifest product %q does not match stable product %q", manifest.Product, expectedProduct)
	}
	if err := validateVersion(manifest.Version); err != nil {
		return fmt.Errorf("version manifest version: %w", err)
	}
	if manifest.Version != expectedVersion {
		return fmt.Errorf("version manifest version %q does not match stable version %q", manifest.Version, expectedVersion)
	}
	if strings.TrimSpace(manifest.ManifestURL) == "" {
		return fmt.Errorf("version manifest missing manifest_url")
	}
	if manifestNeedsChecksums(manifest.Client) && strings.TrimSpace(manifest.Client.Checksums) == "" {
		return fmt.Errorf("version manifest missing client.checksums")
	}
	for i, asset := range manifest.Client.Assets {
		if strings.TrimSpace(asset.URL) == "" {
			return fmt.Errorf("version manifest asset[%d] missing url", i)
		}
		if normalizeAssetPurpose(asset) == clientAssetPurposeRuntime &&
			!isRuntimeAssetKind(normalizeAssetKind(asset.Kind)) {
			return fmt.Errorf("version manifest asset[%d] has unsupported runtime kind %q", i, asset.Kind)
		}
		if strings.TrimSpace(asset.SHA256) != "" && !validSHA256(asset.SHA256) {
			return fmt.Errorf("version manifest asset[%d] has invalid sha256", i)
		}
	}
	return nil
}

func manifestNeedsChecksums(client ClientManifest) bool {
	if len(client.Assets) == 0 {
		return true
	}
	for _, asset := range client.Assets {
		if normalizeAssetPurpose(asset) == clientAssetPurposeRuntime {
			return true
		}
	}
	return false
}

func resolveUpgradePlan(manifest VersionManifest, latest string) upgradePlan {
	platform := currentPlatform()
	plan := upgradePlan{
		Platform:          platform.Key,
		PlatformSupported: platform.Supported,
		Method:            UpdateMethodUnsupported,
	}
	if !platform.Supported {
		plan.Reason = fmt.Sprintf("self-upgrade is not supported on %s", platform.Key)
		return plan
	}

	runtimeAsset := selectPlatformRuntimeAsset(manifest.Client.Assets, platform.Key)
	if runtimeAsset == nil && isLegacyRuntimePlatform(platform.Key) {
		runtimeAsset = &ClientAsset{
			Platform: platform.Key,
			Purpose:  clientAssetPurposeRuntime,
			Kind:     clientAssetKindTarball,
		}
	}

	if runtimeAsset != nil {
		plan.RuntimeAssetURL = strings.TrimSpace(runtimeAsset.URL)
		plan.RuntimeAssetKind = normalizeAssetKind(runtimeAsset.Kind)
		if plan.RuntimeAssetKind == "" {
			plan.RuntimeAssetKind = clientAssetKindTarball
		}
		if managedInstallDirWritable() {
			plan.Method = UpdateMethodAutomatic
			return plan
		}
		plan.Method = UpdateMethodManualCommand
		plan.ManualCommand = VersionInstallCommand(latest)
		plan.Reason = "current install directory is not writable; run the upgrade command in your terminal"
		return plan
	}

	plan.Reason = fmt.Sprintf("release metadata does not declare a compatible asset for %s", platform.Key)
	return plan
}

func detectCurrentPlatform() platformInfo {
	arch := runtime.GOARCH
	switch arch {
	case "amd64", "x86_64":
		arch = "amd64"
	case "arm64", "aarch64":
		arch = "arm64"
	}

	key := fmt.Sprintf("%s-%s", runtime.GOOS, arch)
	switch key {
	case "linux-amd64", "linux-arm64":
		return platformInfo{Key: key, Supported: true}
	default:
		return platformInfo{Key: key, Supported: false}
	}
}

func selectPlatformRuntimeAsset(assets []ClientAsset, platform string) *ClientAsset {
	for i := range assets {
		asset := assets[i]
		if assetPlatform(asset) != platform {
			continue
		}
		if normalizeAssetPurpose(asset) == clientAssetPurposeRuntime {
			return &assets[i]
		}
	}
	return nil
}

func assetPlatform(asset ClientAsset) string {
	if platform := strings.ToLower(strings.TrimSpace(asset.Platform)); platform != "" {
		return platform
	}
	osName := strings.ToLower(strings.TrimSpace(asset.OS))
	arch := strings.ToLower(strings.TrimSpace(asset.Arch))
	if osName == "" || arch == "" {
		return ""
	}
	switch arch {
	case "x86_64":
		arch = "amd64"
	case "aarch64":
		arch = "arm64"
	}
	return osName + "-" + arch
}

func normalizeAssetPurpose(asset ClientAsset) string {
	if purpose := strings.ToLower(strings.TrimSpace(asset.Purpose)); purpose != "" {
		return purpose
	}
	if isRuntimeAssetKind(normalizeAssetKind(asset.Kind)) {
		return clientAssetPurposeRuntime
	}
	return ""
}

func normalizeAssetKind(kind string) string {
	switch strings.ToLower(strings.TrimSpace(kind)) {
	case "", "archive", "tarball", "tgz":
		return clientAssetKindTarball
	default:
		return strings.ToLower(strings.TrimSpace(kind))
	}
}

func isRuntimeAssetKind(kind string) bool {
	return kind == clientAssetKindTarball
}

func isLegacyRuntimePlatform(platform string) bool {
	switch platform {
	case "linux-amd64", "linux-arm64":
		return true
	default:
		return false
	}
}

func StableInstallCommand() string {
	return fmt.Sprintf("curl -fsSL %s | bash", DefaultInstallScriptURL)
}

func VersionInstallCommand(versionTag string) string {
	if strings.TrimSpace(versionTag) == "" {
		return StableInstallCommand()
	}
	return fmt.Sprintf("%s -s -- --version %s", StableInstallCommand(), strings.TrimSpace(versionTag))
}

func isDirWritable(dir string) bool {
	if strings.TrimSpace(dir) == "" {
		return false
	}
	if err := os.MkdirAll(dir, 0755); err != nil {
		return false
	}
	testFile, err := os.CreateTemp(dir, ".agentteam-write-check-*")
	if err != nil {
		return false
	}
	name := testFile.Name()
	_ = testFile.Close()
	_ = os.Remove(name)
	return true
}

func validateVersion(v string) error {
	trimmed := strings.TrimSpace(v)
	if trimmed == "" {
		return fmt.Errorf("version is empty")
	}
	if !versionPattern.MatchString(trimmed) {
		return fmt.Errorf("version %q is invalid", v)
	}
	return nil
}

func compareVersions(a, b string) int {
	a = strings.TrimPrefix(strings.TrimSpace(a), "v")
	b = strings.TrimPrefix(strings.TrimSpace(b), "v")

	aClean, aPre := splitVersion(a)
	bClean, bPre := splitVersion(b)

	cmp := compareSemverParts(aClean, bClean)
	if cmp != 0 {
		return cmp
	}

	return comparePrerelease(aPre, bPre)
}

func splitVersion(v string) (clean, pre string) {
	buildStripped := v
	if idx := strings.Index(buildStripped, "+"); idx >= 0 {
		buildStripped = buildStripped[:idx]
	}
	if idx := strings.Index(buildStripped, "-"); idx >= 0 {
		return buildStripped[:idx], trimGitDescribePrerelease(buildStripped[idx+1:])
	}
	return buildStripped, ""
}

func trimGitDescribePrerelease(pre string) string {
	parts := strings.Split(pre, "-")
	if len(parts) >= 2 && strings.HasPrefix(parts[len(parts)-1], "g") && isHex(parts[len(parts)-1][1:]) && isDigits(parts[len(parts)-2]) {
		return strings.Join(parts[:len(parts)-2], "-")
	}
	if len(parts) >= 3 && parts[len(parts)-1] == "dirty" && strings.HasPrefix(parts[len(parts)-2], "g") && isHex(parts[len(parts)-2][1:]) && isDigits(parts[len(parts)-3]) {
		return strings.Join(parts[:len(parts)-3], "-")
	}
	return pre
}

func compareSemverParts(a, b string) int {
	aParts := strings.SplitN(a, ".", 3)
	bParts := strings.SplitN(b, ".", 3)

	maxLen := len(aParts)
	if len(bParts) > maxLen {
		maxLen = len(bParts)
	}

	for i := 0; i < maxLen; i++ {
		aVal := 0
		bVal := 0

		if i < len(aParts) {
			aVal = parseVersionInt(aParts[i])
		}
		if i < len(bParts) {
			bVal = parseVersionInt(bParts[i])
		}

		if aVal < bVal {
			return -1
		}
		if aVal > bVal {
			return 1
		}
	}
	return 0
}

func parseVersionInt(s string) int {
	n := 0
	for _, ch := range s {
		if ch < '0' || ch > '9' {
			break
		}
		n = n*10 + int(ch-'0')
	}
	return n
}

func comparePrerelease(a, b string) int {
	if a == "" && b != "" {
		return 1
	}
	if a != "" && b == "" {
		return -1
	}
	if a == b {
		return 0
	}

	aParts := strings.Split(a, ".")
	bParts := strings.Split(b, ".")
	maxLen := len(aParts)
	if len(bParts) > maxLen {
		maxLen = len(bParts)
	}

	for i := 0; i < maxLen; i++ {
		if i >= len(aParts) {
			return -1
		}
		if i >= len(bParts) {
			return 1
		}

		cmp := comparePrereleaseIdentifier(aParts[i], bParts[i])
		if cmp != 0 {
			return cmp
		}
	}
	return 0
}

func comparePrereleaseIdentifier(a, b string) int {
	aNum, aIsNum := parseNumericIdentifier(a)
	bNum, bIsNum := parseNumericIdentifier(b)

	switch {
	case aIsNum && bIsNum:
		switch {
		case aNum < bNum:
			return -1
		case aNum > bNum:
			return 1
		default:
			return 0
		}
	case aIsNum:
		return -1
	case bIsNum:
		return 1
	case a < b:
		return -1
	case a > b:
		return 1
	default:
		return 0
	}
}

func parseNumericIdentifier(s string) (int, bool) {
	if s == "" {
		return 0, false
	}

	n := 0
	for _, ch := range s {
		if ch < '0' || ch > '9' {
			return 0, false
		}
		n = n*10 + int(ch-'0')
	}
	return n, true
}

func isDigits(s string) bool {
	if s == "" {
		return false
	}
	for _, ch := range s {
		if ch < '0' || ch > '9' {
			return false
		}
	}
	return true
}

func isHex(s string) bool {
	if s == "" {
		return false
	}
	for _, ch := range s {
		if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') {
			continue
		}
		return false
	}
	return true
}

type execServiceController struct {
	binPath string
}

func NewServiceController(binPath string) DaemonController {
	return &execServiceController{binPath: binPath}
}

func (c *execServiceController) IsRunning() (bool, int, error) {
	receipt, err := daemoncontrol.DefaultReceiptStore().Read()
	if errors.Is(err, daemoncontrol.ErrReceiptMissing) {
		return false, 0, nil
	}
	if err != nil {
		return false, 0, err
	}
	process, found := proc.ProcessByPID(receipt.PID)
	if !found || !receipt.Identity().Matches(process) {
		return false, 0, nil
	}
	return true, receipt.PID, nil
}

func (c *execServiceController) Stop() error {
	switch runtime.GOOS {
	case "linux":
		return exec.Command("systemctl", "stop", "agentteam.service").Run()
	case "windows":
		return exec.Command("schtasks", "/End", "/TN", "AgentTeam").Run()
	default:
		return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
	}
}

func (c *execServiceController) Start() error {
	switch runtime.GOOS {
	case "linux":
		return exec.Command("systemctl", "start", "agentteam.service").Run()
	case "windows":
		return exec.Command("schtasks", "/Run", "/TN", "AgentTeam").Run()
	default:
		return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
	}
}

func (c *execServiceController) IsInstalled() bool {
	switch runtime.GOOS {
	case "linux":
		_, err := os.Stat("/etc/systemd/system/agentteam.service")
		return err == nil
	case "windows":
		err := exec.Command("schtasks", "/Query", "/TN", "AgentTeam").Run()
		return err == nil
	default:
		return false
	}
}

func (c *execServiceController) WaitStop(timeout time.Duration) {
	pollUntil(timeout, 500*time.Millisecond, func() bool {
		running, _, _ := c.IsRunning()
		return !running
	})
}
