package main

/*
Changes made:
- Replaced the Python one-click deployer with a Go CLI so the public installer
  can ship as a single compiled binary with no Python, pip, or runtime package
  setup on user laptops.
- Kept install.sh as the curl|bash entrypoint; it detects OS/arch, installs
  the matching archectl binary locally, and runs the requested lifecycle command.
- Added install, update, and destroy commands. Install/update accept a version
  tag and use it for both GHCR web and workspace images.
- Preserved the narrow-path deployment behavior: DigitalOcean only,
  autogenerated secrets, autogenerated nip.io hostname, Docker,
  PostgreSQL, Traefik, docker-socket-proxy, persistent KB/user mounts, public
  health polling, local credential file, and inline remote bootstrap logs.

Language choice: Go, because this installer is meant to run on arbitrary user
laptops and a static-ish single binary is more reliable than bootstrapping a
Python runtime plus terminal UI dependencies.
*/

import (
	"bufio"
	"bytes"
	"context"
	"crypto/rand"
	"crypto/rsa"
	"crypto/tls"
	"crypto/x509"
	"encoding/base64"
	"encoding/binary"
	"encoding/json"
	"encoding/pem"
	"errors"
	"flag"
	"fmt"
	"io"
	"math"
	"math/big"
	"net"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"os/signal"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"sync"
	"syscall"
	"time"

	"github.com/charmbracelet/lipgloss"
	"golang.org/x/term"
)

const (
	appName                     = "arche"
	appImageRepo                = "ghcr.io/peaberry-studio/arche/web"
	workspaceImageRepo          = "ghcr.io/peaberry-studio/arche/workspace"
	deploySSHUser               = "archectl"
	rootSSHUser                 = "root"
	defaultVersion              = "latest"
	appPort                     = 3000
	dbName                      = "arche"
	defaultDropletSize          = "s-2vcpu-4gb"
	defaultDropletRegion        = "nyc1"
	dropletImage                = "ubuntu-24-04-x64"
	doAPIBase                   = "https://api.digitalocean.com/v2"
	envPlaceholderDomain        = "__ARCHE_DOMAIN__"
	envPlaceholderPublicBaseURL = "__ARCHE_PUBLIC_BASE_URL__"
	defaultHealthWait           = 12 * time.Minute
)

type input struct {
	token   string
	email   string
	size    string
	region  string
	version string
}

type sshMaterial struct {
	privateKey string
	publicKey  string
}

type sshAuth struct {
	privateKey string
	password   string
	knownHost  string
}

type artifacts struct {
	DropletID       int    `json:"droplet_id"`
	FirewallID      string `json:"firewall_id"`
	DropletName     string `json:"droplet_name"`
	IPAddress       string `json:"ip_address"`
	Domain          string `json:"domain"`
	PublicURL       string `json:"public_url"`
	AdminEmail      string `json:"admin_email"`
	CredentialsFile string `json:"credentials_file"`
	JSONFile        string `json:"json_file"`
	SSHKeyFile      string `json:"ssh_key_file,omitempty"`
	KnownHostsFile  string `json:"known_hosts_file,omitempty"`
}

type healthResult struct {
	Ready          bool
	ElapsedSeconds int
	LastStatus     string
}

type cliArgs struct {
	command        string
	token          string
	email          string
	size           string
	region         string
	version        string
	statePath      string
	ipAddress      string
	sshPassword    string
	sshKeyPath     string
	publicURL      string
	dropletID      int
	firewallID     string
	skipHealthWait bool
	validateOnly   bool
	verbose        bool
	yes            bool
	help           bool
}

type stateInput struct {
	Email         string `json:"email"`
	DropletSize   string `json:"droplet_size"`
	DropletRegion string `json:"droplet_region"`
}

type stateSecrets struct {
	PostgresPassword          string `json:"postgres_password"`
	SessionPepper             string `json:"session_pepper"`
	EncryptionKey             string `json:"encryption_key"`
	InternalToken             string `json:"internal_token"`
	GatewayTokenSecret        string `json:"gateway_token_secret"`
	ConnectorOAuthStateSecret string `json:"connector_oauth_state_secret"`
	AdminPassword             string `json:"admin_password"`
	RootPassword              string `json:"root_password,omitempty"`
	SSHPrivateKey             string `json:"ssh_private_key,omitempty"`
	SSHKnownHost              string `json:"ssh_known_host,omitempty"`
}

type deploymentState struct {
	AppName        string       `json:"app_name"`
	Version        string       `json:"version"`
	AppImage       string       `json:"app_image"`
	WorkspaceImage string       `json:"workspace_image"`
	DBName         string       `json:"db_name"`
	Input          stateInput   `json:"input"`
	Deployment     artifacts    `json:"deployment"`
	Secrets        stateSecrets `json:"secrets"`
	UpdatedAt      string       `json:"updated_at"`
}

type ui struct {
	mu             sync.Mutex
	step           int
	width          int
	interactive    bool
	progressActive bool
	progressWidth  int
	verbose        bool
	styles         uiStyles
}

type uiStyles struct {
	title     lipgloss.Style
	subtitle  lipgloss.Style
	panel     lipgloss.Style
	panelWarn lipgloss.Style
	panelOK   lipgloss.Style
	muted     lipgloss.Style
	cyan      lipgloss.Style
	green     lipgloss.Style
	yellow    lipgloss.Style
	bold      lipgloss.Style
}

func newUI() *ui {
	width := 92
	if terminalWidth, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil {
		width = min(max(72, terminalWidth-4), 108)
	}
	return &ui{
		width:       width,
		interactive: term.IsTerminal(int(os.Stdout.Fd())),
		styles: uiStyles{
			title:     lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F5F5F0")),
			subtitle:  lipgloss.NewStyle().Foreground(lipgloss.Color("#6EE7D8")),
			panel:     lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#6EE7D8")).Padding(1, 2).Width(width),
			panelWarn: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#EAB308")).Padding(1, 2).Width(width),
			panelOK:   lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#22C55E")).Padding(1, 2).Width(width),
			muted:     lipgloss.NewStyle().Foreground(lipgloss.Color("#737373")),
			cyan:      lipgloss.NewStyle().Foreground(lipgloss.Color("#6EE7D8")),
			green:     lipgloss.NewStyle().Foreground(lipgloss.Color("#22C55E")),
			yellow:    lipgloss.NewStyle().Foreground(lipgloss.Color("#EAB308")),
			bold:      lipgloss.NewStyle().Bold(true),
		},
	}
}

func (u *ui) banner(title, subtitle string) {
	u.print(u.styles.panel.Render(lipgloss.JoinVertical(lipgloss.Left, u.styles.title.Render(title), u.styles.subtitle.Render(subtitle))))
}

func (u *ui) stage(title string) time.Time {
	u.step++
	u.log("STEP", u.styles.cyan, fmt.Sprintf("%02d  %s", u.step, u.styles.bold.Render(title)))
	return time.Now()
}

func (u *ui) info(message string) {
	u.log("INFO", u.styles.cyan, message)
}

func (u *ui) wait(message string) {
	u.log("WAIT", u.styles.cyan, message)
}

func (u *ui) ok(message string, started time.Time) {
	u.log("OK", u.styles.green, withDuration(message, started))
}

func (u *ui) warn(message string, started ...time.Time) {
	text := message
	if len(started) > 0 {
		text = withDuration(message, started[0])
	}
	u.log("WARN", u.styles.yellow, text)
}

func (u *ui) command(label, command string) {
	u.log("CMD", u.styles.muted, u.styles.bold.Render(label+": ")+u.styles.muted.Render(command))
}

func (u *ui) remote(message string) {
	if !u.verbose {
		return
	}
	u.log("REMOTE", u.styles.muted, u.styles.muted.Render(message))
}

func (u *ui) panel(title string, rows [][2]string, tone string) {
	u.mu.Lock()
	defer u.mu.Unlock()
	u.finishProgressLocked()

	style := u.styles.cyan.Bold(true)
	if tone == "success" {
		style = u.styles.green.Bold(true)
	}
	if tone == "warn" {
		style = u.styles.yellow.Bold(true)
	}
	maxKey := 0
	for _, row := range rows {
		maxKey = max(maxKey, len(row[0]))
	}
	fmt.Println(style.Render(title))
	for _, row := range rows {
		key := u.styles.muted.Render(row[0] + strings.Repeat(" ", maxKey-len(row[0])))
		fmt.Println("  " + key + "  " + row[1])
	}
}

func (u *ui) progress(label string, current, total time.Duration, details string) string {
	ratio := 0.0
	if total > 0 {
		ratio = math.Max(0, math.Min(1, float64(current)/float64(total)))
	}
	width := 22
	filled := int(math.Round(ratio * float64(width)))
	bar := u.styles.cyan.Render(strings.Repeat("━", filled)) + u.styles.muted.Render(strings.Repeat("━", width-filled))
	return fmt.Sprintf("%s  %s  %3d%%  %s", u.styles.cyan.Bold(true).Render(label), bar, int(ratio*100), details)
}

func (u *ui) log(label string, style lipgloss.Style, message string) {
	timestamp := u.styles.muted.Render(time.Now().Format("15:04:05"))
	u.print(timestamp + " " + style.Render(label) + " " + message)
}

func (u *ui) print(lines ...string) {
	u.mu.Lock()
	defer u.mu.Unlock()
	u.finishProgressLocked()
	for _, line := range lines {
		fmt.Println(line)
	}
}

func (u *ui) progressWait(label string, current, total time.Duration, details string) {
	line := u.styles.muted.Render(time.Now().Format("15:04:05")) + " " + u.styles.cyan.Render("WAIT") + " " + u.progress(label, current, total, details)
	u.mu.Lock()
	defer u.mu.Unlock()
	if !u.interactive {
		fmt.Println(line)
		return
	}
	padding := ""
	plainWidth := lipgloss.Width(line)
	if u.progressWidth > plainWidth {
		padding = strings.Repeat(" ", u.progressWidth-plainWidth)
	}
	fmt.Print("\r" + line + padding)
	u.progressWidth = plainWidth
	u.progressActive = true
}

func (u *ui) progressDone(label string, total time.Duration, details string) {
	u.progressWait(label, total, total, details)
	u.mu.Lock()
	defer u.mu.Unlock()
	u.finishProgressLocked()
}

func (u *ui) finishProgressLocked() {
	if !u.progressActive {
		return
	}
	fmt.Println()
	u.progressActive = false
	u.progressWidth = 0
}

type doClient struct {
	token   string
	http    *http.Client
	baseURL string
}

func main() {
	ui := newUI()
	ctx, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt)
	defer stopSignals()

	code := run(ctx, ui)
	if code != 0 {
		os.Exit(code)
	}
}

func run(ctx context.Context, ui *ui) int {
	args, err := parseArgs(os.Args[1:])
	if err != nil {
		fmt.Fprintln(os.Stderr, "Error:", err)
		printUsage(os.Stderr)
		return 1
	}
	if args.help {
		printUsage(os.Stdout)
		return 0
	}
	ui.verbose = args.verbose

	if err := validateVersion(args.version); err != nil {
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}
	if err := validateTemplates(args.version); err != nil {
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}
	if args.validateOnly {
		ui.ok("Template validation passed.", time.Time{})
		return 0
	}

	switch args.command {
	case "install":
		return runInstall(ctx, ui, args)
	case "update":
		return runUpdate(ctx, ui, args)
	case "destroy":
		return runDestroy(ctx, ui, args)
	default:
		fmt.Fprintln(os.Stderr, "Error: unsupported command "+args.command)
		printUsage(os.Stderr)
		return 1
	}
}

func runInstall(ctx context.Context, ui *ui, args cliArgs) int {
	var streamer *remoteStreamer
	defer func() {
		if streamer != nil {
			streamer.stop()
			if streamer.lastError != "" && !streamer.bootComplete() {
				ui.warn("Inline remote log stream ended early: " + streamer.lastError)
			}
		}
	}()

	inputData, err := collectInput(args, ui)
	if err != nil {
		if errors.Is(err, context.Canceled) {
			ui.warn("Cancelled by user.")
			return 130
		}
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}

	started := ui.stage("Checking local SSH tooling")
	if err := ensureLocalSSHDependencies(); err != nil {
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}
	ui.ok("Found local ssh and ssh-keyscan clients", started)

	started = ui.stage("Generating deploy SSH key and first-boot payload")
	sshMaterial, err := generateSSHMaterial()
	if err != nil {
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}
	composeContent := renderCompose()
	bootstrapScript := renderBootstrapScript(inputData)
	cloudInit := renderCloudInit(sshMaterial.publicKey, composeContent, bootstrapScript)
	ui.ok("Generated deploy SSH key, cloud-init, and docker-compose.yml", started)

	client := newDOClient(inputData.token)
	started = ui.stage("Validating DigitalOcean API token")
	if err := client.validateToken(ctx); err != nil {
		if errors.Is(err, context.Canceled) {
			ui.warn("Cancelled by user.")
			return 130
		}
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}
	ui.ok("DigitalOcean API token accepted", started)

	started = ui.stage("Creating Droplet")
	dropletID, dropletName, err := createDroplet(ctx, client, inputData, cloudInit)
	if err != nil {
		if errors.Is(err, context.Canceled) {
			ui.warn("Cancelled by user.")
			return 130
		}
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}
	ui.ok(fmt.Sprintf("Created Droplet %s (id %d)", dropletName, dropletID), started)

	started = ui.stage("Waiting for the Droplet to become active")
	ipAddress, err := waitForDropletIP(ctx, client, dropletID, ui)
	if err != nil {
		if errors.Is(err, context.Canceled) {
			ui.warn("Cancelled by user.")
			return 130
		}
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}
	ui.ok("Droplet is active with public IPv4 "+ipAddress, started)

	started = ui.stage("Attaching firewall")
	firewallID, err := createFirewall(ctx, client, dropletID, dropletName)
	if err != nil {
		if errors.Is(err, context.Canceled) {
			ui.warn("Cancelled by user.")
			return 130
		}
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}
	ui.ok("Attached firewall "+firewallID+" with ports 22, 80, and 443 open", started)

	domain := buildDomainFromIP(ipAddress)
	publicURL := "https://" + domain
	textFile, jsonFile, keyFile, knownHostsFile, err := allocateLocalPaths()
	if err != nil {
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}
	state := deploymentState{
		AppName:        appName,
		Version:        inputData.version,
		AppImage:       appImageForVersion(inputData.version),
		WorkspaceImage: workspaceImageForVersion(inputData.version),
		DBName:         dbName,
		Input: stateInput{
			Email:         inputData.email,
			DropletSize:   inputData.size,
			DropletRegion: inputData.region,
		},
		Deployment: artifacts{
			DropletID:       dropletID,
			FirewallID:      firewallID,
			DropletName:     dropletName,
			IPAddress:       ipAddress,
			Domain:          domain,
			PublicURL:       publicURL,
			AdminEmail:      inputData.email,
			CredentialsFile: textFile,
			JSONFile:        jsonFile,
			SSHKeyFile:      keyFile,
			KnownHostsFile:  knownHostsFile,
		},
		Secrets: stateSecrets{
			SSHPrivateKey: sshMaterial.privateKey,
		},
		UpdatedAt: time.Now().UTC().Format(time.RFC3339),
	}

	started = ui.stage("Waiting for SSH access")
	if !waitForSSHPort(ctx, ipAddress, ui) {
		if ctx.Err() != nil {
			ui.warn("Cancelled by user.")
			return 130
		}
		fmt.Fprintln(os.Stderr, "Error: timed out waiting for SSH on the Droplet")
		return 1
	}
	ui.ok("SSH is reachable for recovery and log streaming", started)

	started = ui.stage("Pinning the server SSH host key")
	knownHost, err := scanSSHHostKey(ctx, ipAddress)
	if err != nil {
		if errors.Is(err, context.Canceled) {
			ui.warn("Cancelled by user.")
			return 130
		}
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}
	state.Secrets.SSHKnownHost = knownHost
	ui.ok("Pinned the server SSH host key", started)

	target := sshTarget{
		ip:   ipAddress,
		user: deploySSHUser,
		auth: sshAuth{
			privateKey: sshMaterial.privateKey,
			knownHost:  knownHost,
		},
	}

	started = ui.stage("Writing local deployment record")
	if err := writeLocalFiles(state); err != nil {
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}
	ui.ok("Saved deployment record to "+textFile, started)
	ui.info("Public URL: " + publicURL)
	if args.verbose {
		ui.command("SSH", displaySSHCommand(target, state))
		ui.command("Bootstrap logs", displaySSHRemoteCommand(target, state, fmt.Sprintf("tail -f /var/log/%s-bootstrap.log", appName)))
		ui.command("Compose logs", displaySSHRemoteCommand(target, state, fmt.Sprintf("cd /opt/%s && docker compose logs -f web", appName)))
	}

	if args.verbose {
		started = ui.stage("Opening the live remote bootstrap log")
		streamer = newRemoteStreamer(ui, target)
		if streamer.available() {
			if err := streamer.start(ctx); err != nil {
				ui.warn("Could not start inline remote log stream: "+err.Error(), started)
			} else if streamer.waitUntilAttached(15 * time.Second) {
				ui.ok("Streaming /var/log/arche-bootstrap.log inline", started)
			} else {
				ui.warn("SSH is reachable, but the live log stream has not attached yet. Health polling will continue.", started)
			}
		} else {
			ui.warn("Local ssh client not found. Falling back to manual log commands.", started)
		}
	}

	started = ui.stage("Fetching server-generated recovery secrets")
	recoveredSecrets, err := waitForRemoteSecrets(ctx, ui, target, 5*time.Minute)
	if err != nil {
		if errors.Is(err, context.Canceled) {
			ui.warn("Cancelled by user.")
			return 130
		}
		ui.warn("Saved a partial deployment record before the remote secret fetch failed.", started)
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}
	state.Secrets.PostgresPassword = recoveredSecrets.PostgresPassword
	state.Secrets.SessionPepper = recoveredSecrets.SessionPepper
	state.Secrets.EncryptionKey = recoveredSecrets.EncryptionKey
	state.Secrets.InternalToken = recoveredSecrets.InternalToken
	state.Secrets.GatewayTokenSecret = recoveredSecrets.GatewayTokenSecret
	state.Secrets.ConnectorOAuthStateSecret = recoveredSecrets.ConnectorOAuthStateSecret
	state.Secrets.AdminPassword = recoveredSecrets.AdminPassword
	state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
	ui.ok("Fetched server-generated secrets over pinned SSH", started)

	started = ui.stage("Refreshing local deployment record")
	if err := writeLocalFiles(state); err != nil {
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}
	ui.ok("Updated deployment record with recovery secrets", started)

	var health *healthResult
	if args.skipHealthWait {
		ui.warn("Skipping public health wait by request.")
	} else {
		started = ui.stage("Waiting for first boot, TLS, and the public health check")
		ui.info("This can take a few minutes while the server installs Docker, pulls images, and requests certificates.")
		result, err := waitForPublicHealth(ctx, publicURL, ui, defaultHealthWait)
		health = &result
		if err != nil {
			if errors.Is(err, context.Canceled) {
				ui.warn("Cancelled by user.")
				return 130
			}
			ui.warn(err.Error(), started)
		} else if result.Ready {
			ui.ok("Public health check passed for "+publicURL, started)
		}
	}

	printSummary(ui, state.Deployment, health)
	return 0
}

func parseArgs(argv []string) (cliArgs, error) {
	args := cliArgs{command: "install", size: defaultDropletSize, region: defaultDropletRegion, version: defaultVersion}
	for len(argv) > 0 {
		if argv[0] == "-vv" || argv[0] == "--verbose" {
			args.verbose = true
			argv = argv[1:]
			continue
		}
		break
	}
	if len(argv) > 0 {
		switch argv[0] {
		case "install", "update", "destroy":
			args.command = argv[0]
			argv = argv[1:]
		case "help", "--help", "-h":
			args.help = true
			return args, nil
		default:
			if !strings.HasPrefix(argv[0], "-") {
				return args, fmt.Errorf("unknown command %q", argv[0])
			}
		}
	}

	flags := flag.NewFlagSet(args.command, flag.ContinueOnError)
	flags.SetOutput(io.Discard)
	flags.BoolVar(&args.help, "help", false, "Show help")
	flags.BoolVar(&args.help, "h", false, "Show help")
	flags.BoolVar(&args.validateOnly, "validate-only", false, "Render and validate generated templates, then exit")
	flags.BoolVar(&args.verbose, "vv", args.verbose, "Show SSH/bootstrap logs")
	flags.BoolVar(&args.verbose, "verbose", args.verbose, "Show SSH/bootstrap logs")
	flags.StringVar(&args.version, "version", defaultVersion, "Arche image version tag, for example v1.2.3 or latest")

	switch args.command {
	case "install":
		flags.StringVar(&args.token, "token", "", "DigitalOcean API token")
		flags.StringVar(&args.email, "email", "", "Email for the Arche admin account and Let's Encrypt")
		flags.StringVar(&args.size, "size", defaultDropletSize, "Droplet size slug")
		flags.StringVar(&args.region, "region", defaultDropletRegion, "Droplet region slug")
		flags.BoolVar(&args.skipHealthWait, "skip-health-wait", false, "Do not wait for the public URL to answer")
	case "update":
		flags.StringVar(&args.token, "token", "", "DigitalOcean API token")
		flags.StringVar(&args.statePath, "state", "", "Path to deployment state JSON")
		flags.StringVar(&args.ipAddress, "ip", "", "Server IPv4 address when no state file is available")
		flags.StringVar(&args.sshKeyPath, "ssh-key", "", "SSH private key path when no state file is available")
		flags.StringVar(&args.sshPassword, "ssh-password", "", "Root SSH password when no state file is available")
		flags.StringVar(&args.publicURL, "url", "", "Public app URL when no state file is available")
		flags.BoolVar(&args.skipHealthWait, "skip-health-wait", false, "Do not wait for the public URL to answer after update")
	case "destroy":
		flags.StringVar(&args.token, "token", "", "DigitalOcean API token")
		flags.StringVar(&args.statePath, "state", "", "Path to deployment state JSON")
		flags.IntVar(&args.dropletID, "droplet-id", 0, "DigitalOcean Droplet id when no state file is available")
		flags.StringVar(&args.firewallID, "firewall-id", "", "DigitalOcean firewall id when no state file is available")
		flags.BoolVar(&args.yes, "yes", false, "Destroy without interactive confirmation")
	default:
		return args, fmt.Errorf("unknown command %q", args.command)
	}

	if err := flags.Parse(argv); err != nil {
		return args, err
	}
	if flags.NArg() > 0 {
		return args, fmt.Errorf("unexpected argument %q", flags.Arg(0))
	}
	if args.command == "update" && args.version == defaultVersion {
		return args, errors.New("update requires --version vX.X.X")
	}
	return args, nil
}

func printUsage(w io.Writer) {
	fmt.Fprintln(w, `Usage:
  archectl install [--token TOKEN] --email EMAIL [--version vX.X.X] [-vv]
  archectl update --token TOKEN --version vX.X.X [--state PATH] [-vv]
  archectl destroy --token TOKEN [--state PATH] [--yes]

Recovery without a state file:
  archectl update --token TOKEN --version vX.X.X --ip IP (--ssh-key PATH | --ssh-password PASSWORD) [--url URL]
  archectl destroy --token TOKEN --droplet-id ID [--firewall-id ID] --yes

If no command is provided, archectl defaults to install for curl|bash compatibility.
State defaults to ~/.arche/deployments/current.json.
Use -vv or --verbose to show SSH/bootstrap logs.`)
}

func collectInput(args cliArgs, ui *ui) (input, error) {
	token, err := collectDOToken(args.token)
	if err != nil {
		return input{}, err
	}
	reader := bufio.NewReader(os.Stdin)

	email := strings.TrimSpace(args.email)
	if email == "" {
		email = strings.TrimSpace(os.Getenv("ARCHE_INSTALL_EMAIL"))
	}
	if email == "" {
		fmt.Print("Email for the Arche admin account and Let's Encrypt: ")
		raw, err := reader.ReadString('\n')
		if err != nil {
			return input{}, fmt.Errorf("read email: %w", err)
		}
		email = strings.TrimSpace(raw)
	}

	if token == "" {
		return input{}, errors.New("DigitalOcean API token is required")
	}
	emailPattern := regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
	if !emailPattern.MatchString(email) {
		return input{}, errors.New("enter a valid email address")
	}

	ui.panel("Deployment plan", [][2]string{
		{"Droplet image", dropletImage},
		{"Droplet size", args.size},
		{"Droplet region", args.region},
		{"Version", args.version},
		{"Web image", appImageForVersion(args.version)},
		{"Workspace image", workspaceImageForVersion(args.version)},
		{"Public URL", "https://arche-<droplet-ip>.nip.io"},
	}, "info")
	ui.warn("The default public hostname depends on nip.io DNS. If nip.io is unavailable, you will need to point your own domain at the Droplet.")
	fmt.Print("Continue and create the Droplet? (Y/n): ")
	answer, err := reader.ReadString('\n')
	if err != nil {
		return input{}, fmt.Errorf("read confirmation: %w", err)
	}
	answer = strings.ToLower(strings.TrimSpace(answer))
	if answer != "" && answer != "y" && answer != "yes" {
		return input{}, errors.New("deployment cancelled")
	}

	return input{token: token, email: email, size: args.size, region: args.region, version: args.version}, nil
}

func ensureLocalSSHDependencies() error {
	if findSSH() == "" {
		return errors.New("local ssh client not found")
	}
	if findSSHKeyscan() == "" {
		return errors.New("local ssh-keyscan client not found")
	}
	return nil
}

func generateSSHMaterial() (sshMaterial, error) {
	privateKey, err := rsa.GenerateKey(rand.Reader, 3072)
	if err != nil {
		return sshMaterial{}, fmt.Errorf("generate SSH private key: %w", err)
	}
	publicKey, err := marshalAuthorizedRSAPublicKey(&privateKey.PublicKey)
	if err != nil {
		return sshMaterial{}, fmt.Errorf("encode SSH public key: %w", err)
	}
	privatePEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})
	return sshMaterial{privateKey: string(privatePEM), publicKey: publicKey}, nil
}

func marshalAuthorizedRSAPublicKey(key *rsa.PublicKey) (string, error) {
	var payload bytes.Buffer
	writeSSHString(&payload, []byte("ssh-rsa"))
	writeSSHMPInt(&payload, big.NewInt(int64(key.E)))
	writeSSHMPInt(&payload, key.N)
	return "ssh-rsa " + base64.StdEncoding.EncodeToString(payload.Bytes()) + " archectl\n", nil
}

func writeSSHString(buf *bytes.Buffer, value []byte) {
	_ = binary.Write(buf, binary.BigEndian, uint32(len(value)))
	_, _ = buf.Write(value)
}

func writeSSHMPInt(buf *bytes.Buffer, value *big.Int) {
	raw := value.Bytes()
	if len(raw) == 0 {
		writeSSHString(buf, nil)
		return
	}
	if raw[0]&0x80 != 0 {
		raw = append([]byte{0}, raw...)
	}
	writeSSHString(buf, raw)
}

func randB64(length int) string {
	buf := make([]byte, length)
	if _, err := rand.Read(buf); err != nil {
		panic(err)
	}
	return base64.StdEncoding.EncodeToString(buf)
}

func randPassword(length int) string {
	const alphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789-_"
	var out strings.Builder
	for out.Len() < length {
		n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
		if err != nil {
			panic(err)
		}
		out.WriteByte(alphabet[n.Int64()])
	}
	return out.String()
}

func newDOClient(token string) *doClient {
	return &doClient{
		token:   token,
		http:    &http.Client{Timeout: 30 * time.Second},
		baseURL: doAPIBase,
	}
}

func (c *doClient) request(ctx context.Context, method string, path string, body any, out any) error {
	var rawBody []byte
	if body != nil {
		encoded, err := json.Marshal(body)
		if err != nil {
			return err
		}
		rawBody = encoded
	}
	for attempt := 1; ; attempt++ {
		var reader io.Reader
		if rawBody != nil {
			reader = bytes.NewReader(rawBody)
		}

		req, err := http.NewRequestWithContext(ctx, method, c.apiBaseURL()+path, reader)
		if err != nil {
			return err
		}
		req.Header.Set("Authorization", "Bearer "+c.token)
		req.Header.Set("Accept", "application/json")
		if body != nil {
			req.Header.Set("Content-Type", "application/json")
		}

		resp, err := c.http.Do(req)
		if err != nil {
			return err
		}
		raw, _ := io.ReadAll(resp.Body)
		_ = resp.Body.Close()

		if shouldRetryDORequest(resp.StatusCode) && attempt < 6 {
			if err := sleepWithContext(ctx, digitalOceanRetryDelay(resp, attempt)); err != nil {
				return err
			}
			continue
		}

		if resp.StatusCode < 200 || resp.StatusCode >= 300 {
			var payload struct {
				Message string `json:"message"`
				ID      string `json:"id"`
			}
			_ = json.Unmarshal(raw, &payload)
			message := strings.TrimSpace(payload.Message)
			if message == "" {
				message = strings.TrimSpace(payload.ID)
			}
			if message == "" {
				message = strings.TrimSpace(string(raw))
			}
			if message == "" {
				message = http.StatusText(resp.StatusCode)
			}
			return fmt.Errorf("DigitalOcean API error (%d): %s", resp.StatusCode, message)
		}
		if out == nil || len(raw) == 0 {
			return nil
		}
		return json.Unmarshal(raw, out)
	}
}

func (c *doClient) apiBaseURL() string {
	if strings.TrimSpace(c.baseURL) != "" {
		return c.baseURL
	}
	return doAPIBase
}

func shouldRetryDORequest(statusCode int) bool {
	return statusCode == http.StatusTooManyRequests || statusCode == http.StatusBadGateway || statusCode == http.StatusServiceUnavailable || statusCode == http.StatusGatewayTimeout
}

func digitalOceanRetryDelay(resp *http.Response, attempt int) time.Duration {
	if retryAfter := strings.TrimSpace(resp.Header.Get("Retry-After")); retryAfter != "" {
		if seconds, err := strconv.Atoi(retryAfter); err == nil && seconds >= 0 {
			return time.Duration(seconds) * time.Second
		}
		if retryAt, err := http.ParseTime(retryAfter); err == nil {
			if delay := time.Until(retryAt); delay > 0 {
				return delay
			}
		}
	}
	if reset := strings.TrimSpace(resp.Header.Get("RateLimit-Reset")); reset != "" {
		if epoch, err := strconv.ParseInt(reset, 10, 64); err == nil {
			if delay := time.Until(time.Unix(epoch, 0)); delay > 0 {
				return delay
			}
		}
	}

	delay := time.Second << min(attempt-1, 4)
	if delay > 30*time.Second {
		return 30 * time.Second
	}
	return delay
}

func sleepWithContext(ctx context.Context, delay time.Duration) error {
	timer := time.NewTimer(delay)
	defer timer.Stop()
	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-timer.C:
		return nil
	}
}

func (c *doClient) validateToken(ctx context.Context) error {
	return c.request(ctx, http.MethodGet, "/account", nil, &struct{}{})
}

func createDroplet(ctx context.Context, client *doClient, data input, userData string) (int, string, error) {
	name := makeDropletName()
	payload := map[string]any{
		"name":       name,
		"region":     data.region,
		"size":       data.size,
		"image":      dropletImage,
		"user_data":  userData,
		"backups":    false,
		"ipv6":       true,
		"monitoring": true,
		"tags":       []string{appName, "one-click"},
	}
	var response struct {
		Droplet struct {
			ID int `json:"id"`
		} `json:"droplet"`
	}
	if err := client.request(ctx, http.MethodPost, "/droplets", payload, &response); err != nil {
		return 0, "", err
	}
	if response.Droplet.ID == 0 {
		return 0, "", errors.New("DigitalOcean did not return a Droplet id")
	}
	return response.Droplet.ID, name, nil
}

func waitForDropletIP(ctx context.Context, client *doClient, dropletID int, ui *ui) (string, error) {
	started := time.Now()
	timeout := 10 * time.Minute
	ticker := time.NewTicker(5 * time.Second)
	defer ticker.Stop()
	attempt := 0
	lastStatus := ""
	for {
		attempt++
		var response struct {
			Droplet struct {
				Status   string `json:"status"`
				Networks struct {
					V4 []struct {
						IPAddress string `json:"ip_address"`
						Type      string `json:"type"`
					} `json:"v4"`
				} `json:"networks"`
			} `json:"droplet"`
		}
		if err := client.request(ctx, http.MethodGet, fmt.Sprintf("/droplets/%d", dropletID), nil, &response); err != nil {
			return "", err
		}
		ip := ""
		for _, network := range response.Droplet.Networks.V4 {
			if network.Type == "public" && network.IPAddress != "" {
				ip = network.IPAddress
				break
			}
		}
		if response.Droplet.Status == "active" && ip != "" {
			ui.progressDone("Droplet provisioning", timeout, "status=active | public IPv4="+ip)
			return ip, nil
		}
		if attempt == 1 || attempt%2 == 0 || response.Droplet.Status != lastStatus {
			details := "status=" + fallback(response.Droplet.Status, "new")
			if ip == "" {
				details += " | public IPv4 pending"
			} else {
				details += " | assigned IPv4=" + ip + " | waiting for active"
			}
			ui.progressWait("Droplet provisioning", time.Since(started), timeout, details)
		}
		lastStatus = response.Droplet.Status
		if time.Since(started) > timeout {
			return "", errors.New("timed out waiting for the Droplet to become active and receive a public IP")
		}
		select {
		case <-ctx.Done():
			return "", ctx.Err()
		case <-ticker.C:
		}
	}
}

func createFirewall(ctx context.Context, client *doClient, dropletID int, dropletName string) (string, error) {
	anyAddresses := []string{"0.0.0.0/0", "::/0"}
	payload := map[string]any{
		"name":        dropletName + "-firewall",
		"droplet_ids": []int{dropletID},
		"inbound_rules": []map[string]any{
			{"protocol": "tcp", "ports": "22", "sources": map[string]any{"addresses": anyAddresses}},
			{"protocol": "tcp", "ports": "80", "sources": map[string]any{"addresses": anyAddresses}},
			{"protocol": "tcp", "ports": "443", "sources": map[string]any{"addresses": anyAddresses}},
		},
		"outbound_rules": []map[string]any{
			{"protocol": "tcp", "ports": "all", "destinations": map[string]any{"addresses": anyAddresses}},
			{"protocol": "udp", "ports": "all", "destinations": map[string]any{"addresses": anyAddresses}},
			{"protocol": "icmp", "destinations": map[string]any{"addresses": anyAddresses}},
		},
	}
	var response struct {
		Firewall struct {
			ID string `json:"id"`
		} `json:"firewall"`
	}
	if err := client.request(ctx, http.MethodPost, "/firewalls", payload, &response); err != nil {
		return "", err
	}
	if response.Firewall.ID == "" {
		return "", errors.New("DigitalOcean did not return a firewall id")
	}
	return response.Firewall.ID, nil
}

func deleteFirewall(ctx context.Context, client *doClient, firewallID string) error {
	if firewallID == "" {
		return nil
	}
	return client.request(ctx, http.MethodDelete, "/firewalls/"+firewallID, nil, nil)
}

func deleteDroplet(ctx context.Context, client *doClient, dropletID int) error {
	if dropletID == 0 {
		return errors.New("state file is missing Droplet id")
	}
	return client.request(ctx, http.MethodDelete, fmt.Sprintf("/droplets/%d", dropletID), nil, nil)
}

func waitForSSHPort(ctx context.Context, ip string, ui *ui) bool {
	started := time.Now()
	timeout := 5 * time.Minute
	last := "connection pending"
	for {
		conn, err := net.DialTimeout("tcp", net.JoinHostPort(ip, "22"), 5*time.Second)
		if err == nil {
			_ = conn.Close()
			ui.progressDone("SSH availability", timeout, "port=22 | ready")
			return true
		}
		last = err.Error()
		ui.progressWait("SSH availability", time.Since(started), timeout, "port=22 | last="+last)
		if time.Since(started) > timeout {
			return false
		}
		select {
		case <-ctx.Done():
			return false
		case <-time.After(10 * time.Second):
		}
	}
}

func waitForPublicHealth(ctx context.Context, publicURL string, ui *ui, timeout time.Duration) (healthResult, error) {
	target := publicURL + "/api/health"
	client := &http.Client{Timeout: 10 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}}}
	started := time.Now()
	attempt := 0
	last := "waiting for first successful health check"
	for time.Since(started) < timeout {
		attempt++
		req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
		resp, err := client.Do(req)
		if err == nil {
			_ = resp.Body.Close()
			if resp.StatusCode == http.StatusOK {
				ui.progressDone("Public health check", timeout, fmt.Sprintf("attempt=%d | last=HTTP 200 | remaining=0s", attempt))
				return healthResult{Ready: true, ElapsedSeconds: int(time.Since(started).Seconds()), LastStatus: "HTTP 200"}, nil
			}
			last = fmt.Sprintf("HTTP %d", resp.StatusCode)
		} else {
			last = err.Error()
			var urlErr *url.Error
			if errors.As(err, &urlErr) {
				last = urlErr.Err.Error()
			}
		}
		remaining := max(time.Duration(0), timeout-time.Since(started))
		ui.progressWait("Public health check", time.Since(started), timeout, fmt.Sprintf("attempt=%d | last=%s | remaining=%s", attempt, last, formatDuration(remaining)))
		select {
		case <-ctx.Done():
			return healthResult{Ready: false, ElapsedSeconds: int(time.Since(started).Seconds()), LastStatus: last}, ctx.Err()
		case <-time.After(15 * time.Second):
		}
	}
	return healthResult{Ready: false, ElapsedSeconds: int(time.Since(started).Seconds()), LastStatus: last}, fmt.Errorf("timed out waiting for public health check. Last status: %s", last)
}

func runUpdate(ctx context.Context, ui *ui, args cliArgs) int {
	state, statePath, err := stateForUpdate(args)
	if err != nil {
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}
	token, err := collectDOToken(args.token)
	if err != nil {
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}

	webImage := appImageForVersion(args.version)
	workspaceImage := workspaceImageForVersion(args.version)
	ui.panel("Update plan", [][2]string{
		{"State file", statePath},
		{"Server IP", state.Deployment.IPAddress},
		{"App URL", state.Deployment.PublicURL},
		{"From version", fallback(state.Version, "unknown")},
		{"To version", args.version},
		{"Web image", webImage},
		{"Workspace image", workspaceImage},
	}, "info")

	if err := ensureSSHKnownHost(ctx, ui, &state); err != nil {
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}

	client := newDOClient(token)
	started := ui.stage("Validating DigitalOcean API token")
	if err := client.validateToken(ctx); err != nil {
		if errors.Is(err, context.Canceled) {
			ui.warn("Cancelled by user.")
			return 130
		}
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}
	ui.ok("DigitalOcean API token accepted", started)

	started = ui.stage("Updating remote Compose configuration")
	script := renderUpdateScript(args.version, renderCompose())
	if err := runRemoteScript(ctx, ui, sshTarget{ip: state.Deployment.IPAddress, user: sshUserForState(state), auth: sshAuthForState(state)}, script); err != nil {
		if errors.Is(err, context.Canceled) {
			ui.warn("Cancelled by user.")
			return 130
		}
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}
	ui.ok("Remote stack updated to "+args.version, started)

	state.Version = args.version
	state.AppImage = webImage
	state.WorkspaceImage = workspaceImage
	state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
	writePath := statePath
	if writePath == "manual flags" {
		writePath, err = defaultStatePath()
		if err != nil {
			fmt.Fprintln(os.Stderr, "Error:", err)
			return 1
		}
		state.Deployment.JSONFile = writePath
	}
	if err := writeStateFiles(state, writePath); err != nil {
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}

	if args.skipHealthWait {
		ui.warn("Skipping public health wait by request.")
		printSummary(ui, state.Deployment, nil)
		return 0
	}

	started = ui.stage("Waiting for updated public health check")
	health, err := waitForPublicHealth(ctx, state.Deployment.PublicURL, ui, defaultHealthWait)
	if err != nil {
		if errors.Is(err, context.Canceled) {
			ui.warn("Cancelled by user.")
			return 130
		}
		ui.warn(err.Error(), started)
	} else if health.Ready {
		ui.ok("Updated version answered the public health check", started)
	}
	printSummary(ui, state.Deployment, &health)
	return 0
}

func stateForUpdate(args cliArgs) (deploymentState, string, error) {
	if args.ipAddress != "" || args.sshPassword != "" || args.sshKeyPath != "" {
		if args.ipAddress == "" {
			return deploymentState{}, "", errors.New("--ip is required when using manual SSH recovery flags")
		}
		if args.sshPassword != "" && args.sshKeyPath != "" {
			return deploymentState{}, "", errors.New("provide either --ssh-key or --ssh-password, not both")
		}
		if args.sshPassword == "" && args.sshKeyPath == "" {
			return deploymentState{}, "", errors.New("manual recovery requires --ssh-key or --ssh-password")
		}
		privateKey := ""
		if args.sshKeyPath != "" {
			key, err := readSSHPrivateKey(args.sshKeyPath)
			if err != nil {
				return deploymentState{}, "", err
			}
			privateKey = key
		}
		publicURL := strings.TrimSpace(args.publicURL)
		if publicURL == "" {
			publicURL = "https://" + buildDomainFromIP(args.ipAddress)
		}
		return deploymentState{
			AppName:        appName,
			Version:        defaultVersion,
			AppImage:       appImageForVersion(defaultVersion),
			WorkspaceImage: workspaceImageForVersion(defaultVersion),
			DBName:         dbName,
			Deployment: artifacts{
				IPAddress: args.ipAddress,
				Domain:    strings.TrimPrefix(strings.TrimPrefix(publicURL, "https://"), "http://"),
				PublicURL: publicURL,
			},
			Secrets: stateSecrets{RootPassword: args.sshPassword, SSHPrivateKey: privateKey},
		}, "manual flags", nil
	}
	return readState(args.statePath)
}

func runDestroy(ctx context.Context, ui *ui, args cliArgs) int {
	state, statePath, err := stateForDestroy(args)
	if err != nil {
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}

	token, err := collectDOToken(args.token)
	if err != nil {
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}

	ui.panel("Destroy plan", [][2]string{
		{"State file", statePath},
		{"Droplet", fmt.Sprintf("%s (%d)", state.Deployment.DropletName, state.Deployment.DropletID)},
		{"Firewall", fallback(state.Deployment.FirewallID, "-")},
		{"Server IP", state.Deployment.IPAddress},
		{"App URL", state.Deployment.PublicURL},
	}, "warn")
	ui.warn("Destroy permanently deletes the Droplet, /opt/arche data, and the PostgreSQL volume. Create a DigitalOcean snapshot or backup first if you need recovery.")

	if !args.yes {
		reader := bufio.NewReader(os.Stdin)
		fmt.Print("Type DESTROY to permanently delete this Droplet: ")
		answer, err := reader.ReadString('\n')
		if err != nil {
			fmt.Fprintln(os.Stderr, "Error:", err)
			return 1
		}
		if strings.TrimSpace(answer) != "DESTROY" {
			fmt.Fprintln(os.Stderr, "Error: destroy cancelled")
			return 1
		}
	}

	client := newDOClient(token)
	started := ui.stage("Validating DigitalOcean API token")
	if err := client.validateToken(ctx); err != nil {
		if errors.Is(err, context.Canceled) {
			ui.warn("Cancelled by user.")
			return 130
		}
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}
	ui.ok("DigitalOcean API token accepted", started)

	if state.Deployment.FirewallID != "" {
		started = ui.stage("Deleting firewall")
		if err := deleteFirewall(ctx, client, state.Deployment.FirewallID); err != nil {
			ui.warn("Firewall delete returned: "+err.Error(), started)
		} else {
			ui.ok("Deleted firewall "+state.Deployment.FirewallID, started)
		}
	}

	started = ui.stage("Deleting Droplet")
	if err := deleteDroplet(ctx, client, state.Deployment.DropletID); err != nil {
		if errors.Is(err, context.Canceled) {
			ui.warn("Cancelled by user.")
			return 130
		}
		fmt.Fprintln(os.Stderr, "Error:", err)
		return 1
	}
	ui.ok("Deleted Droplet "+state.Deployment.DropletName, started)
	ui.warn("Local credential/state files were left in place. Remove them manually if you no longer need recovery records.")
	return 0
}

func stateForDestroy(args cliArgs) (deploymentState, string, error) {
	if args.dropletID != 0 {
		return deploymentState{
			AppName: appName,
			Deployment: artifacts{
				DropletID:   args.dropletID,
				FirewallID:  args.firewallID,
				DropletName: fmt.Sprintf("%s-%d", appName, args.dropletID),
			},
		}, "manual flags", nil
	}
	return readState(args.statePath)
}

func renderUpdateScript(version, compose string) string {
	return fmt.Sprintf(`set -euo pipefail
timestamp() { date -u +"%%Y-%%m-%%dT%%H:%%M:%%SZ"; }
log() { printf '[update][%%s] %%s\n' "$(timestamp)" "$1"; }
set_env() {
  key="$1"
  value="$2"
  sed -i "/^${key}=/d" /opt/%s/.env
  printf '%%s=%%s\n' "$key" "$value" >> /opt/%s/.env
}
cd /opt/%s
log 'Backing up current Compose and env files'
cp docker-compose.yml "docker-compose.yml.bak.$(date -u +%%Y%%m%%d-%%H%%M%%S)"
cp .env ".env.bak.$(date -u +%%Y%%m%%d-%%H%%M%%S)"
log 'Writing updated docker-compose.yml'
printf %%s %s | base64 -d > docker-compose.yml
log 'Writing versioned image settings'
set_env ARCHE_VERSION %s
set_env ARCHE_RELEASE_VERSION %s
set_env ARCHE_WEB_IMAGE %s
set_env OPENCODE_IMAGE %s
set -a
. /opt/%s/.env
set +a
log "Pulling ${ARCHE_WEB_IMAGE}"
docker pull "${ARCHE_WEB_IMAGE}"
log "Pulling ${OPENCODE_IMAGE}"
docker pull "${OPENCODE_IMAGE}"
log 'Recreating web service'
docker compose up -d web
docker compose ps
log 'Update complete'
`, appName, appName, appName, shellQuote(b64(compose)), shellQuote(version), shellQuote(version), shellQuote(appImageForVersion(version)), shellQuote(workspaceImageForVersion(version)), appName)
}

type sshTarget struct {
	ip   string
	user string
	auth sshAuth
}

func sshAuthForState(state deploymentState) sshAuth {
	return sshAuth{
		privateKey: state.Secrets.SSHPrivateKey,
		password:   state.Secrets.RootPassword,
		knownHost:  state.Secrets.SSHKnownHost,
	}
}

func sshUserForState(state deploymentState) string {
	if strings.TrimSpace(state.Secrets.SSHPrivateKey) != "" {
		return deploySSHUser
	}
	return rootSSHUser
}

func (t sshTarget) login() string {
	return t.user + "@" + t.ip
}

func (t sshTarget) shell(command string) string {
	if t.user == rootSSHUser {
		return "bash -lc " + shellQuote(command)
	}
	return "sudo -n bash -lc " + shellQuote(command)
}

func (t sshTarget) stdinScriptCommand() string {
	if t.user == rootSSHUser {
		return "bash -se"
	}
	return "sudo -n bash -se"
}

func (t sshTarget) streamCommand(command string) string {
	if t.user == rootSSHUser {
		return "sh -lc " + shellQuote(command)
	}
	return "sudo -n sh -lc " + shellQuote(command)
}

func displaySSHCommand(target sshTarget, state deploymentState) string {
	var command strings.Builder
	if target.auth.hasPrivateKey() {
		fmt.Fprintf(&command, "ssh -i %s", state.Deployment.SSHKeyFile)
		if state.Deployment.KnownHostsFile != "" {
			fmt.Fprintf(&command, " -o UserKnownHostsFile=%s", state.Deployment.KnownHostsFile)
		}
	} else {
		command.WriteString("ssh")
	}
	fmt.Fprintf(&command, " %s", target.login())
	return command.String()
}

func displaySSHRemoteCommand(target sshTarget, state deploymentState, remoteCommand string) string {
	return displaySSHCommand(target, state) + " " + shellQuote(target.shell(remoteCommand))
}

func (a sshAuth) hasPrivateKey() bool {
	return strings.TrimSpace(a.privateKey) != ""
}

func (a sshAuth) hasPassword() bool {
	return strings.TrimSpace(a.password) != ""
}

func (a sshAuth) validate() error {
	if a.hasPrivateKey() || a.hasPassword() {
		return nil
	}
	return errors.New("SSH authentication details are missing")
}

func ensureSSHKnownHost(ctx context.Context, ui *ui, state *deploymentState) error {
	if strings.TrimSpace(state.Secrets.SSHKnownHost) != "" || strings.TrimSpace(state.Deployment.IPAddress) == "" {
		return nil
	}
	if findSSHKeyscan() == "" {
		ui.warn("ssh-keyscan is not available locally. Falling back to accept-new host key trust for this update.")
		return nil
	}
	started := ui.stage("Pinning the server SSH host key")
	knownHost, err := scanSSHHostKey(ctx, state.Deployment.IPAddress)
	if err != nil {
		if errors.Is(err, context.Canceled) {
			return err
		}
		ui.warn("Could not pre-pin the SSH host key: "+err.Error(), started)
		return nil
	}
	state.Secrets.SSHKnownHost = knownHost
	ui.ok("Pinned the server SSH host key", started)
	return nil
}

func runRemoteScript(ctx context.Context, ui *ui, target sshTarget, script string) error {
	sshPath := findSSH()
	if sshPath == "" {
		return errors.New("local ssh client not found")
	}
	args, env, cleanup, err := prepareSSHInvocation(target.auth, 15, "archectl")
	if err != nil {
		return err
	}
	defer cleanup()

	cmdArgs := append(args, target.login(), target.stdinScriptCommand())
	cmd := exec.CommandContext(ctx, sshPath, cmdArgs...)
	cmd.Stdin = strings.NewReader(script)
	cmd.Env = env
	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}

	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return err
	}
	stderr, err := cmd.StderrPipe()
	if err != nil {
		return err
	}
	if err := cmd.Start(); err != nil {
		return err
	}

	progressDone := make(chan struct{})
	if !ui.verbose {
		started := time.Now()
		go func() {
			ticker := time.NewTicker(2 * time.Second)
			defer ticker.Stop()
			for {
				select {
				case <-progressDone:
					return
				case <-ticker.C:
					ui.progressWait("Remote update", time.Since(started), 10*time.Minute, "pulling images and restarting web")
				}
			}
		}()
	}

	var wg sync.WaitGroup
	scan := func(r io.Reader) {
		defer wg.Done()
		scanner := bufio.NewScanner(r)
		for scanner.Scan() {
			line := strings.TrimSpace(scanner.Text())
			if line != "" {
				ui.remote(line)
			}
		}
	}
	wg.Add(2)
	go scan(stdout)
	go scan(stderr)
	err = cmd.Wait()
	wg.Wait()
	close(progressDone)
	if err == nil && !ui.verbose {
		ui.progressDone("Remote update", 10*time.Minute, "web restarted")
	}
	return err
}

type remoteStreamer struct {
	ui        *ui
	target    sshTarget
	sshPath   string
	cancel    context.CancelFunc
	attached  chan struct{}
	done      chan struct{}
	cleanup   func()
	sshArgs   []string
	sshEnv    []string
	mu        sync.Mutex
	complete  bool
	lastError string
}

func newRemoteStreamer(ui *ui, target sshTarget) *remoteStreamer {
	return &remoteStreamer{ui: ui, target: target, sshPath: findSSH(), attached: make(chan struct{}), done: make(chan struct{})}
}

func (s *remoteStreamer) available() bool {
	return s.sshPath != ""
}

func (s *remoteStreamer) bootComplete() bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.complete
}

func (s *remoteStreamer) start(parent context.Context) error {
	if s.sshPath == "" {
		return errors.New("ssh not found")
	}
	args, env, cleanup, err := prepareSSHInvocation(s.target.auth, 10, "arche-installer")
	if err != nil {
		return err
	}
	ctx, cancel := context.WithCancel(parent)
	s.cancel = cancel
	s.cleanup = cleanup
	s.sshArgs = args
	s.sshEnv = env
	go s.run(ctx)
	return nil
}

func (s *remoteStreamer) waitUntilAttached(timeout time.Duration) bool {
	select {
	case <-s.attached:
		return true
	case <-time.After(timeout):
		return false
	}
}

func (s *remoteStreamer) stop() {
	if s.cancel != nil {
		s.cancel()
	}
	select {
	case <-s.done:
	case <-time.After(5 * time.Second):
	}
	if s.cleanup != nil {
		s.cleanup()
	}
}

func (s *remoteStreamer) run(ctx context.Context) {
	defer close(s.done)
	attachedClosed := false
	for ctx.Err() == nil {
		cmdArgs := append(append([]string{}, s.sshArgs...), s.target.login(), s.target.streamCommand(fmt.Sprintf("touch /var/log/%s-bootstrap.log && tail -n +1 -F /var/log/%s-bootstrap.log", appName, appName)))
		cmd := exec.CommandContext(ctx, s.sshPath, cmdArgs...)
		cmd.Stdin = nil
		cmd.Stderr = nil
		cmd.Env = s.sshEnv
		cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
		stdout, err := cmd.StdoutPipe()
		if err != nil {
			s.setError(err.Error())
			return
		}
		if err := cmd.Start(); err != nil {
			s.setError(err.Error())
			return
		}
		if !attachedClosed {
			close(s.attached)
			attachedClosed = true
		}
		scanner := bufio.NewScanner(stdout)
		for scanner.Scan() {
			line := strings.TrimSpace(scanner.Text())
			if line == "" {
				continue
			}
			s.ui.remote(line)
			if strings.Contains(line, "Bootstrap complete") {
				s.mu.Lock()
				s.complete = true
				s.mu.Unlock()
			}
		}
		err = cmd.Wait()
		if ctx.Err() != nil || s.bootComplete() {
			return
		}
		if err != nil {
			s.setError(err.Error())
		}
		s.ui.wait("Remote bootstrap log stream dropped. Retrying in 5s.")
		select {
		case <-ctx.Done():
			return
		case <-time.After(5 * time.Second):
		}
	}
}

func (s *remoteStreamer) setError(message string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.lastError = message
}

func runRemoteCommand(ctx context.Context, target sshTarget, command string) (string, error) {
	sshPath := findSSH()
	if sshPath == "" {
		return "", errors.New("local ssh client not found")
	}
	args, env, cleanup, err := prepareSSHInvocation(target.auth, 15, "archectl")
	if err != nil {
		return "", err
	}
	defer cleanup()

	cmdArgs := append(args, target.login(), target.shell(command))
	cmd := exec.CommandContext(ctx, sshPath, cmdArgs...)
	cmd.Env = env
	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
	output, err := cmd.CombinedOutput()
	if err != nil {
		return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(string(output)))
	}
	return string(output), nil
}

func prepareSSHInvocation(auth sshAuth, connectTimeout int, display string) ([]string, []string, func(), error) {
	if err := auth.validate(); err != nil {
		return nil, nil, nil, err
	}

	args := []string{
		"-o", "LogLevel=ERROR",
		"-o", fmt.Sprintf("ConnectTimeout=%d", connectTimeout),
	}
	env := append([]string{}, os.Environ()...)
	cleanupFns := []func(){}
	cleanup := func() {
		for i := len(cleanupFns) - 1; i >= 0; i-- {
			cleanupFns[i]()
		}
	}

	knownHostsPath, err := createKnownHostsFile(auth.knownHost)
	if err != nil {
		return nil, nil, nil, err
	}
	cleanupFns = append(cleanupFns, func() { _ = os.Remove(knownHostsPath) })
	if strings.TrimSpace(auth.knownHost) != "" {
		args = append(args, "-o", "StrictHostKeyChecking=yes")
	} else {
		args = append(args, "-o", "StrictHostKeyChecking=accept-new")
	}
	args = append(args, "-o", "UserKnownHostsFile="+knownHostsPath)

	if auth.hasPrivateKey() {
		keyPath, err := createSSHKeyFile(auth.privateKey)
		if err != nil {
			cleanup()
			return nil, nil, nil, err
		}
		cleanupFns = append(cleanupFns, func() { _ = os.Remove(keyPath) })
		args = append(args,
			"-i", keyPath,
			"-o", "IdentitiesOnly=yes",
			"-o", "PreferredAuthentications=publickey",
			"-o", "PasswordAuthentication=no",
			"-o", "KbdInteractiveAuthentication=no",
		)
		return args, env, cleanup, nil
	}

	askpassPath, err := createAskpassScript()
	if err != nil {
		cleanup()
		return nil, nil, nil, err
	}
	cleanupFns = append(cleanupFns, func() { _ = os.Remove(askpassPath) })
	env = append(env,
		"DISPLAY="+display,
		"SSH_ASKPASS="+askpassPath,
		"SSH_ASKPASS_REQUIRE=force",
		"ARCHE_SSH_PASSWORD="+auth.password,
	)
	args = append(args,
		"-o", "PreferredAuthentications=password",
		"-o", "PubkeyAuthentication=no",
		"-o", "KbdInteractiveAuthentication=no",
		"-o", "PasswordAuthentication=yes",
		"-o", "NumberOfPasswordPrompts=1",
	)
	return args, env, cleanup, nil
}

func createAskpassScript() (string, error) {
	file, err := os.CreateTemp("", "arche-askpass-*.sh")
	if err != nil {
		return "", err
	}
	path := file.Name()
	if _, err := file.WriteString("#!/bin/sh\nprintf '%s\\n' \"$ARCHE_SSH_PASSWORD\"\n"); err != nil {
		_ = file.Close()
		return "", err
	}
	if err := file.Close(); err != nil {
		return "", err
	}
	if err := os.Chmod(path, 0o700); err != nil {
		return "", err
	}
	return path, nil
}

func createSSHKeyFile(value string) (string, error) {
	file, err := os.CreateTemp("", "arche-key-*.pem")
	if err != nil {
		return "", err
	}
	path := file.Name()
	if _, err := file.WriteString(strings.TrimSpace(value) + "\n"); err != nil {
		_ = file.Close()
		return "", err
	}
	if err := file.Close(); err != nil {
		return "", err
	}
	if err := os.Chmod(path, 0o600); err != nil {
		return "", err
	}
	return path, nil
}

func createKnownHostsFile(value string) (string, error) {
	file, err := os.CreateTemp("", "arche-known-hosts-*")
	if err != nil {
		return "", err
	}
	path := file.Name()
	if strings.TrimSpace(value) != "" {
		if _, err := file.WriteString(strings.TrimSpace(value) + "\n"); err != nil {
			_ = file.Close()
			return "", err
		}
	}
	if err := file.Close(); err != nil {
		return "", err
	}
	if err := os.Chmod(path, 0o600); err != nil {
		return "", err
	}
	return path, nil
}

func findSSH() string {
	path, err := exec.LookPath("ssh")
	if err != nil {
		return ""
	}
	return path
}

func findSSHKeyscan() string {
	path, err := exec.LookPath("ssh-keyscan")
	if err != nil {
		return ""
	}
	return path
}

func scanSSHHostKey(ctx context.Context, ip string) (string, error) {
	sshKeyscanPath := findSSHKeyscan()
	if sshKeyscanPath == "" {
		return "", errors.New("local ssh-keyscan client not found")
	}
	cmd := exec.CommandContext(ctx, sshKeyscanPath, "-T", "10", ip)
	output, err := cmd.CombinedOutput()
	if err != nil {
		return "", fmt.Errorf("scan SSH host key: %w: %s", err, strings.TrimSpace(string(output)))
	}
	lines := []string{}
	for _, line := range strings.Split(string(output), "\n") {
		line = strings.TrimSpace(line)
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		lines = append(lines, line)
	}
	if len(lines) == 0 {
		return "", errors.New("ssh-keyscan returned no host keys")
	}
	return strings.Join(lines, "\n"), nil
}

func renderEnvFile(data input, secret stateSecrets, domain, publicURL string) string {
	return strings.TrimSpace(fmt.Sprintf(`
ACME_EMAIL=%s
ARCHE_DOMAIN=%s
ARCHE_PUBLIC_BASE_URL=%s
ARCHE_VERSION=%s
ARCHE_RELEASE_VERSION=%s
ARCHE_WEB_IMAGE=%s

DATABASE_URL=postgresql://postgres:%s@postgres:5432/%s?schema=public
POSTGRES_PASSWORD=%s

ARCHE_SESSION_PEPPER=%s
ARCHE_ENCRYPTION_KEY=%s
ARCHE_INTERNAL_TOKEN=%s
ARCHE_CONNECTOR_OAUTH_STATE_SECRET=%s
ARCHE_GATEWAY_TOKEN_SECRET=%s

ARCHE_COOKIE_SECURE=true
ARCHE_SESSION_TTL_DAYS=7

ARCHE_SEED_ADMIN_EMAIL=%s
ARCHE_SEED_ADMIN_PASSWORD=%s
ARCHE_SEED_ADMIN_SLUG=admin

CONTAINER_PROXY_HOST=docker-socket-proxy
CONTAINER_PROXY_PORT=2375
OPENCODE_IMAGE=%s
OPENCODE_NETWORK=arche-internal

KB_CONTENT_HOST_PATH=/opt/arche/kb-content
KB_CONFIG_HOST_PATH=/opt/arche/kb-config
ARCHE_USERS_PATH=/opt/arche/users
`, data.email, domain, publicURL, data.version, data.version, appImageForVersion(data.version), secret.PostgresPassword, dbName, secret.PostgresPassword, secret.SessionPepper, secret.EncryptionKey, secret.InternalToken, secret.ConnectorOAuthStateSecret, secret.GatewayTokenSecret, data.email, secret.AdminPassword, workspaceImageForVersion(data.version))) + "\n"
}

func renderCompose() string {
	return strings.TrimSpace(fmt.Sprintf(`
name: %s

services:
  traefik:
    image: docker.io/traefik:v3.6.7
    restart: unless-stopped
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --providers.docker.endpoint=tcp://docker-socket-proxy:2375
      - --entrypoints.web.address=:80
      - --entrypoints.web.http.redirections.entryPoint.to=websecure
      - --entrypoints.web.http.redirections.entryPoint.scheme=https
      - --entrypoints.websecure.address=:443
      - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}
      - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
      - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
      - --api.dashboard=false
      - --log.level=INFO
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - traefik_letsencrypt:/letsencrypt
    networks:
      - default
      - arche-internal
    depends_on:
      docker-socket-proxy:
        condition: service_healthy

  docker-socket-proxy:
    image: ghcr.io/tecnativa/docker-socket-proxy:latest
    restart: unless-stopped
    environment:
      CONTAINERS: 1
      NETWORKS: 1
      IMAGES: 1
      INFO: 1
      # Arche manages isolated per-user workspaces, so the proxy must allow
      # container lifecycle calls plus network and volume lookups for attach/mount flows.
      POST: 1
      VOLUMES: 1
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      - arche-internal
    healthcheck:
      test: ["CMD-SHELL", "test -S /var/run/docker.sock"]
      interval: 10s
      timeout: 5s
      retries: 5

  postgres:
    image: docker.io/postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_DB: %s
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
    volumes:
      - arche_postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  web:
    image: "${ARCHE_WEB_IMAGE}"
    restart: unless-stopped
    env_file:
      - .env
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - default
      - arche-internal
    volumes:
      - /opt/arche/users:/opt/arche/users
      - /opt/arche/kb-content:/kb-content
      - /opt/arche/kb-config:/kb-config
    labels:
      - traefik.enable=true
      - traefik.docker.network=arche-internal
      - traefik.http.routers.arche.rule=Host("${ARCHE_DOMAIN}")
      - traefik.http.routers.arche.entrypoints=websecure
      - traefik.http.routers.arche.service=arche-web
      - traefik.http.routers.arche.tls=true
      - traefik.http.routers.arche.tls.certresolver=letsencrypt
      - traefik.http.services.arche-web.loadbalancer.server.port=%d

networks:
  default:
  arche-internal:
    name: arche-internal

volumes:
  arche_postgres:
  traefik_letsencrypt:
`, appName, dbName, appPort)) + "\n"
}

func renderBootstrapScript(data input) string {
	return strings.TrimSpace(fmt.Sprintf(`
#!/usr/bin/env bash
set -euo pipefail

export DEBIAN_FRONTEND=noninteractive
STEP_NO=0
ADMIN_EMAIL=%s
ARCHE_VERSION=%s
ARCHE_RELEASE_VERSION=%s
ARCHE_WEB_IMAGE=%s
OPENCODE_IMAGE=%s

timestamp() { date -u +"%%Y-%%m-%%dT%%H:%%M:%%SZ"; }
log() { printf '[bootstrap][%%s] %%s\n' "$(timestamp)" "$1"; }
step() { STEP_NO=$((STEP_NO + 1)); printf '\n[bootstrap][%%s] STEP %%02d %%s\n' "$(timestamp)" "$STEP_NO" "$1"; }
fail() { printf '[bootstrap][%%s] ERROR exit=%%s line=%%s command=%%s\n' "$(timestamp)" "$1" "$2" "$3"; exit "$1"; }
trap 'fail "$?" "$LINENO" "$BASH_COMMAND"' ERR

metadata_ip() { curl -fsS http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address; }
ensure_bare_repo() {
  local repo="$1"
  if git --git-dir="$repo" rev-parse --is-bare-repository >/dev/null 2>&1; then return; fi
  mkdir -p "$(dirname "$repo")"
  rm -rf "$repo"
  git init --bare --initial-branch=main "$repo" >/dev/null 2>&1 || git init --bare "$repo" >/dev/null 2>&1
  git --git-dir="$repo" symbolic-ref HEAD refs/heads/main >/dev/null 2>&1 || true
}
rand_b64() {
  local bytes="${1:-32}"
  head -c "$bytes" /dev/urandom | base64 | tr -d '\n'
}
rand_password() {
  local length="$1"
  local value=""
  while [ "${#value}" -lt "$length" ]; do
    value="${value}$(head -c "$length" /dev/urandom | base64 | tr -dc 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789-_')"
  done
  printf '%%s' "${value:0:$length}"
}

step 'Configuring SSH access'
sed -i 's/^#\?PermitRootLogin .*/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/^#\?PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config
systemctl restart sshd || systemctl restart ssh
log 'SSH access is restricted to the generated deploy user key'

step 'Installing system packages'
apt-get update -y
apt-get install -y ca-certificates curl git sudo
log 'Installed ca-certificates, curl, git, and sudo'

step 'Installing Docker'
curl -fsSL https://get.docker.com | sh
systemctl enable --now docker
log "$(docker --version)"
log "$(docker compose version)"

step 'Deriving public endpoint'
PUBLIC_IP="$(metadata_ip)"
ARCHE_DOMAIN="%s-${PUBLIC_IP//./-}.nip.io"
ARCHE_PUBLIC_BASE_URL="https://${ARCHE_DOMAIN}"
log "Detected public IP ${PUBLIC_IP}"
log "Derived public domain ${ARCHE_DOMAIN}"
log "Using public URL ${ARCHE_PUBLIC_BASE_URL}"

step 'Preparing persistent directories and repositories'
mkdir -p /opt/%s /opt/%s/users /opt/%s/kb-content /opt/%s/kb-config
ensure_bare_repo /opt/%s/kb-content
ensure_bare_repo /opt/%s/kb-config
log 'Ensured /opt/arche/users, /opt/arche/kb-content, and /opt/arche/kb-config exist'

step 'Rendering runtime configuration'
POSTGRES_PASSWORD="$(rand_password 28)"
ARCHE_SESSION_PEPPER="$(rand_b64 32)"
ARCHE_ENCRYPTION_KEY="$(rand_b64 32)"
ARCHE_INTERNAL_TOKEN="$(rand_b64 32)"
ARCHE_CONNECTOR_OAUTH_STATE_SECRET="$(rand_b64 32)"
ARCHE_GATEWAY_TOKEN_SECRET="$(rand_b64 32)"
ARCHE_SEED_ADMIN_PASSWORD="$(rand_password 20)"
cat > /opt/%s/.env <<EOF
ACME_EMAIL=${ADMIN_EMAIL}
ARCHE_DOMAIN=${ARCHE_DOMAIN}
ARCHE_PUBLIC_BASE_URL=${ARCHE_PUBLIC_BASE_URL}
ARCHE_VERSION=${ARCHE_VERSION}
ARCHE_RELEASE_VERSION=${ARCHE_RELEASE_VERSION}
ARCHE_WEB_IMAGE=${ARCHE_WEB_IMAGE}

DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/%s?schema=public
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}

ARCHE_SESSION_PEPPER=${ARCHE_SESSION_PEPPER}
ARCHE_ENCRYPTION_KEY=${ARCHE_ENCRYPTION_KEY}
ARCHE_INTERNAL_TOKEN=${ARCHE_INTERNAL_TOKEN}
ARCHE_CONNECTOR_OAUTH_STATE_SECRET=${ARCHE_CONNECTOR_OAUTH_STATE_SECRET}
ARCHE_GATEWAY_TOKEN_SECRET=${ARCHE_GATEWAY_TOKEN_SECRET}

ARCHE_COOKIE_SECURE=true
ARCHE_SESSION_TTL_DAYS=7

ARCHE_SEED_ADMIN_EMAIL=${ADMIN_EMAIL}
ARCHE_SEED_ADMIN_PASSWORD=${ARCHE_SEED_ADMIN_PASSWORD}
ARCHE_SEED_ADMIN_SLUG=admin

CONTAINER_PROXY_HOST=docker-socket-proxy
CONTAINER_PROXY_PORT=2375
OPENCODE_IMAGE=${OPENCODE_IMAGE}
OPENCODE_NETWORK=arche-internal

KB_CONTENT_HOST_PATH=/opt/arche/kb-content
KB_CONFIG_HOST_PATH=/opt/arche/kb-config
ARCHE_USERS_PATH=/opt/arche/users
EOF
chmod 600 /opt/%s/.env
log 'Wrote /opt/arche/.env with server-generated secrets and the live public URL'

step 'Pulling container images'
set -a
. /opt/%s/.env
set +a
log "Pulling web image ${ARCHE_WEB_IMAGE}"
docker pull "${ARCHE_WEB_IMAGE}"
log "Pulling workspace image ${OPENCODE_IMAGE}"
docker pull "${OPENCODE_IMAGE}"

step 'Starting Arche stack'
cd /opt/%s
docker compose up -d
docker compose ps
log 'Compose stack started'

step 'Recording bootstrap summary'
cat > /opt/%s/bootstrap-summary.txt <<EOF
public_ip=${PUBLIC_IP}
domain=${ARCHE_DOMAIN}
public_url=${ARCHE_PUBLIC_BASE_URL}
compose_dir=/opt/%s
EOF

log 'Bootstrap complete. If the public URL is not ready yet, keep tailing /var/log/arche-bootstrap.log and docker compose logs.'
`, shellQuote(data.email), shellQuote(data.version), shellQuote(data.version), shellQuote(appImageForVersion(data.version)), shellQuote(workspaceImageForVersion(data.version)), appName, appName, appName, appName, appName, appName, appName, dbName, appName, appName, appName, appName, appName, appName)) + "\n"
}

func renderCloudInit(sshPublicKey, compose, bootstrap string) string {
	return strings.TrimSpace(fmt.Sprintf(`#cloud-config
disable_root: true
ssh_pwauth: false
users:
  - name: %s
    gecos: Arche Deploy
    groups: [sudo]
    shell: /bin/bash
    sudo: ALL=(ALL) NOPASSWD:ALL
    lock_passwd: true
    ssh_authorized_keys:
      - %s
write_files:
  - path: /opt/%s/docker-compose.yml
    owner: root:root
    permissions: "0644"
    encoding: b64
    content: %s
  - path: /opt/%s/bootstrap.sh
    owner: root:root
    permissions: "0700"
    encoding: b64
    content: %s
runcmd:
  - bash -lc '/opt/%s/bootstrap.sh > /var/log/%s-bootstrap.log 2>&1'
final_message: '%s bootstrap finished'
`, deploySSHUser, strings.TrimSpace(sshPublicKey), appName, b64(compose), appName, b64(bootstrap), appName, appName, appName)) + "\n"
}

func validateTemplates(version string) error {
	sample := input{token: "sample", email: "admin@example.com", size: defaultDropletSize, region: defaultDropletRegion, version: version}
	envTemplate := renderEnvFile(sample, stateSecrets{
		PostgresPassword:          "postgres-password",
		SessionPepper:             "session-pepper",
		EncryptionKey:             "encryption-key",
		InternalToken:             "internal-token",
		GatewayTokenSecret:        "gateway-token",
		ConnectorOAuthStateSecret: "oauth-state-token",
		AdminPassword:             "admin-password",
	}, envPlaceholderDomain, envPlaceholderPublicBaseURL)
	compose := renderCompose()
	envKeys := map[string]bool{}
	for _, line := range strings.Split(envTemplate, "\n") {
		line = strings.TrimSpace(line)
		if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") {
			continue
		}
		envKeys[strings.SplitN(line, "=", 2)[0]] = true
	}
	re := regexp.MustCompile(`\$\{([A-Z0-9_]+)(?::[^}]*)?\}`)
	missing := map[string]bool{}
	for _, match := range re.FindAllStringSubmatch(compose, -1) {
		if len(match) > 1 && !envKeys[match[1]] {
			missing[match[1]] = true
		}
	}
	if len(missing) > 0 {
		keys := make([]string, 0, len(missing))
		for key := range missing {
			keys = append(keys, key)
		}
		sort.Strings(keys)
		return fmt.Errorf("docker-compose references env vars missing from .env: %s", strings.Join(keys, ", "))
	}
	if !strings.Contains(envTemplate, envPlaceholderDomain) || !strings.Contains(envTemplate, envPlaceholderPublicBaseURL) {
		return errors.New("dynamic domain placeholders are missing from generated .env template")
	}
	return nil
}

func allocateLocalPaths() (string, string, string, string, error) {
	dir, err := deploymentStateDir()
	if err != nil {
		return "", "", "", "", err
	}
	if err := os.MkdirAll(dir, 0o700); err != nil {
		return "", "", "", "", err
	}
	stem := appName + "-" + time.Now().UTC().Format("20060102-150405")
	return filepath.Join(dir, stem+".txt"), filepath.Join(dir, stem+".json"), filepath.Join(dir, stem+"-ssh.pem"), filepath.Join(dir, stem+"-known_hosts"), nil
}

func writeLocalFiles(state deploymentState) error {
	if state.Deployment.SSHKeyFile != "" && strings.TrimSpace(state.Secrets.SSHPrivateKey) != "" {
		if err := os.WriteFile(state.Deployment.SSHKeyFile, []byte(strings.TrimSpace(state.Secrets.SSHPrivateKey)+"\n"), 0o600); err != nil {
			return err
		}
	}
	if state.Deployment.KnownHostsFile != "" && strings.TrimSpace(state.Secrets.SSHKnownHost) != "" {
		if err := os.WriteFile(state.Deployment.KnownHostsFile, []byte(strings.TrimSpace(state.Secrets.SSHKnownHost)+"\n"), 0o600); err != nil {
			return err
		}
	}

	target := sshTarget{ip: state.Deployment.IPAddress, user: sshUserForState(state), auth: sshAuthForState(state)}
	var text strings.Builder
	text.WriteString("Arche one-click deployment\n")
	text.WriteString("=========================\n\n")
	fmt.Fprintf(&text, "Droplet id:        %d\n", state.Deployment.DropletID)
	fmt.Fprintf(&text, "Firewall id:       %s\n", state.Deployment.FirewallID)
	fmt.Fprintf(&text, "Droplet name:      %s\n", state.Deployment.DropletName)
	fmt.Fprintf(&text, "Region:            %s\n", state.Input.DropletRegion)
	fmt.Fprintf(&text, "Size:              %s\n", state.Input.DropletSize)
	fmt.Fprintf(&text, "Public IP:         %s\n", state.Deployment.IPAddress)
	fmt.Fprintf(&text, "Public domain:     %s\n", state.Deployment.Domain)
	fmt.Fprintf(&text, "Public URL:        %s\n\n", state.Deployment.PublicURL)

	fmt.Fprintf(&text, "SSH user:          %s\n", target.user)
	if strings.TrimSpace(state.Secrets.SSHPrivateKey) != "" {
		fmt.Fprintf(&text, "SSH key file:      %s\n", state.Deployment.SSHKeyFile)
		if state.Deployment.KnownHostsFile != "" {
			fmt.Fprintf(&text, "Known hosts file:  %s\n", state.Deployment.KnownHostsFile)
		}
		fmt.Fprintf(&text, "SSH command:       %s\n\n", displaySSHCommand(target, state))
	} else if strings.TrimSpace(state.Secrets.RootPassword) != "" {
		fmt.Fprintf(&text, "SSH password:      %s\n", state.Secrets.RootPassword)
		fmt.Fprintf(&text, "SSH command:       %s\n\n", displaySSHCommand(target, state))
	} else {
		text.WriteString("SSH access:        pending recovery data fetch\n\n")
	}

	fmt.Fprintf(&text, "Version:           %s\n", state.Version)
	fmt.Fprintf(&text, "App image:         %s\n", state.AppImage)
	fmt.Fprintf(&text, "Workspace image:   %s\n", state.WorkspaceImage)
	fmt.Fprintf(&text, "Admin email:       %s\n", state.Deployment.AdminEmail)
	if strings.TrimSpace(state.Secrets.AdminPassword) != "" {
		fmt.Fprintf(&text, "Admin password:    %s\n", state.Secrets.AdminPassword)
	} else {
		text.WriteString("Admin password:    pending recovery data fetch\n")
	}
	text.WriteString("Admin slug:        admin\n\n")

	fmt.Fprintf(&text, "PostgreSQL db:     %s\n", state.DBName)
	text.WriteString("PostgreSQL user:   postgres\n")
	if strings.TrimSpace(state.Secrets.PostgresPassword) != "" {
		fmt.Fprintf(&text, "PostgreSQL pass:   %s\n\n", state.Secrets.PostgresPassword)
		fmt.Fprintf(&text, "ARCHE_SESSION_PEPPER=%s\n", state.Secrets.SessionPepper)
		fmt.Fprintf(&text, "ARCHE_ENCRYPTION_KEY=%s\n", state.Secrets.EncryptionKey)
		fmt.Fprintf(&text, "ARCHE_INTERNAL_TOKEN=%s\n", state.Secrets.InternalToken)
		fmt.Fprintf(&text, "ARCHE_GATEWAY_TOKEN_SECRET=%s\n", state.Secrets.GatewayTokenSecret)
		fmt.Fprintf(&text, "ARCHE_CONNECTOR_OAUTH_STATE_SECRET=%s\n\n", state.Secrets.ConnectorOAuthStateSecret)
	} else {
		text.WriteString("PostgreSQL pass:   pending recovery data fetch\n\n")
	}

	text.WriteString("Useful commands:\n")
	if state.Deployment.IPAddress != "" && (target.auth.hasPrivateKey() || target.auth.hasPassword()) {
		fmt.Fprintf(&text, "%s\n", displaySSHRemoteCommand(target, state, fmt.Sprintf("tail -f /var/log/%s-bootstrap.log", appName)))
		fmt.Fprintf(&text, "%s\n", displaySSHRemoteCommand(target, state, fmt.Sprintf("cd /opt/%s && docker compose logs -f web", appName)))
		fmt.Fprintf(&text, "%s\n", displaySSHRemoteCommand(target, state, fmt.Sprintf("cd /opt/%s && docker compose ps", appName)))
	}

	if err := os.WriteFile(state.Deployment.CredentialsFile, []byte(text.String()), 0o600); err != nil {
		return err
	}
	state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
	return writeStateFiles(state, state.Deployment.JSONFile)
}

func printSummary(ui *ui, result artifacts, health *healthResult) {
	status := "bootstrapping"
	tone := "warn"
	healthWait := "skipped"
	lastCheck := "-"
	if health != nil {
		healthWait = formatDuration(time.Duration(health.ElapsedSeconds) * time.Second)
		lastCheck = health.LastStatus
		if health.Ready {
			status = "ready"
			tone = "success"
		}
	}
	ui.print("")
	ui.panel("Deployment complete", [][2]string{
		{"Status", status},
		{"Server IP", result.IPAddress},
		{"App URL", result.PublicURL},
		{"Credentials", result.CredentialsFile},
		{"JSON record", result.JSONFile},
		{"Health wait", healthWait},
		{"Last check", lastCheck},
	}, tone)
	if health == nil || !health.Ready {
		ui.warn("The server may still be finishing first boot. Keep the bootstrap log open if the URL is not ready yet.")
	} else {
		ui.ok("Arche answered the public health check and is ready to open at: "+result.PublicURL, time.Time{})
	}
}

func makeDropletName() string {
	return appName + "-" + time.Now().UTC().Format("20060102-150405")
}

func appImageForVersion(version string) string {
	return appImageRepo + ":" + version
}

func workspaceImageForVersion(version string) string {
	return workspaceImageRepo + ":" + version
}

func validateVersion(version string) error {
	if version == defaultVersion {
		return nil
	}
	matched, err := regexp.MatchString(`^v[0-9]+\.[0-9]+\.[0-9]+([-.+][A-Za-z0-9][A-Za-z0-9.-]*)?$`, version)
	if err != nil {
		return err
	}
	if !matched {
		return fmt.Errorf("version must be %q or a tag like v1.2.3", defaultVersion)
	}
	return nil
}

func buildDomainFromIP(ip string) string {
	return appName + "-" + strings.ReplaceAll(ip, ".", "-") + ".nip.io"
}

func deploymentStateDir() (string, error) {
	home, err := os.UserHomeDir()
	if err != nil {
		return "", err
	}
	return filepath.Join(home, ".arche", "deployments"), nil
}

func defaultStatePath() (string, error) {
	dir, err := deploymentStateDir()
	if err != nil {
		return "", err
	}
	return filepath.Join(dir, "current.json"), nil
}

func resolveStatePath(path string) (string, error) {
	if strings.TrimSpace(path) != "" {
		return path, nil
	}
	return defaultStatePath()
}

func writeStateFiles(state deploymentState, timestampPath string) error {
	raw, err := json.MarshalIndent(state, "", "  ")
	if err != nil {
		return err
	}
	if err := os.MkdirAll(filepath.Dir(timestampPath), 0o700); err != nil {
		return err
	}
	if err := os.WriteFile(timestampPath, raw, 0o600); err != nil {
		return err
	}
	currentPath, err := defaultStatePath()
	if err != nil {
		return err
	}
	if err := os.MkdirAll(filepath.Dir(currentPath), 0o700); err != nil {
		return err
	}
	return os.WriteFile(currentPath, raw, 0o600)
}

func readState(path string) (deploymentState, string, error) {
	resolved, err := resolveStatePath(path)
	if err != nil {
		return deploymentState{}, "", err
	}
	raw, err := os.ReadFile(resolved)
	if err != nil {
		return deploymentState{}, "", fmt.Errorf("read state file %s: %w", resolved, err)
	}
	var state deploymentState
	if err := json.Unmarshal(raw, &state); err != nil {
		return deploymentState{}, "", fmt.Errorf("parse state file %s: %w", resolved, err)
	}
	if state.Deployment.JSONFile == "" {
		state.Deployment.JSONFile = resolved
	}
	if state.Deployment.IPAddress == "" {
		return deploymentState{}, "", fmt.Errorf("state file %s is missing the server IP address", resolved)
	}
	if strings.TrimSpace(state.Secrets.SSHPrivateKey) == "" && strings.TrimSpace(state.Secrets.RootPassword) == "" {
		return deploymentState{}, "", fmt.Errorf("state file %s is missing SSH connection details", resolved)
	}
	return state, resolved, nil
}

func readSSHPrivateKey(path string) (string, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		return "", fmt.Errorf("read SSH private key %s: %w", path, err)
	}
	key := strings.TrimSpace(string(raw))
	if key == "" {
		return "", fmt.Errorf("SSH private key %s is empty", path)
	}
	return key, nil
}

func collectDOToken(flagValue string) (string, error) {
	token := strings.TrimSpace(flagValue)
	if token == "" {
		token = strings.TrimSpace(os.Getenv("DIGITALOCEAN_TOKEN"))
	}
	if token == "" {
		tty, err := os.Open("/dev/tty")
		if err != nil {
			return "", fmt.Errorf("open /dev/tty for token prompt: %w", err)
		}
		defer tty.Close()
		fmt.Print("DigitalOcean API token (input hidden): ")
		raw, err := term.ReadPassword(int(tty.Fd()))
		fmt.Println()
		if err != nil {
			return "", fmt.Errorf("read DigitalOcean token: %w", err)
		}
		token = strings.TrimSpace(string(raw))
	}
	if token == "" {
		return "", errors.New("DigitalOcean API token is required")
	}
	return token, nil
}

func b64(value string) string {
	return base64.StdEncoding.EncodeToString([]byte(value))
}

func shellQuote(value string) string {
	if value == "" {
		return "''"
	}
	return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
}

func fallback(value, defaultValue string) string {
	if value == "" {
		return defaultValue
	}
	return value
}

func withDuration(message string, started time.Time) string {
	if started.IsZero() {
		return message
	}
	return message + " (" + formatDuration(time.Since(started)) + ")"
}

func formatDuration(value time.Duration) string {
	if value < 0 {
		value = 0
	}
	total := int(value.Round(time.Second).Seconds())
	hours := total / 3600
	minutes := (total % 3600) / 60
	seconds := total % 60
	if hours > 0 {
		return fmt.Sprintf("%dh %dm %ds", hours, minutes, seconds)
	}
	if minutes > 0 {
		return fmt.Sprintf("%dm %ds", minutes, seconds)
	}
	return fmt.Sprintf("%ds", seconds)
}

func waitForRemoteSecrets(ctx context.Context, ui *ui, target sshTarget, timeout time.Duration) (stateSecrets, error) {
	started := time.Now()
	last := "waiting for /opt/arche/.env"
	attempt := 0
	for time.Since(started) < timeout {
		attempt++
		output, err := runRemoteCommand(ctx, target, fmt.Sprintf("test -s /opt/%s/.env && cat /opt/%s/.env", appName, appName))
		if err == nil {
			secrets, parseErr := parseStateSecretsFromEnv(output)
			if parseErr == nil {
				ui.progressDone("Remote recovery data", timeout, fmt.Sprintf("attempt=%d | env ready", attempt))
				return secrets, nil
			}
			last = parseErr.Error()
		} else {
			last = err.Error()
		}
		ui.progressWait("Remote recovery data", time.Since(started), timeout, fmt.Sprintf("attempt=%d | last=%s", attempt, last))
		select {
		case <-ctx.Done():
			return stateSecrets{}, ctx.Err()
		case <-time.After(5 * time.Second):
		}
	}
	return stateSecrets{}, fmt.Errorf("timed out waiting for generated recovery data. Last status: %s", last)
}

func parseStateSecretsFromEnv(content string) (stateSecrets, error) {
	values := map[string]string{}
	for _, line := range strings.Split(content, "\n") {
		line = strings.TrimSpace(line)
		if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") {
			continue
		}
		key, value, ok := strings.Cut(line, "=")
		if !ok {
			continue
		}
		values[key] = value
	}
	secrets := stateSecrets{
		PostgresPassword:          values["POSTGRES_PASSWORD"],
		SessionPepper:             values["ARCHE_SESSION_PEPPER"],
		EncryptionKey:             values["ARCHE_ENCRYPTION_KEY"],
		InternalToken:             values["ARCHE_INTERNAL_TOKEN"],
		GatewayTokenSecret:        values["ARCHE_GATEWAY_TOKEN_SECRET"],
		ConnectorOAuthStateSecret: values["ARCHE_CONNECTOR_OAUTH_STATE_SECRET"],
		AdminPassword:             values["ARCHE_SEED_ADMIN_PASSWORD"],
	}
	missing := []string{}
	required := map[string]string{
		"POSTGRES_PASSWORD":                  secrets.PostgresPassword,
		"ARCHE_SESSION_PEPPER":               secrets.SessionPepper,
		"ARCHE_ENCRYPTION_KEY":               secrets.EncryptionKey,
		"ARCHE_INTERNAL_TOKEN":               secrets.InternalToken,
		"ARCHE_GATEWAY_TOKEN_SECRET":         secrets.GatewayTokenSecret,
		"ARCHE_CONNECTOR_OAUTH_STATE_SECRET": secrets.ConnectorOAuthStateSecret,
		"ARCHE_SEED_ADMIN_PASSWORD":          secrets.AdminPassword,
	}
	for key, value := range required {
		if strings.TrimSpace(value) == "" {
			missing = append(missing, key)
		}
	}
	if len(missing) > 0 {
		sort.Strings(missing)
		return stateSecrets{}, fmt.Errorf("remote .env is missing required keys: %s", strings.Join(missing, ", "))
	}
	return secrets, nil
}
