/* global React */
/*
  Training mode — the radial wheel + slot-pair training panel.
  Reads PORT_SLOTS / PORT_ELEM / PORT_RANK_LABEL / pad from window.
*/

const { useState: useTrainState, useEffect: useTrainEffect, useRef: useTrainRef } = React;

function TrainingMode({ state, dispatch, resolving }) {
  const PORT_SLOTS = window.PORT_SLOTS;
  const PORT_ELEM  = window.PORT_ELEM;
  const Num = window.Num;

  const slot = PORT_SLOTS[state.slotIdx];
  // Default the trained element to an UNLOCKED one: prefer this slot's home
  // element, else the first unlocked around the ring (the locked arc-ends
  // shouldn't strand the selector).
  function defaultTrain() {
    const home = slot.pair.find(e => state.elements[e]?.unlocked);
    if (home) return home;
    return (window.SH_RING || []).find(e => state.elements[e]?.unlocked) || 'fire';
  }
  const [trainMain, setTrainMain] = useTrainState(defaultTrain());
  // Reset selection if the active slot changes (slot tick or first render).
  useTrainEffect(() => { setTrainMain(defaultTrain()); }, [state.slotIdx]);

  const [hoverEl, setHoverEl] = useTrainState(null);
  const [floaty, setFloaty]   = useTrainState([]);
  const [cooldown, setCooldown] = useTrainState(false);

  // Press-and-hold to train: hold a vein ~0.5s to commit. Juicier than a
  // select-then-confirm second button, and the hold itself is the cancel window
  // (drag off / release early to abort). A quick tap just focuses the wheel.
  const HOLD_MS = 500;
  const [holdEl, setHoldEl]   = useTrainState(null);
  const [holdPct, setHoldPct] = useTrainState(0);
  const holdRAF = useTrainRef(null);
  const holdTimer = useTrainRef(null);
  useTrainEffect(() => () => {
    if (holdRAF.current) cancelAnimationFrame(holdRAF.current);
    if (holdTimer.current) clearTimeout(holdTimer.current);
  }, []);

  function cancelHold() {
    if (holdRAF.current) { cancelAnimationFrame(holdRAF.current); holdRAF.current = null; }
    if (holdTimer.current) { clearTimeout(holdTimer.current); holdTimer.current = null; }
    setHoldEl(null); setHoldPct(0);
  }

  function startHold(eid) {
    if (cooldown || resolving) return;
    if (!state.elements[eid]?.unlocked) return;
    setHoverEl(eid);                       // drive the wheel preview while holding
    cancelHold();
    setHoldEl(eid); setHoldPct(0);
    const start = performance.now();
    const tick = (now) => {                 // RAF drives the visual fill only
      setHoldPct(Math.min(1, (now - start) / HOLD_MS));
      holdRAF.current = requestAnimationFrame(tick);
    };
    holdRAF.current = requestAnimationFrame(tick);
    holdTimer.current = setTimeout(() => {   // timer is the reliable commit
      holdTimer.current = null;
      if (holdRAF.current) { cancelAnimationFrame(holdRAF.current); holdRAF.current = null; }
      setHoldEl(null); setHoldPct(0);
      commitTrain(eid);
    }, HOLD_MS);
  }

  function commitTrain(eid) {
    if (cooldown || resolving) return;
    if (!state.elements[eid]?.unlocked) return;
    setCooldown(true); setTimeout(() => setCooldown(false), 360);

    const C = window.SH_C;
    const main = PORT_ELEM[eid];
    const pure = !!state.town.pureTraining;
    const onTime = window.SH_HOME_SLOT[eid] === slot.id && !(state.onTimeUsedToday || []).includes(eid);
    const mainGain = pure ? C.PURE_MAIN : C.MAIN;
    const id1 = Math.random();
    const floats = [{ id: id1, text: `+${mainGain} ${main.name}`, color: main.color, x: 42, y: 50 }];
    setTimeout(() => setFloaty(f => f.filter(x => x.id !== id1)), 900);

    const Nn = window.SH_ringNeighbor(eid);
    if (!pure && Nn && state.elements[Nn]?.unlocked) {
      const leak = onTime ? C.LEAK_ON : C.LEAK_OFF;
      const id2 = id1 + 0.0007;
      floats.push({ id: id2, text: `+${leak} ${PORT_ELEM[Nn].name}`, color: PORT_ELEM[Nn].color, x: 60, y: 63 });
      setTimeout(() => setFloaty(f => f.filter(x => x.id !== id2)), 1000);
    }
    setFloaty(f => [...f, ...floats]);

    dispatch({ type: 'TRAIN', main: eid });
  }

  function doBond() {
    if (cooldown || resolving) return;
    setCooldown(true); setTimeout(() => setCooldown(false), 360);
    const id = Math.random();
    setFloaty(f => [...f, { id, text: '+12 Bond', color: 'var(--gold)', x: 50, y: 60 }]);  // floaty still reads the meter name
    setTimeout(() => setFloaty(f => f.filter(x => x.id !== id)), 900);
    dispatch({ type: 'CARE_ACTION', kind: 'bond' });
  }

  // Rest / Feed — the same care actions the Needs panel & expedition use, surfaced
  // HERE so the trainer can sleep (push the day) and feed without leaving Training.
  // Rest & Pet spend a slot (advance the clock); Feed is the free interstitial.
  function doRest() {
    if (cooldown || resolving) return;
    setCooldown(true); setTimeout(() => setCooldown(false), 360);
    const id = Math.random();
    const atSleep = (state.slotIdx % 4) === (state.sleepSlot ?? 0);
    setFloaty(f => [...f, { id, text: atSleep ? 'slept · fatigue cleared' : 'nap · \u221260 fatigue', color: 'var(--ok)', x: 50, y: 58 }]);
    setTimeout(() => setFloaty(f => f.filter(x => x.id !== id)), 900);
    dispatch({ type: 'CARE_ACTION', kind: 'rest' });
  }
  function doFeed() {
    if (resolving) return;
    if (state.care.fullness >= 90 || state.resources.staple < 1) return;
    const id = Math.random();
    setFloaty(f => [...f, { id, text: '+22 fullness', color: 'var(--stamp-amber)', x: 50, y: 58 }]);
    setTimeout(() => setFloaty(f => f.filter(x => x.id !== id)), 900);
    dispatch({ type: 'CARE_ACTION', kind: 'feed', food: 'staple' });
  }

  return (
    <div style={{ display: 'grid', gridTemplateColumns: '1fr 400px', gridTemplateRows: 'minmax(0, 1fr)', gap: 14, height: '100%' }}>
      {/* LEFT — Radial wheel as the focal display */}
      <div className="panel brackets" style={{ display: 'flex', flexDirection: 'column', position: 'relative' }}>
        <div className="row" style={{ padding: '8px 14px', borderBottom: '1px solid var(--ink-faded)', justifyContent: 'space-between' }}>
          <span className="label label-ink">Aetheric Wheel · The Eight</span>
          <span className="mono" style={{ fontSize: 10, color: 'var(--sepia)' }}>
            {Object.values(state.elements).filter(e => e.unlocked).length} ATTUNED
          </span>
        </div>
        <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', minHeight: 0 }}>
          <ElementRadial
            state={state}
            slot={slot}
            trainMain={trainMain}
            setTrainMain={setTrainMain}
            previewMain={hoverEl}
            onHover={setHoverEl}
            size={520}
            fill
          />
          {floaty.map(f => (
            <div key={f.id} className="floaty"
                 style={{ position: 'absolute', left: `${f.x}%`, top: `${f.y}%`, color: f.color, transform: 'translate(-50%, 0)' }}>
              {f.text}
            </div>
          ))}
        </div>
      </div>

      {/* RIGHT — care glance, slot pair training, veins */}
      <div className="col gap-10" style={{ minHeight: 0 }}>
        <CareGlance state={state} />
        <CareActionBar state={state} disabled={cooldown || resolving}
          onFeed={doFeed} onRest={doRest} onPet={doBond} />
        <SlotTrainingPanel
          slot={slot}
          state={state}
          holdEl={holdEl}
          holdPct={holdPct}
          onHoldStart={startHold}
          onHoldCancel={cancelHold}
          onHover={setHoverEl}
          onFocus={setTrainMain}
          disabled={cooldown || resolving}
        />
      </div>
    </div>
  );
}

// ====================================================================
// ElementRadial — informational 8-spoke wheel. No active-slot highlight,
// no gate copy on locked elements (the player learns those through the town).
// ====================================================================
function ElementRadial({ state, slot, trainMain, setTrainMain, previewMain, onHover, size = 480, fill = false }) {
  const PORT_ELEM = window.PORT_ELEM;
  const C = window.SH_C;
  const cx = size / 2, cy = size / 2;
  const rOuter = size * 0.40;
  // Radius encodes RAW element value on a √-curve, but normalized to the NEXT
  // milestone reached — not the far 400 apex — so the FIRST training is clearly
  // visible and the rim always reads as "the next goal". As the top element crosses
  // a milestone the wheel rescales outward (the goalposts move). Same curve drives
  // polygon, dots, preview AND threshold rings.
  const MILE = [C.THRESH.T1, C.THRESH.T2_blend, C.THRESH.T2_pure, C.THRESH.T3];
  const topM = Math.max(0, ...Object.values(state.elements).map(e => e.unlocked ? (e.mastery || 0) : 0));
  const scaleMax = MILE.find(m => m > topM + 1e-6) || C.THRESH.T3;
  const rOf = (m) => Math.min(1, Math.sqrt(Math.max(0, m) / scaleMax)) * rOuter;

  // Ring order — CLOCKWISE from the top. Each element leaks into the NEXT.
  const order = window.SH_RING;
  const ringNeighbor = window.SH_ringNeighbor;
  const homeSlot = window.SH_HOME_SLOT;
  function spokeAngle(i) { return (i / 8) * Math.PI * 2 - Math.PI / 2; }

  // Threshold rings (Mastery gates), drawn on the same curve. Only those INSIDE
  // the current scale (below the next milestone) show; the rim itself is scaleMax.
  const THRESH_RINGS = MILE.filter(m => m < scaleMax).map(m => ({ m }));

  const polyPts = order.map((eid, i) => {
    const el = state.elements[eid];
    const a = spokeAngle(i);
    const r = el.unlocked ? rOf(el.mastery) : 0;
    return [cx + Math.cos(a) * r, cy + Math.sin(a) * r];
  });

  // Preview-ghost polygon — MAIN +10 to the hovered element, plus the
  // directional leak into its ring-neighbour (on-time / off-time by slot).
  let previewPts = null;
  if (previewMain && state.elements[previewMain]?.unlocked) {
    const pure = !!state.town.pureTraining;
    const mainGain = pure ? C.PURE_MAIN : C.MAIN;
    const nbr = ringNeighbor(previewMain);
    const onTime = homeSlot[previewMain] === slot.id && !(state.onTimeUsedToday || []).includes(previewMain);
    const leak = onTime ? C.LEAK_ON : C.LEAK_OFF;
    const previewVal = (eid) => {
      const el = state.elements[eid];
      if (!el.unlocked) return 0;
      let v = el.mastery;
      if (eid === previewMain) v += mainGain;
      else if (!pure && eid === nbr) v += leak;
      return v;
    };
    previewPts = order.map((eid, i) => {
      const a = spokeAngle(i);
      return [cx + Math.cos(a) * rOf(previewVal(eid)), cy + Math.sin(a) * rOf(previewVal(eid))];
    });
  }

  // Leak chevrons — 8 small clockwise arrows on a guide circle (each element
  // leaks into the next, clockwise). Live (orange) on the arc leaving the
  // hovered/selected element.
  const rChev = rOuter + 12;
  const activeEl = previewMain || trainMain;
  // Pad the viewBox so the east/west spoke labels (Earth, the west Sealed
  // Vein, …) have room and don't clip at the edges.
  const PAD = 96;

  return (
    <svg viewBox={`${-PAD} ${-PAD} ${size + PAD * 2} ${size + PAD * 2}`} style={{
      width: fill ? '100%' : size,
      height: fill ? '100%' : size,
      maxWidth: size + PAD * 2, maxHeight: size + PAD * 2,
      display: 'block',
    }}>
      {/* opposite axes — 4 diameters, each links an element to its opposite
          (Fire↔Ice · Water↔Lightning · Earth↔Wind · Nature↔Holy) */}
      {[0, 1, 2, 3].map(i => {
        const a = spokeAngle(i);
        return <line key={`axis-${i}`}
          x1={cx + Math.cos(a) * rOuter} y1={cy + Math.sin(a) * rOuter}
          x2={cx - Math.cos(a) * rOuter} y2={cy - Math.sin(a) * rOuter}
          stroke="var(--ink-ghost)" strokeWidth="0.7" />;
      })}

      {/* threshold rings + tiny mod numerals tucked beside the top spoke */}
      {/* threshold rings (ascension milestones) — faint, unlabeled (the exact
         gates stay hidden; the player reads progress, not numbers) */}
      {THRESH_RINGS.map(t => (
        <circle key={t.m} cx={cx} cy={cy} r={rOf(t.m)} fill="none"
                stroke="var(--stamp-amber)" strokeWidth="0.8" strokeDasharray="2 4" opacity="0.32" />
      ))}
      {/* rim = the NEXT milestone (scaleMax) — the wheel rescales as you cross gates */}
      <circle cx={cx} cy={cy} r={rOuter} fill="none" stroke="var(--ink-faded)" strokeWidth="0.8" strokeDasharray="1 3" opacity="0.5" />

      {/* leak guide circle + clockwise chevrons */}
      <circle cx={cx} cy={cy} r={rChev} fill="none" stroke="var(--sepia)" strokeWidth="0.5" opacity="0.18" />
      {order.map((eid, i) => {
        const mid = spokeAngle(i) + Math.PI / 8;       // arc leaving element i, toward i+1
        const px = cx + Math.cos(mid) * rChev, py = cy + Math.sin(mid) * rChev;
        const rotDeg = Math.atan2(Math.cos(mid), -Math.sin(mid)) * 180 / Math.PI;  // tangent, clockwise
        const live = eid === activeEl && state.elements[ringNeighbor(eid)]?.unlocked;
        return (
          <g key={`chev-${eid}`} transform={`translate(${px} ${py}) rotate(${rotDeg})`}
             opacity={live ? 0.95 : 0.42}>
            <path d="M -3 -3.4 L 2.4 0 L -3 3.4" fill="none"
                  stroke={live ? 'var(--stamp-amber)' : 'var(--sepia)'} strokeWidth={live ? 1.8 : 1.1}
                  strokeLinecap="round" strokeLinejoin="round" />
          </g>
        );
      })}

      {/* preview-ghost polygon */}
      {previewPts && (
        <path d={previewPts.map((p,i) => (i ? 'L' : 'M') + p.join(' ')).join(' ') + ' Z'}
              fill="rgba(184, 99, 42, 0.16)" stroke="var(--stamp-amber)"
              strokeWidth="1" strokeDasharray="4 3" />
      )}

      {/* value polygon */}
      <path d={polyPts.map((p,i) => (i ? 'L' : 'M') + p.join(' ')).join(' ') + ' Z'}
            fill="rgba(26, 46, 74, 0.20)" stroke="var(--ink)" strokeWidth="1.8" strokeLinejoin="round" />

      {/* element dots */}
      {order.map((eid, i) => {
        const el = state.elements[eid];
        const meta = PORT_ELEM[eid];
        const a = spokeAngle(i);
        if (!el.unlocked) {
          return (
            <circle key={eid} cx={cx + Math.cos(a)*9} cy={cy + Math.sin(a)*9} r="2.5"
                    fill="none" stroke="var(--ink-faded)" strokeDasharray="1 1.5" />
          );
        }
        const r = rOf(el.mastery);
        const sel = eid === trainMain;
        return (
          <circle key={eid} cx={cx + Math.cos(a)*r} cy={cy + Math.sin(a)*r}
                  r={sel ? 5.5 : 4.5} fill={meta.color} stroke="var(--ink)" strokeWidth={sel ? 1.5 : 1} />
        );
      })}

      {/* labels at spoke tips — icon, name, raw element value */}
      {order.map((eid, i) => {
        const el = state.elements[eid];
        const meta = PORT_ELEM[eid];
        const a = spokeAngle(i);
        const lx = cx + Math.cos(a) * (rOuter + 34);
        const ly = cy + Math.sin(a) * (rOuter + 34);
        const home = homeSlot[eid] === slot.id;
        const ICON = 22;
        return (
          <g key={`l-${eid}`}>
            {el.unlocked ? (
              <image href={meta.icon} x={lx - ICON / 2} y={ly - ICON - 7} width={ICON} height={ICON} preserveAspectRatio="xMidYMid meet" />
            ) : (
              <text x={lx} y={ly - 9} textAnchor="middle" fontSize="15" fill="var(--ink-faded)">◌</text>
            )}
            <text x={lx} y={ly + 7} textAnchor="middle" fontSize="11.5"
                  fill={el.unlocked ? meta.color : 'var(--ink-faded)'}
                  fontFamily="IM Fell English SC, serif" style={{ letterSpacing: '0.03em' }}>
              {el.unlocked ? `${meta.name}${home ? ' ◷' : ''}` : 'Sealed Vein'}
            </text>
            <text x={lx} y={ly + 19} textAnchor="middle"
                  fontFamily="JetBrains Mono, monospace">
              {el.unlocked ? (
                <tspan fontSize="12" fontWeight="700" fill="var(--ink)">{el.mastery}</tspan>
              ) : (
                <tspan fontSize="9" fill="var(--ink-faded)">locked</tspan>
              )}
            </text>
          </g>
        );
      })}

      {/* center: form portrait + name (no ascend numerals) */}
      <defs>
        <clipPath id="spirit-center-clip">
          <circle cx={cx} cy={cy - 6} r="30" />
        </clipPath>
      </defs>
      <circle cx={cx} cy={cy - 6} r="32" fill="var(--paper)" stroke="var(--ink)" strokeWidth="1.2" />
      <image href={window.spiritIconSrc(state, { bare: true })}
             x={cx - 36} y={cy - 6 - 36} width="72" height="72"
             preserveAspectRatio="xMidYMid slice"
             clipPath="url(#spirit-center-clip)"
             filter="url(#sh-dropwhite)" />
      <circle cx={cx} cy={cy - 6} r="30" fill="none" stroke="var(--ink)" strokeOpacity="0.45" strokeWidth="0.8" />
      <rect x={cx - 46} y={cy + 26} width="92" height="22"
            fill="var(--paper)" stroke="var(--ink)" strokeWidth="0.8" />
      <text x={cx} y={cy + 41} textAnchor="middle" fontSize="11" fill="var(--ink)"
            fontFamily="IM Fell English SC, serif" style={{ letterSpacing: '0.06em' }}>
        {state.formName}
      </text>

      {/* interactive sectors — hovering an element's slice previews its train
         gain (ghost polygon + live leak chevron); clicking selects it to train. */}
      {order.map((eid, i) => {
        if (!state.elements[eid]?.unlocked) return null;
        const a0 = spokeAngle(i) - Math.PI / 8;
        const a1 = spokeAngle(i) + Math.PI / 8;
        const rS = rOuter + 6;
        const x0 = cx + Math.cos(a0) * rS, y0 = cy + Math.sin(a0) * rS;
        const x1 = cx + Math.cos(a1) * rS, y1 = cy + Math.sin(a1) * rS;
        return (
          <path key={`hit-${eid}`}
            d={`M ${cx} ${cy} L ${x0} ${y0} A ${rS} ${rS} 0 0 1 ${x1} ${y1} Z`}
            fill="transparent"
            style={{ pointerEvents: 'all', cursor: 'pointer' }}
            onMouseEnter={() => onHover && onHover(eid)}
            onMouseLeave={() => onHover && onHover(null)}
            onClick={() => setTrainMain && setTrainMain(eid)} />
        );
      })}
    </svg>
  );
}

// ====================================================================
// SlotTrainingPanel — explicit MAIN selector + Train / Bond actions
// ====================================================================
function SlotTrainingPanel({ slot, state, holdEl, holdPct, onHoldStart, onHoldCancel, onHover, onFocus, disabled }) {
  const PORT_ELEM = window.PORT_ELEM;
  const C = window.SH_C;
  const order = window.SH_RING;
  const homeSlot = window.SH_HOME_SLOT;
  const ringNeighbor = window.SH_ringNeighbor;
  const pure = !!state.town.pureTraining;

  return (
    <div className="panel brackets" style={{ padding: '12px 14px' }}>
      <div className="row" style={{ justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 10 }}>
        <span className="label label-ink">Train</span>
        <span className="mono" style={{ fontSize: 10, color: 'var(--sepia)' }}>HOLD · USES 1 TIME SLOT</span>
      </div>

      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
        {order.map(eid => {
          const el = state.elements[eid];
          const cellOnTime = homeSlot[eid] === slot.id && !(state.onTimeUsedToday || []).includes(eid);
          const cNbr = ringNeighbor(eid);
          const cNbrUnlocked = state.elements[cNbr]?.unlocked;
          return (
            <ElementCell key={eid}
              eid={eid}
              meta={PORT_ELEM[eid]}
              onTime={cellOnTime}
              mainGain={pure ? C.PURE_MAIN : C.MAIN}
              spillAmt={cellOnTime ? C.LEAK_ON : C.LEAK_OFF}
              spillColor={cNbrUnlocked ? PORT_ELEM[cNbr].color : 'var(--ink-faded)'}
              spillShown={!pure && cNbrUnlocked}
              locked={!el.unlocked}
              disabled={disabled}
              holding={holdEl === eid}
              holdPct={holdEl === eid ? holdPct : 0}
              onHoldStart={onHoldStart}
              onHoldCancel={onHoldCancel}
              onHover={() => onHover(eid)}
              onLeave={() => onHover(null)}
              onFocus={() => el.unlocked && onFocus && onFocus(eid)} />
          );
        })}
      </div>

      <div className="mono" style={{ marginTop: 9, fontSize: 9.5, color: 'var(--sepia)', letterSpacing: '0.04em', textAlign: 'center' }}>
        Press &amp; hold a vein to train it
      </div>
    </div>
  );
}

// ====================================================================
// CareGlance — the two care meters the trainer cares about
// ====================================================================
function CareGlance({ state, bare }) {
  const { fatigue, bondPct, bond, fullness } = state.care;
  const inner = (
    <div className="col gap-5">
      <GlanceBar label="Fatigue" value={fatigue} tone="crit"
                 hint={fatigue > 80 ? 'Forced' : fatigue > 55 ? 'Pushing' : 'Steady'}
                 alert={fatigue > 55} critical={fatigue > 80} />
      <GlanceBar label="Fullness" value={fullness} tone="amber"
                 hint={fullness > 90 ? 'Full' : fullness < 30 ? 'Hungry' : fullness < 40 ? 'Peckish' : 'Sated'}
                 alert={fullness < 40} critical={fullness < 30} />
      <GlanceBar label="Bond" value={bondPct} tone="gold" hint={bond} alert={bondPct < 25} />
    </div>
  );
  return bare ? inner : <div className="panel" style={{ padding: '8px 12px' }}>{inner}</div>;
}

// ====================================================================
// CareActionBar — the unified care actions (Feed · Rest · Pet), shared with
// the Needs panel & the expedition. Lives in Training so the trainer can sleep
// (push the day) and tend the spirit without switching modes. Feed is the free
// interstitial; Rest & Pet each spend a slot (advance the diurnal clock).
// ====================================================================
function CareActionBar({ state, disabled, extraFeed = 0, onFeed, onRest, onPet }) {
  const c = state.care;
  const atSleep = (state.slotIdx % 4) === (state.sleepSlot ?? 0);
  // extraFeed = road haul carried on an expedition (eaten before town stores).
  const totalFeed = state.resources.staple + extraFeed;
  const canFeed = c.fullness < 90 && totalFeed > 0;
  // The spirit is running low — call attention to Feed (parallels Rest lighting
  // up on its sleep window).
  const lowFood = c.fullness < 35;
  return (
    <div className="panel brackets" style={{ padding: '10px 12px' }}>
      <div className="row" style={{ justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
        <span className="label label-ink">Tend</span>
        <span className="mono" style={{ fontSize: 8.5, color: 'var(--sepia)', letterSpacing: '0.06em' }}>FEED · FREE &nbsp;·&nbsp; REST · 1 SLOT</span>
      </div>
      <div className="row gap-6">
        <CareBtn label="Feed"
          sub={lowFood ? 'hungry · feed now' : extraFeed > 0 ? `feed ×${totalFeed} · road first` : `staple ×${totalFeed}`}
          color="var(--stamp-amber)" disabled={disabled || !canFeed}
          highlight={lowFood && canFeed} onClick={onFeed} />
        <CareBtn label="Rest"
          sub={atSleep ? 'sleep · full clear' : 'nap · −60 fatigue'}
          color="var(--el-air)" disabled={disabled}
          highlight={atSleep} hold onClick={onRest} />
      </div>
    </div>
  );
}
function CareBtn({ label, sub, color, disabled, highlight, hold, onClick }) {
  // Hold-to-confirm (same gesture + timing as the training element cells): a
  // press fills a sweep over HOLD_MS and only commits when it reaches the end;
  // release / leave early aborts. Non-hold buttons (Feed) are a normal press.
  const HOLD_MS = 500;
  const [pct, setPct] = React.useState(0);
  const rafRef = React.useRef(null);
  const timerRef = React.useRef(null);
  const clear = () => {
    if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; }
    if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
  };
  React.useEffect(() => clear, []);
  const cancel = () => { clear(); setPct(0); };
  const begin = () => {
    if (disabled) return;
    clear();
    const start = performance.now();
    const tick = (now) => { setPct(Math.min(1, (now - start) / HOLD_MS)); rafRef.current = requestAnimationFrame(tick); };
    rafRef.current = requestAnimationFrame(tick);
    timerRef.current = setTimeout(() => { clear(); setPct(0); onClick(); }, HOLD_MS);
  };
  const holding = pct > 0;
  const fillW = Math.round(pct * 100);

  const style = {
    flex: 1, padding: '8px 10px', cursor: disabled ? 'not-allowed' : 'pointer',
    borderWidth: '1.5px', borderStyle: 'solid', borderColor: color, background: 'rgba(244, 236, 214, 0.6)',
    color: 'var(--ink)', textAlign: 'left', opacity: disabled ? 0.42 : 1, position: 'relative', overflow: 'hidden',
    transition: 'background 160ms ease, opacity 120ms ease, box-shadow 160ms ease, border-color 160ms ease',
    touchAction: hold ? 'none' : undefined, userSelect: hold ? 'none' : undefined,
  };
  const labelColor = color;
  const subColor = 'var(--sepia)';
  // Same lit/not-lit logic as the training element cells: amber border + quiet
  // wash + the 3px accent stripe when this is the favored moment; plain otherwise.
  if (highlight && !disabled) {
    style.borderColor = 'var(--stamp-amber)';
    style.background = 'rgba(184, 99, 42, 0.13)';
  }
  if (holding) { style.borderColor = color; style.background = `color-mix(in srgb, ${color} 12%, var(--paper))`; }

  const handlers = hold && !disabled
    ? { onPointerDown: (e) => { e.preventDefault(); begin(); }, onPointerUp: cancel, onPointerLeave: cancel, onPointerCancel: cancel }
    : { onClick: () => !disabled && onClick() };

  return (
    <button {...handlers} disabled={disabled} style={style}>
      {/* hold-to-confirm progress sweep */}
      {hold && (
        <span style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: `${fillW}%`,
          background: `color-mix(in srgb, ${color} 22%, transparent)`,
          borderRight: holding ? `2px solid ${color}` : 'none',
          transition: holding ? 'none' : 'width 140ms ease', pointerEvents: 'none' }} />
      )}
      {highlight && !disabled && <span style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 3, background: 'var(--stamp-amber)', pointerEvents: 'none' }} />}
      <div className="fell" style={{ fontSize: 14, lineHeight: 1, color: labelColor, position: 'relative' }}>{label}</div>
      <div className="mono" style={{ fontSize: 8.5, color: subColor, marginTop: 3, letterSpacing: '0.05em', position: 'relative' }}>{holding ? 'hold to confirm…' : sub}</div>
    </button>
  );
}

function GlanceBar({ label, value, tone, hint, alert, critical, max = 100 }) {
  const toneVar = { gold:'var(--gold)', ink:'var(--ink)', ok:'var(--ok)', amber:'var(--stamp-amber)', crit:'var(--crit)' }[tone] || 'var(--ink)';
  const pct = Math.max(0, Math.min(100, (value / max) * 100));
  // Panic tier: a critical stat reds out and the bar pulses, so urgency reads
  // here in the glance rather than on the (now-calm) Tend buttons.
  const barColor = critical ? 'var(--crit)' : toneVar;
  const sigColor = critical ? 'var(--crit)' : alert ? toneVar : 'var(--sepia)';
  return (
    <div className="row gap-6" style={{ alignItems: 'center' }}>
      <span style={{ width: 6, height: 6, borderRadius: 6, background: (alert || critical) ? barColor : 'transparent' }} />
      <div className="label label-ink" style={{ width: 58, fontSize: 9 }}>{label}</div>
      <div className={critical ? 'pulse-crit' : ''} style={{ flex: 1, height: 9, border: `1px solid ${critical ? 'var(--crit)' : 'var(--ink-faded)'}`, background: 'rgba(255,255,255,0.2)', position: 'relative' }}>
        <div style={{ position: 'absolute', inset: 0, width: `${pct}%`, background: barColor, opacity: critical ? 1 : 0.85, transition: 'width 250ms ease' }} />
      </div>
      <div className="mono" style={{ width: 30, textAlign: 'right', fontSize: 10.5, fontWeight: critical ? 800 : 600, color: critical ? 'var(--crit)' : 'inherit' }}>
        <window.Num value={value} pad={2} />{max !== 100 ? <span style={{ fontSize: 8, color: 'var(--ink-faded)' }}>/{max}</span> : ''}
      </div>
      <div className="elite" style={{ width: 62, fontSize: 9, whiteSpace: 'nowrap', fontWeight: critical ? 700 : 400, color: sigColor }}>{critical ? `⚠ ${hint}` : (hint || '')}</div>
    </div>
  );
}

function ElementCell({ eid, meta, onTime, mainGain, spillAmt, spillColor, spillShown, locked, disabled, holding, holdPct, onHoldStart, onHoldCancel, onHover, onLeave, onFocus }) {
  const lit = onTime && !locked;
  const interactive = !locked && !disabled;
  const borderColor = holding ? meta.color : lit ? 'var(--stamp-amber)' : 'var(--ink-faded)';
  const bg = holding ? `${meta.color}1f`
           : locked ? 'transparent'
           : lit ? 'rgba(184, 99, 42, 0.13)'
           : 'rgba(244, 236, 214, 0.5)';
  const fillW = holding ? Math.round((holdPct || 0) * 100) : 0;
  return (
    <div
      onPointerDown={interactive ? (e) => { e.preventDefault(); onHoldStart(eid); } : undefined}
      onPointerUp={interactive ? () => onHoldCancel() : undefined}
      onPointerLeave={() => { onLeave && onLeave(); if (interactive) onHoldCancel(); }}
      onPointerCancel={interactive ? () => onHoldCancel() : undefined}
      onMouseEnter={!locked ? onHover : undefined}
      onClick={interactive ? onFocus : undefined}
      style={{
        display: 'flex', alignItems: 'center', gap: 6,
        padding: '6px 8px', cursor: locked ? 'not-allowed' : 'pointer',
        border: `1.5px solid ${borderColor}`,
        background: bg,
        opacity: locked ? 0.4 : 1, position: 'relative', overflow: 'hidden',
        userSelect: 'none', touchAction: 'none',
        transition: 'background 150ms ease, border-color 150ms ease',
      }}>
      {/* hold-to-train progress sweep */}
      <span style={{
        position: 'absolute', left: 0, top: 0, bottom: 0, width: `${fillW}%`,
        background: `${meta.color}26`, borderRight: holding ? `2px solid ${meta.color}` : 'none',
        transition: holding ? 'none' : 'width 140ms ease', pointerEvents: 'none',
      }} />
      {/* on-time left accent stripe */}
      {lit && <span style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 3, background: 'var(--stamp-amber)', pointerEvents: 'none' }} />}
      {locked ? (
        <span style={{ width: 20, textAlign: 'center', color: 'var(--ink-faded)', fontFamily: 'IM Fell English SC, serif', fontSize: 15, lineHeight: 1, position: 'relative' }}>◌</span>
      ) : (
        <img src={meta.icon} alt="" style={{ width: 18, height: 18, objectFit: 'contain', flexShrink: 0, position: 'relative' }} />
      )}
      <span className="fell" style={{ fontSize: 13, flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', color: locked ? 'var(--ink-faded)' : 'inherit', position: 'relative' }}>
        {locked ? 'Sealed Vein' : meta.name}
      </span>
      {lit && (
        <span className="mono" title="On-time — +9 spill" style={{
          fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap', flexShrink: 0,
          padding: '2px 5px', lineHeight: 1, background: 'var(--stamp-amber)', color: 'var(--paper-light)', position: 'relative',
        }}>◷</span>
      )}
      {locked ? (
        <span className="mono" style={{ fontSize: 12, fontWeight: 700, color: 'var(--ink-faded)', position: 'relative' }}>—</span>
      ) : (
        <span className="row" style={{ alignItems: 'baseline', gap: 3, position: 'relative' }}>
          <span className="mono" style={{ fontSize: 13, fontWeight: 700, color: meta.color, lineHeight: 1 }}>+{mainGain}</span>
          {spillShown && <span className="mono" style={{ fontSize: 9, fontWeight: 600, color: spillColor, lineHeight: 1 }}>+{spillAmt}</span>}
        </span>
      )}
    </div>
  );
}

// ====================================================================
// SpiritVeinsPanel — quiet readout of the spirit's strongest veins
// ====================================================================
function SpiritVeinsPanel({ state }) {
  const PORT_ELEM = window.PORT_ELEM;
  const veins = Object.entries(state.elements)
    .filter(([, e]) => e.unlocked)
    .sort((a, b) => b[1].mastery - a[1].mastery)
    .slice(0, 3);
  return (
    <div className="panel" style={{ padding: '10px 14px' }}>
      <div className="label label-ink" style={{ marginBottom: 6 }}>The Spirit's Veins</div>
      <div className="row gap-8" style={{ alignItems: 'baseline', marginBottom: 6 }}>
        <span className="fell" style={{ fontSize: 16 }}>{state.formName}</span>
        <span className="elite" style={{ fontSize: 9, color: 'var(--sepia)', letterSpacing: '0.18em' }}>
          {(window.PORT_RANK_LABEL[state.rank] || '').toUpperCase()}
        </span>
      </div>
      <div className="col gap-3">
        {veins.map(([eid, el], i) => {
          const meta = PORT_ELEM[eid];
          const tag = i === 0 ? 'Strongest' : i === 1 ? 'Drift' : 'Trace';
          return (
            <div key={eid} className="row gap-8" style={{ fontSize: 11.5 }}>
              <span className="elite" style={{ width: 60, color: 'var(--sepia)', fontSize: 9.5, letterSpacing: '0.14em' }}>
                {tag.toUpperCase()}
              </span>
              <span style={{ color: meta.color, fontFamily: 'IM Fell English SC, serif', fontSize: 14 }}>{meta.glyph}</span>
              <span className="fell" style={{ color: meta.color, fontSize: 13 }}>{meta.name}</span>
              <span style={{ flex: 1 }} />
              <span className="mono" style={{ color: 'var(--ink)', fontWeight: 600 }}>{el.mastery}</span>
              <span className="mono" style={{ color: 'var(--ink-faded)', fontSize: 9, marginLeft: 4 }}>/ 999</span>
            </div>
          );
        })}
      </div>
    </div>
  );
}

// (TrainingTypesPanel removed — training-type variants come from buildings
// the town owns; they're not selected here.)

Object.assign(window, { TrainingMode, CareActionBar, CareGlance });
