// Package cli: upgrade.go implements the `orca upgrade --to <version>`
// subcommand (REQ-115, gates C-25 and C-27). It is a thin wrapper
// around scripts/install.sh + orca restore that also handles the
// R-017 binding cutover (Traefik :443 → 127.0.0.1:8443 + nftables) for
// existing v0.9/v0.10 clusters, creates the `orca` system user on
// existing peers (C-27, needed before P10b drift detection works),
// and optionally imports the v0.8 internal CA into step-ca.
package cli

import (
	"context"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"time"

	"github.com/spf13/cobra"

	"git.cloudinit.dev/coreci/orca/internal/migration"
	"git.cloudinit.dev/coreci/orca/internal/paths"
)

var (
	upgradeTo       string
	upgradeImportCA bool
	upgradeForce     bool
	upgradeDryRun   bool
)

// commandRunner is the seam for running shell commands (install.sh,
// nft, useradd). Tests inject a mock; production uses execRunner.
type commandRunner interface {
	Run(ctx context.Context, name string, args ...string) ([]byte, error)
}

// execRunner runs commands via os/exec.
type execRunner struct{}

func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
	cmd := exec.CommandContext(ctx, name, args...)
	return cmd.CombinedOutput()
}

// upgradeRunnerOverride is the package-level test seam for the
// command runner.
var upgradeRunnerOverride commandRunner

// httpClientOverride is the package-level test seam for the cutover
// verification HTTP check (C-25). Tests inject a mock.
var httpClientOverride func(url string) (int, error)

// upgradeTransport is the SSH surface the upgrade command needs for
// C-27 orca user creation on peers. Mirrors peerSetupTransport.
type upgradeTransport interface {
	Exec(ctx context.Context, peer string, cmd string) ([]byte, error)
}

// upgradeTransportOverride is the package-level test seam for the
// SSH transport.
var upgradeTransportOverride upgradeTransport

// peersListerOverride is the package-level test seam for listing
// peers to create the orca user on. Returns a list of peer addresses.
var peersListerOverride func() ([]string, error)

var upgradeCmd = &cobra.Command{
	Use:   "upgrade",
	Short: "Upgrade orca to a new version (REQ-115, R-017 cutover)",
	Long: `orca upgrade --to <version> is a thin wrapper around
install.sh + orca restore. It handles:
  - R-017 binding cutover: Traefik :443 → 127.0.0.1:8443 + nftables
    for existing v0.9/v0.10 clusters (with C-25 post-cutover
    verification and rollback on failure)
  - C-27: creates the 'orca' system user on existing peers
    (useradd -r orca, idempotent) — needed before P10b drift detection
  - Optional --import-ca: imports the v0.8 internal CA into step-ca
  - If a v0.8 layout is detected, runs Migratev08tov11 first
  - Idempotent: if already at the target version, no-op`,
	Args: cobra.NoArgs,
	RunE: func(cmd *cobra.Command, args []string) error {
		return runUpgrade(cmd, cmd.OutOrStdout())
	},
}

func init() {
	upgradeCmd.Flags().StringVar(&upgradeTo, "to", "", "target version (e.g. v0.11.0) (required)")
	upgradeCmd.Flags().BoolVar(&upgradeImportCA, "import-ca", false, "import the v0.8 internal CA into step-ca during upgrade")
	upgradeCmd.Flags().BoolVar(&upgradeForce, "force", false, "skip cutover verification (use with caution)")
	upgradeCmd.Flags().BoolVar(&upgradeDryRun, "dry-run", false, "report what would be done without making changes")
	rootCmd.AddCommand(upgradeCmd)
}

// UpgradeResult is the JSON-serializable summary of an upgrade run.
type UpgradeResult struct {
	TargetVersion string   `json:"target_version"`
	CurrentVersion string  `json:"current_version"`
	DryRun        bool     `json:"dry_run"`
	MigratedV08    bool     `json:"migrated_v08"`
	CutoverNeeded  bool     `json:"cutover_needed"`
	CutoverOK      bool     `json:"cutover_ok,omitempty"`
	CutoverRolled  bool     `json:"cutover_rolled_back,omitempty"`
	UsersCreated   []string `json:"users_created,omitempty"`
	CAImported     bool     `json:"ca_imported,omitempty"`
	BinaryUpdated  bool     `json:"binary_updated"`
}

func runUpgrade(cmd *cobra.Command, out interface{ Write([]byte) (int, error) }) error {
	if upgradeTo == "" {
		return fmt.Errorf("--to is required (e.g. --to v0.11.0)")
	}

	res := UpgradeResult{
		TargetVersion:  upgradeTo,
		CurrentVersion: version,
		DryRun:         upgradeDryRun,
	}

	if !jsonOutput {
		fmt.Fprintf(out, "orca upgrade --to %s (current: %s)\n", upgradeTo, version)
	}

	if version == strings.TrimPrefix(upgradeTo, "v") && !upgradeDryRun {
		if !jsonOutput {
			fmt.Fprintf(out, "✓ Already at target version %s; no-op\n", upgradeTo)
		}
		res.BinaryUpdated = false
		if jsonOutput {
			return printJSON(res)
		}
		return nil
	}

	home := paths.Root()
	if migration.Detectv08(home) {
		if !jsonOutput {
			fmt.Fprintf(out, "• v0.8 layout detected; running data migration first\n")
		}
		if upgradeDryRun {
			fmt.Fprintf(out, "  [dry-run] would run Migratev08tov11(source=%s)\n", home)
		} else {
			if err := migration.Migratev08tov11(migration.MigrateOptions{
				SourceDir: home,
				TargetDir: home,
				ImportCA:  upgradeImportCA,
				DryRun:    false,
			}); err != nil {
				return fmt.Errorf("v0.8 migration: %w", err)
			}
		}
		res.MigratedV08 = true
	}

	runner := upgradeRunnerOverride
	if runner == nil {
		runner = execRunner{}
	}

	cutoverNeeded := detectOldTraefikBinding()
	res.CutoverNeeded = cutoverNeeded
	if cutoverNeeded {
		if !jsonOutput {
			fmt.Fprintf(out, "• R-017 cutover: Traefik on :443 detected; cutting over to 127.0.0.1:8443 + nftables\n")
		}
		if upgradeDryRun {
			fmt.Fprintf(out, "  [dry-run] would reconfigure Traefik to 127.0.0.1:8443 + add nftables redirect\n")
		} else {
			ok, err := performCutover(cmd.Context(), runner, out, upgradeForce)
			if err != nil {
				return err
			}
			res.CutoverOK = ok
			if !ok {
				res.CutoverRolled = true
				if jsonOutput {
					return printJSON(res)
				}
				return fmt.Errorf("cutover verification failed; rolled back to :443")
			}
		}
	}

	peers, err := listPeers()
	if err != nil {
		slog.Warn("upgrade: list peers failed", "err", err)
	}
	if len(peers) > 0 {
		if !jsonOutput {
			fmt.Fprintf(out, "• C-27: creating orca system user on %d peer(s)\n", len(peers))
		}
		created, err := createOrcaUserOnPeers(cmd.Context(), peers)
		if err != nil {
			slog.Warn("upgrade: orca user creation on peers failed", "err", err)
		}
		res.UsersCreated = created
	}

	if upgradeImportCA && !res.MigratedV08 {
		if !jsonOutput {
			fmt.Fprintf(out, "• Importing v0.8 internal CA into step-ca\n")
		}
		if upgradeDryRun {
			fmt.Fprintf(out, "  [dry-run] would import ca.crt/ca.key into step-ca\n")
		} else {
			if err := importCA(cmd.Context()); err != nil {
				slog.Warn("upgrade: CA import failed", "err", err)
			} else {
				res.CAImported = true
			}
		}
	}

	if upgradeDryRun {
		fmt.Fprintf(out, "  [dry-run] would download and install orca %s via install.sh\n", upgradeTo)
	} else {
		if !jsonOutput {
			fmt.Fprintf(out, "• Downloading and installing orca %s via install.sh\n", upgradeTo)
		}
		installSh := findInstallScript()
		if _, err := runner.Run(cmd.Context(), "bash", installSh, "--version", upgradeTo); err != nil {
			return fmt.Errorf("install.sh: %w", err)
		}
		res.BinaryUpdated = true
	}

	if jsonOutput {
		return printJSON(res)
	}
	fmt.Fprintf(out, "\n✓ upgrade to %s complete\n", upgradeTo)
	if cutoverNeeded && !upgradeDryRun {
		fmt.Fprintf(out, "\nRollback procedure (if cutover failed):\n")
		fmt.Fprintf(out, "  1. Restore Traefik entrypoint to :443\n")
		fmt.Fprintf(out, "  2. Remove nftables rules: nft delete table inet orca_redirect\n")
		fmt.Fprintf(out, "  3. Restart Traefik: systemctl restart traefik\n")
		fmt.Fprintf(out, "  4. Verify: curl -k https://localhost:443/\n")
	}
	return nil
}

// detectOldTraefikBinding returns true if Traefik is listening on :443
// (the v0.9/v0.10 default that R-017 cuts over from). Best-effort: if
// the check fails, returns false (no cutover needed).
func detectOldTraefikBinding() bool {
	runner := upgradeRunnerOverride
	if runner == nil {
		runner = execRunner{}
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	out, err := runner.Run(ctx, "ss", "-tlnp")
	if err != nil {
		return false
	}
	return strings.Contains(string(out), ":443")
}

// performCutover reconfigures Traefik to listen on 127.0.0.1:8443 and
// adds nftables DNAT redirect from :443 → 127.0.0.1:8443. Then runs
// C-25 post-cutover verification: curl -k https://localhost:443/ must
// return 200. On failure, rolls back (restores :443, removes nft rules)
// and returns (false, nil). On success returns (true, nil). With
// force=true, verification is skipped.
func performCutover(ctx context.Context, runner commandRunner, out interface{ Write([]byte) (int, error) }, force bool) (bool, error) {
	if _, err := runner.Run(ctx, "sed", "-i", "s/:443/127.0.0.1:8443/g", "/etc/traefik/traefik.yml"); err != nil {
		return false, fmt.Errorf("cutover: edit traefik.yml: %w", err)
	}
	if _, err := runner.Run(ctx, "systemctl", "restart", "traefik"); err != nil {
		return false, fmt.Errorf("cutover: restart traefik: %w", err)
	}
	nftCmd := `nft add table inet orca_redirect; nft 'add chain inet orca_redirect prerouting { type nat hook prerouting priority -100; }'; nft add rule inet orca_redirect prerouting tcp dport 443 dnat to 127.0.0.1:8443`
	if _, err := runner.Run(ctx, "bash", "-c", nftCmd); err != nil {
		slog.Warn("cutover: nftables rule add failed (non-fatal if already present)", "err", err)
	}

	if force {
		fmt.Fprintf(out, "  --force: skipping cutover verification\n")
		return true, nil
	}

	if err := verifyCutover(out); err != nil {
		fmt.Fprintf(out, "  ✗ C-25 cutover verification failed: %v\n", err)
		fmt.Fprintf(out, "  Rolling back to :443...\n")
		if rbErr := rollbackCutover(ctx, runner); rbErr != nil {
			slog.Error("cutover: rollback failed", "err", rbErr)
		}
		return false, nil
	}
	fmt.Fprintf(out, "  ✓ C-25 cutover verification passed (200 from Traefik)\n")
	return true, nil
}

// verifyCutover runs the C-25 post-cutover check: curl -k
// https://localhost:443/ must return HTTP 200.
func verifyCutover(out interface{ Write([]byte) (int, error) }) error {
	if httpClientOverride != nil {
		code, err := httpClientOverride("https://localhost:443/")
		if err != nil {
			return err
		}
		if code != 200 {
			return fmt.Errorf("HTTP %d (want 200)", code)
		}
		return nil
	}

	client := &http.Client{Timeout: 10 * time.Second}
	resp, err := client.Get("https://localhost:443/")
	if err != nil {
		return fmt.Errorf("curl: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		return fmt.Errorf("HTTP %d (want 200)", resp.StatusCode)
	}
	return nil
}

// rollbackCutover restores Traefik to :443 and removes nftables rules.
func rollbackCutover(ctx context.Context, runner commandRunner) error {
	if _, err := runner.Run(ctx, "sed", "-i", "s/127.0.0.1:8443/:443/g", "/etc/traefik/traefik.yml"); err != nil {
		return fmt.Errorf("rollback: edit traefik.yml: %w", err)
	}
	if _, err := runner.Run(ctx, "systemctl", "restart", "traefik"); err != nil {
		return fmt.Errorf("rollback: restart traefik: %w", err)
	}
	if _, err := runner.Run(ctx, "nft", "delete", "table", "inet", "orca_redirect"); err != nil {
		slog.Warn("rollback: nft delete table failed (non-fatal if not present)", "err", err)
	}
	return nil
}

// createOrcaUserOnPeers runs the idempotent `useradd -r orca` on each
// peer via SSH (C-27). Returns the list of peers where the user was
// created (or already existed).
func createOrcaUserOnPeers(ctx context.Context, peers []string) ([]string, error) {
	transport := upgradeTransportOverride
	if transport == nil {
		return nil, fmt.Errorf("no SSH transport available for peer user creation")
	}
	var created []string
	for _, peer := range peers {
		cmd := "useradd -r orca -s /usr/sbin/nologin 2>/dev/null || true"
		if _, err := transport.Exec(ctx, peer, cmd); err != nil {
			slog.Warn("upgrade: useradd on peer failed", "peer", peer, "err", err)
			continue
		}
		created = append(created, peer)
	}
	return created, nil
}

// listPeers returns the list of peer addresses for C-27 orca user
// creation. Uses the peersListerOverride test seam if set; otherwise
// returns an empty list (no peers configured).
func listPeers() ([]string, error) {
	if peersListerOverride != nil {
		return peersListerOverride()
	}
	return nil, nil
}

// importCA imports the v0.8 internal CA into step-ca. This is the
// C-07 gate. Production wires a real step-ca client; tests use the
// migration.caImporterOverride seam.
func importCA(ctx context.Context) error {
	caCrt := filepath.Join(paths.Root(), "ca.crt")
	caKey := filepath.Join(paths.Root(), "ca.key")
	if _, err := os.Stat(caCrt); err != nil {
		return fmt.Errorf("import CA: ca.crt not found: %w", err)
	}
	if _, err := os.Stat(caKey); err != nil {
		return fmt.Errorf("import CA: ca.key not found: %w", err)
	}
	importer := migration.GetCAImporter()
	if importer == nil {
		return fmt.Errorf("import CA: no CA importer available")
	}
	return importer.ImportCA(ctx, caCrt, caKey)
}

// findInstallScript returns the path to scripts/install.sh. Checks
// the repo-local path first; falls back to downloading via curl
// (handled by the caller).
func findInstallScript() string {
	candidates := []string{
		"scripts/install.sh",
		"/usr/local/share/orca/scripts/install.sh",
	}
	for _, c := range candidates {
		if _, err := os.Stat(c); err == nil {
			return c
		}
	}
	return "scripts/install.sh"
}
