/* ============================================================
   Airport Cyber Resilience Simulator V2 — Frontend
   React (htm, no build step) + GSAP + Web Audio API.
   ============================================================ */
const html = htm.bind(React.createElement);
const { useState, useEffect, useRef, useCallback } = React;

/* ============================================================
   AUDIO ENGINE — synthesised command-center soundscape.
   No binary assets: everything is generated with Web Audio,
   so the container stays fully self-contained/offline.
   ============================================================ */
class AudioEngine {
  constructor() { this.ctx = null; this.muted = false; this._ring = null; }
  _ensure() {
    if (!this.ctx) this.ctx = new (window.AudioContext || window.webkitAudioContext)();
    if (this.ctx.state === "suspended") this.ctx.resume();
    return this.ctx;
  }
  setMuted(m) { this.muted = m; if (m) this.stopRing(); }
  _noiseBuffer(dur) {
    const ctx = this.ctx, n = Math.floor(ctx.sampleRate * dur), buf = ctx.createBuffer(1, n, ctx.sampleRate), d = buf.getChannelData(0);
    for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
    return buf;
  }
  _tone(freq, t0, dur, type = "sine", peak = 0.3, glideTo = null) {
    const ctx = this.ctx, o = ctx.createOscillator(), g = ctx.createGain();
    o.type = type; o.frequency.setValueAtTime(freq, t0);
    if (glideTo) o.frequency.exponentialRampToValueAtTime(glideTo, t0 + dur);
    g.gain.setValueAtTime(0.0001, t0);
    g.gain.exponentialRampToValueAtTime(peak, t0 + Math.min(0.03, dur * 0.3));
    g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
    o.connect(g).connect(ctx.destination); o.start(t0); o.stop(t0 + dur + 0.02);
  }
  _noise(t0, dur, peak = 0.2, band = null) {
    const ctx = this.ctx, s = ctx.createBufferSource(); s.buffer = this._noiseBuffer(dur);
    const g = ctx.createGain(); g.gain.setValueAtTime(peak, t0); g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
    let node = s;
    if (band) { const f = ctx.createBiquadFilter(); f.type = "bandpass"; f.frequency.value = band; s.connect(f); node = f; }
    node.connect(g).connect(ctx.destination); s.start(t0); s.stop(t0 + dur + 0.02);
  }
  play(name) {
    if (this.muted) return;
    const ctx = this._ensure(), t = ctx.currentTime;
    switch (name) {
      case "boot_swell": this._tone(40, t, 1.6, "sine", 0.35, 120); this._tone(80, t, 1.6, "sine", 0.18, 240); break;
      case "countdown_tick": this._tone(150, t, 0.14, "square", 0.28, 90); break;
      case "ui_tap": this._tone(820, t, 0.012, "square", 0.12); break;
      case "ingress_thud": this._tone(60, t, 0.7, "sine", 0.5, 28); this._noise(t, 0.25, 0.25, 120); break;
      case "line_crackle": this._noise(t, 1.0, 0.14, 2200); break;
      case "relay_click": this._noise(t, 0.05, 0.3, 1600); this._tone(200, t, 0.04, "square", 0.15); break;
      case "email_chirp": this._tone(1400, t, 0.06, "sine", 0.14); this._tone(1750, t + 0.09, 0.06, "sine", 0.14); break;
    }
  }
  startRing() {
    if (this.muted || this._ring) return;
    const ctx = this._ensure();
    const loop = () => {
      if (!this._ring) return;
      const t = ctx.currentTime;
      this._tone(480, t, 0.18, "sine", 0.16); this._tone(620, t + 0.22, 0.18, "sine", 0.16);
    };
    loop(); this._ring = setInterval(loop, 1400);
  }
  stopRing() { if (this._ring) { clearInterval(this._ring); this._ring = null; } }
}
const AUDIO = new AudioEngine();

/* ============================================================
   HELPERS
   ============================================================ */
const money = (n) => {
  n = Number(n) || 0;
  if (Math.abs(n) >= 1e6) return "£" + (n / 1e6).toFixed(2) + "M";
  if (Math.abs(n) >= 1e3) return "£" + (n / 1e3).toFixed(0) + "k";
  return "£" + Math.round(n);
};
const fullMoney = (n) => "£" + Math.round(Number(n) || 0).toLocaleString("en-GB");
const CAP = { normal: 100, ingress: 100, compromised: 70, throttled: 40, isolated: 0, patching: 70, patched: 100 };
// Nodes whose in-circle icon gets a continuous animation.
const ANIM_ICONS = new Set(["atc_tower", "airplane_comms", "ev_charging_grid", "public_wifi"]);
const STATUS_LABEL = { normal: "NORMAL", ingress: "TARGETED", compromised: "COMPROMISED", throttled: "THROTTLED", isolated: "ISOLATED", patching: "PATCHING", patched: "PATCHED" };

/* ============================================================
   ROOT APP
   ============================================================ */
function App() {
  const [boot, setBoot] = useState(null);           // bootstrap data
  const [screen, setScreen] = useState("config");    // config | dashboard | attract
  const [airportId, setAirportId] = useState(null);
  const [audioOn, setAudioOn] = useState(true);
  const [scale, setScale] = useState(1);

  const stageRef = useRef(null);
  const wsRef = useRef(null);
  const evtRef = useRef(null);   // event handler set by Dashboard

  // stage scaling
  useEffect(() => {
    const fit = () => setScale(Math.min(window.innerWidth / 1920, window.innerHeight / 1080));
    fit(); window.addEventListener("resize", fit); return () => window.removeEventListener("resize", fit);
  }, []);
  useEffect(() => { if (stageRef.current) stageRef.current.style.transform = `scale(${scale})`; }, [scale]);

  // bootstrap
  useEffect(() => { fetch("/api/bootstrap").then(r => r.json()).then(setBoot); }, []);

  // websocket (single connection, lives for whole session)
  useEffect(() => {
    let alive = true;
    const connect = () => {
      const proto = location.protocol === "https:" ? "wss" : "ws";
      const ws = new WebSocket(`${proto}://${location.host}/ws`);
      wsRef.current = ws;
      ws.onmessage = (e) => { const m = JSON.parse(e.data); if (evtRef.current) evtRef.current(m); };
      ws.onclose = () => { if (alive) setTimeout(connect, 1200); };
    };
    connect();
    return () => { alive = false; wsRef.current && wsRef.current.close(); };
  }, []);

  const send = useCallback((o) => { const ws = wsRef.current; if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); }, []);

  useEffect(() => { AUDIO.setMuted(!audioOn); }, [audioOn]);

  const initialize = (aid, aud) => {
    setAirportId(aid); setAudioOn(aud); AUDIO.setMuted(!aud);
    AUDIO.play("boot_swell");
    send({ action: "configure", airport_id: aid, audio: aud });
    setScreen("dashboard");
  };

  if (!boot) return html`<div id="stage-wrap"><div style=${{ color: "var(--cyan)", fontFamily: "Orbitron" }}>INITIALISING…</div></div>`;

  return html`
    <div id="stage-wrap">
      <div id="stage" ref=${stageRef}>
        ${screen === "config" && html`<${ConfigScreen} boot=${boot} onInit=${initialize} />`}
        ${screen === "dashboard" && html`<${Dashboard} boot=${boot} airportId=${airportId}
            audioOn=${audioOn} setAudioOn=${setAudioOn} send=${send} evtRef=${evtRef}
            toConfig=${() => { send({ action: "reset" }); setScreen("config"); }}
            toAttract=${() => { send({ action: "reset" }); setScreen("attract"); }} />`}
        ${screen === "attract" && html`<${AttractScreen} onBegin=${() => setScreen("config")} />`}
      </div>
    </div>`;
}

/* ============================================================
   CONFIG SCREEN
   ============================================================ */
function ConfigScreen({ boot, onInit }) {
  const [sel, setSel] = useState(null);
  const [aud, setAud] = useState(true);
  const airports = boot.airports.airports;
  const tap = () => AUDIO.play("ui_tap");
  return html`
    <div id="config">
      <div class="cfg-inner">
        <svg viewBox="0 0 48 48" width="72" height="72" class="cfg-badge"><use href="#icon-shield"/></svg>
        <h1>AIRPORT CYBER RESILIENCE SIMULATOR</h1>
        <h2>V2 · COMMAND CENTER CONFIGURATION</h2>
        <div class="cfg-panel">
          <div class="cfg-label">Select UK Passenger Airport</div>
          <div class="airport-list">
            ${airports.map(a => html`
              <div key=${a.airport_id} class=${"airport-item" + (sel === a.airport_id ? " sel" : "")}
                   onClick=${() => { tap(); setSel(a.airport_id); }}>
                <div class="code">${a.airport_id}</div>
                <div><div class="an">${a.name}</div><div class="at">Tier ${a.tier} · ${a.daily_passengers.toLocaleString()} pax/day</div></div>
              </div>`)}
          </div>
          <div class="cfg-row">
            <div class="cfg-audio">
              <div class=${"toggle" + (aud ? " on" : "")} onClick=${() => { tap(); setAud(v => !v); }}><div class="knob"></div></div>
              <span>Enable Simulation Audio <b style=${{ color: aud ? "var(--green)" : "var(--text-dim)" }}>${aud ? "ON" : "OFF"}</b></span>
            </div>
            <button class="cfg-init" disabled=${!sel} onClick=${() => { tap(); onInit(sel, aud); }}>INITIALIZE SIMULATOR</button>
          </div>
        </div>
      </div>
    </div>`;
}

/* ============================================================
   ATTRACT SCREEN
   ============================================================ */
function AttractScreen({ onBegin }) {
  return html`
    <div id="attract">
      <div class="attract-inner">
        <svg viewBox="0 0 48 48" width="90" height="90" class="attract-shield"><use href="#icon-shield"/></svg>
        <h1>AIRPORT CYBER RESILIENCE SIMULATOR</h1>
        <p>Watch a single point of ingress cascade across critical airport infrastructure —<br/>and weigh the operational, financial and reputational cost of every decision.</p>
        <button class="attract-start" onClick=${() => { AUDIO.play("ui_tap"); onBegin(); }}>TAP TO BEGIN</button>
      </div>
    </div>`;
}

/* ============================================================
   DASHBOARD
   ============================================================ */
function Dashboard({ boot, airportId, audioOn, setAudioOn, send, evtRef, toConfig, toAttract }) {
  const infra = boot.infrastructure, playbooks = boot.playbooks;
  const airport = boot.airports.airports.find(a => a.airport_id === airportId);
  const nodeById = useRef(Object.fromEntries(infra.nodes.map(n => [n.id, n]))).current;

  const [nodeStatus, setNodeStatus] = useState(() => Object.fromEntries(infra.nodes.map(n => [n.id, "normal"])));
  const [nodeVector, setNodeVector] = useState({});
  const [hotLinks, setHotLinks] = useState(() => new Set());
  const [metrics, setMetrics] = useState({ operational: 0, regulatory: 0, mitigation: 0, total: 0, bleed_per_sec: 0, reputational_multiplier: 1, security_integrity: 100, operational_capacity: 100 });
  const [log, setLog] = useState([]);
  const [selPlaybook, setSelPlaybook] = useState(null);
  const [running, setRunning] = useState(false);
  const [clock, setClock] = useState(0);
  const [countdown, setCountdown] = useState(null);
  const [ingress, setIngress] = useState(null);        // {node,icon}
  const [fines, setFines] = useState([]);              // {id,node,label}
  const [pulses, setPulses] = useState([]);            // {id,x1,y1,x2,y2}
  const [patchRing, setPatchRing] = useState(null);    // {node,seconds}
  const [approach, setApproach] = useState(null);      // {source,target} attack closing in during a patch
  const [overlay, setOverlay] = useState(null);        // node id
  const [comms, setComms] = useState({ phase: "idle", message: "" });
  const [scenario, setScenario] = useState({ title: "SELECT A PLAYBOOK", sub: "Choose an attack scenario, then press Start Simulation." });
  const [notify, setNotify] = useState({ reqCaa: false, reqDft: false, doneCaa: false, doneDft: false, reasonCaa: "", reasonDft: "" });
  const [banner, setBanner] = useState(null);   // end-of-sim banner (e.g. Attack Contained)

  const idleRef = useRef(null);
  const commsTimerRef = useRef(null);
  const uid = useRef(0);

  const addLog = useCallback((kind, text) => {
    setLog(l => [{ id: ++uid.current, t: (window.__simT || 0).toFixed(1), kind, text }, ...l].slice(0, 60));
  }, []);

  /* ---- idle → attract after 90s ---- */
  const resetIdle = useCallback(() => {
    if (idleRef.current) clearTimeout(idleRef.current);
    idleRef.current = setTimeout(() => toAttract(), 90000);
  }, [toAttract]);
  useEffect(() => {
    resetIdle();
    const h = () => resetIdle();
    ["pointerdown", "keydown", "mousemove"].forEach(e => window.addEventListener(e, h, { passive: true }));
    return () => { ["pointerdown", "keydown", "mousemove"].forEach(e => window.removeEventListener(e, h)); if (idleRef.current) clearTimeout(idleRef.current); };
  }, [resetIdle]);

  /* ---- floating fine helper ---- */
  const spawnFine = useCallback((node, label) => {
    const id = ++uid.current;
    setFines(f => [...f, { id, node, label }]);
    setTimeout(() => setFines(f => f.filter(x => x.id !== id)), 2400);
  }, []);

  /* ---- spread particle helper ---- */
  const spawnPulse = useCallback((src, tgt) => {
    const a = nodeById[src], b = nodeById[tgt]; if (!a || !b) return;
    const id = ++uid.current;
    setPulses(p => [...p, { id, x1: a.x, y1: a.y, x2: b.x, y2: b.y }]);
    setTimeout(() => setPulses(p => p.filter(x => x.id !== id)), 1300);
  }, [nodeById]);

  /* ---- WebSocket event handler ---- */
  useEffect(() => {
    evtRef.current = (m) => {
      switch (m.type) {
        case "config_ok": break;
        case "metrics": setMetrics(m.metrics); if (m.metrics.clock != null) { setClock(m.metrics.clock); window.__simT = m.metrics.clock; } break;
        case "tick": setClock(m.t); window.__simT = m.t; break;
        case "launch":
          setScenario({ title: m.playbook.name.toUpperCase(), sub: m.playbook.description });
          addLog("mit", `Scenario launched: ${m.playbook.name}`); break;
        case "ingress":
          setIngress({ node: m.node, icon: m.icon });
          setNodeStatus(s => ({ ...s, [m.node]: "ingress" }));
          addLog("ingress", `⚠ INGRESS at ${nodeById[m.node].name} — ${m.method}`); break;
        case "compromised":
          setIngress(ing => (ing && m.initial ? null : ing));
          setNodeStatus(s => ({ ...s, [m.node]: "compromised" }));
          setNodeVector(v => ({ ...v, [m.node]: m.vector }));
          addLog("compromise", `${m.initial ? "☣ INITIAL COMPROMISE" : "✖ COMPROMISED"}: ${nodeById[m.node].name}`); break;
        case "spread":
          setHotLinks(h => new Set(h).add(`${m.source}|${m.target}`).add(`${m.target}|${m.source}`));
          spawnPulse(m.source, m.target);
          AUDIO.play("line_crackle");
          addLog("spread", `↷ LATERAL MOVE: ${nodeById[m.source].name} → ${nodeById[m.target].name} (${m.vector})`); break;
        case "spread_blocked": addLog("good", `Spread to ${nodeById[m.target].name} blocked.`); break;
        case "fine": spawnFine(m.node, m.label); addLog("fine", `${m.label} — ${nodeById[m.node].name}`); break;
        case "mitigation":
          setNodeStatus(s => ({ ...s, [m.node]: m.option === "ISOLATE" ? "isolated" : m.option === "THROTTLE" ? "throttled" : s[m.node] }));
          if (m.option === "ISOLATE" && m.node === "runway_lighting") AUDIO.play("relay_click");
          addLog("mit", `🛡 ${m.message}`); break;
        case "patch_start":
          setNodeStatus(s => ({ ...s, [m.node]: "patching" }));
          setPatchRing({ node: m.node, seconds: m.seconds });
          if (m.target) setApproach({ source: m.node, target: m.target });
          addLog("mit", `⏳ ${m.message}`);
          AUDIO.play("line_crackle"); break;
        case "patch_complete":
          setPatchRing(null); setApproach(null);
          setNodeStatus(s => ({ ...s, [m.node]: m.success ? "patched" : "compromised" }));
          addLog(m.success ? "good" : "compromise", m.success ? `✔ Patch beat the attack on ${nodeById[m.node].name} — spread repelled.` : `✖ Patch too slow on ${nodeById[m.node].name} — the attack reached the next node.`); break;
        case "comms_state":
          setComms({ phase: m.phase, message: m.message });
          addLog("comms", `📣 ${m.message}`);
          if (m.phase === "reactive") { AUDIO.play("email_chirp"); AUDIO.startRing(); }
          else AUDIO.stopRing();
          break;
        case "notify_required":
          setNotify({ reqCaa: m.caa, reqDft: m.dft, doneCaa: false, doneDft: false, reasonCaa: m.caa_reason, reasonDft: m.dft_reason });
          if (m.caa || m.dft) {
            const who = [m.caa ? "CAA" : null, m.dft ? "DfT" : null].filter(Boolean).join(" & ");
            addLog("comms", `📋 Statutory duty: notify ${who} for this incident.`);
          } else {
            addLog("mit", "📋 No external regulator notification required for this incident type.");
          }
          break;
        case "notify_ack":
          if (m.status === "done") {
            setNotify(n => ({ ...n, [m.kind === "caa" ? "doneCaa" : "doneDft"]: true }));
            addLog("good", `📋 ${m.message}`);
          } else { addLog("mit", `📋 ${m.message}`); }
          break;
        case "notify_missed": addLog("fine", `⚖ ${m.message}`); break;
        case "contained":
          setRunning(false); AUDIO.stopRing();
          setBanner({ title: "ATTACK CONTAINED", sub: "Spread halted — no further nodes compromised." });
          addLog("good", `✔ ${m.message} — spread halted, no further compromise.`);
          break;
        case "sim_complete":
          setRunning(false);
          addLog("mit", "Playbook timeline complete.");
          break;
        case "reset":
          setNodeStatus(Object.fromEntries(infra.nodes.map(n => [n.id, "normal"])));
          setHotLinks(new Set()); setIngress(null); setFines([]); setPulses([]); setPatchRing(null);
          setComms({ phase: "idle", message: "" }); setRunning(false); setClock(0); AUDIO.stopRing();
          setNotify({ reqCaa: false, reqDft: false, doneCaa: false, doneDft: false, reasonCaa: "", reasonDft: "" });
          setBanner(null); setApproach(null); break;
      }
    };
    return () => { evtRef.current = null; };
  }, [addLog, nodeById, infra.nodes, spawnFine, spawnPulse]);

  /* ---- countdown driver ---- */
  useEffect(() => {
    if (countdown === null) return;
    if (countdown === 0) {
      AUDIO.play("ingress_thud");
      const t = setTimeout(() => { setCountdown(null); setRunning(true); send({ action: "launch", playbook_id: selPlaybook }); }, 700);
      return () => clearTimeout(t);
    }
    AUDIO.play("countdown_tick");
    const t = setTimeout(() => setCountdown(c => c - 1), 1000);
    return () => clearTimeout(t);
  }, [countdown, selPlaybook, send]);

  const startSim = () => {
    if (!selPlaybook || running || countdown !== null) return;
    AUDIO.play("ui_tap");
    send({ action: "reset" });
    setLog([]); setClock(0); window.__simT = 0; setBanner(null);
    setCountdown(5);
  };

  const doReset = () => { AUDIO.play("ui_tap"); AUDIO.stopRing(); send({ action: "reset" }); toConfig(); };
  const mitigate = (node, option) => { AUDIO.play("ui_tap"); send({ action: "mitigate", node, option }); if (option !== "STOP") setOverlay(null); };
  const doComms = (kind) => { AUDIO.play("ui_tap"); send({ action: "comms", kind }); };
  const doNotify = (kind) => { AUDIO.play("ui_tap"); send({ action: "notify", kind }); };

  // auto-dismiss the end-of-sim banner
  useEffect(() => { if (!banner) return; const t = setTimeout(() => setBanner(null), 6000); return () => clearTimeout(t); }, [banner]);

  /* ---- odometer ---- */
  const finRef = useRef(null); const finDisplay = useRef(0);
  useEffect(() => {
    const el = finRef.current; if (!el) return;
    const cls = (v) => "fin-main " + (v > 2.5e6 ? "red" : v > 5e5 ? "amber" : "");
    // Guaranteed-correct value even if rAF/GSAP is throttled (e.g. hidden tab).
    el.textContent = fullMoney(metrics.total); el.className = cls(metrics.total);
    // Rolling odometer flourish on top when the page is actually visible.
    const obj = { v: finDisplay.current };
    gsap.to(obj, { v: metrics.total, duration: 0.8, ease: "power2.out",
      onUpdate() { el.textContent = fullMoney(obj.v); finDisplay.current = obj.v; el.className = cls(obj.v); },
      onComplete() { finDisplay.current = metrics.total; } });
  }, [metrics.total]);

  const runwayStatus = nodeStatus["runway_lighting"];

  return html`
    <div id="dash">
      <${Sidebar} airport=${airport} metrics=${metrics} finRef=${finRef} log=${log}
        playbooks=${playbooks} selPlaybook=${selPlaybook} setSelPlaybook=${(id) => { AUDIO.play("ui_tap"); setSelPlaybook(id); }}
        running=${running} countdown=${countdown} startSim=${startSim} comms=${comms} doComms=${doComms}
        notify=${notify} doNotify=${doNotify}
        audioOn=${audioOn} setAudioOn=${(v) => { AUDIO.play("ui_tap"); setAudioOn(v); }} doReset=${doReset} />

      <div id="map-area">
        <div id="map-header">
          <div id="scenario-title">${scenario.title}</div>
          <div id="scenario-sub">${scenario.sub}</div>
          <div id="sim-clock">T+${String(clock.toFixed ? clock.toFixed(1) : clock).padStart(4, "0")}s</div>
        </div>
        <${MapView} infra=${infra} nodeById=${nodeById} nodeStatus=${nodeStatus} hotLinks=${hotLinks}
          ingress=${ingress} fines=${fines} pulses=${pulses} patchRing=${patchRing} approach=${approach} runwayStatus=${runwayStatus}
          onNode=${(id) => { AUDIO.play("ui_tap"); setOverlay(id); }} />
      </div>

      ${countdown !== null && html`
        <div id="countdown">
          <div class="cd-label">Simulation Commencing</div>
          <div class="cd-num cd-pop" key=${countdown}>${countdown === 0 ? "GO" : countdown}</div>
          <div class="cd-pb">${(playbooks.find(p => p.playbook_id === selPlaybook) || {}).name || ""}</div>
        </div>`}

      ${banner && html`
        <div id="endbanner">
          <div class="eb-card">
            <svg viewBox="0 0 48 48" width="60" height="60"><use href="#icon-shield"/></svg>
            <div class="eb-t">${banner.title}</div>
            <div class="eb-s">${banner.sub}</div>
          </div>
        </div>`}

      ${overlay && html`<${NodeOverlay} boot=${boot} node=${nodeById[overlay]} status=${nodeStatus[overlay]}
          vector=${nodeVector[overlay]} onClose=${() => setOverlay(null)} onMitigate=${mitigate} />`}
    </div>`;
}

/* ============================================================
   SIDEBAR
   ============================================================ */
function NotifyButton({ kind, label, agency, required, done, reason, onClick }) {
  const state = !required ? "noduty" : done ? "done" : "pending";
  const sub = state === "noduty" ? "No duty · this incident" : state === "done" ? "Notified ✓" : "Action required ⚠";
  return html`
    <button class=${"notify-btn " + state} disabled=${state !== "pending"} title=${reason || ""}
        onClick=${() => onClick(kind)}>
      <div class="nb-label">${label}</div>
      <div class="nb-agency">${agency}</div>
      <div class="nb-state">${sub}</div>
    </button>`;
}

function Sidebar(p) {
  const { airport, metrics, finRef, log, playbooks, selPlaybook, setSelPlaybook, running, countdown, startSim, comms, doComms, notify, doNotify, audioOn, setAudioOn, doReset } = p;
  const armed = selPlaybook && !running && countdown === null;
  return html`
    <aside id="sidebar">
      <div id="brand">
        <div class="brand-mark"><svg viewBox="0 0 48 48" width="40" height="40"><use href="#icon-shield"/></svg></div>
        <div><h1>AIRPORT CYBER</h1><h2>RESILIENCE · V2</h2></div>
        <div class="apt"><b>${airport.airport_id}</b><span>${airport.name}</span></div>
      </div>

      <div class="panel">
        <div class="panel-title"><span>Total Financial Loss</span><i class="dot"></i></div>
        <div ref=${finRef} class="fin-main">£0</div>
        <div class="fin-sub">
          <div>Operational<b>${money(metrics.operational)}</b></div>
          <div>Regulatory<b>${money(metrics.regulatory)}</b></div>
          <div>Mitigation<b>${money(metrics.mitigation)}</b></div>
        </div>
        <div class="fin-bleed">Bleed rate: ${money(metrics.bleed_per_sec)}/sec</div>
        <div class="mini-metrics">
          <div class="mini sec"><span>SECURITY</span><b>${metrics.security_integrity}%</b></div>
          <div class="mini cap"><span>CAPACITY</span><b>${metrics.operational_capacity}%</b></div>
          <div class="mini rep"><span>REP ×</span><b>${metrics.reputational_multiplier}</b></div>
        </div>
      </div>

      <div class="panel">
        <div class="panel-title"><span>Attack Playbook</span><i class="dot"></i></div>
        <div class="pb-select">
          ${playbooks.map((pb, i) => html`
            <div key=${pb.playbook_id} class=${"pb-card" + (selPlaybook === pb.playbook_id ? " sel" : "")} onClick=${() => setSelPlaybook(pb.playbook_id)}>
              <div class="pbx">${String(i + 1).padStart(2, "0")}</div>
              <div><b>${pb.name}</b><span>Ingress · ${pb.ingress_point.replace(/_/g, " ")}</span></div>
            </div>`)}
        </div>
        <button class=${"start-btn" + (armed ? " armed" : "")} disabled=${!armed} onClick=${startSim}>
          ${running ? "SIMULATION ACTIVE" : countdown !== null ? "LAUNCHING…" : "START SIMULATION"}
        </button>
      </div>

      <div class="panel" id="comms">
        <div class="panel-title"><span>Crisis Communications</span><i class="dot"></i></div>
        <div class="comms-body">${comms.message || "Awaiting incident. Media posture nominal."}</div>
        ${comms.phase === "proactive" && html`<button class="comms-proactive" onClick=${() => doComms("proactive")}>RELEASE PROACTIVE MEDIA STATEMENT</button>`}
        ${comms.phase === "reactive" && html`
          <div class="reactive-row">
            <button class="reactive-btn flash" onClick=${() => doComms("reactive")}><svg viewBox="0 0 24 24" width="18" height="18"><use href="#icon-phone"/></svg>ANSWER CALL</button>
            <button class="reactive-btn flash" onClick=${() => doComms("reactive")}><svg viewBox="0 0 24 24" width="18" height="18"><use href="#icon-email"/></svg>REPLY EMAIL</button>
          </div>`}
      </div>

      <div class="panel" id="notify-panel">
        <div class="panel-title"><span>Regulatory Notifications</span><i class="dot"></i></div>
        <div class="notify-row">
          <${NotifyButton} kind="caa" label="Notify CAA" agency="Civil Aviation Authority"
            required=${notify.reqCaa} done=${notify.doneCaa} reason=${notify.reasonCaa} onClick=${doNotify} />
          <${NotifyButton} kind="dft" label="Notify DfT" agency="Dept. for Transport"
            required=${notify.reqDft} done=${notify.doneDft} reason=${notify.reasonDft} onClick=${doNotify} />
        </div>
      </div>

      <div class="panel" id="log-panel">
        <div class="panel-title"><span>Live Incident Log</span><i class="dot"></i></div>
        <div id="event-log">
          ${log.map(it => html`<div key=${it.id} class=${"log-item " + it.kind}><span class="t">T+${it.t}</span>${it.text}</div>`)}
        </div>
      </div>

      <div class="side-actions">
        <button id="reset-btn" onClick=${doReset}><svg viewBox="0 0 24 24" width="20" height="20"><use href="#icon-reset"/></svg>RESET TO CONFIG</button>
        <button class="audio-toggle" onClick=${() => setAudioOn(!audioOn)} title="Toggle audio">
          <svg viewBox="0 0 24 24" width="22" height="22"><use href=${audioOn ? "#icon-speaker-on" : "#icon-speaker-off"}/></svg>
        </button>
      </div>
    </aside>`;
}

/* ============================================================
   MAP VIEW (SVG)
   ============================================================ */
function MapView({ infra, nodeById, nodeStatus, hotLinks, ingress, fines, pulses, patchRing, approach, runwayStatus, onNode }) {
  // runway light strip geometry (next to runway_lighting node)
  const rw = nodeById["runway_lighting"];
  const rwState = runwayStatus === "isolated" ? "dark"
    : (runwayStatus === "throttled" || runwayStatus === "compromised" || runwayStatus === "patching") ? "dim" : "on";
  const rwLights = Array.from({ length: 14 }, (_, i) => i);

  return html`
    <svg id="map" viewBox="0 0 1460 1080" preserveAspectRatio="xMidYMid meet">
      <defs>
        <radialGradient id="bg-grad" cx="50%" cy="35%" r="80%"><stop offset="0%" stop-color="#0d2033"/><stop offset="100%" stop-color="#050a12"/></radialGradient>
      </defs>
      <rect x="0" y="0" width="1460" height="1080" fill="url(#bg-grad)"/>

      <!-- grid -->
      <g opacity="0.5">
        ${Array.from({ length: 25 }, (_, i) => html`<line key=${"v" + i} x1=${i * 60} y1="0" x2=${i * 60} y2="1080" stroke="#0c2033" stroke-width="1"/>`)}
        ${Array.from({ length: 18 }, (_, i) => html`<line key=${"h" + i} x1="0" y1=${i * 60} x2="1460" y2=${i * 60} stroke="#0c2033" stroke-width="1"/>`)}
      </g>

      <!-- links -->
      <g>
        ${infra.links.map((lk, i) => {
          const a = nodeById[lk.source], b = nodeById[lk.target];
          const hot = hotLinks.has(`${lk.source}|${lk.target}`);
          return html`<line key=${i} class=${"link" + (hot ? " hot" : "")} x1=${a.x} y1=${a.y} x2=${b.x} y2=${b.y}/>`;
        })}
      </g>

      <!-- ambient green particles -->
      <g>
        ${infra.links.map((lk, i) => {
          const a = nodeById[lk.source], b = nodeById[lk.target];
          const dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy);
          return html`<circle key=${i} class="particle g ambient" cx=${a.x} cy=${a.y}
            style=${{ "--dx": dx + "px", "--dy": dy + "px", "--dur": (len / 90).toFixed(1) + "s", animationDelay: (i % 5) * 0.6 + "s" }}/>`;
        })}
      </g>

      <!-- red spread pulses -->
      <g>
        ${pulses.map(p => html`<circle key=${p.id} class="particle r spread" cx=${p.x1} cy=${p.y1}
          style=${{ "--dx": (p.x2 - p.x1) + "px", "--dy": (p.y2 - p.y1) + "px", "--dur": "1.1s" }}/>`)}
      </g>

      <!-- attack closing in on the next node while a patch counts down -->
      ${approach && (() => {
        const s = nodeById[approach.source], t = nodeById[approach.target];
        if (!s || !t) return null;
        const dx = t.x - s.x, dy = t.y - s.y;
        return html`<g class="approach-layer">
          <line class="link approaching" x1=${s.x} y1=${s.y} x2=${t.x} y2=${t.y}/>
          ${[0, 1, 2, 3, 4].map(i => html`<circle key=${i} class="approach-particle" cx=${s.x} cy=${s.y}
            style=${{ "--dx": dx + "px", "--dy": dy + "px", "--dur": "1.5s", animationDelay: (i * 0.3) + "s" }}/>`)}
          <circle class="approach-ring" cx=${t.x} cy=${t.y} r="40"/>
          <text class="approach-warn" x=${t.x} y=${t.y - 52}>▲ INCOMING</text>
        </g>`;
      })()}

      <!-- runway lights strip -->
      <g>
        ${rwLights.map(i => html`<rect key=${i} class=${"rwlight " + rwState} x=${rw.x - 46 + i * 7} y=${rw.y + 92} width="4" height="9" rx="1"
          style=${{ animationDelay: (i * 0.06) + "s" }}/>`)}
      </g>

      <!-- nodes -->
      <g>
        ${infra.nodes.map(n => {
          const st = nodeStatus[n.id] || "normal";
          return html`
            <g key=${n.id} class=${"node " + st} transform=${`translate(${n.x},${n.y})`} onClick=${() => onNode(n.id)}>
              <circle class="halo" r="40"/>
              <circle class="core" r="31"/>
              <use class=${"ico" + (ANIM_ICONS.has(n.id) ? " ico-anim ico-" + n.icon : "")} href=${"#node-" + n.icon} x="-22" y="-22" width="44" height="44"/>
              <text class="label" y="60">${n.name}</text>
              <text class="cap" y="76">${CAP[st]}%</text>
              ${patchRing && patchRing.node === n.id && html`<circle class="patch-ring" r="48" style=${{ strokeDasharray: 301, transform: "rotate(-90deg)", animation: `patchspin ${patchRing.seconds}s linear forwards` }}/>`}
            </g>`;
        })}
      </g>

      <!-- ingress method marker -->
      ${ingress && (() => { const n = nodeById[ingress.node]; return html`
        <g class="ingress-marker" transform=${`translate(${n.x},${n.y - 86})`}>
          <circle class="halo" r="28"/>
          <use class="ico" href=${"#ing-" + ingress.icon} x="-19" y="-19" width="38" height="38"/>
          <text class="il" y="46">METHOD OF INGRESS</text>
        </g>`; })()}

      <!-- floating fines -->
      <g>
        ${fines.map(f => { const n = nodeById[f.node]; return html`<text key=${f.id} class="ffine" x=${n.x} y=${n.y - 30}>${f.label}</text>`; })}
      </g>
    </svg>`;
}

/* ============================================================
   NODE OVERLAY
   ============================================================ */
function NodeOverlay({ boot, node, status, vector, onClose, onMitigate }) {
  const [tab, setTab] = useState("details");
  const c = node.compliance;
  const objs = boot.infrastructure.frameworks.caf.objectives;
  const actionable = status === "compromised";
  const badgeClass = status === "compromised" || status === "patching" ? "bad" : (status === "throttled" || status === "isolated") ? "warn" : "";
  return html`
    <div class="overlay" onClick=${(e) => { if (e.target.classList.contains("overlay")) onClose(); }}>
      <div class="overlay-card">
        <button class="overlay-close" onClick=${onClose}>×</button>
        <div class="overlay-head">
          <div class="overlay-icon"><svg viewBox="0 0 48 48" width="40" height="40"><use href=${"#node-" + node.icon}/></svg></div>
          <div><div class="overlay-name">${node.name}</div><div class=${"overlay-status " + badgeClass}>${STATUS_LABEL[status]}</div></div>
        </div>
        <div class="overlay-tabs">
          <button class=${"tab" + (tab === "details" ? " active" : "")} onClick=${() => setTab("details")}>Details</button>
          <button class=${"tab" + (tab === "mitigation" ? " active" : "")} onClick=${() => setTab("mitigation")}>Mitigation</button>
          <button class=${"tab" + (tab === "regulatory" ? " active" : "")} onClick=${() => setTab("regulatory")}>Regulatory Insights</button>
        </div>

        ${tab === "details" && html`<div>
          <div class="kv"><span>Service Type</span><b>${node.type.toUpperCase()}</b></div>
          <div class="kv"><span>Operational Capacity</span><b>${CAP[status]}%</b></div>
          <div class="kv"><span>Impact Factor</span><b>${(node.impact * 100).toFixed(0)}%</b></div>
          <div class="kv"><span>Threat Vector</span><b>${vector || (actionable ? "Under attack" : "None")}</b></div>
          ${node.data_sensitive && html`<div class="kv"><span>Data-Sensitive</span><b style=${{ color: "var(--red)" }}>YES · regulated PII</b></div>`}
          <p class="overlay-desc">${c.insight}</p>
        </div>`}

        ${tab === "mitigation" && html`<div>
          <p class="mit-help">Every response carries an operational trade-off. Choose deliberately.</p>
          <button class="mit-btn iso" disabled=${!actionable} onClick=${() => onMitigate(node.id, "ISOLATE")}><b>1 · ISOLATE NODE</b><span>Sever links, halt spread instantly. Capacity → 0%. Maximum operational bleed.</span></button>
          <button class="mit-btn thr" disabled=${!actionable} onClick=${() => onMitigate(node.id, "THROTTLE")}><b>2 · REDUCE TRAFFIC / THROTTLE</b><span>Slow spread 80%. Capacity → 40%. Cautious operational compromise.</span></button>
          <button class="mit-btn stop" disabled=${!actionable} onClick=${() => onMitigate(node.id, "STOP")}><b>3 · STOP ATTACK (PATCH)</b><span>£50k emergency fee. Starts a 5s patch that <b>races the incoming spread</b> — reverts the node to normal only if it finishes before the attack reaches the next node. Act fast.</span></button>
        </div>`}

        ${tab === "regulatory" && html`<div>
          <div class="reg-badge" style=${{ marginBottom: "14px" }}><span>CAF Objective ${c.caf_objective} — ${objs[c.caf_objective]}</span></div>
          <div class="kv"><span>CAF Principle</span><b>${c.caf_principle}</b></div>
          <div class="kv col"><span>UK Cyber Security & Resilience Bill</span><p>${c.csr_duty}</p></div>
          <div class="kv col"><span>Resilience Insight</span><p>${c.insight}</p></div>
        </div>`}
      </div>
    </div>`;
}

ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
