/**
 * Production server for @executor-js/local.
 *
 * Serves the Vite-built SPA + Effect API + MCP server.
 *
 * Run directly:   bun run apps/local/src/serve.ts
 * Or import:      import { startServer } from "@executor-js/local/serve"
 */

import { resolve, join } from "node:path";
import { readdirSync } from "node:fs";
import type { Subprocess } from "bun";
import { setOAuthCompletionListener } from "@executor-js/api";
import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/server";
import { loadOrMintLocalAuthToken } from "./auth";
import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store";
import { startIntegrationsRefresh } from "./integrations";
import { disposeServerHandlers, getServerHandlers } from "./main";
import {
  DEFAULT_ALLOWED_HOSTS,
  hasFileExtension,
  isAllowedOrigin,
  isUnauthenticatedOAuthClientMetadataPath,
  isUnauthenticatedOAuthPath,
  makeIsAuthorized,
  normalizeCredential,
} from "./serve-shared";

// ---------------------------------------------------------------------------
// Static files
// ---------------------------------------------------------------------------

type StaticHandler = () => Response | Promise<Response>;

const htmlResponse = (file: Bun.BunFile): Response =>
  new Response(file, {
    headers: { "content-type": "text/html", "cache-control": "no-store" },
  });

const oauthClientMetadataResponse = (requestUrl: string, webRequest: Request): Response =>
  new Response(
    JSON.stringify(
      oauthClientIdMetadataDocumentFromRequest({
        requestUrl,
        webRequest,
        mountPrefix: "/api",
      }),
    ),
    {
      headers: {
        "content-type": "application/json",
        "cache-control": "public, max-age=300",
      },
    },
  );

function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHandler> {
  const routes: Record<string, StaticHandler> = {};
  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: filesystem route discovery is best-effort for optional built assets
  try {
    for (const entry of readdirSync(dir, { withFileTypes: true })) {
      const fullPath = join(dir, entry.name);
      const routePath = `${prefix}/${entry.name}`;
      if (entry.isDirectory()) {
        Object.assign(routes, collectStaticRoutes(fullPath, routePath));
      } else {
        const file = Bun.file(fullPath);
        routes[routePath] = () =>
          routePath === "/index.html"
            ? htmlResponse(file)
            : new Response(file, {
                headers: {
                  "content-type": file.type || "application/octet-stream",
                },
              });
      }
    }
  } catch {}
  return routes;
}

/**
 * Convert an embedded web UI map (path → bunfs path) into Bun.serve routes.
 * The embedded map is generated by the CLI build step using `with { type: "file" }`.
 */
function embeddedToStaticRoutes(embedded: Record<string, string>): Record<string, StaticHandler> {
  const routes: Record<string, StaticHandler> = {};
  for (const [key, bunfsPath] of Object.entries(embedded)) {
    const file = Bun.file(bunfsPath);
    routes[`/${key}`] = () =>
      key === "index.html"
        ? htmlResponse(file)
        : new Response(file, {
            headers: {
              "content-type": file.type || "application/octet-stream",
            },
          });
  }
  return routes;
}

// ---------------------------------------------------------------------------
// Dev mode: spawn vite as a child and proxy non-API requests to it
//
// Enabled when EXECUTOR_DEV=1. Lets `dev:cli` serve a fresh UI without
// requiring a manual `bun run build` after every change. The daemon still
// owns /api and /mcp — only SPA/asset requests get proxied.
// ---------------------------------------------------------------------------

interface ViteChild {
  readonly url: string;
  readonly stop: () => Promise<void>;
}

async function allocatePort(): Promise<number> {
  const probe = Bun.serve({
    port: 0,
    hostname: "127.0.0.1",
    fetch: () => new Response(),
  });
  const port = probe.port ?? 0;
  probe.stop(true);
  return port;
}

async function startViteChild(): Promise<ViteChild> {
  const vitePort = await allocatePort();
  const cwd = resolve(import.meta.dirname, "..");
  const env = { ...process.env };
  delete env.PORT;
  // `bunx --bun vite` runs vite under Bun, matching the `dev:vite` script
  // already in apps/local. --strictPort keeps the URL we hand back stable.
  const child: Subprocess = Bun.spawn(
    [
      "bunx",
      "--bun",
      "vite",
      "dev",
      "--port",
      String(vitePort),
      "--strictPort",
      "--host",
      "127.0.0.1",
    ],
    {
      cwd,
      // EXECUTOR_DEV_VITE_PORT — vite.config reads this and points the
      // HMR client at vite's real port. The browser loads HTML through
      // the daemon proxy but opens the HMR WebSocket directly to vite,
      // sidestepping the daemon's lack of WS proxying.
      env: {
        ...env,
        EXECUTOR_DEV_VITE_PORT: String(vitePort),
      },
      stdout: "inherit",
      stderr: "inherit",
    },
  );

  const url = `http://127.0.0.1:${vitePort}`;
  const deadline = Date.now() + 30_000;
  while (Date.now() < deadline) {
    // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: probing a child process that may not be listening yet
    try {
      const r = await fetch(`${url}/`, { redirect: "manual" });
      if (r.status < 500) {
        await r.body?.cancel();
        return {
          url,
          stop: async () => {
            child.kill();
            await child.exited;
          },
        };
      }
      await r.body?.cancel();
    } catch {
      // not up yet
    }
    if (child.exitCode !== null) {
      // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: child process aborted before becoming ready
      throw new Error(`vite dev exited with code ${child.exitCode} before becoming ready`);
    }
    await Bun.sleep(150);
  }
  child.kill();
  // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: vite never became reachable
  throw new Error(`vite dev did not become reachable on ${url} within 30s`);
}

async function proxyToVite(req: Request, viteUrl: string): Promise<Response> {
  const target = new URL(req.url);
  target.protocol = "http:";
  target.host = new URL(viteUrl).host;
  // Strip hop-by-hop headers that confuse the upstream
  const headers = new Headers(req.headers);
  headers.delete("host");
  headers.set("host", target.host);
  const hasBody = req.method !== "GET" && req.method !== "HEAD";
  return fetch(target, {
    method: req.method,
    headers,
    body: hasBody ? req.body : undefined,
    redirect: "manual",
    // @ts-expect-error — Bun/undici extension required for streamed bodies
    duplex: hasBody ? "half" : undefined,
  });
}

// ---------------------------------------------------------------------------
// Server
// ---------------------------------------------------------------------------

export interface StartServerOptions {
  port?: number;
  /** Directory containing built client assets (for dev/disk serving). */
  clientDir?: string;
  /** Embedded web UI map from compiled binary (path → bunfs path). Overrides clientDir. */
  embeddedWebUI?: Record<string, string> | null;
  /** Bind address. Defaults to 127.0.0.1. Use 0.0.0.0 to listen on all interfaces. */
  hostname?: string;
  /**
   * Extra origins granted credentialed CORS access, on top of localhost/
   * 127.0.0.1 (any port). There is no Host allowlist — the bearer token is the
   * security boundary — so reaching the server from another host (e.g. over a
   * tailnet) needs nothing here; this only widens cross-origin CORS.
   */
  allowedHosts?: ReadonlyArray<string>;
  /**
   * Bearer token required for every `/api` and `/mcp` request. Optional — when
   * omitted the stable token is loaded from (or minted into) `auth.json`. Pass
   * an explicit value to override (e.g. the desktop main process threads its
   * own token to the sidecar child).
   */
  authToken?: string;
  /** Test hook for supplying API/MCP handlers without loading the local server graph. */
  handlers?: ServerHandlers;
}

export interface ServerInstance {
  port: number;
  /** The effective bearer token this server validates. Callers publish it in the
   * manifest, print the `?_token=` bootstrap URL, and hand it to MCP clients. */
  authToken: string;
  stop: () => Promise<void>;
}

type ServerHandlers = Awaited<ReturnType<typeof getServerHandlers>>;

const corsHeaders = {
  "access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
  "access-control-allow-headers":
    "authorization, content-type, x-executor-token, x-requested-with, traceparent, tracestate, baggage, b3",
  "access-control-allow-credentials": "true",
  "access-control-expose-headers": "*",
} as const;

const withCorsHeaders = (
  req: Request,
  response: Response,
  allowedHosts: ReadonlySet<string>,
): Response => {
  const origin = req.headers.get("origin");
  // Same-origin requests carry no Origin header — nothing to do. Cross-origin
  // requests only get credentialed CORS if their Origin is an allowed loopback
  // host; an arbitrary web page (e.g. https://evil.example) gets no ACAO, so
  // the browser blocks it reading the response even if it knew the token.
  if (!origin || !isAllowedOrigin(origin, allowedHosts)) return response;
  const headers = new Headers(response.headers);
  headers.set("access-control-allow-origin", origin);
  for (const [key, value] of Object.entries(corsHeaders)) headers.set(key, value);
  headers.set(
    "access-control-allow-headers",
    req.headers.get("access-control-request-headers") ??
      corsHeaders["access-control-allow-headers"],
  );
  headers.append("vary", "Origin");
  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers,
  });
};

const corsPreflightResponse = (req: Request, allowedHosts: ReadonlySet<string>): Response =>
  withCorsHeaders(req, new Response(null, { status: 204 }), allowedHosts);

const closeProvidedHandlers = async (handlers: ServerHandlers): Promise<void> => {
  await handlers.mcp.close();
  await handlers.api.dispose();
};

const ignoreCleanupFailure = async (cleanup: () => Promise<unknown>): Promise<void> => {
  // oxlint-disable-next-line executor/no-promise-catch -- boundary: startup cleanup is best-effort and must not mask the original startup failure
  await cleanup().catch(() => {});
};

export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> {
  const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10);
  const hostname = opts.hostname ?? "127.0.0.1";
  // ONE credential, always present: an explicit override or the stable token
  // from auth.json (minted on first run). Auth is unconditionally on — loopback
  // is no longer a free pass, since Executor runs arbitrary code that any local
  // process could otherwise drive.
  const authToken = normalizeCredential(opts.authToken) ?? loadOrMintLocalAuthToken();
  const isAuthorized = makeIsAuthorized(authToken);
  // CORS-only origin allowlist (no Host gate — the bearer is the boundary).
  const corsAllowedHosts = new Set<string>([
    ...DEFAULT_ALLOWED_HOSTS,
    ...(opts.allowedHosts ?? []),
  ]);
  const clientDir = opts.clientDir ?? resolve(import.meta.dirname, "../dist");

  startIntegrationsRefresh();

  const ownsHandlers = opts.handlers === undefined;
  const handlers = opts.handlers ?? (await getServerHandlers(authToken));
  let viteChild: ViteChild | null = null;

  const disposeOwnedResources = async (): Promise<void> => {
    setOAuthCompletionListener(null);
    if (ownsHandlers) {
      await disposeServerHandlers();
    } else {
      await closeProvidedHandlers(handlers);
    }
    if (viteChild) await viteChild.stop();
  };

  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: after handlers boot, failed static/dev/Bun startup must release DB ownership before surfacing the startup error
  try {
    // Mirror every OAuth callback completion into the local in-memory result
    // store. The Electron desktop renderer polls /api/oauth/await/:sessionId
    // for these when the user runs the flow in their system browser (no
    // shared origin → no postMessage). Cloud doesn't register a listener;
    // its same-origin web SPA receives results via postMessage directly.
    setOAuthCompletionListener((result) => publishOAuthResult(result));

    // Build static routes from either embedded assets, disk, or a spawned
    // vite dev child (EXECUTOR_DEV=1). Vite mode takes precedence and
    // disables the file-extension 404 short-circuit since vite serves
    // hashed asset paths directly.
    let staticRoutes: Record<string, StaticHandler> = {};
    let serveIndex: StaticHandler;

    const devMode = process.env.EXECUTOR_DEV === "1" && !opts.embeddedWebUI;
    if (devMode) {
      console.log("[executor] EXECUTOR_DEV=1 — spawning vite dev child for live UI");
      viteChild = await startViteChild();
      // Diagnostic only — this is the internal vite port the daemon proxies to.
      // It must NOT read as a destination: the URL to open is the `Open:` line the
      // CLI prints (the daemon port, with ?_token). Hitting the vite port directly
      // skips that bootstrap and lands on the auth gate.
      console.log(`[executor] (internal) vite dev child at ${viteChild.url} — don't open this`);
      serveIndex = () =>
        // Unused when viteChild is non-null; defined so the type checker
        // can keep `serveIndex` non-nullable.
        new Response("vite not ready", { status: 503 });
    } else if (opts.embeddedWebUI) {
      staticRoutes = embeddedToStaticRoutes(opts.embeddedWebUI);
      const indexFile = Bun.file(opts.embeddedWebUI["index.html"] ?? join(clientDir, "index.html"));
      serveIndex = () => htmlResponse(indexFile);
    } else {
      staticRoutes = collectStaticRoutes(clientDir);
      const indexFile = Bun.file(join(clientDir, "index.html"));
      serveIndex = () => htmlResponse(indexFile);
    }

    const server = Bun.serve({
      port,
      hostname,
      // Disable Bun's default 10s idle timeout. MCP elicitation and pause/resume
      // can idle longer during human approval; `0` disables the socket timeout.
      idleTimeout: 0,
      routes: { ...staticRoutes },
      async fetch(req) {
        const withCors = (response: Response): Response =>
          withCorsHeaders(req, response, corsAllowedHosts);

        if (req.method === "OPTIONS" && req.headers.has("origin")) {
          return corsPreflightResponse(req, corsAllowedHosts);
        }

        const url = new URL(req.url);

        // Unauthenticated liveness probe — carries no data, used by the CLI
        // reachability check (which therefore never forwards a credential).
        if (url.pathname === "/api/health" && req.method === "GET") {
          return withCors(new Response("ok", { headers: { "content-type": "text/plain" } }));
        }

        // OAuth callbacks and CIMD documents are reached by the external
        // provider, which cannot carry our local bearer. Everything else under
        // /api and /mcp requires the bearer.
        const skipAuth = isUnauthenticatedOAuthPath(url.pathname);
        const isMcpPath = url.pathname === "/mcp" || url.pathname.startsWith("/mcp/");
        const isGatedSurface = url.pathname.startsWith("/api") || isMcpPath;

        if (isGatedSurface && !skipAuth && !isAuthorized(req)) {
          return withCors(
            new Response("Unauthorized", {
              status: 401,
              headers: { "www-authenticate": 'Bearer realm="executor"' },
            }),
          );
        }

        if (isUnauthenticatedOAuthClientMetadataPath(url.pathname) && req.method === "GET") {
          return withCors(oauthClientMetadataResponse(`${url.pathname}${url.search}`, req));
        }

        if (isMcpPath) {
          return withCors(await handlers.mcp.handleRequest(req));
        }

        if (url.pathname.startsWith("/api/mcp-sessions/")) {
          // GET → paused-execution detail for the approval page; POST → record the
          // decision. Both are bearer-gated above.
          const handler =
            req.method === "GET"
              ? handlers.mcp.handlePausedRequest
              : handlers.mcp.handleApprovalRequest;
          return withCors(await handler(req));
        }

        // OAuth result polling — local-only, served outside the typed API
        // because cloud (Cloudflare Workers, stateless) can't back the
        // in-memory store. See setOAuthCompletionListener above.
        const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(url.pathname);
        if (awaitMatch && req.method === "GET") {
          const result = consumeOAuthResult(awaitMatch[1]);
          return withCors(
            new Response(JSON.stringify(result), {
              headers: { "content-type": "application/json" },
            }),
          );
        }

        if (url.pathname.startsWith("/api/") || url.pathname === "/api") {
          url.pathname = url.pathname.slice("/api".length) || "/";
          return withCors(await handlers.api.handler(new Request(url, req)));
        }

        // App-level routes the Effect app serves at root, OUTSIDE the `/api`
        // prefix the shell strips (e.g. `/v1/app/npm/dist-tags`, which the web
        // shell's update check fetches). Public, like `/api/health` above, so no
        // bearer gate. Forward verbatim; without this they'd fall through to the
        // vite/SPA fallback and answer 200-with-HTML, breaking the JSON parse.
        if (url.pathname === "/v1" || url.pathname.startsWith("/v1/")) {
          return withCors(await handlers.api.handler(new Request(url, req)));
        }

        // Dev mode: forward everything else (SPA + hashed assets) to the
        // vite child so source edits show up without a rebuild.
        if (viteChild) {
          return withCors(await proxyToVite(req, viteChild.url));
        }

        // If a path looks like a static asset (has a file extension), do not
        // fall back to SPA HTML. Returning index.html here causes browser module
        // MIME errors when hashed chunks are stale/missing.
        if (hasFileExtension(url.pathname)) {
          return withCors(new Response("Not Found", { status: 404 }));
        }

        // SPA fallback (unauthenticated — the browser loads the shell, then reads
        // its token and sends the bearer on subsequent /api calls).
        return withCors(await serveIndex());
      },
      error(error) {
        console.error("Server error:", error);
        return new Response("Internal Server Error", { status: 500 });
      },
    });

    let stopped = false;
    return {
      port: server.port!,
      authToken,
      async stop() {
        if (stopped) return;
        stopped = true;
        server.stop(true);
        await disposeOwnedResources();
      },
    };
  } catch (cause) {
    await ignoreCleanupFailure(disposeOwnedResources);
    // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: async server start must reject with the original startup failure after cleanup
    throw cause;
  }
}

if (import.meta.main) {
  const server = await startServer();
  console.log(`Executor listening on http://localhost:${server.port}/?_token=${server.authToken}`);
}
