import type { IncomingMessage, ServerResponse } from "node:http";
import { getActiveTaskCount } from "../be/db";
import type { SwarmSpan } from "../otel";
import { isEnvFlagEnabled } from "../utils/env-flag";
import { scrubSecrets } from "../utils/secret-scrubber";

const DEFAULT_ALLOWED_ORIGINS = [
  "https://*.agent-swarm.dev",
  "https://*.agent-swarm.cloud",
  "http://localhost:5274",
  "http://127.0.0.1:5274",
  "http://[::1]:5274",
  "https://ui.swarm.localhost:1355",
];

let warnedAboutAllowAnyOrigin = false;

/** Warn at boot (after config injection), or on first use of the deployment opt-out. */
export function warnIfCorsAllowsAnyOrigin(): void {
  if (isEnvFlagEnabled("CORS_ALLOW_ANY_ORIGIN", false) && !warnedAboutAllowAnyOrigin) {
    warnedAboutAllowAnyOrigin = true;
    console.warn(
      "[CORS] CORS_ALLOW_ANY_ORIGIN=true: any request origin can receive non-credentialed responses; cookie-authenticated responses still require CORS_ALLOWED_ORIGINS. Set CORS_ALLOWED_ORIGINS and disable CORS_ALLOW_ANY_ORIGIN to restrict access.",
    );
  }
}

/** Re-read env on each request so Configuration reloads take effect immediately. */
function getAllowedOrigins(): string[] {
  const raw = process.env.CORS_ALLOWED_ORIGINS;
  if (!raw || !raw.trim()) return DEFAULT_ALLOWED_ORIGINS;
  return raw
    .split(",")
    .map((entry) => entry.trim())
    .filter(Boolean);
}

/**
 * Decide whether `origin` may receive credentialed CORS headers.
 * Unset or blank uses the built-in hosted/dev allowlist. Exact entries retain
 * their case-sensitive string comparison. A single leading `*.` matches one or
 * more complete hostname labels, case-insensitively, but never the apex (list
 * it separately). Wildcard schemes and explicit ports must match exactly.
 * Bare `*`, `https://*`, other wildcard positions, and non-origin URLs are ignored.
 */
export function isOriginAllowedForCredentials(origin: string): boolean {
  const allowed = getAllowedOrigins().some((entry) => {
    if (!entry.includes("*")) return entry === origin;

    // Fixed parsers, never a regex interpolated from operator-controlled text.
    const pattern = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/\*\.([a-zA-Z0-9.-]+)(:\d+)?$/.exec(entry);
    const candidate = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([a-zA-Z0-9.-]+)(:\d+)?$/.exec(origin);
    if (!pattern || !candidate) return false;
    if (pattern[1] !== candidate[1] || pattern[3] !== candidate[3]) return false;

    const suffix = pattern[2]!.toLowerCase();
    const hostname = candidate[2]!.toLowerCase();
    // Reject empty or malformed labels instead of accepting suffix lookalikes.
    const validLabel = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
    if (![...suffix.split("."), ...hostname.split(".")].every((label) => validLabel.test(label))) {
      return false;
    }
    return hostname.endsWith(`.${suffix}`);
  });
  if (!allowed) {
    console.warn(
      `[CORS] Denied credentialed origin ${scrubSecrets(JSON.stringify(origin.slice(0, 512)))}; add the trusted SPA origin to CORS_ALLOWED_ORIGINS (custom values replace hosted/dev defaults).`,
    );
  }
  return allowed;
}

export function setCorsHeaders(req: IncomingMessage, res: ServerResponse) {
  // Echo the request Origin (rather than emitting `*`) so credentialed fetches
  // — e.g. the SPA's `credentials: 'include'` calls to `/p/:id.json` and the
  // page-session cookie endpoints — pass the browser's CORS check. A wildcard
  // would force the browser to reject any credentialed cross-origin response.
  //
  // Only allowlisted origins receive a credentialed CORS grant.
  const rawOrigin = req.headers.origin;
  const origin = Array.isArray(rawOrigin) ? rawOrigin[0] : rawOrigin;
  const allowCredentials = origin ? isOriginAllowedForCredentials(origin) : false;
  const allowAnyOrigin = isEnvFlagEnabled("CORS_ALLOW_ANY_ORIGIN", false);
  if (allowAnyOrigin) warnIfCorsAllowsAnyOrigin();
  if (origin && !allowCredentials && !allowAnyOrigin) {
    res.setHeader("Vary", "Origin");
    return;
  }
  if (origin) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin");
    // The compatibility flag never grants access to browser-supplied cookies.
    // Bearer clients from unlisted origins must use credentials: "omit".
    if (allowCredentials) res.setHeader("Access-Control-Allow-Credentials", "true");
    // When credentials are involved the spec disallows wildcards in
    // Allow-Headers / Allow-Methods / Expose-Headers — they must be
    // explicit. Echo whatever the preflight asked for (defensive default
    // covers Authorization + the common app headers).
    const reqHeaders = req.headers["access-control-request-headers"];
    const askedHeaders = Array.isArray(reqHeaders) ? reqHeaders.join(", ") : reqHeaders;
    res.setHeader(
      "Access-Control-Allow-Headers",
      askedHeaders ?? "Authorization, Content-Type, X-Agent-ID, X-Requested-With",
    );
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
    res.setHeader("Access-Control-Expose-Headers", "Content-Type, Content-Length, ETag, Location");
  } else {
    // No Origin (curl / direct browser nav) — wildcards are fine and avoid
    // breaking non-browser callers.
    res.setHeader("Access-Control-Allow-Origin", "*");
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
    res.setHeader("Access-Control-Allow-Headers", "*");
    res.setHeader("Access-Control-Expose-Headers", "*");
  }
}

export function parseQueryParams(url: string): URLSearchParams {
  const queryIndex = url.indexOf("?");
  if (queryIndex === -1) return new URLSearchParams();
  return new URLSearchParams(url.slice(queryIndex + 1));
}

export function getPathSegments(url: string): string[] {
  const pathEnd = url.indexOf("?");
  const path = pathEnd === -1 ? url : url.slice(0, pathEnd);
  return path.split("/").filter(Boolean);
}

/**
 * `http.response.status_code` for a request span whose response connection
 * closed before completion. `statusCode` initializes to `200` and is only
 * ever updated by `writeHead` — on the premature-close path that never ran,
 * so reporting it verbatim would claim a 200 response was sent when none was.
 * Omitted (not fabricated) when `headersSent` is false.
 *
 * Node emits `close` for BOTH a completed response and a connection
 * terminated early, so this path proves the response did not complete — not
 * that the client was the actor.
 */
export function abortedStatusCodeAttribute(
  headersSent: boolean,
  statusCode: number,
): number | undefined {
  return headersSent ? statusCode : undefined;
}

/**
 * Minimal `ServerResponse` surface `wireHttpSpanLifecycle` needs — narrowed so
 * tests can pass a plain mock instead of a real Node response.
 */
export interface SpanLifecycleResponse {
  readonly headersSent: boolean;
  on(event: "finish" | "error" | "close", listener: (err?: Error) => void): unknown;
}

/**
 * Wires a request span to its response's terminal events so the span always
 * ends exactly once, regardless of which event fires first.
 *
 * `finish` and `error` are the two ways Node signals a completed response;
 * `close` additionally fires on a connection that terminates early —
 * including AFTER `finish` on the happy path, which is why every branch
 * guards on `spanEnded` before touching the span. Extracted out of
 * `src/http/index.ts` (which binds a port at module scope and so cannot be
 * imported directly in a test) so the close-before-finish ordering and the
 * end-exactly-once guarantee can be exercised with a mocked response/span.
 */
export function wireHttpSpanLifecycle(
  res: SpanLifecycleResponse,
  span: SwarmSpan,
  getStatusCode: () => number,
  startTime: number,
): void {
  let spanEnded = false;

  res.on("finish", () => {
    if (spanEnded) return;
    spanEnded = true;
    const statusCode = getStatusCode();
    span.setAttributes({
      "http.response.status_code": statusCode,
      "agentswarm.http.duration_ms": Math.round((performance.now() - startTime) * 10) / 10,
    });
    if (statusCode >= 500) {
      span.setStatus({ code: 2, message: `HTTP ${statusCode}` });
    }
    span.end();
  });

  res.on("error", (err) => {
    if (spanEnded) return;
    spanEnded = true;
    if (err) span.recordException(err);
    span.setStatus({ code: 2, message: err?.message });
    span.end();
  });

  // `close` fires after `finish` on the happy path (the `spanEnded` guard
  // already covers that), but a response connection that terminates early
  // fires neither `finish` nor `error` — without this, the span stays open
  // and is never exported. Verified against Bun 1.4.0 (pinned): `close`
  // fires on a premature close even with `headersSent === false`; Bun
  // 1.3.14 never fires it.
  res.on("close", () => {
    if (spanEnded) return;
    spanEnded = true;
    span.setAttributes({
      "http.response.status_code": abortedStatusCodeAttribute(res.headersSent, getStatusCode()),
      "agentswarm.http.duration_ms": Math.round((performance.now() - startTime) * 10) / 10,
      "agentswarm.http.aborted": true,
    });
    // Status left Unset. `close` cannot distinguish an intentional client
    // cancellation from a response-write failure, and the dominant case
    // here is the former: a normal SSE teardown on /mcp or /mcp-user.
    // Marking every premature close ERROR would put that routine traffic
    // in the service error rate. `agentswarm.http.aborted` carries the
    // signal instead. Revisit if a discriminator becomes available.
    span.end();
  });
}

export function safeRequestUrlForLog(rawUrl: string | undefined): string {
  if (!rawUrl) return "";

  try {
    const url = new URL(rawUrl, "http://localhost");
    const params = Array.from(url.searchParams.keys());
    if (params.length === 0) return url.pathname;

    const redactedQuery = params.map((key) => `${key}=[REDACTED]`).join("&");
    return `${url.pathname}?${redactedQuery}`;
  } catch {
    const pathOnly = rawUrl.split("?")[0] || rawUrl;
    return scrubSecrets(pathOnly);
  }
}

/** Add capacity info to agent response */
export async function agentWithCapacity<T extends { id: string; maxTasks?: number }>(
  agent: T,
): Promise<T & { capacity: { current: number; max: number; available: number } }> {
  const activeCount = await getActiveTaskCount(agent.id);
  const max = agent.maxTasks ?? 1;
  return {
    ...agent,
    capacity: {
      current: activeCount,
      max,
      available: Math.max(0, max - activeCount),
    },
  };
}

/**
 * Parse the JSON body of an incoming request.
 *
 * An empty (or whitespace-only) body parses as `{}` rather than throwing:
 * `JSON.parse("")` raises a SyntaxError, and the `route()` factory only
 * converts ZodErrors — so a bodyless POST used to escape as a 500. Yielding
 * `{}` lets an all-optional body schema accept a bodyless request and turns a
 * required-body schema's failure into the honest 400 its validation produces.
 */
export class RequestBodyTooLargeError extends Error {
  readonly maxBytes: number;

  constructor(maxBytes: number) {
    super(`Payload too large (max ${maxBytes} bytes)`);
    this.name = "RequestBodyTooLargeError";
    this.maxBytes = maxBytes;
  }
}

function requestContentLength(req: IncomingMessage): number | undefined {
  const raw = req.headers["content-length"];
  const value = Array.isArray(raw) ? raw[0] : raw;
  if (!value) return undefined;
  const parsed = Number(value);
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
}

/**
 * Parse a JSON request body with an optional byte cap.
 *
 * On overflow, the request keeps draining while the caller receives the
 * rejection. This preserves keep-alive connections for the next request.
 */
export async function parseBody<T = unknown>(
  req: IncomingMessage,
  maxBytes = Number.POSITIVE_INFINITY,
): Promise<T> {
  const contentLength = requestContentLength(req);
  if (contentLength !== undefined && contentLength > maxBytes) {
    req.resume();
    throw new RequestBodyTooLargeError(maxBytes);
  }

  return await new Promise<T>((resolve, reject) => {
    const chunks: Buffer[] = [];
    let totalBytes = 0;
    let settled = false;

    const cleanup = () => {
      req.off("data", onData);
      req.off("end", onEnd);
      req.off("error", onError);
      req.off("aborted", onAborted);
      req.off("close", onClose);
    };

    const rejectTooLarge = () => {
      if (settled) return;
      settled = true;
      reject(new RequestBodyTooLargeError(maxBytes));
      req.resume();
    };

    const onData = (chunk: Buffer | string) => {
      if (settled) return;
      const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
      totalBytes += buffer.byteLength;
      if (totalBytes > maxBytes) {
        rejectTooLarge();
        return;
      }
      chunks.push(buffer);
    };

    const onEnd = () => {
      if (settled) {
        cleanup();
        return;
      }
      settled = true;
      cleanup();
      const raw = Buffer.concat(chunks).toString();
      if (raw.trim() === "") {
        resolve({} as T);
        return;
      }
      try {
        resolve(JSON.parse(raw) as T);
      } catch (error) {
        reject(error);
      }
    };

    const onError = (error: Error) => {
      cleanup();
      if (!settled) {
        settled = true;
        reject(error);
      }
    };

    const onAborted = () => onError(new Error("Request body was aborted"));

    const onClose = () => {
      if (!req.readableEnded) onError(new Error("Request body closed before completion"));
    };

    req.on("data", onData);
    req.on("end", onEnd);
    req.on("error", onError);
    req.on("aborted", onAborted);
    req.on("close", onClose);
    req.resume();
  });
}

/**
 * Sentinel returned by `enforceContentLengthCap` when the request exceeds the
 * provided byte cap. The caller has already received a `413` response — it
 * should stop processing the request immediately.
 */
export const BODY_TOO_LARGE = Symbol("body-too-large");

/**
 * Reject the request with `413 Payload Too Large` when its `Content-Length`
 * header exceeds `maxBytes`. Returns `BODY_TOO_LARGE` after writing the
 * response (caller short-circuits); otherwise returns `null` and processing
 * continues.
 *
 * This is a cheap pre-flight; downstream `parseBody`/streamed parsers can be
 * a second defence if a malicious client lies about Content-Length.
 *
 * Used by `/api/pages` POST/PUT to bound the per-row body size — page bodies
 * land in SQLite as a TEXT column and there is no per-instance quota yet.
 */
export function enforceContentLengthCap(
  req: IncomingMessage,
  res: ServerResponse,
  maxBytes: number,
): typeof BODY_TOO_LARGE | null {
  const raw = req.headers["content-length"];
  const val = Array.isArray(raw) ? raw[0] : raw;
  if (!val) return null; // No header — best-effort; parseBody will still buffer.
  const n = Number(val);
  if (!Number.isFinite(n) || n < 0) return null;
  if (n > maxBytes) {
    res.writeHead(413, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ error: `Payload too large (max ${maxBytes} bytes)` }));
    return BODY_TOO_LARGE;
  }
  return null;
}

/** Send JSON response */
export function json(res: ServerResponse, data: unknown, status = 200) {
  res.writeHead(status, { "Content-Type": "application/json" });
  res.end(JSON.stringify(data));
}

/** Send error JSON response */
export function jsonError(res: ServerResponse, error: string, status = 400) {
  res.writeHead(status, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ error }));
}

/**
 * Send a 400 response for a workflow `triggerSchema` validation failure.
 * Frozen wire shape: `{ error: "TriggerSchemaError", message, details: string[] }`.
 * `details` carries the per-field validator output so callers can render
 * field-level diagnostics (FE tester, MCP, etc.).
 */
export function triggerSchemaErrorResponse(
  res: ServerResponse,
  message: string,
  details: string[],
) {
  res.writeHead(400, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ error: "TriggerSchemaError", message, details }));
}

/**
 * Derive the API base URL for outbound-facing values (webhook URLs, OAuth
 * redirect URIs). Returns a URL with no trailing slash.
 *
 * Resolution order:
 *   1. `PUBLIC_MCP_BASE_URL` env — explicit public origin. Wins so split
 *      deployments (Helm) can keep `MCP_BASE_URL` pointed at an internal
 *      cluster address while outbound URLs use the public ingress.
 *   2. `MCP_BASE_URL` env — canonical when public and internal hosts coincide
 *      (e.g. an ngrok tunnel set as `MCP_BASE_URL` in local dev).
 *   3. Inbound request host — `X-Forwarded-Proto`/`X-Forwarded-Host` if behind
 *      a proxy/tunnel (ngrok), else `Host` header. Lets the URL stay correct
 *      when neither env var is set and the API is reached via an arbitrary
 *      external hostname.
 *   4. `http://localhost:<PORT>` fallback
 */
export function deriveApiBaseUrl(req: IncomingMessage): string {
  const publicBase = process.env.PUBLIC_MCP_BASE_URL?.trim();
  if (publicBase) return publicBase.replace(/\/+$/, "");

  const envBase = process.env.MCP_BASE_URL?.trim();
  if (envBase) return envBase.replace(/\/+$/, "");

  const fwdProtoRaw = req.headers["x-forwarded-proto"];
  const fwdHostRaw = req.headers["x-forwarded-host"];
  const fwdProto = Array.isArray(fwdProtoRaw) ? fwdProtoRaw[0] : fwdProtoRaw;
  const fwdHost = Array.isArray(fwdHostRaw) ? fwdHostRaw[0] : fwdHostRaw;
  const proto = fwdProto?.split(",")[0]?.trim() || "http";
  const host = fwdHost?.split(",")[0]?.trim() || req.headers.host;

  if (host) return `${proto}://${host}`;
  return `http://localhost:${process.env.PORT || "3013"}`;
}

/**
 * Build the standard OpenTelemetry HTTP *server* semantic-convention span
 * attributes that the API span doesn't already set directly.
 *
 * `http.request.method`, `url.path`, `http.route`, and
 * `http.response.status_code` are set on the span in `src/http/index.ts`;
 * this fills the remaining semconv gaps:
 *
 * - `server.address`           — request host, port stripped
 * - `url.scheme`               — `https`/`http`, honoring `X-Forwarded-Proto`
 * - `network.protocol.version` — HTTP version (`1.1`, `2`, …)
 * - `user_agent.original`      — raw `User-Agent` header
 *
 * `undefined` values are dropped by the OTel span adapter, so absent headers
 * simply omit the attribute.
 */
export function httpServerSemconvAttributes(req: IncomingMessage): {
  "server.address"?: string;
  "url.scheme": string;
  "network.protocol.version"?: string;
  "user_agent.original"?: string;
} {
  const headerValue = (v: string | string[] | undefined): string | undefined =>
    Array.isArray(v) ? v[0] : v;
  // Forwarded headers may be comma-joined by a chain of proxies — take the
  // first hop. Never used for `User-Agent`, whose value legitimately contains
  // commas (e.g. `(KHTML, like Gecko)`).
  const firstHop = (v: string | string[] | undefined): string | undefined =>
    headerValue(v)?.split(",")[0]?.trim() || undefined;

  const fwdProto = firstHop(req.headers["x-forwarded-proto"]);
  const host = firstHop(req.headers["x-forwarded-host"]) ?? headerValue(req.headers.host)?.trim();

  return {
    // Strip the trailing `:<port>` — `server.port` is a separate semconv
    // attribute we don't emit. The anchored regex leaves bracketed IPv6
    // literals intact (`[::1]:3013` → `[::1]`, bare `[::1]` unchanged).
    "server.address": host ? host.replace(/:\d+$/, "") || undefined : undefined,
    "url.scheme": fwdProto ?? "http",
    "network.protocol.version": req.httpVersion || undefined,
    "user_agent.original": headerValue(req.headers["user-agent"]),
  };
}

/**
 * Match a route pattern against HTTP method and path segments.
 *
 * @param method - HTTP method from request (e.g. "GET", "POST")
 * @param pathSegments - URL path segments (e.g. ["api", "config", "resolved"])
 * @param expectedMethod - Expected HTTP method to match
 * @param pattern - Segment patterns: string for literal match, null for dynamic param (must be truthy)
 * @param exact - If true, ensures no extra trailing segments exist (default: false)
 */
export function matchRoute(
  method: string | undefined,
  pathSegments: string[],
  expectedMethod: string,
  pattern: readonly (string | null)[],
  exact = false,
): boolean {
  if (method !== expectedMethod) return false;
  for (let i = 0; i < pattern.length; i++) {
    const seg = pattern[i];
    if (seg === null) {
      if (!pathSegments[i]) return false;
    } else {
      if (pathSegments[i] !== seg) return false;
    }
  }
  if (exact && pathSegments[pattern.length]) return false;
  return true;
}
