/* global React, Icon, Brand, t, window, document, setTimeout, clearTimeout, setInterval, clearInterval */
/*
 * First run — the crunch, then the reveal.
 *
 * The prototype's problem was never the analysis; it was that the analysis
 * arrived as furniture. A rep opened the app and a card was simply there,
 * already knowing things, with no account of where any of it came from. So
 * the first thing Nudge does now is show its work: it reads the book in front
 * of you, walks you through what it found one beat at a time, and only then
 * hands over the workspace.
 *
 * Three acts:
 *   1. The crunch — a locked screen while the engine loads. Every stage is a
 *      real stage and every number is the real number; the only thing staged
 *      is the pacing, because the corpora are local and the true runtime is
 *      about 200ms, which reads as nothing happening at all.
 *   2. The reveal — six beats building one argument, landing on the finding
 *      that the champion always drifted BEFORE the competitor appeared.
 *   3. The hand-off — the last card shrinks into home slot 1 and the app
 *      comes up behind it, so what you were just shown has an address.
 *
 * The overlay sits ON TOP of a fully rendered app. That is deliberate:
 * everything underneath is warm and laid out, which is what makes the closing
 * transform into the real widget possible at all.
 */
(function () {
  "use strict";

  const { useState, useEffect, useRef, useMemo } = React;

  // A floor per stage, not a fixed duration: a stage ends when the real work
  // is done OR the floor has elapsed, whichever is later.
  // Tight enough that nobody waits, slow enough that each line is readable.
  // ~5.5s total rather than ~8.5s: the point is to show the work, not to make
  // anyone sit through it.
  const STAGE_FLOOR = [700, 1500, 1000, 1000, 1400, 900];
  const BEAT_DWELL = 4400;

  const fmt = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  const money = (v) => (Math.abs(v) >= 1e6 ? `$${(v / 1e6).toFixed(1)}M` : `$${Math.round(v / 1000)}K`);

  // A number that counts up to its target. Not decoration — the figure it
  // lands on is the one the next screen argues from.
  function useCountUp(target, run, ms) {
    const [v, setV] = useState(0);
    useEffect(() => {
      if (!run || !target) { setV(run ? target || 0 : 0); return undefined; }
      const start = Date.now(), dur = ms || 900;
      const id = setInterval(() => {
        const p = Math.min(1, (Date.now() - start) / dur);
        setV(Math.round(target * (1 - Math.pow(1 - p, 3))));
        if (p >= 1) clearInterval(id);
      }, 40);
      return () => clearInterval(id);
    }, [target, run]);
    return v;
  }


  // ── the book, assembling ─────────────────────────────────────────────────
  // Fifty-eight marks, one per closed deal, that re-form into a different
  // arrangement at every stage. The movement is not decoration: each
  // formation IS what that stage just worked out, so the field reorganising
  // is the analysis happening rather than a picture of it.
  //
  //   read        an undifferentiated grid — the book as it arrives
  //   timelines   splits by whether the buyer is visible at all
  //   conditions  re-forms by outcome: won, lost, no decision
  //   cross-ref   the ten the CRM blamed on a competitor pull out
  //
  // Positions are computed per formation and applied as transforms, so the
  // marks travel between arrangements instead of cutting.
  const MARK = 18, CELL = 26;

  function grid(items, cols, ox, oy) {
    return items.map((idx, i) => ({
      idx,
      x: ox + (i % cols) * CELL,
      y: oy + Math.floor(i / cols) * CELL,
    }));
  }

  function formationFor(marks, stage) {
    const all = marks.map((_, i) => i);
    const pick = (fn) => all.filter((i) => fn(marks[i]));

    // read / connect — one block, nothing distinguished yet
    if (stage <= 2) {
      return { places: grid(all, 20, 0, 0), groups: [] };
    }
    // timelines — split by whether we can see the buyer at all
    if (stage === 3) {
      const seen = pick((m) => m.timeline);
      const unseen = pick((m) => !m.timeline);
      return {
        places: grid(seen, 8, 0, 0).concat(grid(unseen, 10, 252, 0)),
        groups: [
          { label: "buyer visible", x: 0, n: seen.length },
          { label: "CRM record only", x: 252, n: unseen.length },
        ],
      };
    }
    // conditions — re-form by outcome
    if (stage === 4) {
      const won = pick((m) => m.outcome === "won");
      const lost = pick((m) => m.outcome === "lost");
      const none = pick((m) => m.outcome === "nodecision");
      return {
        places: grid(won, 6, 0, 0).concat(grid(lost, 6, 200, 0)).concat(grid(none, 2, 420, 0)),
        groups: [
          { label: "won", x: 0, n: won.length },
          { label: "lost", x: 200, n: lost.length },
          { label: "no decision", x: 420, n: none.length },
        ],
      };
    }
    // cross-reference — the ones the CRM blamed on a competitor pull out
    const blamed = pick((m) => m.blamed);
    const rest = pick((m) => !m.blamed);
    return {
      // The gap here is wider than the blocks need: the caption
      // "blamed on a competitor" is long, and a label running into its
      // neighbour is the one thing that would make this look like decoration.
      places: grid(blamed, 5, 0, 0).concat(grid(rest, 9, 286, 0)),
      groups: [
        { label: "blamed on a competitor", x: 0, n: blamed.length },
        { label: "everything else", x: 286, n: rest.length },
      ],
    };
  }

  function Book({ marks, stage, cursor, scanning, showLabels = true }) {
    const { places, groups } = useMemo(() => formationFor(marks, stage), [marks, stage]);
    const pos = useMemo(() => {
      const o = {};
      places.forEach((p2) => { o[p2.idx] = p2; });
      return o;
    }, [places]);
    const height = Math.max(...places.map((p2) => p2.y)) + CELL + 18;

    return (
      <div className="fr-book" style={{ height }} aria-hidden>
        {marks.map((m, i) => {
          const at = pos[i] || { x: 0, y: 0 };
          return (
            <span
              key={i}
              className={[
                "fr-mark",
                // During the first pass a mark appears only once its deal has
                // actually been reached.
                stage > 1 || (stage === 1 && i < cursor) ? "is-in" : "",
                scanning && i === cursor - 1 ? "is-at" : "",
                stage >= 3 && m.timeline ? "has-timeline" : "",
                stage >= 4 ? `is-${m.outcome}` : "",
                stage >= 5 && m.blamed ? "is-blamed" : "",
                stage >= 5 && !m.blamed ? "is-receded" : "",
              ].join(" ")}
              style={{
                transform: `translate(${at.x}px, ${at.y}px)`,
                transitionDelay: stage === 1 ? "0ms" : `${(i % 20) * 12}ms`,
              }}
            />
          );
        })}
        {(showLabels ? groups : []).map((g) => (
          <span key={g.label} className="fr-book-label" style={{ left: g.x, top: height - 16 }}>
            <b>{g.n}</b> {t(g.label)}
          </span>
        ))}
      </div>
    );
  }

  // ─────────────────────────── Act 1 · the crunch ──────────────────────────
  function Crunch({ facts, onDone, onSkip }) {
    const [stage, setStage] = useState(0);
    const [done, setDone] = useState([]);
    const [, tick] = [useState(0)[0], useState(0)[1]];
    const [elapsed, setElapsed] = useState(0);
    const [cursor, setCursor] = useState(0);
    // How long this stage has been waiting on data it cannot proceed without.
    const [blockedSince, setBlockedSince] = useState(null);
    const [, setTick] = useState(0);

    const STAGES = useMemo(() => [
      { key: "connect", verb: "Connecting",
        line: "Connecting to HubSpot, Gmail and Calendar",
        doneLine: "Connected · HubSpot, Gmail, Calendar" },
      { key: "read", verb: "Reading",
        line: "Reading every opportunity that has already closed",
        // No count here. This line is written before the buyer data arrives,
        // and a logged figure never updates — "Read the closed book — 35
        // opportunities" sitting above a screen that then says 58 is the kind
        // of contradiction that costs you the reader.
        doneLine: t("Read every deal already closed in your CRM") },
      { key: "timeline", verb: "Rebuilding",
        line: "Rebuilding buyer timelines from email, calls, LinkedIn and Slack",
        doneLine: t("{closed} deals in the book, {n} with buyer timelines",
                    { closed: fmt(facts.closed), n: fmt(facts.timelines) }) },
      { key: "signals", verb: "Detecting",
        line: "Detecting the signals a CRM never records",
        doneLine: t("Detected {n} signals a CRM never records", { n: fmt(facts.markers) }) },
      { key: "test", verb: "Testing",
        line: "Testing every condition against every closed deal",
        doneLine: t("Tested {n} conditions against all {closed} deals", { n: facts.conditions, closed: facts.closed }) },
      { key: "match", verb: "Cross-referencing",
        line: "Cross-referencing what your CRM recorded against what the buyer did",
        doneLine: t("Matched {n} open deals against the losing patterns", { n: facts.open }) },
    ], [facts]);

    useEffect(() => {
      const id = setInterval(() => setElapsed((n) => n + 1), 1000);
      return () => clearInterval(id);
    }, []);
    // The read is a walk, not a flash: stage 1 visits every deal in the book
    // and stage 4 goes back over all of them to test the conditions. The
    // cursor drives both, so the field advances WITH the work rather than
    // appearing all at once when it happens to finish.
    const SCAN_STAGES = { 1: "read", 4: "test" };
    useEffect(() => {
      const kind = SCAN_STAGES[stage];
      if (!kind) return undefined;
      setCursor(0);
      const total = facts.marks.length;
      const step = Math.max(12, Math.floor((STAGE_FLOOR[stage] - 150) / total));
      const id = setInterval(() => {
        setCursor((c) => (c >= total ? c : c + 1));
      }, step);
      return () => clearInterval(id);
    }, [stage, facts.marks.length]);

    // The stage advances when its floor has elapsed AND the engine is ready.
    // `facts.ready` is the real signal; the floor only stops it flashing past.
    useEffect(() => {
      if (stage >= STAGES.length) return undefined;
      // The floor makes a stage legible; `ready` makes it true. Everything from
      // "Rebuilding timelines" on depends on the buyer event data, so that is
      // where a slow read waits — not at the end, claiming to have read a book
      // it has not received. Stages 1 and 2 run on the synchronous corpora and
      // never block.
      if (stage >= 2 && !facts.ready) {
        setBlockedSince((b) => b || Date.now());
        const poll = setInterval(() => setTick((n) => n + 1), 250);
        return () => clearInterval(poll);
      }
      setBlockedSince(null);
      const id = setTimeout(() => {
        setDone((d) => d.concat([STAGES[stage]]));
        setStage((n) => n + 1);
      }, STAGE_FLOOR[stage] || 1100);
      return () => clearTimeout(id);
    }, [stage, STAGES, facts.ready]);

    useEffect(() => {
      if (stage < STAGES.length) return undefined;
      const id = setTimeout(onDone, 650);
      return () => clearTimeout(id);
    }, [stage, STAGES.length]);

    const scanning = !!SCAN_STAGES[stage];
    const seen = facts.marks.slice(0, stage <= 1 ? cursor : facts.marks.length);
    // Lost and no-decision are counted apart, the same way every other
    // surface counts them — a running tally that says "32 lost" and a
    // formation two seconds later that says "29 lost, 3 no decision" is the
    // kind of small contradiction that costs you the whole screen.
    const running = useMemo(() => seen.reduce((a, m) => ({
      value: a.value + m.value,
      won: a.won + (m.outcome === "won" ? 1 : 0),
      lost: a.lost + (m.outcome === "lost" ? 1 : 0),
      none: a.none + (m.outcome === "nodecision" ? 1 : 0),
      signals: a.signals + m.signals,
    }), { value: 0, won: 0, lost: 0, none: 0, signals: 0 }), [seen.length]);

    const nConditions = useCountUp(facts.conditions, stage > 4, 500);
    const current = STAGES[stage] || null;
    // A wait with no end in sight is the one thing a progress screen must not
    // pretend about. Past the point where the read is clearly not instant, the
    // copy stops claiming progress and starts telling the truth: how long it
    // has been, that the size of the book is why, and that leaving is fine.
    const waited = blockedSince ? Math.floor((Date.now() - blockedSince) / 1000) : 0;
    const slow = waited >= 8;
    const verySlow = waited >= 30;
    const stranded = waited >= 45;
    // The deal under the cursor right now — named, priced, and with what it
    // cost. A stream of real accounts is worth more than a spinner.
    const at = scanning ? facts.marks[Math.min(cursor, facts.marks.length - 1)] : null;

    return (
      <div className="fr-crunch">
        <div className="fr-brand"><Brand /></div>

        <Book marks={facts.marks} stage={stage} cursor={cursor} scanning={scanning} />

        <ul className="fr-log">
          {done.map((s, i) => (
            <li
              key={s.key}
              className="fr-log-row"
              /* Older lines recede so the eye stays on what just finished. */
              style={{ opacity: Math.max(0.32, 1 - (done.length - 1 - i) * 0.22) }}
            >
              <span className="fr-tick" aria-hidden><Icon name="check" size={10} /></span>
              {/* Already translated and interpolated in STAGES — running it
                  through t() again would look up an English sentence with the
                  numbers baked in, which no dictionary can ever match. */}
              <span>{s.doneLine}</span>
            </li>
          ))}
        </ul>

        {current && (
          <div className="fr-live">
            <span className="fr-verb">{t(current.verb)}…</span>
            <p className="fr-live-line">
              {slow ? t("Still reading. Large books take a minute or two.") : t(current.line)}
            </p>
            {slow && (
              <p className="fr-waiting mono">
                {t("{n}s on this step", { n: waited })}
                {verySlow && (
                  <span className="fr-waiting-leave">
                    {t("You can leave this tab open — it keeps going.")}
                  </span>
                )}
              </p>
            )}
            {/* No key on the cursor: remounting this every ~24ms restarted its
                fade-in on every deal, so it never got past a few percent
                opacity and the line was effectively invisible. The text
                changes in place — at this speed that reads as a counter,
                which is what it is. */}
            {at && (
              <p className="fr-stream mono">
                <span className="fr-stream-name">{at.account}</span>
                <span className="fr-stream-val">{money(at.value)}</span>
                <span className={`fr-stream-out is-${at.outcome}`}>
                  {at.outcome === "won" ? t("won") : at.outcome === "lost" ? t("lost") : t("no decision")}
                </span>
                {at.cycleDays != null && (
                  <span className="fr-stream-days">{t("{n} days", { n: at.cycleDays })}</span>
                )}
                {at.signals > 0 && (
                  <span className="fr-stream-sig">{t("{n} signals", { n: at.signals })}</span>
                )}
              </p>
            )}
          </div>
        )}

        {/* The meter ACCUMULATES. Showing the final figures from the first
            frame would say the work was already done, which is the opposite
            of what this screen is for — each number appears only once the
            stage that produced it has finished, and counts up to itself. */}
        <div className={`fr-progress ${slow ? "is-waiting" : ""}`} aria-hidden>
          {STAGES.map((s2, i) => (
            <span key={s2.key} className={`fr-progress-seg ${i < stage ? "is-done" : ""} ${i === stage ? "is-live" : ""}`} />
          ))}
        </div>

        {/* Totals that rise WITH the read. Nothing here is known before the
            deal it came from has been visited, which is the difference
            between a progress bar and watching work happen. */}
        <p className="fr-meter mono">
          <span className="fr-meter-time">{elapsed}s</span>
          {seen.length > 0 && (
            <span className="fr-meter-cell">
              <span className="fr-dot" aria-hidden>·</span>
              {t("{n} of {of} read", { n: seen.length, of: facts.closed })}
            </span>
          )}
          {running.value > 0 && (
            <span className="fr-meter-cell">
              <span className="fr-dot" aria-hidden>·</span>
              {money(running.value)}
            </span>
          )}
          {stage >= 2 && (
            <span className="fr-meter-cell">
              <span className="fr-dot" aria-hidden>·</span>
              {t("{won} won / {lost} lost", { won: running.won, lost: running.lost })}
            </span>
          )}
          {stage > 3 && running.signals > 0 && (
            <span className="fr-meter-cell">
              <span className="fr-dot" aria-hidden>·</span>
              {t("{n} signals", { n: fmt(running.signals) })}
            </span>
          )}
          {stage > 4 && (
            <span className="fr-meter-cell">
              <span className="fr-dot" aria-hidden>·</span>
              {t("{n} conditions", { n: nConditions })}
            </span>
          )}
        </p>

        {stranded ? (
          // Past this point waiting is a choice, so it has to be presented as
          // one. The workspace opens on what is already known — the CRM half
          // of the read — and fills the rest in when it lands.
          <button type="button" className="fr-bail" onClick={onSkip}>
            {t("Open the workspace with what's ready")}
            <Icon name="arrow" size={13} />
          </button>
        ) : (
          <button type="button" className="fr-skip" onClick={onSkip}>{t("esc to skip")}</button>
        )}
      </div>
    );
  }

  // ── the gap, drawn ───────────────────────────────────────────────────────
  // The finding is an ORDER, so the screen has to show order. One track per
  // deal: the champion pulls away, then a measured distance, then the
  // competitor appears. Eight tracks, all the same shape, is the argument.
  function GapTracks({ pairs, maxGap, run }) {
    const [shown, setShown] = useState(0);
    useEffect(() => {
      if (!run) { setShown(0); return undefined; }
      // Fast enough that a reader clicking straight through still sees all
      // eight tracks land — the point is the repetition, so a half-drawn
      // chart makes the opposite case.
      const id = setInterval(() => setShown((n) => (n >= pairs.length ? n : n + 1)), 150);
      return () => clearInterval(id);
    }, [run, pairs.length]);

    const span = Math.max(maxGap, 1);
    return (
      <div className="fr-tracks">
        {pairs.map((p, i) => (
          <div key={p.account} className={`fr-track ${i < shown ? "is-in" : ""}`}>
            <span className="fr-track-name">{p.account}</span>
            <span className="fr-track-rail">
              <span className="fr-track-gap" style={{ width: `${(p.gap / span) * 100}%` }}>
                <span className="fr-track-dot is-drift" title={t("champion drifted")} />
                <span className="fr-track-dot is-rival" title={t("competitor appeared")} />
              </span>
            </span>
            <span className="fr-track-days mono">{t("{n} days", { n: p.gap })}</span>
          </div>
        ))}
        <div className="fr-track-key">
          <span><i className="fr-track-dot is-drift" aria-hidden /> {t("champion drifted")}</span>
          <span><i className="fr-track-dot is-rival" aria-hidden /> {t("competitor appeared")}</span>
        </div>
      </div>
    );
  }

  // ─────────────────────────── Act 2 · the reveal ──────────────────────────
  function Beat({ beat, active, model, facts }) {
    const c = model.cohort;
    const x = model.contradiction;
    const lead = x.lead;

    const nClosed = useCountUp(c.n, active && beat === 0);
    const nWon = useCountUp(c.won, active && beat === 0, 1100);
    const nLost = useCountUp(c.lost, active && beat === 0, 1100);
    const nNo = useCountUp(c.noDecision, active && beat === 0, 1100);

    if (beat === 0) {
      return (
        <>
          <p className="fr-kicker">{t("Your closed book")}</p>
          <h2 className="fr-head">{t("We read every deal that has already finished.")}</h2>
          <div className="fr-figures">
            <div><b>{nClosed}</b><span>{t("closed deals")}</span></div>
            <div><b>{nWon}</b><span>{t("won")}</span></div>
            <div><b>{nLost}</b><span>{t("lost")}</span></div>
            <div><b>{nNo}</b><span>{t("no decision")}</span></div>
          </div>
          {/* The same field the crunch just built, carried straight into the
              first beat in its outcome formation. It gives the four figures
              above a shape, and it means the reveal opens on something the
              reader has already watched assemble rather than a fresh screen. */}
          <Book marks={facts.marks} stage={4} cursor={0} scanning={false} showLabels={false} />
          <p className="fr-note">{c.window}</p>
        </>
      );
    }

    if (beat === 1) {
      return (
        <>
          <p className="fr-kicker">{t("What you were told")}</p>
          <h2 className="fr-head">{t("Your CRM recorded a reason on {n} of them.", { n: x.recorded })}</h2>
          <ul className="fr-reasons">
            {x.reasons.map((r, i) => (
              <li key={r.reason} style={{ animationDelay: `${i * 160}ms` }}>
                <b className="mono">{r.n}</b>
                <span>{t(r.reason)}</span>
                <em className="mono">{money(r.value)}</em>
              </li>
            ))}
          </ul>
          <p className="fr-note">
            {t("{n} of your {of} losses carry no reason at all.", { n: x.unrecorded, of: x.outOf })}
          </p>
        </>
      );
    }

    if (beat === 2) {
      return (
        <>
          <p className="fr-kicker">{t("What actually happened")}</p>
          <h2 className="fr-head">
            {t("On the {n} deals blamed on a competitor, this is what the buyer was doing.", { n: lead.n })}
          </h2>
          <ul className="fr-evidence">
            {lead.signals.map((s, i) => (
              <li key={s.id} className={i === 0 ? "is-lead" : ""} style={{ animationDelay: `${i * 220}ms` }}>
                <span className="fr-ev-count mono">{t("{n} of {of}", { n: s.n, of: s.of })}</span>
                <span className="fr-ev-label">{t(s.label)}</span>
              </li>
            ))}
          </ul>
        </>
      );
    }

    if (beat === 3) {
      return (
        <>
          <p className="fr-kicker">{t("The order it happened in")}</p>
          <h2 className="fr-head">
            {t("The champion pulled away first. Every single time.")}
          </h2>
          <GapTracks pairs={lead.ordering.pairs} maxGap={lead.ordering.maxGap} run={active} />
          <p className="fr-punch">
            {t("A median of {days} days opened up before the competitor appeared anywhere. On {before} of {comparable} deals. Never the other way round.",
               { days: lead.ordering.medianDays, before: lead.ordering.before, comparable: lead.ordering.comparable })}
          </p>
        </>
      );
    }

    if (beat === 4) {
      return (
        <>
          <p className="fr-kicker">{t("Right now")}</p>
          <h2 className="fr-head">
            {t("{n} deals in your open pipeline are doing the same thing.", { n: facts.drifting.length })}
          </h2>
          <ul className="fr-deals">
            {facts.drifting.slice(0, 6).map((d, i) => (
              <li key={d.id} style={{ animationDelay: `${i * 130}ms` }}>
                <span className="fr-deal-name">{d.company}</span>
                <span className="fr-deal-why">{t(d.why)}</span>
                <em className="mono">{money(d.value)}</em>
              </li>
            ))}
          </ul>
          {facts.drifting.length > 6 && (
            <p className="fr-note">{t("+{n} more", { n: facts.drifting.length - 6 })}</p>
          )}
        </>
      );
    }

    return (
      <>
        <p className="fr-kicker">{t("From here on")}</p>
        <h2 className="fr-head">{t("This sits on your home page, and keeps reading.")}</h2>
        <p className="fr-body">
          {t("Every deal that closes goes back into the book. When the pattern changes, the card changes with it — and it names the open deals carrying it that day.")}
        </p>
      </>
    );
  }

  // ───────────────────────────── the stage ─────────────────────────────────
  function FirstRunStage({ deals, onDone }) {
    const WL = window.WinLoss;
    const [phase, setPhase] = useState("crunch");   // crunch → reveal → landing
    const [beat, setBeat] = useState(0);
    // Reading and a running clock do not mix: hovering the card holds the
    // beat, so nobody loses a sentence half-read to an auto-advance.
    const [paused, setPaused] = useState(false);
    const [, force] = useState(0);
    const cardRef = useRef(null);

    useEffect(() => {
      if (!WL || !WL.subscribe) return undefined;
      return WL.subscribe(() => force((n) => n + 1));
    }, []);

    const model = WL && WL.read ? WL.read({}) : null;

    const facts = useMemo(() => {
      if (!model) return null;
      const rows = WL.allRows();
      const drift = WL.carryOver("champion-drift", deals);
      return {
        // One entry per closed deal, so the crunch can DRAW the book being
        // read instead of describing it. This is the whole point: a wait with
        // something assembling in it is short, a wait with three lines of grey
        // text in the middle of an empty screen is long.
        marks: rows.map((r) => ({
          account: r.account,
          value: r.value || 0,
          outcome: r.outcome,
          cycleDays: r.cycleDays,
          signals: r.markers ? r.markers.length : 0,
          timeline: r.markers !== null,
          blamed: r.lossReason === "Competitor",
        })),
        closed: model.cohort.n,
        timelines: rows.filter((r) => r.markers !== null).length,
        markers: rows.reduce((s, r) => s + (r.markers ? r.markers.length : 0), 0),
        conditions: WL.FACTORS.length,
        open: WL.openBook(deals).length,
        accounts: rows.map((r) => r.account).filter(Boolean),
        drifting: drift.carrying,
        ready: WL.ready(),
      };
    }, [model && model.cohort.n, model && model.historyStatus]);

    const BEATS = 6;
    const finish = () => { setPhase("landing"); setTimeout(onDone, 720); };

    // Auto-advance, but any click or key takes over.
    useEffect(() => {
      if (phase !== "reveal" || paused) return undefined;
      const id = setTimeout(() => {
        if (beat + 1 >= BEATS) finish(); else setBeat((b) => b + 1);
      }, BEAT_DWELL);
      return () => clearTimeout(id);
    }, [phase, beat, paused]);

    useEffect(() => {
      const onKey = (e) => {
        if (e.key === "Escape") { onDone(); return; }
        if (phase !== "reveal") return;
        if (e.key === "ArrowRight" || e.key === " " || e.key === "Enter") {
          e.preventDefault();
          if (beat + 1 >= BEATS) finish(); else setBeat((b) => b + 1);
        }
        if (e.key === "ArrowLeft") setBeat((b) => Math.max(0, b - 1));
      };
      document.addEventListener("keydown", onKey);
      return () => document.removeEventListener("keydown", onKey);
    }, [phase, beat]);

    // The closing move: measure where the real widget is sitting under the
    // overlay and drive the card onto it, so the thing being handed over
    // visibly becomes the thing on the page.
    useEffect(() => {
      if (phase !== "landing" || !cardRef.current) return;
      const target = document.querySelector(".wl-shell");
      const el = cardRef.current;
      if (!target) { el.style.opacity = "0"; return; }
      const a = el.getBoundingClientRect(), b = target.getBoundingClientRect();
      el.style.transformOrigin = "top left";
      el.style.transition = "transform 640ms cubic-bezier(.22,.61,.36,1), opacity 480ms ease 220ms";
      el.style.transform =
        `translate(${b.left - a.left}px, ${b.top - a.top}px) scale(${Math.min(1, b.width / a.width)})`;
      el.style.opacity = "0.15";
    }, [phase]);

    // The crunch paints as soon as there is anything to paint — waiting for
    // the buyer timelines before showing the screen whose job is to COVER
    // that wait is exactly backwards, and it was costing several seconds of
    // dead login screen. Only the reveal needs the finished analysis.
    if (!model || !facts) return null;
    const analysisReady = !!model.contradiction.lead;

    const advance = () => {
      if (phase !== "reveal") return;
      if (beat + 1 >= BEATS) finish(); else setBeat((b) => b + 1);
    };

    return (
      <div className={`fr-stage is-${phase}`} role="dialog" aria-modal="true" aria-label={t("Reading your closed deals")}>
        <div className="fr-scrim" />
        {phase === "crunch" ? (
          <Crunch
            facts={facts}
            onDone={() => setPhase(analysisReady ? "reveal" : "crunch")}
            onSkip={onDone}
          />
        ) : (
          <div
            className="fr-reveal"
            onClick={advance}
            onMouseEnter={() => setPaused(true)}
            onMouseLeave={() => setPaused(false)}
          >
            <div className="fr-card" ref={cardRef}>
              <Beat beat={beat} active={phase === "reveal"} model={model} facts={facts} />
            </div>
            <div className="fr-foot" onClick={(e) => e.stopPropagation()}>
              <button
                type="button"
                className="fr-back"
                onClick={() => setBeat((b) => Math.max(0, b - 1))}
                disabled={beat === 0}
                aria-label={t("Back")}
              >
                <Icon name="chevron" size={14} />
              </button>
              <div className="fr-dots" role="tablist">
                {Array.from({ length: BEATS }).map((_, i) => (
                  <button
                    key={i}
                    type="button"
                    className={`fr-dot-btn ${i === beat ? "is-on" : ""} ${i < beat ? "is-past" : ""}`}
                    onClick={() => setBeat(i)}
                    aria-label={t("Step {n}", { n: i + 1 })}
                    aria-selected={i === beat}
                    role="tab"
                  >
                    {/* The active dot drains over the dwell, so the wait is
                        something you can see and get ahead of rather than a
                        jump that arrives without warning. */}
                    {i === beat && (
                      <span
                        key={`${beat}-${paused}`}
                        className={`fr-dot-fill ${paused ? "is-held" : ""}`}
                        style={{ animationDuration: `${BEAT_DWELL}ms` }}
                      />
                    )}
                  </button>
                ))}
              </div>
              <button type="button" className="fr-next" onClick={advance}>
                {beat + 1 >= BEATS ? t("Open my workspace") : t("Next")}
                <Icon name="arrow" size={13} />
              </button>
              <button type="button" className="fr-skip is-inline" onClick={onDone}>{t("Skip")}</button>
            </div>
          </div>
        )}
      </div>
    );
  }

  window.FirstRunStage = FirstRunStage;
})();
