#!/usr/bin/env bun
/**
 * headless-seo-check, exposed as a private HTTP service.
 *
 * One request in, one run of the existing binary out, its exit code mapped to
 * an HTTP status and its stdout returned verbatim — the JSON document of
 * `docs/contract.md` §4 is the response body, byte for byte. The binary is not
 * modified, wrapped or parsed: the CLI surface stays the single source of
 * truth, and this file stays small enough to read in one sitting.
 *
 * Authentication is nginx's job (see `templates/nginx-site.conf.tmpl`, which
 * `hsc-setup.sh` renders and installs). nginx maps a
 * Bearer key to a site name and forwards it as `X-HSC-Site`, overwriting
 * whatever the client sent. This service binds to the loopback and trusts that
 * header — but refuses a request without it, so that a service exposed by
 * mistake is not an open audit proxy.
 *
 * Environment: HSC_BIN, HSC_KEYS (both required), HSC_HOST, HSC_PORT,
 * HSC_TIMEOUT_MS.
 */

import { accessSync, constants, readFileSync, statSync } from "node:fs";

import { ANY_PUBLIC_ORIGIN, checkPublicTarget } from "./public-target.ts";

/** Everything the service needs. Built from the environment by `configFromEnv`. */
export interface Config {
  /** Path to the `headless-seo-check` binary. Must exist and be executable at startup. */
  readonly bin: string;
  /** Path to the site → allowed origins map. Re-read on SIGHUP. */
  readonly keysPath: string;
  /** Interface to bind. Loopback: nginx is the only thing that may reach us. */
  readonly host: string;
  /** Port to bind. `0` lets the OS pick one, which is what the tests use. */
  readonly port: number;
  /** A run still alive after this many milliseconds is killed and answered 504. */
  readonly timeoutMs: number;
  /** How many audits may run at once; the rest get 429. */
  readonly maxConcurrent: number;
  /**
   * How a hostname is resolved when a `["*"]` key has to be told whether its
   * target is on the public internet. Injectable for one reason: every test in
   * this directory runs offline, and a guard exercised only against real DNS is
   * a guard that stops being tested the day the network is slow. Production
   * leaves it unset and gets `node:dns`.
   */
  readonly resolve?: (host: string) => Promise<Array<{ address: string }>>;
}

const DEFAULT_PORT = 8169;
const DEFAULT_TIMEOUT_MS = 180_000;
/**
 * The binary already opens up to 6 connections per run. Two runs at once is a
 * deliberate ceiling on what we do to the sites we audit, not a tunable.
 */
const MAX_CONCURRENT = 2;
/** What a 429 tells the caller to wait, in seconds. */
const RETRY_AFTER = 30;
/** How much of the binary's stderr a 502 carries back, in characters. */
const STDERR_EXCERPT = 400;
/** nginx caps the body at 16 KB; refusing the same size here needs no nginx to be true. */
const MAX_BODY_BYTES = 16 * 1024;

/** The fields `POST /audit` accepts. Anything else is a 400 naming it. */
const ACCEPTED_FIELDS = ["site", "sample", "all", "urls", "allow_dynamic", "no_verify"] as const;

/** site → the set of origins that site's key may audit. */
type AllowedOrigins = ReadonlyMap<string, ReadonlySet<string>>;

/** A request the caller got wrong: the message is safe to hand back as a 400. */
class BadRequest extends Error {}

// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------

export function configFromEnv(env: Record<string, string | undefined>): Config {
  const bin = env.HSC_BIN?.trim();
  if (!bin) {
    throw new Error("HSC_BIN is not set: it must point at the headless-seo-check binary");
  }
  const keysPath = env.HSC_KEYS?.trim();
  if (!keysPath) {
    throw new Error("HSC_KEYS is not set: it must point at keys.json");
  }
  return {
    bin,
    keysPath,
    host: env.HSC_HOST?.trim() || "127.0.0.1",
    port: positiveInteger(env.HSC_PORT, DEFAULT_PORT, "HSC_PORT"),
    timeoutMs: positiveInteger(env.HSC_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, "HSC_TIMEOUT_MS"),
    maxConcurrent: MAX_CONCURRENT,
  };
}

function positiveInteger(raw: string | undefined, fallback: number, name: string): number {
  if (raw === undefined || raw.trim() === "") return fallback;
  const value = Number(raw);
  if (!Number.isInteger(value) || value < 1) {
    throw new Error(`${name}=${raw} is not a whole number of at least 1`);
  }
  return value;
}

// ---------------------------------------------------------------------------
// Keys
// ---------------------------------------------------------------------------

/**
 * Reads `keys.json`. Every origin is normalised through `URL` so that the
 * comparison at request time is on scheme + host + port and nothing else:
 * `https://site.example/` and `https://site.example:443/x` are one origin.
 */
export function parseKeys(text: string): AllowedOrigins {
  const document: unknown = JSON.parse(text);
  if (document === null || typeof document !== "object" || Array.isArray(document)) {
    throw new Error("keys.json must be an object mapping a site name to an array of origins");
  }
  const keys = new Map<string, ReadonlySet<string>>();
  for (const [site, origins] of Object.entries(document as Record<string, unknown>)) {
    if (!Array.isArray(origins)) {
      throw new Error(`keys.json: "${site}" must map to an array of origins`);
    }
    const allowed = new Set<string>();
    for (const origin of origins) {
      if (typeof origin !== "string") {
        throw new Error(`keys.json: "${site}" lists an origin that is not a string`);
      }
      // The public form has no allowlist to write — a visitor types their own
      // domain. `["*"]` says so out loud, and must be the entry's only member:
      // mixing it with named origins would read as "these, plus anything",
      // which is the same as "anything" and hides it.
      if (origin === ANY_PUBLIC_ORIGIN) {
        if (origins.length !== 1) {
          throw new Error(`keys.json: "${site}" combines "*" with named origins; "*" already means every one`);
        }
        allowed.add(ANY_PUBLIC_ORIGIN);
        continue;
      }
      const url = httpUrl(origin);
      if (!url) {
        throw new Error(`keys.json: "${site}" lists "${origin}", which is not an http(s) URL`);
      }
      allowed.add(url.origin);
    }
    keys.set(site, allowed);
  }
  return keys;
}

/**
 * Parses an absolute http(s) URL, or returns null. The protocol check is not
 * decoration: `new URL("example.com:8080")` succeeds, with `example.com:` as
 * its scheme, and would otherwise sail through as a valid target.
 */
function httpUrl(value: string): URL | null {
  let url: URL;
  try {
    url = new URL(value);
  } catch {
    return null;
  }
  return url.protocol === "http:" || url.protocol === "https:" ? url : null;
}

// ---------------------------------------------------------------------------
// Request → command line
// ---------------------------------------------------------------------------

export interface Invocation {
  /** The positional argument, as the caller wrote it. */
  readonly target: string;
  /** That target's origin — scheme, host, port — for the key check. */
  readonly origin: string;
  /** The complete argument list, `--json` included. */
  readonly args: readonly string[];
}

/**
 * Translates one request body into one command line, or throws `BadRequest`.
 *
 * The `site` string is passed to the binary as given, path included: contract
 * §1.1 makes a path a sitemap-discovery hint, never a scope, and dropping it
 * would quietly lose that. Only the *origin* is what the key check compares.
 */
export function buildInvocation(body: unknown): Invocation {
  if (body === null || typeof body !== "object" || Array.isArray(body)) {
    throw new BadRequest("the body must be a JSON object");
  }
  const fields = body as Record<string, unknown>;
  for (const name of Object.keys(fields)) {
    if (!(ACCEPTED_FIELDS as readonly string[]).includes(name)) {
      throw new BadRequest(`unknown field "${name}"`);
    }
  }

  const site = fields.site;
  if (typeof site !== "string" || site.trim() === "") {
    throw new BadRequest('"site" is required and must be a string');
  }
  const target = httpUrl(site.trim());
  if (!target) {
    throw new BadRequest('"site" must be an absolute http(s) URL, for instance "https://example.com"');
  }

  const sample = readSample(fields);
  const all = readBoolean(fields, "all");
  const urls = readUrls(fields);

  // Contract §1.4 refuses these three pairs anyway, at exit 2. Refusing them
  // here costs a process and turns a 422 into a 400, which is what they are.
  if (sample !== undefined && all) throw new BadRequest('"sample" and "all" cannot be combined');
  if (sample !== undefined && urls) throw new BadRequest('"sample" and "urls" cannot be combined');
  if (all && urls) throw new BadRequest('"all" and "urls" cannot be combined');

  const positional = site.trim();
  const args: string[] = [positional, "--json"];
  if (sample !== undefined) args.push("--sample", String(sample));
  if (all) args.push("--all");
  if (urls) args.push("--urls", urls.join(","));
  if (readBoolean(fields, "allow_dynamic")) args.push("--allow-dynamic");
  if (readBoolean(fields, "no_verify")) args.push("--no-verify");

  return { target: positional, origin: target.origin, args };
}

function readBoolean(fields: Record<string, unknown>, name: string): boolean {
  const value = fields[name];
  if (value === undefined) return false;
  if (typeof value !== "boolean") throw new BadRequest(`"${name}" must be a boolean`);
  return value;
}

function readSample(fields: Record<string, unknown>): number | undefined {
  const value = fields.sample;
  if (value === undefined) return undefined;
  if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
    throw new BadRequest('"sample" must be a whole number of at least 1');
  }
  return value;
}

function readUrls(fields: Record<string, unknown>): readonly string[] | undefined {
  const value = fields.urls;
  if (value === undefined) return undefined;
  if (!Array.isArray(value) || value.length === 0) {
    throw new BadRequest('"urls" must be a non-empty array of strings');
  }
  return value.map((entry: unknown): string => {
    if (typeof entry !== "string" || entry.trim() === "") {
      throw new BadRequest('"urls" entries must be non-empty strings');
    }
    const url = entry.trim();
    // The CLI takes one comma-separated list, so a comma inside an entry would
    // silently become two entries.
    if (url.includes(",")) throw new BadRequest('"urls" entries must not contain a comma');
    // "-" means "read them from stdin", and this service gives the binary none.
    if (url === "-") throw new BadRequest('"urls" cannot be "-": the service gives the binary no stdin');
    return url;
  });
}

// ---------------------------------------------------------------------------
// Running the binary
// ---------------------------------------------------------------------------

interface Run {
  /** The process exit code, or null when a signal ended it. */
  readonly code: number | null;
  readonly stdout: string;
  readonly stderr: string;
  /** True when we are the ones who killed it, past `timeoutMs`. */
  readonly timedOut: boolean;
}

async function runBinary(config: Config, args: readonly string[]): Promise<Run> {
  const child = Bun.spawn([config.bin, ...args], {
    // No stdin: `--urls -` would otherwise block or read something unexpected.
    stdin: "ignore",
    stdout: "pipe",
    stderr: "pipe",
  });

  // Both pipes are drained while the process runs: a report large enough to
  // fill the pipe buffer would otherwise deadlock the run we are waiting on.
  // A rejection here means the pipe died with the process; "" is the truth.
  const completed: Promise<readonly [string, string]> = Promise.all([
    new Response(child.stdout).text().catch((): string => ""),
    new Response(child.stderr).text().catch((): string => ""),
  ]).then(async ([stdout, stderr]): Promise<readonly [string, string]> => {
    await child.exited;
    return [stdout, stderr];
  });

  let killer: ReturnType<typeof setTimeout> | undefined;
  const killed: Promise<null> = new Promise((resolve): void => {
    killer = setTimeout((): void => {
      child.kill("SIGKILL");
      resolve(null);
    }, config.timeoutMs);
  });

  try {
    const outcome = await Promise.race([completed, killed]);
    // Deliberately not waiting for the pipes after a kill: a process that
    // spawned children of its own leaves them holding the write end open, and
    // a 504 that arrives whenever they feel like it is not a timeout.
    if (outcome === null) return { code: null, stdout: "", stderr: "", timedOut: true };
    const [stdout, stderr] = outcome;
    return { code: child.exitCode, stdout, stderr, timedOut: false };
  } finally {
    clearTimeout(killer);
  }
}

/** Maps a finished run to its response, per the design's status table. */
function respondTo(run: Run): Response {
  if (run.timedOut) return json(504, { error: "audit timed out" });

  const hasReport = run.stdout.trim() !== "";
  if (hasReport && (run.code === 0 || run.code === 1)) return verbatim(200, run.stdout);
  // Contract §4.5: even a fatal run writes a complete JSON document to stdout.
  if (hasReport && run.code === 2) return verbatim(422, run.stdout);

  return json(502, {
    error: hasReport ? "the audit ended on an unexpected exit code" : "the audit produced no report",
    exit_code: run.code,
    detail: run.stderr.trim().slice(-STDERR_EXCERPT),
  });
}

// ---------------------------------------------------------------------------
// HTTP
// ---------------------------------------------------------------------------

function json(status: number, body: unknown, headers: Record<string, string> = {}): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: { "content-type": "application/json", ...headers },
  });
}

/** The binary's stdout, untouched — it is already the document we promise. */
function verbatim(status: number, stdout: string): Response {
  return new Response(stdout, { status, headers: { "content-type": "application/json" } });
}

export interface Service {
  /** The port actually bound, which matters when the config asked for 0. */
  readonly port: number;
  /** Re-reads keys.json; a malformed file leaves the previous map in place. */
  reloadKeys(): void;
  stop(): Promise<void>;
}

export function start(config: Config): Service {
  assertExecutable(config.bin);
  // A missing or malformed keys.json at startup is fatal: systemd should see
  // it now, not on the first audit of the night.
  let keys: AllowedOrigins = parseKeys(readFileSync(config.keysPath, "utf8"));
  let running = 0;

  async function handle(request: Request): Promise<Response> {
    const startedAt = performance.now();
    const url = new URL(request.url);
    let site = "-";
    let target = "-";
    let flags = "";

    /** Every exit from this function goes through here, so every request logs one line. */
    const reply = (response: Response): Response => {
      const ms = Math.round(performance.now() - startedAt);
      // Quoted, because two of these three come from the request: a log line
      // stays one line and stays parseable whatever a caller puts in them.
      console.log(
        `${new Date().toISOString()} ${request.method} ${url.pathname}` +
          ` site=${JSON.stringify(site)} target=${JSON.stringify(target)} flags=${JSON.stringify(flags)}` +
          ` status=${response.status} ms=${ms}`,
      );
      return response;
    };

    if (url.pathname === "/healthz") return reply(json(200, { ok: true }));
    if (url.pathname !== "/audit") return reply(json(404, { error: "no such endpoint" }));
    if (request.method !== "POST") return reply(json(405, { error: "POST only" }, { allow: "POST" }));

    // nginx sets this header on every proxied request and overwrites any value
    // the client sent. Missing means the service was reached directly.
    const header = request.headers.get("x-hsc-site")?.trim();
    if (!header) return reply(json(401, { error: "missing X-HSC-Site" }));
    site = header;

    let body: unknown;
    try {
      body = await request.json();
    } catch {
      return reply(json(400, { error: "the body is not valid JSON" }));
    }

    let invocation: Invocation;
    try {
      invocation = buildInvocation(body);
    } catch (error) {
      if (error instanceof BadRequest) return reply(json(400, { error: error.message }));
      throw error;
    }
    target = invocation.target;
    flags = invocation.args.slice(1).join(" ");

    const allowed = keys.get(site);
    if (allowed?.has(ANY_PUBLIC_ORIGIN)) {
      // This key audits whatever a visitor typed, so the allowlist cannot do the
      // work. Refuse anything that is not on the public internet — and see
      // `public-target.ts` for why this is a courtesy, not the boundary: the
      // binary follows redirects, and `IPAddressDeny=` on the unit is what
      // actually stops a packet reaching a private address.
      const verdict = await checkPublicTarget(
        new URL(invocation.origin).hostname,
        config.resolve,
      );
      if (!verdict.ok) {
        return reply(json(400, { error: verdict.reason }));
      }
    } else if (!allowed?.has(invocation.origin)) {
      return reply(json(403, { error: "target not allowed for this key" }));
    }

    // Counting semaphore. Nothing awaits between the test and the increment,
    // so on a single-threaded runtime this needs no lock.
    if (running >= config.maxConcurrent) {
      return reply(json(429, { error: "busy" }, { "retry-after": String(RETRY_AFTER) }));
    }
    running += 1;
    try {
      return reply(respondTo(await runBinary(config, invocation.args)));
    } catch (error) {
      return reply(json(502, { error: "the audit could not be started", detail: describe(error) }));
    } finally {
      running -= 1;
    }
  }

  const server = Bun.serve({
    hostname: config.host,
    port: config.port,
    maxRequestBodySize: MAX_BODY_BYTES,
    fetch: handle,
  });

  return {
    port: server.port,
    reloadKeys(): void {
      try {
        keys = parseKeys(readFileSync(config.keysPath, "utf8"));
        console.log(`hsc-server: keys reloaded, ${keys.size} site(s)`);
      } catch (error) {
        console.error(`hsc-server: keys reload failed, keeping the previous map: ${describe(error)}`);
      }
    },
    async stop(): Promise<void> {
      await server.stop(true);
    },
  };
}

function assertExecutable(bin: string): void {
  try {
    if (!statSync(bin).isFile()) throw new Error("not a file");
    accessSync(bin, constants.X_OK);
  } catch {
    throw new Error(`HSC_BIN=${bin} is not an executable file`);
  }
}

function describe(error: unknown): string {
  return error instanceof Error ? error.message : String(error);
}

if (import.meta.main) {
  try {
    const config = configFromEnv(process.env);
    const service = start(config);
    process.on("SIGHUP", (): void => service.reloadKeys());
    console.log(`hsc-server: listening on http://${config.host}:${service.port}, binary ${config.bin}`);
  } catch (error) {
    // Bad config, missing binary, unreadable keys: fail now, visibly, so that
    // systemd reports it instead of the first audit of the night.
    console.error(`hsc-server: ${describe(error)}`);
    process.exit(1);
  }
}
