#!/usr/bin/env node
/**
 * Install AgentBridge globally in a way that is convenient for local testing.
 *
 * Modes:
 *   local  build + verify this checkout, pack it, install the tarball (`--force`),
 *          THEN stop running daemons and sync the Claude Code plugin
 *   npm    verify npm latest exists, install latest (`--force`), THEN stop daemons
 *
 * Ordering invariant (downtime + failure safety):
 *   - `npm install -g --force` is a full replace, so no separate `npm uninstall`
 *     is needed — dropping it removes the window where no binary is on PATH.
 *   - stop-running fires AFTER the install succeeds, so the old daemon keeps
 *     serving until the new bytes are on disk and a FAILED install (red build,
 *     unreachable registry, missing version) never kills the running daemon.
 *
 * PREFIX RESOLUTION: a plain `npm install -g` targets npm's configured global
 * prefix, which is NOT always where the user's `agentbridge` command resolves —
 * e.g. an nvm node's bin dir is first on PATH while npm's prefix points elsewhere.
 * In that case the upgrade installs to a directory that is not on PATH and the
 * new bytes silently never take effect. So we resolve the prefix of the
 * `agentbridge`/`abg` currently on PATH and target THAT (via `npm_config_prefix`),
 * falling back to npm's default when nothing is installed yet.
 */
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, isAbsolute, join } from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath, pathToFileURL } from "node:url";

const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const require = createRequire(import.meta.url);
const { agentBridgeInstallEnv } = require("./install-safety.cjs");
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
const packageName = pkg.name;

const args = process.argv.slice(2);
const dryRun = args.includes("--dry-run");
const skipPlugin = args.includes("--skip-plugin");
const mode = args.find((arg) => !arg.startsWith("-"));

function usage() {
  process.stderr.write(`Usage: node scripts/install-global.mjs <local|npm> [--dry-run] [--skip-plugin]

Examples:
  bun run install:global:local   # build this checkout, replace the global install, sync the Claude Code plugin
  bun run install:global:npm     # fully replace the global install with npm latest

Options:
  --skip-plugin   local mode only: skip the Claude Code plugin sync (\`dev\`) step
`);
}

/**
 * Derive the install prefix from a resolved bin path like `<prefix>/bin/<name>`.
 * Returns the prefix, or null when the path is not under a `bin/` directory.
 */
export function installPrefixFromBinPath(binPath) {
  if (!binPath) return null;
  const binDir = dirname(binPath.trim());
  if (basename(binDir) !== "bin") return null;
  return dirname(binDir);
}

/**
 * Resolve the install prefix of the `agentbridge`/`abg` currently on PATH so the
 * global install lands where the user's command actually runs from. Returns the
 * prefix, or null to fall back to npm's default (e.g. first-ever install).
 *
 * `which` is injectable for testing.
 */
export function resolveInstallPrefix(which = (bin) => spawnSync("which", [bin], { encoding: "utf-8" })) {
  for (const bin of ["agentbridge", "abg"]) {
    const res = which(bin);
    const binPath = ((res && res.stdout) || "").trim().split("\n").filter(Boolean)[0];
    if (res && res.status === 0 && binPath) {
      const prefix = installPrefixFromBinPath(binPath);
      if (prefix) return prefix;
    }
  }
  return null;
}

/** Env additions that pin npm's global prefix to the on-PATH install location. */
function prefixEnv(prefix) {
  return prefix ? { npm_config_prefix: prefix } : {};
}

function quote(arg) {
  return /^[A-Za-z0-9_@%+=:,./<>-]+$/.test(arg) ? arg : JSON.stringify(arg);
}

function commandLine(cmd, commandArgs) {
  return [cmd, ...commandArgs].map(quote).join(" ");
}

function printDry(cmd, commandArgs, suffix = "") {
  process.stdout.write(`$ ${commandLine(cmd, commandArgs)}${suffix}\n`);
}

function run(cmd, commandArgs, options = {}) {
  const { allowFailure = false, cwd = root, captureStdout = false, envExtra = {} } = options;
  process.stdout.write(`$ ${commandLine(cmd, commandArgs)}\n`);
  const res = spawnSync(cmd, commandArgs, {
    cwd,
    encoding: "utf-8",
    env: { ...agentBridgeInstallEnv(process.env), ...envExtra },
    stdio: captureStdout ? ["ignore", "pipe", "inherit"] : "inherit",
  });

  if (res.error) {
    process.stderr.write(`install-global: failed to start ${cmd}: ${res.error.message}\n`);
    if (!allowFailure) process.exit(1);
  }
  if (res.status !== 0 && !allowFailure) {
    process.stderr.write(`install-global: command failed with exit code ${res.status}: ${commandLine(cmd, commandArgs)}\n`);
    process.exit(res.status ?? 1);
  }
  return res;
}

function packedTarballFrom(stdout, destination) {
  const file = stdout
    .split("\n")
    .map((line) => line.trim())
    .filter(Boolean)
    .pop();
  if (!file) {
    process.stderr.write("install-global: npm pack did not report a tarball name\n");
    process.exit(1);
  }
  return isAbsolute(file) ? file : join(destination, file);
}

/** Print where the install will land (a `#` note — never a `$ ` command line). */
function reportPrefix(prefix) {
  if (prefix) {
    process.stdout.write(
      `# install target: npm_config_prefix=${prefix} (where your \`agentbridge\` resolves on PATH)\n`,
    );
  } else {
    process.stdout.write(
      "# install target: npm default prefix (no agentbridge on PATH yet)\n",
    );
  }
}

function installLocal() {
  const prefix = dryRun ? resolveInstallPrefix() : null;
  if (dryRun) {
    reportPrefix(prefix);
    printDry("bun", ["run", "prepublishOnly"]);
    printDry("node", ["scripts/install-safety.cjs", "verify-built"]);
    printDry("npm", ["pack", "--pack-destination", "<temp>"]);
    printDry("node", ["scripts/install-safety.cjs", "verify-tarball", "<packed-tarball>"]);
    printDry("npm", ["install", "-g", "--force", "<packed-tarball>"]);
    printDry("node", ["scripts/install-safety.cjs", "stop-running", "--dry-run"], "  # after install succeeds — the old daemon keeps serving until the new bytes are on disk");
    if (!skipPlugin) {
      printDry("bun", ["src/cli.ts", "dev", "--skip-build"], "  # sync Claude Code plugin (skip with --skip-plugin)");
    }
    return;
  }

  const target = resolveInstallPrefix();
  reportPrefix(target);
  const env = prefixEnv(target);
  let packDir = "";
  try {
    // Ordering rationale (downtime-minimizing, failure-safe):
    //   1. Build + verify FIRST so a red build aborts BEFORE anything is killed
    //      (PR #101 — a broken build must never cause a machine-wide outage).
    //   2. `npm install -g --force` is ALREADY a full replace, so the previous
    //      `npm uninstall -g` step is redundant: it only widened the window in
    //      which no `agentbridge` binary exists on PATH. Removed.
    //   3. Stop running daemons AFTER the install succeeds, not before: the old
    //      daemon keeps serving while the new bytes land, so the downtime window
    //      shrinks to "stop -> user restart". A FAILED install (build/pack/verify
    //      /npm install) returns before this line, leaving the daemon untouched.
    run("bun", ["run", "prepublishOnly"]);
    run("node", ["scripts/install-safety.cjs", "verify-built"]);
    packDir = mkdtempSync(join(tmpdir(), "agentbridge-pack-"));
    const packed = run("npm", ["pack", "--pack-destination", packDir], { captureStdout: true });
    const tarball = packedTarballFrom(packed.stdout ?? "", packDir);
    run("node", ["scripts/install-safety.cjs", "verify-tarball", tarball]);
    run("npm", ["install", "-g", "--force", tarball], { envExtra: env });
    run("node", ["scripts/install-safety.cjs", "stop-running"]);
    process.stdout.write(`install-global: installed ${packageName} globally from local source\n`);
  } finally {
    if (packDir) rmSync(packDir, { recursive: true, force: true });
  }

  // Sync the Claude Code plugin from this checkout so one command updates both
  // deployables (terminal CLI + plugin bundles). Non-fatal: the global CLI is
  // already installed at this point, so a plugin-sync failure (e.g. missing
  // `claude` CLI) must not report the whole install as failed.
  if (!skipPlugin) {
    process.stdout.write("# syncing Claude Code plugin from this checkout (skip with --skip-plugin)\n");
    const dev = run("bun", ["src/cli.ts", "dev", "--skip-build"], { allowFailure: true });
    if (dev.status !== 0) {
      process.stderr.write(
        "install-global: global CLI installed, but the Claude Code plugin sync failed — run `bun src/cli.ts dev` in this checkout manually.\n",
      );
    }
  }

  // Running daemons/sessions were stopped after the new bytes landed — make
  // the required restart explicit instead of leaving it implied.
  process.stdout.write(
    "# note: running AgentBridge sessions were stopped — start fresh with `agentbridge claude` / `agentbridge codex`\n",
  );

  warnAboutSurvivingFrontends();
}

/**
 * Old Claude Code windows keep the PREVIOUS plugin loaded in memory; their
 * sessions relaunch daemons at the old build, recreating the exact artifact
 * split this installer just fixed. Surface them explicitly — the user cannot
 * otherwise tell which windows are stale.
 */
function warnAboutSurvivingFrontends() {
  let output = "";
  try {
    output = spawnSync("ps", ["-axo", "pid=,command="], { encoding: "utf-8" }).stdout ?? "";
  } catch {
    return;
  }
  const frontends = output
    .split("\n")
    .map((line) => line.match(/^\s*(\d+)\s+(.+)$/))
    .filter(
      (m) =>
        m &&
        /(?:^|[\s/\\])bridge-server\.js(?:\s|$)/.test(m[2]) &&
        (m[2].includes("agentbridge") || m[2].includes("agent_bridge")),
    )
    .map((m) => Number(m[1]));
  if (frontends.length === 0) return;
  process.stdout.write(
    `# ⚠️  检测到 ${frontends.length} 个仍在运行的 Claude Code 桥接前端 (pid ${frontends.join(", ")})：\n` +
      "#     它们窗口内加载的仍是旧插件代码——请关闭并重开这些 Claude Code 窗口以使用新版本。\n",
  );
}

function installNpm() {
  const spec = `${packageName}@latest`;
  const prefix = dryRun ? resolveInstallPrefix() : null;
  if (dryRun) {
    reportPrefix(prefix);
    printDry("npm", ["view", spec, "version"]);
    printDry("npm", ["install", "-g", "--force", spec]);
    printDry("node", ["scripts/install-safety.cjs", "stop-running", "--dry-run"], "  # after install succeeds — a failed `npm view` / install leaves the running daemon untouched");
    return;
  }

  const target = resolveInstallPrefix();
  reportPrefix(target);
  const env = prefixEnv(target);
  // Validate + install BEFORE stopping anything: the old order killed every
  // running pair before `npm view` even confirmed the registry was reachable,
  // so an unreachable registry or a missing version caused a pointless
  // machine-wide outage. `--force` is a full replace (no separate uninstall
  // needed). A failure on any line below returns before stop-running, leaving
  // the running daemon untouched — zero downtime on the failure path.
  run("npm", ["view", spec, "version"]);
  run("npm", ["install", "-g", "--force", spec], { envExtra: env });
  run("node", ["scripts/install-safety.cjs", "stop-running"]);
  process.stdout.write(`install-global: installed ${packageName} globally from npm latest\n`);
}

// Only dispatch when executed directly (so the helpers above stay importable in tests).
const invokedDirectly =
  process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (invokedDirectly) {
  if (mode === "local" || mode === "source") {
    installLocal();
  } else if (mode === "npm" || mode === "registry") {
    installNpm();
  } else {
    usage();
    process.exit(1);
  }
}
