/**
 * The fire feed, and the one property it exists to guarantee.
 *
 * Most of this file is about **which keys reach the wire**, which is unusual for
 * a feed test and is the whole point of this one. The upstream store is centred
 * on a private home and carries four columns computed from the distance to it —
 * `observations.distance_km`, `bearing_deg`, `threat` and
 * `detections.distance_km`. `threat` is
 * `(16/distance)^2 x log10(acres) x momentum x containment x wind-alignment`, and
 * with acreage and containment already public it inverts to a circle around the
 * house; three fires give an intersection.
 *
 * The structural defence is that this box has no database: cloud-1 serves a
 * projection with a hand-written column list, so there is nothing here to be
 * careless with. The test below is the second line — it asserts the adopted key
 * set **equals** a hard-coded allowlist, so a field added upstream fails here
 * rather than arriving in a browser. An `assert.ok(!keys.has("threat"))` would
 * have passed for every column nobody thought to name, which is precisely the
 * class of column that gets added later.
 *
 * The rest is the ordinary feed contract: off by default, no outbound request on
 * a box that was handed nothing, an empty body rather than an invented fire, a
 * TTL floor, and a dead upstream that degrades the body rather than the response.
 */

import assert from "node:assert/strict";
import { after, beforeEach, describe, it } from "node:test";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import type { FiresBody, HealthBody } from "../../../src/server/wire.ts";

/**
 * Every key a `FireIncident` may carry, and nothing else.
 *
 * Adding to this list is a **privacy decision**, not a refactor. If a field
 * appears upstream and you want it here, the question to answer first is
 * "can this be inverted, joined or differenced into the distance from a fire to
 * somebody's front door" — because `threat` could, and it took a second reading
 * of the collector to notice.
 */
const INCIDENT_KEYS = [
  "acres",
  "county",
  "firstSeen",
  "id",
  "lastSeen",
  "lat",
  "lon",
  "name",
  "observedAt",
  "pctContained",
  "provenance",
  "source",
  "type",
  "url",
] as const;

/** The same, for a hot pixel. `distance_km` is upstream and stays there. */
const DETECTION_KEYS = [
  "acquiredAt",
  "confidence",
  "frp",
  "lat",
  "lon",
  "persistent",
  "persistentDays",
  "sat",
] as const;

/** Home-relative names, in every spelling the two sides use. None may appear. */
const FORBIDDEN = [
  "distance_km",
  "distanceKm",
  "bearing_deg",
  "bearingDeg",
  "threat",
  "area",
  "home",
  "promoted_to",
  "promotedTo",
];

const realFetch = globalThis.fetch;
let calls: string[] = [];
/** What the projection answers with, per path suffix. `null` is an outage. */
let incidentsBody: unknown = null;
let detectionsBody: unknown = null;

/**
 * A projection response shaped exactly as cloud-1 serves one — including three
 * fields this build does not read (`ghostRows`, `readAt`) and one it must never
 * adopt whatever else happens.
 */
function upstreamIncidents(): unknown {
  return {
    latestSeen: "2026-08-22T22:20:06Z",
    ghostRows: 22,
    readAt: "2026-08-22T22:22:35.000Z",
    attribution: ["Incidents from CAL FIRE and NIFC/WFIGS (US Government work, public domain)"],
    incidents: [
      {
        id: "b7e4a30e-67ab-4964-882e-751da30b44e0",
        source: "calfire",
        name: "Timber Fire ",
        lat: 36.224857,
        lon: -121.72983,
        provenance: "us-gov",
        county: "Monterey",
        type: "WF",
        url: "https://www.fire.ca.gov/incidents/2026/8/8/timber-fire/",
        firstSeen: "2026-08-22T21:14:43Z",
        lastSeen: "2026-08-22T22:20:06Z",
        observedAt: "2026-08-22T21:30:08Z",
        acres: 7591,
        pctContained: 29,
        // The four that must not survive the hop, plus a plausible future one.
        // A projection that regressed would send these; nothing may adopt them.
        distance_km: 331.4,
        bearing_deg: 297.5,
        threat: 0.0041,
        promoted_to: null,
        area: "Norco and Corona",
      },
    ],
  };
}

function upstreamDetections(): unknown {
  return {
    windowHours: 24,
    readAt: "2026-08-22T22:22:35.000Z",
    attribution: ["Satellite hot pixels from NASA FIRMS (MODIS, VIIRS)"],
    detections: [
      {
        sat: "MODIS",
        acquiredAt: "2026-08-22T17:37:00Z",
        lat: 36.26633,
        lon: -121.71249,
        frp: 112.9,
        confidence: "94",
        persistent: false,
        persistentDays: 2,
        distance_km: 331.9,
      },
    ],
  };
}

globalThis.fetch = (async (input: unknown) => {
  const url = String(input);
  calls.push(url);
  const body = url.includes("/detections") ? detectionsBody : incidentsBody;
  if (body === null) return new Response("nope", { status: 503 });
  return new Response(JSON.stringify(body), {
    status: 200,
    headers: { "content-type": "application/json" },
  });
}) as unknown as typeof globalThis.fetch;

after(() => {
  globalThis.fetch = realFetch;
});

beforeEach(() => {
  calls = [];
  incidentsBody = upstreamIncidents();
  detectionsBody = upstreamDetections();
});

function appWith(env: Record<string, string>) {
  const config = loadConfig(env);
  config.logLevel = "silent";
  return buildApp(config);
}

const CLOUD1 = {
  TERA_FIRES_SOURCE: "cloud1",
  TERA_FIRES_URL: "http://127.0.0.1:9/api/fires",
};

async function fires(app: ReturnType<typeof buildApp>) {
  const res = await app.inject({ method: "GET", url: "/api/v1/fires" });
  assert.equal(res.statusCode, 200);
  return { body: res.json() as FiresBody, headers: res.headers };
}

describe("a box with no fire source", () => {
  it("serves a real empty body rather than an invented fire", async () => {
    const app = appWith({});
    after(() => app.close());

    const { body } = await fires(app);
    assert.equal(body.source, "none");
    assert.deepEqual(body.incidents, []);
    assert.deepEqual(body.detections, []);
    // The epoch, not "now". "Now" would claim this was fetched a moment ago; it
    // was never fetched, and a board with nothing on it has to be able to say
    // which of those two it is looking at.
    assert.equal(Date.parse(body.fetchedAt), 0);
    assert.equal(body.latestSeen, null);
  });

  it("makes no outbound request at all, however often it is asked", async () => {
    const app = appWith({});
    after(() => app.close());

    await fires(app);
    await fires(app);
    assert.deepEqual(calls, []);
  });

  it("says so on the health body, and says nothing in degraded", async () => {
    const app = appWith({});
    after(() => app.close());

    const res = await app.inject({ method: "GET", url: "/api/v1/health" });
    const health = res.json() as HealthBody;
    assert.equal(health.sources.fires, "none");
    // Off by default is a choice this repo made, not a default that needs
    // configuring. `degraded` is one sentence per *demotion*, and
    // `scripts/check-zero-config-boot.mjs` fails on any demotion at all in an
    // empty environment — a stranger's clone is not misconfigured.
    assert.deepEqual(health.degraded, []);
  });
});

describe("a fire source that was asked for and cannot work", () => {
  it("demotes to none with a sentence naming what is missing", async () => {
    const app = appWith({ TERA_FIRES_SOURCE: "cloud1" });
    after(() => app.close());

    const health = (await app.inject({ method: "GET", url: "/api/v1/health" })).json() as HealthBody;
    assert.equal(health.sources.fires, "none");
    assert.equal(health.degraded.length, 1);
    assert.match(health.degraded[0] ?? "", /TERA_FIRES_URL/);
    assert.deepEqual(calls, []);
  });

  it("demotes an unreadable source name the same way", async () => {
    const app = appWith({ TERA_FIRES_SOURCE: "sqlite", TERA_FIRES_URL: "http://x/api/fires" });
    after(() => app.close());

    const health = (await app.inject({ method: "GET", url: "/api/v1/health" })).json() as HealthBody;
    assert.equal(health.sources.fires, "none");
    assert.match(health.degraded[0] ?? "", /TERA_FIRES_SOURCE/);
  });
});

describe("the projection's key set", () => {
  it("adopts exactly the allowlist and nothing the upstream added", async () => {
    const app = appWith(CLOUD1);
    after(() => app.close());

    const { body } = await fires(app);
    assert.equal(body.incidents.length, 1);

    // Equality, not absence. A subset assertion passes for every field nobody
    // thought to forbid, and the field nobody thought of is the one that leaks.
    const incidentKeys = Object.keys(body.incidents[0] as object).sort();
    assert.deepEqual(incidentKeys, [...INCIDENT_KEYS].sort());

    const detectionKeys = Object.keys(body.detections[0] as object).sort();
    assert.deepEqual(detectionKeys, [...DETECTION_KEYS].sort());
  });

  it("carries no home-relative value anywhere in the serialised body", async () => {
    const app = appWith(CLOUD1);
    after(() => app.close());

    const res = await app.inject({ method: "GET", url: "/api/v1/fires" });
    for (const key of FORBIDDEN) {
      assert.ok(!res.body.includes(key), `${key} reached the wire`);
    }
    // The values, too. `area` is the string "Norco and Corona" upstream — a
    // named two-town reporting district next to the house, which is the
    // coordinate with extra steps.
    assert.ok(!res.body.includes("Norco"));
    assert.ok(!res.body.includes("331.4"));
  });

  it("trims what it adopts and keeps the fields a renderer needs", async () => {
    const app = appWith(CLOUD1);
    after(() => app.close());

    const { body } = await fires(app);
    const incident = body.incidents[0];
    assert.equal(incident?.name, "Timber Fire");
    assert.equal(incident?.acres, 7591);
    assert.equal(incident?.pctContained, 29);
    assert.equal(body.latestSeen, "2026-08-22T22:20:06Z");
    assert.equal(body.detectionWindowHours, 24);
    assert.deepEqual(body.attribution, [
      "Incidents from CAL FIRE and NIFC/WFIGS (US Government work, public domain)",
      "Satellite hot pixels from NASA FIRMS (MODIS, VIIRS)",
    ]);
  });
});

describe("both halves, or neither", () => {
  it("keeps the last whole body when only the detections fail", async () => {
    const app = appWith(CLOUD1);
    after(() => app.close());

    const first = await fires(app);
    assert.equal(first.body.incidents.length, 1);

    // A live incident set beside a silently empty detection array is a board
    // that has stopped showing the strongest evidence it has, with nothing
    // saying so. The whole snapshot is refused instead.
    detectionsBody = null;
    const config = loadConfig({ ...CLOUD1, TERA_FIRES_TTL: "0" });
    config.logLevel = "silent";
    const impatient = buildApp(config);
    after(() => impatient.close());
    const second = await fires(impatient);
    // Nothing has ever answered for this second app, so it has no last good
    // body — and an empty one that says it was never fetched is the answer.
    assert.equal(Date.parse(second.body.fetchedAt), 0);
    assert.equal(second.body.source, "none");
  });

  it("serves the last good body when the upstream goes away entirely", async () => {
    const app = appWith(CLOUD1);
    after(() => app.close());

    const first = await fires(app);
    const stamp = first.body.fetchedAt;

    incidentsBody = null;
    detectionsBody = null;
    const again = await fires(app);
    // Still the fire, still the same stamp — which is what tells a viewer the
    // answer is old rather than that the fire went out.
    assert.equal(again.body.incidents.length, 1);
    assert.equal(again.body.fetchedAt, stamp);
  });
});

describe("how often it asks", () => {
  it("floors the TTL, so TERA_FIRES_TTL=0 is not a fetch per request", async () => {
    const app = appWith({ ...CLOUD1, TERA_FIRES_TTL: "0" });
    after(() => app.close());

    const { body } = await fires(app);
    // Five minutes: half the collector's own ten-minute cron, which is already
    // twice as often as anything can change.
    assert.equal(body.ttlSeconds, 300);

    await fires(app);
    await fires(app);
    // Two calls — incidents and detections — for the first ask, and nothing
    // more inside the floor.
    assert.equal(calls.length, 2);
  });

  it("asks for one window of overpasses, clamped", async () => {
    const app = appWith({ ...CLOUD1, TERA_FIRES_DETECTION_HOURS: "9000" });
    after(() => app.close());

    await fires(app);
    const detections = calls.find((c) => c.includes("/detections")) ?? "";
    assert.match(detections, /hours=48/);
  });

  it("is publicly cacheable, because a list of agency records is public", async () => {
    const app = appWith(CLOUD1);
    after(() => app.close());

    const { headers } = await fires(app);
    assert.match(String(headers["cache-control"]), /^public, max-age=/);
  });

  it("is not publicly cached for a caller who arrived with a session", async () => {
    const app = appWith(CLOUD1);
    after(() => app.close());

    const res = await app.inject({
      method: "GET",
      url: "/api/v1/fires",
      headers: { authorization: "Bearer something" },
    });
    assert.equal(res.headers["cache-control"], "private, no-store");
  });
});
