/* global React, ReactDOM */
// Deepsea Watch v2 — multi-user, server-backed.
//
// Differences from the original design prototype:
//   * No localStorage for event data. Source of truth is the server.
//   * SSE keeps the page live; falls back to interval refresh if SSE drops.
//   * Logging a timer / down / kill requires Steam OpenID sign-in.
//   * The prediction engine here is fed by a server-computed consensus
//     anchor (cluster of agreeing reports), not raw events.
//   * Each row may carry a `disputed` flag when ≥2 reporters contradict
//     the majority — surfaced as a small chip on the row.

const { useState, useEffect, useRef, useMemo, useCallback, useContext, createContext } = React;

// ===== toast system =========================================================
// Tiny context-based toast queue. `useToast()` returns a `show(message, kind?)`
// function callable from anywhere under <ToastProvider>. Toasts stack top-right
// and auto-dismiss after 4.5s. `kind` is 'error' (default) | 'info' | 'success'.
const ToastCtx = createContext(() => {});
const useToast = () => useContext(ToastCtx);

function ToastProvider({ children }) {
  const [toasts, setToasts] = useState([]);
  const show = useCallback((message, kind = 'error') => {
    const id = Math.random().toString(36).slice(2);
    setToasts(t => [...t, { id, message, kind }]);
    setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 4500);
  }, []);
  const dismiss = useCallback(id => setToasts(t => t.filter(x => x.id !== id)), []);
  return (
    <ToastCtx.Provider value={show}>
      {children}
      <div className="toasts" role="status" aria-live="polite">
        {toasts.map(t => (
          <div key={t.id} className={`toast toast-${t.kind}`} onClick={() => dismiss(t.id)}>
            {t.message}
          </div>
        ))}
      </div>
    </ToastCtx.Provider>
  );
}

// ===== constants — kept in sync with server/consensus.js =====================
const EVENT_MS = 3 * 60 * 60 * 1000;
const GAP_MIN_MS = 60 * 60 * 1000;
const GAP_MAX_MS = 120 * 60 * 1000;
const CARGO_MS = 5 * 60 * 1000;
const WORLDS = ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W8', 'VIP', 'M+'];
const REGIONS = ['NA', 'EU', 'AU'];

// ===== time helpers =========================================================
function parseDuration(input) {
  if (!input) return null;
  const s = String(input).trim().toLowerCase();
  if (!s) return null;
  if (/^\d{1,2}:\d{1,2}$/.test(s)) {
    const [h, m] = s.split(':').map(Number);
    return (h * 60 + m) * 60 * 1000;
  }
  let h = 0, m = 0, matched = false;
  const hMatch = s.match(/(\d+(?:\.\d+)?)\s*h/);
  const mMatch = s.match(/(\d+)\s*m/);
  if (hMatch) { h = parseFloat(hMatch[1]); matched = true; }
  if (mMatch) { m = parseInt(mMatch[1], 10); matched = true; }
  if (matched) return Math.round((h * 60 + m) * 60 * 1000);
  if (/^\d+(?:\.\d+)?$/.test(s)) return Math.round(parseFloat(s) * 60 * 1000);
  return null;
}

function fmt(ms, opts = {}) {
  if (ms == null || isNaN(ms)) return '—';
  const sign = ms < 0 ? '-' : '';
  ms = Math.abs(ms);
  const h = Math.floor(ms / 3600000);
  const m = Math.floor((ms % 3600000) / 60000);
  const s = Math.floor((ms % 60000) / 1000);
  if (opts.compact) {
    if (h > 0) return `${sign}${h}h${String(m).padStart(2, '0')}`;
    if (m > 0) return `${sign}${m}m`;
    return `${sign}${s}s`;
  }
  if (h > 0) return `${sign}${h}h ${String(m).padStart(2, '0')}m`;
  if (m > 0) return `${sign}${m}m ${String(s).padStart(2, '0')}s`;
  return `${sign}${s}s`;
}

function fmtClock(ms) {
  if (ms == null) return '—';
  const d = new Date(ms);
  let h = d.getHours();
  const m = d.getMinutes();
  const isPM = h >= 12;
  h = h % 12; if (h === 0) h = 12;
  return `${h}:${String(m).padStart(2, '0')}${isPM ? 'p' : 'a'}`;
}

function fmtMMSS(ms) {
  ms = Math.max(0, ms);
  const m = Math.floor(ms / 60000);
  const s = Math.floor((ms % 60000) / 1000);
  return `${m}:${String(s).padStart(2, '0')}`;
}

// ===== prediction (mirrors server/consensus.js#predict) =====================
function predict(anchor, downs, now) {
  if (!anchor && (!downs || downs.length === 0)) return { kind: 'unknown' };

  if (!anchor) {
    // Down at D ⇒ next spawn must be after D (the event isn't running NOW).
    // Earliest = D itself; latest = D + GAP_MAX assuming the prior event
    // just ended.
    const latestDown = downs[downs.length - 1];
    return {
      kind: 'down_only',
      latestDown: latestDown.at,
      nextStartMin: latestDown.at,
      nextStartMax: latestDown.at + GAP_MAX_MS,
    };
  }

  if (now >= anchor.at && now < anchor.eventEnd) {
    const startExact = anchor.eventEnd - EVENT_MS;
    return {
      kind: 'live', anchor, cycleN: -1,
      startMin: startExact, startMax: startExact,
      endMin: anchor.eventEnd, endMax: anchor.eventEnd,
    };
  }

  let cycleEndMin = anchor.eventEnd;
  let cycleEndMax = anchor.eventEnd;

  for (let N = 0; N < 30; N++) {
    let startMin = cycleEndMin + GAP_MIN_MS;
    let startMax = cycleEndMax + GAP_MAX_MS;
    let endMin = startMin + EVENT_MS;
    let endMax = startMax + EVENT_MS;

    const relevant = (downs || []).filter(
      d => d.at > anchor.eventEnd && d.at >= cycleEndMin && d.at <= endMax + 5 * 60 * 1000,
    );
    for (const d of relevant) {
      if (d.at >= startMin && d.at <= startMax) {
        startMin = Math.max(startMin, d.at);
        endMin = startMin + EVENT_MS;
      } else if (d.at >= endMin && d.at <= endMax) {
        endMax = Math.min(endMax, d.at);
        startMax = endMax - EVENT_MS;
      }
    }

    if (now < startMin) {
      return { kind: 'gap', anchor, cycleN: N, prevEndMin: cycleEndMin, prevEndMax: cycleEndMax, nextStartMin: startMin, nextStartMax: startMax };
    }
    if (now >= startMin && now < startMax) {
      return { kind: 'window', anchor, cycleN: N, nextStartMin: startMin, nextStartMax: startMax, endMin, endMax };
    }
    if (now >= startMax && now <= endMin) {
      return { kind: 'live', anchor, cycleN: N, startMin, startMax, endMin, endMax };
    }
    if (now > endMin && now <= endMax) {
      return { kind: 'live', anchor, cycleN: N, startMin, startMax, endMin, endMax, uncertainEnd: true };
    }
    cycleEndMin = endMin;
    cycleEndMax = endMax;
  }
  return { kind: 'unknown' };
}

function nextSpawnEstimate(p) {
  if (!p) return null;
  switch (p.kind) {
    case 'live':
      return { nextStartMin: p.endMin + GAP_MIN_MS, nextStartMax: p.endMax + GAP_MAX_MS, sourceLabel: 'after this event ends' };
    case 'gap':
    case 'window':
      return { nextStartMin: p.nextStartMin, nextStartMax: p.nextStartMax, sourceLabel: p.kind === 'window' ? 'spawn possible now' : 'next spawn' };
    case 'down_only':
      return { nextStartMin: p.nextStartMin, nextStartMax: p.nextStartMax, sourceLabel: 'approx (no live timer)' };
    default: return null;
  }
}

// ===== API client ===========================================================
async function api(path, opts = {}) {
  const res = await fetch(path, {
    credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    ...opts,
    body: opts.body ? JSON.stringify(opts.body) : undefined,
  });
  if (!res.ok) {
    const e = await res.json().catch(() => ({}));
    throw new Error(e.error || `http_${res.status}`);
  }
  return res.json();
}

// ===== presentational components (mostly verbatim from the design) =========
function StatusBadge({ p, disputed }) {
  let inner;
  if (!p) inner = <span className="badge badge-unk">no data</span>;
  else switch (p.kind) {
    case 'live':
      inner = p.cycleN === -1
        ? <span className="badge badge-live"><span className="dot dot-live" />LIVE</span>
        : <span className="badge badge-est"><span className="dot dot-est" />LIVE · EST</span>;
      break;
    case 'window': inner = <span className="badge badge-window"><span className="dot dot-window" />SOON</span>; break;
    case 'gap': inner = <span className="badge badge-gap"><span className="dot dot-gap" />DOWN</span>; break;
    case 'down_only': inner = <span className="badge badge-down"><span className="dot dot-gap" />DOWN</span>; break;
    default: inner = <span className="badge badge-unk">no data</span>;
  }
  return (
    <div className="badge-row">
      {inner}
      {disputed && <span className="badge badge-disputed" title="Reports conflict — picking the majority cluster">DISPUTED</span>}
    </div>
  );
}

function PrimaryCountdown({ p, now }) {
  if (!p) return <div className="primary"><span className="primary-num muted">—</span><span className="primary-sub">no timer yet</span></div>;
  if (p.kind === 'live') {
    const isFresh = p.cycleN === -1;
    const left = p.endMin - now;
    const leftMax = p.endMax - now;
    if (isFresh) {
      return <div className="primary"><span className="primary-num">{fmt(Math.max(0, left))}</span><span className="primary-sub">until event ends</span></div>;
    }
    const mid = Math.max(0, (left + leftMax) / 2);
    return <div className="primary primary-estimate"><span className="primary-num"><span className="approx">≈</span>{fmt(mid)}</span><span className="primary-sub">estimated end</span></div>;
  }
  const est = nextSpawnEstimate(p);
  if (!est) return <div className="primary"><span className="primary-num muted">—</span><span className="primary-sub">no estimate</span></div>;
  const toMin = est.nextStartMin - now;
  const toMax = est.nextStartMax - now;
  if (toMin <= 0 && toMax > 0) {
    return <div className="primary primary-spawn"><span className="primary-num">SPAWN POSSIBLE</span><span className="primary-sub">closes in {fmt(toMax, { compact: true })}</span></div>;
  }
  if (toMax <= 0) {
    return <div className="primary"><span className="primary-num muted">—</span><span className="primary-sub">prediction stale · log a fresh timer</span></div>;
  }
  return (
    <div className="primary">
      <span className="primary-num">{fmt(Math.max(0, toMin), { compact: true })}<span className="primary-num-tail">  →  {fmt(Math.max(0, toMax), { compact: true })}</span></span>
      <span className="primary-sub">until {est.sourceLabel}</span>
    </div>
  );
}

function Timeline({ reports, anchor, p, now, legendOn }) {
  const VIEW_BACK_MS = 3 * 60 * 60 * 1000;
  const VIEW_FWD_MS = 3 * 60 * 60 * 1000;
  const t0 = now - VIEW_BACK_MS;
  const t1 = now + VIEW_FWD_MS;
  const span = t1 - t0;
  const pctOf = t => Math.max(0, Math.min(100, ((t - t0) / span) * 100));
  const inView = t => t >= t0 && t <= t1;

  const segs = [];
  if (anchor) {
    const anchorStart = anchor.eventEnd - EVENT_MS;
    segs.push({ type: 'past-event', t0: anchorStart, t1: anchor.eventEnd });
    let endMin = anchor.eventEnd, endMax = anchor.eventEnd;
    for (let i = 0; i < 8 && endMin < t1; i++) {
      const sMin = endMin + GAP_MIN_MS;
      const sMax = endMax + GAP_MAX_MS;
      const eMin = sMin + EVENT_MS;
      const eMax = sMax + EVENT_MS;
      segs.push({ type: 'gap', t0: endMin, t1: sMin });
      segs.push({ type: 'spawn-window', t0: sMin, t1: sMax });
      segs.push({ type: 'event-certain', t0: sMax, t1: eMin });
      segs.push({ type: 'event-uncertain-end', t0: eMin, t1: eMax });
      endMin = eMin; endMax = eMax;
    }
  }

  const downs = (reports || []).filter(e => e.type === 'down' && !e.deletedAt);
  const lives = (reports || []).filter(e => e.type === 'live' && !e.deletedAt);
  const kills = (reports || []).filter(e => e.type === 'kill' && !e.deletedAt);

  const hourTicks = [];
  const startHour = Math.ceil(t0 / 3600000) * 3600000;
  for (let t = startHour; t <= t1; t += 3600000) hourTicks.push(t);

  const annotations = [];
  {
    const seen = new Set();
    const labels = {
      'past-event': { text: 'past event · 3h', side: 'below' },
      'gap': { text: 'down · 60m', side: 'below' },
      'spawn-window': { text: 'spawn · 1h', side: 'below' },
    };
    for (const s of segs) {
      if (seen.has(s.type)) continue;
      const lbl = labels[s.type];
      if (!lbl) continue;
      const a = Math.max(s.t0, t0), b = Math.min(s.t1, t1);
      if (b - a < 60000) continue;
      let text = lbl.text;
      if (s.type === 'past-event' && anchor && now >= (anchor.eventEnd - EVENT_MS) && now < anchor.eventEnd) {
        text = 'current event · 3h';
      }
      const mid = (a + b) / 2;
      annotations.push({ side: lbl.side, text, leftPct: pctOf(mid) });
      seen.add(s.type);
    }
  }

  return (
    <div className={`tl ${legendOn ? 'tl-with-legend' : ''}`}>
      <div className="tl-track">
        {segs.filter(s => s.t1 >= t0 && s.t0 <= t1).map((s, i) => (
          <div key={i} className={`tl-seg tl-${s.type}`} style={{ left: pctOf(s.t0) + '%', width: (pctOf(s.t1) - pctOf(s.t0)) + '%' }} />
        ))}
        {hourTicks.map(t => (
          <div key={t} className="tl-hour-tick" style={{ left: pctOf(t) + '%' }}>
            <div className="tl-hour-label">{new Date(t).toLocaleTimeString([], { hour: 'numeric' })}</div>
          </div>
        ))}
        {lives.filter(e => inView(e.at)).map((e, i) => (
          <div key={'L' + e.id} className="tl-dot tl-dot-live" style={{ left: pctOf(e.at) + '%' }}
               title={`${e.reporter.personaName} · ${fmt(e.remainingMs, { compact: true })} left @ ${fmtClock(e.at)}`} />
        ))}
        {downs.filter(e => inView(e.at)).map((e, i) => (
          <div key={'D' + e.id} className="tl-mark tl-mark-down" style={{ left: pctOf(e.at) + '%' }}
               title={`${e.reporter.personaName} reported down @ ${fmtClock(e.at)}`} />
        ))}
        {kills.filter(e => inView(e.at)).map((e, i) => (
          <div key={'K' + e.id} className="tl-dot tl-dot-kill" style={{ left: pctOf(e.at) + '%' }}
               title={`${e.reporter.personaName} · cargo kill @ ${fmtClock(e.at)}`} />
        ))}
        <div className="tl-now-bar" style={{ left: pctOf(now) + '%' }} />
      </div>
      <div className="tl-now-label mono" style={{ left: pctOf(now) + '%' }}>{fmtClock(now)}</div>
      <div className="tl-annotations tl-annotations-below" aria-hidden={!legendOn}>
        {annotations.filter(a => a.side === 'below').map((a, i) => (
          <div key={i} className="tl-ann tl-ann-below" style={{ left: a.leftPct + '%' }}>
            <span className="tl-ann-line" />
            <span className="tl-ann-text">{a.text}</span>
          </div>
        ))}
      </div>
      {p && (p.kind === 'gap' || p.kind === 'window' || p.kind === 'down_only') && (() => {
        const est = nextSpawnEstimate(p);
        if (!est) return null;
        if (!inView(est.nextStartMin) && !inView(est.nextStartMax)) return null;
        const left = pctOf(est.nextStartMin);
        const right = pctOf(est.nextStartMax);
        return (
          <div className="tl-callout" style={{ left: left + '%', width: (right - left) + '%' }}>
            <div className="tl-callout-line" />
            <div className="tl-callout-label">next spawn · {fmtClock(est.nextStartMin)}–{fmtClock(est.nextStartMax)}</div>
          </div>
        );
      })()}
    </div>
  );
}

function HistoryList({ reports, me, outlierIds, onDelete }) {
  if (!reports || reports.length === 0) {
    return <div className="hist-empty">No observations yet. Report a timer or mark Down.</div>;
  }
  const outliers = new Set(outlierIds || []);
  return (
    <div className="hist-list">
      {reports.map(e => {
        const isMine = me && e.reporter.steamId === me.steamId;
        const canDelete = isMine || (me && me.isAdmin);
        const isOutlier = outliers.has(e.id);
        const isDeleted = !!e.deletedAt;
        return (
          <div key={e.id} className={`hist-item hist-${e.type} ${isOutlier ? 'hist-outlier' : ''} ${isDeleted ? 'hist-deleted' : ''}`}>
            <span className="hist-time">{fmtClock(e.at)}</span>
            <span className="hist-type">{e.type === 'live' ? 'LIVE' : e.type === 'down' ? 'DOWN' : 'KILL'}</span>
            <span className="hist-detail">
              {e.type === 'live'
                ? <>reported <span className="mono">{fmt(e.remainingMs, { compact: true })}</span> remaining · ended <span className="mono">{fmtClock(e.eventEnd)}</span></>
                : e.type === 'kill' ? <>cargo kill — 5min cooldown</>
                : <>marked down</>}
              {isOutlier && <span className="hist-tag">outlier</span>}
              {isDeleted && <span className="hist-tag">deleted</span>}
            </span>
            <span className="hist-reporter">
              {e.reporter.avatarUrl && <img src={e.reporter.avatarUrl} alt="" className="hist-avatar" />}
              <a href={e.reporter.profileUrl || '#'} target="_blank" rel="noopener noreferrer" className="hist-name">{e.reporter.personaName}</a>
            </span>
            {canDelete && !isDeleted ? (
              <button className="hist-del" onClick={() => onDelete(e)} title="delete">×</button>
            ) : <span />}
          </div>
        );
      })}
    </div>
  );
}

function WorldRow({ world, worldState, reports, now, me, hasBoats, onToggleBoats, onAction, onDeleteReport, legendOn, busy }) {
  const toast = useToast();
  const [open, setOpen] = useState(false);          // history panel
  const [logOpen, setLogOpen] = useState(false);    // Log Remaining slide-out
  const [input, setInput] = useState('');
  const inputRef = useRef();
  const panelRef = useRef();

  // Auto-focus the input the moment the panel slides open.
  useEffect(() => {
    if (logOpen) {
      const id = setTimeout(() => inputRef.current?.focus(), 50);
      return () => clearTimeout(id);
    }
  }, [logOpen]);

  // Click anywhere outside the expanded panel → collapse. mousedown so we
  // close before any subsequent click on a different control fires.
  useEffect(() => {
    if (!logOpen) return;
    const onMouseDown = (e) => {
      if (panelRef.current && !panelRef.current.contains(e.target)) {
        setLogOpen(false);
      }
    };
    document.addEventListener('mousedown', onMouseDown);
    return () => document.removeEventListener('mousedown', onMouseDown);
  }, [logOpen]);

  const anchor = worldState.consensus;
  const downs = useMemo(() => reports.filter(r => r.type === 'down' && !r.deletedAt).map(r => ({ at: r.at })), [reports]);
  const p = useMemo(() => predict(anchor, downs, now), [anchor, downs, now]);
  const est = nextSpawnEstimate(p);

  const killRemaining = worldState.lastKillAt ? Math.max(0, (worldState.lastKillAt + CARGO_MS) - now) : 0;
  const killActive = killRemaining > 0;
  // Kills only make sense while the event is live — if the world isn't
  // reported active, you can't have killed the cargo on it. The button stays
  // visible so the cooldown badge can still tick down if there's a stale kill;
  // clicking when blocked surfaces a toast.
  const canKill = me && p && p.kind === 'live';

  const commit = () => {
    if (!me) return; // controls block is hidden when signed out; defensive.
    const ms = parseDuration(input);
    if (ms != null && ms > 0 && ms <= EVENT_MS + 60000) {
      onAction({ world, type: 'live', remainingMs: ms });
      setInput('');
      inputRef.current?.blur();
      setLogOpen(false);
    } else if (input.trim()) {
      inputRef.current?.classList.add('shake');
      setTimeout(() => inputRef.current?.classList.remove('shake'), 400);
    }
  };

  const phaseClass = p ? `row-phase-${p.kind}` : 'row-phase-unknown';
  const sortedReports = useMemo(() => [...reports].sort((a, b) => b.at - a.at), [reports]);

  return (
    <div className={`row ${phaseClass} ${logOpen ? 'is-logging' : ''}`}>
      <div className="row-grid">
        <div className="col-world">
          {killActive && (
            <div className="kill-countdown mono" title="Cargo cooldown — 5 min after a kill">
              {fmtMMSS(killRemaining)}
            </div>
          )}
          <div className="world-head">
            <div className="world-name">{world}</div>
            <button
              className={`micro-btn boats-toggle ${hasBoats ? 'active' : ''}`}
              onClick={() => onToggleBoats(world, !hasBoats)}
              title={hasBoats ? 'Boats marked here — click to remove' : 'Mark personal boats on this world'}
              aria-pressed={hasBoats}
              disabled={!me}
            >
              <svg className="boat-icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
                <path d="M12 3l7 12h-7V3z" />
                <rect x="11.4" y="12" width="1.2" height="4" />
                <path d="M3 17h18l-2 4H5l-2-4z" />
              </svg>
            </button>
            <button
              className={`micro-btn skull-toggle ${killActive ? 'active' : ''}`}
              onClick={() => {
                if (!me) return;
                if (!canKill) {
                  toast(`Deepsea not reported as active on ${world}. Report time remaining first.`);
                  return;
                }
                onAction({ world, type: 'kill' });
              }}
              title={
                !me ? 'Sign in to log a kill'
                : !canKill ? `Deepsea not active on ${world} — report a timer first`
                : killActive ? `Cargo cooldown ${fmtMMSS(killRemaining)} — click to log another kill`
                : 'Log a cargo kill — starts 5-min cooldown'
              }
              aria-pressed={killActive}
              disabled={!me}
            >
              <svg className="skull-icon" viewBox="0 0 24 24" fill="currentColor" fillRule="evenodd" aria-hidden="true">
                <path d="M12 3C8 3 5 6 5 10v3.2c0 .7.3 1.3.8 1.8l.7.7V18h2v-2h1.3v2h2v-2h1.4v2h2v-2H17v-2.3l.7-.7c.5-.5.8-1.1.8-1.8V10c0-4-3-7-7-7zM9 8.5a1.7 1.7 0 110 3.4 1.7 1.7 0 010-3.4zm6 0a1.7 1.7 0 110 3.4 1.7 1.7 0 010-3.4z" />
              </svg>
            </button>
          </div>
          <StatusBadge p={p} disputed={worldState.disputed} />
          {anchor && anchor.reporterCount > 1 && (
            <div className="row-conf mono" title="Number of distinct reporters backing this timer">
              ✓ {anchor.reporterCount} reporters agree
            </div>
          )}
        </div>

        <div className="col-primary">
          <PrimaryCountdown p={p} now={now} />
          {est && p && p.kind !== 'live' && (
            <div className="next-spawn">
              <span className="ns-label">spawns in</span>
              <span className="ns-time mono">{fmtClock(est.nextStartMin)} – {fmtClock(est.nextStartMax)}</span>
            </div>
          )}
          {p && p.kind === 'live' && (
            <div className="next-spawn">
              <span className="ns-label">ends</span>
              <span className="ns-time mono">
                {p.cycleN === -1 ? fmtClock(p.endMin) : `~${fmtClock(p.endMin)} – ~${fmtClock(p.endMax)}`}
              </span>
            </div>
          )}
        </div>

        <div className="col-timeline">
          <Timeline reports={reports} anchor={anchor} p={p} now={now} legendOn={legendOn} />
        </div>

        <div className="col-controls">
          <div className={`ctrl-collapse ${logOpen ? 'is-open' : ''}`}>
            <button className="btn btn-primary btn-trigger"
                    onClick={() => { if (me) setLogOpen(true); }}
                    disabled={!me || busy || logOpen}
                    title={me ? 'Log a remaining-time observation' : 'Sign in with Steam to log timers'}
                    tabIndex={logOpen ? -1 : 0}>
              Log Remaining
            </button>
            <div className="ctrl-expanded" aria-hidden={!logOpen} ref={panelRef}>
              <input
                ref={inputRef}
                className="time-input mono"
                value={input}
                onChange={e => setInput(e.target.value)}
                onKeyDown={e => {
                  if (e.key === 'Enter') commit();
                  if (e.key === 'Escape') { setInput(''); setLogOpen(false); }
                }}
                placeholder="1h 23m"
                aria-label={`Report time remaining for ${world}`}
                disabled={busy || !me}
                tabIndex={logOpen ? 0 : -1}
              />
              <button className="btn btn-primary" onClick={commit}
                      disabled={busy || !me} tabIndex={logOpen ? 0 : -1}>Save</button>
              <button className="btn btn-down"
                      onClick={() => { onAction({ world, type: 'down' }); setLogOpen(false); }}
                      disabled={busy || !me} tabIndex={logOpen ? 0 : -1}>Down</button>
            </div>
          </div>
          <div className="ctrl-row ctrl-row-sub">
            <button className="btn-link" onClick={() => setOpen(o => !o)}>
              {open ? 'hide history' : `history (${reports.filter(r => !r.deletedAt).length})`}
            </button>
          </div>
        </div>
      </div>
      {open && (
        <div className="row-history">
          <HistoryList reports={sortedReports} me={me} outlierIds={worldState.outlierIds} onDelete={onDeleteReport} />
        </div>
      )}
    </div>
  );
}

// ===== App ==================================================================
function App() {
  const toast = useToast();
  const [me, setMe] = useState(null); // null = unknown, false = signed out, obj = signed in
  const [region, setRegion] = useState(() => {
    const r = sessionStorage.getItem('ds-region');
    return REGIONS.includes(r) ? r : 'NA';
  });
  const [state, setState] = useState(null); // server payload
  const [now, setNow] = useState(Date.now());
  const [legendOn, setLegendOn] = useState(false);
  const [busy, setBusy] = useState(false);

  useEffect(() => { sessionStorage.setItem('ds-region', region); }, [region]);

  // tick
  useEffect(() => {
    const id = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(id);
  }, []);

  // who am I
  useEffect(() => {
    fetch('/api/me', { credentials: 'same-origin' })
      .then(r => (r.ok ? r.json() : Promise.reject()))
      .then(setMe, () => setMe(false));
  }, []);

  // Post-3DS landing: Stripe sent us back with ?donate=success or ?donate=failed.
  // Surface a toast and scrub the param.
  useEffect(() => {
    const params = new URLSearchParams(location.search);
    const status = params.get('donate');
    if (status === 'success') toast('Thanks for your support! 💚', 'success');
    else if (status === 'failed') toast('Donation was not completed.', 'error');
    if (params.has('donate')) {
      params.delete('donate');
      const q = params.toString();
      history.replaceState(null, '', location.pathname + (q ? '?' + q : ''));
    }
  }, [toast]);

  const refresh = useCallback(async () => {
    try {
      const s = await api(`/api/regions/${region}/state`);
      setState(s);
    } catch (e) { toast(`Refresh failed: ${e.message}`); }
  }, [region, toast]);

  useEffect(() => { refresh(); }, [refresh]);

  // SSE — re-fetch state on every change ping.
  useEffect(() => {
    const es = new EventSource(`/api/regions/${region}/stream`);
    const onChange = () => refresh();
    es.addEventListener('change', onChange);
    es.addEventListener('hello', () => {/* connected */});
    es.onerror = () => { /* EventSource auto-reconnects */ };
    return () => { es.removeEventListener('change', onChange); es.close(); };
  }, [region, refresh]);

  // Slow safety-net poll in case SSE drops silently.
  useEffect(() => {
    const id = setInterval(refresh, 30_000);
    return () => clearInterval(id);
  }, [refresh]);

  const reportsByWorld = useMemo(() => {
    const out = {};
    WORLDS.forEach(w => { out[w] = []; });
    (state?.reports || []).forEach(r => { if (out[r.world]) out[r.world].push(r); });
    return out;
  }, [state]);

  const doAction = useCallback(async ({ world, type, remainingMs }) => {
    if (!me) return;
    setBusy(true);
    try {
      await api('/api/reports', { method: 'POST', body: { region, world, type, remainingMs } });
      // SSE will refresh us, but optimistically refresh in case our own event is debounced.
      refresh();
      if (type === 'kill') toast(`Cargo kill logged on ${world}.`, 'success');
    } catch (e) { toast(`Failed to log ${type} on ${world}: ${e.message}`); }
    finally { setBusy(false); }
  }, [me, region, refresh, toast]);

  const toggleBoats = useCallback(async (world, enabled) => {
    if (!me) return;
    try {
      await api('/api/boats', { method: 'POST', body: { region, world, enabled } });
      // Local optimistic update — boats aren't broadcast.
      setState(s => s && ({ ...s, boats: { ...s.boats, [world]: enabled || undefined } }));
    } catch (e) { toast(`Boats update failed: ${e.message}`); }
  }, [me, region, toast]);

  const deleteReport = useCallback(async (e) => {
    if (!confirm('Delete this report?')) return;
    try {
      await api(`/api/reports/${e.id}`, { method: 'DELETE' });
      refresh();
    } catch (err) { toast(`Delete failed: ${err.message}`); }
  }, [refresh, toast]);

  // header counts
  const summary = useMemo(() => {
    const c = { live: 0, window: 0, gap: 0, down_only: 0, unknown: 0 };
    if (!state) return c;
    for (const w of WORLDS) {
      const ws = state.worlds[w];
      const downs = reportsByWorld[w].filter(r => r.type === 'down' && !r.deletedAt).map(r => ({ at: r.at }));
      const p = predict(ws?.consensus, downs, now);
      if (p.kind in c) c[p.kind]++; else c.unknown++;
    }
    return c;
  }, [state, reportsByWorld, now]);

  return (
    <div className="app">
      <header className="hdr">
        <div className="hdr-left">
          <div className="logo">
            <svg viewBox="0 0 24 24" width="28" height="28" aria-hidden="true">
              <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="1.5" opacity="0.4" />
              <circle cx="12" cy="12" r="6" fill="none" stroke="currentColor" strokeWidth="1.5" opacity="0.7" />
              <circle cx="12" cy="12" r="2" fill="currentColor" />
              <line x1="12" y1="12" x2="20" y2="6" stroke="currentColor" strokeWidth="1.5" />
            </svg>
          </div>
          <div className="hdr-titleblock">
            <span className="hdr-prefix">ALPHABOSS'S</span>
            <h1>DEEPSEA<span className="hdr-watch">WATCH</span></h1>
          </div>
        </div>
        <div className="hdr-center">
          <div className="hdr-counts">
            <span className="cnt cnt-live"><span className="dot dot-live" />{summary.live} live</span>
            <span className="cnt cnt-window"><span className="dot dot-window" />{summary.window} soon</span>
            <span className="cnt cnt-gap"><span className="dot dot-gap" />{summary.gap + summary.down_only} down</span>
            <span className="cnt cnt-unk">{summary.unknown} unknown</span>
          </div>
        </div>
        <div className="hdr-right">
          <div className="hdr-actions">
            <label className="switch" title="Show annotations on each timeline bar">
              <span className="switch-label">Legend</span>
              <input type="checkbox" checked={legendOn} onChange={e => setLegendOn(e.target.checked)} />
              <span className="switch-track"><span className="switch-thumb" /></span>
            </label>
            <a className="btn btn-ghost" href="/kills">Kill log</a>
            <div className="region-select" role="radiogroup" aria-label="Region">
              {REGIONS.map(r => (
                <button key={r} role="radio" aria-checked={region === r}
                        className={`region-opt ${region === r ? 'active' : ''}`}
                        onClick={() => setRegion(r)}>{r}</button>
              ))}
            </div>
            <AuthChip me={me} />
            <DonateButton variant="header" />
          </div>
        </div>
      </header>

      {legendOn && (
        <div className="help">
          <div className="help-grid">
            <div>
              <div className="help-h">Log a timer</div>
              <div className="help-d">When you enter a world while the event is running, click <strong>Log Remaining</strong>, type the time remaining (<span className="mono">1h 23m</span>, <span className="mono">1:23</span>, <span className="mono">23m</span>, or just <span className="mono">83</span> for minutes) and hit Enter. This anchors prediction with zero uncertainty.</div>
            </div>
            <div>
              <div className="help-h">Mark Down</div>
              <div className="help-d">Use when you're at a world and the event is <em>not</em> running but you don't know when it ended. Down observations narrow the prediction by ruling out the times when the event would have been live.</div>
            </div>
            <div>
              <div className="help-h">Consensus across reporters</div>
              <div className="help-d">If multiple people are reporting, the server clusters timers by their implied end time (±5min tolerance) and picks the densest cluster — three honest reports beat one typo. A <span className="badge badge-disputed" style={{ verticalAlign: 'middle' }}>DISPUTED</span> chip appears when reporters meaningfully disagree.</div>
            </div>
            <div>
              <div className="help-h">Per-world markers</div>
              <div className="help-d">
                Click <span className="inline-icon-btn" aria-hidden="true">
                  <svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3l7 12h-7V3z" /><rect x="11.4" y="12" width="1.2" height="4" /><path d="M3 17h18l-2 4H5l-2-4z" /></svg>
                </span> next to a world to mark personal boats parked there. Click <span className="inline-icon-btn" aria-hidden="true">
                  <svg viewBox="0 0 24 24" fill="currentColor" fillRule="evenodd"><path d="M12 3C8 3 5 6 5 10v3.2c0 .7.3 1.3.8 1.8l.7.7V18h2v-2h1.3v2h2v-2h1.4v2h2v-2H17v-2.3l.7-.7c.5-.5.8-1.1.8-1.8V10c0-4-3-7-7-7zM9 8.5a1.7 1.7 0 110 3.4 1.7 1.7 0 010-3.4zm6 0a1.7 1.7 0 110 3.4 1.7 1.7 0 010-3.4z" /></svg>
                </span> to log a cargo kill (only while the world is live) — starts a 5-min cooldown above the world name and adds a red dot to your timeline.
              </div>
            </div>
          </div>
          <div className="help-legend">
            <div className="help-h">Timeline legend (6h view, projected from your last live timer)</div>
            <div className="legend-row">
              <span className="lg-item"><span className="lg-swatch lg-past-event" /> past event (3h)</span>
              <span className="lg-item"><span className="lg-swatch lg-gap" /> down (60m guaranteed)</span>
              <span className="lg-item"><span className="lg-swatch lg-spawn-window" /> spawn (1h window — event may begin anywhere in here)</span>
              <span className="lg-item"><span className="lg-swatch lg-event-certain" /> projected event, certain</span>
              <span className="lg-item"><span className="lg-swatch lg-event-uncertain-end" /> projected event, uncertain end</span>
            </div>
            <div className="legend-row">
              <span className="lg-item"><span className="lg-mark lg-mark-live" /> live timer report</span>
              <span className="lg-item"><span className="lg-mark lg-mark-down" /> down observation</span>
              <span className="lg-item"><span className="lg-mark lg-mark-kill" /> cargo kill (yours)</span>
              <span className="lg-item"><span className="lg-mark lg-mark-now" /> now</span>
              <span className="lg-item"><span className="lg-callout-swatch" /> next spawn window callout</span>
            </div>
          </div>
        </div>
      )}

      {state && (
        <main className="list">
          {WORLDS.map(w => (
            <WorldRow
              key={w}
              world={w}
              worldState={state.worlds[w] || {}}
              reports={reportsByWorld[w] || []}
              now={now}
              me={me || null}
              hasBoats={!!state.boats?.[w]}
              onToggleBoats={toggleBoats}
              onAction={doAction}
              onDeleteReport={deleteReport}
              legendOn={legendOn}
              busy={busy}
            />
          ))}
        </main>
      )}
      {!state && <div className="hist-empty" style={{ padding: 40, textAlign: 'center' }}>Loading {region}…</div>}

      <footer className="ftr">
        <DonateButton variant="footer" />
        <span className="mono">deepsea-watch · v2 · server-backed · region {region}</span>
      </footer>
    </div>
  );
}

function AuthChip({ me }) {
  if (me === null) return <div className="auth-chip muted mono">…</div>;
  if (!me) {
    return (
      <div className="auth-signin">
        <a className="btn btn-steam" href="/auth/steam">
          <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true">
            <path d="M12 0C5.4 0 0 5.4 0 12c0 5.4 3.5 9.9 8.4 11.4l3-4.3c-.6.1-1.2.2-1.8.2-1 0-2-.3-2.9-.7L9 16c.4 1 1.4 1.6 2.4 1.6 1.5 0 2.7-1.2 2.7-2.7 0-.5-.1-1-.4-1.4l5-3.4c.2.4.3.9.3 1.4 0 1.6-1.3 2.9-2.9 2.9-.6 0-1.1-.2-1.6-.5l-3 4.2c.4.1.7.2 1.1.2 3.7 0 6.6-3 6.6-6.6 0-.9-.2-1.8-.5-2.6L24 6.7C23.4 2.9 18.1 0 12 0z"/>
          </svg>
          Sign in with Steam
        </a>
        <span className="auth-signin-hint">Login to access more features</span>
      </div>
    );
  }
  return (
    <div className="auth-chip">
      {me.avatarUrl && <img src={me.avatarUrl} alt="" className="auth-avatar" />}
      <span className="auth-name">{me.personaName}</span>
      <form method="post" action="/auth/logout" onSubmit={async e => {
        e.preventDefault();
        await fetch('/auth/logout', { method: 'POST', credentials: 'same-origin' });
        location.reload();
      }}>
        <button type="submit" className="btn-link" title="Sign out">sign out</button>
      </form>
    </div>
  );
}

// ===== donations =============================================================
// DonateButton lives in the header. On mount it fetches /api/donate/config; if
// the server reports donations are disabled (no Stripe keys configured), the
// button renders nothing — no broken UI surface in half-configured deploys.
// Clicking opens DonateModal, which walks the donor through:
//   1. amount pick:  $3 / $5 / $20 / Other
//   2. checkout:     embedded Stripe Payment Element + Donate $N button
// Success and failure both surface through the existing useToast() hook.
function DonateButton({ variant = 'header' }) {
  const [config, setConfig] = useState(null);  // null = loading, false = disabled, obj = enabled
  const [open, setOpen] = useState(false);

  useEffect(() => {
    fetch('/api/donate/config', { credentials: 'same-origin' })
      .then(r => r.ok ? r.json() : Promise.reject())
      .then(c => setConfig(c?.enabled ? c : false), () => setConfig(false));
  }, []);

  if (!config) return null;  // loading or disabled → no chrome

  const heart = (
    <svg viewBox="0 0 24 24" width={variant === 'footer' ? 14 : 14} height={variant === 'footer' ? 14 : 14}
         fill="currentColor" aria-hidden="true">
      <path d="M12 21s-7-4.5-9.5-9A5.5 5.5 0 0 1 12 6a5.5 5.5 0 0 1 9.5 6c-2.5 4.5-9.5 9-9.5 9z"/>
    </svg>
  );

  return (
    <>
      {variant === 'header' ? (
        <button className="btn btn-ghost btn-donate-icon" onClick={() => setOpen(true)} aria-label="Donate" title="Support the project">
          {heart}
        </button>
      ) : (
        <button className="btn btn-donate-footer" onClick={() => setOpen(true)} title="Support the project">
          {heart}
          <span>Support Us</span>
        </button>
      )}
      {open && <DonateModal config={config} onClose={() => setOpen(false)} />}
    </>
  );
}

function DonateModal({ config, onClose }) {
  const toast = useToast();
  const [step, setStep] = useState('pick');           // 'pick' | 'pay'
  const [amountCents, setAmountCents] = useState(0);
  const [otherDollars, setOtherDollars] = useState('');
  const [showOther, setShowOther] = useState(false);
  const [clientSecret, setClientSecret] = useState(null);
  const [paying, setPaying] = useState(false);
  const [errMsg, setErrMsg] = useState(null);
  const hostRef = useRef();
  const stripeRef = useRef(null);
  const elementsRef = useRef(null);

  // Esc to close, click backdrop to close.
  useEffect(() => {
    const onKey = e => { if (e.key === 'Escape') onClose(); };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [onClose]);

  const pickAmount = async (cents) => {
    setErrMsg(null);
    setAmountCents(cents);
    setStep('pay');
    setClientSecret(null);
    try {
      const r = await api('/api/donate/intent', { method: 'POST', body: { amountCents: cents } });
      setClientSecret(r.clientSecret);
    } catch (e) {
      setErrMsg(e.message);
      setStep('pick');
    }
  };

  const submitOther = () => {
    const dollars = parseFloat(otherDollars);
    if (!Number.isFinite(dollars) || dollars < 1 || dollars > 500) {
      setErrMsg(`Enter an amount between $1 and $500.`);
      return;
    }
    pickAmount(Math.round(dollars * 100));
  };

  // Mount the Stripe Payment Element once we have a clientSecret + Stripe.js.
  useEffect(() => {
    if (!clientSecret) return;
    if (!window.Stripe) {
      setErrMsg('Stripe.js did not load. Refresh and try again.');
      return;
    }
    const stripe = window.Stripe(config.publishableKey);
    const elements = stripe.elements({
      clientSecret,
      appearance: {
        theme: 'night',
        variables: {
          colorPrimary: '#3acfff',
          colorBackground: '#0c1825',
          colorText: '#e6eef7',
          fontFamily: 'Inter, system-ui, sans-serif',
          borderRadius: '6px',
        },
      },
    });
    const el = elements.create('payment', { layout: 'tabs' });
    el.mount(hostRef.current);
    stripeRef.current = stripe;
    elementsRef.current = elements;
    return () => { try { el.unmount(); } catch {/* host gone */} };
  }, [clientSecret, config.publishableKey]);

  const submitPayment = async () => {
    if (!stripeRef.current || !elementsRef.current) return;
    setPaying(true);
    setErrMsg(null);
    const { error, paymentIntent } = await stripeRef.current.confirmPayment({
      elements: elementsRef.current,
      confirmParams: { return_url: location.origin + '/?donate=success' },
      redirect: 'if_required',
    });
    setPaying(false);
    if (error) {
      setErrMsg(error.message || 'Payment failed.');
      return;
    }
    if (paymentIntent && (paymentIntent.status === 'succeeded' || paymentIntent.status === 'processing')) {
      toast(`Thanks for your support! 💚 ($${(amountCents / 100).toFixed(2)})`, 'success');
      onClose();
    }
  };

  const dollars = (amountCents / 100).toFixed(amountCents % 100 === 0 ? 0 : 2);

  return (
    <div className="modal-backdrop" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="modal" role="dialog" aria-labelledby="donate-title">
        <header className="modal-head">
          <h2 id="donate-title" className="modal-title">Support Deepsea Watch</h2>
          <button className="modal-close" onClick={onClose} aria-label="Close donate dialog">×</button>
        </header>

        {step === 'pick' && (
          <>
            <p className="modal-sub">A one-off thank-you helps keep the lights on and Railway funded. Card processed by Stripe; nothing stored here.</p>
            <div className="donate-tiers">
              {config.presetCents.map(cents => (
                <button key={cents} className="donate-tile" onClick={() => pickAmount(cents)}>
                  <span className="donate-tile-amt">${cents / 100}</span>
                </button>
              ))}
              <button className={`donate-tile donate-tile-other ${showOther ? 'is-active' : ''}`} onClick={() => setShowOther(true)}>
                <span className="donate-tile-amt">Other</span>
              </button>
            </div>
            {showOther && (
              <div className="donate-other">
                <span className="donate-other-prefix mono">$</span>
                <input
                  className="donate-other-input mono"
                  type="number" min="1" max="500" step="1"
                  value={otherDollars}
                  onChange={e => setOtherDollars(e.target.value)}
                  onKeyDown={e => { if (e.key === 'Enter') submitOther(); }}
                  placeholder="Amount"
                  autoFocus
                />
                <button className="btn btn-primary" onClick={submitOther} disabled={!otherDollars}>Continue</button>
              </div>
            )}
            {errMsg && <div className="modal-err mono">{errMsg}</div>}
          </>
        )}

        {step === 'pay' && (
          <>
            <div className="modal-step-head">
              <button className="btn-link" onClick={() => { setStep('pick'); setClientSecret(null); setErrMsg(null); }}>← change amount</button>
              <span className="modal-step-amount mono">Donating ${dollars}</span>
            </div>
            {!clientSecret && !errMsg && <div className="modal-loading mono">Preparing checkout…</div>}
            <div ref={hostRef} className="donate-element-host" />
            {errMsg && <div className="modal-err mono">{errMsg}</div>}
            <button className="btn btn-primary donate-submit"
                    onClick={submitPayment}
                    disabled={!clientSecret || paying}>
              {paying ? 'Processing…' : `Donate $${dollars}`}
            </button>
          </>
        )}
      </div>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(
  <ToastProvider><App /></ToastProvider>
);
