process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1";

// Enrich process PATH at startup so binary resolution and `which` calls can find
// binaries installed via version managers (nvm, volta, fnm, etc.).
// Critical when running as a launchd/systemd service with a restricted PATH.
import { getEnrichedPath } from "./path-resolver.js";
process.env.PATH = getEnrichedPath();

import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { exec, execSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { serveStatic } from "hono/bun";
import { createRoutes } from "./routes.js";
import { CliLauncher } from "./cli-launcher.js";
import { WsBridge } from "./ws-bridge.js";
import { SessionStore } from "./session-store.js";
import { WorktreeTracker } from "./worktree-tracker.js";
import { containerManager } from "./container-manager.js";
import { join } from "node:path";
import { TerminalManager } from "./terminal-manager.js";
import { generateSessionTitle } from "./auto-namer.js";
import * as sessionNames from "./session-names.js";
import { getSettings } from "./settings-manager.js";
import { PRPoller } from "./pr-poller.js";
import { RecorderManager } from "./recorder.js";
import { CronScheduler } from "./cron-scheduler.js";
import { AgentExecutor } from "./agent-executor.js";
import { migrateCronJobsToAgents } from "./agent-cron-migrator.js";

import { startPeriodicCheck, setServiceMode, stopPeriodicCheck } from "./update-checker.js";
import { imagePullManager } from "./image-pull-manager.js";
import { GracefulShutdown } from "./graceful-shutdown.js";
import { isRunningAsService } from "./service.js";
import { authMiddleware, validateWsToken, validateWsOrigin, initAuthToken, extractBearerToken, validateToken, getAuthToken } from "./auth-middleware.js";
import { createRateLimiterFromEnv, rateLimitMiddleware } from "./rate-limiter.js";
import { createHealthRoutes, createDetailedHealthRoutes } from "./health.js";
import { createAuthRoutes } from "./routes/auth-routes.js";
import { createPrerequisitesRoutes } from "./routes/prerequisites-routes.js";
import { securityHeaders } from "./security-headers.js";
import { initGhAvailability } from "./github-pr.js";
import { createLogger } from "./logger.js";
import { config, ZENCODE_DIR } from "./config.js";
import { errorHandler } from "./errors.js";
import type { SocketData } from "./ws-bridge.js";
import type { ServerWebSocket } from "bun";

const log = createLogger("server");

const __dirname = dirname(fileURLToPath(import.meta.url));
const packageRoot = config.packageRoot || resolve(__dirname, "..");

const port = config.port;
const hostname = config.host;
const idleTimeoutSeconds = config.idleTimeoutSeconds;
const sessionStore = new SessionStore(config.sessionDir);
const wsBridge = new WsBridge();
const launcher = new CliLauncher(port, hostname);
const worktreeTracker = new WorktreeTracker();
const CONTAINER_STATE_PATH = join(ZENCODE_DIR, "containers.json");
const terminalManager = new TerminalManager();
const prPoller = new PRPoller(wsBridge);
const recorder = new RecorderManager();
const cronScheduler = new CronScheduler(launcher, wsBridge);
const agentExecutor = new AgentExecutor(launcher, wsBridge);

// ── Restore persisted sessions from disk ────────────────────────────────────
wsBridge.setStore(sessionStore);
wsBridge.setRecorder(recorder);
launcher.setStore(sessionStore);
launcher.setRecorder(recorder);
launcher.restoreFromDisk();
await wsBridge.restoreFromDisk();

// ── Docker availability check ───────────────────────────────────────────────
const dockerAvailable = await containerManager.checkDocker();
if (dockerAvailable) {
  const dockerVersion = await containerManager.getDockerVersion();
  log.info({ dockerVersion: dockerVersion ?? "unknown" }, "Docker available");
  await containerManager.restoreState(CONTAINER_STATE_PATH);
} else {
  log.info("Docker not available — container features disabled (this is fine for local CLI usage)");
}

// When the CLI reports its internal session_id, store it for --resume on relaunch
wsBridge.onCLISessionIdReceived((sessionId, cliSessionId) => {
  launcher.setCLISessionId(sessionId, cliSessionId);
});

// When a Codex adapter is created, attach it to the WsBridge
launcher.onCodexAdapterCreated((sessionId, adapter) => {
  wsBridge.attachCodexAdapter(sessionId, adapter);
});

// Provide initialize options (systemPrompt, etc.) from launcher session info
wsBridge.onInitializeOptionsNeeded((sessionId) => {
  const info = launcher.getSession(sessionId);
  if (!info?.systemPrompt && !info?.appendSystemPrompt) return null;
  return {
    systemPrompt: info.systemPrompt,
    appendSystemPrompt: info.appendSystemPrompt,
  };
});

// Start watching PRs when git info is resolved for a session
wsBridge.onSessionGitInfoReadyCallback((sessionId, cwd, branch) => {
  prPoller.watch(sessionId, cwd, branch);
});

// Auto-relaunch CLI when a browser connects to a session with no CLI
const relaunchingSet = new Set<string>();
wsBridge.onCLIRelaunchNeededCallback(async (sessionId) => {
  if (relaunchingSet.has(sessionId)) return;
  const info = launcher.getSession(sessionId);
  if (info?.archived) return;
  if (info && info.state !== "starting") {
    relaunchingSet.add(sessionId);
    log.info({ sessionId }, "Auto-relaunching CLI");
    try {
      const result = await launcher.relaunch(sessionId);
      if (!result.ok && result.error) {
        wsBridge.broadcastToSession(sessionId, { type: "error", message: result.error });
      }
    } finally {
      setTimeout(() => relaunchingSet.delete(sessionId), 5000);
    }
  }
});

// Auto-generate session title after first turn completes
wsBridge.onFirstTurnCompletedCallback(async (sessionId, firstUserMessage) => {
  // Don't overwrite a name that was already set (manual rename or prior auto-name)
  if (sessionNames.getName(sessionId)) return;
  if (!getSettings().openrouterApiKey.trim()) return;
  const info = launcher.getSession(sessionId);
  const model = info?.model || "claude-sonnet-4-6";
  log.info({ sessionId, model }, "Auto-naming session via OpenRouter");
  const title = await generateSessionTitle(firstUserMessage, model);
  // Re-check: a manual rename may have occurred while we were generating
  if (title && !sessionNames.getName(sessionId)) {
    log.info({ sessionId, title }, "Auto-named session");
    sessionNames.setName(sessionId, title);
    wsBridge.broadcastNameUpdate(sessionId, title);
  }
});

log.info({ dir: sessionStore.directory }, "Session persistence");
if (recorder.isGloballyEnabled()) {
  log.info({ dir: recorder.getRecordingsDir(), maxLines: recorder.getMaxLines() }, "Recording enabled");
}

const app = new Hono();
app.onError(errorHandler());

const DEFAULT_CORS_ORIGINS = [
  "http://localhost:5174",
  "http://localhost:3457",
  "http://localhost:3456",
  "http://127.0.0.1:5174",
  "http://127.0.0.1:3457",
  "http://127.0.0.1:3456",
];

const extraOrigins = config.corsOrigins;

const allowedOrigins = [...DEFAULT_CORS_ORIGINS, ...extraOrigins];

// ── Auth token initialization ───────────────────────────────────────────────
initAuthToken();

// ── Health / readiness endpoints (unauthenticated, outside rate-limiting) ────
const packageVersion: string = (() => {
  try {
    const pkgPath = resolve(packageRoot, "package.json");
    return JSON.parse(readFileSync(pkgPath, "utf-8")).version;
  } catch (err) {
    log.debug({ err }, "Failed to read package.json version");
    return "unknown";
  }
})();

app.route("", createHealthRoutes({ version: packageVersion, wsBridge }));

app.use("*", securityHeaders());
app.use("/api/*", cors({ origin: allowedOrigins }));
const rateLimiter = createRateLimiterFromEnv();
rateLimiter.startCleanup();
app.use("/api/*", rateLimitMiddleware(rateLimiter));

// Auth-check routes must be registered BEFORE authMiddleware so they're accessible without a token
app.route("/api", createAuthRoutes());
app.route("/api", createPrerequisitesRoutes());

app.use("/api/*", authMiddleware());
app.route("/api/health", createDetailedHealthRoutes({ version: packageVersion, wsBridge }));
app.route("/api", createRoutes({
  launcher,
  wsBridge,
  sessionStore,
  worktreeTracker,
  terminalManager,
  prPoller,
  recorder,
  cronScheduler,
  agentExecutor,
}));

// In production, serve built frontend using absolute path (works when installed as npm package)
if (config.isProduction) {
  const distDir = resolve(packageRoot, "dist");
  app.use("/*", serveStatic({ root: distDir }));
  app.get("/*", serveStatic({ path: resolve(distDir, "index.html") }));
}

let server: ReturnType<typeof Bun.serve<SocketData>>;
try {
  server = Bun.serve<SocketData>({
    port,
    hostname,
    idleTimeout: idleTimeoutSeconds,
  async fetch(req, server) {
    const url = new URL(req.url);

    // ── WebSocket auth gate ─────────────────────────────────────────
    const isWsUpgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";
    if (isWsUpgrade) {
      // Origin validation — reject unknown browser origins
      const origin = req.headers.get("origin");
      const originCheck = validateWsOrigin(origin, allowedOrigins);
      if (!originCheck.valid) {
        return new Response(JSON.stringify({ error: originCheck.error }), {
          status: 403,
          headers: { "Content-Type": "application/json" },
        });
      }

      // Token validation — prefer Sec-WebSocket-Protocol, fallback to query param
      const protocolHeader = req.headers.get("sec-websocket-protocol");
      const wsAuth = validateWsToken(url, protocolHeader);
      if (!wsAuth.valid) {
        return new Response(JSON.stringify({ error: wsAuth.error }), {
          status: 401,
          headers: { "Content-Type": "application/json" },
        });
      }
    }

    // ── CLI WebSocket — Claude Code CLI connects here via --sdk-url ────
    const cliMatch = url.pathname.match(/^\/ws\/cli\/([a-f0-9-]+)$/);
    if (cliMatch) {
      const sessionId = cliMatch[1];
      // On reconnect the CLI sends the UUID of its last message so the server
      // can replay any messages sent after that point (§10 of the protocol).
      const lastRequestId = req.headers.get("x-last-request-id") ?? undefined;
      const upgraded = server.upgrade(req, {
        data: { kind: "cli" as const, sessionId, lastRequestId },
      });
      if (upgraded) return undefined;
      return new Response("WebSocket upgrade failed", { status: 400 });
    }

    // ── Browser WebSocket — connects to a specific session ─────────────
    const browserMatch = url.pathname.match(/^\/ws\/browser\/([a-f0-9-]+)$/);
    if (browserMatch) {
      const sessionId = browserMatch[1];
      const upgraded = server.upgrade(req, {
        data: { kind: "browser" as const, sessionId },
      });
      if (upgraded) return undefined;
      return new Response("WebSocket upgrade failed", { status: 400 });
    }

    // ── Terminal WebSocket — embedded terminal PTY connection ─────────
    const termMatch = url.pathname.match(/^\/ws\/terminal\/([a-f0-9-]+)$/);
    if (termMatch) {
      const terminalId = termMatch[1];
      const upgraded = server.upgrade(req, {
        data: { kind: "terminal" as const, terminalId },
      });
      if (upgraded) return undefined;
      return new Response("WebSocket upgrade failed", { status: 400 });
    }

    // ── Hybrid transport POST — CLI sends events via HTTP POST ────────
    // When CLAUDE_CODE_POST_FOR_SESSION_INGRESS_V2 is set, the CLI sends
    // messages via POST instead of WebSocket (but still receives via WS).
    // URL conversion: wss://host/ws/cli/<id> → https://host/session/cli/<id>/events
    const postMatch = url.pathname.match(/^\/session\/cli\/([a-f0-9-]+)\/events$/);
    if (postMatch && req.method === "POST") {
      // Auth gate — same token validation as API routes
      const token = extractBearerToken(req.headers.get("authorization"));
      if (!validateToken(token)) {
        return new Response(JSON.stringify({ error: "Authentication required. Provide Authorization: Bearer <token> header." }), {
          status: 401,
          headers: { "Content-Type": "application/json" },
        });
      }

      // Content-Type validation
      const contentType = req.headers.get("content-type");
      if (!contentType || !contentType.includes("application/json")) {
        return new Response(JSON.stringify({ error: "Content-Type must be application/json" }), {
          status: 400,
          headers: { "Content-Type": "application/json" },
        });
      }

      // Parse body
      let body: unknown;
      try {
        body = await req.json();
      } catch {
        return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
          status: 400,
          headers: { "Content-Type": "application/json" },
        });
      }

      // Validate structure: { events: [...] }
      if (!body || typeof body !== "object" || !Array.isArray((body as Record<string, unknown>).events)) {
        return new Response(JSON.stringify({ error: "Body must contain an 'events' array" }), {
          status: 400,
          headers: { "Content-Type": "application/json" },
        });
      }

      const sessionId = postMatch[1];
      const events = (body as { events: unknown[] }).events;
      const result = wsBridge.processCLIEvents(sessionId, events);

      if (!result.found) {
        return new Response(JSON.stringify({ error: "Session not found" }), {
          status: 404,
          headers: { "Content-Type": "application/json" },
        });
      }

      return new Response(JSON.stringify({ ok: true, processed: result.processed }), {
        status: 200,
        headers: { "Content-Type": "application/json" },
      });
    }

    // Hono handles the rest
    return app.fetch(req, server);
  },
  websocket: {
    open(ws: ServerWebSocket<SocketData>) {
      const data = ws.data;
      if (data.kind === "cli") {
        wsBridge.handleCLIOpen(ws, data.sessionId);
        launcher.markConnected(data.sessionId);
      } else if (data.kind === "browser") {
        wsBridge.handleBrowserOpen(ws, data.sessionId);
      } else if (data.kind === "terminal") {
        terminalManager.addBrowserSocket(ws);
      }
    },
    message(ws: ServerWebSocket<SocketData>, msg: string | Buffer) {
      const data = ws.data;
      if (data.kind === "cli") {
        wsBridge.handleCLIMessage(ws, msg);
      } else if (data.kind === "browser") {
        wsBridge.handleBrowserMessage(ws, msg);
      } else if (data.kind === "terminal") {
        terminalManager.handleBrowserMessage(ws, msg);
      }
    },
    close(ws: ServerWebSocket<SocketData>) {
      const data = ws.data;
      if (data.kind === "cli") {
        wsBridge.handleCLIClose(ws);
      } else if (data.kind === "browser") {
        wsBridge.handleBrowserClose(ws);
      } else if (data.kind === "terminal") {
        terminalManager.removeBrowserSocket(ws);
      }
    },
  },
});
} catch (err: unknown) {
  if (err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "EADDRINUSE") {
    log.error({ port }, "Port is already in use");
    try {
      const isWindows = process.platform === "win32";
      const cmd = isWindows
        ? `netstat -ano | findstr :${port}`
        : `lsof -ti:${port}`;
      const result = execSync(cmd, { encoding: "utf-8" }).trim();
      if (result) {
        if (isWindows) {
          log.error({ port, processes: result }, "Processes using port");
        } else {
          const pids = result.split("\n").join(", ");
          log.error({ port, pids }, "Processes using port");
          log.error({ killCmd: `kill ${result.split("\n").join(" ")}` }, "Kill command");
        }
      }
    } catch (err) {
      log.debug({ err }, "lsof/netstat not available or no results");
    }
    log.error({ suggestion: `PORT=${port + 1} zencode` }, "Start on a different port");
    process.exit(1);
  }
  throw err;
}

log.info({ url: `http://${server.hostname}:${server.port}` }, "Server running");
log.info({ wsCliUrl: `ws://${server.hostname}:${server.port}/ws/cli/:sessionId` }, "CLI WebSocket");
log.info({ wsBrowserUrl: `ws://${server.hostname}:${server.port}/ws/browser/:sessionId` }, "Browser WebSocket");

if (!config.isProduction) {
  log.info("Dev mode: frontend at http://localhost:5174");
}

// ── Auto-open browser ───────────────────────────────────────────────────────
if (!process.argv.includes("--no-open") && !process.env.ZENCODE_NO_OPEN) {
  const token = getAuthToken();
  const host = server.hostname === "0.0.0.0" ? "localhost" : server.hostname;
  const baseUrl = `http://${host}:${server.port}`;
  const openUrl = token ? `${baseUrl}?token=${token}` : baseUrl;

  const platform = process.platform;
  const openCmd =
    platform === "darwin"
      ? `open "${openUrl}"`
      : platform === "win32"
        ? `cmd /c start "" "${openUrl}"`
        : `xdg-open "${openUrl}"`;

  exec(openCmd, (err) => {
    if (err) {
      log.debug({ err }, "Failed to auto-open browser (non-fatal)");
    }
  });
}

// ── Cron scheduler ──────────────────────────────────────────────────────────
cronScheduler.startAll();

// ── Agent system ────────────────────────────────────────────────────────────
await migrateCronJobsToAgents();
agentExecutor.startAll();

// ── Image pull manager — pre-pull missing Docker images for environments ────
await imagePullManager.initFromEnvironments();

// ── Eagerly detect gh CLI availability (non-blocking) ───────────────────────
initGhAvailability().catch((err) => {
  log.warn({ err }, "Failed to detect GitHub CLI availability");
});

// ── Update checker ──────────────────────────────────────────────────────────
startPeriodicCheck();
if (isRunningAsService()) {
  setServiceMode(true);
  log.info("Running as background service (auto-update available)");
}

// ── Graceful shutdown — clean up all resources ──────────────────────────────
const gracefulShutdown = new GracefulShutdown();

// 1. Stop accepting new connections
gracefulShutdown.register("server.stop", () => server.stop());

// 2. Stop scheduled tasks
gracefulShutdown.register("cronScheduler.destroy", () => cronScheduler.destroy());
gracefulShutdown.register("agentExecutor.destroy", () => agentExecutor.destroy());

// 3. Stop pollers & checkers
gracefulShutdown.register("prPoller.destroy", () => prPoller.destroy());
gracefulShutdown.register("stopPeriodicCheck", () => stopPeriodicCheck());

// 4. Kill CLI processes
gracefulShutdown.register("launcher.killAll", () => launcher.killAll());

// 5. Close all WebSocket sessions (CLI + browser sockets)
gracefulShutdown.register("wsBridge.closeAllSessions", () => wsBridge.closeAllSessions());

// 6. Kill terminal sessions
gracefulShutdown.register("terminalManager.killAll", () => terminalManager.killAll());

// 7. Flush pending session writes
gracefulShutdown.register("sessionStore.flushAll", () => sessionStore.flushAll());

// 8. Persist state
gracefulShutdown.register("containerManager.persistState", () => {
  if (dockerAvailable) {
    containerManager.persistState(CONTAINER_STATE_PATH);
  }
});

// 9. Stop rate limiter cleanup timer
gracefulShutdown.register("rateLimiter.stopCleanup", () => rateLimiter.stopCleanup());

gracefulShutdown.installSignalHandlers();

// ── Reconnection watchdog ────────────────────────────────────────────────────
// After a server restart, restored CLI processes may not reconnect their
// WebSocket. Give them a grace period, then kill + relaunch any that are
// still in "starting" state (alive but no WS connection).
const RECONNECT_GRACE_MS = config.reconnectGraceMs;
const starting = launcher.getStartingSessions();
if (starting.length > 0) {
  log.info({ gracePeriodSec: RECONNECT_GRACE_MS / 1000, count: starting.length }, "Waiting for CLI processes to reconnect");
  setTimeout(async () => {
    const stale = launcher.getStartingSessions();
    for (const info of stale) {
      if (info.archived) continue;
      log.info({ sessionId: info.sessionId }, "CLI did not reconnect, relaunching");
      await launcher.relaunch(info.sessionId);
    }
  }, RECONNECT_GRACE_MS);
}
