#!/usr/bin/env node
/**
 * @file plugin-bootstrap.js
 * @description Makes CCAM usable straight from a Claude Code plugin install.
 * Runs from the plugin's `SessionStart` hook and must NEVER block a session:
 * the foreground pass only checks a fast path, then hands the real work to a
 * detached worker copy of itself.
 *
 * The worker installs runtime dependencies, removes hook entries left behind by
 * an older `npm run install-hooks` (they would double-count every event), puts
 * the `ccam` CLI on PATH, and starts the dashboard server detached.
 *
 * Everything writable lives under `~/.claude/agent-dashboard/runtime/` — NOT in
 * the plugin cache, which Claude Code garbage-collects and replaces wholesale
 * on every plugin update.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const fs = require("fs");
const os = require("os");
const path = require("path");
const crypto = require("crypto");
const { spawn, spawnSync } = require("child_process");

const { getDataDir, getClaudeHome, getSettingsPath } = require("../server/lib/claude-home");
const { getServerInfoPath } = require("../server/lib/server-info");
const { isOurEntry } = require("./install-hooks");

const PLUGIN_ROOT = path.resolve(__dirname, "..");
// node:sqlite — the only SQLite driver a `--omit=dev --ignore-scripts` install
// leaves us with, since better-sqlite3 is not a runtime dependency and its
// native addon would need a build step.
const MIN_NODE = [22, 5, 0];
const LOCK_STALE_MS = 10 * 60 * 1000;

function runtimeDir() {
  return path.join(getDataDir(), "runtime");
}

function pluginVersion() {
  try {
    return require(path.join(PLUGIN_ROOT, "package.json")).version;
  } catch {
    return "unknown";
  }
}

/* ------------------------------------------------------------------ state */

function statePath(rt = runtimeDir()) {
  return path.join(rt, "state.json");
}

function readState(rt = runtimeDir()) {
  try {
    return JSON.parse(fs.readFileSync(statePath(rt), "utf8"));
  } catch {
    return null;
  }
}

function writeState(state, rt = runtimeDir()) {
  fs.mkdirSync(rt, { recursive: true });
  fs.writeFileSync(statePath(rt), JSON.stringify(state, null, 2) + "\n", "utf8");
}

/** A dashboard server that is actually listening (the discovery file is PID-checked). */
function serverIsLive() {
  // NOT resolveAllDashboardPorts(): it falls back to [DEFAULT_PORT] when the
  // discovery file has no live entry (a reasonable guess for the CLI/hook
  // handler), which would make bootstrap believe a server is already running
  // when none is. livePids() only counts entries with a PID that is actually
  // alive.
  return livePids().length > 0;
}

/** Hash of the dependency manifest — changes mean the runtime tree must be reinstalled. */
function depsHash(root = PLUGIN_ROOT) {
  const h = crypto.createHash("sha256");
  for (const f of ["package.json", "package-lock.json"]) {
    try {
      h.update(fs.readFileSync(path.join(root, f)));
    } catch {
      h.update(f); // absent counts as its own state
    }
  }
  return h.digest("hex").slice(0, 16);
}

/**
 * Nothing to do when the recorded state matches this plugin build AND a server
 * is already up. Deliberately cheap: this runs on every single SessionStart.
 */
function fastPathOk(rt = runtimeDir()) {
  const state = readState(rt);
  if (!state) return false;
  if (state.pluginVersion !== pluginVersion()) return false;
  // A plugin update lands in a NEW cache directory; the server still running
  // from the old one has to be replaced, so this is not a fast path.
  if (state.pluginRoot !== PLUGIN_ROOT) return false;
  if (state.depsHash !== depsHash()) return false;
  if (!fs.existsSync(path.join(rt, "node_modules"))) return false;
  return serverIsLive();
}

/* ------------------------------------------------------- node version gate */

function nodeVersionOk(version = process.versions.node) {
  const parts = String(version)
    .split(".")
    .map((n) => parseInt(n, 10) || 0);
  for (let i = 0; i < MIN_NODE.length; i++) {
    if (parts[i] > MIN_NODE[i]) return true;
    if (parts[i] < MIN_NODE[i]) return false;
  }
  return true;
}

/* -------------------------------------------------------------------- lock */

function lockPath(rt = runtimeDir()) {
  return path.join(rt, ".bootstrap.lock");
}

function isPidAlive(pid) {
  if (!pid) return false;
  try {
    process.kill(pid, 0);
    return true;
  } catch (err) {
    return err.code === "EPERM";
  }
}

function lockIsStale(dir, now = Date.now()) {
  let stat;
  try {
    stat = fs.statSync(dir);
  } catch {
    return false; // gone already
  }
  if (now - stat.mtimeMs > LOCK_STALE_MS) return true;
  let pid = 0;
  try {
    pid = parseInt(fs.readFileSync(path.join(dir, "pid"), "utf8").trim(), 10);
  } catch {
    return true; // lock dir without a readable pid is debris
  }
  return !isPidAlive(pid);
}

/**
 * Atomic `mkdir` lock so two sessions starting at once cannot race the install
 * or spawn two servers (the second would die on EADDRINUSE). A lock whose owner
 * is dead, or older than LOCK_STALE_MS, is reclaimed.
 *
 * @returns {boolean} true when this process owns the lock
 */
function acquireLock(rt = runtimeDir()) {
  const dir = lockPath(rt);
  fs.mkdirSync(rt, { recursive: true });
  for (let attempt = 0; attempt < 2; attempt++) {
    try {
      fs.mkdirSync(dir);
      fs.writeFileSync(path.join(dir, "pid"), String(process.pid), "utf8");
      return true;
    } catch (err) {
      if (err.code !== "EEXIST") return false;
      if (!lockIsStale(dir)) return false;
      try {
        fs.rmSync(dir, { recursive: true, force: true });
      } catch {
        return false;
      }
    }
  }
  return false;
}

function releaseLock(rt = runtimeDir()) {
  try {
    fs.rmSync(lockPath(rt), { recursive: true, force: true });
  } catch {
    /* best effort */
  }
}

/* --------------------------------------------------------- legacy cleanup */

/**
 * `claude plugin install` materializes the plugin's inline hooks into
 * `~/.claude/settings.json` itself, with `${CLAUDE_PLUGIN_ROOT}` already
 * resolved to the cache path — confirmed by installing this plugin for real
 * and inspecting the file. Those entries also contain `hook-handler.js`, so
 * `isOurEntry()` alone (checkout vs. plugin, both match the same substring)
 * cannot tell a legitimate plugin-installed hook from a leftover
 * `npm run install-hooks` entry. Only entries whose command does NOT resolve
 * under the plugin cache are the legacy, checkout-installed kind.
 *
 * @returns {(entry: object) => boolean}
 */
function isCheckoutHookEntry(claudeHome = getClaudeHome()) {
  const cacheRoot = path.join(claudeHome, "plugins", "cache");
  return (entry) => {
    if (!isOurEntry(entry)) return false;
    const commands = [
      entry.command,
      ...(Array.isArray(entry.hooks) ? entry.hooks.map((h) => h.command) : []),
    ];
    return !commands.some((c) => typeof c === "string" && c.includes(cacheRoot));
  };
}

/**
 * Remove hook entries a previous `npm run install-hooks` wrote into
 * ~/.claude/settings.json. The plugin installs its own hooks, and events carry
 * no id — two handlers mean every token and cost figure is counted twice.
 * Never touches the plugin's own cache-resolved entries (see
 * `isCheckoutHookEntry`) — only genuine leftover checkout paths. The original
 * file is copied aside before any write.
 *
 * @returns {number} how many entries were removed
 */
function stripLegacyHooks(settingsPath = getSettingsPath()) {
  let settings;
  try {
    settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
  } catch {
    return 0; // no settings file, or not ours to touch
  }
  if (!settings.hooks || typeof settings.hooks !== "object") return 0;

  const isLegacy = isCheckoutHookEntry();
  let removed = 0;
  for (const [type, entries] of Object.entries(settings.hooks)) {
    if (!Array.isArray(entries)) continue;
    const kept = entries.filter((e) => !isLegacy(e));
    removed += entries.length - kept.length;
    if (kept.length) settings.hooks[type] = kept;
    else delete settings.hooks[type];
  }
  if (!removed) return 0;

  fs.copyFileSync(settingsPath, `${settingsPath}.ccam-bak`);
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
  return removed;
}

/* ------------------------------------------------------------ CLI on PATH */

function cliDir() {
  return path.join(os.homedir(), ".local", "bin");
}

/**
 * Write a launcher for `ccam` into ~/.local/bin. A launcher rather than a
 * symlink into the plugin cache: that directory is replaced on every plugin
 * update, which would leave a dangling link until the next session.
 *
 * Never overwrites a `ccam` this bootstrap did not write — a developer's
 * `npm link`ed checkout CLI must keep winning.
 *
 * @returns {"written"|"exists"|"foreign"|"failed"}
 */
function linkCli(dir = cliDir(), root = PLUGIN_ROOT) {
  const marker = "# ccam-plugin-launcher";
  const target = path.join(dir, process.platform === "win32" ? "ccam.cmd" : "ccam");
  const body =
    process.platform === "win32"
      ? `@rem ccam-plugin-launcher\r\n@node "${path.join(root, "bin", "ccam.js")}" %*\r\n`
      : `#!/bin/sh\n${marker}\nexec "${process.execPath}" "${path.join(root, "bin", "ccam.js")}" "$@"\n`;
  try {
    if (fs.existsSync(target)) {
      const current = fs.readFileSync(target, "utf8");
      if (!current.includes("ccam-plugin-launcher")) return "foreign";
      if (current === body) return "exists";
    }
    fs.mkdirSync(dir, { recursive: true });
    fs.writeFileSync(target, body, "utf8");
    fs.chmodSync(target, 0o755);
    return "written";
  } catch {
    return "failed";
  }
}

/* ------------------------------------------------------------- dependencies */

/**
 * Install runtime dependencies into the runtime dir. `--ignore-scripts` keeps
 * the root postinstall from pulling the whole Vite client toolchain; the
 * manifests are copied out of the (read-only) plugin cache so npm has a project
 * to install for.
 */
function installDeps(rt = runtimeDir(), root = PLUGIN_ROOT) {
  fs.mkdirSync(rt, { recursive: true });
  for (const f of ["package.json", "package-lock.json"]) {
    const src = path.join(root, f);
    if (fs.existsSync(src)) fs.copyFileSync(src, path.join(rt, f));
  }
  const npm = process.platform === "win32" ? "npm.cmd" : "npm";
  const res = spawnSync(
    npm,
    ["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"],
    { cwd: rt, stdio: "inherit", shell: process.platform === "win32" }
  );
  return res.status === 0;
}

/* ------------------------------------------------------------------ server */

/**
 * Discovery-file entries whose PID is actually alive right now. Reads the file
 * directly rather than server-info's `resolveAllDashboardPorts()`, which falls
 * back to `[DEFAULT_PORT]` when nothing is live — a reasonable guess for the
 * CLI/hook handler, but wrong here: it would make the bootstrap believe a
 * server is already running when none is.
 *
 * @returns {{port:number, pid:number}[]}
 */
function liveServers() {
  try {
    const parsed = JSON.parse(fs.readFileSync(getServerInfoPath(), "utf8"));
    const servers = Array.isArray(parsed.servers) ? parsed.servers : [parsed];
    return servers.filter((s) => s && isPidAlive(s.pid));
  } catch {
    return [];
  }
}

function livePids() {
  return liveServers().map((s) => s.pid);
}

/**
 * Stop running dashboard servers. Needed after a plugin update: the old server
 * runs from a cache directory Claude Code has already replaced, so it must be
 * retired before the new one can take the port.
 *
 * @returns {number} how many processes were signalled
 */
function stopDashboard() {
  let stopped = 0;
  for (const pid of livePids()) {
    try {
      process.kill(pid, "SIGTERM");
      stopped++;
    } catch {
      /* already gone, or not ours to signal */
    }
  }
  return stopped;
}

/**
 * Block until no dashboard PID is alive, so the replacement server does not
 * race the old one for the port. Bounded — the worker is detached, but it must
 * not hang forever if a process refuses to die.
 */
function waitForPortsFree(timeoutMs = 10000) {
  const deadline = Date.now() + timeoutMs;
  const sleeper = new Int32Array(new SharedArrayBuffer(4));
  while (livePids().length && Date.now() < deadline) {
    Atomics.wait(sleeper, 0, 0, 200);
  }
  return livePids().length === 0;
}

function startDashboard(rt = runtimeDir(), root = PLUGIN_ROOT) {
  const logFile = fs.openSync(path.join(rt, "server.log"), "a");
  const child = spawn(process.execPath, [path.join(root, "server", "index.js")], {
    cwd: root,
    detached: true,
    stdio: ["ignore", logFile, logFile],
    env: {
      ...process.env,
      NODE_PATH: path.join(rt, "node_modules"),
      // The plugin cache is read-only; /ccam-open builds the bundle here.
      DASHBOARD_CLIENT_DIST: path.join(rt, "client-dist"),
    },
  });
  child.unref();
  return child.pid;
}

/* --------------------------------------------------------------- worker run */

function log(rt, line) {
  const stamped = `[${new Date().toISOString()}] ${line}\n`;
  try {
    fs.appendFileSync(path.join(rt, "bootstrap.log"), stamped);
  } catch {
    /* best effort */
  }
}

/**
 * The real work. Runs detached from the SessionStart hook, so its only channel
 * is the log file; `/ccam-update` runs it in the foreground with `force`, which
 * reinstalls dependencies and restarts the server unconditionally.
 *
 * @returns {"ok"|"locked"|"node-too-old"|"install-failed"}
 */
function bootstrap(rt = runtimeDir(), { force = false } = {}) {
  fs.mkdirSync(rt, { recursive: true });

  if (!nodeVersionOk()) {
    log(
      rt,
      `Node ${process.versions.node} is too old — CCAM needs Node >= ${MIN_NODE.join(".")} ` +
        `(the server stores data through node:sqlite). Upgrade Node, then start a new session.`
    );
    return "node-too-old";
  }

  if (!acquireLock(rt)) {
    log(rt, "another bootstrap holds the lock — nothing to do");
    return "locked";
  }

  try {
    const wantDeps = depsHash();
    const state = readState(rt) || {};
    if (force || state.depsHash !== wantDeps || !fs.existsSync(path.join(rt, "node_modules"))) {
      log(rt, "installing runtime dependencies (first run takes a few minutes)");
      if (!installDeps(rt)) {
        log(rt, "npm install failed — see the output above; run /ccam-doctor after fixing it");
        return "install-failed";
      }
    }

    const removed = stripLegacyHooks();
    if (removed) {
      log(
        rt,
        `removed ${removed} hook entr${removed === 1 ? "y" : "ies"} left by npm run install-hooks ` +
          `(backup: ${getSettingsPath()}.ccam-bak) — the plugin installs its own`
      );
    }

    log(rt, `ccam CLI launcher: ${linkCli()}`);

    // Built eagerly (not lazily behind /ccam-open) so the dashboard, including
    // client-only routes like /run, works the instant `claude` is started —
    // same trigger as the dependency install above: missing, or this plugin
    // version has not built one yet.
    const { buildClient } = require("./plugin-open");
    const uiResult = buildClient({
      rt,
      root: PLUGIN_ROOT,
      force: force || state.depsHash !== wantDeps,
      logPath: path.join(rt, "client-build.log"),
    });
    if (uiResult === "failed") {
      log(rt, "dashboard UI build failed — API and MCP tools still work; run /ccam-open to retry");
    } else if (uiResult === "built") {
      log(rt, "built the dashboard UI bundle");
    }

    // After a plugin update the running server executes code from a cache
    // directory Claude Code has already discarded — retire it first.
    const movedRoot = force || (state.pluginRoot && state.pluginRoot !== PLUGIN_ROOT);
    if (movedRoot && stopDashboard()) {
      log(rt, "stopped the server started from the previous plugin version");
      waitForPortsFree();
    }

    if (movedRoot || !serverIsLive()) {
      const pid = startDashboard(rt);
      log(rt, `started the dashboard server (pid ${pid})`);
    }

    writeState(
      {
        pluginVersion: pluginVersion(),
        depsHash: wantDeps,
        pluginRoot: PLUGIN_ROOT,
        claudeHome: getClaudeHome(),
        updatedAt: new Date().toISOString(),
      },
      rt
    );
    return "ok";
  } finally {
    releaseLock(rt);
  }
}

/**
 * Foreground pass, invoked by the SessionStart hook. Returns within
 * milliseconds in the steady state, and otherwise hands off to a detached
 * worker so the session never waits on an install.
 */
function main() {
  if (fastPathOk()) return;
  const child = spawn(process.execPath, [__filename, "--worker"], {
    detached: true,
    stdio: "ignore",
  });
  child.unref();
}

if (require.main === module) {
  const force = process.argv.includes("--force");
  if (force || process.argv.includes("--worker")) {
    // `--force` (from /ccam-update) runs in the foreground so the user sees the
    // npm output and the result.
    const result = bootstrap(runtimeDir(), { force });
    if (force) {
      console.log(result === "ok" ? "CCAM runtime refreshed." : `bootstrap: ${result}`);
      if (result !== "ok") process.exitCode = 1;
    }
  } else {
    try {
      main();
    } catch {
      // A bootstrap failure must never break a Claude Code session.
    }
  }
}

module.exports = {
  MIN_NODE,
  LOCK_STALE_MS,
  runtimeDir,
  statePath,
  readState,
  writeState,
  depsHash,
  fastPathOk,
  nodeVersionOk,
  lockPath,
  lockIsStale,
  acquireLock,
  releaseLock,
  isCheckoutHookEntry,
  stripLegacyHooks,
  linkCli,
  installDeps,
  serverIsLive,
  liveServers,
  livePids,
  stopDashboard,
  startDashboard,
  bootstrap,
};
