/**
 * Engine connect/disconnect for the local host UI (ADR-028, ADR-044 P2).
 *
 * A single descriptor table for the AI coding engines the host can run
 * (Claude, Codex, Kimi, Grok, Cursor) plus the connect/disconnect/status logic
 * the local UI drives. This is the host-side twin of the desktop's
 * `apps/host-desktop/src/llm.ts`, but it writes the `.env.local` the RUNNING
 * launchd/npm host reads (UAI_HOME) and sets `process.env` so a connect takes
 * effect WITHOUT a host restart — the adapters' `available()` and task-up's env
 * injection both read `process.env`.
 *
 * Three auth modes:
 *   - token-command  (Claude): `claude setup-token` prints a token to stdout;
 *                    we capture it (an `sk-ant-oat…` value) and persist it as
 *                    CLAUDE_CODE_OAUTH_TOKEN. A pasted token is accepted too.
 *   - login-command  (Codex/Kimi/Grok): `<cli> login` runs a browser OAuth and
 *                    writes a config file; success = that file appearing.
 *   - api-key        (Cursor): no command — persist a pasted CURSOR_API_KEY.
 *
 * Detection mirrors each adapter's `available()`: env/`.env.local` for
 * claude/cursor, a config file under the owner home for codex/kimi/grok.
 *
 * Every side-effecting seam (spawn, the `.env.local` path, the owner home,
 * process.env) is injectable so the flows are unit-testable without touching
 * the real filesystem or spawning anything.
 */

import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
import {
  chmodSync,
  existsSync,
  mkdirSync,
  readFileSync,
  rmSync,
  writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";

import { env } from "./env";

export type EngineKind = "claude" | "codex" | "kimi" | "grok" | "cursor";
export type EngineAuthMode = "token-command" | "login-command" | "api-key";

/** One entry of the engine catalog the UI's "Add engine" panel renders. */
export interface EngineCatalogEntry {
  kind: EngineKind;
  label: string;
  authMode: EngineAuthMode;
  /** One-line help shown under the engine in the picker. */
  notes: string | null;
  /** Where to mint an API key (api-key mode only). */
  getKeyUrl: string | null;
}

interface EngineDescriptor {
  label: string;
  authMode: EngineAuthMode;
  notes: string;
  getKeyUrl?: string;
}

/** Static descriptor table — the single source of truth for engine metadata. */
const DESCRIPTORS: Record<EngineKind, EngineDescriptor> = {
  claude: {
    label: "Claude",
    authMode: "token-command",
    notes:
      "Opens your browser to authorize Claude, then captures the token automatically.",
  },
  codex: {
    label: "Codex",
    authMode: "login-command",
    notes: "Opens your browser to sign in to your OpenAI Codex account.",
  },
  kimi: {
    label: "Kimi Code",
    authMode: "login-command",
    notes:
      "Sign in with your Moonshot Kimi Code subscription. Activates after the next image rebuild.",
  },
  grok: {
    label: "Grok",
    authMode: "login-command",
    notes:
      "Sign in with your xAI Grok subscription. Activates after the next image rebuild.",
  },
  cursor: {
    label: "Cursor",
    authMode: "api-key",
    notes: "Paste a Cursor API key. Requires Cursor Pro.",
    getKeyUrl: "https://cursor.com/dashboard?tab=integrations",
  },
};

/** Display order (matches the cloud picker's ordering intent). */
const ORDER: EngineKind[] = ["claude", "codex", "kimi", "grok", "cursor"];

// ---------------------------------------------------------------------------
// Injectable seams.
// ---------------------------------------------------------------------------

/** How a login/token command is spawned. Tests provide a fake. */
export type EngineSpawn = (command: string, args: string[]) => ChildProcess;

export interface EngineSeams {
  /** Spawn a CLI, piping stdout/stderr. */
  spawn: EngineSpawn;
  /** Absolute path to the `.env.local` the running host reads (UAI_HOME). */
  envLocalPath: () => string;
  /** The owner's real home — where codex/kimi/grok write their config dirs. */
  ownerHome: () => string;
  /** The live process env (adapters + task-up read creds from here). */
  procEnv: NodeJS.ProcessEnv;
}

function defaultSeams(): EngineSeams {
  return {
    spawn: (command, args) =>
      nodeSpawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }),
    envLocalPath: () => join(env.uaiHome, ".env.local"),
    ownerHome: () => process.env.UAI_OWNER_HOME?.trim() || homedir(),
    procEnv: process.env,
  };
}

function withDefaults(seams: Partial<EngineSeams>): EngineSeams {
  return { ...defaultSeams(), ...seams };
}

// ---------------------------------------------------------------------------
// Public catalog / status.
// ---------------------------------------------------------------------------

/** The static engine catalog (kind, label, authMode, notes, getKeyUrl). */
export function engineCatalog(): EngineCatalogEntry[] {
  return ORDER.map((kind) => {
    const d = DESCRIPTORS[kind];
    return {
      kind,
      label: d.label,
      authMode: d.authMode,
      notes: d.notes,
      getKeyUrl: d.getKeyUrl ?? null,
    };
  });
}

/** kind → connected, for every engine. */
export function engineStatuses(
  seams: Partial<EngineSeams> = {},
): Record<EngineKind, boolean> {
  const s = withDefaults(seams);
  return {
    claude: detect("claude", s),
    codex: detect("codex", s),
    kimi: detect("kimi", s),
    grok: detect("grok", s),
    cursor: detect("cursor", s),
  };
}

/** Whether a single engine is connected on this host right now. */
export function detectEngine(
  kind: EngineKind,
  seams: Partial<EngineSeams> = {},
): boolean {
  return detect(kind, withDefaults(seams));
}

export function isEngineKind(value: unknown): value is EngineKind {
  return (
    value === "claude" ||
    value === "codex" ||
    value === "kimi" ||
    value === "grok" ||
    value === "cursor"
  );
}

// ---------------------------------------------------------------------------
// Connect / disconnect.
// ---------------------------------------------------------------------------

export interface ConnectOptions {
  /** api-key mode: the pasted key (Cursor). */
  apiKey?: string;
  /** token-command manual fallback: a pasted token (Claude). */
  pastedToken?: string;
}

export interface ConnectResult {
  ok: boolean;
  message: string;
}

/**
 * Connect an engine. api-key/paste resolve synchronously; login/token spawn a
 * CLI and stream its output to `onLog` (the browser OAuth happens meanwhile).
 */
export async function connectEngine(
  kind: EngineKind,
  opts: ConnectOptions,
  onLog: (line: string) => void,
  seams: Partial<EngineSeams> = {},
): Promise<ConnectResult> {
  const s = withDefaults(seams);
  const d = DESCRIPTORS[kind];

  if (d.authMode === "api-key") {
    const key = (opts.apiKey ?? "").trim();
    if (!key) return { ok: false, message: `Paste your ${d.label} API key.` };
    if (/\s/.test(key) || key.length < 8) {
      return {
        ok: false,
        message: "That doesn't look like an API key. Paste just the value.",
      };
    }
    upsertEnvLocal("CURSOR_API_KEY", key, s);
    return { ok: true, message: `${d.label} connected.` };
  }

  if (d.authMode === "token-command") {
    // Manual paste fallback — accept a token without spawning the CLI.
    if (opts.pastedToken !== undefined) {
      const token = opts.pastedToken.trim();
      if (!token) return { ok: false, message: "Paste a token." };
      if (/\s/.test(token) || token.length < 20) {
        return {
          ok: false,
          message: "That doesn't look like a token. Paste just the value.",
        };
      }
      upsertEnvLocal("CLAUDE_CODE_OAUTH_TOKEN", token, s);
      return { ok: true, message: `${d.label} connected.` };
    }
    return runTokenCommand(kind, d.label, onLog, s);
  }

  return runLoginCommand(kind, d.label, onLog, s);
}

/** Disconnect an engine: forget its credential (env line and/or config file). */
export function disconnectEngine(
  kind: EngineKind,
  seams: Partial<EngineSeams> = {},
): void {
  const s = withDefaults(seams);
  if (kind === "cursor") {
    removeEnvLocal("CURSOR_API_KEY", s);
    return;
  }
  if (kind === "claude") {
    removeEnvLocal("CLAUDE_CODE_OAUTH_TOKEN", s);
    removeEnvLocal("ANTHROPIC_API_KEY", s);
    removeEnvLocal("ANTHROPIC_AUTH_TOKEN", s);
    return;
  }
  // login-command engines: remove the config file the adapter detects.
  try {
    rmSync(configPath(kind, s), { force: true });
  } catch {
    /* best effort */
  }
}

/**
 * Extract a Claude OAuth token from `claude setup-token` stdout. The CLI prints
 * the token (an `sk-ant-oat…` value) on its own line near the end; scan from the
 * bottom for it, falling back to a lone long token-charset line.
 */
export function extractClaudeToken(stdout: string): string | null {
  const lines = stdout
    .split(/\r?\n/)
    .map((l) => l.trim())
    .filter(Boolean);
  for (let i = lines.length - 1; i >= 0; i -= 1) {
    const m = /(sk-ant-[A-Za-z0-9_-]{16,})/.exec(lines[i] ?? "");
    if (m?.[1]) return m[1];
  }
  for (let i = lines.length - 1; i >= 0; i -= 1) {
    const line = lines[i] ?? "";
    if (/^[A-Za-z0-9_-]{40,}$/.test(line)) return line;
  }
  return null;
}

// ---------------------------------------------------------------------------
// Internals.
// ---------------------------------------------------------------------------

function detect(kind: EngineKind, s: EngineSeams): boolean {
  switch (kind) {
    case "claude":
      return (
        envOrFileHas("CLAUDE_CODE_OAUTH_TOKEN", s) ||
        envOrFileHas("ANTHROPIC_API_KEY", s) ||
        envOrFileHas("ANTHROPIC_AUTH_TOKEN", s)
      );
    case "cursor":
      return envOrFileHas("CURSOR_API_KEY", s);
    case "codex":
    case "kimi":
    case "grok":
      return existsSync(configPath(kind, s));
  }
}

/** True when `key` is set in process.env or present in `.env.local`. */
function envOrFileHas(key: string, s: EngineSeams): boolean {
  if (s.procEnv[key]) return true;
  try {
    const body = readFileSync(s.envLocalPath(), "utf8");
    return new RegExp(`^${key}=.+`, "m").test(body);
  } catch {
    return false;
  }
}

/** Config file whose presence gates a login-command engine. */
function configPath(kind: EngineKind, s: EngineSeams): string {
  const home = s.ownerHome();
  if (kind === "codex") return join(home, ".codex", "auth.json");
  if (kind === "kimi") {
    return join(home, ".kimi-code", "credentials", "kimi-code.json");
  }
  return join(home, ".grok", "auth.json");
}

/** Resolve the login/token CLI binary — absolute for kimi/grok, else on PATH. */
function resolveBin(kind: EngineKind, s: EngineSeams): string {
  if (kind === "kimi") {
    const local = join(s.ownerHome(), ".kimi-code", "bin", "kimi");
    return existsSync(local) ? local : "kimi";
  }
  if (kind === "grok") {
    const local = join(s.ownerHome(), ".grok", "bin", "grok");
    return existsSync(local) ? local : "grok";
  }
  // claude / codex are on PATH.
  return kind;
}

function runTokenCommand(
  kind: EngineKind,
  label: string,
  onLog: (line: string) => void,
  s: EngineSeams,
): Promise<ConnectResult> {
  return new Promise((resolve) => {
    let settled = false;
    const done = (r: ConnectResult): void => {
      if (!settled) {
        settled = true;
        resolve(r);
      }
    };
    let child: ChildProcess;
    try {
      child = s.spawn(resolveBin(kind, s), ["setup-token"]);
    } catch (err) {
      done({ ok: false, message: err instanceof Error ? err.message : String(err) });
      return;
    }
    let stdout = "";
    child.stdout?.on("data", (b: Buffer) => {
      const text = b.toString("utf8");
      stdout += text;
      relay(text, onLog);
    });
    child.stderr?.on("data", (b: Buffer) => relay(b.toString("utf8"), onLog));
    child.on("error", (err: NodeJS.ErrnoException) =>
      done({
        ok: false,
        message:
          err.code === "ENOENT"
            ? `${label} CLI not found on PATH — install Claude Code first.`
            : err.message,
      }),
    );
    child.on("exit", () => {
      const token = extractClaudeToken(stdout);
      if (!token) {
        done({
          ok: false,
          message:
            "Couldn't read a token from the CLI output. Try again, or paste the token manually.",
        });
        return;
      }
      upsertEnvLocal("CLAUDE_CODE_OAUTH_TOKEN", token, s);
      done({ ok: true, message: `${label} connected.` });
    });
  });
}

function runLoginCommand(
  kind: EngineKind,
  label: string,
  onLog: (line: string) => void,
  s: EngineSeams,
): Promise<ConnectResult> {
  return new Promise((resolve) => {
    let settled = false;
    const done = (r: ConnectResult): void => {
      if (!settled) {
        settled = true;
        resolve(r);
      }
    };
    let child: ChildProcess;
    try {
      child = s.spawn(resolveBin(kind, s), ["login"]);
    } catch (err) {
      done({ ok: false, message: err instanceof Error ? err.message : String(err) });
      return;
    }
    child.stdout?.on("data", (b: Buffer) => relay(b.toString("utf8"), onLog));
    child.stderr?.on("data", (b: Buffer) => relay(b.toString("utf8"), onLog));
    child.on("error", (err: NodeJS.ErrnoException) =>
      done({
        ok: false,
        message:
          err.code === "ENOENT"
            ? `${label} CLI not found — install it first.`
            : err.message,
      }),
    );
    child.on("exit", () => {
      done(
        detect(kind, s)
          ? { ok: true, message: `${label} connected.` }
          : { ok: false, message: `${label} login didn't complete.` },
      );
    });
  });
}

function relay(text: string, onLog: (line: string) => void): void {
  for (const l of text.split(/\r?\n/)) {
    if (l.trim()) onLog(l.trim());
  }
}

/** Upsert KEY=value into the host's `.env.local` (0600) AND process.env. */
function upsertEnvLocal(key: string, value: string, s: EngineSeams): void {
  const file = s.envLocalPath();
  mkdirSync(dirname(file), { recursive: true });
  let body = "";
  try {
    body = readFileSync(file, "utf8");
  } catch {
    /* new file */
  }
  const line = `${key}=${value}`;
  const re = new RegExp(`^${key}=.*$`, "m");
  body = re.test(body)
    ? body.replace(re, line)
    : body + (body && !body.endsWith("\n") ? "\n" : "") + line + "\n";
  writeFileSync(file, body, { mode: 0o600 });
  try {
    chmodSync(file, 0o600);
  } catch {
    /* best effort */
  }
  // Immediate effect: the running host reads creds from process.env (adapters'
  // available() + task-up env injection), not by reloading .env.local — so set
  // it here or a connect wouldn't take effect until a restart.
  s.procEnv[key] = value;
}

/** Remove a KEY line from `.env.local` and process.env (no-op if absent). */
function removeEnvLocal(key: string, s: EngineSeams): void {
  const file = s.envLocalPath();
  try {
    const body = readFileSync(file, "utf8");
    const next = body
      .split(/\r?\n/)
      .filter((l) => !new RegExp(`^${key}=`).test(l))
      .join("\n");
    writeFileSync(file, next, { mode: 0o600 });
  } catch {
    /* no file */
  }
  delete s.procEnv[key];
}
