/* global React, GraphNode, NT, SkyhearthT2 */
/* =====================================================================
   expedition-keyed.jsx — the PLAYABLE keyed-band expedition (T2).

   A raised spirit launched into one of the 4 keyed zones. Every node the player
   works resolves through window.SkyhearthT2.resolveAction (the shared core in
   t2-zones.js) — the SAME function the conformance harness drives. This component
   only renders the field + collects the run; the systems live in the core.

   Reuses the Z0 field vocabulary: the Aether Current Chart parchment panel,
   GraphNode glyphs, the Hull meter, the work/tend console split.
   ===================================================================== */
const { useState: useStateKx, useEffect: useEffectKx, useRef: useRefKx } = React;

const KX = {
  TRAVEL_MS: 560, RETURN_SPEEDUP: 3,
  FAT_MOVE: 4, FAT_WORK: 8, FAT_HAZARD: 6, FAT_LIGHT: 3,
  FULL_DRAIN: [12, 16, 22, 28],
};
// role → GraphNode visual glyph (NT taxonomy)
const KX_VISUAL = {
  vessel: 'launch', forage: 'forage', 'forage-rich': 'cache', offering: 'echo',
  scout: 'echo', 'event-restore': 'event', 'hazard-gentle': 'hazard',
  'hazard-mid': 'hazard', 'hazard-real': 'hazard', recruit: 'rescue',
  capstone: 'gate', hub: 'crossroads', junction: 'crossroads',
};
const KX_ROLE_LABEL = {
  forage: 'Forage drift', 'forage-rich': 'Rich corner', offering: 'Offering node',
  scout: 'Scout post', 'event-restore': 'Restorative cache',
  'hazard-gentle': 'Hazard · gentle', 'hazard-mid': 'Hazard · mid', 'hazard-real': 'Deep hazard',
  recruit: 'Foundered soul', capstone: 'Capstone', hub: 'Crossroads hub', junction: 'Junction', vessel: 'The Vessel',
};
const KX_ARRIVE = {
  forage: 'A forage drift. Comb it for stores, or press on.',
  'forage-rich': 'A rich corner — heavier stores and a chance at an offering.',
  offering: 'An offering node — draw the keyed element here.',
  scout: 'A scouting post — reveal the route ahead.',
  'event-restore': 'A restorative cache — the harness can mend here.',
  'hazard-gentle': 'A gentle hazard. Finesse it, force it, or pay a little Hull.',
  'hazard-mid': 'A mid hazard. Three ways through — pick your cost.',
  'hazard-real': 'A DEEP hazard. A raised build finesses or force-wins it; an under-matured one with no pay-route strands here.',
  recruit: 'A soul adrift. Claim the citizen (it then runs as a base node), or press on.',
  capstone: 'A guarded capstone — the lane\u2019s deep payoff.',
  hub: 'A crossroads. Commit to a lane.', junction: 'A junction — the lanes rejoin.', vessel: 'The vessel.',
};

const elementsMap = (state) => {
  const m = {}; for (const k in state.elements) m[k] = state.elements[k].mastery || 0; return m;
};

// ====================================================================
function KeyedExpeditionPhase({ state, dispatch, zoneId, onAbort }) {
  const T2 = window.SkyhearthT2;
  const zone = T2.ZONES[zoneId];
  const { N, fwd } = T2.parse(zoneId);
  const TERMS = T2.terminals(zoneId);
  const PE = window.PORT_ELEM || {};
  const elColor = (e) => (PE[e] ? PE[e].color : 'var(--gold)');
  const elName = (e) => (PE[e] ? PE[e].name : e);

  const SLOT_ORDER = T2.SLOT_ORDER;
  const todLabel = SLOT_ORDER[((state.slotIdx % 4) + 4) % 4];
  const rank = state.rank || 0;
  const FULL_DRAIN = KX.FULL_DRAIN[rank] ?? 16;
  const townBuilt = React.useMemo(() => new Set(Object.keys(state.built || {})), [state.built]);
  const hullMax = townBuilt.has('E1') ? T2.TUNING.HULL_E1 : T2.TUNING.HULL_BASE;

  // node coords / built node objects
  const coords = Object.fromEntries(Object.keys(N).map((id) => [id, { x: N[id][0], y: N[id][1] }]));
  const edges = zone.edges.map((s) => s.split(' '));

  const stateRef = useRefKx(state); stateRef.current = state;
  const timers = useRefKx([]);
  const push = (fn, ms) => { const id = setTimeout(fn, ms); timers.current.push(id); return id; };
  useEffectKx(() => () => timers.current.forEach(clearTimeout), []);

  const [run, setRun] = useStateKx(() => ({
    vessel: 'v',
    visited: { v: true },
    visitedPath: ['v'],
    revealed: new Set(['v', ...fwd['v']]),
    cleared: {},                 // hazards passed / recruits resolved (claimed/walled/deferred)
    worked: {},                  // a content node has been worked once (repeat = +slot)
    tried: {},                   // progression attempted at least once (retry = +slot, §3)
    haul: { staple: 0, coins: 0, offerings: {} },
    banked: [],                  // deep-recruit ids banked this run
    built: [],                   // building ids recruited this run (claimable at the Hearth)
    phase: 'choosing',
    floaty: null,
    note: `Launched into ${zone.name} \u2014 keyed to ${zone.key}. Work the lanes, mind the deep hazard, bank what you can.`,
  }));

  // floaty auto-clear
  useEffectKx(() => {
    if (!run.floaty) return;
    const id = setTimeout(() => setRun((r) => (r.floaty && r.floaty.key === run.floaty.key ? { ...r, floaty: null } : r)), 1000);
    return () => clearTimeout(id);
  }, [run.floaty && run.floaty.key]);

  const ctxNow = () => ({
    elements: elementsMap(stateRef.current), slot: todLabel,
    hull: stateRef.current.hull, hullMax,
    townBuilt, runClaimed: new Set(run.built),
  });

  const roleOf = (id) => T2.roleOf(zoneId, id);
  const blockingHazard = roleOf(run.vessel).startsWith('hazard') && !run.cleared[run.vessel];
  const reach = (run.phase === 'choosing' && !blockingHazard)
    ? fwd[run.vessel].filter((t) => !run.visited[t]) : [];
  const reachSet = new Set(reach);
  const isStranded = run.phase === 'stranded';
  const deadEnd = run.phase === 'choosing' && !blockingHazard && reach.length === 0;

  // ---- travel one edge (spends a slot on the shared clock) ----
  function travel(id) {
    if (run.phase !== 'choosing' || blockingHazard) return;
    if (!fwd[run.vessel].includes(id) || run.visited[id]) return;
    dispatch({ type: 'FIELD_CARE', tick: true, fat: KX.FAT_MOVE });
    setRun((r) => ({ ...r, phase: 'traveling', vessel: id,
      visited: { ...r.visited, [id]: true },
      visitedPath: r.visitedPath[r.visitedPath.length - 1] === id ? r.visitedPath : [...r.visitedPath, id] }));
    push(() => setRun((r) => {
      const revealed = new Set(r.revealed); fwd[id].forEach((n) => revealed.add(n));
      return { ...r, revealed, phase: 'choosing', note: KX_ARRIVE[roleOf(id)] || 'Bearing resolved.' };
    }), KX.TRAVEL_MS);
  }

  // ---- work an action at the current node (delegates to the shared core) ----
  function attempt(action) {
    if (run.phase !== 'choosing') return;
    const id = run.vessel;
    const role = roleOf(id);
    const alreadyWorked = !!run.worked[id];
    const isContent = action.kind === 'forage' || action.kind === 'forage-rich' || action.kind === 'offering';
    // primary action is FREE (the move paid it); a REPEAT adds a slot — either a
    // deep farm / 2nd draw of content, OR a RETRY of a failed gate (hazard /
    // recruit / capstone): the move buys the first attempt only (checks.md §3).
    const isGate = action.kind === 'hazard' || action.kind === 'recruit' || action.kind === 'capstone';
    const repeat = (alreadyWorked && isContent) || (isGate && !!run.tried[id]);
    const exertion = role.startsWith('hazard') ? KX.FAT_HAZARD
      : (action.kind === 'forage' || action.kind === 'forage-rich' || action.kind === 'offering') ? KX.FAT_WORK : KX.FAT_LIGHT;

    const o = T2.resolveAction(zoneId, id, action, ctxNow());
    const newHull = Math.max(0, Math.min(hullMax, stateRef.current.hull + (o.hullDelta || 0)));
    const failure = newHull <= 0 && !o.strand;
    dispatch({ type: 'FIELD_CARE', tick: repeat, fat: exertion, hull: o.hullDelta || 0 });

    setRun((r) => {
      const haul = { staple: r.haul.staple, coins: r.haul.coins, offerings: { ...r.haul.offerings } };
      const cleared = { ...r.cleared }, worked = { ...r.worked };
      const tried = { ...r.tried, [id]: true };
      const banked = [...r.banked], built = [...r.built];
      let fText = '', fColor = 'var(--gold)';
      let note = o.note || r.note;

      if (o.staple) { haul.staple += o.staple; fText = `+${o.staple} feed`; }
      if (o.offering) { haul.offerings[o.offering.el] = (haul.offerings[o.offering.el] || 0) + o.offering.amt; fText = `+${o.offering.amt} ${o.offering.el}`; fColor = elColor(o.offering.el); }
      if (o.hullDelta) { fText = `${o.hullDelta > 0 ? '+' : '\u2212'}${Math.abs(o.hullDelta)} Hull`; fColor = o.hullDelta > 0 ? 'var(--ok)' : 'var(--crit)'; }

      if (action.kind === 'forage' || action.kind === 'forage-rich' || action.kind === 'offering') {
        worked[id] = true; if (!o.staple && !o.offering) { fText = 'nothing'; fColor = 'var(--crit)'; }
      } else if (action.kind === 'scout') { worked[id] = true; cleared[id] = true; fText = 'scouted'; fColor = 'var(--el-air)';
        const rev = new Set(r.revealed); (fwd[id] || []).forEach((n) => { rev.add(n); (fwd[n] || []).forEach((m) => rev.add(m)); }); r.revealed = rev;
      } else if (action.kind === 'event-restore') { worked[id] = true; cleared[id] = true;
      } else if (action.kind === 'hazard') {
        if (o.strand) { fText = 'STRANDED'; fColor = 'var(--crit)'; }
        else if (o.pass) { cleared[id] = true; fText = o.how === 'finesse' ? 'finessed' : o.how === 'force-win' ? 'forced' : o.how === 'brute' ? 'brute' : 'paid'; fColor = 'var(--ok)'; }
      } else if (action.kind === 'recruit' || action.kind === 'capstone') {
        if (o.walled) { cleared[id] = true; fText = 'walled · NYI'; fColor = 'var(--sepia)'; }
        else if (o.pass && o.built) { cleared[id] = true; built.push(o.built); fText = 'recruited'; fColor = 'var(--el-air)'; }
        else if (o.pass && o.banked) { cleared[id] = true; banked.push(o.banked); fText = 'banked'; fColor = 'var(--el-air)'; }
        else if (o.pass && o.deferred) { cleared[id] = true; fText = 'deferred'; fColor = 'var(--stamp-amber)'; }
        else { fText = 'no answer'; fColor = 'var(--crit)'; }   // gate not met — retry allowed
      }

      const phase = o.strand ? 'stranded' : failure ? 'failure' : 'choosing';
      if (o.strand) note = o.note + ' The run ends here — the behind-hazard recruit is lost (recoverable on a later run).';
      if (failure) note = 'Hull integrity lost — the vessel breaks apart.';
      return { ...r, haul, cleared, worked, tried, banked, built, revealed: r.revealed, phase,
        floaty: { key: Date.now(), text: fText, color: fColor, node: id }, note };
    });
  }

  // ---- extraction → retrace home at 3× ----
  function startReturn() {
    setRun((r) => ({ ...r, phase: 'returning', note: 'Extracting — retracing the route home at 3\u00d7.' }));
    const path = [...run.visitedPath].reverse();
    const step = Math.max(110, Math.round(KX.TRAVEL_MS / KX.RETURN_SPEEDUP));
    path.slice(1).forEach((id, idx) => {
      push(() => setRun((r) => ({ ...r, vessel: id })), step * (idx + 1));
    });
    push(() => setRun((r) => ({ ...r, phase: 'success', note: 'Home. Cargo + recruits banked at the Hearth.' })), step * path.length + 200);
  }
  function returnToPort(success) {
    dispatch({ type: 'RETURN_PORT', success, haul: run.haul, rescues: [],
      banked: run.banked, built: run.built, zoneId, daysLeft: state.manifestDays });
  }

  // ---- field care passthroughs (shared reducer) ----
  function restHere() { if (run.phase === 'choosing') { dispatch({ type: 'CARE_ACTION', kind: 'rest' }); setRun((r) => ({ ...r, floaty: { key: Date.now(), text: 'rest', color: 'var(--ok)', node: r.vessel } })); } }
  function feedHere() {
    const s = stateRef.current;
    if (s.care.fullness >= 90) return;
    // Road-feed-first: eat what was scavenged on this run before the home larder.
    if (run.haul.staple >= 1) {
      dispatch({ type: 'FIELD_CARE', fill: 22 });   // +fullness, no town staple spent
      setRun((r) => ({ ...r, haul: { ...r.haul, staple: Math.max(0, r.haul.staple - 1) },
        floaty: { key: Date.now(), text: 'road feed', color: 'var(--ok)', node: r.vessel } }));
      return;
    }
    if (s.resources.staple < 1) return;
    dispatch({ type: 'CARE_ACTION', kind: 'feed', food: 'staple' });
    setRun((r) => ({ ...r, floaty: { key: Date.now(), text: 'town stores', color: 'var(--ok)', node: r.vessel } }));
  }
  function petHere() { if (stateRef.current.care.bondPct >= 100) return; dispatch({ type: 'CARE_ACTION', kind: 'bond' }); setRun((r) => ({ ...r, floaty: { key: Date.now(), text: '+12 Bond', color: 'var(--gold)', node: r.vessel } })); }

  // ---- display helpers ----
  function nodeState(n) {
    const id = n.id;
    if (id === run.vessel) return 'active';
    if (run.cleared[id] || run.worked[id]) return 'success';
    if (reachSet.has(id)) return 'reachable';
    if (TERMS.has(id)) return 'dest';
    if (run.revealed.has(id)) return 'revealed';
    return 'fog';
  }
  function edgeState(e) {
    const [a, b] = e;
    if (run.visited[a] && (run.visited[b] || b === run.vessel)) return 'traveled';
    if (a === run.vessel && reachSet.has(b)) return 'open';
    return 'dim';
  }
  const renderNode = (id) => ({ id, x: coords[id].x, y: coords[id].y,
    type: run.cleared[id] && (roleOf(id) === 'recruit') ? 'forage' : KX_VISUAL[roleOf(id)] || 'forage' });

  const vNode = coords[run.vessel];
  const spiritSrc = window.spiritIconSrc ? window.spiritIconSrc(state) : 'assets/drift-mote.png';

  // Live field API for the click-by-click PLAYTHROUGH harness — publishes the
  // component's REAL handlers (the exact functions the chart nodes + action
  // buttons call). The harness invokes these by id because the scaled SVG map
  // defeats synthetic coordinate clicks; the code path is identical to a click.
  useEffectKx(() => {
    window.SH_field = {
      kind: 'keyed', zoneId, vessel: run.vessel, phase: run.phase,
      reach, blockingHazard, edges,
      run, hull: state.hull,
      actions: (id) => T2.nodeActions(zoneId, id || run.vessel, ctxNow()),
      role: (id) => T2.roleOf(zoneId, id || run.vessel),
      travel, attempt, extract: startReturn,
    };
    return () => { if (window.SH_field && window.SH_field.kind === 'keyed') window.SH_field = null; };
  });

  return (
    <div className="canvas-content" style={{ padding: '24px 32px', display: 'flex', flexDirection: 'column', gap: 12 }}>
      {/* HEADER */}
      <header style={{ display: 'grid', gridTemplateColumns: '420px 1fr 120px', gap: 24, alignItems: 'center', paddingRight: 8 }}>
        <div className="col gap-4">
          <div className="label">Hull Integrity {hullMax > 20 ? '· mason pay-route open' : ''}</div>
          <KxHull hull={state.hull} max={20} />
        </div>
        <div className="col" style={{ alignItems: 'center', gap: 3 }}>
          <div className="fell" style={{ fontSize: 22, lineHeight: 1.1 }}>{zone.name}</div>
          <KxToD slot={state.slotIdx} order={SLOT_ORDER} keyEl={zone.key} todTheme={zone.todTheme} />
        </div>
        <button className="btn" onClick={onAbort}
          style={{ padding: '5px 10px', fontSize: 10, background: 'transparent', color: 'var(--sepia)', borderColor: 'var(--ink-faded)', justifySelf: 'end' }}>
          ◂ Abort to Port
        </button>
      </header>

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

      {/* CHART */}
      <div className="panel brackets" style={{ padding: '12px 24px 14px', height: 392, position: 'relative', overflow: 'hidden' }}>
        <div className="row" style={{ justifyContent: 'space-between', marginBottom: 6, position: 'relative', zIndex: 3 }}>
          <span className="label label-ink">Aether Current Chart · {zone.name} · keyed {zone.key}</span>
          <span className="stamp stamp-blue" style={{ fontSize: 9 }}>{todLabel.toUpperCase()} · KEY {zone.key.toUpperCase()}</span>
        </div>
        <svg viewBox="0 0 1300 380" preserveAspectRatio="none" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0.13, pointerEvents: 'none' }}>
          {[60, 130, 200, 270, 340].map((base, r) => (
            <path key={r} fill="none" stroke={r % 2 ? 'var(--el-air)' : 'var(--stamp-amber)'} strokeWidth="1.2"
              d={`M 0 ${base} ${Array.from({ length: 13 }).map((_, i) => `Q ${i * 100 + 50} ${base + (i % 2 ? -22 : 22)} ${(i + 1) * 100} ${base}`).join(' ')}`} />
          ))}
        </svg>
        {(() => {
          const VIEW_W = 1240, VIEW_H = 344, PAD = 14;
          const K = Math.min((VIEW_W - 2 * PAD) / 1300, (VIEW_H - 2 * PAD) / 340);
          const WORLD_W = 1300 * K, WORLD_H = 340 * K;
          const ox = (VIEW_W - WORLD_W) / 2, oy = (VIEW_H - WORLD_H) / 2;
          return (
            <div style={{ position: 'relative', zIndex: 2, width: '100%', height: VIEW_H, overflow: 'hidden' }}>
              <div style={{ position: 'absolute', top: 0, left: 0, width: 1300, height: 340, transformOrigin: 'top left', transform: `translate(${ox}px, ${oy}px) scale(${K})` }}>
                <svg viewBox="0 0 1300 340" width={1300} height={340} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
                  {[...edges].sort((a, b) => ({ dim: 0, open: 1, traveled: 2 }[edgeState(a)] - { dim: 0, open: 1, traveled: 2 }[edgeState(b)])).map((e, i) => {
                    const a = coords[e[0]], b = coords[e[1]]; if (!a || !b) return null;
                    const st = edgeState(e); const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
                    const stroke = st === 'traveled' ? 'var(--stamp-amber)' : st === 'open' ? 'var(--ink)' : 'var(--ink-faded)';
                    return <path key={i} d={`M ${a.x} ${a.y} Q ${mx} ${my} ${b.x} ${b.y}`} fill="none"
                      stroke={stroke} strokeWidth={st === 'traveled' ? 2.4 : st === 'open' ? 1.8 : 1}
                      strokeDasharray={st === 'dim' ? '4 6' : ''} opacity={st === 'dim' ? 0.55 : 0.9} strokeLinecap="round" />;
                  })}
                </svg>
                {Object.keys(N).map((id) => {
                  const s = nodeState(id);
                  return <GraphNode key={id} node={renderNode(id)} state={s} hideShip
                    onClick={s === 'reachable' ? () => travel(id) : undefined} />;
                })}
                <div style={{ position: 'absolute', left: vNode.x, top: vNode.y, transform: 'translate(-50%, calc(-50% - 30px))', transition: `left ${KX.TRAVEL_MS}ms cubic-bezier(0.4,0,0.6,1), top ${KX.TRAVEL_MS}ms cubic-bezier(0.4,0,0.6,1)`, zIndex: 8, pointerEvents: 'none' }}>
                  <img src={spiritSrc} alt="" style={{ width: 58, height: 58, objectFit: 'contain', filter: 'url(#sh-dropwhite) drop-shadow(0 4px 9px rgba(74,54,38,0.45))' }} />
                </div>
                {run.floaty && run.floaty.text && coords[run.floaty.node] && (
                  <div key={run.floaty.key} className="floaty" style={{ left: coords[run.floaty.node].x, top: coords[run.floaty.node].y - 40, transform: 'translateX(-50%)', fontSize: 22, color: run.floaty.color }}>{run.floaty.text}</div>
                )}
              </div>
            </div>
          );
        })()}
      </div>

      {/* CONSOLE */}
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 320px', gap: 12, flex: 1, minHeight: 0 }}>
        <KxConsole run={run} state={state} zoneId={zoneId} ctx={ctxNow()} reach={reach}
          blockingHazard={blockingHazard} deadEnd={deadEnd} isDest={TERMS.has(run.vessel)}
          onAttempt={attempt} onExtract={startReturn} onRest={restHere} onFeed={feedHere} onPet={petHere}
          onLimp={() => setRun((r) => ({ ...r, phase: 'stranded' }))} />
        <KxShipPanel state={state} hull={state.hull} hullMax={hullMax} haul={run.haul} banked={run.banked} built={run.built} zone={zone} />
      </div>

      {run.phase === 'success' && <KxModal title="Run Complete" tone="ok" onReturn={() => returnToPort(true)} haul={run.haul} banked={run.banked} built={run.built} />}
      {run.phase === 'stranded' && <KxModal title="Stranded" tone="warn" note="The deep hazard could not be forced. The behind-hazard recruit is lost — but never permanently; a later run of this zone restores it." onReturn={() => returnToPort(false)} haul={run.haul} banked={run.banked} built={run.built} />}
      {run.phase === 'failure' && <KxModal title="Hull Lost" tone="crit" note="The harness broke apart in the field." onReturn={() => returnToPort(false)} haul={run.haul} banked={run.banked} built={run.built} />}
    </div>
  );
}

// ====================================================================
// Hull meter (segmented; matches Z0)
function KxHull({ hull, max }) {
  const SEG = 20, filled = Math.ceil((hull / max) * SEG);
  return (
    <div className="row gap-8" style={{ alignItems: 'center' }}>
      <div className="row" style={{ gap: 2 }}>
        {Array.from({ length: SEG }).map((_, i) => (
          <span key={i} style={{ width: 7, height: 16, background: i < filled ? (hull / max > 0.33 ? 'var(--ink)' : 'var(--crit)') : 'var(--ink-trace)', border: '0.5px solid var(--ink-faded)' }} />
        ))}
      </div>
      <span className="mono" style={{ fontSize: 11, color: 'var(--sepia)' }}>{Math.round(hull)}/{max}</span>
    </div>
  );
}

// ToD strip with the keyed favour flag
function KxToD({ slot, order, keyEl, todTheme }) {
  const cur = ((slot % 4) + 4) % 4;
  const glyphs = { Night: '☾', Morning: '☼', Afternoon: '◐', Evening: '☆' };
  const onTheme = order[cur] === todTheme;
  return (
    <div className="row gap-8" style={{ fontSize: 11, color: 'var(--sepia)', alignItems: 'center' }}>
      <div className="row gap-6">
        {order.map((s, i) => (
          <span key={s} className="elite" style={{ fontSize: 11, padding: '1px 7px',
            border: `1px solid ${i === cur ? 'var(--stamp-amber)' : 'var(--ink-faded)'}`,
            color: i === cur ? 'var(--stamp-amber)' : 'var(--ink-faded)',
            background: i === cur ? 'rgba(184,99,42,0.08)' : 'transparent' }}>{glyphs[s]} {s}</span>
        ))}
      </div>
      <span className="mono" style={{ fontSize: 8.5, color: onTheme ? 'var(--ok)' : 'var(--ink-faded)' }}>
        {onTheme ? `★ ${keyEl} FAVOURED` : `key home: ${todTheme}`}
      </span>
    </div>
  );
}

// the node action console (work / tend)
function KxConsole({ run, state, zoneId, ctx, reach, blockingHazard, deadEnd, isDest, onAttempt, onExtract, onRest, onFeed, onPet, onLimp }) {
  const [tab, setTab] = useStateKx('work');
  const T2 = window.SkyhearthT2;
  const PE = window.PORT_ELEM || {};
  const elName = (e) => (PE[e] ? PE[e].name : e);

  if (run.phase !== 'choosing') {
    const tag = run.phase === 'traveling' ? 'UNDERWAY' : run.phase === 'returning' ? 'RETURNING ·3×' : 'COMPLETE';
    return (
      <div className="panel" style={{ display: 'flex', flexDirection: 'column' }}>
        <div className="row" style={{ padding: '8px 14px', borderBottom: '1px solid var(--ink-faded)', justifyContent: 'space-between' }}>
          <span className="label label-ink">Field Console</span><span className="mono" style={{ fontSize: 10, color: 'var(--sepia)' }}>{tag}</span>
        </div>
        <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 18 }}>
          <span className="elite" style={{ fontSize: 13, color: 'var(--sepia)', textAlign: 'center', maxWidth: 360 }}>{run.note}</span>
        </div>
      </div>
    );
  }

  const id = run.vessel;
  const role = T2.roleOf(zoneId, id);
  const acts = T2.nodeActions(zoneId, id, ctx).map((a) => ({ ...a, doneAlready: !!run.cleared[id] && a.kind !== 'forage' && a.kind !== 'forage-rich' && a.kind !== 'offering' }));
  const keyEl = T2.keyOf(zoneId, id);
  const info = (T2.NODE_INFO[id] || [null])[0];
  const c = state.care;
  const needsTend = c.fullness < 8 || c.fatigue >= 80 || c.weight <= 9;

  return (
    <div className="panel" style={{ display: 'flex', flexDirection: 'column', minHeight: 0 }}>
      <div className="row" style={{ padding: '8px 14px', borderBottom: '1px solid var(--ink-faded)', justifyContent: 'space-between', alignItems: 'center' }}>
        <span className="label label-ink">Field Console · {KX_ROLE_LABEL[role] || role}{info ? ` · ${info}` : ''}</span>
        <span className="mono" style={{ fontSize: 10, color: 'var(--sepia)' }}>{ctx.slot} · vs {String(keyEl).toUpperCase()}</span>
      </div>

      <div style={{ padding: '13px 14px', overflow: 'auto', flex: 1, minHeight: 0 }}>
        {tab === 'work'
          ? <KxWork acts={acts} role={role} run={run} id={id} onAttempt={onAttempt} keyEl={keyEl} />
          : <KxTend state={state} roadFeed={run.haul.staple} onRest={onRest} onFeed={onFeed} onPet={onPet} />}
      </div>

      <div style={{ borderTop: '1px solid var(--ink-faded)', flexShrink: 0 }}>
        <div className="row" style={{ padding: '7px 14px', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px dashed var(--ink-faded)' }}>
          <span className="label" style={{ color: (blockingHazard || deadEnd) ? 'var(--crit)' : 'var(--sepia)' }}>
            {blockingHazard ? 'A hazard bars the way — resolve it'
              : deadEnd ? 'A right-edge end — extract to bank the run'
              : isDest ? `A capstone end · ${reach.length} bearings open`
              : `Onward · ${reach.length} open · no backtrack`}
          </span>
          <span className="elite" style={{ fontSize: 10, color: 'var(--sepia)' }}>{reach.length ? 'pick a node ▸' : 'extract below'}</span>
        </div>
        <div style={{ padding: '8px 14px', borderBottom: '1px dashed var(--ink-faded)' }}>
          <button className="btn" style={{ width: '100%', padding: '9px 14px', fontSize: 13, background: 'var(--ink)', color: 'var(--paper-light)', letterSpacing: '0.10em' }}
            onClick={onExtract}>Extract · Return Home ▸</button>
        </div>
        <div className="row" style={{ padding: '8px 14px', gap: 0 }}>
          <KxTab label="Work the drift" active={tab === 'work'} onClick={() => setTab('work')} sub={`${acts.length} ACTION${acts.length === 1 ? '' : 'S'}`} />
          <KxTab label="Tend the spirit" active={tab === 'tend'} alert={needsTend} onClick={() => setTab('tend')} sub={needsTend ? 'NEEDS TENDING ⚠' : 'REST · PET · FEED'} />
        </div>
      </div>
    </div>
  );
}

function KxTab({ label, sub, active, alert, onClick }) {
  return (
    <button onClick={onClick} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, padding: '6px 8px', borderRadius: 0, cursor: 'pointer',
      border: `1.5px solid ${active ? 'var(--ink)' : 'var(--ink-faded)'}`, backgroundColor: active ? '#1a2e4a' : 'transparent', color: active ? '#f4ecd6' : 'var(--sepia)' }}>
      <span className="fell" style={{ fontSize: 14 }}>{label}</span>
      <span className="mono" style={{ fontSize: 8, letterSpacing: '0.06em', color: alert ? 'var(--crit)' : active ? '#f4ecd6' : 'var(--sepia)' }}>{sub}</span>
    </button>
  );
}

// the action list — hazards show the 3 solutions; recruits show claim + DAG state
function KxWork({ acts, role, run, id, onAttempt, keyEl }) {
  const PE = window.PORT_ELEM || {};
  const elColor = (e) => (PE[e] ? PE[e].color : 'var(--gold)');
  if (!acts.length) {
    return <div style={{ border: '1.5px dashed var(--ink-faded)', padding: '16px', textAlign: 'center' }}>
      <span className="elite" style={{ fontSize: 12, color: 'var(--sepia)' }}>{role === 'vessel' ? 'The vessel — set a bearing on the chart.' : 'A routing node — commit to a lane on the chart.'}</span>
    </div>;
  }
  const pcol = (p) => (p >= 0.66 ? 'var(--ok)' : p >= 0.33 ? 'var(--stamp-amber)' : 'var(--crit)');
  const cleared = !!run.cleared[id];
  return (
    <div className="col gap-9">
      {role.startsWith('hazard') && (
        <div style={{ padding: '5px 10px', border: '1px solid var(--ink-faded)', background: 'rgba(26,46,74,0.03)' }}>
          <span className="mono" style={{ fontSize: 9, color: 'var(--sepia)', lineHeight: 1.4 }}>
            THREE SOLUTIONS · finesse (clear by margin) · force (roll the band) · {role === 'hazard-real' ? 'default is NYI — needs the mason\u2019s pay-route or you strand' : 'pay a fixed Hull'}
          </span>
        </div>
      )}
      {acts.map((a) => {
        const disabled = a.enabled === false || (cleared && a.kind !== 'forage' && a.kind !== 'forage-rich' && a.kind !== 'offering' && a.sol == null && a.key !== 'claim');
        const claimedDone = cleared && (a.kind === 'recruit' || a.kind === 'capstone' || a.sol != null) ;
        return (
          <div key={a.key} className="row" style={{ justifyContent: 'space-between', alignItems: 'center',
            border: '1.5px solid var(--ink-faded)', padding: '9px 12px', opacity: (a.enabled === false || claimedDone) ? 0.55 : 1 }}>
            <div className="col gap-4" style={{ minWidth: 0 }}>
              <div className="row gap-8" style={{ alignItems: 'baseline' }}>
                <span className="fell" style={{ fontSize: 15 }}>{a.label}</span>
                {a.nyi && <span className="stamp" style={{ fontSize: 6.5, borderColor: 'var(--crit)', color: 'var(--crit)' }}>NYI</span>}
                {a.key === 'claim' && a.prereqs && a.prereqs.length > 0 && (
                  <span className="mono" style={{ fontSize: 8.5, color: a.prereqsOk ? 'var(--ok)' : 'var(--crit)' }}>
                    {a.prereqsOk ? 'prereqs met' : `needs ${a.need.join('+')}`}
                  </span>
                )}
              </div>
              <span className="elite" style={{ fontSize: 9.5, color: 'var(--sepia)' }}>{a.note}</span>
            </div>
            <div className="row gap-10" style={{ alignItems: 'center' }}>
              {claimedDone
                ? <span className="mono" style={{ fontSize: 10, color: 'var(--ok)' }}>✓ {a.sol ? 'CLEARED' : 'DONE'}</span>
                : <>
                    {a.p != null && a.kind !== 'scout' && a.kind !== 'event-restore' && (
                      <div className="row gap-6" style={{ alignItems: 'center' }}>
                        <div style={{ width: 50, height: 6, background: 'var(--ink-trace)', border: '0.5px solid var(--ink-faded)' }}>
                          <div style={{ height: '100%', width: `${Math.round(a.p * 100)}%`, background: pcol(a.p) }} />
                        </div>
                        <span className="mono" style={{ fontSize: 10, color: pcol(a.p), width: 30, textAlign: 'right' }}>{Math.round(a.p * 100)}%</span>
                      </div>
                    )}
                    <button className="btn" disabled={disabled} onClick={disabled ? undefined : () => onAttempt(a)}
                      style={{ padding: '5px 14px', fontSize: 12, background: disabled ? 'transparent' : 'var(--ink)', color: disabled ? 'var(--ink-faded)' : 'var(--paper-light)', borderColor: disabled ? 'var(--ink-faded)' : 'var(--ink)', cursor: disabled ? 'default' : 'pointer' }}>
                      {a.label.split(' ')[0]} ▸
                    </button>
                  </>}
            </div>
          </div>
        );
      })}
    </div>
  );
}

function KxTend({ state, roadFeed = 0, onRest, onFeed, onPet }) {
  const c = state.care;
  const Row = ({ glyph, name, meter, value, col, can, note, onAct }) => (
    <div style={{ border: '1.5px solid var(--ink-faded)', padding: '9px 12px', opacity: can ? 1 : 0.6 }}>
      <div className="row" style={{ justifyContent: 'space-between', alignItems: 'center', marginBottom: 7 }}>
        <span className="fell" style={{ fontSize: 16 }}>{glyph} {name}</span>
        <button className="btn" disabled={!can} onClick={can ? onAct : undefined}
          style={{ padding: '5px 16px', fontSize: 12, background: can ? 'var(--ink)' : 'transparent', color: can ? 'var(--paper-light)' : 'var(--ink-faded)', borderColor: can ? 'var(--ink)' : 'var(--ink-faded)' }}>{name} ▸</button>
      </div>
      <div className="row gap-10" style={{ alignItems: 'center' }}>
        <span className="mono" style={{ fontSize: 8.5, color: 'var(--sepia)', width: 52 }}>{meter}</span>
        <div style={{ flex: 1, height: 6, background: 'var(--ink-trace)', border: '0.5px solid var(--ink-faded)' }}>
          <div style={{ height: '100%', width: `${Math.max(0, Math.min(100, value))}%`, background: col, opacity: 0.85 }} />
        </div>
        <span className="elite" style={{ fontSize: 9.5, color: 'var(--sepia)', maxWidth: 220, textAlign: 'right' }}>{note}</span>
      </div>
    </div>
  );
  return (
    <div className="col gap-9">
      <Row glyph="☾" name="Rest" meter="Fatigue" value={c.fatigue} col={c.fatigue > 70 ? 'var(--crit)' : 'var(--stamp-amber)'} can mend onAct={onRest} note="−fatigue · mends Hull · +1 slot" />
      <Row glyph="♥" name="Pet" meter="Bond" value={c.bondPct} col="var(--gold)" can={c.bondPct < 100} onAct={onPet} note="+12 bond · +1 slot" />
      <Row glyph="◦" name="Feed" meter="Fullness" value={c.fullness} col={c.fullness < 8 ? 'var(--crit)' : 'var(--stamp-amber)'} can={(roadFeed + state.resources.staple) >= 1 && c.fullness < 90} onAct={onFeed} note={`ration · +22 fullness · road ${roadFeed} · stores ${state.resources.staple}`} />
    </div>
  );
}

// ship / haul panel
function KxShipPanel({ state, hull, hullMax, haul, banked, built, zone }) {
  const T2 = window.SkyhearthT2;
  const PE = window.PORT_ELEM || {};
  const offs = Object.entries(haul.offerings).filter(([, v]) => v > 0);
  const nm = (id) => (T2.NODE_INFO[id] || [id])[0];
  return (
    <div className="panel" style={{ display: 'flex', flexDirection: 'column', minHeight: 0 }}>
      <div className="row" style={{ padding: '8px 14px', borderBottom: '1px solid var(--ink-faded)', justifyContent: 'space-between' }}>
        <span className="label label-ink">The Hold</span><span className="mono" style={{ fontSize: 10, color: 'var(--sepia)' }}>{zone.key}</span>
      </div>
      <div style={{ padding: '12px 14px', overflow: 'auto', flex: 1, minHeight: 0 }} className="col gap-10">
        <div className="row" style={{ justifyContent: 'space-between' }}>
          <span className="elite" style={{ fontSize: 12, color: 'var(--sepia)' }}>Stores</span>
          <span className="mono" style={{ fontSize: 12 }}>{haul.staple} feed</span>
        </div>
        {offs.length > 0 && (
          <div className="col gap-3">
            <span className="elite" style={{ fontSize: 10, color: 'var(--sepia)' }}>Offerings drawn</span>
            {offs.map(([e, v]) => (
              <div key={e} className="row" style={{ justifyContent: 'space-between' }}>
                <span className="mono" style={{ fontSize: 11, color: (PE[e] ? PE[e].color : 'var(--gold)') }}>{PE[e] ? PE[e].name : e}</span>
                <span className="mono" style={{ fontSize: 11 }}>+{v}</span>
              </div>
            ))}
          </div>
        )}
        <hr className="divider-h" style={{ borderColor: 'var(--ink-faded)', margin: '2px 0' }} />
        <div className="col gap-4">
          <span className="elite" style={{ fontSize: 11, color: 'var(--sepia)' }}>Recruited this run ({built.length + banked.length})</span>
          {built.length + banked.length === 0
            ? <span className="mono" style={{ fontSize: 10, color: 'var(--ink-faded)' }}>none yet — claim a foundered soul</span>
            : [...built, ...banked].map((id) => (
              <div key={id} className="row gap-6" style={{ alignItems: 'baseline' }}>
                <span className="mono" style={{ fontSize: 10, color: T2.DEEP_RECRUITS.has(id) ? 'var(--stamp-amber)' : 'var(--el-air)' }}>{T2.DEEP_RECRUITS.has(id) ? '✚' : '◆'} {id}</span>
                <span className="elite" style={{ fontSize: 10, color: 'var(--sepia)' }}>{nm(id)}</span>
              </div>
            ))}
        </div>
      </div>
    </div>
  );
}

function KxModal({ title, tone, note, onReturn, haul, banked, built }) {
  const T2 = window.SkyhearthT2;
  const col = tone === 'ok' ? 'var(--ok)' : tone === 'warn' ? 'var(--stamp-amber)' : 'var(--crit)';
  return (
    <div className="modal-backdrop" style={{ position: 'absolute', inset: 0, background: 'rgba(20,16,10,0.62)', zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <div className="panel" style={{ width: 460, padding: '26px 30px', background: 'var(--paper-light)' }}>
        <div className="fell" style={{ fontSize: 26, color: col, marginBottom: 8 }}>{title}</div>
        {note && <div className="elite" style={{ fontSize: 12.5, color: 'var(--sepia)', lineHeight: 1.5, marginBottom: 14 }}>{note}</div>}
        <div className="col gap-5" style={{ marginBottom: 18 }}>
          <div className="row" style={{ justifyContent: 'space-between' }}><span className="elite" style={{ fontSize: 12, color: 'var(--sepia)' }}>Feed banked</span><span className="mono">{haul.staple}</span></div>
          <div className="row" style={{ justifyContent: 'space-between' }}><span className="elite" style={{ fontSize: 12, color: 'var(--sepia)' }}>Buildings recruited</span><span className="mono">{built.length}{built.length ? ` · ${built.join(', ')}` : ''}</span></div>
          <div className="row" style={{ justifyContent: 'space-between' }}><span className="elite" style={{ fontSize: 12, color: 'var(--sepia)' }}>Deep recruits banked</span><span className="mono">{banked.length}{banked.length ? ` · ${banked.join(', ')}` : ''}</span></div>
        </div>
        <button className="btn btn-launch" style={{ width: '100%' }} onClick={onReturn}>Return to the Hearth ▸</button>
      </div>
    </div>
  );
}

window.KeyedExpeditionPhase = KeyedExpeditionPhase;
