/**
 * The fire panel, and the three empty states it must never collapse.
 *
 * A silent board and a dead feed look identical. That is the whole problem this
 * panel exists to solve, and it is why "the board is empty" is not one sentence
 * but three:
 *
 *   - nothing has ever answered → a **fault**;
 *   - nothing is drawn but *n* live records were refused → a **finding**, and
 *     the number is the finding;
 *   - nothing is drawn and nothing was refused → a different finding.
 *
 * The real body underneath these tests is `LIVE_FIRES_BODY`, captured through
 * the whole wire on an ordinary day. Clipped to the SoCal board it yields the
 * second case exactly: zero drawn, twenty-two suppressed. Clipped to California
 * it yields five fires and names the largest one the frame cannot show, because
 * `california.ts` caps at 38.05 N and the state does not.
 */

import assert from "node:assert/strict";
import { describe, it } from "node:test";

import {
  evidenceCaption,
  fireHeadline,
  formatAcres,
  mountFirePanel,
  offBoardDirection,
  relativeAge,
  suppressedNote,
  type FirePanelView,
} from "../../ui/firePanel.ts";
import { promote, type FirePromotion } from "../../server/fires.ts";
import { LIVE_FIRES_BODY } from "../data/firesFixture.ts";
import { FakeDocument, type FakeElement } from "./fakeDom.ts";

const CALIFORNIA = { minLat: 32.55, maxLat: 38.05, minLng: -123.05, maxLng: -114.0 };
const SOCAL = { minLat: 33.28, maxLat: 34.36, minLng: -118.88, maxLng: -117.22 };

/** Four minutes after the fixture's own `fetchedAt`, so the copy reads literally. */
const NOW = Date.parse("2026-08-22T22:26:35.806Z");

/**
 * `FirePromotion` must reach the panel with no adapter, exactly as it reaches
 * the renderer. Asserted at compile time: a renamed field on the gate stops the
 * build rather than quietly emptying a caption.
 */
const _assignable: (p: FirePromotion) => FirePanelView = (p) => p;
void _assignable;

function setup(bounds?: typeof CALIFORNIA) {
  const doc = new FakeDocument();
  const host = doc.createElement("div");
  doc.body.append(host);
  const panel = mountFirePanel(host as unknown as HTMLElement, { bounds });
  return { doc, panel, root: panel.root as unknown as FakeElement };
}

function part(root: FakeElement, role: string): FakeElement {
  const found = root.querySelector(`[data-role=${role}]`);
  assert.ok(found, `no element for role ${role}`);
  return found;
}

function text(root: FakeElement, role: string): string {
  return part(root, role).textContent ?? "";
}

// ---- The three empty states -----------------------------------------------

describe("the empty board", () => {
  it("calls a feed that has never answered a fault, not a calm day", () => {
    const { panel, root } = setup();
    panel.apply({ fetchedAt: new Date(0).toISOString(), ageMs: null, drawn: [], suppressed: 0 });

    assert.equal(root.getAttribute("data-state"), "fault");
    assert.match(text(root, "headline"), /no fire feed configured/i);
    assert.doesNotMatch(
      text(root, "headline"),
      /no active fire/i,
      "a dead feed must never report an all-clear",
    );
    // No age, because there is no fetch to be aged. A "1970" stamp would be a
    // number that looks like an answer.
    assert.equal(text(root, "fetched"), "");
    panel.dispose();
  });

  it("states the quiet SoCal board with its fetch age and the number refused", () => {
    const gated = promote(LIVE_FIRES_BODY, SOCAL, NOW);
    assert.equal(gated.drawn.length, 0);
    assert.equal(gated.suppressed, 22);

    const { panel, root } = setup(SOCAL);
    panel.apply(gated);

    assert.equal(root.getAttribute("data-state"), "quiet");
    assert.equal(
      text(root, "headline"),
      "No active fire on this board — CAL FIRE and WFIGS, 4 minutes ago.",
    );
    assert.match(text(root, "suppressed"), /^22 live records inside this frame/);
    assert.match(text(root, "suppressed"), /prescribed burn/);
    // The machine-readable instant is on the page beside the human one.
    assert.equal(part(root, "fetched").getAttribute("datetime"), LIVE_FIRES_BODY.fetchedAt);
    assert.ok(text(root, "fetched").includes(LIVE_FIRES_BODY.fetchedAt));
    assert.equal(part(root, "drawn").children.length, 0);
    panel.dispose();
  });

  it("does not print a suppressed count when nothing was suppressed", () => {
    const { panel, root } = setup();
    panel.apply({
      fetchedAt: "2026-08-22T22:22:35.806Z",
      ageMs: 240_000,
      drawn: [],
      suppressed: 0,
    });
    assert.match(text(root, "headline"), /No active fire on this board/);
    assert.equal(text(root, "suppressed"), "", "nothing refused is a different fact from 22");
    panel.dispose();
  });

  it("renders the fault sentence before anything has been applied", () => {
    const { panel, root } = setup();
    assert.match(text(root, "headline"), /no fire feed configured/i);
    panel.dispose();
  });
});

// ---- The truthful full board ----------------------------------------------

describe("the California board", () => {
  it("names the five fires the gate admitted, worst first", () => {
    const gated = promote(LIVE_FIRES_BODY, CALIFORNIA, NOW);
    const { panel, root } = setup(CALIFORNIA);
    panel.apply(gated);

    assert.equal(root.getAttribute("data-state"), "active");
    assert.match(text(root, "headline"), /^5 active fires on this board/);

    const rows = part(root, "drawn").children;
    assert.equal(rows.length, 5);
    const names = rows.map((row) => row.querySelector(".tera-fire__name")?.textContent);
    assert.deepEqual(names, [
      "Timber Fire",
      "Alpaugh Fire",
      "Carrizo Fire",
      "Amber Fire",
      "GREEN",
    ]);

    const timber = rows[0];
    assert.ok(timber);
    assert.equal(timber.getAttribute("data-tier"), "2");
    const facts = timber.querySelector(".tera-fire__facts")?.textContent ?? "";
    assert.ok(facts.includes("7,591 acres"), facts);
    assert.ok(facts.includes("29% contained"), facts);
    panel.dispose();
  });

  it("says an agency has not reported containment rather than saying zero", () => {
    const gated = promote(LIVE_FIRES_BODY, CALIFORNIA, NOW);
    const { panel, root } = setup(CALIFORNIA);
    panel.apply(gated);
    // Carrizo, Amber and GREEN all carry `pctContained: null` on this day.
    const carrizo = part(root, "drawn").children[2];
    assert.ok(carrizo);
    const facts = carrizo.querySelector(".tera-fire__facts")?.textContent ?? "";
    assert.ok(facts.includes("containment not reported"), facts);
    assert.doesNotMatch(facts, /0% contained/);
    panel.dispose();
  });

  it("names what the frame cannot show, and says it is not drawn", () => {
    // `california.ts` caps at 38.05 N. MP18 is at 41.13 N with 7,610 acres.
    const gated = promote(LIVE_FIRES_BODY, CALIFORNIA, NOW);
    assert.equal(gated.offBoard[0]?.name, "MP18 Fire");

    const { panel, root } = setup(CALIFORNIA);
    panel.apply(gated);

    assert.match(text(root, "off-board-heading"), /outside this frame/i);
    const rows = part(root, "off-board").children;
    assert.ok(rows.length > 0 && rows.length <= 3, "capped upstream at three");
    const first = rows[0]?.textContent ?? "";
    assert.ok(first.includes("MP18 Fire"), first);
    assert.ok(first.includes("7,610 acres"), first);
    assert.match(first, /\d+ km north of this frame/);
    assert.match(first, /Listed, not drawn/);
    panel.dispose();
  });

  it("does not name Bug Fire, which is 93,733 acres and 94 % contained", () => {
    // The off-board list is gated by the same ladder as the drawn set, or it
    // reintroduces exactly the noise the ladder exists to remove.
    const gated = promote(LIVE_FIRES_BODY, CALIFORNIA, NOW);
    const { panel, root } = setup(CALIFORNIA);
    panel.apply(gated);
    assert.doesNotMatch(text(root, "off-board"), /Bug Fire/);
    panel.dispose();
  });

  it("leaves the off-board section empty when the gate found nothing outside", () => {
    const { panel, root } = setup(CALIFORNIA);
    panel.apply({
      fetchedAt: "2026-08-22T22:22:35.806Z",
      ageMs: 240_000,
      drawn: [],
      offBoard: [],
      suppressed: 3,
    });
    assert.equal(text(root, "off-board-heading"), "");
    assert.equal(part(root, "off-board").children.length, 0);
    panel.dispose();
  });
});

// ---- Hot pixels are captioned as evidence ---------------------------------

describe("the hot-pixel caption", () => {
  it("always says evidence, not incidents", () => {
    const gated = promote(LIVE_FIRES_BODY, CALIFORNIA, NOW);
    const { panel, root } = setup(CALIFORNIA);
    panel.apply(gated);
    const caption = text(root, "evidence");
    assert.match(caption, /evidence, not incidents/);
    assert.match(caption, /last 24 h/);
    assert.match(caption, /persistent sources/);
    assert.ok(caption.includes(String(gated.persistentDetections)), caption);
    panel.dispose();
  });

  it("says so when there are none, rather than saying nothing", () => {
    assert.match(
      evidenceCaption({ fetchedAt: "", ageMs: 0, drawn: [], detections: [] }),
      /No satellite hot pixels/,
    );
  });

  it("omits the persistent clause when there is no furniture to report", () => {
    const caption = evidenceCaption({
      fetchedAt: "",
      ageMs: 0,
      drawn: [],
      detections: [{}, {}],
      persistentDetections: 0,
      detectionWindowHours: 24,
    });
    assert.match(caption, /2 satellite hot pixels, last 24 h — evidence, not incidents\./);
    assert.doesNotMatch(caption, /persistent/);
  });
});

// ---- The pure copy --------------------------------------------------------

describe("the panel's copy", () => {
  it("ages a fetch coarsely, because the feed polls every ten minutes", () => {
    assert.equal(relativeAge(null), "never");
    assert.equal(relativeAge(1_000), "just now");
    assert.equal(relativeAge(60_000), "1 minute ago");
    assert.equal(relativeAge(240_000), "4 minutes ago");
    assert.equal(relativeAge(3 * 3_600_000), "3 hours ago");
    assert.equal(relativeAge(4 * 86_400_000), "4 days ago");
  });

  it("groups acres and keeps a small fire's tenths", () => {
    assert.equal(formatAcres(93_733), "93,733");
    assert.equal(formatAcres(7_591), "7,591");
    assert.equal(formatAcres(268.1), "268");
    assert.equal(formatAcres(19.5), "19.5");
    assert.equal(formatAcres(Number.NaN), "—");
  });

  it("keeps the three headline cases apart", () => {
    const base = { fetchedAt: "2026-08-22T22:22:35.806Z", drawn: [] };
    assert.equal(fireHeadline({ ...base, ageMs: null }).state, "fault");
    assert.equal(fireHeadline({ ...base, ageMs: 0, suppressed: 22 }).state, "quiet");
    assert.equal(
      fireHeadline({
        ...base,
        ageMs: 0,
        drawn: [{ id: "a", name: "A", acres: 500, pctContained: null, lat: 36, lon: -120 }],
      }).state,
      "active",
    );
  });

  it("prints one fire in the singular", () => {
    const headline = fireHeadline({
      fetchedAt: "2026-08-22T22:22:35.806Z",
      ageMs: 60_000,
      drawn: [{ id: "a", name: "A", acres: 500, pctContained: null, lat: 36, lon: -120 }],
    });
    assert.equal(headline.text, "1 active fire on this board — CAL FIRE and WFIGS, 1 minute ago.");
  });

  it("keeps the suppressed note off an active board", () => {
    assert.equal(
      suppressedNote({
        fetchedAt: "",
        ageMs: 0,
        suppressed: 40,
        drawn: [{ id: "a", name: "A", acres: 500, pctContained: null, lat: 36, lon: -120 }],
      }),
      null,
      "the number behind a calm board is not a number about a burning one",
    );
    assert.equal(
      suppressedNote({ fetchedAt: "", ageMs: null, suppressed: 40, drawn: [] }),
      null,
      "a feed that never answered has suppressed nothing",
    );
  });

  it("picks the direction a fire actually lies in, longitude squashed", () => {
    assert.equal(offBoardDirection({ lat: 41.13, lon: -123.68 }), null, "no bounds, no claim");
    assert.match(
      offBoardDirection({ lat: 41.13, lon: -123.68 }, CALIFORNIA) ?? "",
      /^34[0-9] km north of this frame$/,
    );
    assert.match(
      offBoardDirection({ lat: 33.9, lon: -117.1 }, SOCAL) ?? "",
      /km east of this frame$/,
    );
    assert.equal(
      offBoardDirection({ lat: 34.0, lon: -118.0 }, SOCAL),
      null,
      "a point inside the bounds is not outside them",
    );
  });
});

// ---- Lifecycle ------------------------------------------------------------

describe("the panel's lifecycle", () => {
  it("mounts one stylesheet per document, not one per board", () => {
    const doc = new FakeDocument();
    const a = doc.createElement("div");
    const b = doc.createElement("div");
    doc.body.append(a);
    doc.body.append(b);
    const first = mountFirePanel(a as unknown as HTMLElement);
    const second = mountFirePanel(b as unknown as HTMLElement);
    assert.equal(doc.head.querySelectorAll("style").length, 1);
    first.dispose();
    second.dispose();
  });

  it("goes quiet after dispose rather than throwing", () => {
    const { panel, root } = setup();
    panel.dispose();
    panel.apply({ fetchedAt: "2026-08-22T22:22:35.806Z", ageMs: 0, drawn: [], suppressed: 1 });
    assert.match(text(root, "headline"), /no fire feed configured/i);
  });

  it("falls back to the fault sentence on a null view", () => {
    const gated = promote(LIVE_FIRES_BODY, CALIFORNIA, NOW);
    const { panel, root } = setup(CALIFORNIA);
    panel.apply(gated);
    assert.equal(part(root, "drawn").children.length, 5);
    panel.apply(null);
    assert.equal(part(root, "drawn").children.length, 0);
    assert.match(text(root, "headline"), /no fire feed configured/i);
    assert.equal(text(root, "evidence"), "");
    panel.dispose();
  });

  it("never prints an unnamed incident's id as its name", () => {
    const { panel, root } = setup();
    panel.apply({
      fetchedAt: "2026-08-22T22:22:35.806Z",
      ageMs: 0,
      drawn: [
        {
          id: "{6AD80DE9-CD41-40D3-8939-A86CCF775981}",
          name: null,
          acres: 500,
          pctContained: null,
          lat: 36,
          lon: -120,
        },
      ],
    });
    const row = part(root, "drawn").children[0];
    assert.ok(row);
    assert.equal(row.querySelector(".tera-fire__name")?.textContent, "Unnamed incident");
    panel.dispose();
  });

  it("writes no raw z-index", async () => {
    const { FIRE_PANEL_CSS } = await import("../../ui/firePanel.ts");
    assert.deepEqual(FIRE_PANEL_CSS.match(/z-index:\s*\d/g) ?? [], []);
  });
});
