/* global React, ShipBlueprint, TrainingMode, NeedsMode */
const { useState, useEffect, useRef } = React;

// ====================================================================
// Constants — kept here so port-training.jsx / port-needs.jsx can import
// via window (each Babel <script> gets its own scope).
// ====================================================================
const PORT_SLOTS = [
  { id: 'night',     label: 'Night',     pair: ['ice',       'holy']   },
  { id: 'morning',   label: 'Morning',   pair: ['fire',      'earth']  },
  { id: 'afternoon', label: 'Afternoon', pair: ['lightning', 'wind']   },
  { id: 'evening',   label: 'Evening',   pair: ['water',     'nature'] },
];

// Element identity. `rescue` names the field rescue that UNLOCKS each sealed
// element (A1e: unlock fires from the rescue, NOT from building construction).
const PORT_ELEM = {
  fire:      { name: 'Fire',      glyph: '△', icon: 'assets/icons/fire.png',      color: 'var(--el-fire)',  rescue: 'start arc'        },
  ice:       { name: 'Ice',       glyph: '◊', icon: 'assets/icons/ice.png',       color: '#5a7d9a',         rescue: 'N2 · Nature-convince' },
  lightning: { name: 'Lightning', glyph: '╱', icon: 'assets/icons/lightning.png', color: '#7d5aa8',         rescue: 'later band'       },
  earth:     { name: 'Earth',     glyph: '□', icon: 'assets/icons/earth.png',     color: 'var(--el-earth)', rescue: 'start arc'        },
  water:     { name: 'Water',     glyph: '~', icon: 'assets/icons/water.png',     color: 'var(--el-water)', rescue: 'start arc'        },
  holy:      { name: 'Holy',      glyph: '✦', icon: 'assets/icons/holy.png',      color: '#b08c3c',         rescue: 'later band'       },
  wind:      { name: 'Wind',      glyph: '≋', icon: 'assets/icons/wind.png',      color: '#3f8a6e',         rescue: 'later band'       },
  nature:    { name: 'Nature',    glyph: '✿', icon: 'assets/icons/nature.png',    color: '#5d7a3f',         rescue: 'start arc'        },
};

const PORT_RANK_LABEL = ['Nascent', 'Lesser', 'Greater', 'True Primal'];

// Expose to the helper files BEFORE they render (they pull via window).
Object.assign(window, { PORT_SLOTS, PORT_ELEM, PORT_RANK_LABEL });

// ====================================================================
// SleepSchedule — shared time-of-day strip that marks BOTH the current slot
// and the spirit's sleep moment (its `sleepSlot`, Night by default). Used on
// the expedition ToD row AND the city screen so the schedule reads identically
// in both. Verbose on purpose: the sleep window names itself and its cost.
// ====================================================================
const SLEEP_GLYPHS = { Night: '☾', Morning: '☼', Afternoon: '◐', Evening: '☆' };
const SLEEP_SKIP_FATIGUE = 30;   // display mirror of app.jsx FATIGUE_SLEEP_SKIP
function SleepSchedule({ slotIdx = 0, sleepSlot = 0, showLabel = true }) {
  const labels = ['Night', 'Morning', 'Afternoon', 'Evening'];
  const cur = ((slotIdx % 4) + 4) % 4;
  const sleep = ((sleepSlot % 4) + 4) % 4;
  const atSleep = cur === sleep;
  return (
    <div className="row gap-10" style={{ alignItems: 'center', fontSize: 11, color: 'var(--sepia)' }}>
      {/* paddingTop gives the corner SLEEP stamp headroom so it never clips */}
      <div className="row gap-7" style={{ alignItems: 'center', paddingTop: 8 }}>
        {labels.map((s, i) => {
          const isCur = i === cur, isSleep = i === sleep;
          // The CURRENT slot is loud (filled amber, bold, full opacity); the rest
          // recede. The sleep slot wears a SLEEP stamp — colour + position name it.
          const accent = isCur ? 'var(--paper-light)' : isSleep ? 'var(--el-air)' : 'var(--ink-soft)';
          const border = isCur ? 'var(--stamp-amber)' : isSleep ? 'var(--el-air)' : 'var(--ink-faded)';
          const bg = isCur ? 'var(--stamp-amber)' : 'transparent';
          return (
            <span key={s} className="elite" style={{
              position: 'relative',
              fontSize: isCur ? 12 : 11, padding: isCur ? '3px 10px' : '2px 7px',
              whiteSpace: 'nowrap', letterSpacing: '0.02em',
              border: `${isCur ? 1.5 : 1}px ${isSleep && !isCur ? 'dashed' : 'solid'} ${border}`,
              color: accent, background: bg,
              fontWeight: isCur ? 700 : 400,
              opacity: isCur ? 1 : isSleep ? 0.8 : 0.4,
              boxShadow: isCur ? '0 1px 5px rgba(184,99,42,0.35)' : 'none',
            }}>
              {SLEEP_GLYPHS[s]} {s}
              {isSleep && (
                <span className="stamp" title={atSleep ? `Sleep window — acting costs +${SLEEP_SKIP_FATIGUE} fatigue` : 'The spirit\u2019s sleep moment'} style={{
                  position: 'absolute', top: -10, left: '50%', transform: 'translateX(-50%) rotate(-7deg)',
                  fontSize: 6.5, padding: '1px 4px', letterSpacing: '0.08em', opacity: 1,
                  whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 2,
                  background: 'var(--paper)',
                  borderColor: atSleep ? 'var(--crit)' : 'var(--el-air)',
                  color: atSleep ? 'var(--crit)' : 'var(--el-air)',
                }}>☾ SLEEP</span>
              )}
            </span>
          );
        })}
      </div>
    </div>
  );
}
Object.assign(window, { SleepSchedule });

// ====================================================================
// Resolve beat — when the player commits a slot action (Train, Bond,
// Dine, etc.), state is applied immediately so the diurnal clock + care
// bars can CSS-transition to their new positions. The mode swap
// (training → needs) is *deferred* by RESOLVE_MS, but starts the
// crossfade well before then (FADE_OUT_AT) so the transition feels
// continuous with the action commit, not a separate beat.
// ====================================================================
const RESOLVE_MS    = 650;
const FADE_OUT_AT   = 220;   // when the outgoing mode begins to fade
const NUM_ANIM_MS   = 480;
const NUM_ANIM_DELAY_MS = 80;

function useAnimatedNumber(target, { duration = NUM_ANIM_MS, delay = NUM_ANIM_DELAY_MS } = {}) {
  const [val, setVal] = useState(target);
  const fromRef = useRef(target);
  const targetRef = useRef(target);
  const rafRef = useRef(0);

  useEffect(() => {
    if (target === targetRef.current) return;
    fromRef.current = val;
    targetRef.current = target;
    const startAt = performance.now() + delay;
    function tick(now) {
      const t = Math.max(0, Math.min(1, (now - startAt) / duration));
      const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic
      const v = fromRef.current + (target - fromRef.current) * eased;
      setVal(v);
      if (t < 1) rafRef.current = requestAnimationFrame(tick);
    }
    rafRef.current = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(rafRef.current);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [target]);

  return val;
}

// <Num value={n} pad={2} /> — drop-in tweening number for stat readouts.
function Num({ value, pad: padN = 0 }) {
  const v = useAnimatedNumber(value);
  let s = Math.round(v).toString();
  if (padN > 0) s = s.padStart(padN, '0');
  return s;
}

Object.assign(window, { useAnimatedNumber, Num, RESOLVE_MS });

// ====================================================================
// PortPhase — orchestrates header / mode-switch / body / footer
// ====================================================================
function PortPhase({ state, dispatch, popupOpen }) {
  const mode = state.mode || 'train';
  const slot = PORT_SLOTS[state.slotIdx];
  const [inventoryOpen, setInventoryOpen] = useState(false);
  const [codexOpen, setCodexOpen] = useState(false);

  // Resolve beat. smartDispatch intercepts slot-consuming actions so they
  // play the "clank clank — bar fills — stats interp — needs surface"
  // sequence before the mode flips.
  const [resolving, setResolving] = useState(false);
  // bodyMode = which mode the body is currently SHOWING (vs `mode`, which is
  // the player's intent). They differ during the crossfade window: the
  // outgoing mode fades while the incoming mode mounts on top.
  const [bodyMode, setBodyMode] = useState(mode);
  const [fadingOut, setFadingOut] = useState(false);
  const resolvingRef = useRef(false);
  const timersRef = useRef([]);
  // A queued Needs swap that's waiting for a modal (ascension / collapse) to be
  // dismissed first — see smartDispatch + the popupOpen effect below.
  const pendingNeedsRef = useRef(null);
  useEffect(() => () => timersRef.current.forEach(clearTimeout), []);

  const stateRef = useRef(state);
  useEffect(() => { stateRef.current = state; }, [state]);

  // Perform the crossfade mode swap (training ↔ needs). Skips an empty Needs
  // panel and a no-op same-mode swap.
  function doModeSwap(landing) {
    if (landing === 'needs' && countNeeds(stateRef.current).outstanding === 0) landing = 'train';
    if (landing === bodyMode) return;
    setFadingOut(true);
    const tSwap = setTimeout(() => {
      dispatch({ type: 'SET_MODE', mode: landing });
      setBodyMode(landing);
      setFadingOut(false);
    }, 180);
    timersRef.current.push(tSwap);
  }

  // When a modal closes, run any Needs swap that was deferred so the popup and
  // the panel change don't fire at once (the double-change reads as jarring).
  useEffect(() => {
    if (popupOpen) return;
    if (pendingNeedsRef.current == null) return;
    const landing = pendingNeedsRef.current;
    pendingNeedsRef.current = null;
    doModeSwap(landing);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [popupOpen]);

  // Keep bodyMode in sync when mode changes outside the resolve beat
  // (e.g. clicking the pill directly).
  useEffect(() => {
    if (!resolvingRef.current) setBodyMode(mode);
  }, [mode]);

  function smartDispatch(action) {
    // FEED is interstitial — free, no slot tick, no mode-swap resolve beat.
    const isFreeFeed = action.type === 'CARE_ACTION' && action.kind === 'feed';
    if (isFreeFeed) { dispatch(action); return; }

    const isSlotAction = action.type === 'TRAIN' || action.type === 'CARE_ACTION';
    if (!isSlotAction) { dispatch(action); return; }
    if (resolvingRef.current) return; // ignore mashing during resolve

    // Decide the mode the player should land in after the beat:
    //  • TRAIN → swing to Needs (the spirit's reaction) — BUT only if there's
    //    actually a need to surface. Training on a settled spirit should not
    //    yank the player to an empty Needs panel.
    //  • CARE_ACTIONs (Rest, Dine, Forage, Pet) → never swap.
    //    They're resolutions of the panel the player is already on; let them
    //    see the bars settle in place. Feed is already filtered out above.
    // (Needs mode removed — actions now resolve in place; no mode swap.)
    let intent = null;

    resolvingRef.current = true;
    setResolving(true);
    const prevRank = stateRef.current.rank;     // to detect an ascension this action
    dispatch(action);

    if (intent) {
      // Start the crossfade *early* — outgoing mode begins to dim while the
      // action's number-tween is still finishing. Feels continuous, not
      // two separate beats. By this point dispatch has settled, so we can
      // consult stateRef to see if 'needs' would land on an empty panel.
      const tFade = setTimeout(() => {
        const st = stateRef.current;
        // Death resets the mode itself — don't queue anything.
        if (st.dying) { pendingNeedsRef.current = null; return; }
        // If this action also raised a modal (ascension / collapse), defer the
        // swap until the popup is dismissed — let the popup resolve first.
        if (st.rank > prevRank || st.collapsed) { pendingNeedsRef.current = intent; return; }
        doModeSwap(intent);
      }, FADE_OUT_AT);
      timersRef.current.push(tFade);
    }

    const tEnd = setTimeout(() => {
      setResolving(false);
      resolvingRef.current = false;
    }, RESOLVE_MS);
    timersRef.current.push(tEnd);
  }

  return (
    <div className="canvas-content" style={{
      padding: '16px 24px', display: 'flex', flexDirection: 'column', gap: 12,
    }}>
      <PortHeader state={state} resolving={resolving} />

      <hr className="divider-h" style={{ borderColor: 'var(--ink)', margin: 0 }} />

      {/* Mode switch row */}
      <div className="row" style={{ alignItems: 'center', padding: '0 2px', gap: 20 }}>
        <ModePill active={mode} onChange={m => !resolving && dispatch({ type: 'SET_MODE', mode: m })} dimmed={resolving} />
        <window.SleepSchedule slotIdx={state.slotIdx} sleepSlot={state.sleepSlot ?? 0} />
        <span style={{ flex: 1 }} />
        <ModeSubline mode={mode} state={state} slot={slot} resolving={resolving} />
      </div>

      {/* Mode body — bodyMode (not `mode`) drives this so we can crossfade.
          The wrapper plays a fadeIn keyframe on remount (key=bodyMode), and
          dims itself when fadingOut to let the new mode replace it cleanly. */}
      <div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
        <div key={bodyMode} className="mode-body" style={{
          position: 'absolute', inset: 0,
          opacity: fadingOut ? 0 : 1,
          transform: fadingOut ? 'translateY(-4px)' : 'translateY(0)',
          transition: 'opacity 180ms ease-out, transform 180ms ease-out',
          animation: fadingOut ? 'none' : 'modeFadeIn 240ms ease-out',
        }}>
          {bodyMode === 'train'
            ? <TrainingMode state={state} dispatch={smartDispatch} resolving={resolving} />
            : <NeedsMode    state={state} dispatch={smartDispatch} resolving={resolving} />}
        </div>
      </div>

      <PortFooter state={state} dispatch={dispatch} disabled={resolving} onOpenInventory={() => setInventoryOpen(true)} onOpenCodex={() => setCodexOpen(true)} />

      {inventoryOpen && (
        <InventoryOverlay state={state} dispatch={dispatch} onClose={() => setInventoryOpen(false)} />
      )}
      {codexOpen && window.CodexApp && (
        <window.CodexApp.CodexOverlay onClose={() => setCodexOpen(false)}
          sv={window.SH_discovery && window.SH_discovery.sv} />
      )}
    </div>
  );
}

// ====================================================================
// PortHeader — town · DayClock · spirit
// ====================================================================
function PortHeader({ state, resolving }) {
  // Top "vein" element (the spirit's strongest unlocked element).
  const top = topVein(state);
  const topVal = useAnimatedNumber(top.mastery);
  const totalOffers = Object.values(state.resources.offerings).reduce((a, n) => a + n, 0);
  const stapleVal = useAnimatedNumber(state.resources.staple);
  const offerVal  = useAnimatedNumber(totalOffers);

  return (
    <header style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr', alignItems: 'center', gap: 22, paddingRight: 104 }}>
      <div className="col gap-4">
        <div className="row gap-12" style={{ alignItems: 'baseline' }}>
          <div className="fell" style={{ fontSize: 22 }}>{state.town.name.toUpperCase()} PORT</div>
          <span className="stamp stamp-blue tilt-l">TIER {roman(state.town.tier)}</span>
        </div>
        <div className="row gap-12" style={{ fontSize: 12, color: 'var(--sepia)', flexWrap: 'wrap', alignItems: 'center' }}>
          <span><span className="elite">Pop.</span> <span className="mono" style={{ fontWeight: 600 }}>{pad(state.town.pop)} / 50</span></span>
          <span style={{ opacity: 0.4 }}>·</span>
          <span><span className="elite">Coin</span> <span className="mono" style={{ fontWeight: 600 }}>{pad(state.town.currency)}</span></span>
          <span style={{ opacity: 0.4 }}>·</span>
          <span><span className="elite">Staple</span> <span className="mono" style={{ fontWeight: 600, color: state.resources.staple < 3 ? 'var(--crit)' : 'var(--ink)' }}>{pad(Math.round(stapleVal))}</span></span>
          <span style={{ opacity: 0.4 }}>·</span>
          <span><span className="elite">Offerings</span> <span className="mono" style={{ fontWeight: 600 }}>{pad(Math.round(offerVal))}</span></span>
        </div>
      </div>

      <div className="col" style={{ alignItems: 'center', gap: 3 }}>
        <div className="label">Now Raising</div>
        <div className="row gap-10" style={{ alignItems: 'baseline' }}>
          <span className="fell" style={{ fontSize: 22 }}>{state.formName}</span>
          <span className="stamp stamp-amber tilt-r">{PORT_RANK_LABEL[state.rank].toUpperCase()}</span>
        </div>
      </div>

      <div />
    </header>
  );
}

// ====================================================================
// DayClock — 24h diurnal round. Lifespan/days-left is hidden from player.
// ====================================================================
function DayClock({ day, slotIdx, schedule, resolving }) {
  const size = 168;
  const cx = size / 2, cy = size / 2;
  const rOuter  = size / 2 - 3;
  const rInner  = rOuter - 13;
  const rNumber = rOuter - 18;
  const rLabel  = rInner * 0.62;
  const rHand   = rInner - 10;
  const rCenter = 20;
  const handHour = slotIdx * 6 + 3;
  // Accumulate the hand's rotation so CSS transitions take the FORWARD path
  // (clockwise, like a real clock) instead of lerp'ing backward through 180°.
  // Without this, slot 3 → slot 0 sweeps 315° → 45° counter-clockwise.
  const handDegRef = useRef(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;  // 0..359, always forward
    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={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
      <div className="label" style={{ marginBottom: 2 }}>Diurnal Round · The Day's Slots</div>
      <svg viewBox={`0 0 ${size} ${size}`} style={{ width: size, height: size }}>
        {/* rim */}
        <circle cx={cx} cy={cy} r={rOuter} fill="var(--paper-light)" stroke="var(--ink)" strokeWidth="1.6" />
        <circle cx={cx} cy={cy} r={rOuter - 2} 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.32; stroke = action.color; sw = 1.1; dash = 'none'; }
          else if (isNow) { fill = 'var(--stamp-amber)'; fillOp = 0.10; stroke = 'var(--stamp-amber)'; sw = 1.4; 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 */}
        {Array.from({ length: 24 }).map((_, h) => {
          const isMajor = h % 6 === 0;
          const isHalf  = h % 3 === 0 && !isMajor;
          const [x1, y1] = xy(h, rOuter);
          const [x2, y2] = xy(h, isMajor ? rOuter - 9 : isHalf ? rOuter - 6 : rOuter - 3);
          return <line key={h} x1={x1} y1={y1} x2={x2} y2={y2} stroke="var(--ink)" strokeWidth={isMajor ? 1.2 : 0.6} />;
        })}

        {/* numerals at cardinals */}
        {[0,6,12,18].map(h => {
          const [tx, ty] = xy(h, rNumber);
          return (
            <text key={h} x={tx} y={ty + 3} textAnchor="middle" fontSize="8.5"
                  fill="var(--sepia)" fontFamily="JetBrains Mono, monospace"
                  style={{ fontWeight: 600 }}>
              {h.toString().padStart(2, '0')}
            </text>
          );
        })}

        {/* 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" />;
        })}

        {/* slot action labels */}
        {[0,1,2,3].map(i => {
          const midH = i * 6 + 3;
          const [lx, ly] = xy(midH, rLabel);
          const action = schedule[i];
          const isNow = i === slotIdx;
          if (action) return (
            <text key={i} x={lx} y={ly + 3} textAnchor="middle" fontSize="10.5"
                  fill="var(--ink)" fontFamily="IM Fell English SC, serif"
                  style={{ letterSpacing: '0.04em' }}>{action.label}</text>
          );
          if (isNow) return (
            <text key={i} x={lx} y={ly + 3} textAnchor="middle" fontSize="11"
                  fill="var(--stamp-amber)" fontFamily="IM Fell English SC, serif"
                  style={{ letterSpacing: '0.08em' }}>NOW</text>
          );
          return (
            <text key={i} x={lx} y={ly + 3} textAnchor="middle" fontSize="10"
                  fill="var(--ink-faded)" fontFamily="Special Elite, monospace">—</text>
          );
        })}

        {/* hand — transitions smoothly when slot ticks ("time passing" cue) */}
        <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="2.2" strokeLinecap="round" />
          <line x1="0" y1="0" x2="0" y2={7} stroke="var(--ink)" strokeWidth="1.6" strokeLinecap="round" />
        </g>

        {/* center cap with day numeral */}
        <circle cx={cx} cy={cy} r={rCenter} fill="var(--paper)" stroke="var(--ink)" strokeWidth="1.2" />
        <text x={cx} y={cy - 2} textAnchor="middle" fontSize="7" fill="var(--sepia)"
              fontFamily="Special Elite, monospace" style={{ letterSpacing: '0.18em' }}>DAY</text>
        <text x={cx} y={cy + 12} textAnchor="middle" fontSize="15" fill="var(--ink)"
              fontFamily="IM Fell English SC, serif" style={{ fontWeight: 600 }}>
          <Num value={day} pad={2} />
        </text>
      </svg>
      <div className="mono" style={{
        fontSize: 9, letterSpacing: '0.18em', marginTop: 4,
        color: resolving ? 'var(--stamp-amber)' : 'var(--ink-faded)',
        opacity: resolving ? 1 : 0.35,
        transition: 'opacity 200ms ease, color 200ms ease',
      }}>
        {resolving ? 'RESOLVING SLOT …' : '— — —'}
      </div>
    </div>
  );
}

// ====================================================================
// ModePill — Training / Needs toggle
// ====================================================================
function ModePill({ active, onChange, dimmed }) {
  return (
    <div className="row" style={{
      alignItems: 'center', border: '1.5px solid var(--ink)', overflow: 'hidden',
      opacity: dimmed ? 0.55 : 1, pointerEvents: dimmed ? 'none' : 'auto',
      transition: 'opacity 200ms ease',
    }}>
      {[
        { id: 'train', label: 'Training', sub: 'Push elements' },
      ].map((m, i) => {
        const on = m.id === active;
        return (
          <div key={m.id} onClick={() => onChange(m.id)} style={{
            padding: '8px 16px', cursor: 'pointer',
            background: on ? 'var(--ink)' : 'transparent',
            color: on ? 'var(--paper-light)' : 'var(--ink)',
            borderLeft: i ? '1.5px solid var(--ink)' : 'none',
            opacity: on ? 1 : 0.62,
            transition: 'background 120ms ease, opacity 120ms ease',
          }}>
            <div className="fell" style={{ fontSize: 15, letterSpacing: '0.08em', lineHeight: 1 }}>{m.label.toUpperCase()}</div>
            <div className="mono" style={{ fontSize: 9, color: on ? 'rgba(244,236,214,0.72)' : 'var(--sepia)', marginTop: 2, letterSpacing: '0.12em' }}>{m.sub}</div>
          </div>
        );
      })}
    </div>
  );
}

// ====================================================================
// ModeSubline — quiet status line on the right of the mode switcher
// ====================================================================
function ModeSubline({ mode, state, slot, resolving }) {
  if (resolving) {
    return (
      <span className="mono" style={{ fontSize: 11, color: 'var(--stamp-amber)', letterSpacing: '0.16em' }}>
        SLOT COMMITTING — THE DAY TURNS
      </span>
    );
  }
  if (mode === 'train') {
    const [a, b] = slot.pair;
    return (
      <span className="mono" style={{ fontSize: 11, color: 'var(--sepia)', letterSpacing: '0.14em' }}>
        {slot.label.toUpperCase()} SLOT · ON-TIME {PORT_ELEM[a].name.toUpperCase()} · {PORT_ELEM[b].name.toUpperCase()}
      </span>
    );
  }
  const needs = countNeeds(state);
  return (
    <span className="mono" style={{ fontSize: 11, color: 'var(--sepia)', letterSpacing: '0.14em' }}>
      {needs.outstanding} NEEDS OUTSTANDING · {needs.urgent} URGENT
    </span>
  );
}

// Light needs count for the subline — full derivation lives in port-needs.jsx.
function countNeeds(state) {
  const c = state.care;
  let outstanding = 0, urgent = 0;
  if (c.fatigue > 55)   { outstanding++; urgent++; }
  if (c.fullness < 30)  { outstanding++; urgent++; }
  return { outstanding, urgent };
}

// ====================================================================
// PortFooter
// ====================================================================
function PortFooter({ state, dispatch, disabled, onOpenInventory, onOpenCodex }) {
  const canLaunch = state.rank >= 1;
  const launchOff = !canLaunch || disabled;
  const ready = window.SH_ritualReady ? window.SH_ritualReady(state) : null;
  const nextRank = state.rank + 1;
  const nextLabel = PORT_RANK_LABEL[nextRank] || '—';

  // Ascension is automatic — this is a passive readout of what the spirit is
  // forming toward, never an action the player takes.
  function ascentStatus() {
    if (state.rank >= 3) return 'True Primal — fully ascended.';
    if (!ready) return '—';
    const vein = ready.vein ? (window.PORT_ELEM[ready.vein]?.name || '') : '';
    if (ready.metaLocked) return `apex ≥ ${(window.SH_C||{}).THRESH ? window.SH_C.THRESH.T3 : 400} · meta-locked`;
    if (ready.ok) {
      if (ready.formKind === 'pure')  return `Qualifies · pure ${vein}`;
      if (ready.formKind === 'blend') {
        const v2 = ready.vein2 ? (window.PORT_ELEM[ready.vein2]?.name || '') : '';
        return `Qualifies · ${vein}/${v2} blend`;
      }
      return `Qualifies · Lesser ${vein}`;
    }
    const C = window.SH_C || {};
    const T = C.THRESH || {};
    if (nextRank === 1) return `any element ≥ ${T.T1}`;
    if (nextRank === 2) return `pure ≥ ${T.T2_pure} · or blend two adjacent ≥ ${T.T2_blend}`;
    return `apex ≥ ${T.T3} + town milestone`;
  }

  return (
    <footer className="row" style={{
      padding: '12px 16px', justifyContent: 'space-between', alignItems: 'stretch', gap: 12,
      border: '1.5px solid var(--ink)', background: 'rgba(244, 236, 214, 0.4)',
    }}>
      <div className="row" style={{ gap: 12 }}>
        <button className="btn" onClick={() => dispatch({ type: 'GOTO', page: 'map' })}>
          <span style={{ marginRight: 8 }}>◆</span>
          Hearth Archipelago
          <span className="mono" style={{ marginLeft: 12, fontSize: 12, opacity: 0.6 }}>
            {Object.values(state.constructed || {}).filter(Boolean).length} / 14 plots
          </span>
        </button>

        <button className="btn" onClick={onOpenInventory}>
          <span style={{ marginRight: 8 }}>▣</span>
          Inventory
        </button>

        <button className="btn" onClick={onOpenCodex}>
          <span style={{ marginRight: 8 }}>◎</span>
          Codex
        </button>
      </div>

      <button
        className="btn btn-launch"
        onClick={() => dispatch({ type: 'LAUNCH' })}
        disabled={launchOff}
        style={launchOff ? { opacity: 0.45, cursor: 'not-allowed' } : {}}
      >
        Launch Expedition ▸
      </button>
    </footer>
  );
}

// ====================================================================
// Helpers
// ====================================================================
function topVein(state) {
  return Object.entries(state.elements)
    .filter(([k, e]) => e.unlocked)
    .reduce((a, [k, e]) => e.mastery > a.mastery ? { id: k, mastery: e.mastery } : a, { id: 'fire', mastery: 0 });
}
function pad(n) { return n.toString().padStart(2, '0'); }
function roman(n) { return ['0','I','II','III','IV','V','VI','VII','VIII','IX','X'][n] || n; }

// Spirit icon path — R0 = drift mote; R1+ = the non-harnessed spirit form,
// keyed by the primal embedded in the form name ("Lesser Ifrit" → fire, etc).
// Filenames live in /assets and follow the matrix the user uploaded (wind→air,
// holy→light in the file names).
const PRIMAL_TO_ELEMENT_FILE = {
  ifrit:     'fire',
  shiva:     'ice',
  ramuh:     'lightning',
  titan:     'earth',
  leviathan: 'water',
  alexander: 'light',   // holy
  garuda:    'air',     // wind
  lakshmi:   'nature',
};
function spiritIconSrc(state, opts) {
  opts = opts || {};
  if (!state || state.rank === 0) return 'assets/drift-mote.png';
  // R2 Adult — the canonical pure/dual portrait (codex identities, §10) is the
  // SINGLE source of truth for the T2 texture: it serves both the wide field
  // icon AND the tight `bare` wheel-hub crop (slice-cropped, so a character
  // portrait fits the circle). Centralised here so the hub no longer diverged
  // into the young R1 sheet — the T2 form now actually shows up. Forms without
  // dedicated art (multi / junk) still fall through to the element sheet below.
  if (state.rank === 2 && window.CX_ART) {
    const fid = codexFormIdFor(state);
    if (fid && window.CX_ART[fid]) return window.CX_ART[fid].src;
  }
  // formName looks like "Lesser Ifrit" / "Greater Titan" / "Lakshmi, True Primal".
  const name = (state.formName || '').toLowerCase();
  let el = null;
  for (const primal in PRIMAL_TO_ELEMENT_FILE) {
    if (name.includes(primal)) { el = PRIMAL_TO_ELEMENT_FILE[primal]; break; }
  }
  if (!el) return 'assets/drift-mote.png';
  // Growth matrix: R1 Lesser = the bare young spirit (T_spirit_*); R2 Greater &
  // R3 True Primal = the grown, harness-rigged form (T_harness_spirit_*) — Greaters
  // had no distinct art before and reused the Lesser's. `opts.bare` forces the
  // creature-only art for tight circular crops (the training wheel hub).
  if (state.rank >= 2 && !opts.bare) return `assets/T_harness_spirit_${el}.jpeg`;
  return `assets/T_spirit_${el}.jpeg`;
}

// ====================================================================
// Codex discovery — REAL per-save progress (supersedes the mock review SAVE
// that the in-game codex was falling back to, which left almost everything
// "known" for no reason). A form is recorded the moment the spirit ascends
// INTO it (seen + trigger-known, per the codex two-axis model). The Nascent
// mote is always known. Persisted in localStorage so it survives a refresh.
// ====================================================================
const SH_SEEN_KEY = 'sh-codex-seen', SH_TK_KEY = 'sh-codex-tk';
function loadIdSet(k) { try { return new Set(JSON.parse(localStorage.getItem(k) || '[]')); } catch (e) { return new Set(); } }
function saveIdSet(k, set) { try { localStorage.setItem(k, JSON.stringify([...set])); } catch (e) {} }
const elSuffix = (el) => (el === 'lightning' ? 'lit' : el);
// Map the live spirit to its Codex form id (matches FORMS in codex/data.jsx).
function codexFormIdFor(state) {
  const FORMS = window.FORMS;
  if (!FORMS || !state || state.rank === 0) return 'mote';
  const unlocked = Object.entries(state.elements)
    .filter(([, e]) => e.unlocked)
    .sort((a, b) => (b[1].mastery || 0) - (a[1].mastery || 0));
  const top = unlocked[0] ? unlocked[0][0] : 'fire';
  if (state.rank === 1) return 'lesser_' + elSuffix(top);
  // R2 Greater (and R3, approximated): pure unless a ring-adjacent second vein
  // also cleared the blend bar — then it's the matching dual form.
  const second = unlocked[1] ? unlocked[1][0] : null;
  const secondVal = unlocked[1] ? (unlocked[1][1].mastery || 0) : 0;
  const blendNeed = (window.SH_C && window.SH_C.THRESH) ? window.SH_C.THRESH.T2_blend : 49;
  if (second && window.SH_ringAdjacent && window.SH_ringAdjacent(top, second) && secondVal >= blendNeed) {
    const dual = FORMS.find(f => f.profile === 'dual' && f.elements.length === 2 &&
      f.elements.includes(top) && f.elements.includes(second));
    if (dual) return dual.id;
  }
  return 'pure_' + elSuffix(top);
}
const SH_discovery = {
  sv(id) {
    if (id === 'mote') return { seen: 1, tk: 1 };
    const seen = loadIdSet(SH_SEEN_KEY), tk = loadIdSet(SH_TK_KEY);
    return { seen: seen.has(id) ? 1 : 0, tk: tk.has(id) ? 1 : 0 };
  },
  // Record the spirit's current form as discovered (achieving a form gives BOTH
  // axes — you've sighted it AND you know the recipe that made it).
  record(state) {
    const id = codexFormIdFor(state);
    if (!id || id === 'mote') return;
    const seen = loadIdSet(SH_SEEN_KEY), tk = loadIdSet(SH_TK_KEY);
    if (seen.has(id) && tk.has(id)) return;
    seen.add(id); tk.add(id);
    saveIdSet(SH_SEEN_KEY, seen); saveIdSet(SH_TK_KEY, tk);
  },
};
window.SH_discovery = SH_discovery;
window.SH_codexFormIdFor = codexFormIdFor;

// Expose helpers used by training/needs modules.
Object.assign(window, { pad, roman, topVein, spiritIconSrc });
// ====================================================================
// InventoryOverlay — modal listing the spirit's stockpile (staple +
// per-element offering banks). Each bank can be fed directly from here.
// ====================================================================
function InventoryOverlay({ state, dispatch, onClose }) {
  const PORT_ELEM = window.PORT_ELEM;
  const full = state.care.fullness >= 90;

  // Build the rows: staple first, then each unlocked element (showing 0s too,
  // so the player understands their gather range).
  const offerings = state.resources.offerings;
  const elementRows = Object.entries(PORT_ELEM)
    .filter(([k]) => state.elements[k]?.unlocked)
    .sort((a, b) => (offerings[b[0]] || 0) - (offerings[a[0]] || 0));

  function feedStaple() {
    if (state.resources.staple < 1 || full) return;
    dispatch({ type: 'CARE_ACTION', kind: 'feed', food: 'staple' });
  }
  function feedOffering(k) {
    if ((offerings[k] || 0) < 1 || full) return;
    dispatch({ type: 'CARE_ACTION', kind: 'feed', food: k });
  }

  // Close on Escape
  useEffect(() => {
    function onKey(e) { if (e.key === 'Escape') onClose(); }
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onClose]);

  return (
    <div onClick={onClose}
      style={{
        position: 'absolute', inset: 0, zIndex: 300,
        background: 'rgba(20, 16, 10, 0.55)',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
      }}>
      <div onClick={e => e.stopPropagation()} className="frayed-card"
        style={{
          width: 760, maxHeight: '86%',
          display: 'flex', flexDirection: 'column',
        }}>
        <div className="col" style={{ minHeight: 0, overflow: 'auto', flex: 1 }}>
        <div className="row" style={{
          padding: '12px 18px', borderBottom: '1.5px solid var(--ink)',
          alignItems: 'baseline', justifyContent: 'space-between',
        }}>
          <div className="col" style={{ gap: 2 }}>
            <span className="label">Spirit · Stockpile</span>
            <div className="fell" style={{ fontSize: 22 }}>Hearth Inventory</div>
          </div>
          <div className="row" style={{ alignItems: 'baseline', gap: 20 }}>
            <span className="mono" style={{ fontSize: 11, color: 'var(--sepia)', whiteSpace: 'nowrap' }}>
              <span className="elite">FULLNESS</span>&nbsp;<span style={{ color: 'var(--ink)', fontWeight: 700 }}>{pad(state.care.fullness)}</span>
            </span>
            <button onClick={onClose}
              style={{
                padding: '4px 12px', border: '1.5px solid var(--ink)',
                background: 'transparent', cursor: 'pointer',
                fontFamily: 'IM Fell English SC, serif', fontSize: 13, letterSpacing: '0.06em',
              }}>CLOSE · ESC</button>
          </div>
        </div>

        <div style={{ padding: '14px 18px', overflowY: 'auto' }}>
          <div className="elite" style={{ fontSize: 10, color: 'var(--sepia)', letterSpacing: '0.18em', marginBottom: 8 }}>
            BASIC FEED · SUSTAINS
          </div>
          <InventoryRow
            label="Staple"
            sub="Non-elemental ration · the everyday feed."
            glyph="◦" color="var(--ink-soft)"
            stock={state.resources.staple}
            effect="+22 fullness · sustains, grows nothing"
            onFeed={feedStaple}
            disabled={full || state.resources.staple < 1}
          />

          <div className="elite" style={{ fontSize: 10, color: 'var(--sepia)', letterSpacing: '0.18em', margin: '16px 0 8px' }}>
            ELEMENTAL OFFERINGS · DIRECTIONAL
          </div>
          {elementRows.map(([k, meta]) => {
            const stock = offerings[k] || 0;
            return (
              <InventoryRow key={k}
                label={`${meta.name} Offering`}
                sub={`Banked vial of ${meta.name.toLowerCase()}-aether.`}
                glyph={meta.glyph} icon={meta.icon} color={meta.color}
                stock={stock}
                effect={`+28 fullness · +3 ${meta.name} Mastery`}
                onFeed={() => feedOffering(k)}
                disabled={full || stock < 1}
              />
            );
          })}

          {Object.entries(PORT_ELEM).filter(([k]) => !state.elements[k]?.unlocked).length > 0 && (
            <>
              <div className="elite" style={{ fontSize: 10, color: 'var(--sepia)', letterSpacing: '0.18em', margin: '16px 0 8px' }}>
                SEALED · BEYOND THE TOWN'S REACH
              </div>
              <div className="row gap-12" style={{ alignItems: 'center', padding: '7px 0', borderBottom: '1px dashed var(--ink-ghost)', opacity: 0.5 }}>
                <span style={{ width: 24, color: 'var(--ink-faded)', fontFamily: 'IM Fell English SC, serif', fontSize: 16, textAlign: 'center' }}>◌</span>
                <span className="fell" style={{ fontSize: 14, flex: 1, color: 'var(--ink-faded)' }}>Sealed veins</span>
                <span className="elite" style={{ fontSize: 10, color: 'var(--sepia)', letterSpacing: '0.14em' }}>grow the town to reveal</span>
              </div>
            </>
          )}
        </div>

        <div className="row" style={{
          padding: '10px 18px', borderTop: '1px solid var(--ink-faded)',
          fontSize: 11, color: 'var(--sepia)', justifyContent: 'space-between',
        }}>
          <span className="mono">Click a stocked food to feed (free, no slot cost).</span>
          {full && (
            <span className="mono" style={{ color: 'var(--crit)' }}>◌ Spirit is full.</span>
          )}
        </div>
        </div>
      </div>
    </div>
  );
}

function InventoryRow({ label, sub, glyph, icon, color, stock, effect, onFeed, disabled }) {
  const off = disabled;
  // Empty banks are "hit" — struck through and dimmed so the player reads them
  // as depleted at a glance, not just a small ×00.
  const empty = stock <= 0;
  return (
    <div className="row gap-12" style={{
      alignItems: 'center', padding: '10px 0',
      borderBottom: '1px dashed var(--ink-faded)',
      opacity: empty ? 0.46 : 1,
      transition: 'opacity 150ms ease',
    }}>
      <span style={{
        width: 30, height: 30, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
        border: `1.5px solid ${empty ? 'var(--ink-faded)' : color}`,
        color: empty ? 'var(--ink-faded)' : color, fontFamily: 'IM Fell English SC, serif', fontSize: 18,
      }}>{icon ? <img src={icon} alt="" style={{ width: 20, height: 20, objectFit: 'contain', filter: empty ? 'grayscale(1)' : 'none' }} /> : glyph}</span>
      <div className="col" style={{ flex: 1, gap: 1 }}>
        <span className="fell" style={{ fontSize: 15, color: empty ? 'var(--ink-faded)' : color, textDecoration: empty ? 'line-through' : 'none', textDecorationThickness: '1px' }}>{label}</span>
        <span className="mono" style={{ fontSize: 10, color: 'var(--sepia)', letterSpacing: '0.06em' }}>{sub}</span>
      </div>
      <div className="col" style={{ alignItems: 'flex-end', minWidth: 160 }}>
        <span className="mono" style={{ fontSize: 11, color: 'var(--ink-soft)' }}>{effect}</span>
        <span className="fell" style={{ fontSize: 11, color: 'var(--sepia)' }}>
          {empty
            ? <span className="mono" style={{ color: 'var(--ink-faded)', fontWeight: 700, letterSpacing: '0.14em' }}>EMPTY</span>
            : <>stocked · <span className="mono" style={{ color: 'var(--ink)', fontWeight: 700 }}>×{pad(stock)}</span></>}
        </span>
      </div>
      <button onClick={off ? undefined : onFeed} disabled={off}
        style={{
          padding: '8px 14px', minWidth: 96,
          border: `1.5px solid ${off ? 'var(--ink-faded)' : color}`,
          background: off ? 'transparent' : `${color}1f`,
          color: off ? 'var(--ink-faded)' : color,
          cursor: off ? 'not-allowed' : 'pointer',
          fontFamily: 'IM Fell English SC, serif', fontSize: 13, letterSpacing: '0.06em',
          transition: 'background 120ms ease',
        }}>
        Feed ▸
      </button>
    </div>
  );
}

Object.assign(window, { PortPhase });
