#!/usr/bin/env node
import { Buffer } from "node:buffer";
import { spawn, execSync, type ChildProcess } from "node:child_process";
import { createConnection } from "node:net";
import { fileURLToPath } from "node:url";
import { dirname, join, resolve as resolvePath } from "node:path";
import { existsSync } from "node:fs";
import type { Readable, Writable } from "node:stream";
import {
  formatAccessHintLines,
  isValidBindHost,
  localUrlForPort,
  networkUrlsForBindHost,
  probeHostForBindHost,
  urlForHost,
} from "./cli/networkHints.js";
import {
  shouldSuppressServerOutputLine,
  type ServerOutputFilterContext,
} from "./cli/startupOutput.js";
import { startIdentityProxy } from "./proxy/identityProxy.js";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// External-facing port stays at 25947 so existing tooling / AI clients
// (Claude Code, OpenCode, MiMo Code, etc.) keep working with the
// unchanged `ANTHROPIC_BASE_URL=http://localhost:25947/proxy`. The
// identity-injecting proxy listens on that port; the TanStack Start
// upstream is moved to a private port (25949 by default) and reverse-
// proxied through.
const DEFAULT_PORT = 25947;
const DEFAULT_UPSTREAM_PORT = 25949;
const LOCAL_PROBE_TIMEOUT_MS = 2000;
const BRANDED_WINDOWS_RUNTIME_EXE = "agent-inspector.exe";
const DEFAULT_CAPTURE_MODE = "simple";

type CaptureMode = "simple" | "full";

process.title = "Agent Inspector";

/**
 * Subcommand router. The legacy one-liner UX (`agent-inspector` with no args,
 * or `agent-inspector start`) starts the proxy. `agent-inspector onboard` runs
 * the guided skill install. Unknown subcommands fall back to the legacy
 * behavior so a stray positional arg doesn't break an old invocation.
 */
const subcommand = process.argv[2];

if (subcommand === "onboard") {
  const { runOnboard } = await import("./cli/onboard.js");
  const code = await runOnboard(process.argv.slice(3));
  process.exit(code);
}

if (subcommand === "doctor") {
  const { runDoctor } = await import("./cli/doctor.js");
  const code = await runDoctor(process.argv.slice(3));
  process.exit(code);
}

await runStart(process.argv.slice(2));

// -----------------------------------------------------------------------------
// Legacy `start` behavior — start the proxy on the configured port. Extracted
// into a function so the router above can keep the top-level flow readable.
// -----------------------------------------------------------------------------
async function isInspectorHealthy(port: number, host?: string): Promise<boolean> {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), LOCAL_PROBE_TIMEOUT_MS);
  try {
    const response = await fetch(`${urlForHost(port, probeHostForBindHost(host))}/api/health`, {
      cache: "no-store",
      signal: controller.signal,
    });
    return response.ok;
  } catch {
    return false;
  } finally {
    clearTimeout(timeout);
  }
}

async function getRunningCaptureMode(port: number, host?: string): Promise<CaptureMode | null> {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), LOCAL_PROBE_TIMEOUT_MS);
  try {
    const response = await fetch(`${urlForHost(port, probeHostForBindHost(host))}/api/config`, {
      cache: "no-store",
      signal: controller.signal,
    });
    if (!response.ok) return null;
    const raw: unknown = await response.json();
    return readCaptureMode(raw);
  } catch {
    return null;
  } finally {
    clearTimeout(timeout);
  }
}

function isPortAcceptingConnections(port: number, host?: string): Promise<boolean> {
  return new Promise((resolve) => {
    const socket = createConnection({ host: probeHostForBindHost(host), port });
    const finish = (value: boolean): void => {
      socket.removeAllListeners();
      socket.destroy();
      resolve(value);
    };

    socket.setTimeout(LOCAL_PROBE_TIMEOUT_MS);
    socket.once("connect", () => finish(true));
    socket.once("timeout", () => finish(false));
    socket.once("error", () => finish(false));
  });
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

async function waitForInspectorHealthy(
  port: number,
  timeoutMs: number,
  host?: string,
): Promise<boolean> {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    if (await isInspectorHealthy(port, host)) return true;
    await sleep(250);
  }
  return false;
}

function openBrowser(targetUrl: string): void {
  let command: string[] | undefined;
  // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
  switch (process.platform) {
    case "darwin":
      command = ["open", targetUrl];
      break;
    case "linux":
      command = ["xdg-open", targetUrl];
      break;
    case "win32":
      command = ["cmd", "/c", "start", "", targetUrl];
      break;
    default:
      // Unsupported platform - do nothing
      break;
  }
  if (command === undefined) return;
  const [bin, ...cmdArgs] = command;
  if (bin === undefined) return;
  const browserProcess = spawn(bin, cmdArgs, {
    stdio: "ignore",
    detached: true,
    windowsHide: true,
  });
  browserProcess.unref();
}

function waitForProcessExit(child: ChildProcess): Promise<number> {
  return new Promise((resolve) => {
    child.once("exit", (code) => {
      resolve(code ?? 1);
    });
    child.once("error", () => {
      resolve(1);
    });
  });
}

function pipeServerOutput(child: ChildProcess, context: ServerOutputFilterContext): void {
  pipeServerOutputStream(child.stdout, process.stdout, context);
  pipeServerOutputStream(child.stderr, process.stderr, context);
}

function pipeServerOutputStream(
  stream: Readable | null,
  target: Writable,
  context: ServerOutputFilterContext,
): void {
  if (stream === null) return;

  let buffered = "";
  stream.on("data", (chunk: unknown) => {
    const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
    const lines = `${buffered}${text}`.split(/\r?\n/);
    buffered = lines.pop() ?? "";
    for (const line of lines) {
      if (!shouldSuppressServerOutputLine(line, context)) {
        target.write(`${line}\n`);
      }
    }
  });
  stream.on("end", () => {
    if (buffered.length === 0) return;
    if (!shouldSuppressServerOutputLine(buffered, context)) {
      target.write(buffered);
    }
    buffered = "";
  });
}

type ServerCommand = {
  command: string;
  args: string[];
};

function resolveServerCommand(outputDir: string, serverPath: string): ServerCommand {
  const brandedRuntime = join(outputDir, BRANDED_WINDOWS_RUNTIME_EXE);
  return process.platform === "win32" && existsSync(brandedRuntime)
    ? { command: brandedRuntime, args: [serverPath] }
    : { command: process.execPath, args: [serverPath] };
}

function parseCaptureMode(value: string | undefined): CaptureMode | null {
  switch (value) {
    case undefined:
      return null;
    case "simple":
      return "simple";
    case "full":
      return "full";
    default:
      return null;
  }
}

function readCaptureMode(raw: unknown): CaptureMode | null {
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
  const descriptor = Object.getOwnPropertyDescriptor(raw, "captureMode");
  const value: unknown = descriptor?.value;
  return typeof value === "string" ? parseCaptureMode(value) : null;
}

function printAccessHints(port: number, host?: string): void {
  const networkUrls = networkUrlsForBindHost(port, host);
  for (const line of formatAccessHintLines(port, networkUrls, host)) {
    console.log(line);
  }
}

/** Try to start the identity proxy. Returns `null` (and prints a warning)
 *  if the listen port is already in use by another process — e.g. when
 *  restarting a previous instance that didn't shut down cleanly. The
 *  TanStack Start server keeps running either way; clients just lose the
 *  per-PID attribution until they fall back to direct port `port` access. */
async function tryStartIdentityProxy(
  identityPort: number,
  upstreamPort: number,
  upstreamHost: string | undefined,
): Promise<import("node:http").Server | null> {
  try {
    const server = await startIdentityProxy({
      listenPort: identityPort,
      upstreamHost: upstreamHost ?? "127.0.0.1",
      upstreamPort,
    });
    return server;
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    console.warn(
      `[identity-proxy] Could not listen on port ${identityPort}: ${message}. ` +
        `Falling back to OS-scan heuristic on port ${upstreamPort}. ` +
        `Pass --identity-proxy-port <n> to choose another port, or --no-identity-proxy to disable.`,
    );
    return null;
  }
}

async function runStart(args: string[]): Promise<void> {
  const envPort = process.env["PORT"];
  const portDefault = envPort !== undefined ? Number(envPort) : DEFAULT_PORT;
  const envHost = process.env["NITRO_HOST"] ?? process.env["HOST"];
  const envMode =
    process.env["AGENT_INSPECTOR_CAPTURE_MODE"] ?? process.env["AGENT_INSPECTOR_MODE"];

  let port = portDefault;
  let host = envHost !== undefined && envHost.trim().length > 0 ? envHost.trim() : undefined;
  let open = true;
  let openWasSpecified = false;
  let background = false;
  let forceRestart = false;
  let configDir: string | undefined;
  let providersJson: string | undefined;
  let captureMode = DEFAULT_CAPTURE_MODE;
  let captureModeWasSpecified = false;
  let enableIdentityProxy = true;
  let identityProxyPort =
    process.env["AGENT_INSPECTOR_PORT"] !== undefined
      ? Number(process.env["AGENT_INSPECTOR_PORT"])
      : DEFAULT_PORT;

  if (envMode !== undefined && envMode !== "") {
    const parsedMode = parseCaptureMode(envMode);
    if (parsedMode === null) {
      console.error(`Invalid capture mode: ${envMode}. Use simple or full.`);
      process.exitCode = 1;
      return;
    }
    captureMode = parsedMode;
    captureModeWasSpecified = true;
  }

  if (host !== undefined && !isValidBindHost(host)) {
    console.error(`Invalid host: ${host}. Use an IP or hostname without protocol/path.`);
    process.exitCode = 1;
    return;
  }

  for (let i = 0; i < args.length; i++) {
    const arg = args[i] ?? "";
    if (arg.startsWith("--host=")) {
      const value = arg.slice(arg.indexOf("=") + 1).trim();
      if (!isValidBindHost(value)) {
        console.error(`Invalid host: ${value}. Use an IP or hostname without protocol/path.`);
        process.exitCode = 1;
        return;
      }
      host = value;
      continue;
    }
    if (arg.startsWith("--mode=") || arg.startsWith("--capture-mode=")) {
      const value = arg.slice(arg.indexOf("=") + 1);
      const parsedMode = parseCaptureMode(value);
      if (parsedMode === null) {
        console.error(`Invalid capture mode: ${value}. Use simple or full.`);
        process.exitCode = 1;
        return;
      }
      captureMode = parsedMode;
      captureModeWasSpecified = true;
      continue;
    }
    switch (arg) {
      case "--port":
      case "-p":
        port = Number(args[i + 1]);
        i++;
        break;
      case "--host":
      case "-H": {
        const value = args[i + 1]?.trim() ?? "";
        if (!isValidBindHost(value)) {
          console.error(`Invalid host: ${value}. Use an IP or hostname without protocol/path.`);
          process.exitCode = 1;
          return;
        }
        host = value;
        i++;
        break;
      }
      case "--no-open":
        open = false;
        openWasSpecified = true;
        break;
      case "--open":
        open = true;
        openWasSpecified = true;
        break;
      case "--force-restart":
      case "--restart":
        forceRestart = true;
        break;
      case "--background":
        background = true;
        break;
      case "--config-dir":
        configDir = args[i + 1];
        i++;
        break;
      case "--providers":
        providersJson = args[i + 1];
        i++;
        break;
      case "--mode":
      case "--capture-mode": {
        const value = args[i + 1];
        const parsedMode = parseCaptureMode(value);
        if (parsedMode === null) {
          console.error(`Invalid capture mode: ${String(value)}. Use simple or full.`);
          process.exitCode = 1;
          return;
        }
        captureMode = parsedMode;
        captureModeWasSpecified = true;
        i++;
        break;
      }
      case "--no-identity-proxy":
        enableIdentityProxy = false;
        break;
      case "--identity-proxy-port": {
        const value = args[i + 1];
        const parsed = Number(value);
        if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
          console.error(`Invalid identity-proxy port: ${String(value)}.`);
          process.exitCode = 1;
          return;
        }
        identityProxyPort = parsed;
        i++;
        break;
      }
      default:
        break;
    }
  }

  if (!Number.isInteger(port) || port <= 0 || port > 65535) {
    console.error(`Invalid port: ${String(port)}. Use --port <1-65535>.`);
    process.exitCode = 1;
    return;
  }

  /**
   * Check if a port is in use and kill the process using it
   */
  function killProcessOnPort(targetPort: number): void {
    const platform = process.platform;

    try {
      let pids: number[] = [];

      if (platform === "win32") {
        // Windows: use netstat to find PID, then taskkill
        const output = execSync(`netstat -ano | findstr :${targetPort}`, {
          encoding: "utf8",
          timeout: 5000,
          windowsHide: true,
        });
        const lines = output.trim().split("\n");
        for (const line of lines) {
          const parts = line.trim().split(/\s+/);
          if (parts.length >= 5) {
            const localAddress = parts[1] ?? "";
            const pidStr = parts[4] ?? "";
            if (localAddress !== "" && pidStr !== "" && localAddress.includes(`:${targetPort}`)) {
              const pid = parseInt(pidStr, 10);
              if (!isNaN(pid) && pid > 0) {
                pids.push(pid);
              }
            }
          }
        }
        // Remove duplicates
        pids = [...new Set(pids)];

        for (const pid of pids) {
          try {
            console.log(`Killing process ${pid} on port ${targetPort}...`);
            execSync(`taskkill /PID ${pid} /F`, {
              encoding: "utf8",
              timeout: 5000,
              windowsHide: true,
            });
          } catch {
            // Process may have already exited
          }
        }
      } else {
        // Unix-like: use lsof
        const output = execSync(`lsof -ti:${targetPort}`, { encoding: "utf8", timeout: 5000 });
        const lines = output.trim().split("\n");
        for (const line of lines) {
          const pid = parseInt(line.trim(), 10);
          if (!isNaN(pid) && pid > 0) {
            pids.push(pid);
          }
        }
        // Remove duplicates
        pids = [...new Set(pids)];

        for (const pid of pids) {
          try {
            console.log(`Killing process ${pid} on port ${targetPort}...`);
            execSync(`kill -9 ${pid}`, { encoding: "utf8", timeout: 5000 });
          } catch {
            // Process may have already exited
          }
        }
      }
    } catch {
      // No process found on port, which is fine
    }
  }

  // TanStack Start listens on the private upstream port; the identity
  // proxy owns the public-facing `port`. When the identity proxy is
  // disabled (or not yet running) we fall back to TanStack listening on
  // the public port directly.
  const upstreamPort = enableIdentityProxy ? DEFAULT_UPSTREAM_PORT : port;
  process.env["PORT"] = String(upstreamPort);

  const url = urlForHost(port, host);

  if (!forceRestart && (await isInspectorHealthy(port, host))) {
    console.log(`agent-inspector is already running at ${url}`);
    printAccessHints(port, host);
    if (captureModeWasSpecified) {
      const runningMode = await getRunningCaptureMode(port, host);
      if (runningMode !== null && runningMode !== captureMode) {
        console.log(`Existing instance capture mode is ${runningMode}; requested ${captureMode}.`);
        console.log(`Use --force-restart to restart with ${captureMode} mode.`);
      }
    }
    console.log(`Use --force-restart to restart the existing instance.`);
    if (open && openWasSpecified) {
      openBrowser(url);
    }
    return;
  }

  if (!forceRestart && (await isPortAcceptingConnections(port, host))) {
    console.error(`Port ${port} is already in use, but it is not a healthy agent-inspector.`);
    console.error(`Stop that process, choose --port <n>, or re-run with --force-restart.`);
    process.exitCode = 1;
    return;
  }

  if (forceRestart) {
    const restartPorts = new Set<number>();
    restartPorts.add(port);
    if (enableIdentityProxy) {
      restartPorts.add(identityProxyPort);
      restartPorts.add(upstreamPort);
    }
    for (const targetPort of restartPorts) {
      killProcessOnPort(targetPort);
    }
  }

  console.log(`Server running at ${url}`);
  console.log(`   Capture mode: ${captureMode}`);
  console.log(`   Proxy: ${url}/proxy`);
  printAccessHints(port, host);
  console.log(``);
  console.log(`Route AI coding tools through the proxy:`);
  console.log(`   Claude Code:  ANTHROPIC_BASE_URL=${url}/proxy claude`);
  console.log(`   OpenCode:     LLM_BASE_URL=${url}/proxy opencode`);
  console.log(`   MiMo Code:    OPENAI_BASE_URL=${url}/proxy mimo`);
  console.log(
    `   Direct HTTP:  curl ${url}/proxy/v1/messages -d '{"model":"...","messages":[...]}'`,
  );
  console.log(
    `   Remote/container tools: use the Network proxy URL above when shown; otherwise restart with --host 0.0.0.0 or a reachable IP.`,
  );
  console.log(``);
  console.log(`Routing environment variables:`);
  console.log(`   ROUTES            JSON map of model prefix -> upstream URL`);
  console.log(`   DEFAULT_UPSTREAM  Fallback upstream for unmatched models`);
  console.log(
    `   Example: ROUTES='{"claude-":"https://api.anthropic.com","MiniMax":"https://api.minimaxi.com/anthropic"}'`,
  );

  if (open) {
    openBrowser(url);
  }

  // Compute server path
  const outputDir = __dirname;
  const serverPath = join(outputDir, "../.output/server/index.mjs");
  const serverCommand = resolveServerCommand(outputDir, serverPath);

  // Start the server with the branded Windows runtime when postinstall created it.
  const serverEnv = { ...process.env };
  if (configDir !== undefined) {
    // Normalize MSYS / Git Bash paths to Windows native form.
    // On Windows, `path.join('/c/Users/foo')` becomes `\c\Users\foo`
    // (leading slash converted to backslash).
    // Child processes spawned by `spawn()` won't follow that style, so
    // rewrite `\c\...` (or any `\x\...` drive) to `C:\...` before
    // handing the path to the proxy server. No-op on already-native
    // Windows paths and on non-Windows platforms.
    let resolvedPath = join(configDir);
    const msysMatch = /^\\([a-z])\\(.*)$/i.exec(resolvedPath);
    if (msysMatch !== null) {
      const drive = (msysMatch[1] ?? "").toUpperCase();
      const rest = msysMatch[2] ?? "";
      resolvedPath = `${drive}:\\${rest}`;
    } else {
      resolvedPath = resolvePath(resolvedPath);
    }
    serverEnv["AGENT_INSPECTOR_CONFIG_DIR"] = resolvedPath;
  }
  if (providersJson !== undefined) {
    serverEnv["AGENT_INSPECTOR_PROVIDERS_JSON"] = providersJson;
  }
  if (host !== undefined) {
    serverEnv["HOST"] = host;
    serverEnv["NITRO_HOST"] = host;
  }
  serverEnv["AGENT_INSPECTOR_CAPTURE_MODE"] = captureMode;
  const serverProcess = spawn(serverCommand.command, serverCommand.args, {
    stdio: background ? ["ignore", "ignore", "ignore"] : ["ignore", "pipe", "pipe"],
    detached: background,
    env: serverEnv,
    windowsHide: background,
  });
  if (!background) {
    pipeServerOutput(serverProcess, {
      enableIdentityProxy,
      publicPort: port,
      upstreamPort,
    });
  }

  // Identity proxy — a native Node HTTP server that sits in front of
  // TanStack Start and looks up each client's PID via the OS connection
  // table, injecting the result as `X-Agent-Inspector-Client-*` headers.
  // This lets AI tools (which can't easily set those headers themselves)
  // still get accurate session attribution. Started only after TanStack
  // Start is healthy, so the upstream is ready by the time clients connect.
  const identityProxy = enableIdentityProxy
    ? await tryStartIdentityProxy(identityProxyPort, upstreamPort, host)
    : null;

  if (background) {
    serverProcess.unref();
    // Probe the upstream TanStack port directly — the identity proxy is
    // not started yet so the public port is not yet accepting traffic.
    if (await waitForInspectorHealthy(upstreamPort, 5000, host)) {
      console.log(`agent-inspector background server is ready at ${url}`);
      if (identityProxy !== null) {
        console.log(`Identity proxy ready on port ${identityProxyPort} (PID-attributing proxy).`);
      }
      printAccessHints(port, host);
      if (identityProxy !== null) {
        console.log(
          ``,
          `Route AI coding tools through the identity proxy for accurate PID attribution:`,
          `   Claude Code:  ANTHROPIC_BASE_URL=http://localhost:${identityProxyPort}/proxy claude`,
          `   OpenCode:     LLM_BASE_URL=http://localhost:${identityProxyPort}/proxy opencode`,
          `   MiMo Code:    OPENAI_BASE_URL=http://localhost:${identityProxyPort}/proxy mimo`,
          `   (Direct ${port} still works; it falls back to OS-scan heuristic.)`,
        );
      }
      return;
    }
    console.error(`agent-inspector background server did not become ready at ${url}.`);
    process.exitCode = 1;
    return;
  }

  if (identityProxy !== null) {
    console.log(``);
    console.log(`Identity proxy ready at ${url} (PID-attributing public entrypoint).`);
    if (upstreamPort !== port) {
      console.log(`   Internal runtime port ${upstreamPort} is hidden behind the public proxy.`);
    }
  }

  process.exitCode = await waitForProcessExit(serverProcess);

  if (identityProxy !== null) {
    await new Promise<void>((resolve) => identityProxy.close(() => resolve()));
  }
}
