// Package update implements the `vocat update` self-updater. It queries the
// GitHub Releases API for a newer build, downloads the matching Linux binary
// for the current architecture, verifies it against a published SHA256SUMS,
// atomically replaces the running binary on disk, and restarts the vocat
// systemd unit.
//
// Trust model: GitHub TLS guarantees the channel; the repository owner controls
// which assets are published; SHA256SUMS guards integrity. There is no GPG
// signature verification — an accepted trade-off for a closed-network testing
// tool. The web UI's check-update button remains an intentional no-op; only the
// CLI performs code replacement.
package update

import (
	"bytes"
	"context"
	"fmt"
	"log/slog"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strings"
	"time"

	"vocat/internal/buildinfo"
)

// Options captures the resolved flags for an update invocation.
type Options struct {
	Check  bool   // report-only
	Repo   string // owner/name
	Target string // binary path to replace
	Force  bool   // reinstall even at equal version
	Token  string // optional GitHub bearer token
	Help   bool   // print usage, do nothing
}

// Run executes the update subcommand. It returns nil on success or when an
// update is reported-but-not-applied under --check; it returns an error only
// when something concrete went wrong.
func Run(logger *slog.Logger, args []string) error {
	opts, err := parseFlags(args)
	if err != nil {
		return err
	}
	if opts.Help {
		printUpdateUsage()
		return nil
	}
	if opts.Repo == "" {
		opts.Repo = strings.TrimSpace(os.Getenv("VOCAT_REPO"))
	}
	if opts.Token == "" {
		opts.Token = strings.TrimSpace(os.Getenv("GITHUB_TOKEN"))
	}
	if opts.Repo == "" {
		return fmt.Errorf("update: no repository configured (set --repo=owner/name or VOCAT_REPO)")
	}
	if opts.Target == "" {
		opts.Target = resolveDefaultTarget()
	}

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
	defer cancel()

	logger.Info("checking for updates", "repo", opts.Repo, "current", buildinfo.Version)
	release, err := LatestRelease(ctx, opts.Repo, opts.Token)
	if err != nil {
		return err
	}
	latest := strings.TrimPrefix(release.TagName, "v")
	if latest == "" {
		latest = release.TagName
	}

	if latest == buildinfo.Version && !opts.Force {
		logger.Info("already up to date", "version", buildinfo.Version)
		fmt.Printf("vocat %s is already the latest release.\n", buildinfo.Version)
		return nil
	}
	if opts.Check {
		fmt.Printf("update available: %s -> %s\n", buildinfo.Version, latest)
		if release.Body != "" {
			fmt.Println(strings.TrimSpace(release.Body))
		}
		return nil
	}

	logger.Info("update available", "current", buildinfo.Version, "latest", latest)
	return applyUpdate(ctx, logger, opts, release, latest)
}

func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release *Release, latest string) error {
	assetNames := assetNamesFor(runtime.GOOS, runtime.GOARCH)
	var asset *Asset
	for _, name := range assetNames {
		if asset = findAsset(release, name); asset != nil {
			break
		}
	}
	if asset == nil {
		return fmt.Errorf("update: release %s has none of assets %q for %s/%s", release.TagName, assetNames, runtime.GOOS, runtime.GOARCH)
	}

	sumsAsset := findAsset(release, "SHA256SUMS")
	if sumsAsset == nil {
		return fmt.Errorf("update: release %s missing SHA256SUMS — refusing to install unverified", release.TagName)
	}

	// The temp file MUST live in the same directory as the target so os.Rename
	// stays on one filesystem; a cross-device rename fails with EXDEV.
	targetDir := filepath.Dir(opts.Target)
	if err := os.MkdirAll(targetDir, 0o755); err != nil {
		return fmt.Errorf("update: ensure target dir %s: %w", targetDir, err)
	}
	tmp, err := os.CreateTemp(targetDir, ".vocat-update-*")
	if err != nil {
		return fmt.Errorf("update: create temp file: %w", err)
	}
	tmpPath := tmp.Name()
	cleanup := func() { _ = os.Remove(tmpPath) }
	defer func() {
		if tmp != nil {
			_ = tmp.Close()
		}
	}()

	logger.Info("downloading binary", "asset", asset.Name, "size", asset.Size, "url", asset.BrowserDownloadURL)
	if err := downloadAsset(ctx, asset.BrowserDownloadURL, opts.Token, tmp); err != nil {
		cleanup()
		return err
	}
	if err := tmp.Close(); err != nil {
		cleanup()
		return fmt.Errorf("update: finalize temp file: %w", err)
	}
	tmp = nil

	var sums bytes.Buffer
	if err := downloadAsset(ctx, sumsAsset.BrowserDownloadURL, opts.Token, &sums); err != nil {
		cleanup()
		return err
	}
	expectedHash, err := ParseSHA256SUMS(sums.String(), asset.Name)
	if err != nil {
		cleanup()
		return err
	}
	ok, err := VerifyFileSHA256(tmpPath, expectedHash)
	if err != nil {
		cleanup()
		return err
	}
	if !ok {
		cleanup()
		return fmt.Errorf("update: sha256 mismatch for %s — refusing to install", asset.Name)
	}
	logger.Info("verified binary", "sha256", expectedHash)

	if err := os.Chmod(tmpPath, 0o755); err != nil {
		cleanup()
		return fmt.Errorf("update: chmod temp binary: %w", err)
	}
	if err := backupAndReplace(opts.Target, tmpPath); err != nil {
		cleanup()
		return err
	}
	logger.Info("installed new binary", "target", opts.Target, "version", latest)
	fmt.Printf("vocat updated to %s.\n", latest)

	if err := restartService(logger); err != nil {
		// The file replacement already succeeded; a restart failure is not
		// fatal — the operator can restart the service manually.
		fmt.Printf("Binary replaced, but automatic restart failed: %v\n", err)
		fmt.Println("Restart the vocat service manually to apply the new build.")
	}
	return nil
}

// backupAndReplace renames the current binary aside, then moves the verified
// temp file into place. Both renames are atomic on the same filesystem. On
// Linux the kernel holds the running binary's inode, so replacing it mid-flight
// is safe.
func backupAndReplace(target, tmp string) error {
	backup := target + ".previous"
	if _, err := os.Stat(target); err == nil {
		_ = os.Remove(backup)
		if err := os.Rename(target, backup); err != nil {
			return fmt.Errorf("update: move current binary aside: %w", err)
		}
	}
	if err := os.Rename(tmp, target); err != nil {
		// Best-effort rollback so the operator is not left without a binary.
		if _, statErr := os.Stat(backup); statErr == nil {
			_ = os.Rename(backup, target)
		}
		return fmt.Errorf("update: move new binary into place: %w", err)
	}
	_ = os.Remove(backup)
	return nil
}

// restartService restarts the vocat systemd unit. If systemctl is unavailable
// (non-systemd hosts, containers), it returns an error the caller surfaces as
// a non-fatal warning.
func restartService(logger *slog.Logger) error {
	if _, err := exec.LookPath("systemctl"); err != nil {
		return fmt.Errorf("systemctl not found in PATH")
	}
	cmd := exec.Command("systemctl", "restart", "vocat")
	if out, err := cmd.CombinedOutput(); err != nil {
		logger.Warn("systemctl restart failed", "error", err, "output", string(out))
		return fmt.Errorf("systemctl restart vocat: %w", err)
	}
	return nil
}

// resolveDefaultTarget returns the conventional install path when present,
// falling back to the running executable. This lets `vocat update` "just work"
// on the standard systemd host without flags.
func resolveDefaultTarget() string {
	const defaultPath = "/opt/vocat/bin/vocat"
	if _, err := os.Stat(defaultPath); err == nil {
		return defaultPath
	}
	exe, err := os.Executable()
	if err != nil {
		return defaultPath
	}
	resolved, err := filepath.EvalSymlinks(exe)
	if err != nil {
		return exe
	}
	return resolved
}

func findAsset(release *Release, name string) *Asset {
	for i := range release.Assets {
		if release.Assets[i].Name == name {
			return &release.Assets[i]
		}
	}
	return nil
}

func assetNamesFor(goos, goarch string) []string {
	if goos == "linux" && goarch == "arm" {
		// Official 32-bit ARM builds target GOARM=7. Keep the generic legacy
		// name as a fallback for installations consuming an older release.
		return []string{"vocat-linux-armv7", "vocat-linux-arm"}
	}
	return []string{fmt.Sprintf("vocat-%s-%s", goos, goarch)}
}

func printUpdateUsage() {
	fmt.Println(`Usage: vocat update [flags]

Fetch the latest release from GitHub and replace this binary in place.

Flags:
  --check            Report whether an update is available, then exit.
  --force            Reinstall even when already at the latest version.
  --repo owner/name  GitHub repository (default: $VOCAT_REPO).
  --target path      Binary to replace (default: /opt/vocat/bin/vocat if
                     present, otherwise the running executable).
  --token token      GitHub bearer token (default: $GITHUB_TOKEN).
  -h, --help         Show this help.

Environment:
  VOCAT_REPO         Fallback for --repo.
  GITHUB_TOKEN       Fallback for --token. Required for private repos and
                     recommended to avoid unauthenticated rate limits.`)
}

func parseFlags(args []string) (Options, error) {
	var opts Options
	for i := 0; i < len(args); i++ {
		arg := args[i]
		switch {
		case arg == "--check":
			opts.Check = true
		case arg == "--force":
			opts.Force = true
		case arg == "--repo":
			i++
			if i >= len(args) {
				return opts, fmt.Errorf("update: --repo requires a value")
			}
			opts.Repo = args[i]
		case strings.HasPrefix(arg, "--repo="):
			opts.Repo = strings.TrimPrefix(arg, "--repo=")
		case arg == "--target":
			i++
			if i >= len(args) {
				return opts, fmt.Errorf("update: --target requires a value")
			}
			opts.Target = args[i]
		case strings.HasPrefix(arg, "--target="):
			opts.Target = strings.TrimPrefix(arg, "--target=")
		case arg == "--token":
			i++
			if i >= len(args) {
				return opts, fmt.Errorf("update: --token requires a value")
			}
			opts.Token = args[i]
		case strings.HasPrefix(arg, "--token="):
			opts.Token = strings.TrimPrefix(arg, "--token=")
		case arg == "-h" || arg == "--help":
			opts.Help = true
		default:
			return opts, fmt.Errorf("update: unknown flag %q", arg)
		}
	}
	return opts, nil
}
