/**
 * editor_score.js — the SCORE IT panel.  [Lane B, SPRINT14 gate 2.1]
 *
 * One button in A's editor runs the whole audit gauntlet against the yard
 * currently on the glass, in-page, on demand, and renders the answer as a card:
 * winnable lines at $80 with the funnel state SAID OUT LOUD, the cheapest
 * honest line, held-vs-bare garden separation, and the 15%-rule margin flags.
 *
 * ── This file's entire job is to be a front-end ─────────────────────────────
 *
 * It contains no audit logic and no wind. The gauntlet is `scorecard.js`, which
 * is the same engine `audit.html` runs; the wind inside it is `windForSite()`,
 * which is C's shared builder and the only door site wind comes through. If you
 * find yourself about to compute a load, a coverage or a m/s in this file, that
 * is the fourth harness arriving and Sprint 13 says it will be wrong in a new
 * way. Put it in the engine where both front-ends get it.
 *
 * ── What it deliberately does NOT read ──────────────────────────────────────
 *
 * `EDITOR.world`. Not once. A's page renders on a calm stub wind (correctly —
 * gate 1 is geometry), and a tree anchor's sway closure samples the wind its
 * world was built with forever, so scoring off the editor's anchors would
 * freeze every tree at calm and under-read every tree corner exactly the way
 * C's landmine 2 did (q4: 1.02 frozen vs 1.24 live). The score builds its own
 * dressed world from `EDITOR.siteClone()` — which is A's contract taken
 * literally ("that is what B feeds the audit"), and has the bonus that the
 * yard scored is the yard exported, byte for byte.
 *
 * ── Registration ────────────────────────────────────────────────────────────
 *
 * Self-registering on import: no exports anyone must call, no edit to
 * editor.html beyond the one import line that loads it (A's ask-1 seam,
 * THREADS 2026-07-18). Mounts at reserved order 40 via `EDITOR.mountPanel`,
 * fills `body`, and styles with A's contract classes only.
 *
 * Because it imports A's page, this module cannot run standalone on lane/b.
 * That is by design and is flagged in THREADS rather than worked around.
 */

import { loadStorm } from './weather.js';
import { START_BUDGET } from './contracts.js';
import { scoreSite, buildScoringWorld, cheapestHonest, marginFlags, collateralExposure } from '../../../tools/site_audit/scorecard.js';
// SPRINT15 gate 2.2: the storm list and the SELECTION live in editor_storm.js
// now — C's converged store (storm + second; this panel ignores the second on
// purpose — a score flies the WHOLE storm). D scored the wildnight while
// viewing the southerly because this file and editor.wind.js each defaulted a
// private stormKey; neither carries one any more.
import { stormSel, STORM_KEYS } from './editor_storm.js';

const el = (tag, cls, text) => {
  const n = document.createElement(tag);
  if (cls) n.className = cls;
  if (text != null) n.textContent = text;
  return n;
};

/** A `.ed-card-row`: dim key on the left, value on the right. */
function kv(parent, key, value, cls) {
  const row = el('div', 'ed-card-row');
  row.append(el('span', 'ed-kv', key));
  row.append(el('span', cls || null, value));
  parent.append(row);
  return row;
}

function card(parent, head, headCls) {
  const c = el('div', 'ed-card');
  const h = el('div', `ed-card-head${headCls ? ' ' + headCls : ''}`, head);
  c.append(h);
  parent.append(c);
  return c;
}

const pct = (x) => `${(x * 100).toFixed(0)}%`;
const money = (n) => `$${n}`;

function boot() {
  const EDITOR = globalThis.EDITOR;
  if (!EDITOR) {
    console.error('[score] EDITOR missing — editor_score.js must be imported AFTER createEditor() resolves.');
    return;
  }

  const { body } = EDITOR.mountPanel({ id: 'score', title: 'SCORE IT', order: 40 });
  body.replaceChildren();

  // --- controls ------------------------------------------------------------
  const rowStorm = el('div', 'ed-row');
  rowStorm.append(el('span', 'ed-label', 'storm'));
  const sel = el('select', 'ed-sel');
  for (const s of STORM_KEYS) sel.append(new Option(s.replace(/^storm_/, ''), s));
  // One storm selection for the whole page (gate 2.2): this select is a VIEW of
  // C's store, not an owner — change it here and the wind panel follows, change
  // it there and this follows. Scoring one storm while reading another's wind
  // field is how D got quietly wrong numbers.
  sel.value = stormSel().storm;
  sel.addEventListener('change', () => stormSel().setStorm(sel.value, 'score'));
  stormSel().onChange(({ storm }) => { sel.value = storm; });
  rowStorm.append(sel);
  body.append(rowStorm);

  const rowBtn = el('div', 'ed-row');
  const btn = el('button', 'ed-btn primary', 'SCORE IT');
  rowBtn.append(btn);
  body.append(rowBtn);

  const out = el('div');
  body.append(out);

  const note = el('div', 'ed-note',
    'Scores a fresh dressed world built from the EXPORT clone — not the editor\'s '
    + 'view, whose wind is a calm stub. Every number flies windForSite() and the real '
    + 'commit→attach chain. Slow is honest — every candidate flies the full storm — '
    + 'but the page stays live and the progress line says where it is.');
  body.append(note);

  // A score describes the yard as it was WHEN SCORED. The moment anything
  // moves, the card is a claim about a yard that no longer exists — so it says
  // so rather than quietly ageing on screen. (Same instinct as A clearing the
  // overlay on rebuild: one place remembers staleness, nobody else has to.)
  let stale = false;
  const markStale = () => {
    if (!out.firstChild || stale) return;
    stale = true;
    const warn = el('div', 'ed-card-head ed-warn', '⚠ YARD CHANGED SINCE THIS SCORE — re-run');
    out.prepend(warn);
  };
  EDITOR.on('change', markStale);
  EDITOR.on('siteload', markStale);

  async function run() {
    stale = false;
    btn.disabled = true;
    btn.textContent = 'scoring…';
    // The progress line (gate 2.1). D's verdict on the old blocking run: the
    // worst part "is not the wait, it's not knowing whether it died". scoreSite
    // yields between flights now, so every update here actually PAINTS.
    const prog = el('div', 'ed-note', 'building a dressed world…');
    out.replaceChildren(prog);
    // yield so the button repaints before the first heavy chunk
    await new Promise((r) => setTimeout(r, 0));

    const t0 = performance.now();
    const secs = () => `${((performance.now() - t0) / 1000).toFixed(1)} s`;
    const onProgress = (p) => {
      if (p.phase === 'sweep') {
        prog.textContent = `sweeping — flight ${p.done}/${p.total} quads · ${secs()}`;
      } else if (p.phase === 'fly') {
        prog.textContent = `flying the garden — line ${p.done}/${p.total}${p.label ? ` · ${p.label}` : ''} · ${secs()}`;
      } else if (p.phase === 'separation') {
        prog.textContent = p.done
          ? `separation target judged · ${secs()}`
          : `judging the pinned separation target (${p.label}) · ${secs()}`;
      }
    };
    try {
      const site = EDITOR.siteClone();
      const stormName = stormSel().storm;
      const stormDef = await loadStorm(stormName);

      // The separation block names its OWN storm — judging A's pinned target on
      // whatever storm the dropdown happens to show would be a different claim
      // wearing the target's name.
      const sepKey = site.separation?.stormKey ?? null;
      const sepStormDef = sepKey ? (sepKey === stormName ? stormDef : await loadStorm(sepKey)) : null;

      const built = await buildScoringWorld(site);
      const score = await scoreSite({ site, stormDef, stormName, sepStormDef, prebuilt: built, onProgress });
      render(out, score, performance.now() - t0);
      globalThis.__editorScore = score;   // for the selftest + console spelunking
    } catch (err) {
      out.replaceChildren();
      const c = card(out, 'SCORE FAILED', 'ed-err');
      c.append(el('div', 'ed-note', String(err?.stack ?? err)));
      console.error('[score]', err);
    } finally {
      btn.disabled = false;
      btn.textContent = 'SCORE IT';
    }
  }

  btn.addEventListener('click', run);
}

/** Render a scorecard result. Pure DOM from pure data — no scoring here. */
function render(out, s, ms) {
  out.replaceChildren();

  // ── the header card: WHAT WAS SCORED, and the funnel state, loudly ────────
  // Sprint 11 shipped a headline built with the funnel off, and the number
  // looked entirely reasonable. Three harnesses made that mistake because
  // nothing printed the funnel state next to the score. This card does, first,
  // before any result — a score whose weather you can't see is a rumour.
  const head = card(out, s.funnelOn ? 'FUNNEL ON' : 'NO FUNNEL', s.funnelOn ? 'ed-warn' : null);
  kv(head, 'yard', s.site);
  kv(head, 'storm', s.storm ?? '—');
  kv(head, 'venturi', s.funnelOn
    ? s.venturi.map((v) => `(${v.x},${v.z}) axis ${v.axis} gain ${v.gain}`).join(' · ')
    : 'none declared in site.wind');
  kv(head, 'anchors', `${s.anchorCount} ${s.dressed ? 'dressed ✓' : '⚠ UNDRESSED'}`,
    s.dressed ? 'ed-ok' : 'ed-err');
  if (!s.dressed) {
    head.append(el('div', 'ed-note ed-err',
      `dress() failed (${s.dressError ?? 'unknown'}) — these are GRAYBOX positions and every `
      + 'ratingHint silently became 1.0. That is the missing-importmap gotcha; the numbers below '
      + 'are not what ships. Fix the page before believing this card.'));
  }
  kv(head, 'bed', `${s.bed.w}×${s.bed.d} m at (${s.bed.x}, ${s.bed.z})`);
  kv(head, 'took', `${(ms / 1000).toFixed(1)} s`);

  // ── 1. winnability at $80 ────────────────────────────────────────────────
  const V = s.verdict;
  const vcls = V.code === 'pass' ? 'ed-ok' : V.code === 'marginal-only' ? 'ed-warn' : 'ed-err';
  const vhead = { pass: '✓ WINNABLE', 'marginal-only': '⚠ MARGINAL ONLY',
    unaffordable: '✗ UNAFFORDABLE', 'no-cover': '✗ NO LINE COVERS THE BED' }[V.code] ?? V.code;
  const cw = card(out, `${vhead} — at ${money(START_BUDGET)}`, vcls);
  kv(cw, 'quads in band', `${s.cands} candidate${s.cands === 1 ? '' : 's'}`);
  kv(cw, 'clean lines', String(s.winners.length), s.winners.length ? 'ed-ok' : 'ed-err');
  kv(cw, 'marginal lines', String(s.marginalWinners.length), s.marginalWinners.length ? 'ed-warn' : null);

  if (V.code === 'no-cover') {
    cw.append(el('div', 'ed-note ed-err',
      'No quad in the audit band shades the bed at all. This yard cannot be rigged: it needs an '
      + 'anchor near the bed before it ships. (The band is an availability FLOOR, not a ceiling — '
      + 'a yard failing here is failing on geometry, not on the heuristic.)'));
  } else if (V.code === 'marginal-only') {
    const b = V.best;
    cw.append(el('div', 'ed-note ed-warn',
      `Every affordable line carries a corner inside the ${pct(s.MARGIN)} break-in-game band. `
      + `Best is ${b.ids.join(',')} at ${money(b.hw)} — on this bench it holds; in the real game it is `
      + 'the wild night’s 39.8 TATTERED. Clean would cost '
      + `${b.cleanHw != null ? money(b.cleanHw) : 'steel over the shop ceiling'}.`));
  } else if (!V.ok) {
    const b = V.best;
    cw.append(el('div', 'ed-note ed-err',
      `No affordable holding line. Cheapest is ${b.ids.join(',')} at ${money(b.hw)} on a `
      + `${money(START_BUDGET)} budget`
      + (b.unholdable.length
        ? `, and ${b.unholdable.map((c) => c.id).join('/')} is over the shop’s ceiling at any price.`
        : '.')));
  }

  // ── 2. the cheapest HONEST line ──────────────────────────────────────────
  // Not the cheapest that holds: Sprint 13 proved that quantity sells rigs the
  // sim then charges you for. Cheapest among lines that are clean AND beat a
  // bare bed in flight.
  const ch = cheapestHonest(s);
  const cc = card(out, 'CHEAPEST HONEST LINE', ch ? 'ed-ok' : 'ed-err');
  if (ch) {
    kv(cc, 'line', ch.row.ids.join(',') + (ch.row.pinned ? '  📌 pinned' : ''));
    kv(cc, 'hardware', money(ch.row.cleanHw) + (ch.row.hw < ch.row.cleanHw ? `  (holds at ${money(ch.row.hw)})` : ''));
    kv(cc, '+ spare', money(ch.total), ch.total <= START_BUDGET ? 'ed-ok' : 'ed-warn');
    kv(cc, 'area', `${ch.row.area.toFixed(0)} m²`);
    kv(cc, 'garden', `${ch.garden.hp} ${ch.garden.state.toUpperCase()}`,
      ch.garden.state === 'full' ? 'ed-ok' : ch.garden.state === 'tattered' ? 'ed-warn' : 'ed-err');
    kv(cc, 'worth', `${ch.garden.hp - s.bare.hp >= 0 ? '+' : ''}${(ch.garden.hp - s.bare.hp).toFixed(1)} HP over bare`);
    if (ch.total > START_BUDGET) {
      cc.append(el('div', 'ed-note ed-warn',
        `Buyable, but there is no room left for a ${money(ch.total - ch.row.cleanHw)} spare — `
        + 'a night with no spare is a repair the player cannot make.'));
    }
  } else {
    cc.append(el('div', 'ed-note ed-err',
      'No line is BOTH clean and better than leaving the bed bare. Either nothing clean is '
      + 'buyable, or the clean lines that are buyable do not actually save the garden — which is '
      + 'the wild night’s intended reading, and a bug on any other night.'));
  }

  // ── 3. the garden: held vs bare (gardenfly) ──────────────────────────────
  const cg = card(out, 'GARDEN — HELD vs BARE (flown)');
  kv(cg, 'bare bed', `${s.bare.hp} ${s.bare.state.toUpperCase()}`,
    s.bare.state === 'dead' ? 'ed-err' : s.bare.state === 'tattered' ? 'ed-warn' : 'ed-ok');
  if (s.bestGarden) {
    kv(cg, 'best clean', `${s.bestGarden.ids} → ${s.bestGarden.hp} ${s.bestGarden.state.toUpperCase()}`,
      s.bestGarden.state === 'full' ? 'ed-ok' : 'ed-warn');
    kv(cg, 'separation', `${s.bestGarden.hp - s.bare.hp >= 0 ? '+' : ''}${(s.bestGarden.hp - s.bare.hp).toFixed(1)} HP`,
      s.bestGarden.hp - s.bare.hp > 0 ? 'ed-ok' : 'ed-err');
    kv(cg, 'by hail / rain', `${s.bestGarden.byHail} / ${s.bestGarden.byRain}`);
  } else {
    kv(cg, 'best clean', 'none buyable', 'ed-err');
  }
  if (s.bestMarginal && (!s.bestGarden || s.bestMarginal.hp > s.bestGarden.hp)) {
    cg.append(el('div', 'ed-note ed-warn',
      `⚠ The best-reading line ${s.bestMarginal.ids} is MARGINAL: `
      + `${s.bestMarginal.hp} ${s.bestMarginal.state.toUpperCase()} on paper, inside the `
      + `${pct(s.MARGIN)} band. Treat it as the trap, not the answer — it is the shape of result `
      + 'that shipped a 91.9 FULL the player experienced as 39.8 TATTERED.'));
  }
  if (s.skipped) {
    cg.append(el('div', 'ed-note', `${s.skipped} affordable line(s) beyond the ${s.flyCap}-flight cap were not flown.`));
  }

  // ── 4. the pinned separation target ──────────────────────────────────────
  const sep = s.separation;
  if (sep) {
    const cs = card(out, `SEPARATION TARGET — ${sep.ok ? 'MEETS' : 'FAILS'}`, sep.ok ? 'ed-ok' : 'ed-err');
    kv(cs, 'storm', s.sepStormName);
    kv(cs, 'held', `${sep.held.hp} ${sep.held.state.toUpperCase()} (${money(sep.held.cost)}, ${sep.held.cornersLost}/4 lost)`,
      sep.heldOk ? 'ed-ok' : 'ed-err');
    kv(cs, 'bare', String(sep.bare.hp), sep.bareOk ? 'ed-ok' : 'ed-err');
    kv(cs, 'held wins?', sep.heldWin ? 'yes' : 'no', sep.heldWin ? 'ed-ok' : 'ed-err');
    if (sep.ok && !sep.heldClean) {
      cs.append(el('div', 'ed-note ed-warn',
        '⚠ Meets the target, but the pinned line is KNIFE-EDGED: '
        + sep.held.marginal.map((id) => {
          const p = sep.held.peaks.find((x) => x.id === id);
          return `${id} ${pct(p.headroom)} headroom (${p.peakN}/${p.effN} N)`;
        }).join(', ')
        + `, inside the ${pct(s.MARGIN)} band. The pin is A’s ruling and this card does not `
        + 'overrule it — but nobody has PLAYED this line.'));
    }
  } else if (s.sepUnjudged) {
    const cs = card(out, 'SEPARATION TARGET — NOT JUDGED', 'ed-warn');
    cs.append(el('div', 'ed-note', s.sepUnjudged));
  } else {
    const cs = card(out, 'SEPARATION TARGET — NONE PINNED', 'ed-warn');
    cs.append(el('div', 'ed-note',
      'This yard declares no `separation` block, so there is no held-vs-bare target to judge it '
      + 'against. A shipping site should pin one: it is the difference between "a sail helps here" '
      + 'and "a sail is the point of this night".'));
  }

  // ── 5. margin flags — the 15% rule ───────────────────────────────────────
  const flags = marginFlags(s);
  const cm = card(out, `MARGIN FLAGS — the ${pct(s.MARGIN)} rule`, flags.length ? 'ed-warn' : 'ed-ok');
  if (!flags.length) {
    kv(cm, 'knife-edge corners', 'none', 'ed-ok');
  } else {
    for (const f of flags) {
      // the bill rides on the same line as the flag: "knife-edge" and
      // "knife-edge AND $180 of carport" are different authoring decisions
      const bill = f.collateral
        ? (f.collateral.unpriced
          ? ` · wrecks ${f.collateral.label} (UNPRICED)`
          : ` · wrecks ${f.collateral.label} ${money(f.collateral.cost)}`)
        : '';
      const row = kv(cm, `${f.id}${f.hint !== 1 ? ` ×${f.hint}` : ''}`,
        `${pct(f.headroom)} headroom — ${(f.peak / 1000).toFixed(1)} kN on ${f.line}${bill}`, 'ed-warn');
      row.title = `worst case across every swept line; first seen on ${f.line}`;
    }
  }
  cm.append(el('div', 'ed-note',
    `A corner within ${pct(s.MARGIN)} of its effective rating breaks in the real game — benches `
    + 'under-read the UI’s peaks by 5–15% and the cause is still unfound, so MANUAL.md '
    + 'carries this as policy. Marginal is not PASS.'));

  // ── 6. collateral exposure ───────────────────────────────────────────────
  // "Winnable at $80" is an incomplete sentence on a yard where letting a
  // corner go bills $180. Priced through world.collateralFor() — the one
  // resolver, site JSON first, GLB extra second.
  const exp = collateralExposure(s);
  const ce = card(out, 'COLLATERAL EXPOSURE', exp.total > 0 ? 'ed-warn' : null);
  if (!exp.priced.length && !exp.unpriced.length) {
    kv(ce, 'at risk', 'nothing priced in this yard', 'ed-ok');
  } else {
    for (const e of exp.priced) kv(ce, e.label, `${money(e.cost)}  (${e.anchors.join(', ')})`, 'ed-warn');
    kv(ce, 'total if all lost', money(exp.total), exp.total > START_BUDGET ? 'ed-err' : 'ed-warn');
    for (const e of exp.unpriced) {
      kv(ce, e.label, `UNPRICED — not scored  (${e.anchors.join(', ')})`, 'ed-err');
    }
    if (exp.unpriced.length) {
      ce.append(el('div', 'ed-note ed-err',
        'An unpriced label reads "not scored", never "free" — that distinction is the whole '
        + 'point. A yard whose trap prices to nothing scores as SAFER than it is, which is '
        + 'exactly the lie this card exists to prevent.'));
    }
    ce.append(el('div', 'ed-note',
      'Billed per STRUCTURE, not per corner — two beams letting go is one carport gone, not two. '
      + `Against a ${money(START_BUDGET)} budget, this is what the yard can take off the player `
      + 'on top of the hardware.'));
  }
}

boot();
