/* recaps.jsx — Historical Recaps tab.
   • An overview (win rate / wins / losses / averages) + a wins-and-losses chart.
   • A PERIOD view (Daily / Weekly / Monthly / 3M / 6M / 12M) that buckets closed
     trades into recap summaries grouped by analyst — derived from our own data,
     so every server gets recaps even with no recap channel set.
   • For servers with a recap channel + "show published recap" on, the analyst's
     published Daily Recap overlays the matching day with a reconciliation badge.
   • Owner only: a reconciliation panel listing discrepancies the bot flagged.
*/

function RecapCard({ recap, analysts }) {
  const win = recap.pnlPct >= 0;
  const basis = recap.basis != null ? recap.basis : recap.entryPrice;
  return (
    <div className="recap">
      <div className="top">
        <div>
          <div className="ticker-big">${recap.ticker}</div>
          <div className="contract" style={{ marginTop: 6, color: "var(--fg-1)" }}>{recap.contract || "shares"}</div>
          <div style={{ marginTop: 8 }}>
            <AnalystChip analyst={recap.analyst} analysts={analysts} />
          </div>
        </div>
        <div style={{ textAlign: "right" }}>
          {recap.unpriced
            ? <div className="pnl" style={{ color: "var(--fg-2)" }} title="Lapsed at expiry with no exit price — confirm the closing price in the To-Do queue">—</div>
            : <div className={cx("pnl", win ? "win" : "loss")}>{fmtPct(recap.pnlPct)}</div>}
          <div className="pnl-sub">
            {recap.events.length} interactions
            {basis != null && <> · {recap.avgPrice != null ? "avg" : "entry"} {fmtNum(basis)}</>}
          </div>
          <div className="pnl-sub">{fmtDay(recap.entryTs || recap.events[0].ts)} → {fmtDay(recap.updatedTs)}</div>
        </div>
      </div>
      <TradeTimeline events={recap.events} />
      <div className="footer">
        <span style={{ textTransform: "uppercase", letterSpacing: ".10em" }}>{recap.status === "expired" ? "Expired" : "Closed"}</span>
        <span>
          {recap.cardConflict && <span className="pill" style={{ color: "var(--c-watch)", marginRight: 6 }} title="An automated price card and a manual update disagree by more than 5% on this position">CHECK</span>}
          {recap.unpriced
            ? <span className="pill" style={{ color: "var(--fg-2)" }}>AWAITING PRICE</span>
            : <span className="pill" style={{ color: win ? "var(--c-up)" : "var(--c-down)" }}>{win ? "WIN" : "LOSS"}</span>}
        </span>
      </div>
    </div>
  );
}

// ── Prominent win / loss / average summary (clearer than the old inline text)
function RecapSummary({ recaps, awaiting = 0 }) {
  const total = recaps.length;
  const wins = recaps.filter(r => r.pnlPct >= 0);
  const losses = recaps.filter(r => r.pnlPct < 0);
  const winRate = total ? Math.round((wins.length / total) * 100) : 0;
  const avg = total ? recaps.reduce((s, r) => s + r.pnlPct, 0) / total : 0;
  const net = recaps.reduce((s, r) => s + r.pnlPct, 0);
  const avgWin = wins.length ? wins.reduce((s, r) => s + r.pnlPct, 0) / wins.length : 0;
  const avgLoss = losses.length ? losses.reduce((s, r) => s + r.pnlPct, 0) / losses.length : 0;
  const wPct = total ? (wins.length / total) * 100 : 50;

  return (
    <div className="recap-summary">
      <div className="recap-hero">
        <div className="recap-winrate" style={{ color: winRate >= 50 ? "var(--c-up)" : "var(--c-down)" }}>{total ? winRate + "%" : "—"}</div>
        <div className="recap-hero-lbl">win rate<span>{total} closed trade{total === 1 ? "" : "s"}{awaiting > 0 ? ` · ${awaiting} awaiting price` : ""}</span></div>
      </div>
      <div className="recap-mid">
        <div className="recap-wlbar" title={`${wins.length} wins · ${losses.length} losses`}>
          <span className="wl-win" style={{ width: wPct + "%" }} />
          <span className="wl-loss" style={{ width: (100 - wPct) + "%" }} />
        </div>
        <div className="recap-statset">
          <div className="recap-stat"><span className="rs-val up">{wins.length}</span><span className="rs-lbl">wins</span></div>
          <div className="recap-stat"><span className="rs-val down">{losses.length}</span><span className="rs-lbl">losses</span></div>
          {window.GLANCE_UI && <div className="recap-stat"><span className="rs-val" style={{ color: net >= 0 ? "var(--c-up)" : "var(--c-down)" }}>{total ? fmtPct(net) : "—"}</span><span className="rs-lbl">net P&amp;L</span></div>}
          <div className="recap-stat"><span className="rs-val" style={{ color: avg >= 0 ? "var(--c-up)" : "var(--c-down)" }}>{total ? fmtPct(avg) : "—"}</span><span className="rs-lbl">avg P&amp;L</span></div>
          <div className="recap-stat"><span className="rs-val up">{wins.length ? fmtPct(avgWin) : "—"}</span><span className="rs-lbl">avg win</span></div>
          <div className="recap-stat"><span className="rs-val down">{losses.length ? fmtPct(avgLoss) : "—"}</span><span className="rs-lbl">avg loss</span></div>
        </div>
      </div>
    </div>
  );
}

// ── Daily P&L line chart on a SIGNED-LOG scale. One point per trading day
//    (cumulative net P&L of closed trades); the signed-log axis keeps a
//    +12,900% runner and a −55% stop on the same readable canvas — linear feel
//    near zero, log compression for outliers, honest gridlines so the scale is
//    legible. Segments + dots color by that DAY's net; click a day to open its
//    full recap (closed trades, trims, published overlay) right below.
const SLOG_S = 10;   // percent per "decade" anchor: 0→10% linear-ish, then log
const slog = (v) => Math.sign(v) * Math.log10(1 + Math.abs(v) / SLOG_S);

function RecapChart({ recaps, analysts, trimsByDay, publishedByDay, basisFor, canReconcile }) {
  const [hover, setHover] = useState(null);
  const [selDay, setSelDay] = useState(null);        // startOfDay ms of the open day
  const [w, setW] = useState(960);
  // Callback ref, not useRef+mount-effect: the wrapper unmounts whenever a
  // filter collapses the series below 2 days, so a one-shot effect would either
  // never attach or keep observing a detached node (width frozen at 960).
  const roRef = useRef(null);
  const wrapRef = useCallback((el) => {
    if (roRef.current) { roRef.current.disconnect(); roRef.current = null; }
    if (!el || typeof ResizeObserver === "undefined") return;
    const measure = () => { const cw = el.clientWidth; if (cw) setW(cw); };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    roRef.current = ro;
  }, []);

  const [grain, setGrain] = useState("day");   // day | week | month

  // Bucket closed trades into the chosen grain (oldest → newest). Each bar is
  // that PERIOD's own net — what actually answers "how did this week go?" — with
  // the running cumulative kept for the hover readout.
  const buckets = useMemo(() => {
    const m = new Map();
    for (const r of recaps) {
      const b = recapBucket(r.updatedTs, grain);
      if (!m.has(b.key)) m.set(b.key, { key: b.key, sortTs: b.sortTs, label: b.label, list: [] });
      m.get(b.key).list.push(r);
    }
    const arr = [...m.values()].sort((a, b) => a.sortTs - b.sortTs);
    let cum = 0;
    for (const b of arr) {
      b.net = b.list.reduce((s, r) => s + r.pnlPct, 0);
      b.wins = b.list.filter(r => r.pnlPct >= 0).length;
      cum += b.net; b.cum = cum;
    }
    return arr;
  }, [recaps, grain]);

  // Keep the open period panel honest if filters change under it.
  const sel = selDay != null ? buckets.find(b => b.key === selDay) : null;
  const best = buckets.reduce((a, b) => (!a || Math.abs(b.net) > Math.abs(a.net) ? b : a), null);

  const GRAINS = [["day", "Daily"], ["week", "Weekly"], ["month", "Monthly"]];
  const grainNoun = grain === "week" ? "week" : grain === "month" ? "month" : "day";

  // Short axis label per grain: "Jul 13" / "w/c Jul 13" / "Jul 2026".
  const barLabel = (b) => {
    const d = new Date(b.sortTs);
    if (grain === "month") return d.toLocaleDateString(undefined, { month: "short", year: "2-digit" });
    if (grain === "week") return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
    return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
  };

  const H = 250, padT = 26, padB = 30;
  const innerH = H - padT - padB;
  const n = buckets.length;
  // Bars are laid out in a plain flex row (not SVG) so each one can carry a real
  // text label that stays legible at any width — the old cumulative line pushed
  // every value onto a cramped axis where the percentages were unreadable.
  const maxAbs = Math.max(...buckets.map(b => Math.abs(b.net)), 0.01);
  // Signed-log heights so a single +12,900% period can't flatten every other bar,
  // while the printed value on each bar carries the exact number regardless.
  const hFor = (v) => Math.max(3, (slog(Math.abs(v)) / slog(maxAbs)) * (innerH / 2));

  if (!n) return null;

  return (
    <div className="recap-chartwrap" ref={wrapRef}>
      <div className="recap-chart-hd">
        <div className="rc-title">
          Performance by {grainNoun}
          <span className="rc-sub">{n} {grainNoun}{n === 1 ? "" : "s"} · each bar is that {grainNoun}'s net</span>
        </div>
        <div className="seg" role="group" aria-label="Chart grain">
          {GRAINS.map(([v, l]) => (
            <button key={v} className={cx("seg-btn", grain === v && "on")}
              onClick={() => { setGrain(v); setSelDay(null); setHover(null); }}>{l}</button>
          ))}
        </div>
      </div>

      <div className="rc-bars" style={{ height: H }} onMouseLeave={() => setHover(null)}>
        <div className="rc-zero" style={{ top: padT + innerH / 2 }} />
        {buckets.map((b, i) => {
          const up = b.net >= 0;
          const bh = hFor(b.net);
          const on = hover === i || selDay === b.key;
          return (
            <button type="button" key={b.key}
              className={cx("rc-bar", up ? "up" : "down", on && "on", best && b.key === best.key && "peak")}
              onMouseEnter={() => setHover(i)}
              onClick={() => { setSelDay(selDay === b.key ? null : b.key); }}
              title={`${b.label} · ${b.list.length} trade${b.list.length === 1 ? "" : "s"} · ${fmtPct(b.net)}`}
            >
              <span className="rc-bar-val" style={{ bottom: up ? `calc(50% + ${bh}px + 3px)` : "auto", top: up ? "auto" : `calc(50% + ${bh}px + 3px)` }}>
                {fmtPct(b.net)}
              </span>
              <span className="rc-bar-fill" style={{ height: bh, bottom: up ? "50%" : "auto", top: up ? "auto" : "50%" }} />
              <span className="rc-bar-x">{barLabel(b)}</span>
            </button>
          );
        })}
      </div>

      <div className="recap-chart-meta mono">
        {hover != null && buckets[hover]
          ? (() => {
              const b = buckets[hover];
              const tr = grain === "day" && trimsByDay ? (trimsByDay.get(b.sortTs) || []) : [];
              return <>{b.label} · {b.list.length} trade{b.list.length === 1 ? "" : "s"} · <span className="up">{b.wins}W</span>/<span className="down">{b.list.length - b.wins}L</span>
                {tr.length ? <> · {tr.length} trim{tr.length === 1 ? "" : "s"}</> : null}
                {" · "}net <strong style={{ color: b.net >= 0 ? "var(--c-up)" : "var(--c-down)" }}>{fmtPct(b.net)}</strong>
                {" · running "}<strong style={{ color: b.cum >= 0 ? "var(--c-up)" : "var(--c-down)" }}>{fmtPct(b.cum)}</strong>
                {" · click for the recap"}</>;
            })()
          : <>bar heights use a signed-log scale so one outsized {grainNoun} can't flatten the rest — the printed % on each bar is the exact figure · click any bar for its recap</>}
      </div>

      {sel && (
        <div className="recap-daypanel">
          <div className="recap-daypanel-hd">
            <span className="mono">{sel.label}</span>
            <button className="icon-btn" title="Close" onClick={() => setSelDay(null)}>{I("close", { size: 14 })}</button>
          </div>
          <RecapPeriodBlock
            bucket={{ key: sel.key, sortTs: sel.sortTs, label: sel.label, recaps: sel.list }}
            analysts={analysts}
            trims={grain === "day" && trimsByDay ? trimsByDay.get(sel.sortTs) : null}
            published={grain === "day" && publishedByDay ? publishedByDay.get(anchorDayKey(sel.sortTs)) : null}
            basisFor={basisFor}
            canReconcile={canReconcile}
          />
        </div>
      )}
    </div>
  );
}

// ── Trims taken in a period — partial exits, scaling out of a position. Part
//    of the day's story, so it belongs in the recap. NOTE the copy describes
//    the trim AT ITS MOMENT: a position trimmed at 10am may be fully closed by
//    3pm (and then also appears in that day's closed trades), so we must never
//    claim these are all still open.
function RecapTrims({ trims, analysts, basisFor }) {
  if (!trims || !trims.length) return null;
  // Grouped under an analyst header exactly like the closed trades above, so the
  // whole day reads as one list with the same shape rather than two unrelated ones.
  const groups = new Map();
  for (const e of trims) { if (!groups.has(e.analyst)) groups.set(e.analyst, []); groups.get(e.analyst).push(e); }
  const ordered = [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
  return (
    <div className="recap-trims">
      <div className="recap-sub-hd">
        Trims taken <span className="recap-an-n">{trims.length}</span>
        <span className="rt-note">partial exits taken during the day</span>
      </div>
      {ordered.map(([analyst, list]) => (
        <div key={analyst} className="recap-an-group">
          <div className="recap-an-hd"><AnalystChip analyst={analyst} analysts={analysts} /><span className="recap-an-n">{list.length}</span></div>
          {list.map(e => {
        // Options are premium-based (always +); only SHORT shares invert.
        // Mirrors positions.jsx / data.jsx so P&L colors read identically.
            const sign = (e.instrument !== "option" && e.direction === "short") ? -1 : 1;
            // Cost basis lives on the POSITION, not on the trim event, so a trim
            // almost never carries avgPrice and the % was permanently blank.
            const basis = e.avgPrice != null ? e.avgPrice : (basisFor ? basisFor(e) : null);
            // The exit value of a trim: its fill, or the mark if it was logged as an
            // update (Manage -> Update writes current_price, not price). When the
            // analyst stated ONLY a percentage result and no price (e.g. Ansh's
            // "MSFT at 20%"), derive the implied exit from the basis — exactly as a
            // CLOSE with a stated % already scores.
            let exit = e.price != null ? e.price : e.currentPrice;
            if (exit == null && e.realizedPct != null && basis != null && Number(basis) > 0)
              exit = Number(basis) * (1 + Number(e.realizedPct) * sign);
            // A FRACTION, not a percentage — fmtPct multiplies by 100 itself, and
            // pnlPct elsewhere is a fraction too (data.jsx: `pnlMult: 1 + pnlPct`).
            const pct = (exit != null && basis != null && Number(basis) > 0)
              ? sign * ((Number(exit) - Number(basis)) / Number(basis)) : null;
            return (
              <div key={e.id} className="recap-line recap-trim-line">
                <span className="rl-sq trim" />
                <span className="rl-tkr">${e.ticker}</span>
                <span className="rl-con">{e.contract || "shares"}</span>
                <span className="rl-mv mono">
                  {basis != null ? <>{fmtNum(basis)} <span className="rl-arr">→</span> </> : <span className="rl-arr">@ </span>}
                  {exit != null ? fmtNum(exit) : "—"}
                </span>
                <span className={cx("rl-pct", pct != null ? (pct >= 0 ? "up" : "down") : "")}>{pct != null ? fmtPct(pct) : ""}</span>
              </div>
            );
          })}
        </div>
      ))}
    </div>
  );
}

// ── Best / worst trade callouts + a compact per-analyst leaderboard (by net).
function RecapHighlights({ recaps, analysts }) {
  const { best, worst, board } = useMemo(() => {
    let best = null, worst = null;
    for (const r of recaps) {
      if (!best || r.pnlPct > best.pnlPct) best = r;
      if (!worst || r.pnlPct < worst.pnlPct) worst = r;
    }
    const m = new Map();
    for (const r of recaps) { if (!m.has(r.analyst)) m.set(r.analyst, []); m.get(r.analyst).push(r); }
    const board = [...m.entries()].map(([analyst, list]) => {
      const wins = list.filter(r => r.pnlPct >= 0).length;
      const net = list.reduce((s, r) => s + r.pnlPct, 0);
      return { analyst, n: list.length, wins, winRate: Math.round((wins / list.length) * 100), net };
    }).sort((a, b) => b.net - a.net);
    return { best, worst, board };
  }, [recaps]);
  if (!recaps.length) return null;
  return (
    <div className="recap-highlights">
      <div className="recap-bestworst">
        {best && (
          <div className="rbw">
            <span className="rbw-lbl up">▲ Best</span>
            <span className="ticker">${best.ticker}</span>
            <AnalystChip analyst={best.analyst} analysts={analysts} />
            <span className="rl-pct up">{fmtPct(best.pnlPct)}</span>
          </div>
        )}
        {worst && worst.key !== (best && best.key) && (
          <div className="rbw">
            <span className="rbw-lbl down">▼ Worst</span>
            <span className="ticker">${worst.ticker}</span>
            <AnalystChip analyst={worst.analyst} analysts={analysts} />
            <span className="rl-pct down">{fmtPct(worst.pnlPct)}</span>
          </div>
        )}
      </div>
      {board.length > 1 && (
        <div className="recap-board">
          <div className="recap-board-hd">Analyst leaderboard <span className="usage-dim">· by net P&amp;L</span></div>
          {board.map(b => (
            <div key={b.analyst} className="recap-board-row">
              <AnalystChip analyst={b.analyst} analysts={analysts} />
              <span className="rb-wr mono">{b.winRate}% WR</span>
              <span className="rb-rec mono"><span className="up">{b.wins}</span>/<span className="down">{b.n - b.wins}</span></span>
              <span className={cx("rb-net mono", b.net >= 0 ? "up" : "down")}>{fmtPct(b.net)}</span>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// ─── Period bucketing (Daily / Weekly / Monthly / 3M / 6M / 12M) ──────────────
const RECAP_PERIODS = [["day", "Daily"], ["week", "Weekly"], ["month", "Monthly"], ["q", "3M"], ["h", "6M"], ["year", "12M"]];

// Recaps bucket by MARKET day (see the market-time block in shared.jsx): a trade
// closed at the 4pm ET bell belongs to THAT trading day for everyone, not to
// whatever calendar day it happened to be in the viewer's timezone.
// `startOfDayMs` returns the local-midnight anchor OF THE MARKET DAY — a stable
// per-trading-day key that also formats correctly for labels.
function startOfDayMs(ts) {
  const d = marketDayDate(marketDayKey(ts));
  return d ? d.getTime() : 0;
}
function startOfWeekMs(ts) {
  const d = marketDayDate(marketDayKey(ts));
  if (!d) return 0;
  const dow = (d.getDay() + 6) % 7;           // Monday-first trading week
  d.setDate(d.getDate() - dow);
  return d.getTime();
}
// The market day a REAL timestamp belongs to, as YYYY-MM-DD — the key published
// recaps (tape_recaps.recap_date) are already stored under.
function isoDayLocal(ms) { return marketDayKey(ms); }
// Inverse of startOfDayMs: read the date straight off a local-midnight ANCHOR.
// Never pass an anchor back through marketDayKey — that re-applies the timezone
// shift and lands a day early.
function anchorDayKey(anchorMs) {
  const d = new Date(anchorMs);
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}

// → { key, sortTs, label } for the bucket a timestamp falls in, given the period.
function recapBucket(ts, period) {
  const d = new Date(ts);
  if (period === "week") { const s = startOfWeekMs(ts); return { key: "w" + s, sortTs: s, label: "Week of " + new Date(s).toLocaleDateString(undefined, { month: "short", day: "numeric" }) }; }
  if (period === "month") { const s = new Date(d.getFullYear(), d.getMonth(), 1).getTime(); return { key: "m" + s, sortTs: s, label: new Date(s).toLocaleDateString(undefined, { month: "long", year: "numeric" }) }; }
  if (period === "q") { const q = Math.floor(d.getMonth() / 3); const s = new Date(d.getFullYear(), q * 3, 1).getTime(); return { key: "q" + s, sortTs: s, label: "Q" + (q + 1) + " " + d.getFullYear() }; }
  if (period === "h") { const half = d.getMonth() < 6 ? 0 : 1; const s = new Date(d.getFullYear(), half * 6, 1).getTime(); return { key: "h" + s, sortTs: s, label: "H" + (half + 1) + " " + d.getFullYear() }; }
  if (period === "year") { const s = new Date(d.getFullYear(), 0, 1).getTime(); return { key: "y" + s, sortTs: s, label: String(d.getFullYear()) }; }
  const s = startOfDayMs(ts); return { key: "d" + s, sortTs: s, label: new Date(s).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", year: "numeric" }) };
}

// Stats count only trades we can actually price. A lapsed option with no exit
// evidence (`unpriced`) has an unknown outcome — counting it as a 0% scratch would
// inflate the win rate with trades nobody can verify, so it is reported separately
// as "awaiting price" until the closing price is confirmed in the To-Do queue.
function bucketStats(all) {
  const recaps = all.filter(r => !r.unpriced);
  const awaiting = all.length - recaps.length;
  const total = recaps.length;
  const wins = recaps.filter(r => r.pnlPct >= 0).length;
  const winRate = total ? Math.round((wins / total) * 100) : 0;
  const totalGain = recaps.reduce((s, r) => s + r.pnlPct, 0);
  const avg = total ? totalGain / total : 0;
  const top = recaps.reduce((b, r) => (!b || r.pnlPct > b.pnlPct ? r : b), null);
  return { total, wins, losses: total - wins, winRate, totalGain, avg, top, awaiting };
}

function groupByAnalyst(recaps) {
  const m = new Map();
  for (const r of recaps) { if (!m.has(r.analyst)) m.set(r.analyst, []); m.get(r.analyst).push(r); }
  for (const list of m.values()) list.sort((a, b) => b.pnlPct - a.pnlPct);
  const best = (list) => Math.max(...list.map(x => x.pnlPct));
  return [...m.entries()].sort((a, b) => best(b[1]) - best(a[1]));
}

// One closed trade as a compact recap line (click to expand the full card).
function RecapLine({ recap, analysts, open, onToggle }) {
  const win = recap.pnlPct >= 0;
  const basis = recap.basis != null ? recap.basis : recap.entryPrice;
  return (
    <>
      <div className={cx("recap-line", open && "open")} onClick={onToggle}>
        <span className={cx("rl-sq", recap.unpriced ? "" : win ? "win" : "loss")} />
        <span className="rl-tkr">${recap.ticker}</span>
        <span className="rl-con">{recap.contract || "shares"}</span>
        <span className="rl-mv mono">{basis != null ? fmtNum(basis) : "—"} <span className="rl-arr">→</span> {recap.unpriced ? "?" : recap.exit != null ? fmtNum(recap.exit) : "—"}</span>
        {recap.unpriced
          ? <span className="rl-pct" style={{ color: "var(--fg-2)" }} title="Lapsed at expiry with no exit price — confirm the closing price in the To-Do queue">awaiting price</span>
          : <span className={cx("rl-pct", win ? "up" : "down")}>{fmtPct(recap.pnlPct)}</span>}
      </div>
      {open && <div className="recap-line-detail"><RecapCard recap={recap} analysts={analysts} /></div>}
    </>
  );
}

// The analyst's PUBLISHED recap for a day, overlaid on the derived bucket.
function PublishedRecap({ pub, serverName, canReconcile }) {
  const [open, setOpen] = useState(false);
  // Prefer the LIVE open-flag count from the API. `trades[].status` is frozen at
  // parse time, so resolving a to-do never cleared it and the badge stayed stuck
  // on "2 flags" forever. Fall back to the stored statuses only if the API
  // couldn't attach a count (older payload / lookup failed).
  const stored = (pub.trades || []).filter(t => t.status && !["confirmed", "auto_closed"].includes(t.status));
  const nFlags = pub.openFlags != null ? pub.openFlags : stored.length;
  const reconciled = nFlags === 0;
  const st = pub.stats || {};
  return (
    <div className="pub-recap">
      <div className="pub-recap-hdr" onClick={() => setOpen(o => !o)}>
        <span className="pub-tag">{I("recap", { size: 12 })} Published recap{serverName ? ` · ${serverName}` : ""}</span>
        <span className="pub-stats mono">
          {st.winrate != null && <>WR {fmtNum(st.winrate)}%</>}
          {st.total_gains != null && <> · {st.total_gains >= 0 ? "+" : ""}{fmtNum(st.total_gains)}%</>}
          {st.avg_gain != null && <> · avg {st.avg_gain >= 0 ? "+" : ""}{fmtNum(st.avg_gain)}%</>}
        </span>
        {/* Reconciliation is OUR data-quality state, not the analyst's result.
            A member seeing "2 flags" on someone's published recap learns nothing
            they can act on and everything about our internal QA. Gated on the
            same capability that can actually work the queue. */}
        {canReconcile && (
          <span className={cx("pub-badge", reconciled ? "ok" : "warn")}>
            {reconciled ? "✓ reconciled" : `${nFlags} flag${nFlags === 1 ? "" : "s"}`}
          </span>
        )}
        <span className="pub-caret">{open ? "▾" : "▸"}</span>
      </div>
      {open && (
        <div className="pub-recap-body">
          {(pub.trades || []).map((t, i) => {
            const win = t.win === true || (t.pct != null && t.pct >= 0);
            return (
              <div key={i} className="pub-line">
                <span className={cx("rl-sq", win ? "win" : "loss")} />
                <span className="pub-an">{t.analyst_handle || t.analyst}</span>
                <span className="rl-tkr">${t.ticker}</span>
                <span className="rl-con">{t.contract || "shares"}</span>
                <span className="rl-mv mono">{t.entry != null ? fmtNum(t.entry) : "—"} <span className="rl-arr">→</span> {t.exit != null ? fmtNum(t.exit) : "—"}</span>
                {/* A recap's `pct` is already a PERCENTAGE (112.9, -48.89) — unlike
                    pnlPct everywhere else, which is a fraction. fmtPct multiplies
                    by 100, so using it here rendered 112.9% as +11290.0%. */}
                <span className={cx("rl-pct", win ? "up" : "down")}>
                  {t.pct != null ? `${t.pct >= 0 ? "+" : ""}${fmtNum(t.pct)}%` : ""}
                </span>
                {canReconcile && t.status && !["confirmed", "auto_closed"].includes(t.status) && <span className="pub-flag">{t.status.replace(/_/g, " ")}</span>}
                {canReconcile && t.status === "auto_closed" && <span className="pub-fix">auto-closed</span>}
              </div>
            );
          })}
          {st.play_of_day && <div className="pub-pod">🔥 {st.play_of_day}</div>}
        </div>
      )}
    </div>
  );
}

// One period bucket: header stats + trades grouped by analyst + trims taken +
// published overlay. A bucket can be trims-only (no closed trades that day).
function RecapPeriodBlock({ bucket, analysts, published, trims, trimStats, basisFor, canReconcile }) {
  // Header stats include the period's priced trims when the toggle is on —
  // the lists below stay closed-trades vs trims, separately labelled.
  const s = bucketStats(trimStats && trimStats.length ? bucket.recaps.concat(trimStats) : bucket.recaps);
  const groups = groupByAnalyst(bucket.recaps);
  const [openId, setOpenId] = useState(null);
  return (
    <div className="recap-period">
      <div className="recap-period-hdr">
        <div className="rp-label">{bucket.label}</div>
        <div className="rp-stats">
          {s.total > 0 ? (
            <>
              <span className="rp-stat"><b style={{ color: s.winRate >= 50 ? "var(--c-up)" : "var(--c-down)" }}>{s.winRate}%</b> win</span>
              <span className="rp-stat"><b className="up">{s.wins}</b>/<b className="down">{s.losses}</b></span>
              <span className="rp-stat">total <b style={{ color: s.totalGain >= 0 ? "var(--c-up)" : "var(--c-down)" }}>{fmtPct(s.totalGain)}</b></span>
              <span className="rp-stat">avg <b style={{ color: s.avg >= 0 ? "var(--c-up)" : "var(--c-down)" }}>{fmtPct(s.avg)}</b></span>
              <span className="rp-stat rp-n">{s.total} trade{s.total === 1 ? "" : "s"}</span>
              {s.awaiting > 0 && <span className="rp-stat rp-n" title="Lapsed at expiry with no exit price — excluded from these stats until confirmed">+{s.awaiting} awaiting price</span>}
            </>
          ) : s.awaiting > 0 ? (
            <span className="rp-stat rp-n">{s.awaiting} awaiting price · no priced trades</span>
          ) : (
            <span className="rp-stat rp-n">no closed trades · trims only</span>
          )}
        </div>
      </div>
      {published && published.map(p => <PublishedRecap key={p.id} pub={p.recap} serverName={p.serverName} canReconcile={canReconcile} />)}
      <div className="recap-period-body">
        {groups.length > 0 && (
          <div className="recap-sub-hd">
            Positions closed <span className="recap-an-n">{bucket.recaps.length}</span>
            <span className="rt-note">full exits booked on this day</span>
          </div>
        )}
        {groups.map(([analyst, list]) => (
          <div key={analyst} className="recap-an-group">
            <div className="recap-an-hd"><AnalystChip analyst={analyst} analysts={analysts} /><span className="recap-an-n">{list.length}</span></div>
            {list.map(r => (
              <RecapLine key={r.key} recap={r} analysts={analysts} open={openId === r.key} onToggle={() => setOpenId(openId === r.key ? null : r.key)} />
            ))}
          </div>
        ))}
        <RecapTrims trims={trims} analysts={analysts} basisFor={basisFor} />
      </div>
    </div>
  );
}

// Owner-only: reconciliation discrepancies the recap bot flagged.
function RecapFlagsPanel({ serverIds = "" }) {
  const [flags, setFlags] = useState(null);
  const [open, setOpen] = useState(false);
  useEffect(() => {
    let alive = true;
    // Scope flags to the servers in view, so pending resolves for a server you
    // haven't selected don't show up (mirrors how recaps/events are filtered).
    const qs = "status=open" + (serverIds ? "&server_ids=" + encodeURIComponent(serverIds) : "");
    tapeFetch(apiBase() + "/api/recap-flags?" + qs)
      .then(r => r.ok ? r.json() : { flags: [] })
      .then(({ flags }) => { if (alive) setFlags(flags || []); })
      .catch(() => { if (alive) setFlags([]); });
    return () => { alive = false; };
  }, [serverIds]);
  async function act(id, status) {
    setFlags(prev => prev.filter(f => f.id !== id));
    try {
      const r = await tapeFetch(apiBase() + "/api/recap-flags/" + encodeURIComponent(id), {
        method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ status }),
      });
      if (!r.ok) throw new Error("HTTP " + r.status);
      toast.success(status === "resolved" ? "Marked resolved" : "Dismissed");
    } catch (e) { toast.error("Couldn't update — " + e.message); }
  }
  if (!flags || !flags.length) return null;
  const KIND = { untracked: "no tracked position", exit_mismatch: "exit price differs", unknown_analyst: "analyst not tracked" };
  return (
    <div className="recap-flags panel">
      <div className="recap-flags-hdr" onClick={() => setOpen(o => !o)}>
        <span>{I("alert", { size: 14 })} Reconciliation · <b>{flags.length}</b> open flag{flags.length === 1 ? "" : "s"}</span>
        <span className="pub-caret">{open ? "▾" : "▸"}</span>
      </div>
      {open && (
        <div className="recap-flags-body">
          {flags.map(f => (
            <div key={f.id} className="recap-flag">
              <div className="rf-main">
                <span className="rf-kind">{KIND[f.kind] || f.kind}</span>
                <span className="rf-tkr">{f.analyst_handle ? f.analyst_handle + " · " : ""}${f.ticker}{f.contract ? " " + f.contract : ""}</span>
                <span className="rf-detail mono">{f.detail}</span>
              </div>
              <div className="rf-actions">
                <button className="btn" onClick={() => act(f.id, "resolved")}>Resolve</button>
                <button className="btn ghost" onClick={() => act(f.id, "dismissed")}>Dismiss</button>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

function Recaps({ recaps, analysts, enabled, filter, events = [], publishedRecaps = [], servers = [], isOwner = false, serverIdsParam = "", canReconcile = false }) {
  const [view, setView] = useState("period");  // period buckets vs flat per-trade cards
  const [period, setPeriod] = useState("day");
  const [sort, setSort] = useState("recent");
  const [anFilter, setAnFilter] = useState(() => new Set());

  const availableAnalysts = useMemo(() => {
    const set = new Set();
    for (const r of recaps) if (enabled.has(r.analyst)) set.add(r.analyst);
    return [...set].sort();
  }, [recaps, enabled]);

  // A trim event carries no cost basis — that lives on the POSITION it belongs
  // to, which is why a trim's % was permanently blank. Map each event id to its
  // position's basis. Keyed by EVENT ID rather than a rebuilt instrument key: a
  // position has no `ts`, and instrumentKey needs one to key a 0DTE contract.
  const basisFor = useMemo(() => {
    const m = new Map();
    for (const p of aggregatePositions(events || [])) {
      const b = p.avgPrice != null ? p.avgPrice : p.entryPrice;
      if (b == null) continue;
      for (const ev of p.events) if (ev.id) m.set(ev.id, b);
    }
    return (e) => (e && e.id != null && m.has(e.id)) ? m.get(e.id) : null;
  }, [events]);

  const filtered = useMemo(() => {
    const fl = (filter || "").toLowerCase().trim();
    let arr = recaps.filter(r => enabled.has(r.analyst) && analystMatch(r.analyst, anFilter));
    if (fl) arr = arr.filter(r => r.ticker.toLowerCase().includes(fl));
    return arr;
  }, [recaps, enabled, filter, anFilter]);

  // Only trades with real exit evidence feed the headline stats and the P&L chart.
  // Lapsed options we cannot price still LIST (flagged "awaiting price") so they
  // aren't hidden — they're just not scored as wins.
  const scored = useMemo(() => filtered.filter(r => !r.unpriced), [filtered]);
  const awaiting = filtered.length - scored.length;

  // Trim events bucketed by local day, honoring the same analyst/ticker filters
  // as the closed trades — trims are part of the day's recap story.
  const trimsByDay = useMemo(() => {
    const fl = (filter || "").toLowerCase().trim();
    const m = new Map();
    for (const e of (events || [])) {
      if (e.action !== "trim") continue;
      if (!enabled.has(e.analyst) || !analystMatch(e.analyst, anFilter)) continue;
      if (fl && !String(e.ticker || "").toLowerCase().includes(fl)) continue;
      const d = startOfDayMs(e.ts);
      if (!m.has(d)) m.set(d, []);
      m.get(d).push(e);
    }
    for (const list of m.values()) list.sort((a, b) => b.ts - a.ts);
    return m;
  }, [events, enabled, anFilter, filter]);

  // Trims contribute to the STATS (owner's call, default ON, per-device toggle).
  // A trim is a realized partial exit — leaving it out understated every number
  // members see. Each priced trim becomes a pseudo-recap row: same pct rule the
  // trims list renders (fill else mark, event avg else the position's basis,
  // short shares invert), fractions like every other pnlPct. Unpriced trims
  // contribute nothing — no guesses in the stats.
  const [includeTrims, setIncludeTrims] = useState(() => {
    try { return localStorage.getItem("recaps:trims") !== "0"; } catch (_) { return true; }
  });
  const toggleTrims = () => setIncludeTrims(v => {
    try { localStorage.setItem("recaps:trims", v ? "0" : "1"); } catch (_) {}
    return !v;
  });
  const trimRows = useMemo(() => {
    const fl = (filter || "").toLowerCase().trim();
    const out = [];
    for (const e of (events || [])) {
      if (e.action !== "trim") continue;
      if (!enabled.has(e.analyst) || !analystMatch(e.analyst, anFilter)) continue;
      if (fl && !String(e.ticker || "").toLowerCase().includes(fl)) continue;
      const sign = (e.instrument !== "option" && e.direction === "short") ? -1 : 1;
      const basis = e.avgPrice != null ? e.avgPrice : basisFor(e);
      // Stated-% trims (no price) derive their implied exit from the basis so they
      // fold into the stats like priced ones — mirrors RecapTrims + closed scoring.
      let exit = e.price != null ? e.price : e.currentPrice;
      if (exit == null && e.realizedPct != null && basis != null && Number(basis) > 0)
        exit = Number(basis) * (1 + Number(e.realizedPct) * sign);
      if (exit == null || basis == null || Number(basis) <= 0) continue;
      out.push({
        key: "trim:" + e.id, analyst: e.analyst, ticker: e.ticker, contract: e.contract,
        pnlPct: sign * ((Number(exit) - Number(basis)) / Number(basis)),
        updatedTs: e.ts, isTrim: true,
      });
    }
    return out;
  }, [events, enabled, anFilter, filter, basisFor]);
  // What the stat surfaces see. The LISTS stay real closed trades — trims render
  // in their own labelled section, never as fake "closed" rows.
  const statRecaps = useMemo(() => includeTrims ? scored.concat(trimRows) : scored,
    [includeTrims, scored, trimRows]);
  const trimStatsByBucket = useMemo(() => {
    const m = new Map();
    if (!includeTrims) return m;
    for (const r of trimRows) {
      const b = recapBucket(r.updatedTs, period);
      if (!m.has(b.key)) m.set(b.key, []);
      m.get(b.key).push(r);
    }
    return m;
  }, [includeTrims, trimRows, period]);

  // Flat individual trades (the "Trades" view), sortable like before.
  const trades = useMemo(() => {
    const arr = [...filtered];
    if (sort === "pnl_desc") arr.sort((a, b) => b.pnlPct - a.pnlPct);
    else if (sort === "pnl_asc") arr.sort((a, b) => a.pnlPct - b.pnlPct);
    else arr.sort((a, b) => b.updatedTs - a.updatedTs);
    return arr;
  }, [filtered, sort]);

  // Servers whose published recap should be shown (show_recap !== false).
  const recapServers = useMemo(() => {
    const m = new Map();
    for (const s of (servers || [])) if (s.show_recap !== false) m.set(s.guild_id, s);
    return m;
  }, [servers]);

  // Published recaps keyed by local day, restricted to show-recap servers + the
  // analysts currently enabled. Only overlaid in the Daily period.
  const publishedByDay = useMemo(() => {
    const map = new Map();
    for (const p of (publishedRecaps || [])) {
      const srv = recapServers.get(p.guildId);
      if (!srv) continue;
      const day = p.date || (p.ts != null ? isoDayLocal(p.ts) : null);
      if (!day) continue;
      if (!map.has(day)) map.set(day, []);
      map.get(day).push({ id: p.id, recap: p, serverName: (servers.length > 1 ? srv.name : null) });
    }
    return map;
  }, [publishedRecaps, recapServers, servers]);

  const buckets = useMemo(() => {
    const m = new Map();
    for (const r of filtered) {
      const b = recapBucket(r.updatedTs, period);
      if (!m.has(b.key)) m.set(b.key, { ...b, recaps: [] });
      m.get(b.key).recaps.push(r);
    }
    // Daily view: a day with only trims still gets a recap block.
    if (period === "day") {
      for (const day of trimsByDay.keys()) {
        const b = recapBucket(day, "day");
        if (!m.has(b.key)) m.set(b.key, { ...b, recaps: [] });
      }
    }
    return [...m.values()].sort((a, b) => b.sortTs - a.sortTs);
  }, [filtered, period, trimsByDay]);

  return (
    <div>
      {isOwner && <RecapFlagsPanel serverIds={serverIdsParam} />}

      {filtered.length > 0 && (
        <div className="panel recap-overview">
          <RecapSummary recaps={statRecaps} awaiting={awaiting} />
          <RecapChart recaps={statRecaps} analysts={analysts} trimsByDay={trimsByDay} publishedByDay={publishedByDay} basisFor={basisFor} canReconcile={canReconcile} />
          {window.GLANCE_UI && <RecapHighlights recaps={statRecaps} analysts={analysts} />}
        </div>
      )}

      <div className="filterbar" style={{ gridTemplateColumns: "1fr auto" }}>
        <div className="panel-meta">
          {view === "period"
            ? <>{filtered.length} closed · {buckets.length} {RECAP_PERIODS.find(p => p[0] === period)[1].toLowerCase()} recap{buckets.length === 1 ? "" : "s"}</>
            : <>{filtered.length} closed position{filtered.length === 1 ? "" : "s"}</>}
        </div>
        <div className="recap-controls">
          <AnalystFilter available={availableAnalysts} selected={anFilter} onChange={setAnFilter} analysts={analysts} />
          <label className="switch recap-trim-toggle" title="Count partial exits (trims) in the win rate, totals and chart. The closed-trades lists are unaffected.">
            <input type="checkbox" checked={includeTrims} onChange={toggleTrims} />
            <span className="switch-track"><span className="switch-thumb" /></span>
            <span className="recap-trim-lbl">Trims in stats</span>
          </label>
          <div className="seg" role="group" aria-label="Recap view">
            {[["period", "Periods"], ["trades", "Trades"]].map(([v, l]) => (
              <button key={v} className={cx("seg-btn", view === v && "on")} onClick={() => setView(v)}>{l}</button>
            ))}
          </div>
          {view === "period" ? (
            <div className="seg" role="group" aria-label="Recap period">
              {RECAP_PERIODS.map(([v, l]) => (
                <button key={v} className={cx("seg-btn", period === v && "on")} onClick={() => setPeriod(v)}>{l}</button>
              ))}
            </div>
          ) : (
            <div className="seg" role="group" aria-label="Sort trades">
              {[["recent", "Recent"], ["pnl_desc", "Top wins"], ["pnl_asc", "Top losses"]].map(([v, l]) => (
                <button key={v} className={cx("seg-btn", sort === v && "on")} onClick={() => setSort(v)}>{l}</button>
              ))}
            </div>
          )}
        </div>
      </div>

      {view === "period" ? (
        <div className="recap-periods">
          {buckets.map(b => (
            <RecapPeriodBlock
              key={b.key}
              bucket={b}
              analysts={analysts}
              published={period === "day" ? publishedByDay.get(anchorDayKey(b.sortTs)) : null}
              trims={period === "day" ? trimsByDay.get(b.sortTs) : null}
              trimStats={trimStatsByBucket.get(b.key)}
              basisFor={basisFor}
              canReconcile={canReconcile}
            />
          ))}
        </div>
      ) : (
        <div className="recap-grid">
          {trades.map(r => <RecapCard key={r.key} recap={r} analysts={analysts} />)}
        </div>
      )}

      {filtered.length === 0 && (view !== "period" || buckets.length === 0) && <Empty msg="No closed positions match the filter." />}
    </div>
  );
}

Object.assign(window, { Recaps, RecapCard, RecapSummary, RecapChart, RecapHighlights, RecapPeriodBlock, PublishedRecap });
