/**
 * What the board is actually saying about fire — including, almost every day,
 * that there is none.
 *
 * ### The empty state is the feature
 *
 * Most days this panel's whole job is to render nothing, convincingly. That
 * sounds like a small ask and it is the hardest sentence in the round to get
 * right, because **a quiet board and a dead feed look identical**. On the day
 * this was written the SoCal board drew zero fires — correctly, nothing was
 * burning in Los Angeles — while twenty-two live incident records sat inside its
 * bounds, every one with no acreage and fifteen of them nameless LA County
 * dispatch numbers. A board that just showed empty terrain would have been right
 * for a reason nobody could see, and would have been indistinguishable from a
 * board whose upstream had been down since Tuesday.
 *
 * So there are **three** empty states here and they are never collapsed into
 * one. This is the same argument `HealthBody.degraded` already makes for a log,
 * applied to a picture:
 *
 *   1. `ageMs === null` — nothing has *ever* answered. That is a fault, and it
 *      says "no fire feed configured", not "no fire".
 *   2. `drawn` empty with `suppressed > 0` — the board is quiet and *n* live
 *      records were refused. That is a finding, and the number is printed,
 *      because "twenty-two records did not meet the threshold" is a far stronger
 *      claim than silence.
 *   3. `drawn` empty with `suppressed === 0` — nothing is happening inside this
 *      frame at all. Also a finding, and a different one.
 *
 * Every one of the three carries the fetch age, because a finding with no
 * timestamp on it is not a finding.
 *
 * ### The board is a crop and says so
 *
 * `california.ts` caps at 38.05 N. Eighteen live incidents are north of that
 * line, and on an ordinary day the largest fire in the whole store is one of
 * them. A board captioned "California" that reads all-clear while 93,733 acres
 * burn off-frame is the cry-wolf failure inverted — worse, in fact, because the
 * viewer has no way to know the frame is the limit rather than the world. So the
 * off-board section is not decoration; it is the sentence that makes the frame
 * honest. It is fed from `FirePromotion.offBoard`, which is gated by the same
 * ladder as `drawn` and capped at three, so it names the largest and never
 * claims to be a complete list.
 *
 * ### Hot pixels are captioned as evidence, every time
 *
 * A satellite detection is a report that one cell of one overpass was warm. The
 * upstream store contains a permanent industrial heat source 4.7 km from its
 * operator's house that reports on every pass, on every day, with no incident
 * behind it. The caption says "evidence, not incidents" and the persistent count
 * is printed beside it, because a reader who can see the number of known flare
 * stacks can calibrate the ones that are not.
 *
 * ### Nothing home-relative reaches this file
 *
 * `observations.distance_km`, `bearing_deg`, `threat` and
 * `detections.distance_km` are computed against the upstream operator's house
 * and invert to a circle around it. They never leave cloud-1, which is the whole
 * design of the feed. This panel is the surface where such a value would
 * eventually be *displayed*, so it is worth writing down here too: there is no
 * distance-to-anything in this file, and a future "how far is that from me"
 * feature is a new coordinate the viewer supplies, never one the feed carries.
 */

// ---- The shape this panel is handed ---------------------------------------

/**
 * One fire, as the panel needs it.
 *
 * A structural subset of `DrawnFire` in `src/server/fires.ts`, restated rather
 * than imported for the same reason the engine restates it: this module is DOM
 * and strings, and it should not pull a wire module in to render a name. A
 * `FirePromotion` is assignable to `FirePanelView` with no adapter.
 */
export interface FirePanelFire {
  id: string;
  name: string | null;
  acres: number;
  pctContained: number | null;
  lat: number;
  lon: number;
  county?: string | null;
  url?: string | null;
  tier?: 1 | 2;
}

export interface FirePanelView {
  /** ISO-8601 of the last **successful** upstream fetch. Epoch zero when never. */
  fetchedAt: string;
  /** Milliseconds since `fetchedAt`, or `null` when nothing has ever answered. */
  ageMs: number | null;
  drawn: readonly FirePanelFire[];
  /** Gated fires outside the frame, largest first. Capped upstream at three. */
  offBoard?: readonly FirePanelFire[];
  /** Live rows inside the frame the gate refused. The number behind a calm board. */
  suppressed?: number;
  /** Hot pixels drawn. Known furniture is not in here; see `persistentDetections`. */
  detections?: readonly unknown[];
  persistentDetections?: number;
  detectionWindowHours?: number;
}

export interface FirePanelOptions {
  /**
   * The board's bounds, so an off-board fire can be described as "north of this
   * frame" rather than as "somewhere else". Optional: without it the direction
   * is omitted rather than guessed.
   */
  bounds?: { minLat: number; maxLat: number; minLng: number; maxLng: number };
  /**
   * Who the incidents came from, for the caption. Defaults to the two agencies
   * the upstream collector actually reads.
   */
  agencies?: string;
}

export interface FirePanelHandle {
  root: HTMLElement;
  apply(view: FirePanelView | null): void;
  dispose(): void;
}

const DEFAULT_AGENCIES = "CAL FIRE and WFIGS";

// ---- Style ----------------------------------------------------------------

/**
 * Exported so the stylesheet test can assert what a Node test cannot see: that
 * nothing in here writes a raw `z-index`, and that the panel uses the shared
 * tokens rather than inventing a second palette.
 */
export const FIRE_PANEL_CSS = `
.tera-fire { display: flex; flex-direction: column; gap: var(--s2, 8px); }
.tera-fire__state {
  margin: 0;
  font-size: 11px;
  line-height: 1.5;
  color: var(--ink, rgba(255,255,255,.78));
}
.tera-fire__state[data-state="fault"] { color: var(--amber-ink, #ffd68a); }
.tera-fire__note {
  margin: 0;
  font-size: 9px;
  line-height: 1.6;
  letter-spacing: .04em;
  color: var(--ink-3, rgba(255,255,255,.4));
}
.tera-fire__list { display: flex; flex-direction: column; gap: var(--s2, 8px); margin: 0; padding: 0; list-style: none; }
.tera-fire__fire {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: var(--s2, 8px) var(--s3, 12px);
  border: 1px solid var(--hairline, rgba(255,255,255,.11));
  border-left: 2px solid rgba(242, 177, 52, 0.55);
  border-radius: var(--r-sm, 5px);
  background: rgba(255, 255, 255, 0.03);
}
.tera-fire__fire[data-tier="2"] { border-left-color: rgba(255, 122, 42, 0.85); }
.tera-fire__name { font-size: 11px; letter-spacing: .04em; color: var(--ink, rgba(255,255,255,.78)); }
.tera-fire__facts {
  font-size: 9px;
  letter-spacing: .06em;
  text-transform: uppercase;
  font-variant-numeric: tabular-nums;
  color: var(--ink-2, rgba(255,255,255,.56));
}
.tera-fire__section {
  margin: 0;
  font-size: 9px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--ink-3, rgba(255,255,255,.4));
}
.tera-fire__off { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 2px; }
.tera-fire__off li {
  font-size: 10px;
  line-height: 1.5;
  font-variant-numeric: tabular-nums;
  color: var(--ink-2, rgba(255,255,255,.56));
}
.tera-fire__evidence {
  margin: 0;
  font-size: 9px;
  line-height: 1.6;
  color: var(--ink-3, rgba(255,255,255,.4));
  border-left: 2px solid rgba(143, 196, 232, 0.45);
  padding-left: var(--s2, 8px);
}
`;

const STYLE_ID = "tera-fire-panel-style";

// ---- Copy, as pure functions ----------------------------------------------

/**
 * "4 minutes ago". The one number that separates a quiet board from a dead one.
 *
 * Coarse on purpose past the hour: this feed polls every ten minutes and an age
 * printed to the second would imply a precision the cache does not have.
 */
export function relativeAge(ageMs: number | null): string {
  if (ageMs === null || !Number.isFinite(ageMs)) return "never";
  const seconds = Math.max(0, Math.round(ageMs / 1000));
  if (seconds < 45) return "just now";
  const minutes = Math.round(seconds / 60);
  if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
  const hours = Math.round(minutes / 60);
  if (hours < 36) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
  const days = Math.round(hours / 24);
  return `${days} day${days === 1 ? "" : "s"} ago`;
}

/** Grouped thousands, without pulling a locale into a test's expectations. */
export function formatAcres(acres: number): string {
  if (!Number.isFinite(acres)) return "—";
  const rounded = acres >= 100 ? Math.round(acres) : Math.round(acres * 10) / 10;
  const [whole, fraction] = String(rounded).split(".");
  const grouped = (whole ?? "0").replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  return fraction === undefined ? grouped : `${grouped}.${fraction}`;
}

/**
 * The headline sentence, and the three cases it must never collapse.
 *
 * Returned as a pair so the caller can tag the fault case for the stylesheet
 * without re-deriving which case it is.
 */
export function fireHeadline(
  view: FirePanelView,
  agencies: string = DEFAULT_AGENCIES,
): { state: "fault" | "quiet" | "active"; text: string } {
  const age = relativeAge(view.ageMs);
  if (view.ageMs === null) {
    return {
      state: "fault",
      text: "No fire feed configured — this board has never heard from one.",
    };
  }
  const drawn = view.drawn.length;
  if (drawn === 0) {
    return { state: "quiet", text: `No active fire on this board — ${agencies}, ${age}.` };
  }
  return {
    state: "active",
    text: `${drawn} active fire${drawn === 1 ? "" : "s"} on this board — ${agencies}, ${age}.`,
  };
}

/**
 * The second line of a quiet board: what the gate refused, in a number.
 *
 * `null` where there is nothing to add. Twenty-two suppressed records is the
 * most informative thing the SoCal board can say on an ordinary day, and it is
 * the sentence that turns "we drew nothing" into "we looked, and here is what we
 * looked at".
 */
export function suppressedNote(view: FirePanelView): string | null {
  const suppressed = view.suppressed ?? 0;
  if (view.ageMs === null) return null;
  if (view.drawn.length > 0 || suppressed <= 0) return null;
  return `${suppressed} live record${suppressed === 1 ? "" : "s"} inside this frame did not meet the threshold: under ten acres or no acreage at all, eighty percent contained or more, or a prescribed burn.`;
}

/** "340 km north of this frame", or `null` where the bounds are not known. */
export function offBoardDirection(
  fire: { lat: number; lon: number },
  bounds?: { minLat: number; maxLat: number; minLng: number; maxLng: number },
): string | null {
  if (bounds === undefined) return null;
  const north = fire.lat - bounds.maxLat;
  const south = bounds.minLat - fire.lat;
  const east = fire.lon - bounds.maxLng;
  const west = bounds.minLng - fire.lon;
  // Longitude is squashed by the cosine of the latitude before the two are
  // compared, or a fire two degrees east of a board at 38 N wins against one two
  // degrees north of it while actually being closer.
  const squash = Math.cos((fire.lat * Math.PI) / 180);
  const options: [string, number][] = [
    ["north", north * 111.32],
    ["south", south * 111.32],
    ["east", east * 111.32 * squash],
    ["west", west * 111.32 * squash],
  ];
  let best: [string, number] | null = null;
  for (const option of options) {
    if (option[1] <= 0) continue;
    if (best === null || option[1] > best[1]) best = option;
  }
  if (best === null) return null;
  return `${Math.round(best[1])} km ${best[0]} of this frame`;
}

/** The hot-pixel caption. Always says "evidence, not incidents". Always. */
export function evidenceCaption(view: FirePanelView): string {
  const count = view.detections?.length ?? 0;
  const hours = view.detectionWindowHours ?? 0;
  const window = hours > 0 ? `, last ${Math.round(hours)} h` : "";
  const persistent = view.persistentDetections ?? 0;
  const head =
    count === 0
      ? `No satellite hot pixels on this board${window}.`
      : `${count} satellite hot pixel${count === 1 ? "" : "s"}${window} — evidence, not incidents.`;
  if (persistent <= 0) return head;
  return `${head} ${persistent} known persistent source${persistent === 1 ? "" : "s"} — flare stacks, kilns, landfills — are counted and not drawn.`;
}

// ---- Mount ----------------------------------------------------------------

export function mountFirePanel(
  host: HTMLElement,
  options: FirePanelOptions = {},
): FirePanelHandle {
  const doc = host.ownerDocument;
  const agencies = options.agencies ?? DEFAULT_AGENCIES;

  const root = doc.createElement("section");
  root.className = "tera-fire";
  root.setAttribute("aria-label", "Fire");
  root.setAttribute("data-state", "fault");

  // One stylesheet per document, not per panel: this is torn down and rebuilt on
  // every board switch, and a `<style>` per mount accumulates one copy of the
  // same rules per city the visitor has looked at.
  const head = doc.head ?? null;
  let styleAdded = false;
  if (head !== null && head.querySelector(`#${STYLE_ID}`) === null) {
    const style = doc.createElement("style");
    style.id = STYLE_ID;
    style.textContent = FIRE_PANEL_CSS;
    head.append(style);
    styleAdded = true;
  }

  const headline = doc.createElement("p");
  headline.className = "tera-fire__state";
  headline.setAttribute("data-role", "headline");

  const stamp = doc.createElement("time");
  stamp.setAttribute("data-role", "fetched");
  stamp.className = "tera-fire__note";

  const note = doc.createElement("p");
  note.className = "tera-fire__note";
  note.setAttribute("data-role", "suppressed");

  const list = doc.createElement("ul");
  list.className = "tera-fire__list";
  list.setAttribute("data-role", "drawn");

  const offHeading = doc.createElement("p");
  offHeading.className = "tera-fire__section";
  offHeading.setAttribute("data-role", "off-board-heading");

  const off = doc.createElement("ul");
  off.className = "tera-fire__off";
  off.setAttribute("data-role", "off-board");

  const evidence = doc.createElement("p");
  evidence.className = "tera-fire__evidence";
  evidence.setAttribute("data-role", "evidence");

  root.append(headline, stamp, note, list, offHeading, off, evidence);
  host.append(root);

  let disposed = false;

  function apply(view: FirePanelView | null): void {
    if (disposed) return;

    if (view === null) {
      root.setAttribute("data-state", "fault");
      headline.setAttribute("data-state", "fault");
      headline.textContent = "No fire feed configured — this board has never heard from one.";
      stamp.textContent = "";
      stamp.removeAttribute("datetime");
      note.textContent = "";
      list.replaceChildren();
      offHeading.textContent = "";
      off.replaceChildren();
      evidence.textContent = "";
      return;
    }

    const head2 = fireHeadline(view, agencies);
    root.setAttribute("data-state", head2.state);
    headline.setAttribute("data-state", head2.state);
    headline.textContent = head2.text;

    // The machine-readable instant sits beside the human one on purpose. "4
    // minutes ago" is what a reader wants; the ISO string is what a screenshot,
    // a bug report and a test can all agree on afterwards.
    if (view.ageMs === null) {
      stamp.textContent = "";
      stamp.removeAttribute("datetime");
    } else {
      stamp.setAttribute("datetime", view.fetchedAt);
      stamp.textContent = `Fetched ${view.fetchedAt} · ${relativeAge(view.ageMs)}`;
    }

    const suppressed = suppressedNote(view);
    note.textContent = suppressed ?? "";

    const fires: HTMLElement[] = [];
    for (const fire of view.drawn) {
      const item = doc.createElement("li");
      item.className = "tera-fire__fire";
      item.setAttribute("data-fire", fire.id);
      if (fire.tier !== undefined) item.setAttribute("data-tier", String(fire.tier));

      const name = doc.createElement("span");
      name.className = "tera-fire__name";
      // An unnamed fire is printed as unnamed. Defaulting to the id would turn
      // an agency's silence into a name, and `{6AD80DE9-…}` is not one.
      name.textContent = fire.name ?? "Unnamed incident";

      const facts = doc.createElement("span");
      facts.className = "tera-fire__facts";
      facts.textContent = factsFor(fire);

      item.append(name, facts);
      fires.push(item);
    }
    list.replaceChildren(...fires);

    const offBoard = view.offBoard ?? [];
    if (offBoard.length === 0) {
      offHeading.textContent = "";
      off.replaceChildren();
    } else {
      offHeading.textContent = "Largest gated fires outside this frame";
      const rows: HTMLElement[] = [];
      for (const fire of offBoard) {
        const row = doc.createElement("li");
        row.setAttribute("data-fire", fire.id);
        const where = offBoardDirection(fire, options.bounds);
        const parts = [
          fire.name ?? "Unnamed incident",
          `${formatAcres(fire.acres)} acres`,
          where ?? "outside this frame",
        ];
        row.textContent = `${parts.join(" — ")}. Listed, not drawn.`;
        rows.push(row);
      }
      off.replaceChildren(...rows);
    }

    evidence.textContent = evidenceCaption(view);
  }

  apply(null);

  return {
    root,
    apply,
    dispose() {
      disposed = true;
      root.remove();
      if (styleAdded) doc.head?.querySelector(`#${STYLE_ID}`)?.remove();
    },
  };
}

/** "7,591 acres · 29% contained", or the honest gap where an agency is silent. */
function factsFor(fire: FirePanelFire): string {
  const parts = [`${formatAcres(fire.acres)} acres`];
  // `null` containment means "the agency has not said", which is not zero, and
  // printing "0% contained" would put a claim in the reader's head that nobody
  // upstream made.
  parts.push(
    fire.pctContained === null || fire.pctContained === undefined
      ? "containment not reported"
      : `${Math.round(fire.pctContained)}% contained`,
  );
  if (fire.county !== null && fire.county !== undefined && fire.county !== "") {
    parts.push(fire.county);
  }
  return parts.join(" · ");
}
