// SPDX-License-Identifier: GPL-3.0-or-later

package cli

import (
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"runtime"

	"github.com/spf13/cobra"
)

// newSelfUpdateCommand creates the `privatebackup self-update` command.
//
// The CLI never talks to the public update host directly; it asks the
// backend daemon (which has a Tor circuit already up) to fetch and verify
// the new CLI binary, then streams the verified bytes over localhost and
// atomically swaps them into place.
func (c *CLI) newSelfUpdateCommand() *cobra.Command {
	var (
		check          bool
		applyDaemon    bool
		showAutoStatus bool
		autoUpdate     string // "on", "off", "" (no change)
	)
	cmd := &cobra.Command{
		Use:   "self-update",
		Short: "Update the Private Archive CLI to the latest signed release",
		Long: `Update the Private Archive client to the latest signed release.

The CLI cannot reach the public update host directly. It delegates the
fetch to the backend daemon, which downloads the new binary through the
existing Tor circuit and verifies its signature against the embedded
public key before streaming bytes back. The CLI then atomically replaces
its own executable on disk.

Use --check to inspect availability without installing. Use
--auto=on|off to opt in or out of automatic updates (the daemon honours
this for the backend; the desktop frontend reads the same flag).`,
		Example: `  # Check whether an update is available
  privatebackup self-update --check

  # Install the latest CLI release
  privatebackup self-update

  # Disable automatic updates (warning: future server protocol changes
  # may break the client until you manually update)
  privatebackup self-update --auto=off`,
		RunE: func(cmd *cobra.Command, args []string) error {
			return c.runSelfUpdate(check, applyDaemon, showAutoStatus, autoUpdate)
		},
	}
	cmd.Flags().BoolVar(&check, "check", false, "Only check for updates; do not install")
	cmd.Flags().BoolVar(&applyDaemon, "include-daemon", false, "Also apply backend daemon update if available")
	cmd.Flags().BoolVar(&showAutoStatus, "status", false, "Show current auto-update setting and exit")
	cmd.Flags().StringVar(&autoUpdate, "auto", "", "Set automatic-update preference: on or off")
	return cmd
}

func (c *CLI) runSelfUpdate(check, includeDaemon, showStatus bool, autoFlag string) error {
	ctx, cancel := c.GetContext()
	defer cancel()

	if showStatus {
		cfg, err := c.client.GetUpdateConfig(ctx)
		if err != nil {
			return wrapBackendErr(err)
		}
		c.renderer.KeyValue("Auto-update", boolStr(cfg.AutoUpdate))
		c.renderer.KeyValue("Channel", cfg.Channel)
		return nil
	}

	if autoFlag != "" {
		var enabled bool
		switch autoFlag {
		case "on", "true", "yes":
			enabled = true
		case "off", "false", "no":
			enabled = false
		default:
			return fmt.Errorf("--auto must be one of: on, off")
		}
		cfg, err := c.client.SetAutoUpdate(ctx, enabled)
		if err != nil {
			return wrapBackendErr(err)
		}
		c.renderer.Success(fmt.Sprintf("Auto-update set to %s", boolStr(cfg.AutoUpdate)))
		if !cfg.AutoUpdate {
			c.renderer.Newline()
			c.renderer.Warning("Automatic updates are now disabled.")
			c.renderer.Info("Older clients may eventually be rejected by the server when the protocol changes.")
			c.renderer.Info("Run `privatebackup self-update` periodically to stay on a supported version.")
		}
		return nil
	}

	// Always start by asking the daemon to refresh manifest state.
	state, err := c.client.CheckForUpdates(ctx, includeDaemon)
	if err != nil {
		return wrapBackendErr(err)
	}
	c.renderer.KeyValue("Current version", state.CurrentVersion)
	if state.LatestVersion != "" {
		c.renderer.KeyValue("Latest version", state.LatestVersion)
	}
	c.renderer.KeyValue("Channel", state.Channel)
	c.renderer.KeyValue("Auto-update", boolStr(state.AutoUpdate))
	if state.LastError != "" {
		c.renderer.Warning(fmt.Sprintf("Last check error: %s", state.LastError))
	}

	if !state.UpdateAvailable {
		c.renderer.Success("CLI is already up to date.")
		return nil
	}
	if check {
		c.renderer.Info(fmt.Sprintf("A newer release (%s) is available.", state.LatestVersion))
		if state.ReleaseNotesURL != "" {
			c.renderer.KeyValue("Release notes", state.ReleaseNotesURL)
		}
		c.renderer.Info("Run `privatebackup self-update` to install it.")
		return nil
	}

	c.renderer.Info(fmt.Sprintf("Downloading CLI %s through Tor...", state.LatestVersion))
	body, expectedSHA, _, err := c.client.FetchUpdateBinary(ctx, "cli")
	if err != nil {
		return wrapBackendErr(err)
	}
	got := sha256.Sum256(body)
	if hex.EncodeToString(got[:]) != expectedSHA {
		return errors.New("downloaded bytes did not match the daemon's verified SHA256 (transport corruption?)")
	}

	exe, err := os.Executable()
	if err != nil {
		return fmt.Errorf("locating CLI executable: %w", err)
	}
	exe, err = filepath.EvalSymlinks(exe)
	if err != nil {
		return fmt.Errorf("resolving CLI executable: %w", err)
	}

	if err := atomicReplace(exe, body); err != nil {
		return fmt.Errorf("replacing %s: %w", exe, err)
	}
	c.renderer.Success(fmt.Sprintf("Installed CLI %s at %s", state.LatestVersion, exe))
	if runtime.GOOS == "windows" {
		c.renderer.Info("On Windows the previous version is left as <binary>.old until next launch.")
	}
	return nil
}

// atomicReplace writes data to a sibling temp file and renames it over the
// live binary. Same-filesystem rename is atomic on Unix; the running CLI
// process keeps executing the (now-unlinked) old inode and exits normally,
// so no special handling is needed for the active invocation.
func atomicReplace(live string, data []byte) error {
	dir := filepath.Dir(live)
	tmp, err := os.CreateTemp(dir, ".self-update-*")
	if err != nil {
		return err
	}
	cleanup := func() {
		_ = tmp.Close()
		_ = os.Remove(tmp.Name())
	}
	if _, err := tmp.Write(data); err != nil {
		cleanup()
		return err
	}
	if err := tmp.Sync(); err != nil {
		cleanup()
		return err
	}
	if err := tmp.Close(); err != nil {
		_ = os.Remove(tmp.Name())
		return err
	}
	if err := os.Chmod(tmp.Name(), 0755); err != nil {
		_ = os.Remove(tmp.Name())
		return err
	}
	if runtime.GOOS == "windows" {
		// Windows cannot rename over an open .exe: shuffle the live one
		// out of the way first. On next CLI startup, we could clean up
		// the .old file, but cleanup is also fine the next time
		// self-update runs.
		old := live + ".old"
		_ = os.Remove(old)
		if _, err := os.Stat(live); err == nil {
			if err := os.Rename(live, old); err != nil {
				_ = os.Remove(tmp.Name())
				return fmt.Errorf("moving live binary aside: %w", err)
			}
		}
	}
	if err := os.Rename(tmp.Name(), live); err != nil {
		_ = os.Remove(tmp.Name())
		return err
	}
	return nil
}

func wrapBackendErr(err error) error {
	if isConnectionError(err) {
		return errors.New("cannot reach the backend daemon — start it with `privatebackup daemon start`")
	}
	return err
}

func boolStr(b bool) string {
	if b {
		return "enabled"
	}
	return "disabled"
}
