/* Ears — control the MyMickey hardware from the app.
 *
 * The ears are a pair of round 720x720 panels running a character animation
 * off an SD card. They cannot be reached directly from here: this page is
 * served over real HTTPS, the ear's own certificate is self-signed, and plain
 * http:// from a secure page is blocked as mixed content. So the ear polls the
 * API every 1.5s and picks up whatever we left for it. Every control on this
 * page is therefore fire-and-forget — you are writing to a queue, not calling
 * a device, and the ear catches up on its next poll.
 *
 * Three ways to drive them, in increasing order of hands-off:
 *
 *   Manual      you press walk / jump / stop yourself.
 *   Step mode   your phone's accelerometer counts your steps and walks the
 *               character in time with you.
 *   Convention  the server runs the whole thing on a clock and you put your
 *               phone away.
 *
 * Convention mode deliberately lives on the server (server/party/convention.js)
 * rather than on a timer here. A phone in a pocket locks, throttles its timers
 * and eventually suspends the tab — which would stop the ears mid-afternoon.
 */
(function () {
/* PillStub, not StampPill: StampPill is the wait-time pill and takes a numeric
 * `value`, so the label below was dropped and every ear state rendered as the
 * default "0 min". PillStub is the label-shaped one. */
const { COLORS, Card, Caption, Display, Body, Stamp, PillStub, Icon } = window.PH365_UI;

const API_BASE = window.PH365_API_BASE || '';
const PIN_KEY = 'mm_ear_pin';

const api = async (path, init) => {
  const res = await fetch((API_BASE || '') + path, {
    ...init,
    headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
  });
  const body = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
  return body;
};

/* ---------- step detection ----------
 *
 * A pedometer off the raw accelerometer. Gravity is always in the signal, so
 * the magnitude of the vector sits around 9.8 at rest and each footfall shows
 * up as a spike above it. We low-pass the magnitude to kill jitter, then count
 * a step on a rising crossing of a threshold with a refractory period, which
 * is what stops one bouncy step registering as three.
 *
 * A much harder spike is taken as a jump — the same signal, a higher bar.
 */
function useStepDetector({ enabled, onStep, onJump, onStill }) {
  const [supported, setSupported] = React.useState(true);
  const [needsPermission, setNeedsPermission] = React.useState(false);
  const [steps, setSteps] = React.useState(0);
  const [granted, setGranted] = React.useState(false);

  const refs = React.useRef({ smooth: 9.8, armed: true, lastStep: 0, lastAny: 0, stillFired: true });
  const cbs = React.useRef({ onStep, onJump, onStill });
  cbs.current = { onStep, onJump, onStill };

  React.useEffect(() => {
    if (typeof window.DeviceMotionEvent === 'undefined') { setSupported(false); return; }
    // iOS 13+ gates motion behind an explicit grant that must originate in a
    // user gesture, so it cannot be requested from here — only from the button.
    if (typeof window.DeviceMotionEvent.requestPermission === 'function' && !granted) {
      setNeedsPermission(true);
    }
  }, [granted]);

  const requestPermission = React.useCallback(async () => {
    try {
      if (typeof window.DeviceMotionEvent?.requestPermission === 'function') {
        const r = await window.DeviceMotionEvent.requestPermission();
        if (r !== 'granted') return false;
      }
      setGranted(true);
      setNeedsPermission(false);
      return true;
    } catch {
      return false;
    }
  }, []);

  React.useEffect(() => {
    if (!enabled || needsPermission || !supported) return undefined;

    const STEP_THRESHOLD = 2.2;   // m/s² above the smoothed baseline
    const JUMP_THRESHOLD = 9.0;   // a real hop, not a heavy footfall
    const REFRACTORY_MS = 260;    // fastest believable step cadence
    const STILL_MS = 1300;        // no steps for this long => stop walking

    const onMotion = (e) => {
      const a = e.accelerationIncludingGravity;
      if (!a || a.x == null) return;
      const mag = Math.sqrt(a.x * a.x + a.y * a.y + a.z * a.z);
      const r = refs.current;
      r.smooth = r.smooth * 0.9 + mag * 0.1;      // low-pass baseline
      const delta = mag - r.smooth;
      const now = Date.now();

      if (delta > JUMP_THRESHOLD && now - r.lastStep > REFRACTORY_MS) {
        r.lastStep = now; r.lastAny = now; r.stillFired = false; r.armed = false;
        cbs.current.onJump?.();
        return;
      }
      if (r.armed && delta > STEP_THRESHOLD && now - r.lastStep > REFRACTORY_MS) {
        r.lastStep = now; r.lastAny = now; r.stillFired = false; r.armed = false;
        setSteps(s => s + 1);
        cbs.current.onStep?.();
      } else if (delta < STEP_THRESHOLD * 0.4) {
        r.armed = true;                            // must fall back before the next
      }
    };

    const still = setInterval(() => {
      const r = refs.current;
      if (!r.stillFired && Date.now() - r.lastAny > STILL_MS) {
        r.stillFired = true;
        cbs.current.onStill?.();
      }
    }, 400);

    window.addEventListener('devicemotion', onMotion);
    return () => { window.removeEventListener('devicemotion', onMotion); clearInterval(still); };
  }, [enabled, needsPermission, supported]);

  return { supported, needsPermission, requestPermission, steps, resetSteps: () => setSteps(0) };
}

/* ---------- small pieces ---------- */

function PinGate({ onSave }) {
  const [v, setV] = React.useState('');
  return (
    <Card>
      <Display size={18}>Host PIN</Display>
      <Body style={{ color: COLORS.inkSoft, margin: '6px 0 12px' }}>
        The PIN your pair of ears carries — typed on the ear's WiFi screen or
        its controller page. One number links the ears, hosts the games, and
        runs the photos. Stored on this phone only.
      </Body>
      <div style={{ display: 'flex', gap: 8 }}>
        <input
          value={v}
          onChange={e => setV(e.target.value)}
          placeholder="host PIN"
          style={{
            flex: 1, padding: '12px 14px', borderRadius: 12, fontSize: 16,
            border: `1.5px solid ${COLORS.paperEdge}`, background: COLORS.paper, color: COLORS.ink,
          }}
        />
        <button onClick={() => v.trim() && onSave(v.trim())} style={btn(COLORS.teal, true)}>Save</button>
      </div>
    </Card>
  );
}

function btn(color, solid, disabled) {
  return {
    padding: '12px 16px', borderRadius: 12, fontSize: 15, fontWeight: 700,
    cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.45 : 1,
    border: `1.5px solid ${color}`,
    background: solid ? color : 'transparent',
    color: solid ? COLORS.paper : color,
    fontFamily: 'inherit',
  };
}

/* ---------- Heads Up calibration wizard ----------
 *
 * The tilt sensor lives in ONE ear of the headband, and the phone cannot
 * read it directly — but the ear can stream its gravity reading up through
 * the relay ({"gstream":1} puts gx/gy/gz into every status poll). So the
 * wizard runs here, where the instructions can be read while the headband
 * is being worn: hold steady, tilt forward, tilt backward. From those three
 * gravity vectors it computes the neutral direction, the nod rotation axis,
 * and a threshold, and hands the result back to the ear ({"hucalset":...}),
 * which saves it to flash. Because everything is derived from what the
 * sensor actually reported in each pose, the board's mounting angle —
 * upside-down included — cancels out, and play-time tilt is measured as a
 * true rotation in degrees.
 *
 * Pacing: the ear posts a status every ~1.5s, so one pose needs a few
 * seconds of holding still. gq counts sensor samples — a change is a fresh
 * reading, a stall means the stream (or the ear) went away. */

const HU_SAMPLES = 3;          /* fresh readings per pose */
const HU_POSE_MIN_DEG = 12;    /* fwd/back must move at least this far */
const HU_STEADY_TOL = 80;      /* milli-g of agreement between readings */

const vdot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const vcross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const vmag = a => Math.hypot(a[0], a[1], a[2]);
const vnorm = a => { const m = vmag(a) || 1; return [a[0] / m, a[1] / m, a[2] / m]; };
const vangleDeg = (a, b) => Math.atan2(vmag(vcross(a, b)), vdot(a, b)) * 180 / Math.PI;

function HeadsUpWizard({ pin, earId, earLabel, onSaved, onClose }) {
  const [step, setStep] = React.useState('intro');  /* intro steady fwd back send test */
  const [msg, setMsg] = React.useState('');
  const [got, setGot] = React.useState(0);          /* readings banked this pose */
  const [live, setLive] = React.useState(null);     /* the gyro ear's latest status */
  const m = React.useRef({ gq: null, gqAt: Date.now(), samples: [],
                           N: null, F: null, B: null, cal: null, sentGq: 0 });

  const send = React.useCallback(async (cmd) => {
    try {
      await api('/api/ears/cmd', {
        method: 'POST',
        body: JSON.stringify({ key: pin, id: earId, cmd }),
      });
    } catch { /* the poll below reports a dead connection in its own words */ }
  }, [pin, earId]);

  /* Keep the ear's gravity stream open for as long as the wizard is up. */
  React.useEffect(() => {
    send({ gstream: 1 });
    const t = setInterval(() => send({ gstream: 1 }), 25000);
    return () => { clearInterval(t); send({ gstream: 0 }); };
  }, [send]);

  const stepRef = React.useRef(step);
  stepRef.current = step;

  /* One fast poll loop; each fresh gq is one sensor reading to bank. */
  React.useEffect(() => {
    let alive = true;

    const banked = (pose, avg) => {
      const s = m.current;
      s.samples = [];
      setGot(0);
      if (pose === 'steady') { s.N = avg; setMsg(''); setStep('fwd'); return; }
      if (pose === 'fwd') { s.F = avg; setMsg(''); setStep('back'); return; }
      s.B = avg;
      /* All three poses in hand — do the geometry. The nod axis comes from
       * both swings so a lopsided forward pose cannot tilt it. */
      const N = vnorm(s.N), F = vnorm(s.F), B = vnorm(s.B);
      const axisRaw = [0, 1, 2].map(i => vcross(N, F)[i] + vcross(B, N)[i]);
      if (vmag(axisRaw) < 0.1) {
        setMsg('Those poses were too alike — starting over. Bigger tilts this time.');
        s.N = s.F = s.B = null;
        setStep('steady');
        return;
      }
      const axis = vnorm(axisRaw);
      const t = vnorm(vcross(axis, N));
      const swing = Math.min(vangleDeg(N, F), vangleDeg(N, B));
      const thr = Math.max(12, Math.min(35, Math.round(swing * 0.35)));
      s.cal = { n: N, t, thr };
      s.sentGq = s.gq;
      send({
        hucalset: 1,
        hunx: Math.round(N[0] * 1000), huny: Math.round(N[1] * 1000), hunz: Math.round(N[2] * 1000),
        hutx: Math.round(t[0] * 1000), huty: Math.round(t[1] * 1000), hutz: Math.round(t[2] * 1000),
        huthr: thr,
      });
      setMsg('');
      setStep('send');
    };

    const feed = (sample) => {
      const s = m.current;
      const pose = stepRef.current;
      if (pose === 'fwd' || pose === 'back') {
        const away = vangleDeg(sample, s.N);
        if (away < HU_POSE_MIN_DEG) {
          s.samples = [];
          setGot(0);
          setMsg(pose === 'fwd' ? 'Tilt further forward and hold it…'
                                : 'Tilt further back and hold it…');
          return;
        }
        if (pose === 'back' && s.F &&
            vdot(vcross(s.N, sample), vcross(s.N, s.F)) > 0) {
          s.samples = [];
          setGot(0);
          setMsg('That looks like forward — tilt the other way, chin up.');
          return;
        }
      }
      if (s.samples.length &&
          s.samples[0].some((v2, i) => Math.abs(v2 - sample[i]) > HU_STEADY_TOL)) {
        s.samples = [sample];               /* moved — start the pose over */
        setGot(1);
        setMsg('Hold as still as you can…');
        return;
      }
      s.samples.push(sample);
      setGot(s.samples.length);
      setMsg('');
      if (s.samples.length >= HU_SAMPLES) {
        const avg = [0, 1, 2].map(i =>
          s.samples.reduce((acc, x) => acc + x[i], 0) / s.samples.length);
        banked(stepRef.current, avg);
      }
    };

    const tick = async () => {
      let st = null;
      try {
        const r = await api(`/api/ears?key=${encodeURIComponent(pin)}`);
        const e = (r.ears || []).find(x => x.id === earId);
        st = e && e.online ? e.status : null;
      } catch { /* keep the last reading; the stall message covers it */ }
      if (!alive) return;
      setLive(st);
      const s = m.current;
      const pose = stepRef.current;
      if (st && typeof st.gq === 'number' && st.gq !== s.gq) {
        const first = s.gq == null;
        s.gq = st.gq;
        s.gqAt = Date.now();
        /* the first sample after opening may predate the stream — skip it */
        if (!first && (pose === 'steady' || pose === 'fwd' || pose === 'back'))
          feed([st.gx, st.gy, st.gz]);
        if (pose === 'send' && st.gq >= s.sentGq + 2) {
          if (st.hucal === 1) {
            onSaved(s.cal);
            setStep('test');
          } else {
            setMsg('The ear did not take the calibration — try again.');
            setStep('steady');
          }
        }
      } else if (Date.now() - s.gqAt > 10000 &&
                 (pose === 'steady' || pose === 'fwd' || pose === 'back')) {
        setMsg(st ? 'Waiting for the sensor stream — give it a few seconds…'
                  : 'The ear went quiet — check it is powered and online.');
      }
    };

    tick();
    const t = setInterval(tick, 1000);
    return () => { alive = false; clearInterval(t); };
  }, [pin, earId, send, onSaved]);

  const start = () => {
    m.current.samples = [];
    m.current.N = m.current.F = m.current.B = null;
    m.current.gqAt = Date.now();
    setGot(0);
    setMsg('');
    setStep('steady');
  };

  const POSES = { steady: 1, fwd: 2, back: 3 };
  const title =
    step === 'intro' ? 'Calibrate the tilt sensor'
    : step === 'steady' ? 'Pose 1 of 3 — hold steady'
    : step === 'fwd' ? 'Pose 2 of 3 — tilt forward'
    : step === 'back' ? 'Pose 3 of 3 — tilt backward'
    : step === 'send' ? 'Saving to the ear…'
    : 'Calibrated!';
  const body =
    step === 'intro'
      ? `Put the headband on (or hold it exactly the way it is worn), and keep
         this phone where you can read it. Three poses, a few seconds each:
         hold steady, tilt forward, tilt backward. The result is saved on the
         ${earLabel} and remembered for every game after this one.`
    : step === 'steady' ? 'Head level, like wearing it normally. Hold still.'
    : step === 'fwd' ? 'Nod down like a big YES — and hold it there.'
    : step === 'back' ? 'Chin up, look at the ceiling — and hold it there.'
    : step === 'send' ? 'Handing the numbers to the ear — a couple of seconds.'
    : 'Try it on: nod down for GOT IT, tilt back for PASS.';

  const deg = live && typeof live.deg === 'number' ? live.deg : 0;
  const thr = m.current.cal ? m.current.cal.thr : 25;

  return (
    <div style={{
      position: 'fixed', inset: 0, zIndex: 60, background: 'rgba(20,16,12,0.55)',
      display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 18,
    }}>
      <div style={{
        background: COLORS.paper, borderRadius: 18, padding: 20, width: '100%',
        maxWidth: 420, border: `1.5px solid ${COLORS.paperEdge}`,
      }}>
        <Display size={20}>{title}</Display>
        <Body style={{ color: COLORS.inkSoft, marginTop: 8 }}>{body}</Body>

        {POSES[step] ? (
          <div style={{ marginTop: 14 }}>
            <div style={{ display: 'flex', gap: 6 }}>
              {[1, 2, 3].map(p => (
                <div key={p} style={{
                  flex: 1, height: 8, borderRadius: 4,
                  background: p < POSES[step] ? COLORS.moss
                            : p === POSES[step] ? COLORS.teal : COLORS.paperEdge,
                }} />
              ))}
            </div>
            <Body style={{ marginTop: 10 }}>
              {'●'.repeat(got)}{'○'.repeat(Math.max(0, HU_SAMPLES - got))}
              &nbsp; {got} / {HU_SAMPLES} readings
            </Body>
            <Body style={{ color: COLORS.inkSoft, marginTop: 4, fontSize: 13 }}>
              The sensor reports about every 1.5 seconds — each pose takes
              around five.
            </Body>
          </div>
        ) : null}

        {step === 'test' ? (
          <div style={{ marginTop: 14 }}>
            <div style={{ position: 'relative', height: 26, borderRadius: 13,
                          background: COLORS.paperEdge, overflow: 'hidden' }}>
              <div style={{
                position: 'absolute', top: 0, bottom: 0, left: '50%',
                width: `${Math.min(50, Math.abs(deg) / 60 * 50)}%`,
                transform: deg < 0 ? 'translateX(-100%)' : 'none',
                background: deg >= 0 ? COLORS.moss : COLORS.mustard,
              }} />
              <div style={{ position: 'absolute', top: 0, bottom: 0,
                            left: `${50 + thr / 60 * 50}%`, width: 2, background: COLORS.ink }} />
              <div style={{ position: 'absolute', top: 0, bottom: 0,
                            left: `${50 - thr / 60 * 50}%`, width: 2, background: COLORS.ink }} />
            </div>
            <Display size={18} style={{ marginTop: 10, textAlign: 'center' }}>
              {deg > thr ? 'GOT IT!' : deg < -thr ? 'PASS' : `${deg}°`}
            </Display>
          </div>
        ) : null}

        {msg ? <Body style={{ color: COLORS.clay, marginTop: 10 }}>{msg}</Body> : null}

        <div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
          {step === 'intro' ? (
            <button onClick={start} style={{ ...btn(COLORS.teal, true), flex: 1 }}>Start</button>
          ) : null}
          {step === 'test' ? (
            <>
              <button onClick={start} style={btn(COLORS.mustard, false)}>Redo</button>
              <button onClick={onClose} style={{ ...btn(COLORS.teal, true), flex: 1 }}>Done</button>
            </>
          ) : null}
          {step !== 'test' ? (
            <button onClick={onClose} style={btn(COLORS.inkSoft, false)}>Cancel</button>
          ) : null}
        </div>
      </div>
    </div>
  );
}

/* ---------- the screen ---------- */

window.EarsScreen = function EarsScreen() {
  const [pin, setPin] = React.useState(() => {
    /* one PIN across the whole app: the party pages store theirs under
     * mm.pin (JSON-encoded) — honor it so typing it anywhere covers both */
    try {
      const v = localStorage.getItem('mm.pin');
      if (v) { const s = JSON.parse(v); if (s) return s; }
    } catch { /* fall through */ }
    return localStorage.getItem(PIN_KEY) || '';
  });
  const [ears, setEars] = React.useState([]);
  const [conv, setConv] = React.useState({ on: false, seconds: 60, beatSeconds: 9 });
  const [err, setErr] = React.useState('');
  const [stepMode, setStepMode] = React.useState(false);
  const [stepDir, setStepDir] = React.useState(1);
  const [busy, setBusy] = React.useState('');

  /* Kept apart from `err` on purpose. `err` is the state of the connection and
   * the 3s refresh below clears it on every good poll — so anything a button
   * put there vanished within three seconds, usually before it was read. This
   * is what a press has to say for itself, and only a later press replaces it. */
  const [notice, setNotice] = React.useState('');
  const noticeTimer = React.useRef(0);
  const say = React.useCallback((msg) => {
    setNotice(msg);
    clearTimeout(noticeTimer.current);
    if (msg) noticeTimer.current = setTimeout(() => setNotice(''), 7000);
  }, []);
  React.useEffect(() => () => clearTimeout(noticeTimer.current), []);

  const savePin = (p) => {
    localStorage.setItem(PIN_KEY, p);
    try { localStorage.setItem('mm.pin', JSON.stringify(p)); } catch { /* private mode */ }
    setPin(p);
  };

  const online = ears.filter(e => e.online);
  const status = online.find(e => e.status)?.status || null;
  /* The ear tags outfits hidden from its own menus with a leading '!' —
   * the card on the SD holds them and loads them by name, they are just
   * kept out of the on-glass grid. Here they get their own drawer. */
  const allOutfits = status?.outfits || [];
  const outfits = allOutfits.filter(o => o && o[0] !== '!');
  const hiddenOutfits = allOutfits.filter(o => o && o[0] === '!').map(o => o.slice(1));
  const current = String(status?.outfit || '').split('/').pop();
  const [showHiddenOutfits, setShowHiddenOutfits] = React.useState(false);

  const refresh = React.useCallback(async () => {
    if (!pin) return;
    try {
      const [e, c] = await Promise.all([
        api(`/api/ears?key=${encodeURIComponent(pin)}`),
        api(`/api/ears/convention?key=${encodeURIComponent(pin)}`),
      ]);
      setEars(e.ears || []);
      setHuCats(e.headsupCats || []);
      setHuSaved(e.hucal || {});
      setFwInfo(e.fw || null);
      setConv(c);
      setErr('');
    } catch (ex) { setErr(ex.message); }
  }, [pin]);

  React.useEffect(() => {
    if (!pin) return undefined;
    refresh();
    const t = setInterval(() => { if (!document.hidden) refresh(); }, 3000);
    return () => clearInterval(t);
  }, [pin, refresh]);

  /* Every control here was fire-and-forget in the literal sense: the reply was
   * dropped, so a press looked identical whether an ear took it or nothing was
   * listening at all. That is the single most confusing thing about this page.
   *
   * Two distinct nothing-happened cases, and they need different words:
   *   queued 0    the relay knows no ears whatsoever — never seen one.
   *   all offline it knows them and has parked the command; the relay holds it
   *               for STALE_CMD_MS and hands it over on the next poll, so this
   *               is a wait, not a failure. */
  const cmd = React.useCallback(async (c) => {
    if (!pin) return false;
    try {
      const { queued } = await api('/api/ears/cmd', { method: 'POST', body: JSON.stringify({ key: pin, id: 'all', cmd: c }) });
      if (!queued) {
        say('No ear has ever checked in here. Power them on and give them ~10s to call in.');
        return false;
      }
      if (!online.length) {
        say('Held for the ears — none are online right now, so they will act on this when they next check in.');
        return false;
      }
      say('');
      if (navigator.vibrate) navigator.vibrate(8);
      return true;
    } catch (ex) { say(ex.message); return false; }
  }, [pin, online.length, say]);

  /* ---- On-the-glass extras: website QR, manual walk-across ---- */
  const [qrOn, setQrOn] = React.useState(false);

  const toggleQr = () => {
    const next = !qrOn;
    setQrOn(next);
    cmd({ qr: next ? 1 : 0 });
  };

  /* A crossing needs a shared world: a panorama at least two screens
   * wide, or a video backdrop the pair plays in lockstep. */
  const canCross = ears.some(e =>
    e.online && e.status &&
    ((typeof e.status.bgw === 'number' && e.status.bgw >= 1600) ||
     (typeof e.status.bgv === 'number' && e.status.bgv > 0)));

  /* ---- Mounting calibration: per-BOARD display rotation ----
   * Addressed by device id, never by side: side labels have been known
   * to tangle (two ears both claiming "left"), and the angle belongs to
   * the physical board however the sides are assigned. */
  const [rot, setRotState] = React.useState({});
  const rotTimer = React.useRef({});
  const rotSeeded = React.useRef({});

  /* Seed each board's slider (and the QR toggle) from what it reports */
  React.useEffect(() => {
    for (const e of ears) {
      if (e.status && e.status.qr === 1) setQrOn(true);
      if (rotSeeded.current[e.id]) continue;
      const r = e.status && typeof e.status.rot === 'number' ? e.status.rot : null;
      if (r == null) continue;
      rotSeeded.current[e.id] = true;
      setRotState(prev => ({ ...prev, [e.id]: r > 180 ? r - 360 : r }));
    }
  }, [ears]);

  const earCmd = React.useCallback(async (id, c) => {
    if (!pin) return;
    try {
      await api('/api/ears/cmd', { method: 'POST', body: JSON.stringify({ key: pin, id, cmd: c }) });
    } catch (ex) { say(ex.message); }
  }, [pin, say]);

  /* Live-follow with a small debounce; the ear applies it on its next
   * relay poll (~1.5s), so calibration is turn, wait a beat, look. */
  const spinEar = (id, deg) => {
    setRotState(r => ({ ...r, [id]: deg }));
    clearTimeout(rotTimer.current[id]);
    rotTimer.current[id] = setTimeout(
      () => earCmd(id, { rot: ((deg % 360) + 360) % 360 }), 250);
  };


  /* Step mode drives the same queue, but only on a CHANGE of state. Sending a
   * walk command per footfall would be a request every ~500ms per ear, which
   * both floods the queue and gains nothing — the ear is already walking. */
  const walking = React.useRef(false);
  const steps = useStepDetector({
    enabled: stepMode,
    onStep: () => {
      if (!walking.current) { walking.current = true; cmd({ s: 'w', dir: stepDir }); }
    },
    onJump: () => { walking.current = false; cmd({ s: 'j' }); },
    onStill: () => { if (walking.current) { walking.current = false; cmd({ s: 'i' }); } },
  });

  /* What happens when this page stops being on screen, and what can be done
   * about it.
   *
   * iOS suspends a page it is not showing. DeviceMotion stops firing, and no
   * web API brings it back — a browser cannot read the accelerometer of a
   * phone that is in a pocket, whatever it is told to do. That is not a bug
   * here to fix; it is why convention mode was built on the server instead.
   *
   * Two halves of it are worth handling anyway:
   *
   *   The screen timeout is the common way this dies — you are walking, the
   *   phone dims, the ears stop. A wake lock holds the screen on for as long
   *   as step mode is running, which covers it.
   *
   *   Leaving Safari cannot be covered, but the ears should not be left
   *   walking into the void for however long you are gone. Hiding the page
   *   stops them, so coming back is a clean start rather than a character
   *   that has been marching since lunchtime. */
  const wakeLock = React.useRef(null);
  React.useEffect(() => {
    if (!stepMode) return undefined;
    const grab = async () => {
      try {
        if (!('wakeLock' in navigator) || document.hidden) return;
        wakeLock.current = await navigator.wakeLock.request('screen');
      } catch { /* refused or unsupported: step mode still works, just not past the timeout */ }
    };
    grab();
    const onVis = () => {
      if (document.hidden) {
        if (walking.current) { walking.current = false; cmd({ s: 'i' }); }
      } else {
        grab();          /* the lock is dropped whenever the page is hidden */
      }
    };
    document.addEventListener('visibilitychange', onVis);
    return () => {
      document.removeEventListener('visibilitychange', onVis);
      const wl = wakeLock.current;
      wakeLock.current = null;
      wl?.release?.().catch(() => {});
    };
  }, [stepMode, cmd]);

  const toggleStepMode = async () => {
    if (!stepMode) {
      /* Checked before the motion prompt, not after: asking for a sensor we
       * have nowhere to send is the wrong first impression, and every footfall
       * would otherwise raise the same "nothing listening" error. */
      if (!online.length) { say('No ear has checked in yet — nothing would receive your steps.'); return; }
      if (steps.needsPermission) {
        const ok = await steps.requestPermission();
        if (!ok) { setErr('Motion access was declined — step mode needs it.'); return; }
      }
      if (conv.on) await setConvention({ on: false });   // they would fight
      setStepMode(true);
    } else {
      setStepMode(false);
      walking.current = false;
      cmd({ s: 'i' });
    }
  };

  const setConvention = async (patch) => {
    setBusy('conv');
    try {
      const next = await api('/api/ears/convention', {
        method: 'POST',
        body: JSON.stringify({ key: pin, ...patch }),
      });
      setConv(next);
      if (patch.on) { setStepMode(false); walking.current = false; }
    } catch (ex) { setErr(ex.message); }
    setBusy('');
  };

  /* ---- Heads Up: the game on the ears, driven from here ----
   *
   * One ear of the headband carries the tilt sensor. That ear runs the
   * round (word, clock, nod-scoring), the other mirrors it, and this card
   * only starts rounds and reads scores off the status polls — same
   * arrangement the party server uses in server/party/games/headsup.js. */
  const [huCats, setHuCats] = React.useState([]);
  const [huSaved, setHuSaved] = React.useState({});   /* server copy, by ear id */
  /* pinned {id, label} while the wizard runs — pinned to the ear rather than
   * derived per render, so one dropped poll cannot unmount it mid-pose */
  const [huWiz, setHuWiz] = React.useState(null);

  const gyroEar = online.find(e => e.status && e.status.imu && e.status.imu !== 'none');
  const huMirror = gyroEar ? online.find(e => e.id !== gyroEar.id) : null;
  const huSt = gyroEar ? gyroEar.status : null;
  const huCalibrated = !!huSt && huSt.hucal === 1;
  const huPlaying = !!huSt && huSt.gmode === 'headsup';

  /* the phone keeps its own copy too, so a server restart loses nothing */
  const calKey = (id) => `mm_hucal_${pin}_${id}`;
  const savedCalFor = (id) => {
    if (huSaved[id] && Array.isArray(huSaved[id].n)) return huSaved[id];
    try {
      const v = JSON.parse(localStorage.getItem(calKey(id)) || 'null');
      return v && Array.isArray(v.n) ? v : null;
    } catch { return null; }
  };

  const huWizId = huWiz && huWiz.id;
  const huCalSaved = React.useCallback((cal) => {
    if (!huWizId) return;
    try { localStorage.setItem(`mm_hucal_${pin}_${huWizId}`, JSON.stringify(cal)); } catch { /* private mode */ }
    api('/api/ears/hucal', {
      method: 'POST',
      body: JSON.stringify({ key: pin, id: huWizId, cal }),
    }).catch(() => { /* the ear's own flash copy is the one that plays */ });
    refresh();
  }, [pin, huWizId, refresh]);

  const huRestore = async () => {
    const cal = gyroEar && savedCalFor(gyroEar.id);
    if (!cal) return;
    await earCmd(gyroEar.id, {
      hucalset: 1,
      hunx: Math.round(cal.n[0] * 1000), huny: Math.round(cal.n[1] * 1000),
      hunz: Math.round(cal.n[2] * 1000),
      hutx: Math.round(cal.t[0] * 1000), huty: Math.round(cal.t[1] * 1000),
      hutz: Math.round(cal.t[2] * 1000),
      huthr: Math.round(cal.thr),
    });
    say('Saved calibration sent — the ear picks it up on its next check-in.');
  };

  const huStart = async (catIdx) => {
    if (!gyroEar) return;
    await earCmd(gyroEar.id, { game: 5, hucat: catIdx });
    if (huMirror) await earCmd(huMirror.id, { game: 5, humirror: 1 });
    say('Dealing to the ears — the round starts on the head in a few seconds.');
  };

  /* ---- Firmware over the relay ----
   *
   * The batch files flash over USB or the LAN; this path needs neither.
   * Upload the built .bin once, and each ear pulls it from the service
   * over its own internet connection when told to — so a headband on the
   * other side of the country updates as easily as one on the desk. */
  const [fwInfo, setFwInfo] = React.useState(null);
  const [fwBusy, setFwBusy] = React.useState(false);
  const fwFileRef = React.useRef(null);

  const fwUrl = `${API_BASE || window.location.origin}/api/ears/fw.bin?key=${encodeURIComponent(pin)}`;

  const uploadFw = async (file) => {
    if (!file) return;
    setFwBusy(true);
    try {
      const res = await fetch(`${API_BASE || ''}/api/ears/fw?key=${encodeURIComponent(pin)}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/octet-stream' },
        body: file,
      });
      const body = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
      say(`Build uploaded (${Math.round(body.size / 1024)} KB, ${body.sha8}) — flash the ears below.`);
      refresh();
    } catch (ex) { say(ex.message); }
    setFwBusy(false);
  };

  const flashEar = async (id) => {
    await earCmd(id, { fwup: fwUrl });
    say('Flashing — the ear\'s screen freezes for about a minute, then it reboots into the new build.');
  };

  /* ---- Photos & slideshow ----
   * The server already keeps a photo store per PIN and advances a slideshow
   * on its own clock (server/party/photos.js) — the ears just fetch whatever
   * the poll response names. This card is the missing phone side: crop to a
   * square here, upload, and drive the modes. */
  const [photoList, setPhotoList] = React.useState([]);
  const [photoStat, setPhotoStat] = React.useState({ mode: 'off', seconds: 8, count: 0 });
  const [phPersist, setPhPersist] = React.useState(true);
  const [phBusy, setPhBusy] = React.useState(false);
  const phFileRef = React.useRef(null);

  const refreshPhotos = React.useCallback(async () => {
    if (!pin) return;
    try {
      const r = await api(`/api/photos?key=${encodeURIComponent(pin)}`);
      setPhotoList(r.photos || []);
      setPhotoStat(r.status || { mode: 'off' });
      if (typeof r.persistent === 'boolean') setPhPersist(r.persistent);
    } catch { /* the 3s roster poll already reports a dead connection */ }
  }, [pin]);
  React.useEffect(() => { refreshPhotos(); }, [refreshPhotos]);

  /* while the show runs, follow which frame is up so the grid highlights it */
  React.useEffect(() => {
    if (photoStat.mode !== 'show') return undefined;
    const t = setInterval(() => { if (!document.hidden) refreshPhotos(); }, 5000);
    return () => clearInterval(t);
  }, [photoStat.mode, refreshPhotos]);

  /* Center-crop to the panel's square on a canvas, so the server never
   * decodes an image and the ear never sees a shape it has to letterbox. */
  const squareJpeg = (file) => new Promise((resolve, reject) => {
    const url = URL.createObjectURL(file);
    const img = new Image();
    img.onload = () => {
      try {
        const s = Math.min(img.naturalWidth, img.naturalHeight);
        const c = document.createElement('canvas');
        c.width = c.height = 720;
        c.getContext('2d').drawImage(
          img, (img.naturalWidth - s) / 2, (img.naturalHeight - s) / 2, s, s,
          0, 0, 720, 720);
        c.toBlob(b => (b ? resolve(b) : reject(new Error('could not encode'))),
                 'image/jpeg', 0.86);
      } catch (ex) { reject(ex); } finally { URL.revokeObjectURL(url); }
    };
    img.onerror = () => { URL.revokeObjectURL(url); reject(new Error(`could not read ${file.name}`)); };
    img.src = url;
  });

  const uploadPhotos = async (files) => {
    const list = [...(files || [])];
    if (!list.length) return;
    setPhBusy(true);
    let ok = 0, lastErr = '';
    for (const f of list) {
      try {
        const blob = await squareJpeg(f);
        const res = await fetch(`${API_BASE || ''}/api/photos?key=${encodeURIComponent(pin)}`, {
          method: 'POST', headers: { 'Content-Type': 'image/jpeg' }, body: blob,
        });
        const body = await res.json().catch(() => ({}));
        if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
        ok++;
      } catch (ex) { lastErr = ex.message; }
    }
    setPhBusy(false);
    say(lastErr ? `Uploaded ${ok} of ${list.length} — ${lastErr}`
                : `Uploaded ${ok} photo${ok === 1 ? '' : 's'}.`);
    refreshPhotos();
  };

  const photoState = async (patch) => {
    try {
      const r = await api('/api/photos/state', {
        method: 'POST', body: JSON.stringify({ key: pin, ...patch }),
      });
      setPhotoStat(r.status);
    } catch (ex) { say(ex.message); }
  };

  const deletePhoto = async (id) => {
    try {
      await api(`/api/photos/${id}?key=${encodeURIComponent(pin)}`, { method: 'DELETE' });
      refreshPhotos();
    } catch (ex) { say(ex.message); }
  };

  /* ---- which ear is which ----
   * The roster keys ears by device id, so even two ears that both still
   * claim the same side are individually addressable. Each shows a big
   * numeral; the user reads the number off the physical LEFT ear and the
   * sides are written and rebooted into. */
  const [assign, setAssign] = React.useState(null);   /* null | {ids:[a,b]} */

  const earCmdTo = React.useCallback(async (id, c) => {
    const { queued } = await api('/api/ears/cmd', {
      method: 'POST', body: JSON.stringify({ key: pin, id, cmd: c }),
    });
    return queued;
  }, [pin]);

  const startAssign = async () => {
    const targets = ears.filter(e => e.online);
    if (targets.length !== 2) {
      say(targets.length
        ? 'Only one ear is online — power the other on first.'
        : 'Neither ear is online right now.');
      return;
    }
    setBusy('assign');
    try {
      const ids = targets.map(e => e.id);
      await earCmdTo(ids[0], { ident: 1 });
      await earCmdTo(ids[1], { ident: 2 });
      setAssign({ ids });
      say('');
    } catch (ex) { say(ex.message); }
    setBusy('');
  };

  const pickLeft = async (n) => {
    if (!assign) return;
    const leftId = assign.ids[n - 1];
    const rightId = assign.ids[n === 1 ? 1 : 0];
    setBusy('assign');
    try {
      /* side + reboot ride one command: the ear stores the side in NVS and
       * restarts, and ear_select reads it back on the way up */
      await earCmdTo(leftId, { side: 'left', reboot: 1 });
      await earCmdTo(rightId, { side: 'right', reboot: 1 });
      setAssign(null);
      say('Sides saved — the ears are rebooting into them (about 25s).');
    } catch (ex) { say(ex.message); }
    setBusy('');
  };

  const cancelAssign = async () => {
    setAssign(null);
    try { await earCmdTo('all', { ident: 0 }); } catch { /* cosmetic */ }
  };

  if (!pin) {
    return (
      <div style={{ padding: 16 }}>
        <Display size={26} style={{ marginBottom: 4 }}>Ears</Display>
        <PinGate onSave={savePin} />
      </div>
    );
  }

  return (
    <div style={{ padding: 16, paddingBottom: 168, display: 'flex', flexDirection: 'column', gap: 14 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
        <Display size={26}>Ears</Display>
        <div style={{ flex: 1 }} />
        <PillStub
          color={online.length ? COLORS.moss : COLORS.clay}
          label={online.length ? `${online.length} online` : 'offline'}
        />
      </div>

      {/* A failing connection outranks whatever the last press had to say. */}
      {err || notice ? (
        <Card style={{ borderColor: err ? COLORS.clay : COLORS.mustard }}>
          <Body style={{ color: err ? COLORS.clay : COLORS.ink }}>{err || notice}</Body>
        </Card>
      ) : null}

      {!online.length ? (
        <Card>
          <Display size={18}>Nothing has checked in</Display>
          <Body style={{ color: COLORS.inkSoft, marginTop: 6 }}>
            The ears reach this app by calling out to it, so they need to be powered
            on and on a network with internet. They show their own address on screen
            for a few seconds after joining — that page works on the same WiFi even
            when this one cannot see them.
          </Body>
        </Card>
      ) : (
        <Card>
          <Caption>Right now</Caption>
          <Display size={20} style={{ marginTop: 2 }}>
            {current ? current.replace(/_/g, ' ') : 'no outfit'}
          </Display>
          <Body style={{ color: COLORS.inkSoft }}>
            {status?.state || 'idle'}{status?.spot != null ? ` · spot ${status.spot}` : ''}
            {status?.fps ? ` · ${Math.round(status.fps)}fps` : ''}
          </Body>
        </Card>
      )}

      {/* ---- Which ear is which ---- */}
      <Card>
        <Display size={18}>Which ear is which?</Display>
        {!assign ? (
          <>
            <Body style={{ color: COLORS.inkSoft, marginTop: 4 }}>
              {ears.length
                ? ears.map(e => `${e.id}: ${e.side || 'unassigned'}${e.online ? '' : ' · offline'}`).join('  —  ')
                : 'No ears on this PIN yet.'}
            </Body>
            <Body style={{ color: COLORS.inkSoft, marginTop: 6 }}>
              If left and right feel swapped — or both ears claim the same
              side — each ear can flash a big number so you can tell them
              apart and set the sides from here.
            </Body>
            <button
              onClick={startAssign}
              disabled={busy === 'assign'}
              style={{ ...btn(COLORS.teal, true, busy === 'assign'), marginTop: 10 }}
            >
              Show numbers on the ears
            </button>
          </>
        ) : (
          <>
            <Body style={{ marginTop: 6 }}>
              Look at the ears — which number is on the <b>left</b> one?
            </Body>
            <div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
              <button onClick={() => pickLeft(1)} disabled={busy === 'assign'}
                      style={btn(COLORS.teal, true, busy === 'assign')}>1 is LEFT</button>
              <button onClick={() => pickLeft(2)} disabled={busy === 'assign'}
                      style={btn(COLORS.teal, true, busy === 'assign')}>2 is LEFT</button>
              <button onClick={cancelAssign} style={btn(COLORS.inkSoft, false)}>Cancel</button>
            </div>
          </>
        )}
      </Card>

      {/* ---- On the glass ---- */}
      <Card>
        <Display size={18}>On the glass</Display>
        <Body style={{ color: COLORS.inkSoft, marginTop: 4 }}>
          Website QR takes over whichever ear is showing scenery (the one
          without the character) — scanning it opens lumenears.com. Press
          again to bring the scenery back. Walk across sends the
          character strolling over to the other ear.
        </Body>
        <div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
          <button onClick={toggleQr} style={btn(COLORS.teal, true)}>
            {qrOn ? 'Back to scenery' : 'Website QR'}
          </button>
          <button
            onClick={() => cmd({ xfer: 1 })}
            disabled={!canCross}
            style={btn(COLORS.mustard, true, !canCross)}
          >
            Walk across
          </button>
        </div>
        {!canCross && (
          <Body style={{ color: COLORS.inkSoft, marginTop: 8 }}>
            This outfit&apos;s world cannot cross between the ears yet.
            Pick any outfit with a scrolling or animated backdrop and the
            button wakes up.
          </Body>
        )}
      </Card>

      {/* ---- Heads Up ---- */}
      <Card style={huPlaying ? { borderColor: COLORS.grape, borderWidth: 2 } : null}>
        <Display size={18}>Heads Up</Display>
        <Body style={{ color: COLORS.inkSoft, marginTop: 4 }}>
          The word goes on the ears — the one screen the wearer cannot see.
          Friends shout clues; nod down for GOT IT, tilt back to PASS. The
          tilt sensor rides in one ear of the headband and scores the nods.
        </Body>

        {!gyroEar ? (
          <Body style={{ color: COLORS.inkSoft, marginTop: 10 }}>
            {online.length
              ? 'Neither online ear reports a tilt sensor — Heads Up needs the ear that carries it.'
              : 'No ears online right now — power them on first.'}
          </Body>
        ) : (
          <>
            <Body style={{ marginTop: 10 }}>
              Sensor in the {gyroEar.side ? `${gyroEar.side} ear` : `ear ${gyroEar.id}`}
              &nbsp;({huSt.imu}) · {huCalibrated ? 'calibrated' : 'not calibrated'}
              {huSt.headbest ? ` · best ${huSt.headbest}` : ''}
              {huMirror ? '' : ' · other ear offline, no mirror'}
            </Body>

            {huPlaying ? (
              <Body style={{ marginTop: 8, color: COLORS.grape, fontWeight: 700 }}>
                {huSt.hu === 1 ? 'Get ready…'
                  : huSt.hu === 2 || huSt.hu === 3 ? `Playing — score ${huSt.score}`
                  : huSt.hu === 4 ? `Round over — got ${huSt.score}`
                  : 'On the category menu'}
              </Body>
            ) : null}

            {huCalibrated ? (
              <>
                <Caption style={{ marginTop: 12 }}>Deal a round</Caption>
                <div style={{ display: 'flex', gap: 6, marginTop: 6, flexWrap: 'wrap' }}>
                  {huCats.map((c, i) => (
                    <button key={c.key} onClick={() => huStart(i)}
                            style={{ ...btn(c.color || COLORS.teal, false),
                                     padding: '8px 12px', fontSize: 13 }}>
                      {c.name}
                    </button>
                  ))}
                </div>
                {!huCats.length ? (
                  <Body style={{ color: COLORS.inkSoft, marginTop: 6 }}>
                    No categories from the server — the ear&apos;s own menu still
                    works: long-press its glass and pick Heads Up.
                  </Body>
                ) : null}
              </>
            ) : (
              <Body style={{ color: COLORS.clay, marginTop: 8 }}>
                Calibrate once before the first game — it takes about twenty
                seconds and is remembered from then on.
              </Body>
            )}

            <div style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
              <button
                onClick={() => setHuWiz({
                  id: gyroEar.id,
                  label: gyroEar.side ? `${gyroEar.side} ear` : `ear ${gyroEar.id}`,
                })}
                style={btn(huCalibrated ? COLORS.inkSoft : COLORS.teal, !huCalibrated)}
              >
                {huCalibrated ? 'Recalibrate' : 'Calibrate'}
              </button>
              {!huCalibrated && savedCalFor(gyroEar.id) ? (
                <button onClick={huRestore} style={btn(COLORS.moss, true)}>
                  Restore saved calibration
                </button>
              ) : null}
              {huPlaying ? (
                <button onClick={() => cmd({ game: 0 })} style={btn(COLORS.clay, false)}>
                  End game
                </button>
              ) : null}
            </div>
          </>
        )}
      </Card>

      {huWiz ? (
        <HeadsUpWizard
          pin={pin}
          earId={huWiz.id}
          earLabel={huWiz.label}
          onSaved={huCalSaved}
          onClose={() => { setHuWiz(null); refresh(); }}
        />
      ) : null}

      {/* ---- Photos & slideshow ---- */}
      <Card style={photoStat.mode !== 'off' ? { borderColor: COLORS.teal, borderWidth: 2 } : null}>
        <Display size={18}>Your photos</Display>
        <Body style={{ color: COLORS.inkSoft, marginTop: 4 }}>
          Put your own pictures on the ears — one at a time, or a slideshow
          that both ears follow in step. Photos are cropped square here on
          the phone; tap one below to show just it, or start the show.
        </Body>

        <input
          ref={phFileRef} type="file" accept="image/*" multiple
          style={{ display: 'none' }}
          onChange={ev => { uploadPhotos(ev.target.files); ev.target.value = ''; }}
        />
        <div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
          <button onClick={() => phFileRef.current && phFileRef.current.click()}
                  disabled={phBusy}
                  style={btn(COLORS.teal, true, phBusy)}>
            {phBusy ? 'Uploading…' : 'Add photos'}
          </button>
          {photoList.length ? (
            photoStat.mode === 'show' ? (
              <button onClick={() => photoState({ mode: 'off' })} style={btn(COLORS.mustard, true)}>
                Stop slideshow
              </button>
            ) : (
              <button onClick={() => photoState({ mode: 'show' })} style={btn(COLORS.moss, true)}>
                Start slideshow
              </button>
            )
          ) : null}
          {photoStat.mode === 'one' ? (
            <button onClick={() => photoState({ mode: 'off' })} style={btn(COLORS.mustard, false)}>
              Back to the show
            </button>
          ) : null}
        </div>

        {photoStat.mode === 'show' ? (
          <div style={{ display: 'flex', gap: 8, marginTop: 10, alignItems: 'center', flexWrap: 'wrap' }}>
            <Caption>Change every</Caption>
            {[5, 10, 20, 60].map(s => (
              <button key={s} onClick={() => photoState({ seconds: s })}
                      style={{ ...btn(COLORS.grape, photoStat.seconds === s), padding: '6px 10px', fontSize: 13 }}>
                {s}s
              </button>
            ))}
          </div>
        ) : null}

        {photoList.length ? (
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(76px, 1fr))', gap: 8, marginTop: 12 }}>
            {photoList.map(p => (
              <div key={p.id} style={{ position: 'relative' }}>
                <img
                  src={`${API_BASE || ''}/api/photo/${p.id}.jpg`}
                  alt=""
                  onClick={() => photoState({ mode: 'one', id: p.id })}
                  style={{
                    width: '100%', aspectRatio: '1', objectFit: 'cover',
                    borderRadius: 10, cursor: 'pointer',
                    border: photoStat.current === p.id
                      ? `3px solid ${COLORS.teal}` : `1.5px solid ${COLORS.paperEdge}`,
                  }}
                />
                <button
                  onClick={() => deletePhoto(p.id)}
                  aria-label="delete photo"
                  style={{
                    position: 'absolute', top: -6, right: -6, width: 22, height: 22,
                    borderRadius: 11, border: 'none', background: COLORS.clay,
                    color: COLORS.paper, fontSize: 12, lineHeight: '22px',
                    padding: 0, cursor: 'pointer',
                  }}
                >✕</button>
              </div>
            ))}
          </div>
        ) : (
          <Body style={{ color: COLORS.inkSoft, marginTop: 10 }}>
            {phPersist
              ? 'Nothing here yet. Photos are saved under your ear PIN, so they stay with this headband — add them once and they are here every time you come back.'
              : 'Nothing here yet. This service is running without its database, so photos last until it restarts.'}
          </Body>
        )}
      </Card>

      {/* ---- Mounting calibration ---- */}
      <Card>
        <Display size={18}>Mounting calibration</Display>
        <Body style={{ color: COLORS.inkSoft, marginTop: 4 }}>
          Mounted ears rarely sit straight. Turn each display until its
          picture stands upright (give it a second or two to follow the
          slider), then lock it in — the angle is saved on the ears
          themselves and survives reboots.
        </Body>
        {!ears.length && (
          <Body style={{ color: COLORS.inkSoft, marginTop: 8 }}>
            No ears on this PIN yet — power them on first.
          </Body>
        )}
        {ears.map(e => (
          <div key={e.id} style={{ marginTop: 10 }}>
            <Body>
              {e.side ? `${e.side} ear` : `ear ${e.id}`}
              {e.online ? '' : ' (offline)'}
              &nbsp;&middot;&nbsp;{rot[e.id] || 0}&deg;
            </Body>
            <input
              type="range" min={-180} max={180} step={1}
              value={rot[e.id] || 0}
              onChange={ev => spinEar(e.id, Number(ev.target.value))}
              style={{ width: '100%' }}
            />
          </div>
        ))}
        <button
          onClick={() => cmd({ rotsave: 1 })}
          style={{ ...btn(COLORS.teal, true), marginTop: 10 }}
        >
          Lock it in
        </button>
      </Card>

      {/* ---- Convention mode ---- */}
      <Card style={conv.on ? { borderColor: COLORS.mustard, borderWidth: 2 } : null}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
          <div style={{ flex: 1 }}>
            <Display size={18}>Convention mode</Display>
            <Body style={{ color: COLORS.inkSoft, marginTop: 2 }}>
              {!conv.on
                /* Say which outfits it will use. Someone who has just watched
                 * the picker offer twenty of them deserves to know this mode
                 * deliberately uses eight, rather than discovering it as a
                 * rotation that seems to be missing things. */
                ? 'Cycles the original characters on their own — the park outfits stay out of it while nobody is watching.'
                /* Armed with nothing listening is a real state, not a failure: the
                 * plan is arithmetic on the clock, so an ear that checks in later
                 * joins the rotation already in step. Say that, or it reads as a
                 * button that did nothing. */
                : !online.length
                  ? 'Running on the server — the ears join in as soon as they check in.'
                  : `${conv.outfit ? conv.outfit.replace(/_/g, ' ') : '—'} · ${conv.move || 'idle'}`}
            </Body>
          </div>
          <button
            onClick={() => setConvention({ on: !conv.on, outfits })}
            disabled={busy === 'conv'}
            style={btn(conv.on ? COLORS.mustard : COLORS.teal, conv.on, busy === 'conv')}
          >
            {conv.on ? 'Stop' : 'Start'}
          </button>
        </div>

        {conv.on ? (
          <div style={{ marginTop: 12 }}>
            <Caption>
              {conv.outfits?.length
                ? `${conv.outfits.length} outfits in rotation · change every ${conv.seconds}s · move every ${conv.beatSeconds}s`
                : `No outfits in rotation · move every ${conv.beatSeconds}s`}
            </Caption>
            <div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
              {[30, 60, 120, 300].map(s => (
                <button key={s} onClick={() => setConvention({ seconds: s })}
                        style={btn(COLORS.grape, conv.seconds === s)}>{s}s</button>
              ))}
            </div>
            <Body style={{ color: COLORS.inkSoft, marginTop: 10, fontSize: 13 }}>
              This runs on the server, so you can lock your phone and put it away.
              Both ears read the same clock and stay in step.
            </Body>
          </div>
        ) : null}
      </Card>

      {/* ---- Step mode ---- */}
      <Card style={stepMode ? { borderColor: COLORS.moss, borderWidth: 2 } : null}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
          <div style={{ flex: 1 }}>
            <Display size={18}>Walk with me</Display>
            <Body style={{ color: COLORS.inkSoft, marginTop: 2 }}>
              {!steps.supported
                ? 'This phone reports no motion sensor.'
                : stepMode
                  ? `${steps.steps} steps — keep this page open, the phone cannot feel you walk from another app`
                  : 'Your steps drive the character. Hop to make them hop. Needs this page on screen — use Convention mode for a phone in a pocket.'}
            </Body>
          </div>
          <button onClick={toggleStepMode} disabled={!steps.supported}
                  style={btn(stepMode ? COLORS.moss : COLORS.teal, stepMode, !steps.supported)}>
            {stepMode ? 'Stop' : steps.needsPermission ? 'Allow' : 'Start'}
          </button>
        </div>

        {stepMode ? (
          <div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
            <button onClick={() => setStepDir(0)} style={{ ...btn(COLORS.teal, stepDir === 0), flex: 1 }}>◀ Face left</button>
            <button onClick={() => setStepDir(1)} style={{ ...btn(COLORS.teal, stepDir === 1), flex: 1 }}>Face right ▶</button>
          </div>
        ) : null}
      </Card>

      {/* ---- Manual ---- */}
      <Card>
        <Display size={18} style={{ marginBottom: 10 }}>Move</Display>
        <div style={{ display: 'flex', gap: 8 }}>
          <button onClick={() => cmd({ s: 'w', dir: 0 })} style={{ ...btn(COLORS.teal, status?.state === 'walk_left'), flex: 1 }}>◀ Walk</button>
          <button onClick={() => cmd({ s: 'i' })} style={{ ...btn(COLORS.ink, status?.state === 'idle'), flex: 1 }}>■ Stop</button>
          <button onClick={() => cmd({ s: 'w', dir: 1 })} style={{ ...btn(COLORS.teal, status?.state === 'walk_right'), flex: 1 }}>Walk ▶</button>
        </div>
        <button onClick={() => cmd({ s: 'j' })} style={{ ...btn(COLORS.mustard, false), width: '100%', marginTop: 8 }}>⬆ Jump</button>

        <Caption style={{ marginTop: 14 }}>Scenic spot</Caption>
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 6, marginTop: 6 }}>
          {[0, 1, 2, 3, 4, 5, 6, 7].map(n => (
            <button key={n} onClick={() => cmd({ spot: n })}
                    style={{ ...btn(COLORS.grape, status?.spot === n), padding: '8px 0', minWidth: 0 }}>{n}</button>
          ))}
        </div>
      </Card>

      {/* ---- Outfits ---- */}
      <Card>
        <Display size={18} style={{ marginBottom: 10 }}>Outfit</Display>
        {outfits.length ? (
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 8 }}>
            {outfits.map(o => (
              <button key={o} onClick={() => cmd({ outfit: o })}
                      style={{ ...btn(COLORS.teal, o === current), textAlign: 'left' }}>
                {o.replace(/_/g, ' ')}
              </button>
            ))}
          </div>
        ) : (
          <Body style={{ color: COLORS.inkSoft }}>No outfits reported yet.</Body>
        )}

        {hiddenOutfits.length ? (
          <>
            <button
              onClick={() => setShowHiddenOutfits(s => !s)}
              style={{ ...btn(COLORS.grape, false), marginTop: 12 }}
            >
              {showHiddenOutfits
                ? 'Hide the extras'
                : `More outfits (${hiddenOutfits.length})`}
            </button>
            {showHiddenOutfits ? (
              <>
                <Body style={{ color: COLORS.inkSoft, marginTop: 8, fontSize: 13 }}>
                  These stay off the ears&apos; own menus — only this page
                  can put them on.
                </Body>
                <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 8, marginTop: 8 }}>
                  {hiddenOutfits.map(o => (
                    <button key={o} onClick={() => cmd({ outfit: o })}
                            style={{ ...btn(COLORS.grape, o === current), textAlign: 'left' }}>
                      {o.replace(/_/g, ' ')}
                    </button>
                  ))}
                </div>
              </>
            ) : null}
          </>
        ) : null}
      </Card>

      {/* ---- Tuning ---- */}
      <Card>
        <Display size={18} style={{ marginBottom: 10 }}>Tuning</Display>
        <Caption>Brightness</Caption>
        <input type="range" min="5" max="100" defaultValue={status?.bl ?? 80}
               onChange={e => cmd({ bl: +e.target.value })} style={{ width: '100%' }} />
        <Caption style={{ marginTop: 10 }}>Speed</Caption>
        <input type="range" min="8" max="30" defaultValue={Math.round(status?.fps ?? 24)}
               onChange={e => cmd({ fps: +e.target.value })} style={{ width: '100%' }} />
      </Card>

      {/* ---- Firmware ---- */}
      <Card>
        <Display size={18}>Firmware</Display>
        <Body style={{ color: COLORS.inkSoft, marginTop: 4 }}>
          Flash the ears from here — no cable, no shared WiFi. Build on the
          PC (build_mickey800.bat), upload the .bin, then flash each ear:
          it downloads the build over its own internet connection, freezes
          for about a minute, and reboots into it. Keep the ears powered
          until they come back.
        </Body>

        <Body style={{ marginTop: 10 }}>
          {fwInfo
            ? `${fwInfo.source === 'push'
                  ? `Build from git push${fwInfo.commit ? ` (${fwInfo.commit})` : ''}`
                  : 'Uploaded build'}: ${Math.round(fwInfo.size / 1024)} KB · ${fwInfo.sha8} · ${new Date(fwInfo.at).toLocaleTimeString()}`
            : 'No build on the server yet — push a commit touching the firmware, or upload a .bin here.'}
        </Body>

        <input
          ref={fwFileRef} type="file" accept=".bin"
          style={{ display: 'none' }}
          onChange={ev => { uploadFw(ev.target.files && ev.target.files[0]); ev.target.value = ''; }}
        />
        <button onClick={() => fwFileRef.current && fwFileRef.current.click()}
                disabled={fwBusy}
                style={{ ...btn(COLORS.teal, !fwInfo, fwBusy), marginTop: 10 }}>
          {fwBusy ? 'Uploading…' : fwInfo ? 'Upload a different build' : 'Upload build (.bin)'}
        </button>

        {ears.map(e => {
          const st = e.status || {};
          const fu = typeof st.fwup === 'number' ? st.fwup : 0;
          const busyFlashing = fu > 0 && fu <= 100;
          return (
            <div key={e.id} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 12 }}>
              <div style={{ flex: 1 }}>
                <Body>
                  {e.side ? `${e.side} ear` : `ear ${e.id}`}{e.online ? '' : ' (offline)'}
                </Body>
                <Body style={{ color: COLORS.inkSoft, fontSize: 13 }}>
                  {st.fw ? `build ${st.fw}` : 'build unknown'}
                  {st.slot ? ` · ${st.slot}` : ''}
                  {fu > 0 && fu < 100 ? ` · downloading ${fu}%`
                    : fu === 100 ? ' · flashing, rebooting…'
                    : fu === -1 ? ' · last flash failed — try again' : ''}
                </Body>
              </div>
              <button
                onClick={() => flashEar(e.id)}
                disabled={!fwInfo || !e.online || busyFlashing}
                style={btn(COLORS.mustard, true, !fwInfo || !e.online || busyFlashing)}
              >
                {busyFlashing ? `${fu}%` : 'Flash'}
              </button>
            </div>
          );
        })}
      </Card>

      <button onClick={() => savePin('')} style={{ ...btn(COLORS.inkSoft, false), alignSelf: 'flex-start' }}>
        Forget PIN
      </button>
    </div>
  );
};
})();
