import { spawn, type ChildProcess } from "node:child_process";
import * as fs from "node:fs";
import * as net from "node:net";
import * as path from "node:path";
import { FAILURE_CODES, FailureError, subprocessFailureMetadata } from "@argent/registry";
import { ensureCdpReachable } from "../../blueprints/chromium-cdp";
import { chromiumIdFromPort } from "../../utils/device-info";
import { trackChromiumPort } from "../../utils/chromium-discovery";
import { electronGuiChildEnv } from "../../utils/electron-env";

// Booting an Electron app is one way to produce a Chromium/CDP device: the
// launched process is a Chromium runtime exposing a CDP endpoint, so the
// resulting device id, platform, and tool surface are all the generic
// `chromium` ones. This file stays "electron"-named because the *launcher*
// is Electron-specific (it resolves an Electron binary / .app bundle); the
// device it yields is not.
export interface ElectronBootResult {
  platform: "chromium";
  id: string;
  port: number;
  pid: number;
  appPath: string;
  booted: true;
}

interface BootElectronOptions {
  appPath: string;
  port?: number;
  extraArgs?: string[];
  /** Defaults to 30s. */
  readyTimeoutMs?: number;
}

const DEFAULT_READY_TIMEOUT_MS = 30_000;

/**
 * How long a successful readiness probe must hold before boot reports success.
 * A second instance losing Electron's single-instance lock opens its CDP
 * listener during startup and only then quits, so the probe can answer a beat
 * before the child's exit event lands — without the hold, boot would name an
 * already-dead instance. A lock-quitting app closes the listener and exits
 * well within this window; every successful boot pays exactly this much
 * extra latency.
 */
const BOOT_CONFIRM_WINDOW_MS = 300;

/** Pick a free localhost port the kernel hands out. */
async function pickFreePort(): Promise<number> {
  return new Promise<number>((resolve, reject) => {
    const srv = net.createServer();
    srv.unref();
    srv.on("error", reject);
    srv.listen(0, "127.0.0.1", () => {
      const addr = srv.address();
      if (addr && typeof addr === "object") {
        const { port } = addr;
        srv.close(() => resolve(port));
      } else {
        srv.close(() => reject(new Error("Could not allocate a free TCP port")));
      }
    });
  });
}

/**
 * Pick the Electron binary to spawn:
 *  - If `appPath` is a directory, look for `node_modules/.bin/electron` inside it.
 *  - If it's a packaged macOS .app bundle, return its Contents/MacOS/<exec> path.
 *  - Otherwise assume the path itself is the executable.
 *
 * Returns `{ command, args }` where args are the prefix BEFORE the user's --remote-debugging-port flag.
 */
function resolveLauncher(appPath: string): { command: string; args: string[] } {
  const abs = path.resolve(appPath);
  if (!fs.existsSync(abs)) {
    throw new FailureError(`Electron boot: path does not exist: ${abs}`, {
      error_code: FAILURE_CODES.CHROMIUM_ELECTRON_APP_PATH_INVALID,
      failure_stage: "electron_app_path_missing",
      failure_area: "tool_server",
      error_kind: "validation",
    });
  }
  const stat = fs.statSync(abs);
  if (stat.isDirectory()) {
    if (abs.endsWith(".app")) {
      // macOS packaged app bundle. Read Contents/Info.plist's CFBundleExecutable
      // for the real binary name; fall back to the basename.
      const macOsDir = path.join(abs, "Contents", "MacOS");
      if (!fs.existsSync(macOsDir)) {
        throw new FailureError(
          `Electron boot: ${abs} is a .app bundle but has no Contents/MacOS. ` +
            `Pass the inner binary directly, or use the project directory of an unpackaged app.`,
          {
            error_code: FAILURE_CODES.CHROMIUM_ELECTRON_APP_PATH_INVALID,
            failure_stage: "electron_app_bundle_invalid",
            failure_area: "tool_server",
            error_kind: "validation",
          }
        );
      }
      const entries = fs.readdirSync(macOsDir).filter((name) => !name.startsWith("."));
      if (entries.length === 0) {
        throw new FailureError(`Electron boot: ${macOsDir} is empty.`, {
          error_code: FAILURE_CODES.CHROMIUM_ELECTRON_APP_PATH_INVALID,
          failure_stage: "electron_app_bundle_empty",
          failure_area: "tool_server",
          error_kind: "validation",
        });
      }
      // Prefer one matching the .app folder name, otherwise take the first.
      const bundleName = path.basename(abs, ".app");
      const exec = entries.find((n) => n === bundleName) ?? entries[0]!;
      return { command: path.join(macOsDir, exec), args: [] };
    }
    // Unpackaged project directory — use ./node_modules/.bin/electron if present.
    const localBin = path.join(abs, "node_modules", ".bin", "electron");
    if (fs.existsSync(localBin)) {
      return { command: localBin, args: [abs] };
    }
    // Fall back to PATH-resolved `electron`.
    return { command: "electron", args: [abs] };
  }
  // A file — assume it's executable.
  return { command: abs, args: [] };
}

async function waitForCdpReady(port: number, deadlineMs: number): Promise<void> {
  const deadline = Date.now() + deadlineMs;
  let lastErr: unknown = null;
  while (Date.now() < deadline) {
    try {
      await ensureCdpReachable(port);
      return;
    } catch (err) {
      lastErr = err;
      await new Promise((r) => setTimeout(r, 250));
    }
  }
  const detail = lastErr instanceof Error ? lastErr.message : String(lastErr);
  throw new FailureError(
    `Electron CDP never became reachable on port ${port} within ${deadlineMs}ms. ${detail}`,
    {
      error_code: FAILURE_CODES.CHROMIUM_ELECTRON_CDP_TIMEOUT,
      failure_stage: "electron_cdp_ready",
      failure_area: "tool_server",
      error_kind: "timeout",
    },
    { cause: lastErr instanceof Error ? lastErr : undefined }
  );
}

/**
 * Spawn an Electron app and wait until its CDP endpoint is responding.
 *
 * The child is detached so the tool-server's lifecycle does not own it — the
 * caller manages the app process explicitly through Electron's own quit /
 * close-window flows. We `unref()` the process; closing the tool-server does
 * not bring the app down (matching the simulator-server pattern where the
 * simulator outlives the bridge).
 */
/**
 * Strip user-supplied --remote-debugging-port from extraArgs so the caller
 * can't accidentally point Electron at a different CDP port than the one we
 * tracked and reported back. Last-wins on Chromium's flag parser, so a stray
 * override would otherwise silently break list-devices / interaction tools.
 */
function sanitizeExtraArgs(extra: string[]): string[] {
  return extra.filter((a) => {
    if (a === "--remote-debugging-port" || a.startsWith("--remote-debugging-port=")) {
      process.stderr.write(
        `[electron-boot] dropping user-supplied "${a}" — Argent manages the CDP port.\n`
      );
      return false;
    }
    return true;
  });
}

/**
 * Signal the whole process group led by `pid`, reporting whether anything was
 * there. The Electron child is spawned detached, so it leads its own group and
 * every descendant inherits it. A SIGTERM to the leader handle normally
 * suffices: the npm `electron` launcher forwards it to the real binary and
 * exits once that child does, so the whole app quits cleanly. The group sweep
 * covers what that leaves behind — an app that traps SIGTERM or blocks quit
 * from its `before-quit` handler, and helpers outliving a wedged browser.
 * Survivors reparent (to launchd on macOS, init/systemd on Linux) but keep
 * their pgid, which is what lets a group signal still reach them.
 */
function signalGroup(pid: number, signal: NodeJS.Signals | 0): boolean {
  try {
    process.kill(-pid, signal);
    return true;
  } catch (err) {
    // ESRCH = the group is empty; anything else (EPERM) means it isn't.
    return (err as NodeJS.ErrnoException).code !== "ESRCH";
  }
}

function killChildEscalating(child: ChildProcess): void {
  // SIGTERM through the handle lets Electron run its quit sequence and take
  // its helpers down with it; a group SIGTERM would hit every helper directly
  // and defeat that, so it is sent only when the handle is already dead and
  // child.kill reaches nothing — orphaned survivors still get a graceful-quit
  // request. SIGKILL after 2s catches stuck processes (hardware-accelerated
  // GPU shutdown can deadlock on some Intel drivers) and sweeps the group.
  try {
    child.kill("SIGTERM");
  } catch {
    /* already gone */
  }
  if (child.pid !== undefined && (child.exitCode !== null || child.signalCode !== null)) {
    signalGroup(child.pid, "SIGTERM");
  }
  setTimeout(() => {
    if (child.exitCode === null && child.signalCode === null) {
      try {
        child.kill("SIGKILL");
      } catch {
        /* already gone */
      }
    }
    // The group's own liveness decides this escalation, not the leader's exit
    // status: the leader routinely exits while a helper lives on. Probe-then-
    // kill leaves the same recycled-pgid window the raw-pid fallback documents.
    if (child.pid !== undefined && signalGroup(child.pid, 0)) {
      signalGroup(child.pid, "SIGKILL");
    }
  }, 2000).unref();
}

/**
 * ChildProcess handles for the Electron apps this tool-server booted, keyed by
 * CDP port. Retained so teardown can kill through the handle: its
 * exitCode/signalCode guard lets {@link killChildEscalating} skip the delayed
 * SIGKILL on the leader once the child has exited, so that kill can never land
 * on a recycled pid. The group SIGKILL is gated only by a group-liveness probe
 * — see killChildEscalating — so it carries the same residual probe-to-kill
 * window as any raw-pid signal. Entries are dropped when the child exits or a
 * kill consumes them. Holding the handle does not re-ref the unref'd child, so
 * the tool-server's event loop still isn't kept alive by it.
 */
const liveChildren = new Map<number, ChildProcess>();

/**
 * Terminate a Chromium/Electron app this tool-server booted on `port`.
 * Prefers the retained ChildProcess handle ({@link liveChildren}) and kills it
 * with {@link killChildEscalating}: its exit-status guard keeps the delayed
 * leader SIGKILL off recycled pids, while the group sweep relies on a
 * liveness probe. Only when no handle is held (the child already exited, or
 * it was booted by an earlier tool-server process) does it fall back to
 * best-effort group signalling on the raw pid. An already-exited process is a
 * no-op, not an error.
 *
 * `pid` must be a detached-spawn group leader, since every signal this reaches
 * targets the whole group led by it ({@link signalGroup}) — a pid discovered
 * some other way (a CDP-reported browser pid, say) names a group the caller
 * never spawned.
 */
export function killChromiumByPort(port: number, pid?: number): void {
  const child = liveChildren.get(port);
  if (child) {
    liveChildren.delete(port);
    killChildEscalating(child);
    return;
  }
  if (pid !== undefined) killChromiumByPidFallback(pid);
}

/** How long to wait for a killed instance to actually exit before giving up on it. */
const EXIT_WAIT_TIMEOUT_MS = 5000;
const EXIT_POLL_MS = 50;

/**
 * Terminate the instance on `port` and wait until the process is actually gone.
 * {@link killChromiumByPort} only delivers the signal, so a caller that reboots
 * the same app immediately would race the dying process's single-instance lock
 * — the replacement quits on startup and its CDP endpoint never comes up.
 * Best-effort: returns after `timeoutMs` regardless, so a wedged process can't
 * stall a run (the 2s SIGKILL escalation normally lands well inside it).
 * `pid` carries {@link killChromiumByPort}'s group-leader requirement, and the
 * exit poll below probes that same group.
 */
export async function killChromiumByPortAndWait(
  port: number,
  pid?: number,
  timeoutMs = EXIT_WAIT_TIMEOUT_MS
): Promise<void> {
  const child = liveChildren.get(port);
  const alive = child && child.exitCode === null && child.signalCode === null;
  // Attached before the kill so an exit between the two can't be missed.
  const exited = alive ? new Promise<void>((resolve) => child.once("exit", () => resolve())) : null;

  killChromiumByPort(port, pid);

  if (exited) return Promise.race([exited, sleepUnref(timeoutMs)]);
  if (!child && pid !== undefined) {
    // A child this process booted that already exited and was evicted from the
    // handle registry: no exit event to await, so poll. The probe targets the
    // group, not the leader — the single-instance lock this wait guards is
    // held by the browser process, a group member that can outlive the leader.
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
      if (!signalGroup(pid, 0)) return;
      await sleepUnref(EXIT_POLL_MS);
    }
  }
}

/** Timer-based delay that never holds the event loop open. */
function sleepUnref(ms: number): Promise<void> {
  return new Promise((resolve) => {
    setTimeout(resolve, ms).unref();
  });
}

/**
 * Raw-pid fallback: group SIGTERM, then group SIGKILL after a grace period.
 * Group signalling is safe here because the pid is always a detached-spawn
 * group leader — every producer ({@link bootElectronApp}) spawns detached, so
 * pgid=pid names the app's own group, never the caller's — and necessary
 * because helpers routinely outlive the leader: a leader-only signal would
 * report "gone" while the rest of the app lives on. The SIGKILL is gated only
 * on a group-liveness re-probe (signal 0); a group emptying between that probe
 * and the kill could still hand its pgid to a newcomer — the residual
 * recycled-pgid window inherent to any raw-pid signal, shared by the group
 * sweep in {@link killChildEscalating}.
 */
function killChromiumByPidFallback(pid: number): void {
  if (!signalGroup(pid, "SIGTERM")) return; // group already empty, nothing to escalate
  setTimeout(() => {
    if (signalGroup(pid, 0)) signalGroup(pid, "SIGKILL");
  }, 2000).unref();
}

/**
 * Chromium switches that keep an argent-booted app fully responsive while its
 * window is unfocused, occluded, or minimized. Without them the compositor
 * throttles a hidden window: mouse-input acks stall for seconds per event
 * (they wait on hit-testing), wheel scrolls hang, and `document.visibilityState`
 * flips to "hidden".
 *
 * Division of labor with primePageSession's focus emulation: emulation keeps
 * the renderer responsive while a CDP session is attached, but sessions are
 * created lazily on first tool use and die with the tool-server (which
 * idle-exits by design while the app outlives it) — these flags cover that
 * detached lifecycle, plus runtimes where emulation is unavailable. An
 * agent-driven app must stay testable regardless of where the human puts the
 * window, so they are unconditional for apps we spawn; externally launched
 * CDP targets are unaffected.
 */
const ANTI_THROTTLING_ARGS = [
  "--disable-background-timer-throttling",
  "--disable-backgrounding-occluded-windows",
  "--disable-renderer-backgrounding",
];

export async function bootElectronApp(options: BootElectronOptions): Promise<ElectronBootResult> {
  const port = options.port ?? (await pickFreePort());
  const launcher = resolveLauncher(options.appPath);
  const extra = sanitizeExtraArgs(options.extraArgs ?? []);

  const args = [
    ...launcher.args,
    `--remote-debugging-port=${port}`,
    ...ANTI_THROTTLING_ARGS,
    ...extra,
  ];

  let child: ChildProcess;
  try {
    child = spawn(launcher.command, args, {
      detached: true,
      stdio: ["ignore", "pipe", "pipe"],
      // Strip ELECTRON_RUN_AS_NODE (see electronGuiChildEnv): if the tool-server
      // inherited it from an Electron-based MCP host, the Electron binary would
      // run in Node mode with no CDP endpoint — so boot-device fails below (the
      // child exits early, or the readiness probe times out) instead of the app
      // coming up.
      env: electronGuiChildEnv({ ELECTRON_ENABLE_LOGGING: "1" }),
    });
  } catch (err) {
    throw new FailureError(
      `Electron boot: failed to spawn ${launcher.command}: ${err instanceof Error ? err.message : String(err)}`,
      {
        error_code: FAILURE_CODES.CHROMIUM_ELECTRON_SPAWN_FAILED,
        failure_stage: "electron_spawn",
        failure_area: "tool_server",
        error_kind: "subprocess",
        ...subprocessFailureMetadata(err, "electron"),
      },
      { cause: err instanceof Error ? err : new Error(String(err)) }
    );
  }

  // Attach the `error` listener BEFORE checking pid / wiring anything else.
  // Node's `spawn()` returns synchronously, but ENOENT / EACCES / EAGAIN are
  // delivered as a deferred `'error'` event on the next tick. EventEmitter
  // convention: an unhandled `error` event escapes as an uncaught exception —
  // here that would crash the entire tool-server every time someone called
  // boot-device with `electronAppPath` on a host that doesn't have electron
  // on PATH. Fold the event into the readiness race so the caller sees a
  // clean rejection instead.
  const onSpawnError = (err: NodeJS.ErrnoException, reject: (e: Error) => void) => {
    const codeSuffix = err.code ? ` (${err.code})` : "";
    reject(
      new FailureError(
        `Electron boot: failed to launch ${launcher.command}${codeSuffix}: ${err.message}. ` +
          `Make sure 'electron' is installed (npm i electron in the app dir, or globally) and on PATH.`,
        {
          error_code: FAILURE_CODES.CHROMIUM_ELECTRON_SPAWN_FAILED,
          failure_stage: "electron_spawn_error",
          failure_area: "tool_server",
          error_kind: "subprocess",
          ...subprocessFailureMetadata(err, "electron"),
        },
        { cause: err }
      )
    );
  };
  let spawnErrorReject: ((e: Error) => void) | null = null;
  const spawnError = new Promise<never>((_resolve, reject) => {
    spawnErrorReject = reject;
  });
  const spawnErrorListener = (err: NodeJS.ErrnoException) => {
    if (spawnErrorReject) onSpawnError(err, spawnErrorReject);
  };
  child.once("error", spawnErrorListener);

  if (!child.pid) {
    // No pid + no async error yet is still possible on some platforms when
    // spawn fails very early. Detach the error listener before throwing so a
    // deferred `'error'` event delivered after this synchronous throw doesn't
    // resolve onto an orphan promise (which Node would surface as an
    // UnhandledPromiseRejection and — with default --unhandled-rejections=throw
    // — crash the tool-server).
    child.removeListener("error", spawnErrorListener);
    spawnErrorReject = null;
    throw new FailureError(
      `Electron boot: spawn returned without a pid (binary: ${launcher.command}).`,
      {
        error_code: FAILURE_CODES.CHROMIUM_ELECTRON_SPAWN_FAILED,
        failure_stage: "electron_spawn_no_pid",
        failure_area: "tool_server",
        error_kind: "subprocess",
        failure_command: "electron",
      }
    );
  }

  // Forward Electron stderr to our stderr so launch failures are visible to
  // the user / agent. Drop stdout (renderer chatter) to keep tool-server logs clean.
  child.stderr?.on("data", (chunk: Buffer) => {
    process.stderr.write(`[chromium-cdp-${port}] ${chunk}`);
  });
  child.unref();

  // Race the readiness probe against the child's exit event. If the process
  // dies before CDP comes up (e.g. main.js crashes during startup), without
  // this race the caller would see a generic readiness-timeout error 30s
  // later instead of "process exited with code N".
  //
  // Both onExit and the earlier spawnErrorListener stay attached to the child
  // for the duration of Promise.race below. After we resolve (success OR
  // failure), they MUST be detached: the child is detached + unref'd, so it
  // outlives this function. A natural exit later (e.g. user closes the
  // Electron window) would otherwise reject the orphan `earlyExit` promise
  // with "exited with code 0" → unhandled rejection → tool-server crash.
  // Same shape as the no-pid throw path above, just for the steady-state run.
  let earlyExitReject: ((e: Error) => void) | null = null;
  const earlyExit = new Promise<never>((_resolve, reject) => {
    earlyExitReject = reject;
  });
  const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
    if (!earlyExitReject) return;
    const reason = signal ? `signal ${signal}` : `code ${code ?? "?"}`;
    earlyExitReject(
      new FailureError(
        `Electron boot: child process exited with ${reason} before CDP was ready. Inspect [chromium-cdp-${port}] stderr above for the cause.`,
        {
          error_code: FAILURE_CODES.CHROMIUM_ELECTRON_EXITED_BEFORE_READY,
          failure_stage: "electron_early_exit",
          failure_area: "tool_server",
          error_kind: "subprocess",
          ...subprocessFailureMetadata({ code, signal }, "electron"),
        }
      )
    );
  };
  child.once("exit", onExit);

  const detachBootListeners = () => {
    child.removeListener("error", spawnErrorListener);
    child.removeListener("exit", onExit);
    spawnErrorReject = null;
    earlyExitReject = null;
  };

  try {
    await Promise.race([
      waitForCdpReady(port, options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS),
      earlyExit,
      spawnError,
    ]);
    // The probe winning the race does not prove the app is staying up — see
    // BOOT_CONFIRM_WINDOW_MS. Confirm with the boot listeners still attached:
    // an exit that already landed has rejected earlyExit (so the race rejects
    // at once), and one still in flight gets the window to land; either way
    // the catch below cleans up as an early exit. On a clean window the
    // unsettled earlyExit is inert — detachBootListeners() nulls its reject
    // before anything can fire it.
    await Promise.race([earlyExit, sleepUnref(BOOT_CONFIRM_WINDOW_MS)]);
  } catch (err) {
    // CDP didn't come up — terminate the orphan so we don't leak a process.
    // Detach the boot listeners first so the impending kill→exit doesn't
    // chain into a stale earlyExit rejection.
    //
    // INVARIANT: detachBootListeners() MUST be the first synchronous
    // statement in this catch block — no awaits before it. The boot-time
    // listeners would otherwise keep firing during any awaited cleanup and
    // re-introduce the orphan-rejection bug this commit closes.
    detachBootListeners();
    killChildEscalating(child);
    throw err;
  }
  // Happy path: detach the boot-time listeners now that the race and the
  // confirmation window have both resolved. The child is intentionally
  // long-lived; any later exit / error belongs to whatever code subsequently
  // manages the session, not to this boot fn.
  detachBootListeners();

  // Retain the handle so a later teardown (killChromiumByPort) can kill via
  // the ChildProcess instead of a recyclable raw pid — see liveChildren.
  // Unlike the boot-time onExit just detached, this listener only clears the
  // map entry; it can't reject anything, so a natural exit long after boot
  // stays inert. The identity check keeps a stale child's exit from evicting
  // a newer boot that reused the same fixed port.
  liveChildren.set(port, child);
  child.once("exit", () => {
    if (liveChildren.get(port) === child) liveChildren.delete(port);
  });

  trackChromiumPort(port);

  return {
    platform: "chromium",
    id: chromiumIdFromPort(port),
    port,
    pid: child.pid,
    appPath: path.resolve(options.appPath),
    booted: true,
  };
}
