// ---------------------------------------------------------------------------
// eval-server.ts -- HTTP server for the eval UI
//
// DO NOT REORDER the `./boot-preflight.js` import below — it MUST be the
// first import in this file so `mergeStoredKeysIntoEnv()` runs before any
// provider module (Anthropic / OpenAI / OpenRouter SDK) captures
// `process.env.*_API_KEY` at module-load time. Moving it lower (or guarding
// it) silently breaks stored-key-only boots. See 0702 T-020/T-021.
// ---------------------------------------------------------------------------

// Side-effect import: populates process.env.*_API_KEY from the file-backed
// settings store BEFORE any downstream module reads those env vars. Must
// remain the first import — do not reorder.
import "./boot-preflight.js";

import * as http from "node:http";
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { Router } from "./router.js";
import { sendJson, getStudioToken } from "./router.js";
import { registerRoutes } from "./api-routes.js";
import { proxyToPlatform, shouldProxyToPlatform } from "./platform-proxy.js";
import { registerImproveRoutes } from "./improve-routes.js";
import { registerModelCompareRoutes } from "./model-compare-routes.js";
import { registerSkillCreateRoutes } from "./skill-create-routes.js";
import { registerSweepRoutes } from "./sweep-routes.js";
import { registerIntegrationRoutes } from "./integration-routes.js";
import { registerScopeTransferRoutes } from "../studio/routes/index.js";
import { registerWorkspaceRoutes } from "./workspace-routes.js";
import { loadWorkspace, addProject, setActiveProject, projectIdFromPath } from "./workspace-store.js";
import { createActiveRootStore, resolveActiveRoot } from "./active-root-store.js";
import { scanSkills } from "../eval/skill-scanner.js";
import { registerAuthoringRoutes } from "./authoring-routes.js";
import { registerPluginCliRoutes } from "./plugin-cli-routes.js";
import { registerGitRoutes } from "./git-routes.js";
import { registerDesktopOpenRoutes } from "./desktop-open-routes.js";
import { registerOauthGithubRoutes } from "./oauth-github-routes.js";
import { handleActiveTenant } from "./active-tenant-routes.js";
import { checkDistFreshness } from "./check-dist-freshness.js";
import { homedir } from "node:os";

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

const MIME_TYPES: Record<string, string> = {
  ".html": "text/html",
  ".js": "application/javascript",
  ".css": "text/css",
  ".json": "application/json",
  ".png": "image/png",
  ".svg": "image/svg+xml",
  ".ico": "image/x-icon",
  ".woff": "font/woff",
  ".woff2": "font/woff2",
  ".ttf": "font/ttf",
  ".map": "application/json",
  ".webp": "image/webp",
};

export interface EvalServerOptions {
  port: number;
  /**
   * 0698 T-013: `root` is now optional. When omitted, the active project is
   * derived from `~/.vskill/workspace.json`. When provided (e.g. via CLI
   * `--root`), it continues to work as before — and is auto-seeded into the
   * workspace as the active project if no workspace exists yet.
   */
  root?: string;
  projectName?: string;
  /** Override the workspace config directory (tests). Defaults to ~/.vskill. */
  workspaceDir?: string;
}

export async function startEvalServer(opts: EvalServerOptions): Promise<http.Server> {
  const router = new Router();
  const { port } = opts;
  const workspaceDir =
    opts.workspaceDir ??
    process.env.VSKILL_WORKSPACE_DIR ??
    path.join(homedir(), ".vskill");

  // 0698 T-013: resolve root from (in order): explicit opts.root (CLI --root),
  // or the active project in workspace.json. Legacy code paths that require a
  // non-empty root still get one — when there's no workspace and no --root,
  // we fall back to cwd so the server can boot with a minimal empty state.
  const root = opts.root ?? resolveActiveRoot(workspaceDir) ?? process.cwd();

  // 0863: the scan root is no longer frozen into route closures. Route handlers
  // read it per-request via `rootStore.getRoot()`, and POST /api/workspace/active
  // calls `rootStore.setRoot(path)` to re-root the running server on a project
  // switch — no process restart, no port change, no studio-token re-mint.
  const rootStore = createActiveRootStore(root);
  const getRoot = (): string => rootStore.getRoot();
  // Best-effort per-project skill count for the switch response (skeleton + toast).
  const countSkills = async (r: string): Promise<number> => (await scanSkills(r)).length;

  // Register workspace endpoints FIRST so legacy routes can consult the store.
  // rootStore + countSkills let POST /api/workspace/active re-root in place.
  registerWorkspaceRoutes(router, { workspaceDir, rootStore, countSkills });

  // If --root was passed but workspace is empty, seed it with that project
  // so CLI parity holds (user sees the same sidebar with multi-project UI).
  if (opts.root) {
    seedWorkspaceFromRoot(workspaceDir, opts.root);
  }

  // Register API routes. Each registration receives the `getRoot` accessor
  // instead of a frozen root string, so a runtime switch re-targets them all.
  registerRoutes(router, getRoot, opts.projectName);
  registerImproveRoutes(router, getRoot);
  registerModelCompareRoutes(router, getRoot);
  registerSkillCreateRoutes(router, getRoot);
  registerSweepRoutes(router, getRoot);
  registerIntegrationRoutes(router, getRoot);
  registerScopeTransferRoutes(router, getRoot);
  registerAuthoringRoutes(router, getRoot);
  registerPluginCliRoutes(router, getRoot);
  registerGitRoutes(router, getRoot);
  registerDesktopOpenRoutes(router);
  // 0843 followup (2026-05-11): GitHub OAuth Authorization Code + PKCE flow
  // (replaces the Tauri device-flow IPC path, which was double-broken by
  // (a) Tauri 2.x ACL rejecting all custom commands at runtime and
  // (b) the vskill OAuth App having Device Flow disabled in GitHub settings).
  registerOauthGithubRoutes(router);

  // Static asset directory
  const staticDir = path.resolve(__dirname, "../eval-ui");

  const server = http.createServer(async (req, res) => {
    // Handle CORS preflight. Router exposes an optional `options` handler
    // declared on the class — type-checked, no `as any` cast needed.
    if (req.method === "OPTIONS") {
      const apiRoutes = req.url?.startsWith("/api/");
      if (apiRoutes && router.options) {
        router.options(req, res);
        return;
      }
      res.writeHead(204);
      res.end();
      return;
    }

    // Try API routes first
    const handled = await router.handle(req, res);
    if (handled) return;

    // 0839 T-011 / US-004 — `/__internal/active-tenant` is a loopback-only
    // GET/POST helper used by the Studio TenantPicker. Lives outside the
    // `/api/*` namespace by design (the studio-token gate only covers
    // `/api/*`; this path predates that gate and is guarded by the
    // loopback bind + an explicit remoteAddress check inside the handler).
    if ((req.url ?? "").startsWith("/__internal/active-tenant")) {
      const taken = await handleActiveTenant(req, res);
      if (taken) return;
    }

    // 0712 US-003 T-016B (default re-targeted in 0725): forward platform-owned
    // API surface (`/api/v1/skills/*` — check-updates, stream, etc.) to the
    // vskill-platform process. The studio frontend issues these as relative
    // URLs; in production they resolve via same-origin to the platform, but in
    // dev the studio is served by this eval-server (port-hashed, e.g. 3162)
    // and would otherwise 404. Target is `VSKILL_PLATFORM_URL` (default
    // https://verified-skill.com — production worker; mirrors api/client.ts
    // DEFAULT_BASE_URL). Local-platform devs override via
    // `VSKILL_PLATFORM_URL=http://localhost:3017`.
    if (shouldProxyToPlatform(req.url)) {
      await proxyToPlatform(req, res);
      return;
    }

    // 404 for unknown API routes
    if (req.url?.startsWith("/api/")) {
      sendJson(res, { error: "Not found" }, 404, req);
      return;
    }

    // Serve static files
    await serveStatic(req, res, staticDir);
  });

  // POST /api/shutdown — graceful self-stop, used by `vskill studio --force`
  // to free the port before restarting in-place. Replies before closing so
  // the caller sees a clean 200 instead of a connection reset. Registered
  // here (after `server` is constructed) so the handler can close it.
  // Loopback-only: the same CSRF gate the install/state routes use, since
  // any same-origin tab on a developer box could otherwise terminate studio.
  router.post("/api/shutdown", async (req, res) => {
    const addr = req.socket.remoteAddress ?? "";
    if (addr !== "127.0.0.1" && addr !== "::1" && addr !== "::ffff:127.0.0.1") {
      sendJson(res, { ok: false, error: "loopback-only" }, 403, req);
      return;
    }
    sendJson(res, { ok: true, shuttingDown: true });
    setTimeout(() => {
      try {
        server.close(() => process.exit(0));
      } catch {
        process.exit(0);
      }
      // Hard fallback if close() never fires (active websocket, hung handler).
      setTimeout(() => process.exit(0), 1000).unref();
    }, 50);
  });

  return new Promise((resolve) => {
    // 0836 US-001: bind explicit loopback so the kernel rejects non-loopback
    // connects (LAN, dual-stack `::`). The default Node behaviour (no host
    // arg) binds dual-stack including LAN-reachable interfaces — anyone on
    // the same Wi-Fi could reach the GitHub-token-injecting proxy.
    server.listen(port, "127.0.0.1", () => {
      // 0728: Warn when running compiled bundle predates source edits.
      // pkgRoot is two levels up from __dirname (dist/eval-server/ or src/eval-server/).
      const pkgRoot = path.resolve(__dirname, "..", "..");
      const freshness = checkDistFreshness(pkgRoot);
      if (freshness.stale) {
        console.log(
          `\n  \x1b[33m⚠️  Stale dist detected (${freshness.details}) — run \`npm run build\` and restart\x1b[0m`,
        );
      }
      // Read the bound port from server.address() in case `port: 0` was
      // passed (the OS assigns a free port). The banner string MUST stay
      // visually consistent with prior versions for the bridge port-detector.
      const bound = server.address();
      const boundPort =
        bound && typeof bound === "object" ? bound.port : port;
      console.log(`\n  Skill Studio: http://localhost:${boundPort}\n`);
      // 0836 US-002 AC-US2-01/07: emit the Studio token banner so:
      //   - CLI users (`npx vskill studio`) can copy it for curl
      //   - The Tauri sidecar (sidecar.rs stdout parser) can capture it
      //     and stash on SharedSidecar.studio_token for the WebView IPC.
      // Token is per-process and lives only in eval-server memory.
      const token = getStudioToken();
      console.log(`  Studio token: ${token}\n`);
      resolve(server);
    });
  });
}

// ---------------------------------------------------------------------------
// 0698 T-013: workspace-aware root resolution + CLI --root seed.
// 0863: `resolveActiveRoot` now lives in active-root-store.ts (shared with the
// runtime root store) and is imported above — no local duplicate.
// ---------------------------------------------------------------------------

function seedWorkspaceFromRoot(workspaceDir: string, root: string): void {
  try {
    let ws = loadWorkspace(workspaceDir);
    // When `vskill studio` is invoked with an explicit `--root` (or the
    // CLI-default of cwd), that project should become the active one even if
    // the workspace already has other registered projects. Otherwise the user
    // sees a stale "fixtures" pill top-left just because an earlier session
    // pinned it — confusing and wrong.
    const existing = ws.projects.find((p) => p.path === root);
    if (existing) {
      if (ws.activeProjectId !== existing.id) {
        setActiveProject(workspaceDir, ws, existing.id);
      }
      return;
    }
    ws = addProject(workspaceDir, ws, { path: root });
    // addProject auto-activates the first add, but when there are already
    // other projects it leaves the existing active alone. Force-activate.
    const id = projectIdFromPath(root);
    if (ws.activeProjectId !== id) {
      setActiveProject(workspaceDir, ws, id);
    }
  } catch {
    /* non-fatal — workspace is optional */
  }
}

async function serveStatic(
  req: http.IncomingMessage,
  res: http.ServerResponse,
  staticDir: string,
): Promise<void> {
  let urlPath = new URL(req.url || "/", "http://localhost").pathname;
  if (urlPath === "/") urlPath = "/index.html";

  // Security: prevent path traversal
  const safePath = path.normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
  const filePath = path.join(staticDir, safePath);

  // Ensure we stay within staticDir
  if (!filePath.startsWith(staticDir)) {
    res.writeHead(403);
    res.end("Forbidden");
    return;
  }

  try {
    const stat = fs.statSync(filePath);
    if (stat.isFile()) {
      const ext = path.extname(filePath);
      const contentType = MIME_TYPES[ext] || "application/octet-stream";
      // 0836 US-002 follow-up: HTML files need the per-process X-Studio-Token
      // injected so the browser tab (npx vskill studio) can authenticate
      // /api/* calls. Streaming the raw file would skip this — read + inject.
      // Non-HTML assets (JS/CSS/images) stream as before.
      if (ext === ".html") {
        const raw = fs.readFileSync(filePath, "utf-8");
        res.writeHead(200, { "Content-Type": contentType });
        res.end(injectStudioToken(raw));
        return;
      }
      res.writeHead(200, { "Content-Type": contentType });
      fs.createReadStream(filePath).pipe(res);
      return;
    }
  } catch {
    // File not found - SPA fallback
  }

  // SPA fallback: serve index.html for all unmatched routes
  const indexPath = path.join(staticDir, "index.html");
  try {
    const content = fs.readFileSync(indexPath, "utf-8");
    res.writeHead(200, { "Content-Type": "text/html" });
    res.end(injectStudioToken(content));
  } catch {
    res.writeHead(404);
    res.end("Not found — run 'npm run build:eval-ui' first");
  }
}

/**
 * 0836 US-002 follow-up: inject the per-process X-Studio-Token into the
 * served index.html so browser-mode (`npx vskill studio` → user's default
 * browser) can authenticate API calls. Previously only Tauri's IPC path
 * delivered the token; the browser tab had no way to obtain it and every
 * /api/* call returned 401.
 *
 * Safe against DNS rebinding because the only consumer of this HTML is
 * the same-origin loopback page. An attacker.com tab cannot read this
 * HTML cross-origin (browser-enforced).
 *
 * Token resolution order on the client (StudioTokenBridge.ts):
 *   1. window.__VSKILL_STUDIO_TOKEN__  ← injected here (works in both modes)
 *   2. Tauri IPC get_studio_token       ← desktop-only fallback
 */
function injectStudioToken(html: string): string {
  const token = getStudioToken();
  // Use the marker pattern Tauri's CSP allows. Inline JSON ensures the
  // value never appears in a script src attribute and isn't interpreted
  // as a string literal that could break on quotes.
  const marker = `<script id="__vskill_studio_token__" type="application/json">${
    JSON.stringify({ token })
  }</script>`;
  // Inject just before </head>; fall back to prepending <body> if head not found.
  if (html.includes("</head>")) {
    return html.replace("</head>", `${marker}\n</head>`);
  }
  if (html.includes("<body")) {
    return html.replace(/<body([^>]*)>/, `<body$1>\n${marker}`);
  }
  // No <head> or <body>: prepend (shouldn't happen for a real SPA shell).
  return `${marker}\n${html}`;
}
