#!/usr/bin/env node
/**
 * The zero-config boot gate: hand the API nothing at all and it still answers.
 *
 * This exists because two independent server designs made the same mistake —
 * defaulting the weather source to a provider that requires a contact string,
 * then failing hard when the contact was absent — which breaks the one
 * acceptance test the whole repo is built around: a stranger clones this, runs
 * one command, and gets a working box with no account, no key and no network.
 * CONTRACT.md §5.1 resolves it (a source configured without what it needs is
 * demoted, not fatal) and this script is what keeps the resolution honest.
 *
 * The environment handed to the server is genuinely empty — `env: {}`, the
 * in-process equivalent of `env -i` — which is possible only because the child
 * is launched by absolute path (`process.execPath`) and so needs no PATH to find
 * itself. Nothing is stubbed and no config object is constructed by hand: this
 * starts the real entry point and asks the real socket, which is the difference
 * between this and `server/src/test/boot.test.ts`, which asserts the same
 * property one layer down.
 *
 *     node scripts/check-zero-config-boot.mjs
 *     node scripts/check-zero-config-boot.mjs --compose
 *
 * The second form is CONTRACT.md §0's literal wording — `docker compose up` under
 * an empty environment — and needs a working Docker. CI runs the first form,
 * because a gate that depends on docker-in-docker being present on the runner is
 * measuring the runner rather than the repo. The two assert the same thing: the
 * compose file adds only `TERA_HOST` and container hardening, and every other
 * variable in it carries a `:-` default precisely so that an empty environment
 * resolves it to the empty string.
 */

import { spawn } from "node:child_process";
import { connect } from "node:net";
import { fileURLToPath } from "node:url";

// ---- Where and what ----

const REPO_ROOT = fileURLToPath(new URL("..", import.meta.url));
const HEALTH_HOST = "127.0.0.1";
const HEALTH_PORT = 8431;
const HEALTH_URL = `http://${HEALTH_HOST}:${HEALTH_PORT}/api/v1/health`;

/** How long the server gets to bind and answer before this gives up. */
const PROCESS_DEADLINE_MS = 20_000;
/** Compose has to build an image first, which is a different order of patience. */
const COMPOSE_DEADLINE_MS = 300_000;

const useCompose = process.argv.slice(2).includes("--compose");

// ---- Reporting ----

function fail(headline, detail = [], log = []) {
  console.error(`\ncheck-zero-config-boot: FAIL — ${headline}\n`);
  for (const line of detail) console.error(line);
  if (log.length > 0) {
    console.error("\n--- what the server said ---");
    console.error(log.join("").trimEnd());
    console.error("--- end ---");
  }
  console.error("");
  process.exitCode = 1;
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// ---- Making sure we are testing our own server ----

/**
 * Is anything already listening on the port we are about to claim?
 *
 * This guard is here because the check silently passed without it. The port was
 * occupied by an unrelated process, the server we spawned died on `EADDRINUSE`
 * within a second, and the poll cheerfully collected a 200 from the stranger —
 * a green gate asserting nothing at all. A check that can pass against a server
 * it did not start is worse than no check, so an occupied port is a hard stop.
 */
function portInUse() {
  return new Promise((resolve) => {
    const socket = connect({ host: HEALTH_HOST, port: HEALTH_PORT });
    const settle = (answer) => {
      socket.destroy();
      resolve(answer);
    };
    socket.setTimeout(1_500);
    socket.once("connect", () => settle(true));
    socket.once("timeout", () => settle(false));
    socket.once("error", () => settle(false));
  });
}

async function assertPortFree() {
  if (!(await portInUse())) return true;
  fail(`something is already listening on ${HEALTH_HOST}:${HEALTH_PORT}.`, [
    "This check has to own that port, because otherwise it polls whatever is there and",
    "reports a healthy stranger while the server it started is dead in a ditch. It cannot",
    "move to another port either: choosing one would mean setting TERA_PORT, and the empty",
    "environment is the thing under test.",
    "",
    "Stop whatever is on the port and run it again.",
  ]);
  return false;
}

/**
 * A second line of defence against answering for somebody else's process: if the
 * server on the other end has been up longer than this check has been running,
 * it is not the one we started.
 */
function uptimeLooksLikeOurs(body, startedAt) {
  const allowed = Math.ceil((Date.now() - startedAt) / 1000) + 2;
  return typeof body.uptimeSeconds !== "number" || body.uptimeSeconds <= allowed;
}

// ---- The assertion ----

/**
 * Poll until health answers or the deadline passes. `stillRunning` lets the
 * caller abort early when the thing under test has already died, so a server
 * that exits on boot reports its own error instead of a twenty-second timeout.
 */
async function waitForHealth(deadlineMs, stillRunning) {
  const deadline = Date.now() + deadlineMs;
  let lastError = "nothing was listening";
  while (Date.now() < deadline) {
    if (!stillRunning()) return { dead: true, lastError };
    try {
      const response = await fetch(HEALTH_URL, {
        signal: AbortSignal.timeout(2_000),
      });
      const text = await response.text();
      return { status: response.status, text };
    } catch (err) {
      lastError = err instanceof Error ? err.message : String(err);
    }
    await sleep(250);
  }
  return { timedOut: true, lastError };
}

/**
 * Everything the health body has to say on a box that was handed nothing.
 *
 * The source and mode checks are not padding. A default that needs configuration
 * is exactly the bug this gate was written for, and it would still return 200
 * while being wrong — the failure showed up as an empty sky, not as a dead
 * process. `degraded` being non-empty means the config layer demoted something,
 * which on an empty environment means a default asked for something it was never
 * going to be given.
 */
function checkPosture(body) {
  const problems = [];
  if (body.ok !== true) {
    problems.push(`  health reported ok: ${JSON.stringify(body.ok)}, expected true`);
  }
  if (body.sources?.weather !== "none") {
    problems.push(
      `  weather source defaulted to ${JSON.stringify(body.sources?.weather)}; CONTRACT.md §5.1`,
      `  requires "none", because every other source wants a contact string or a key.`,
    );
  }
  if (body.auth?.mode !== "none") {
    problems.push(
      `  auth mode defaulted to ${JSON.stringify(body.auth?.mode)}; CONTRACT.md §6 requires "none"`,
      `  so that a self-hoster never creates an account anywhere.`,
    );
  }
  if (!Array.isArray(body.degraded)) {
    problems.push(`  health body has no degraded array; it is how demotions become visible.`);
  } else if (body.degraded.length > 0) {
    problems.push(
      `  the config layer demoted ${body.degraded.length} source(s) on an empty environment:`,
      ...body.degraded.map((line) => `    ${line}`),
      `  A demotion here means a default was chosen that needs configuration to work.`,
    );
  }
  return problems;
}

function report(body) {
  console.log("check-zero-config-boot: ok — 200 from /api/v1/health on an empty environment.");
  console.log(`  service:  ${body.service} ${body.version}`);
  console.log(
    `  sources:  weather=${body.sources?.weather} flights=${body.sources?.flights} ` +
      `satellites=${body.sources?.satellites} markers=${body.sources?.markers}`,
  );
  console.log(`  auth:     ${body.auth?.mode}`);
  console.log(`  degraded: none`);
}

/** Shared tail of both modes: parse, assert, print. */
function finish(result, log, startedAt) {
  if (result.dead) {
    fail(
      "the server exited before it ever answered.",
      [
        "It was started with a genuinely empty environment, which is the whole point: a box",
        "that needs a variable set before it will boot is not self-hostable. See CONTRACT.md §5.1.",
        `  last connection attempt: ${result.lastError}`,
      ],
      log,
    );
    return;
  }
  if (result.timedOut) {
    fail(
      `nothing answered ${HEALTH_URL} before the deadline.`,
      [`  last connection attempt: ${result.lastError}`],
      log,
    );
    return;
  }
  if (result.status !== 200) {
    fail(
      `${HEALTH_URL} returned ${result.status}, expected 200.`,
      [
        "Health touches no upstream and reads no file by design, so a non-200 here is the",
        "server refusing to be healthy without configuration it should not need.",
        `  body: ${result.text.slice(0, 500)}`,
      ],
      log,
    );
    return;
  }

  let body;
  try {
    body = JSON.parse(result.text);
  } catch {
    fail("health answered 200 but the body was not JSON.", [`  body: ${result.text.slice(0, 500)}`], log);
    return;
  }

  if (!uptimeLooksLikeOurs(body, startedAt)) {
    fail(
      `${HEALTH_URL} answered, but from a server this check did not start.`,
      [
        `  it reports ${body.uptimeSeconds}s of uptime; this check has been running for`,
        `  ${Math.ceil((Date.now() - startedAt) / 1000)}s. Something else claimed the port first.`,
      ],
      log,
    );
    return;
  }

  const problems = checkPosture(body);
  if (problems.length > 0) {
    fail("health answered 200, but not from a zero-config box.", problems, log);
    return;
  }
  report(body);
}

// ---- Mode: the real entry point under an empty environment ----

async function runProcessMode() {
  if (!(await assertPortFree())) return;
  console.log("check-zero-config-boot: starting server/src/index.ts with env -i (no variables at all)");

  const startedAt = Date.now();
  const child = spawn(process.execPath, ["server/src/index.ts"], {
    cwd: REPO_ROOT,
    // The empty environment is the test. `process.execPath` is absolute, so the
    // child needs no PATH to exist, and Node needs nothing else to run.
    env: {},
    stdio: ["ignore", "pipe", "pipe"],
  });

  const log = [];
  child.stdout.on("data", (chunk) => log.push(String(chunk)));
  child.stderr.on("data", (chunk) => log.push(String(chunk)));

  let alive = true;
  let exitInfo = "";
  child.on("exit", (code, signal) => {
    alive = false;
    exitInfo = signal ? `killed by ${signal}` : `exited with code ${code}`;
  });
  child.on("error", (err) => {
    alive = false;
    exitInfo = `could not spawn: ${err.message}`;
  });

  try {
    const result = await waitForHealth(PROCESS_DEADLINE_MS, () => alive);
    if (result.dead) log.push(`\n(process ${exitInfo})\n`);
    finish(result, log, startedAt);
  } finally {
    if (alive) {
      child.kill("SIGTERM");
      // The entry point closes on SIGTERM; if it does not, this is a check, not a
      // supervisor, and a lingering child would hang CI.
      for (let waited = 0; alive && waited < 5_000; waited += 100) await sleep(100);
      if (alive) child.kill("SIGKILL");
    }
  }
}

// ---- Mode: docker compose, for whoever has a Docker ----

function docker(args, timeoutMs) {
  return new Promise((resolve) => {
    const child = spawn("docker", args, {
      cwd: REPO_ROOT,
      // PATH and HOME are for the Docker CLI itself — finding its binary and its
      // config — and reach the container through nothing. The compose file
      // interpolates only TERA_* variables, all with `:-` defaults, so the
      // container's own environment is empty either way.
      env: { PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", HOME: process.env.HOME ?? "/tmp" },
      stdio: ["ignore", "pipe", "pipe"],
      timeout: timeoutMs,
    });
    const out = [];
    child.stdout.on("data", (chunk) => out.push(String(chunk)));
    child.stderr.on("data", (chunk) => out.push(String(chunk)));
    child.on("close", (code) => resolve({ code, out: out.join("") }));
    child.on("error", (err) => resolve({ code: -1, out: `could not run docker: ${err.message}` }));
  });
}

async function runComposeMode() {
  if (!(await assertPortFree())) return;
  const compose = ["compose", "-f", "deploy/docker-compose.yml"];
  console.log("check-zero-config-boot: docker compose up, with no .env file and no TERA_* set");

  const up = await docker([...compose, "up", "-d", "--build"], COMPOSE_DEADLINE_MS);
  if (up.code !== 0) {
    fail("`docker compose up` failed.", ["  " + up.out.trim().split("\n").join("\n  ")]);
    return;
  }
  // After the build, not before it: `up -d` returns with the container just
  // started, so the uptime it reports is measured from about here.
  const startedAt = Date.now();

  try {
    const result = await waitForHealth(60_000, () => true);
    const logs = await docker([...compose, "logs", "--no-color"], 30_000);
    finish(result, [logs.out], startedAt);
  } finally {
    await docker([...compose, "down", "-v"], 60_000);
  }
}

// ---- Run ----

await (useCompose ? runComposeMode() : runProcessMode());
