/* global React, ReactDOM, PortPhase, ExpeditionPhase, CityMap, ShipBlueprint */
const { useState: useStateApp, useReducer, useEffect: useEffectApp, useRef: useRefApp } = React;

// ====================================================================
// State model — Raising System v0 (HANDOFF_TRAINING.md, 2026-05-30 pass)
//
// • The 8 elements ARE the only stats. Each holds a raw VALUE 0–999
//   (trained/fed; 999 = the maxed True Primal ceiling). The value IS the
//   check strength — checks resolve LINEARLY across a [min,max] band
//   (practical-numbers §4); there is no dice roll / sqrt bonus.
// • Start arc = Fire·Water·Earth·Nature unlocked; Ice·Lightning·Wind·Holy locked
//   (unlock order Ice→Lightning→Wind→Holy, ring order).
// • Leak ring (clockwise, directional): each element leaks into the NEXT.
//   Fire→Water→Earth→Nature→Ice→Lightning→Wind→Holy→Fire.
// • Training: MAIN +10 to the trained element; LEAK to its ring-neighbour
//   (+9 on-time / +2 off-time), or VANISHES if the neighbour is locked.
// • Day = 4 slots: Night·Morning·Afternoon·Evening. On-time = training an
//   element during its home slot, available at most once/day/element.
// • Ascension (Mastery gates): R1 primary≥20 · R2 blend(two adj ≥49) or
//   pure(≥121) · R3 pure ≥400 + town milestone (meta-locked).
// • Lifespan (days), refreshed on ascend: R0 1 · R1 3 · R2 10 · R3 20.
// • Hull (harness integrity, pool 20) is the SECOND cost pool — failed
//   resource rolls cost Hull + the wasted slot, never the lifespan clock.
// ====================================================================

// ==== SHARED CORE ====================================================
// The game brain lives in sh-core.js (window.SkyhearthCore) so desktop and
// mobile run the SAME reducer + constants. Pull every symbol the views use.
const SkyhearthCore = window.SkyhearthCore;
const {
  SLOTS, ELEMENTS, RANK_NAMES, C, RING, HOME_SLOT, UNLOCK_ORDER,
  checkChance, resolveCheck, capVal, ringNeighbor, ringOpposite, ringAdjacent,
  FULLNESS_DRAIN_PER_SLOT, bodyTick, formForRank, regenForage, forageKey, FORAGE,
  INITIAL_STATE, CARE_META, tickSlot, ritualReady, autoAscend, reducer, baseReducer,
} = SkyhearthCore;
// ====================================================================
// GlobalDayClock — persistent mini diurnal dial pinned to the canvas
// top-right. Carries the day/slot readout across the Atlas map and the
// expedition run; the Port keeps its full-size hero dial. No labels —
// just coloured slot sectors, ticks, the sweeping hand, and the day
// numeral. Rendered ONCE in App (outside the phase containers) so it
// survives phase transitions and its hand animates continuously.
// ====================================================================
function GlobalDayClock({ day, slotIdx, schedule, visible }) {
  const size = 78;
  const cx = size / 2, cy = size / 2;
  const rOuter  = size / 2 - 2;
  const rInner  = rOuter - 7;
  const rHand   = rInner - 4;
  const rCenter = 12.5;
  const handHour = slotIdx * 6 + 3;

  // Accumulate rotation so the hand always sweeps forward (clockwise),
  // matching the Port dial — see DayClock in port.jsx.
  const handDegRef = useRefApp(null);
  const targetDeg  = (handHour / 24) * 360;
  if (handDegRef.current == null) {
    handDegRef.current = targetDeg;
  } else {
    const prev = handDegRef.current;
    const delta = (((targetDeg - prev) % 360) + 360) % 360;
    handDegRef.current = prev + delta;
  }
  const handDeg = handDegRef.current;

  function ang(h) { return (h / 24) * Math.PI * 2 - Math.PI / 2; }
  function xy(h, r) { const a = ang(h); return [cx + Math.cos(a) * r, cy + Math.sin(a) * r]; }

  return (
    <div style={{
      position: 'absolute', top: 18, right: 24, zIndex: 60,
      opacity: visible ? 1 : 0,
      transform: visible ? 'translateY(0)' : 'translateY(-8px)',
      transition: 'opacity 320ms ease, transform 320ms ease',
      pointerEvents: 'none',
    }}>
      <div style={{
        borderRadius: '50%',
        boxShadow: '0 3px 12px rgba(20,14,8,0.30), 0 0 0 3px rgba(244,236,214,0.9)',
      }}>
        <svg viewBox={`0 0 ${size} ${size}`} style={{ width: size, height: size, display: 'block' }}>
          {/* rim */}
          <circle cx={cx} cy={cy} r={rOuter} fill="var(--paper-light)" stroke="var(--ink)" strokeWidth="1.4" />
          <circle cx={cx} cy={cy} r={rOuter - 1.5} fill="none" stroke="var(--ink-faded)" strokeWidth="0.5" />

          {/* slot sectors */}
          {[0,1,2,3].map(i => {
            const h0 = i * 6, h1 = (i + 1) * 6;
            const [x0, y0] = xy(h0, rInner);
            const [x1, y1] = xy(h1, rInner);
            const action = schedule[i];
            const isNow = i === slotIdx;
            let fill = 'transparent', fillOp = 0, stroke = 'var(--ink-ghost)', sw = 0.5, dash = '2 3';
            if (action) { fill = action.color; fillOp = 0.34; stroke = action.color; sw = 1; dash = 'none'; }
            else if (isNow) { fill = 'var(--stamp-amber)'; fillOp = 0.14; stroke = 'var(--stamp-amber)'; sw = 1.2; dash = 'none'; }
            return (
              <path key={i}
                d={`M ${cx} ${cy} L ${x0} ${y0} A ${rInner} ${rInner} 0 0 1 ${x1} ${y1} Z`}
                fill={fill} fillOpacity={fillOp}
                stroke={stroke} strokeWidth={sw} strokeDasharray={dash}
                style={{ transition: 'fill 600ms ease, fill-opacity 600ms ease, stroke 600ms ease, stroke-width 600ms ease' }} />
            );
          })}

          {/* hour ticks — majors at the four slot cardinals */}
          {Array.from({ length: 24 }).map((_, h) => {
            const isMajor = h % 6 === 0;
            const [x1, y1] = xy(h, rOuter);
            const [x2, y2] = xy(h, isMajor ? rOuter - 5 : rOuter - 2.5);
            return <line key={h} x1={x1} y1={y1} x2={x2} y2={y2} stroke="var(--ink)" strokeWidth={isMajor ? 1 : 0.5} />;
          })}

          {/* slot boundary spokes */}
          {[0,6,12,18].map(h => {
            const [x, y] = xy(h, rInner);
            return <line key={h} x1={cx} y1={cy} x2={x} y2={y} stroke="var(--ink-faded)" strokeWidth="0.5" strokeDasharray="2 2" />;
          })}

          {/* hand */}
          <g style={{
            transform: `translate(${cx}px, ${cy}px) rotate(${handDeg}deg)`,
            transition: 'transform 600ms cubic-bezier(0.4, 0.0, 0.2, 1)',
            transformOrigin: '0 0', transformBox: 'view-box',
          }}>
            <line x1="0" y1="0" x2="0" y2={-rHand} stroke="var(--ink)" strokeWidth="1.8" strokeLinecap="round" />
            <line x1="0" y1="0" x2="0" y2={4} stroke="var(--ink)" strokeWidth="1.3" strokeLinecap="round" />
          </g>

          {/* center cap with day numeral */}
          <circle cx={cx} cy={cy} r={rCenter} fill="var(--paper)" stroke="var(--ink)" strokeWidth="1" />
          <text x={cx} y={cy + 4} textAnchor="middle" fontSize="12" fill="var(--ink)"
                fontFamily="IM Fell English SC, serif" style={{ fontWeight: 600 }}>
            <window.Num value={day} pad={2} />
          </text>
        </svg>
      </div>
    </div>
  );
}

// Per-screen sky-margin signature (border-asymmetry as wayfinding):
//  • Port — top-right weighted (home, settled).
//  • Map  — even & wide (a chart laid flat, expansive).
//  • Expedition — bottom-weighted (the field stretching forward).
const SCREEN_OFFSET = {
  port:       { top: 14, right: 14, bottom: 2,  left: 2  },
  map:        { top: 20, right: 20, bottom: 20, left: 20 },
  expedition: { top: 4,  right: 8,  bottom: 34, left: 8  },
};

function App() {
  const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
  // Dev/review hook: drive the real reducer from the console or the harness, e.g.
  //   SH_dispatch({type:'LAUNCH', zone:'z-nature'})  — jump straight into a keyed zone.
  useEffectApp(() => { window.SH_dispatch = dispatch; window.SH_state = state; });
  const [transitioning, setTransitioning] = useStateApp(null);
  const [renderedPhase, setRenderedPhase] = useStateApp(state.phase);

  useEffectApp(() => {
    if (state.phase === renderedPhase) return;
    const isExpeditionEdge = state.phase === 'expedition' || renderedPhase === 'expedition';
    if (!isExpeditionEdge) { setRenderedPhase(state.phase); return; }
    setTransitioning('iris-in');
    const t1 = setTimeout(() => { setRenderedPhase(state.phase); setTransitioning('iris-out'); }, 500);
    const t2 = setTimeout(() => setTransitioning(null), 1000);
    return () => { clearTimeout(t1); clearTimeout(t2); };
  }, [state.phase]);

  // Auto-clear ascend flash
  useEffectApp(() => {
    if (!state.ascending) return;
    const t = setTimeout(() => dispatch({ type: 'CLEAR_ASCENDING' }), 900);
    return () => clearTimeout(t);
  }, [state.ascending]);

  // Watch for a rank increase — surface the ascension fanfare modal. Captures
  // the freshly-updated form so it persists until the player dismisses it
  // (independent of the brief 900ms `ascending` shake flag above).
  const prevRankRef = useRefApp(state.rank);
  const [ascendInfo, setAscendInfo] = useStateApp(null);
  // Record the starting form once the codex data is live (the mote, plus
  // whatever form a loaded/checkpointed state already is).
  useEffectApp(() => { if (window.SH_discovery) window.SH_discovery.record(state); }, []);
  useEffectApp(() => {
    if (state.rank > prevRankRef.current) {
      const veinId = window.topVein ? window.topVein(state).id : 'fire';
      const meta = ELEMENTS[veinId] || ELEMENTS.fire;
      // Log the freshly-reached form into the real Codex discovery store.
      if (window.SH_discovery) window.SH_discovery.record(state);
      // The form's authored Codex record — surfaced as the evolution "news"
      // (bonus distribution, lifespan, sleep, appetite, bond, role/niche).
      let cform = null;
      try {
        const fid = window.SH_codexFormIdFor ? window.SH_codexFormIdFor(state) : null;
        cform = (window.FORMS && fid) ? window.FORMS.find(f => f.id === fid) : null;
      } catch (e) {}
      // The existence of True Primals is a secret — only reveal the 4th rung
      // once one has actually been reached (and remember it thereafter).
      let seenPrimal = false;
      try { seenPrimal = localStorage.getItem('sh-seen-primal') === '1'; } catch (e) {}
      if (state.rank >= 3) {
        seenPrimal = true;
        try { localStorage.setItem('sh-seen-primal', '1'); } catch (e) {}
      }
      setAscendInfo({
        fromRank: prevRankRef.current,
        toRank:   state.rank,
        formName: state.formName,
        src:      window.spiritIconSrc ? window.spiritIconSrc(state) : 'assets/drift-mote.png',
        veinColor: meta.color,
        veinName:  meta.name,
        cform,
        seenPrimal,
      });
    }
    prevRankRef.current = state.rank;
  }, [state.rank]);

  // Stage scaling
  useEffectApp(() => {
    function fit() {
      const stage = document.getElementById('stage');
      const canvas = document.getElementById('canvas');
      if (!stage || !canvas) return;
      const sw = stage.clientWidth, sh = stage.clientHeight;
      const scale = Math.min(sw / 1440, sh / 900);
      canvas.style.transform = `scale(${scale})`;
    }
    fit();
    window.addEventListener('resize', fit);
    return () => window.removeEventListener('resize', fit);
  }, []);

  const inExpedition = renderedPhase === 'expedition';

  return (
    <>
      {window.AetherSky && (
        <window.AetherSky slotIdx={state.slotIdx}
          offset={SCREEN_OFFSET[renderedPhase] || SCREEN_OFFSET.port} />
      )}
      <div style={{ position: 'absolute', inset: 0, zIndex: 1, display: renderedPhase === 'port' ? 'block' : 'none' }}>
        <PortPhase state={state} dispatch={dispatch}
          popupOpen={!!ascendInfo || state.collapsed || state.dying} />
      </div>
      <div style={{ position: 'absolute', inset: 0, zIndex: 1, display: renderedPhase === 'map' ? 'block' : 'none' }}>
        <CityMap state={state} dispatch={dispatch} onBack={() => dispatch({ type: 'GOTO', page: 'port' })} />
      </div>
      {inExpedition && (
        <div style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
          {state.expeditionZone && state.expeditionZone !== 'whispering-strata' && window.KeyedExpeditionPhase
            ? <window.KeyedExpeditionPhase state={state} dispatch={dispatch} zoneId={state.expeditionZone}
                onAbort={() => dispatch({ type: 'GOTO', page: 'port' })} />
            : <ExpeditionPhase state={state} dispatch={dispatch} />}
        </div>
      )}
      {state.launchMenu && <LaunchPicker state={state} dispatch={dispatch} />}
      <GlobalDayClock
        day={state.day}
        slotIdx={state.slotIdx}
        schedule={state.daySchedule}
        visible={true}
      />
      {transitioning && <IrisWipe mode={transitioning} />}
      {ascendInfo && <AscensionModal info={ascendInfo} onClose={() => setAscendInfo(null)} />}
      {state.collapsed && !state.dying && (
        <CollapseModal info={{ state, formName: state.formName }}
          onClose={() => dispatch({ type: 'CLEAR_COLLAPSE' })} />
      )}
      {state.dying && <DeathModal state={state} onReset={() => dispatch({ type: 'RESET_TO_MOTE' })} />}
    </>
  );
}

function IrisWipe({ mode }) {
  return (
    <div style={{
      position: 'absolute', inset: 0, zIndex: 9000, pointerEvents: 'none',
      animation: mode === 'iris-in' ? 'iris-close 500ms forwards' : 'iris-open 500ms forwards',
    }}>
      <style>{`
        @keyframes iris-close {
          0% { background: radial-gradient(circle at center, transparent 0%, transparent 100%, #0e0a08 100%); }
          100% { background: radial-gradient(circle at center, transparent 0%, transparent 0%, #0e0a08 30%); }
        }
        @keyframes iris-open {
          0% { background: radial-gradient(circle at center, transparent 0%, transparent 0%, #0e0a08 30%); }
          100% { background: radial-gradient(circle at center, transparent 0%, transparent 100%, #0e0a08 100%); }
        }
      `}</style>
    </div>
  );
}

// ====================================================================
// AscensionModal — the fanfare when a spirit takes its next form. Warm
// halo + slow ray-burst keyed to the dominant vein, the new form's name,
// and a rank ladder marking the step just taken.
// ====================================================================
function AscensionModal({ info, onClose }) {
  const motes = [12, 30, 50, 68, 86];   // x% positions for the rising light-motes
  // Hold the reveal until the portrait actually decodes — otherwise the
  // SVG drop-white filter flashes an unprocessed image as the modal opens.
  const [ready, setReady] = React.useState(false);
  React.useEffect(() => {
    const fallback = setTimeout(() => setReady(true), 650); // in case onLoad never fires
    return () => clearTimeout(fallback);
  }, []);
  return (
    <div className="modal-backdrop" style={{
      position: 'absolute', inset: 0, zIndex: 9500,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      background: 'radial-gradient(ellipse at center, rgba(176,140,60,0.30), rgba(20,16,10,0.80) 70%)',
      opacity: ready ? 1 : 0,
      transition: 'opacity 420ms ease',
    }}>
      <div className="panel brackets" style={{
        width: 720, background: 'var(--paper)', paddingBottom: 30,
        border: '2px solid var(--ink)', position: 'relative', overflow: 'hidden',
        animation: 'ascend-rise 540ms cubic-bezier(0.2,0.7,0.2,1) both',
        boxShadow: '0 18px 60px rgba(20,14,8,0.55), 0 0 0 1px rgba(176,140,60,0.45)',
      }}>
        {/* portrait stage with rotating ray-burst + halo */}
        <div style={{ position: 'relative', height: 258, overflow: 'hidden',
          borderBottom: '1.5px solid var(--ink-faded)' }}>
          <div style={{
            position: 'absolute', top: '54%', left: '50%', width: 780, height: 780,
            background: `repeating-conic-gradient(from 0deg, ${info.veinColor}22 0deg 5deg, transparent 5deg 11deg)`,
            animation: 'ray-spin 42s linear infinite',
            WebkitMaskImage: 'radial-gradient(circle, #000 16%, transparent 60%)',
            maskImage: 'radial-gradient(circle, #000 16%, transparent 60%)',
          }} />
          <div style={{
            position: 'absolute', top: '54%', left: '50%', width: 340, height: 340,
            transform: 'translate(-50%,-50%)',
            background: `radial-gradient(circle, ${info.veinColor}40, transparent 64%)`,
          }} />
          {motes.map((x, i) => (
            <span key={i} style={{
              position: 'absolute', bottom: 26, left: `${x}%`, width: 5, height: 5,
              borderRadius: '50%', background: info.veinColor,
              boxShadow: `0 0 8px ${info.veinColor}`,
              animation: `mote-drift ${3 + (i % 3)}s ease-in ${i * 0.55}s infinite`,
            }} />
          ))}
          <img className="ascend-portrait" src={info.src} alt="" onLoad={() => setReady(true)} style={{
            position: 'absolute', top: '50%', left: '50%', width: 206, height: 206,
            objectFit: 'contain',
            filter: 'url(#sh-dropwhite) drop-shadow(0 6px 18px rgba(74,54,38,0.45))',
          }} />
          <div style={{ position: 'absolute', top: 16, left: 0, right: 0, textAlign: 'center' }}>
            <span className="stamp stamp-amber" style={{ fontSize: 11 }}>ASCENSION · CONFIRMED</span>
          </div>
        </div>

        <div style={{ textAlign: 'center', padding: '30px 44px 0' }}>
          <div className="fell" style={{ fontSize: 42, lineHeight: 1.05 }}>{info.formName}</div>

          {/* rank ladder */}
          <div className="row" style={{ justifyContent: 'center', margin: '24px 0 4px' }}>
            {(info.seenPrimal ? RANK_NAMES : RANK_NAMES.slice(0, 3)).map((rn, i) => {
              const reached = i <= info.toRank;
              const isNew = i === info.toRank;
              return (
                <React.Fragment key={rn}>
                  {i > 0 && <span style={{ width: 30, height: 1, alignSelf: 'flex-start', marginTop: 7,
                    background: i <= info.toRank ? 'var(--ink)' : 'var(--ink-ghost)' }} />}
                  <div className="col" style={{ alignItems: 'center', width: 84 }}>
                    <div style={{
                      width: 16, height: 16, borderRadius: '50%',
                      border: `1.5px solid ${reached ? 'var(--ink)' : 'var(--ink-ghost)'}`,
                      background: isNew ? info.veinColor : reached ? 'var(--ink)' : 'transparent',
                      boxShadow: isNew ? `0 0 0 4px ${info.veinColor}40` : 'none',
                    }} />
                    <span className="fell" style={{ fontSize: 13, marginTop: 7,
                      color: reached ? 'var(--ink)' : 'var(--ink-faded)',
                      fontWeight: isNew ? 600 : 400 }}>{rn}</span>
                  </div>
                </React.Fragment>
              );
            })}
          </div>

          {info.cform && <FormReveal cform={info.cform} ready={ready} accent={info.veinColor} />}

          <button className="btn btn-launch" style={{ marginTop: 24 }} onClick={onClose}>
            Continue ▸
          </button>
        </div>
      </div>
    </div>
  );
}

// ====================================================================
// FormReveal — the evolution "news": the freshly-reached form's authored
// record, surfaced right on the ascension screen. The element bonuses granted
// on ascend are the headline (large chips); lifespan / sleep / appetite /
// temperament + the role line round it out. Everything fades up in sequence
// once the portrait has decoded.
// ====================================================================
function FormReveal({ cform, ready, accent }) {
  const PE = window.PORT_ELEM || {};
  const elName  = (e) => (PE[e] ? PE[e].name : e);
  const elColor = (e) => (PE[e] ? PE[e].color : accent);
  const elIcon  = (e) => (PE[e] ? PE[e].icon : null);
  const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : '—');
  const restLabel = window.restLabel ? window.restLabel(cform.rest) : `${cform.rest.slots} sleep`;
  const dist = (cform.d && cform.d.dist) || {};
  const bonuses = Object.entries(dist).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]);
  const niche = (cform.d && cform.d.niche && cform.d.niche.length) ? cform.d.niche.join(' / ') : null;

  // staggered fade-up keyed to `ready`
  const rise = (i) => ({
    opacity: ready ? 1 : 0,
    transform: ready ? 'translateY(0)' : 'translateY(8px)',
    transition: `opacity 460ms ease ${360 + i * 120}ms, transform 460ms ease ${360 + i * 120}ms`,
  });

  const stats = [
    ['Lifespan', `${cform.life} days`],
    ['Sleep', restLabel],
    ['Appetite', cap(cform.food)],
    ['Temperament', cap(cform.bond)],
  ];

  return (
    <div style={{
      marginTop: 22, paddingTop: 18, borderTop: '1px solid var(--ink-faded)',
      textAlign: 'left',
    }}>
      <div className="row" style={{ justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12, ...rise(0) }}>
        <span className="label" style={{ letterSpacing: '0.22em' }}>THE FORM TAKES SHAPE</span>
        {cform.role && cform.role !== 'Lesser' &&
          <span className="elite" style={{ fontSize: 11, color: 'var(--sepia)' }}>
            {cform.role}{niche ? ` · ${niche}` : ''}
          </span>}
      </div>

      {/* element bonuses granted on ascend — the headline */}
      {bonuses.length > 0 && (
        <div className="row" style={{ gap: 10, flexWrap: 'wrap', marginBottom: 16, ...rise(1) }}>
          {bonuses.map(([e, v]) => (
            <div key={e} className="row gap-8" style={{ alignItems: 'center',
              border: `1.5px solid ${elColor(e)}`, background: `${elColor(e)}14`,
              padding: '7px 12px' }}>
              {elIcon(e)
                ? <img src={elIcon(e)} alt="" style={{ width: 22, height: 22, objectFit: 'contain' }} />
                : <span style={{ color: elColor(e), fontSize: 18 }}>◆</span>}
              <span className="col" style={{ gap: 0, lineHeight: 1.05 }}>
                <span className="mono" style={{ fontSize: 17, fontWeight: 700, color: elColor(e) }}>+{v}</span>
                <span className="elite" style={{ fontSize: 9, letterSpacing: '0.1em', color: 'var(--sepia)' }}>{elName(e).toUpperCase()}</span>
              </span>
            </div>
          ))}
          <span className="elite" style={{ alignSelf: 'center', fontSize: 10, color: 'var(--ink-faded)', letterSpacing: '0.08em' }}>
            woven into the veins on ascent
          </span>
        </div>
      )}

      {/* the form's standing stats */}
      <div className="row" style={{ justifyContent: 'space-between', gap: 14, ...rise(2) }}>
        {stats.map(([k, v]) => (
          <div key={k} className="col" style={{ gap: 3, flex: 1, minWidth: 0 }}>
            <span className="label" style={{ fontSize: 8.5 }}>{k}</span>
            <span className="mono" style={{ fontSize: 12.5, color: 'var(--ink)', fontWeight: 600,
              whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{v}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

// ====================================================================
// DeathModal — the spirit dies. Two causes: a Greater+ Manifestation Clock
// running dry (no form left to ascend into), or starvation at any rank.
// The vessel dies; a new mote will drift in.
// ====================================================================
function DeathModal({ state, onReset }) {
  const src = window.spiritIconSrc ? window.spiritIconSrc(state) : 'assets/drift-mote.png';
  const starved = state.deathCause === 'starve';
  const stamp = starved ? 'WASTED AWAY · NO RESERVE LEFT' : 'MANIFESTATION · EXPIRED';
  return (
    <div className="modal-backdrop" style={{
      position: 'absolute', inset: 0, zIndex: 9500,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      background: 'radial-gradient(ellipse at center, rgba(40,16,10,0.62), rgba(8,6,5,0.93) 72%)',
    }}>
      <div className="panel" style={{
        width: 660, background: 'var(--paper)', padding: '34px 46px 30px',
        border: '3px double var(--crit)', position: 'relative',
        boxShadow: '0 18px 60px rgba(0,0,0,0.6)',
      }}>
        <div style={{ textAlign: 'center' }}>
          <span className="stamp" style={{ borderColor: 'var(--crit)', color: 'var(--crit)', fontSize: 11 }}>
            {stamp}
          </span>
          <div className="fell" style={{ fontSize: 34, marginTop: 12, color: 'var(--crit)' }}>
            Death Claims the Vessel
          </div>
        </div>

        {/* the dead form fades; the new mote re-gathers over it */}
        <div style={{ position: 'relative', height: 172, margin: '16px 0 4px' }}>
          <img src={src} alt="" style={{
            position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)',
            width: 156, height: 156, objectFit: 'contain',
            filter: 'url(#sh-dropwhite) grayscale(1)', opacity: 0.38,
          }} />
          <img src="assets/drift-mote.png" alt="" style={{
            position: 'absolute', top: '50%', left: '50%', width: 86, height: 86,
            objectFit: 'contain',
            filter: 'url(#sh-dropwhite) drop-shadow(0 0 16px rgba(176,140,60,0.55))',
            animation: 'mote-gather 2.6s ease-in-out infinite',
          }} />
        </div>

        <div className="elite" style={{ textAlign: 'center', fontSize: 13, color: 'var(--sepia)', lineHeight: 1.65 }}>
          The <span className="fell" style={{ fontSize: 15, color: 'var(--ink)' }}>{state.formName}</span>{' '}
          {starved
            ? 'wasted away — with the aether-belly long empty, the body burned its last reserve and could give no more. Its veins scatter to the sky currents, and from the quiet, a new mote begins to drift.'
            : 'has burned through its allotted days. Its veins scatter to the sky currents — and from the quiet, a new mote begins to drift.'}
        </div>

        <div className="row" style={{ justifyContent: 'center', marginTop: 24 }}>
          <button className="btn btn-launch" onClick={onReset}>
            A New Mote Drifts In ▸
          </button>
        </div>
      </div>
    </div>
  );
}

// ====================================================================
// CollapseModal — fatigue maxed out. The spirit drops into a forced sleep:
// exhaustion clears, but the crash costs trust (major Bond loss).
// A setback, not a reset — dismiss returns to the port.
// ====================================================================
function CollapseModal({ info, onClose }) {
  const src = window.spiritIconSrc ? window.spiritIconSrc(info.state) : 'assets/drift-mote.png';
  return (
    <div className="modal-backdrop" style={{
      position: 'absolute', inset: 0, zIndex: 9500,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      background: 'radial-gradient(ellipse at center, rgba(28,30,46,0.58), rgba(10,10,16,0.90) 72%)',
    }}>
      <div className="panel" style={{
        width: 600, background: 'var(--paper)', padding: '32px 44px 28px',
        border: '2px solid var(--stamp-amber)', position: 'relative',
        boxShadow: '0 18px 60px rgba(0,0,0,0.55)',
        animation: 'ascend-rise 460ms cubic-bezier(0.2,0.7,0.2,1) both',
      }}>
        <div style={{ textAlign: 'center' }}>
          <span className="stamp stamp-amber" style={{ fontSize: 11 }}>FATIGUE · MAXED · FORCED REST</span>
          <div className="fell" style={{ fontSize: 30, marginTop: 12 }}>The Spirit Collapses</div>
        </div>

        {/* slumped form with a slow breathing pulse */}
        <div style={{ position: 'relative', height: 150, margin: '14px 0 6px' }}>
          <div style={{
            position: 'absolute', top: '54%', left: '50%', width: 220, height: 220,
            transform: 'translate(-50%,-50%)',
            background: 'radial-gradient(circle, rgba(122,111,90,0.32), transparent 64%)',
          }} />
          <img src={src} alt="" className="ascend-portrait" style={{
            position: 'absolute', top: '52%', left: '50%', width: 138, height: 138,
            objectFit: 'contain',
            filter: 'url(#sh-dropwhite) saturate(0.6) drop-shadow(0 6px 14px rgba(40,40,60,0.4))',
          }} />
          <span className="fell" style={{
            position: 'absolute', top: 14, left: '57%',
            fontSize: 26, color: 'var(--ink-faded)', opacity: 0.7,
          }}>z</span>
          <span className="fell" style={{
            position: 'absolute', top: 4, left: '61%',
            fontSize: 34, color: 'var(--ink-faded)', opacity: 0.5,
          }}>Z</span>
        </div>

        <div className="elite" style={{ textAlign: 'center', fontSize: 13, color: 'var(--sepia)', lineHeight: 1.6 }}>
          The <span className="fell" style={{ fontSize: 15, color: 'var(--ink)' }}>{info.formName}</span> pushed
          past its limit and dropped where it stood. The forced rest wipes the exhaustion clean —
          but waking alone, untended, fractures its trust in you.
        </div>

        {/* setback ledger */}
        <div className="row" style={{ justifyContent: 'center', gap: 28, margin: '20px 0 4px' }}>
          <div className="col" style={{ alignItems: 'center' }}>
            <span className="mono" style={{ fontSize: 11, color: 'var(--crit)', fontWeight: 700 }}>−25</span>
            <span className="label" style={{ marginTop: 3 }}>Bond</span>
          </div>
          <div className="col" style={{ alignItems: 'center' }}>
            <span className="mono" style={{ fontSize: 11, color: 'var(--ok)', fontWeight: 700 }}>→ 0</span>
            <span className="label" style={{ marginTop: 3 }}>Fatigue</span>
          </div>
        </div>

        <div className="row" style={{ justifyContent: 'center', marginTop: 22 }}>
          <button className="btn btn-launch" onClick={onClose}>Let It Wake ▸</button>
        </div>
      </div>
    </div>
  );
}

// ====================================================================
// LaunchPicker — the keyed-zone destination chooser (opens on Launch once the
// Z0 gateway crew is rescued, §5). Z0 stays available; each keyed zone shows its
// key element, on-theme ToD, and how many of its recruits are still unbanked.
// ====================================================================
function LaunchPicker({ state, dispatch }) {
  const T2 = window.SkyhearthT2;
  const PE = window.PORT_ELEM || {};
  const built = state.built || {}, banked = state.banked || {};
  const hi = (() => { let id = 'fire', v = -1; for (const k in state.elements) { const m = state.elements[k].mastery || 0; if (m > v) { v = m; id = k; } } return { id, v }; })();
  const zoneRows = Object.keys(T2.ZONES).map((zid) => {
    const z = T2.ZONES[zid];
    const ids = Object.keys(z.nodes);
    const recruitIds = ids.filter((n) => T2.ALL_14.includes(n));
    const remaining = recruitIds.filter((n) => !banked[n] && !built[n]);
    const keyEls = z.key.toLowerCase().split('/');
    const matches = keyEls.includes(hi.id);
    return { zid, z, remaining: remaining.length, total: recruitIds.length, keyEls, matches };
  });
  return (
    <div className="modal-backdrop" style={{ position: 'absolute', inset: 0, background: 'rgba(20,16,10,0.6)', zIndex: 8000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <div className="panel" style={{ width: 720, maxHeight: '86%', overflow: 'auto', padding: '24px 28px', background: 'var(--paper-light)' }}>
        <div className="row" style={{ justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 4 }}>
          <span className="fell" style={{ fontSize: 26 }}>Set a Bearing</span>
          <span className="mono" style={{ fontSize: 10, color: 'var(--sepia)' }}>SPECIALTY · {String(hi.id).toUpperCase()} {hi.v}</span>
        </div>
        <div className="elite" style={{ fontSize: 12, color: 'var(--sepia)', lineHeight: 1.5, marginBottom: 16 }}>
          The keyed band is open. Each zone reads ONE element — launch a specialist whose key matches, or it sits at the leak floor and clears nothing. The variety gate.
        </div>
        <div className="col gap-10">
          {/* Z0 */}
          <button className="panel" onClick={() => dispatch({ type: 'LAUNCH', zone: 'whispering-strata' })}
            style={{ textAlign: 'left', cursor: 'pointer', padding: '12px 16px', borderColor: 'var(--ink-faded)', background: 'transparent' }}>
            <div className="row" style={{ justifyContent: 'space-between', alignItems: 'center' }}>
              <span className="fell" style={{ fontSize: 18 }}>The Whispering Strata <span className="mono" style={{ fontSize: 9, color: 'var(--sepia)' }}>· Zone 0 · home · unkeyed</span></span>
              <span className="btn" style={{ padding: '4px 12px', fontSize: 11, background: 'var(--ink)', color: 'var(--paper-light)' }}>Launch ▸</span>
            </div>
          </button>
          {zoneRows.map(({ zid, z, remaining, total, keyEls, matches }) => (
            <button key={zid} className="panel" onClick={() => dispatch({ type: 'LAUNCH', zone: zid })}
              style={{ textAlign: 'left', cursor: 'pointer', padding: '12px 16px',
                borderColor: matches ? (PE[keyEls[0]] ? PE[keyEls[0]].color : 'var(--ink)') : 'var(--ink-faded)',
                borderWidth: matches ? 1.5 : 1, background: matches ? 'rgba(184,99,42,0.04)' : 'transparent' }}>
              <div className="row" style={{ justifyContent: 'space-between', alignItems: 'center' }}>
                <div className="col gap-3" style={{ minWidth: 0 }}>
                  <span className="fell" style={{ fontSize: 18 }}>{z.name}
                    <span className="mono" style={{ fontSize: 9, color: 'var(--sepia)' }}> · keyed {z.key} · {z.todTheme}</span>
                  </span>
                  <div className="row gap-8" style={{ alignItems: 'center' }}>
                    {keyEls.map((e) => <span key={e} className="stamp" style={{ fontSize: 7, borderColor: (PE[e] ? PE[e].color : 'var(--ink)'), color: (PE[e] ? PE[e].color : 'var(--ink)') }}>{e.toUpperCase()}</span>)}
                    <span className="mono" style={{ fontSize: 9.5, color: remaining ? 'var(--stamp-amber)' : 'var(--ok)' }}>
                      {remaining ? `${remaining}/${total} recruits remain` : 'all recruits banked'}
                    </span>
                    {matches && <span className="mono" style={{ fontSize: 9, color: 'var(--ok)' }}>★ your specialty</span>}
                  </div>
                </div>
                <span className="btn" style={{ padding: '4px 12px', fontSize: 11, background: 'var(--ink)', color: 'var(--paper-light)' }}>Launch ▸</span>
              </div>
            </button>
          ))}
        </div>
        <div className="row" style={{ justifyContent: 'flex-end', marginTop: 16 }}>
          <button className="btn" onClick={() => dispatch({ type: 'CLOSE_LAUNCH' })}
            style={{ padding: '6px 16px', fontSize: 11, background: 'transparent', color: 'var(--sepia)', borderColor: 'var(--ink-faded)' }}>Cancel</button>
        </div>
      </div>
    </div>
  );
}

// Expose constants for port.jsx (which loads after this in the script tag order — but
// app.jsx renders last so we attach now for safety in case of HMR/timing oddities).
window.SH_SLOTS = SLOTS;
window.SH_ELEMENTS = ELEMENTS;
window.SH_RANK_NAMES = RANK_NAMES;
window.SH_C = C;
window.SH_RING = RING;
window.SH_HOME_SLOT = HOME_SLOT;
window.SH_UNLOCK_ORDER = UNLOCK_ORDER;
window.SH_checkChance = checkChance;
window.SH_resolveCheck = resolveCheck;
window.SH_capVal = capVal;
window.SH_ringNeighbor = ringNeighbor;
window.SH_ringOpposite = ringOpposite;
window.SH_ringAdjacent = ringAdjacent;
window.SH_FULLNESS_DRAIN_PER_SLOT = FULLNESS_DRAIN_PER_SLOT;
window.SH_ritualReady = ritualReady;
window.SH_formForRank = formForRank;

// Expose the REAL reducer + initial state so the conformance harness can drive
// the actual prototype state machine (dispatch real actions, read real state)
// without mounting the UI. (TASK A — harness drives real code.)
window.SkyhearthReducer = reducer;
window.SkyhearthInitialState = INITIAL_STATE;

// Guarded mount — the harness sets window.SKYHEARTH_NO_MOUNT before loading this
// file so it can use the reducer headlessly. The game itself mounts normally.
if (!window.SKYHEARTH_NO_MOUNT) {
  ReactDOM.createRoot(document.getElementById('canvas')).render(<App />);
}
