// src/FootprintReport.jsx — Reports → tab 6 "AI footprint" (16 Sep 2026).
// Markus (producer) wants the AI side's environmental footprint ready for festival Q&A.
// Headline (Hugo, 17 Sep 2026): "This project took <water>, the equivalent of growing N almonds",
// then the same screen time filmed for real or made with VFX. There is no money here, so every
// role that can open Reports sees this tab. Server: GET /api/reports/footprint (services/aiFootprint.js).
// Design: docs/superpowers/specs/2026-09-16-ai-footprint-design.md

function _fpJson(r) {
  return r.json().catch(() => ({})).then(j => (r.ok ? j : Promise.reject(new Error(j.error || ("The server answered " + r.status + ".")))));
}
// Shared mode (17 Sep 2026, Hugo: "SHould be able to share a standalone version of this page to anyone"):
// the public page /share/<project>/footprint/<token> (services/footprintShare.js) renders this component
// with props.shared = { published_at, report }. It fetches nothing and leaves out the assumptions and the
// call log; the only action is Print.
function FootprintReport(props) {
  const shared = props && props.shared && props.shared.report ? props.shared : null;
  const [data, setData] = React.useState(shared ? shared.report : null);
  const [error, setError] = React.useState("");
  const [draft, setDraft] = React.useState(null);
  const [dirty, setDirty] = React.useState(false);
  const [busy, setBusy] = React.useState(false);
  const [status, setStatus] = React.useState({ text: "", error: false });
  const [printing, setPrinting] = React.useState(false);
  const [promptBox, setPromptBox] = React.useState(null);   // the prompt, shown when the browser refuses to copy
  const [shareBox, setShareBox] = React.useState(null);     // the Share window: { info, busy, error, confirmStop, copied }
  const dirtyRef = React.useRef(false);
  const copyBtnRef = React.useRef(null);       // focus goes back here when the prompt window closes
  const shareBtnRef = React.useRef(null);      // … and here when the Share window closes
  const backdropDown = React.useRef(false);   // a drag-select that ends on the backdrop must not close the window
  dirtyRef.current = dirty;

  const load = React.useCallback(() => {
    if (shared) return Promise.resolve();
    const f = window.authFetch || fetch;
    return f("/api/reports/footprint").then(_fpJson)
      .then(d => { setData(d); setError(""); if (!dirtyRef.current) setDraft(d.settings); })
      .catch(e => setError(e.message || "The footprint could not be loaded."));
  }, []);

  React.useEffect(() => {
    if (shared) return undefined;
    load();
    let t = null;
    const soon = () => { clearTimeout(t); t = setTimeout(load, 2500); };
    const onSse = (e) => {
      const ty = e && e.detail && e.detail.type;
      if (ty === "footprint_changed" || ty === "generate" || ty === "new_asset_version") soon();
    };
    window.addEventListener("paradise-sse", onSse);
    window.addEventListener("focus", soon);
    const iv = setInterval(() => { if (!document.hidden) load(); }, 60000);
    return () => {
      clearTimeout(t);
      clearInterval(iv);
      window.removeEventListener("paradise-sse", onSse);
      window.removeEventListener("focus", soon);
    };
  }, [load]);

  React.useEffect(() => {
    if (!promptBox) return undefined;
    const onKey = (e) => { if (e.key === "Escape") closePromptBox(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [promptBox]);

  React.useEffect(() => {
    if (!shareBox) return undefined;
    const onKey = (e) => {
      if (e.key !== "Escape") return;
      if (shareBox.confirmStop) setShareBox(b => (b ? { ...b, confirmStop: false } : b));
      else closeShare();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [shareBox]);

  const role = String(window.__effectiveRole || (window.__currentUser && window.__currentUser.role) || "");
  const canEdit = !shared && (role === "admin" || role === "producer");

  const setField = (key, value, sub) => {
    setDraft(d => {
      const n = JSON.parse(JSON.stringify(d || {}));
      if (sub) { n.workstation = n.workstation || {}; n.workstation[sub] = value; } else n[key] = value;
      return n;
    });
    setDirty(true);
    setStatus({ text: "", error: false });
  };
  const numOrNull = (v) => (v === "" ? null : Number(v));
  const save = (reset) => {
    const f = window.authFetch || fetch;
    setBusy(true);
    setStatus({ text: "", error: false });
    return f("/api/reports/footprint/settings", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(reset ? { reset: true } : { settings: draft }),
    }).then(_fpJson)
      .then(() => {
        dirtyRef.current = false;
        setDirty(false);
        setStatus({ text: reset ? "Back to the defaults." : "Saved for the whole project.", error: false });
        return load();
      })
      .catch(e => setStatus({ text: e.message, error: true }))
      .finally(() => setBusy(false));
  };
  const downloadCsv = () => {
    const f = window.authFetch || fetch;
    setStatus({ text: "Preparing the CSV…", error: false });
    f("/api/reports/footprint.csv")
      .then(r => {
        if (!r.ok) throw new Error("The CSV could not be made (" + r.status + ").");
        const m = /filename="([^"]+)"/.exec(r.headers.get("content-disposition") || "");
        return r.blob().then(b => ({ b, name: m ? m[1] : "ai-footprint.csv" }));
      })
      .then(({ b, name }) => {
        const url = URL.createObjectURL(b);
        const a = document.createElement("a");
        a.href = url;
        a.download = name;
        document.body.appendChild(a);
        a.click();
        a.remove();
        setTimeout(() => URL.revokeObjectURL(url), 10000);
        setStatus({ text: "CSV downloaded: " + name, error: false });
      })
      .catch(e => setStatus({ text: e.message, error: true }));
  };
  // ChatGPT prompts (17 Sep 2026). Hugo pasted the first one into ChatGPT and asked where the other pages
  // were, so the button now opens a window with one prompt per image (the answer, side by side, where it
  // comes from), each with its own Copy button. Results also toast: the status line sits far below.
  const tell = (text, ok) => {
    setStatus({ text, error: !ok });
    if (window.__toast) window.__toast(text, ok);
  };
  const closePromptBox = () => {
    setPromptBox(null);
    setTimeout(() => { try { copyBtnRef.current && copyBtnRef.current.focus(); } catch (_) {} }, 0);
  };
  const openPrompts = () => {
    let items = [];
    try {
      items = typeof _fpInfographicPrompts === "function"
        ? _fpInfographicPrompts(data)
        : [{ key: "summary", title: "1 · The answer", what: "the almond sentence, the three figures and the comparison bars", prompt: _fpInfographicPrompt(data) }];
      items = (items || []).filter(it => it && it.prompt);
    } catch (e) {
      tell("The ChatGPT prompts could not be made: " + e.message, false);
      return;
    }
    if (!items.length) {
      const T0 = data.totals;
      tell(!(T0.litres[1] > 0) || !(T0.kg[1] > 0)
        ? "No infographic yet: it needs counted AI water use to show the almonds."
        : "No infographic: there are too many comparison rows for one image. Use Print statement.", false);
      return;
    }
    setPromptBox({ items, copied: null });
  };
  const copyPromptItem = (it) => {
    const done = () => {
      setPromptBox(b => (b ? { ...b, copied: it.key } : b));
      tell(`Prompt ${it.title.split(" ")[0]} copied. Paste it into ChatGPT as its own message.`, true);
    };
    const byHand = () => {
      // The browser refused the clipboard: select the text so Ctrl+C copies it.
      const ta = document.getElementById("fp-prompt-" + it.key);
      let ok = false;
      if (ta) {
        ta.focus();
        ta.select();
        try { ok = document.execCommand("copy"); } catch (_) {}
      }
      if (ok) done();
      else tell("The browser did not let the page copy. The text is selected: press Ctrl+C.", false);
    };
    if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(it.prompt).then(done, byHand);
    else byHand();
  };
  // Share (17 Sep 2026). The server keeps a snapshot of this page under a random link; the synced
  // settings row carries it to the online copy, which shows it to anyone with the link.
  const shareCall = (method) => {
    const f = window.authFetch || fetch;
    return f("/api/reports/footprint/share", method === "GET" ? {} : { method, headers: { "Content-Type": "application/json" }, body: "{}" }).then(_fpJson);
  };
  const closeShare = () => {
    setShareBox(null);
    setTimeout(() => { try { shareBtnRef.current && shareBtnRef.current.focus(); } catch (_) {} }, 0);
  };
  const openShare = () => {
    setShareBox({ info: null, busy: true, error: "", confirmStop: false, copied: false });
    shareCall("GET")
      .then(info => setShareBox(b => (b ? { ...b, info, busy: false } : b)))
      .catch(e => setShareBox(b => (b ? { ...b, busy: false, error: e.message } : b)));
  };
  const changeShare = (method) => {
    setShareBox(b => (b ? { ...b, busy: true, error: "", confirmStop: false, copied: false } : b));
    shareCall(method)
      .then(info => {
        setShareBox(b => (b ? { ...b, info, busy: false } : b));
        tell(method === "DELETE" ? "Sharing stopped. The link no longer opens."
          : info.created ? "Link created. It opens for other people within a minute."
          : info.updated === false ? "The link already shows the latest figures." : "The link now shows the latest figures.", true);
      })
      .catch(e => setShareBox(b => (b ? { ...b, busy: false, error: e.message } : b)));
  };
  const copyShareLink = () => {
    const url = shareBox && shareBox.info && shareBox.info.url;
    if (!url) return;
    const done = () => {
      setShareBox(b => (b ? { ...b, copied: true } : b));
      tell("Link copied. Anyone with it can open the page.", true);
    };
    const byHand = () => {
      const input = document.getElementById("fp-share-link");
      let ok = false;
      if (input) {
        input.focus();
        input.select();
        try { ok = document.execCommand("copy"); } catch (_) {}
      }
      if (ok) done();
      else setShareBox(b => (b ? { ...b, error: "The browser did not let the page copy. The link is selected: press Ctrl+C." } : b));
    };
    if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(url).then(done, byHand);
    else byHand();
  };
  // Like the Edit Plan PDF: an A4-sized frame parked off screen (a tiny frame lays the page out at
  // 1px wide), the document title becomes the PDF's file name, fonts load before printing.
  const printStatement = async () => {
    if (printing || !data) return;
    setPrinting(true);
    setStatus({ text: "", error: false });
    const iframe = document.createElement("iframe");
    iframe.setAttribute("aria-hidden", "true");
    iframe.style.cssText = "position:fixed;left:-10000px;top:0;width:794px;height:1123px;border:0;opacity:0;pointer-events:none;";
    document.body.appendChild(iframe);
    const prevTitle = document.title;
    const docTitle = "AI footprint - " + String((data.project && data.project.name) || "project").replace(/[^\w. -]+/g, "").trim()
      + " - " + _fpLocalDay(data.generated_at);
    document.title = docTitle;
    try {
      // A slow font server must never leave the button on "Preparing…": after the time limit the sheet
      // prints with the fallback fonts.
      const wait = (ms) => new Promise(r => setTimeout(r, ms));
      await Promise.race([
        new Promise(res => { iframe.onload = () => res(); iframe.srcdoc = _fpStatementHtml(data, docTitle, _fpAppFontFace()); }),
        wait(5000),
      ]);
      const win = iframe.contentWindow, doc = iframe.contentDocument;
      if (!win || !doc || !doc.querySelector(".sentence")) throw new Error("the page did not load in time. Try again.");
      try { if (doc.fonts) await Promise.race([doc.fonts.ready, wait(3000)]); } catch (_) {}
      await wait(350);
      win.focus();
      win.print();
    } catch (e) {
      setStatus({ text: "The statement could not be printed: " + e.message, error: true });
    } finally {
      document.title = prevTitle;
      setTimeout(() => { try { iframe.remove(); } catch (_) {} }, 1500);
      setPrinting(false);
    }
  };

  if (!data) return <div className="rvx-loading">{error || "Working out the AI footprint…"}</div>;

  // ── The page (redesigned 17 Sep 2026 after Hugo: "the fonts so different and basic … the alignment off
  // … the table so lazily put … the title a proper mess"; his ChatGPT infographic is the reference).
  // One type scale (display figures 88 / 64 / 44 / 34, text 17 / 14 / 12.5, caps 11), one heading style
  // (kicker + display title), one grid per panel, and the multiples as the loudest thing on the page.
  const T = data.totals, A = data.almonds, K = data.comparisons;
  const d = draft || data.settings;
  const ws = d.workstation || data.settings.workstation;
  const cmpK = data.compare || {};
  const sharePct = (l) => (l.included && T.kg[1] > 0 ? Math.max(0, Math.min(100, (l.kg[1] / T.kg[1]) * 100)) : 0);
  const CO2 = <span className="fp-co2">CO<sub>2</sub>e</span>;
  const P = data.production || { minutes: null, rows: [] };
  const minutesText = P.minutes != null ? _fpSig(P.minutes, 3) : null;
  const noWater = !(T.litres[1] > 0);
  const water = _fpLitresParts(T.litres[1]), carbon = _fpKgParts(T.kg[1]), energy = _fpKwhParts(T.kwh[1]);
  const scope = String(data.basis || "").replace(/^Estimate\s*·\s*/, "");

  // "The same film, made another way". The wording follows the printed statement (_fpSCmp).
  const aiRow = P.rows.find(c => c.key === "ai") || P.rows[0];
  const others = P.rows.filter(c => c !== aiRow);
  const wsCounted = !!(data.settings && data.settings.workstation && data.settings.workstation.include);
  const cmpReady = !!(minutesText && aiRow && aiRow.tonnes && others.length);
  const splitLabel = (c) => {
    if (c === aiRow) return { kicker: "The AI way", name: wsCounted ? "This project, with this computer" : "This project, generative AI" };
    const m = /^(.*?)\s*\((.*)\)\s*$/.exec(c.label || "");
    if (!m) return { kicker: c.label, name: "" };
    return { kicker: m[1], name: m[2].charAt(0).toUpperCase() + m[2].slice(1) };
  };
  const shortName = (c) => (c.key === "filmed" ? "filming" : c.key === "vfx" ? "VFX" : c.label);
  const ax = cmpReady ? _fpAxis(Math.max(0, ...P.rows.map(c => c.tonnes[1]))) : null;
  const inAxisUnit = (t) => (!ax ? "—" : ax.unitWord === "Tonnes" ? _fpSig(t) : _fpSig(t * 1000));
  const axisUnit = ax && ax.unitWord === "Tonnes" ? "t" : "kg";

  const head = ({ kicker, title, sub, actions, xl }) => (
    <header className={"fp-head" + (xl ? " fp-head--xl" : "")}>
      <div className="fp-head-text">
        {kicker && <div className="fp-kicker">{kicker}</div>}
        <h3 className="fp-title">{title}</h3>
        {sub && <p className="fp-sub">{sub}</p>}
      </div>
      {actions && <div className="fp-head-actions">{actions}</div>}
    </header>
  );

  // The almond unit chart: one shape = u almonds (1, 2, 5, 10 …), at most 150 shapes.
  const almondChart = () => {
    const c = A[1] > 0 ? A[1] : 0;
    let u = 1;
    for (let k = 0; k < 12 && Math.round(c / u) > 150; k++) u = [1, 2, 5][(k + 1) % 3] * Math.pow(10, Math.floor((k + 1) / 3));
    const n = Math.round(c / u);
    const per = 20, pitchX = 26, pitchY = 32;
    const rows = Math.max(1, Math.ceil(Math.max(n, 1) / per));
    return (
      <figure className="fp-orchard">
        <svg className="fp-orchard-svg" viewBox={`0 0 ${per * pitchX} ${rows * pitchY}`} role="img"
          aria-label={n > 0 ? `${n} almond shapes, one for every ${u} almond${u === 1 ? "" : "s"}` : "less than one almond"}>
          <defs>
            <g id="fp-almond-glyph">
              <path className="fp-almond-shell" d="M9 1 C 15 7 17 16 13.4 23 C 11.6 26.6 6.4 26.6 4.6 23 C 1 16 3 7 9 1 Z"/>
              <path className="fp-almond-ridge" d="M9 4.5 C 10.4 10 10.4 17 9 24"/>
            </g>
          </defs>
          {(n > 0 ? Array.from({ length: n }) : [0]).map((_, i) => (
            <use key={i} href="#fp-almond-glyph" x={(i % per) * pitchX + 4} y={Math.floor(i / per) * pitchY + 2}
              className={n > 0 ? "" : "fp-almond-empty"}/>
          ))}
        </svg>
        <figcaption>
          <b>1 shape = {_fpInt(u)} almond{u === 1 ? "" : "s"}</b>
          <span>{_fpSig(data.almond.litres, 3)} L of water grows one almond · range {_fpSig(A[0])} – {_fpSig(A[2])}</span>
        </figcaption>
      </figure>
    );
  };

  const stat = (key, label, n, unit, sub) => (
    <div className={"fp-stat fp-stat--" + key}>
      <div className="fp-kicker">{label}</div>
      <div className="fp-stat-fig"><span className="fp-num">{n}</span><span className="fp-stat-unit">{unit}</span></div>
      <div className="fp-stat-sub">{sub}</div>
    </div>
  );
  const term = (v, label, basis) => (
    <div className="fp-term">
      <div className="fp-term-fig"><span className="fp-approx">≈</span><span className="fp-num">{_fpSig(v)}</span></div>
      <div className="fp-term-label">{label}</div>
      <div className="fp-term-basis">{basis}</div>
    </div>
  );

  // The bars: the label above, the value after the track, the multiple stacked on the right.
  const race = () => (
    <div className="fp-race" role="figure"
      aria-label={`${ax.unitWord} of CO2e for these ${minutesText} minutes: ` + P.rows.map(c => `${splitLabel(c).kicker} ${inAxisUnit(c.tonnes[1])} ${axisUnit}`).join(", ")}>
      {P.rows.map((c, i) => {
        const pct = ax.max > 0 ? Math.min(100, (c.tonnes[1] / ax.max) * 100) : 0;
        const cm = c === aiRow ? null : _fpSCmp(c, aiRow);
        const lab = splitLabel(c);
        return (
          <div key={c.key} className={"fp-race-row" + (c === aiRow ? " is-ai" : "")}>
            <div className="fp-race-label"><b>{lab.kicker}</b>{lab.name && <span>{lab.name}</span>}</div>
            <div className="fp-race-lane">
              <div className="fp-race-track" aria-hidden="true">
                <i className={c.tonnes[1] > 0 ? "has-value" : ""} style={{ width: pct + "%", animationDelay: i * 120 + "ms" }}/>
                {c.tonnes[1] > 0 && pct < 0.5 && <span className="fp-race-sliver">a sliver: too thin to see at this scale</span>}
              </div>
              <div className="fp-race-value"><span className="fp-num">{inAxisUnit(c.tonnes[1])}</span> {axisUnit}</div>
            </div>
            <div className="fp-race-mult">
              {c === aiRow ? <span className="fp-race-base">the baseline</span>
                : !cm ? <span className="fp-race-base">no comparison yet</span>
                : <>
                  <div className="fp-mult"><span className="fp-approx">≈</span><span className="fp-num">{cm.x}</span><span className="fp-times">×</span></div>
                  <div className="fp-mult-as">{cm.inv ? `as much for the AI way as for ${shortName(c)}` : "as much"}</div>
                  {(cm.floor || cm.overlap) && <div className="fp-mult-floor">{cm.floor ? <>at least <b>{cm.floor}×</b></> : "the ranges overlap"}</div>}
                </>}
            </div>
          </div>
        );
      })}
      <div className="fp-race-row fp-race-axis" aria-hidden="true">
        <div/>
        <div className="fp-race-lane">
          <div className="fp-race-ticks">{ax.ticks.map(t => <span key={t.f} style={{ left: t.f * 100 + "%" }}>{t.label}</span>)}</div>
          <div/>
        </div>
        <div/>
      </div>
      <p className="fp-race-caption">
        {ax.unitWord} of {CO2} for these {minutesText} minutes · central estimates · linear scale from zero ·
        “at least” compares the larger way’s low estimate with the smaller way’s high one
      </p>
    </div>
  );

  // Side by side: the ways as columns, grouped rows, one grid with the bars above.
  const sbsGroup = (label) => (
    <tr className="fp-cmp-group"><th colSpan={P.rows.length + 1} scope="colgroup">{label}</th></tr>
  );
  const sbsRow = (label, cell, note, kind) => (
    <tr className={kind ? "fp-cmp-row--" + kind : ""}>
      <th scope="row"><span>{label}</span>{note && <small>{note}</small>}</th>
      {P.rows.map(c => <td key={c.key} className={c === aiRow ? "is-ai" : ""}>{cell(c)}</td>)}
    </tr>
  );
  const pmCell = (c) => (c.perMinute ? <>{_fpTonnes(c.perMinute[1])}<small>{_fpRange(c.perMinute, x => _fpTonnes(x))}</small></> : "—");
  const srcCell = (c) => (c === aiRow ? "This statement" : <>
    {c.url ? <a href={c.url} target="_blank" rel="noopener noreferrer">{c.source}</a> : c.source}
    {c.confidence && <small>confidence {c.confidence}</small>}
  </>);
  const sbsHead = (rows) => (
    <thead><tr>
      <td className="fp-cmp-corner"/>
      {rows.map(c => {
        const lab = splitLabel(c);
        return (
          <th key={c.key} scope="col" className={c === aiRow ? "is-ai" : ""}>
            <span className="fp-kicker">{lab.kicker}</span>
            {lab.name && <span className="fp-cmp-name">{lab.name}</span>}
          </th>
        );
      })}
    </tr></thead>
  );
  const sideBySide = () => (
    <table className="fp-cmp">
      <colgroup><col className="fp-cmp-col-label"/>{P.rows.map(c => <col key={c.key}/>)}</colgroup>
      {sbsHead(P.rows)}
      <tbody>
        {sbsGroup("How much")}
        {sbsRow(<>{CO2} for these {minutesText} min</>, c => <span className="fp-cmp-fig"><span className="fp-num">{_fpTonnes(c.tonnes[1]).split(" ")[0]}</span> {_fpTonnes(c.tonnes[1]).split(" ")[1]}</span>, null, "big")}
        {sbsRow("Per finished minute", pmCell, "low – high under each figure")}
        {sbsRow("Range for these minutes", c => _fpRange(c.tonnes, x => _fpTonnes(x)))}
        {sbsGroup("The difference")}
        {sbsRow("Compared with the AI way", c => {
          if (c === aiRow) return <span className="fp-cmp-base">the baseline</span>;
          const cm = _fpSCmp(c, aiRow);
          if (!cm) return "—";
          return <span className="fp-cmp-mult"><span className="fp-approx">≈</span><span className="fp-num">{cm.x}</span><span className="fp-times">×</span>
            <small>{cm.inv ? "the AI way emitted this many times as much" : "as much"}</small></span>;
        }, null, "x")}
        {sbsRow(`Low-end comparison`, c => {
          if (c === aiRow) return "—";
          const cm = _fpSCmp(c, aiRow);
          return cm && cm.floor ? <b>at least {cm.floor}×</b> : cm && cm.overlap ? "the ranges overlap" : "—";
        }, `${wsCounted ? "AI plus this computer" : "AI inference only"}: the larger way’s low estimate against the smaller way’s high one`)}
        {sbsRow("The AI way as a share of it", c => (c === aiRow ? "—"
          : c.aiShare > 1 ? "≈ " + _fpSig(c.aiShare) + "× this way" : <b>{_fpShare(c.aiShare)}</b>))}
        {sbsGroup("In everyday terms")}
        {sbsRow("Return flights Vienna–London", c => (c.flights != null ? "≈ " + _fpSig(c.flights) : "—"), `≈ ${cmpK.flightKg || 190} kg CO2e each`)}
        {sbsRow("Driving a petrol car", c => {
          const dist = _fpDistance(c.carKm, data.compare);
          return <>{dist.km}{dist.far && <small>{dist.far}</small>}</>;
        }, `${cmpK.carKgPerKm || 0.2} kg CO2e per km`)}
        {sbsGroup("What it is")}
        {sbsRow("What the figure covers", c => c.includes, null, "text")}
        {sbsRow("Source", srcCell, null, "text")}
      </tbody>
    </table>
  );
  const ratesOnly = () => (
    <table className="fp-cmp">
      <colgroup><col className="fp-cmp-col-label"/>{others.map(c => <col key={c.key}/>)}</colgroup>
      {sbsHead(others)}
      <tbody>
        <tr><th scope="row"><span>Per finished minute</span><small>low – high under each figure</small></th>{others.map(c => <td key={c.key}>{pmCell(c)}</td>)}</tr>
        <tr className="fp-cmp-row--text"><th scope="row"><span>What the figure covers</span></th>{others.map(c => <td key={c.key}>{c.includes}</td>)}</tr>
        <tr className="fp-cmp-row--text"><th scope="row"><span>Source</span></th>{others.map(c => <td key={c.key}>{srcCell(c)}</td>)}</tr>
      </tbody>
    </table>
  );

  const actions = shared ? (
    <div className="fp-actions">
      <span className={"fp-status" + (status.error ? " is-error" : "")} aria-live="polite">{status.text}</span>
      <button type="button" className="rvx-costs-save fp-actions-main" disabled={printing} onClick={printStatement}>{printing ? "Preparing…" : "Print or save as PDF"}</button>
    </div>
  ) : (
    <div className="fp-actions">
      <button type="button" className="rvx-costs-add" onClick={downloadCsv}>Download CSV</button>
      <button type="button" className="rvx-costs-add" disabled={printing} onClick={printStatement}>{printing ? "Preparing…" : "Print statement"}</button>
      <button type="button" className="rvx-costs-add" ref={shareBtnRef} onClick={openShare}>Share</button>
      <button type="button" className="rvx-costs-save fp-actions-main" ref={copyBtnRef} onClick={openPrompts}>ChatGPT prompts</button>
    </div>
  );

  // The Share window. Heights stay put between its states (invariant 20): the body keeps a min-height.
  const sb = shareBox, si = sb && sb.info;
  const shareWindow = sb && ReactDOM.createPortal((
    <div className="modal-backdrop" style={{ zIndex: "var(--z-modal-2)" }}
      onMouseDown={(e) => { backdropDown.current = e.target === e.currentTarget; }}
      onClick={(e) => { if (backdropDown.current && e.target === e.currentTarget && !sb.busy) closeShare(); }}>
      <div className="confirm-delete-modal glass fp-link-modal" role="dialog" aria-modal="true" aria-labelledby="fp-share-title">
        <div className="confirm-delete-eyebrow" style={{ color: "var(--ink-gold)" }}>Share</div>
        <div className="confirm-delete-title" id="fp-share-title">{si && si.shared ? "This page has a public link" : "Share this page with anyone"}</div>
        <div className="confirm-delete-body fp-link-body">
          {!si && !sb.error && <p className="fp-link-note">Checking the link…</p>}
          {si && si.shared && (
            <>
              <div className="fp-link-row">
                <input id="fp-share-link" className="fp-link-input" aria-label="Public link" readOnly value={si.url || ""}
                  onFocus={(e) => e.target.select()}/>
                <button type="button" className="rvx-costs-save" disabled={!si.url} onClick={copyShareLink}>{sb.copied ? "Copied" : "Copy link"}</button>
              </div>
              <p className="fp-link-note">
                Last updated {_fpWhen(si.published_at, true)}.{si.published_by ? <> Shared by {si.published_by}.</> : null}
                {si.url && <> <a href={si.url} target="_blank" rel="noopener noreferrer">Open the page</a></>}
              </p>
            </>
          )}
          {si && (
            <p>Anyone with the link can open a read-only copy of this page. They do not need an account.
              The copy leaves out the assumptions and the call log.
              It updates itself when the figures change, at most every 10 minutes.</p>
          )}
          {si && si.shared && si.stale && <p className="fp-link-note">The figures changed since the last update.</p>}
          {si && !si.syncs && (
            <p className="confirm-delete-warn">This project does not sync to the online tracker, so nobody else could open a link. Sharing works for the project that syncs.</p>
          )}
          {si && si.syncs && !si.can_share && (
            <p className="fp-link-note">Producers and admins can {si.shared ? "update or stop" : "create"} the link.</p>
          )}
          {sb.confirmStop && (
            <p className="confirm-delete-warn">Stop sharing? The link stops working for everyone. A new link will be a different address.</p>
          )}
          <p className={"fp-link-error" + (sb.error ? " is-on" : "")} aria-live="polite">{sb.error}</p>
        </div>
        <div className="confirm-delete-actions">
          {/* Keyed, so each set mounts fresh and autoFocus lands on the safe button, never on "Stop sharing". */}
          {sb.confirmStop ? (
            <React.Fragment key="confirm">
              <button type="button" className="admin-suspend-btn" disabled={sb.busy} onClick={() => setShareBox(b => ({ ...b, confirmStop: false }))} autoFocus>Keep sharing</button>
              <button type="button" className="confirm-delete-btn" disabled={sb.busy} onClick={() => changeShare("DELETE")}>{sb.busy ? "Stopping…" : "Stop sharing"}</button>
            </React.Fragment>
          ) : (
            <React.Fragment key="normal">
              {si && si.shared && si.can_share && si.syncs && (
                <button type="button" className="admin-suspend-btn fp-link-stop" disabled={sb.busy} onClick={() => setShareBox(b => ({ ...b, confirmStop: true }))}>Stop sharing</button>
              )}
              {/* Never disabled: it takes the focus while the window loads, and a request may finish after it closes. */}
              <button type="button" className="admin-suspend-btn" onClick={closeShare} autoFocus>Close</button>
              {si && si.shared && si.can_share && si.syncs && si.stale && (
                <button type="button" className="rvx-costs-add" disabled={sb.busy} onClick={() => changeShare("POST")}>{sb.busy ? "Updating…" : "Update now"}</button>
              )}
              {si && !si.shared && si.can_share && si.syncs && (
                <button type="button" className="rvx-costs-save" disabled={sb.busy} onClick={() => changeShare("POST")}>{sb.busy ? "Creating…" : "Create link"}</button>
              )}
            </React.Fragment>
          )}
        </div>
      </div>
    </div>
  ), document.getElementById("modal-root") || document.body);

  return (
    <div className={"fp-root" + (shared ? " fp-root--shared" : "")}>
      {shareWindow}
      {promptBox && ReactDOM.createPortal((
        <div className="modal-backdrop" style={{ zIndex: "var(--z-modal-2)" }}
          onMouseDown={(e) => { backdropDown.current = e.target === e.currentTarget; }}
          onClick={(e) => { if (backdropDown.current && e.target === e.currentTarget) closePromptBox(); }}>
          <div className="confirm-delete-modal glass fp-prompt-modal" role="dialog" aria-modal="true" aria-labelledby="fp-prompt-title">
            <div className="confirm-delete-eyebrow" style={{ color: "var(--ink-gold)" }}>ChatGPT prompts</div>
            <div className="confirm-delete-title" id="fp-prompt-title">{promptBox.items.length === 1 ? "One image for ChatGPT" : `${promptBox.items.length} images for ChatGPT`}</div>
            <div className="confirm-delete-body">
              <p>Paste one prompt per message. Each one makes one portrait image (1024 × 1536); together they form a set.
                Do not paste the printed statement: pages of small text come out garbled.</p>
              <ol className="fp-prompt-list">
                {promptBox.items.map(it => (
                  <li key={it.key} className={promptBox.copied === it.key ? "is-copied" : ""}>
                    <div className="fp-prompt-row">
                      <div className="fp-prompt-meta"><b>{it.title}</b><span>{it.what}</span></div>
                      <button type="button" className="rvx-costs-add" onClick={() => copyPromptItem(it)}>
                        {promptBox.copied === it.key ? "Copied" : "Copy"}
                      </button>
                    </div>
                    <textarea id={"fp-prompt-" + it.key} className="fp-prompt-text" aria-label={"Prompt " + it.title} readOnly value={it.prompt}
                      onFocus={(e) => e.target.select()}/>
                  </li>
                ))}
              </ol>
            </div>
            <div className="confirm-delete-actions">
              <button type="button" className="admin-suspend-btn" onClick={closePromptBox} autoFocus>Close</button>
            </div>
          </div>
        </div>
      ), document.getElementById("modal-root") || document.body)}

      <section className="rv-panel fp-card fp-card--lead">
        {head({ kicker: shared ? <>Estimate · published {_fpWhen(shared.published_at || data.generated_at, true)}</> : <>Estimate · updated {_fpWhen(data.generated_at, true)}</>, title: "AI footprint", xl: true, actions })}
        <p className="fp-scope">{scope}</p>
        <div className="fp-hero">
          <div className="fp-hero-text">
            <div className="fp-kicker">The answer</div>
            <p className="fp-hero-line">{noWater
              ? <>This project has no counted AI water use yet.</>
              : <>This project took <strong>{_fpLitres(T.litres[1])} of water</strong>, the equivalent of growing <strong>≈ {_fpSig(A[1])} almonds</strong>.</>}</p>
            <p className="fp-hero-note">{data.almond_note}</p>
          </div>
          {!noWater && almondChart()}
        </div>
        <div className="fp-stats">
          {stat("water", "Water", water.n, water.u, <>≈ {_fpSig(A[1])} almonds · range {_fpRange(T.litres, x => _fpLitres(x))}</>)}
          {stat("carbon", "Greenhouse gases", carbon.n, <>{carbon.u} {CO2}</>, <>range {_fpRange(T.kg, x => _fpKg(x))}</>)}
          {stat("energy", "Electricity", energy.n, energy.u, <>incl. data-centre overhead · range {_fpRange(T.kwh, x => _fpKwh(x))}</>)}
        </div>
        <div className="fp-terms">
          <div className="fp-terms-head"><div className="fp-kicker">In everyday terms</div><span>central estimate</span></div>
          {term(K.flights, "return flights Vienna–London", <>economy, ≈ {cmpK.flightKg} kg {CO2} each</>)}
          {term(K.carKm, "km in a petrol car", <>{cmpK.carKgPerKm} kg {CO2} per km</>)}
          {term(K.phoneCharges, "phone charges", <>{cmpK.phoneChargeWh} Wh each</>)}
          {term(K.householdDays, "days of an EU home’s electricity", <>≈ {cmpK.householdKwhPerDay} kWh a day</>)}
        </div>
      </section>

      {P.rows.length > 1 && (
        <section className="rv-panel fp-card">
          {head({
            kicker: "Filmed for real, or made with VFX",
            title: cmpReady ? `The same ${minutesText} minutes, three ways` : "The same film, made another way",
            sub: cmpReady
              ? <>What each way would have emitted for the AI-made screen time ({P.minutes_source}). Industry averages, not a measured saving.</>
              : shared ? <>The AI-made screen time is not set yet, so here are the other ways’ rates per finished minute:</>
                : <>Set the AI-made screen time under Assumptions to compare. Until then, the other ways’ rates:</>,
          })}
          {cmpReady ? <>
            {race()}
            <div className="fp-cmp-wrap">{sideBySide()}</div>
          </> : <div className="fp-cmp-wrap">{ratesOnly()}</div>}
          <p className="fp-foot">{data.comparison_note}</p>
        </section>
      )}

      {!shared && <section className="rv-panel fp-card">
        {head({ kicker: "Settings", title: "Assumptions", sub: canEdit ? "Saved for the whole project." : "Producers and admins can change these." })}
        <div className="rvx-costs-form fp-form">
          <label><span>Topview credits used</span>
            <input id="fp-credits-used" type="number" min="0" step="1" disabled={!canEdit}
              value={d.credits_used != null ? d.credits_used : ""}
              placeholder={_fpInt(data.defaults.credits_used) + " (from Costs I've paid)"}
              onChange={e => setField("credits_used", numOrNull(e.target.value))}/></label>
          <label><span>Credits per video second</span>
            <input id="fp-credits-per-second" type="number" step="0.1" disabled={!canEdit}
              min={data.defaults.credits_per_second_min} max={data.defaults.credits_per_second_max}
              value={d.credits_per_second != null ? d.credits_per_second : ""}
              onChange={e => setField("credits_per_second", numOrNull(e.target.value))}/></label>
          {P.rows.length > 1 && (
            <label><span>AI-made screen time (min)</span>
              <input id="fp-screen-minutes" type="number" min="0.1" max="1000" step="0.1" disabled={!canEdit}
                value={d.screen_minutes != null ? d.screen_minutes : ""}
                placeholder={data.defaults.screen_minutes != null ? _fpSig(data.defaults.screen_minutes, 3) + " (from the edit)" : "minutes"}
                onChange={e => setField("screen_minutes", numOrNull(e.target.value))}/></label>
          )}
          <label><span>Count this computer</span>
            <select id="fp-ws-include" disabled={!canEdit} value={ws.include ? "yes" : "no"}
              onChange={e => setField("workstation", e.target.value === "yes", "include")}>
              <option value="no">No (it is not AI)</option>
              <option value="yes">Yes</option>
            </select></label>
          <label><span>Computer hours</span>
            <input id="fp-ws-hours" type="number" min="0" step="1" disabled={!canEdit}
              value={ws.hours != null ? ws.hours : ""}
              placeholder={data.defaults.rize_hours != null ? _fpInt(data.defaults.rize_hours) + " (from Rize)" : "hours"}
              onChange={e => setField("workstation", numOrNull(e.target.value), "hours")}/></label>
          <label><span>Average watts</span>
            <input id="fp-ws-watts" type="number" min="10" max="3000" step="10" disabled={!canEdit}
              value={ws.watts != null ? ws.watts : ""}
              onChange={e => setField("workstation", numOrNull(e.target.value), "watts")}/></label>
          <label><span>Computer’s grid</span>
            <select id="fp-ws-region" disabled={!canEdit} value={ws.region}
              onChange={e => setField("workstation", e.target.value, "region")}>
              {data.regions.map(r => <option key={r.key} value={r.key}>{r.label} · {r.kg} kg/kWh</option>)}
            </select></label>
          <div className="rvx-costs-actions fp-form-actions">
            <span className={"fp-status" + (status.error ? " is-error" : "")} aria-live="polite">{status.text}</span>
            {canEdit && <button type="button" className="rvx-costs-add" disabled={busy} onClick={() => save(true)}>Reset to defaults</button>}
            {canEdit && <button type="button" className="rvx-costs-save" disabled={busy || !dirty} onClick={() => save(false)}>{busy ? "Saving…" : "Save"}</button>}
          </div>
        </div>
        <p className="fp-foot">
          Video seconds are the larger of two figures: the length of the {_fpInt(data.video.takes)} takes in the tracker
          ({_fpInt(data.video.kept_seconds)} s; takes not measured yet count as {_fpSig(data.defaults.avg_requested_seconds, 3)} s),
          or the Topview credits ÷ credits per second ({_fpInt(data.video.credit_seconds)} s, which includes discarded renders).
          Topview charges 0.7 credits a second at 480p, 1.5 at 720p and 3.7 at 1080p (Seedance 2.5).
        </p>
      </section>}

      <section className="rv-panel fp-card">
        {head({ kicker: "Central estimate", title: "Where it comes from", sub: "Largest first. The CSV has the low and high figure for every line." })}
        <div className="fp-cmp-wrap">
          <table className="fp-lines">
            <colgroup>
              <col className="fp-lines-activity"/><col className="fp-lines-count"/><col className="fp-lines-unit"/>
              <col className="fp-lines-n"/><col className="fp-lines-n"/><col className="fp-lines-n"/><col className="fp-lines-n"/><col className="fp-lines-share"/>
            </colgroup>
            <thead><tr>
              <th scope="col">Activity</th>
              <th scope="col" className="is-num">Count</th>
              <th scope="col">Energy per unit <small>low / central / high</small></th>
              <th scope="col" className="is-num">kWh</th>
              <th scope="col" className="is-num">kg {CO2}</th>
              <th scope="col" className="is-num">Water</th>
              <th scope="col" className="is-num">Almonds</th>
              <th scope="col">Share of {CO2}</th>
            </tr></thead>
            <tbody>{[...data.lines].sort((a, b) => (b.included - a.included) || (b.kg[1] - a.kg[1])).map(l => (
              <tr key={l.key} className={l.included ? "" : "is-off"}>
                <td><span className="fp-lines-name">{l.label}</span>{l.detail && <small>{l.detail}</small>}</td>
                <td className="is-num"><span className="fp-lines-count-n">{_fpInt(l.count)}</span> <small className="fp-inline">{l.unit}</small></td>
                <td>{l.perUnit
                  ? <><span className="fp-lines-pu">{_fpSig(l.perUnit[0], 3)} / <b>{_fpSig(l.perUnit[1], 3)}</b> / {_fpSig(l.perUnit[2], 3)}</span><small>{l.perUnitLabel}</small></>
                  : "—"}</td>
                <td className="is-num">{l.included ? _fpCell(l.kwh[1]) : <small className="fp-inline">not counted</small>}</td>
                <td className="is-num">{l.included ? _fpCell(l.kg[1]) : "—"}</td>
                <td className="is-num">{l.included ? _fpLitresCell(l.litres[1]) : "—"}</td>
                <td className="is-num">{l.included ? _fpCell(l.almonds[1]) : "—"}</td>
                <td>{l.included && <div className="fp-share"><div className="fp-share-bar"><i style={{ width: sharePct(l) + "%" }}/></div><span>{sharePct(l) > 0 && sharePct(l) < 1 ? "< 1" : Math.round(sharePct(l))} %</span></div>}</td>
              </tr>
            ))}</tbody>
            <tfoot><tr>
              <th scope="row">Total</th><td/><td/>
              <td className="is-num">{_fpCell(T.kwh[1])}</td>
              <td className="is-num">{_fpCell(T.kg[1])}</td>
              <td className="is-num">{_fpLitresCell(T.litres[1])}</td>
              <td className="is-num">{_fpCell(A[1])}</td>
              <td><div className="fp-share"><div className="fp-share-bar is-blank"/><span>100 %</span></div></td>
            </tr></tfoot>
          </table>
        </div>
      </section>

      <div className={"fp-pair" + (shared ? " fp-pair--single" : "")}>
        {!shared && <section className="rv-panel fp-card">
          {head({ kicker: "Measured from now on", title: "The call log", sub: data.log.since ? <>Every AI call since {_fpWhen(data.log.since, true)}.</> : "The log starts with the next AI call." })}
          <div className="fp-log-figs">
            <div><span className="fp-num">{_fpInt(data.log.calls)}</span><small>calls logged live</small></div>
            <div><span className="fp-num">{_fpInt(data.log.tokens)}</span><small>tokens</small></div>
            <div><span className="fp-num">{_fpInt(data.log.backfillCalls)}</span><small>earlier calls, from records</small></div>
          </div>
          {data.log.byModel.length > 0 && (
            <table className="fp-lines fp-lines--compact">
              <thead><tr><th scope="col">Provider</th><th scope="col">Model</th><th scope="col" className="is-num">Calls</th><th scope="col" className="is-num">Tokens in</th><th scope="col" className="is-num">Tokens out</th></tr></thead>
              <tbody>{data.log.byModel.map((m, i) => (
                <tr key={i}>
                  <td>{m.provider}</td><td>{m.model || "—"}</td>
                  <td className="is-num">{_fpInt(m.calls)}</td>
                  <td className="is-num">{_fpInt(m.input_tokens)}</td>
                  <td className="is-num">{_fpInt(m.output_tokens)}</td>
                </tr>
              ))}</tbody>
            </table>
          )}
          <p className="fp-foot">
            The earlier text and voice calls come from the craft history, the edit-OCR caches, the caption records and the
            voice takes; their models are the defaults in the code. Image jobs are counted one by one from the generation queue.
          </p>
        </section>}

        <section className="rv-panel fp-card">
          {head({ kicker: "Honest limits", title: "What this leaves out", sub: "Say these out loud when you quote the figures." })}
          <div className="fp-lists">
            <div><div className="fp-kicker">Left out</div><ul>{data.exclusions.map((x, i) => <li key={i}>{x}</li>)}</ul></div>
            <div><div className="fp-kicker">Do not claim</div><ul>{data.dont_claim.map((x, i) => <li key={i}>{x}</li>)}</ul></div>
          </div>
        </section>
      </div>

      <section className="rv-panel fp-card">
        {head({ kicker: `Factors ${data.factors_version}`, title: "How this was calculated", sub: "Low, central and high value for every factor, with its basis and source." })}
        <div className="fp-cmp-wrap">
          <table className="fp-lines fp-lines--method">
            <colgroup><col style={{ width: "19%" }}/><col style={{ width: "6%" }}/><col style={{ width: "6%" }}/><col style={{ width: "6%" }}/><col style={{ width: "13%" }}/><col/><col style={{ width: "8%" }}/></colgroup>
            <thead><tr>
              <th scope="col">Factor</th><th scope="col" className="is-num">Low</th><th scope="col" className="is-num">Central</th><th scope="col" className="is-num">High</th>
              <th scope="col">Unit</th><th scope="col">Basis and source</th><th scope="col">Confidence</th>
            </tr></thead>
            <tbody>{data.method.map((m, i) => (
              <tr key={i}>
                <td><span className="fp-lines-name">{m.item}</span></td>
                <td className="is-num">{m.low}</td>
                <td className="is-num"><b>{m.central}</b></td>
                <td className="is-num">{m.high}</td>
                <td><small className="fp-inline">{m.unit}</small></td>
                <td className="fp-lines-basis">{m.basis}{m.url ? <> · <a href={m.url} target="_blank" rel="noopener noreferrer">source</a></> : null}</td>
                <td><span className={"fp-conf fp-conf--" + String(m.confidence).replace(/\s+/g, "-")}>{m.confidence}</span></td>
              </tr>
            ))}</tbody>
          </table>
        </div>
      </section>
    </div>
  );
}

window.FootprintReport = FootprintReport;
