package cli

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"runtime"

	"github.com/spf13/cobra"

	"github.com/aoagents/agent-orchestrator/backend/internal/config"
)

// releaseRepo is the GitHub "owner/repo" that `ao start` fetches the desktop app
// from. It defaults to the production target and is overridable at build time so
// a test binary fetches from the fork without a source edit:
//
//	go build -ldflags "-X github.com/aoagents/agent-orchestrator/backend/internal/cli.releaseRepo=harshitsinghbhandari/agent-orchestrator" ./cmd/ao
//
// Mirrors how version.go's Version var is stamped by release tooling.
var releaseRepo = "AgentWrapper/agent-orchestrator"

// appBundleName is the macOS bundle directory name produced by electron-forge
// (spaced, per frontend/forge.config.ts).
const appBundleName = "Agent Orchestrator.app"

// appStateFileName is the marker the desktop app writes under ~/.ao on every
// launch (spec §5). `ao start` is a read-only consumer of it.
const appStateFileName = "app-state.json"

// appState mirrors the app-written ~/.ao/app-state.json marker (spec §5). Only
// the desktop app writes it; `ao start` reads it as a fast-path hint and never
// trusts appPath without stat-ing it (invariant 2).
type appState struct {
	SchemaVersion    int    `json:"schemaVersion"`
	AppPath          string `json:"appPath"`
	Version          string `json:"version"`
	InstalledAt      string `json:"installedAt"`
	LastReconciledAt string `json:"lastReconciledAt"`
	InstallSource    string `json:"installSource"`
}

type startOptions struct {
	json bool
}

// startResult is the JSON shape emitted with --json: what `ao start` resolved,
// whether it fetched, whether it opened, and the resulting bundle path.
type startResult struct {
	Resolved bool   `json:"resolved"`
	Fetched  bool   `json:"fetched"`
	Opened   bool   `json:"opened"`
	AppPath  string `json:"appPath"`
}

func newStartCommand(ctx *commandContext) *cobra.Command {
	opts := startOptions{}
	cmd := &cobra.Command{
		Use:   "start",
		Short: "Fetch (if needed) and open the Agent Orchestrator desktop app",
		Long: "Fetch (if needed) and open the Agent Orchestrator desktop app.\n\n" +
			"The desktop app now owns the daemon, state, and updates. `ao start` no\n" +
			"longer runs a daemon: it resolves the installed app (or downloads the\n" +
			"latest release), opens it, and exits.",
		Args: noArgs,
		RunE: func(cmd *cobra.Command, args []string) error {
			return ctx.runStart(cmd.Context(), cmd, opts)
		},
	}
	cmd.Flags().BoolVar(&opts.json, "json", false, "Output start result as JSON")
	return cmd
}

// runStart implements the spec §6.1 algorithm: resolve the installed app, fetch
// it if absent, open it, then print the deprecation notice. It never blocks or
// supervises the launched app.
func (c *commandContext) runStart(ctx context.Context, cmd *cobra.Command, opts startOptions) error {
	out := cmd.OutOrStdout()
	res := startResult{}

	appPath := c.resolveApp()
	res.Resolved = appPath != ""

	var err error
	if appPath == "" {
		// Progress for the fetch path goes to stderr so stdout stays pure JSON
		// under --json. The resolve-and-launch fast path above stays quiet.
		appPath, err = c.fetchApp(ctx, cmd.ErrOrStderr())
		if err != nil {
			return err
		}
		res.Fetched = true
	}
	res.AppPath = appPath

	opened, err := c.openApp(ctx, appPath)
	if err != nil {
		return err
	}
	res.Opened = opened

	if opts.json {
		return writeJSON(out, res)
	}

	c.printDeprecationNotice(out)
	if !opened {
		c.printManualOpen(out, appPath)
	}
	return nil
}

// resolveApp returns the path to a usable desktop bundle, or "" when none is
// found (spec §6.2). Resolution order is fixed: marker path -> stat -> known
// location scan. It never compares versions (invariant 5).
func (c *commandContext) resolveApp() string {
	if p := c.markerAppPath(); p != "" && isUsableBundle(p) {
		return p
	}
	for _, p := range appScanLocations() {
		if isUsableBundle(p) {
			return p
		}
	}
	return ""
}

// appScanLocations is the known-location scan source. It is a package var so
// tests can point the scan at a temp bundle instead of real system paths.
var appScanLocations = knownAppLocations

// markerAppPath reads ~/.ao/app-state.json and returns its recorded appPath, or
// "" if the marker is missing/unreadable. It does not stat the path; callers do.
func (c *commandContext) markerAppPath() string {
	dir, err := aoStateDir()
	if err != nil {
		return ""
	}
	data, err := os.ReadFile(filepath.Join(dir, appStateFileName))
	if err != nil {
		return ""
	}
	var st appState
	if err := json.Unmarshal(data, &st); err != nil {
		return ""
	}
	return st.AppPath
}

// aoStateDir resolves the canonical ~/.ao home, honoring AO_DATA_DIR exactly as
// the daemon's config does (the marker lives beside running.json under ~/.ao).
func aoStateDir() (string, error) {
	cfg, err := config.Load()
	if err != nil {
		return "", err
	}
	// running.json lives directly under ~/.ao; the marker sits beside it.
	return filepath.Dir(cfg.RunFilePath), nil
}

// knownAppLocations lists the platform's standard install paths to scan when the
// marker misses (covers website installs and stale markers, spec §6.2).
func knownAppLocations() []string {
	switch runtime.GOOS {
	case "darwin":
		paths := []string{"/Applications/" + appBundleName}
		if home, err := os.UserHomeDir(); err == nil {
			paths = append(paths, filepath.Join(home, "Applications", appBundleName))
		}
		return paths
	case "windows":
		var paths []string
		// Default electron-builder NSIS per-user install (perMachine:false).
		if local := os.Getenv("LOCALAPPDATA"); local != "" {
			paths = append(paths, windowsInstalledExe(local))
		}
		// Per-machine fallback (if a user chose an all-users install).
		if pf := os.Getenv("ProgramFiles"); pf != "" {
			paths = append(paths, filepath.Join(pf, "Agent Orchestrator", "agent-orchestrator.exe"))
		}
		return paths
	case "linux":
		paths := []string{linuxAppImagePath()}
		if home, err := os.UserHomeDir(); err == nil {
			paths = append(paths, filepath.Join(home, "Applications", "agent-orchestrator.AppImage"))
		}
		return paths
	default:
		return nil
	}
}

// windowsInstalledExe is the default per-user electron-builder NSIS install
// target for the app exe under %LOCALAPPDATA%.
func windowsInstalledExe(localAppData string) string {
	return filepath.Join(localAppData, "Programs", "Agent Orchestrator", "agent-orchestrator.exe")
}

// linuxAppImagePath is the stable location `ao start` downloads the AppImage to
// and scans for. Keeping it out of the cleared staging dir lets re-runs resolve
// the existing download instead of re-fetching (spec §6.2/§6.3).
func linuxAppImagePath() string {
	dir, err := aoStateDir()
	if err != nil {
		// Fall back to a bare filename so a misconfigured state dir surfaces as a
		// clear "not found" rather than a panic; fetch will re-error on download.
		return "agent-orchestrator.AppImage"
	}
	return filepath.Join(dir, "agent-orchestrator.AppImage")
}

// isUsableBundle reports whether p stats as a usable app bundle. On macOS a
// bundle is a directory; on Windows/Linux it is a regular file (the installed
// exe / the AppImage). The filesystem is the source of truth (invariant 2).
func isUsableBundle(p string) bool {
	if p == "" {
		return false
	}
	info, err := os.Stat(p)
	if err != nil {
		return false
	}
	if runtime.GOOS == "darwin" {
		return info.IsDir()
	}
	// Windows exe / Linux AppImage: must be a regular file, not a directory.
	return info.Mode().IsRegular()
}

// fetchApp downloads the latest desktop release for this platform and returns
// the resolved bundle path (spec §6.3). macOS unpacks a signed zip into staging,
// Windows runs the NSIS installer silently, and Linux drops a chmod'd AppImage at
// a stable path.
func (c *commandContext) fetchApp(ctx context.Context, w io.Writer) (string, error) {
	switch runtime.GOOS {
	case "darwin":
		return c.fetchAppDarwin(ctx, w)
	case "windows":
		return c.fetchAppWindows(ctx, w)
	case "linux":
		return c.fetchAppLinux(ctx, w)
	default:
		return "", fmt.Errorf("ao start: fetch not supported on %s", runtime.GOOS)
	}
}

// fetchAppDarwin downloads the latest macOS release zip and unpacks it into a
// staging dir under ~/.ao/staging, returning the .app bundle path (spec §6.3).
func (c *commandContext) fetchAppDarwin(ctx context.Context, w io.Writer) (string, error) {
	asset, err := assetName()
	if err != nil {
		return "", err
	}
	url := downloadURL(asset)

	stateDir, err := aoStateDir()
	if err != nil {
		return "", err
	}
	staging := filepath.Join(stateDir, "staging")
	// Clear any stale or partial prior unpack so ditto extracts into a clean dir
	// (a leftover bundle could otherwise merge with the new one).
	if err := os.RemoveAll(staging); err != nil {
		return "", fmt.Errorf("clear staging dir: %w", err)
	}
	if err := os.MkdirAll(staging, 0o750); err != nil {
		return "", fmt.Errorf("create staging dir: %w", err)
	}

	zipPath := filepath.Join(staging, asset)
	if err := c.download(ctx, w, url, asset, zipPath); err != nil {
		return "", fmt.Errorf("download %s: %w", url, err)
	}

	// The unpack step is silent and can take seconds on a large bundle; announce
	// it so a quiet `ao start` doesn't look hung after the download finishes.
	_, _ = fmt.Fprintln(w, "Unpacking...")
	// ditto preserves the .app code signature; plain unzip corrupts it (spec §6.3).
	if out, err := c.deps.CommandOutput(ctx, "ditto", "-x", "-k", zipPath, staging); err != nil {
		return "", fmt.Errorf("ditto unpack: %w: %s", err, out)
	}

	appPath := filepath.Join(staging, appBundleName)
	if !isUsableBundle(appPath) {
		return "", fmt.Errorf("ao start: %s not found in unpacked release at %s", appBundleName, staging)
	}
	return appPath, nil
}

// fetchAppWindows downloads the NSIS installer into staging, runs it silently,
// and returns the default per-user install path (spec §6.3).
//
// ponytail: the silent-install flow (NSIS `/S`, default per-user dir) is
// untested on real Windows hardware (this build host is macOS). If the installed
// exe isn't where electron-builder's defaults put it, this surfaces as a clear
// "not found" error rather than silently launching the wrong thing.
func (c *commandContext) fetchAppWindows(ctx context.Context, w io.Writer) (string, error) {
	asset, err := assetName()
	if err != nil {
		return "", err
	}
	url := downloadURL(asset)

	stateDir, err := aoStateDir()
	if err != nil {
		return "", err
	}
	staging := filepath.Join(stateDir, "staging")
	if err := os.RemoveAll(staging); err != nil {
		return "", fmt.Errorf("clear staging dir: %w", err)
	}
	if err := os.MkdirAll(staging, 0o750); err != nil {
		return "", fmt.Errorf("create staging dir: %w", err)
	}

	installerPath := filepath.Join(staging, asset)
	if err := c.download(ctx, w, url, asset, installerPath); err != nil {
		return "", fmt.Errorf("download %s: %w", url, err)
	}

	// The `/S` installer runs with no UI and no output; announce it so the wait
	// after the download reads as progress, not a hang.
	_, _ = fmt.Fprintln(w, "Installing...")
	// NSIS silent install (`/S`) to the default per-user location. The installer
	// is configured oneClick:false/perMachine:false, so `/S` installs without UI
	// under %LOCALAPPDATA% for the current user.
	if out, err := c.deps.CommandOutput(ctx, installerPath, "/S"); err != nil {
		return "", fmt.Errorf("silent install: %w: %s", err, out)
	}

	local := os.Getenv("LOCALAPPDATA")
	if local == "" {
		return "", fmt.Errorf("ao start: LOCALAPPDATA not set; cannot locate installed app")
	}
	appPath := windowsInstalledExe(local)
	if !isUsableBundle(appPath) {
		return "", fmt.Errorf("ao start: installed app not found at %s", appPath)
	}
	return appPath, nil
}

// fetchAppLinux downloads the self-contained AppImage to a stable path under
// ~/.ao, makes it executable, and returns it. There is no install step (spec
// §6.3). Re-runs resolve the existing file via knownAppLocations and skip fetch.
func (c *commandContext) fetchAppLinux(ctx context.Context, w io.Writer) (string, error) {
	asset, err := assetName()
	if err != nil {
		return "", err
	}
	url := downloadURL(asset)

	appPath := linuxAppImagePath()
	if err := os.MkdirAll(filepath.Dir(appPath), 0o750); err != nil {
		return "", fmt.Errorf("create state dir: %w", err)
	}
	// Download to a temp name in the same dir, then rename for atomicity so a
	// killed download never leaves a half-written executable at the stable path.
	tmpPath := appPath + ".part"
	if err := c.download(ctx, w, url, asset, tmpPath); err != nil {
		return "", fmt.Errorf("download %s: %w", url, err)
	}
	// chmod+rename is near-instant, but a one-line marker keeps the post-download
	// step from looking silent and matches the other platforms.
	_, _ = fmt.Fprintln(w, "Installing...")
	// An AppImage is a self-contained executable; it must be 0755 to launch.
	if err := os.Chmod(tmpPath, 0o755); err != nil { //nolint:gosec // G302: AppImage must be executable
		return "", fmt.Errorf("chmod AppImage: %w", err)
	}
	if err := os.Rename(tmpPath, appPath); err != nil {
		return "", fmt.Errorf("install AppImage: %w", err)
	}
	if !isUsableBundle(appPath) {
		return "", fmt.Errorf("ao start: AppImage not found at %s", appPath)
	}
	return appPath, nil
}

// download streams url to dst using the injected HTTP client, reporting progress
// to w. asset is the human-facing filename used in the announce line. A silent
// hundreds-of-MB fetch reads as a hang, so we print a start line (with the size
// from Content-Length when present) and then progress: a live carriage-return
// percentage when w is an interactive terminal, or a plain start+done pair when
// it is not (CI, pipes, redirected output).
func (c *commandContext) download(ctx context.Context, w io.Writer, url, asset, dst string) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
	if err != nil {
		return err
	}
	// deps.HTTPClient carries a short (2s) timeout sized for loopback daemon
	// probes; a release asset is hundreds of MB. Copy the client and drop the
	// timeout, relying on ctx for cancellation. The Transport is preserved so
	// tests still reach their httptest server.
	client := *c.deps.HTTPClient
	client.Timeout = 0
	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("unexpected status %s", resp.Status)
	}

	// Content-Length is -1 when the server omits the header; total<=0 means the
	// percentage is unknown and we report transferred bytes instead.
	total := resp.ContentLength
	if total > 0 {
		_, _ = fmt.Fprintf(w, "Downloading Agent Orchestrator (%s, ~%s) from %s...\n", asset, humanBytes(total), releaseRepo)
	} else {
		_, _ = fmt.Fprintf(w, "Downloading Agent Orchestrator (%s) from %s...\n", asset, releaseRepo)
	}

	f, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
	if err != nil {
		return err
	}
	defer func() { _ = f.Close() }()

	pw := &progressWriter{w: w, total: total, tty: writerIsInteractive(w)}
	if _, err := io.Copy(f, io.TeeReader(resp.Body, pw)); err != nil {
		return err
	}
	pw.done()
	return f.Close()
}

// progressWriter counts bytes copied through it and renders download progress to
// w. On an interactive terminal it overwrites a single line with a carriage
// return; otherwise it stays quiet during the copy and prints one "Done" line at
// the end, so logs and pipes never fill with \r spam or per-chunk noise.
type progressWriter struct {
	w       io.Writer
	total   int64 // bytes; <=0 when Content-Length was absent
	tty     bool
	written int64
}

func (p *progressWriter) Write(b []byte) (int, error) {
	n := len(b)
	p.written += int64(n)
	if p.tty {
		if p.total > 0 {
			pct := p.written * 100 / p.total
			_, _ = fmt.Fprintf(p.w, "\r  %d%% (%s / %s)", pct, humanBytes(p.written), humanBytes(p.total))
		} else {
			_, _ = fmt.Fprintf(p.w, "\r  %s", humanBytes(p.written))
		}
	}
	return n, nil
}

// done emits the terminal progress line. On a TTY it closes the live line with a
// newline; off a TTY it prints the single completion line.
func (p *progressWriter) done() {
	if p.tty {
		_, _ = fmt.Fprintln(p.w)
		return
	}
	_, _ = fmt.Fprintf(p.w, "Downloaded %s.\n", humanBytes(p.written))
}

// writerIsInteractive reports whether w is an interactive terminal, mirroring
// stdinIsInteractive: only a real *os.File backed by a character device counts,
// so a bytes.Buffer or pipe is treated as non-interactive.
func writerIsInteractive(w io.Writer) bool {
	f, ok := w.(*os.File)
	if !ok {
		return false
	}
	info, err := f.Stat()
	if err != nil {
		return false
	}
	return info.Mode()&os.ModeCharDevice != 0
}

// humanBytes formats a byte count with a binary-unit suffix (KiB, MiB, ...),
// e.g. 314572800 -> "300.0 MiB". A tiny hand-rolled formatter avoids promoting
// the already-indirect go-humanize dependency to a direct one for one string.
func humanBytes(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	div, exp := int64(unit), 0
	for v := n / unit; v >= unit; v /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

// assetName maps the current GOOS/GOARCH to the stable release asset name the
// release pipeline publishes (spec §6.3, §8). The pipeline uses "x64" for amd64.
//   - darwin: agent-orchestrator-darwin-{arm64,x64}.zip (signed bundle zip)
//   - windows: agent-orchestrator-win32-x64.exe (NSIS installer, amd64 only)
//   - linux: agent-orchestrator-linux-x64.AppImage (self-contained, amd64 only)
func assetName() (string, error) {
	switch runtime.GOOS {
	case "darwin":
		arch, err := assetArch(runtime.GOARCH)
		if err != nil {
			return "", err
		}
		return fmt.Sprintf("agent-orchestrator-darwin-%s.zip", arch), nil
	case "windows":
		if _, err := requireAMD64(); err != nil {
			return "", err
		}
		return "agent-orchestrator-win32-x64.exe", nil
	case "linux":
		if _, err := requireAMD64(); err != nil {
			return "", err
		}
		return "agent-orchestrator-linux-x64.AppImage", nil
	default:
		return "", fmt.Errorf("ao start: no release asset for %s", runtime.GOOS)
	}
}

// requireAMD64 enforces the amd64/x64-only support window for Windows and Linux.
// arm64 Windows/Linux are not published yet; surface a clear unsupported error
// mirroring assetArch rather than fetching a 404.
func requireAMD64() (string, error) {
	if runtime.GOARCH != "amd64" {
		return "", fmt.Errorf("ao start: unsupported architecture %q on %s (only amd64 is published)", runtime.GOARCH, runtime.GOOS)
	}
	return "x64", nil
}

// assetArch maps a Go GOARCH to the release-asset arch token.
func assetArch(goarch string) (string, error) {
	switch goarch {
	case "arm64":
		return "arm64", nil
	case "amd64":
		return "x64", nil
	default:
		return "", fmt.Errorf("ao start: unsupported architecture %q", goarch)
	}
}

// downloadURL builds the constant releases/latest/download URL for asset.
func downloadURL(asset string) string {
	return fmt.Sprintf("https://github.com/%s/releases/latest/download/%s", releaseRepo, asset)
}

// openApp launches the resolved bundle detached and reports whether it launched
// (spec §6.5). It passes --installed-via=npm-bootstrap so the app can record the
// install source in its marker. It never waits on the app. A launch failure
// returns (false, nil) so the caller falls back to manual-open instructions.
func (c *commandContext) openApp(ctx context.Context, appPath string) (bool, error) {
	switch runtime.GOOS {
	case "darwin":
		// `open` returns immediately; --args forwards the rest to the app.
		if out, err := c.deps.CommandOutput(ctx, "open", appPath, "--args", "--installed-via=npm-bootstrap"); err != nil {
			return false, fmt.Errorf("open %s: %w: %s", appPath, err, out)
		}
		return true, nil
	case "windows", "linux":
		// No `open`-style launcher on these platforms; exec the bundle directly,
		// detached, so `ao start` does not block on the app. StartProcess uses
		// cmd.Start() + a detached SysProcAttr (see process.go).
		//
		// ponytail: on some Linux hosts the AppImage may need --no-sandbox; not
		// added here without evidence the bundled Electron requires it. If sandbox
		// launch failures appear, append "--no-sandbox" as the follow-up.
		err := c.deps.StartProcess(processStartConfig{
			Path: appPath,
			Args: []string{"--installed-via=npm-bootstrap"},
		})
		if err != nil {
			// Treat a launch failure as "not opened" so the caller prints the
			// manual-open path rather than aborting the whole command.
			return false, nil //nolint:nilerr // launch failure is reported via the bool, not as an error
		}
		return true, nil
	default:
		return false, nil
	}
}

// printDeprecationNotice explains the new role of the npm `ao` binary. Keep it
// honest: Track B (live auto-update) is not done, so it does not promise it.
func (c *commandContext) printDeprecationNotice(w io.Writer) {
	_, _ = fmt.Fprint(w, "Agent Orchestrator is now a desktop app, and the npm `ao` is just its launcher.\n"+
		"The app is distributed from the website and GitHub Releases; it owns the daemon and updates itself.\n"+
		"You can keep running `ao start` to fetch (if needed) and open it.\n")
}

// printManualOpen tells the user how to open the bundle when `ao start` could
// not launch it for them (non-darwin, or a failed launch handled upstream).
func (c *commandContext) printManualOpen(w io.Writer, appPath string) {
	_, _ = fmt.Fprintf(w, "Could not open the app automatically. Open it manually: %s\n", appPath)
}
