/* shared.jsx — icons, tiny utilities, common components */

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

// ─── Icons (lucide-style minimal stroke set, ~14-18px)
const Icon = ({ d, size = 14, strokeWidth = 1.75, fill = "none", className = "icon", style }) => (
  <svg className={className} width={size} height={size} viewBox="0 0 24 24"
    fill={fill} stroke="currentColor" strokeWidth={strokeWidth}
    strokeLinecap="round" strokeLinejoin="round" style={style}>
    {Array.isArray(d) ? d.map((p, i) => <path key={i} d={p} />) : <path d={d} />}
  </svg>
);

const Icons = {
  activity:    "M22 12h-4l-3 9-6-18-3 9H2",
  upload:      ["M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4", "M17 8l-5-5-5 5", "M12 3v12"],
  download:    ["M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4", "M7 10l5 5 5-5", "M12 15V3"],
  table:       ["M3 3h18v18H3z", "M3 9h18", "M3 15h18", "M9 3v18", "M15 3v18"],
  events:      ["M22 12h-4l-3 9-6-18-3 9H2"],
  sources:     ["M3 6h18", "M7 12h10", "M11 18h2"],
  chart:       ["M3 3v18h18", "M7 14l4-4 3 3 5-6"],
  calc:        ["M6 2h12a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z", "M8 6h8v3H8z", "M8 13h.01", "M12 13h.01", "M16 13h.01", "M8 17h.01", "M12 17h.01", "M16 17h.01"],
  recap:       ["M3 3h18v6H3z", "M3 13h18v8H3z", "M7 17h6"],
  book:        ["M4 19.5A2.5 2.5 0 0 1 6.5 17H20", "M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"],
  bug:         ["m8 2 1.88 1.88", "M14.12 3.88 16 2", "M9 7.13v-1a3.003 3.003 0 1 1 6 0v1", "M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6", "M12 20v-9", "M6.53 9C4.6 8.8 3 7.1 3 5", "M6 13H2", "M3 21c0-2.1 1.7-3.8 3.8-4", "M20.97 5c0 2.1-1.6 3.8-3.5 4", "M22 13h-4", "M17.2 17c2.1.2 3.8 1.9 3.8 4"],
  bell:        ["M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9", "M10.3 21a1.94 1.94 0 0 0 3.4 0"],
  back:        "m15 18-6-6 6-6",
  settings:    ["M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z", "M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"],
  search:      ["M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z", "m21 21-4.3-4.3"],
  filter:      "M22 3H2l8 9.5V19l4 2v-8.5L22 3z",
  eye:         ["M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z", "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"],
  eyeOff:      ["M9.9 4.2A10 10 0 0 1 12 4c7 0 10 7 10 7a16 16 0 0 1-2.4 3.3", "M6.6 6.6A16 16 0 0 0 2 11s3 7 10 7c1.7 0 3.2-.4 4.6-1", "M14.1 14.1a3 3 0 1 1-4.2-4.2", "m2 2 20 20"],
  arrowUp:     ["M12 19V5", "m5 12 7-7 7 7"],
  arrowDown:   ["M12 5v14", "m19 12-7 7-7-7"],
  arrowUpRight: ["M7 17l10-10", "M7 7h10v10"],
  expand:      ["M3 3h6v6H3z", "M21 21h-6v-6h6z"],
  chevDown:    "m6 9 6 6 6-6",
  chevRight:   "m9 18 6-6-6-6",
  close:       ["m18 6-12 12", "m6 6 12 12"],
  trash:       ["M3 6h18", "M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2", "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6", "M10 11v6", "M14 11v6"],
  edit:        ["M12 20h9", "M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"],
  plus:        ["M12 5v14", "M5 12h14"],
  minus:       ["M5 12h14"],
  dots:        ["M12 5h.01", "M12 12h.01", "M12 19h.01"],
  hash:        ["M4 9h16", "M4 15h16", "M10 3 8 21", "M16 3l-2 18"],
  zap:         "M13 2 3 14h9l-1 8 10-12h-9l1-8z",
  shield:      ["M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"],
  database:    ["M12 8c4.97 0 9-1.34 9-3s-4.03-3-9-3-9 1.34-9 3 4.03 3 9 3z", "M3 5v6c0 1.66 4.03 3 9 3s9-1.34 9-3V5", "M3 11v6c0 1.66 4.03 3 9 3s9-1.34 9-3v-6"],
  server:      ["M3 4h18v6H3z", "M3 14h18v6H3z", "M7 7h.01", "M7 17h.01"],
  user:        ["M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2", "M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"],
  link:        ["M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7", "M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"],
  refresh:     ["M21 12a9 9 0 0 1-15 6.7L3 16", "M3 12a9 9 0 0 1 15-6.7L21 8", "M21 3v5h-5", "M3 21v-5h5"],
  alert:       ["M12 9v4", "M12 17h.01", "M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"],
  spark:       "m13 2-3 7h4l-3 7", // small lightning
  trend:       ["m3 17 6-6 4 4 8-8", "M14 7h7v7"],
  trendDown:   ["m3 7 6 6 4-4 8 8", "M14 17h7v-7"],
  clock:       ["M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z", "M12 6v6l4 2"],
  message:     ["M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"],
  brain:       ["M12 5a3 3 0 1 0-5.5-1.6", "M12 5a3 3 0 1 1 5.5-1.6", "M12 5v14", "M6.5 8a3 3 0 1 0-2 5", "M17.5 8a3 3 0 1 1 2 5", "M8 17a3 3 0 1 0-2 4", "M16 17a3 3 0 1 1 2 4"],
  check:       "m5 12 5 5L20 7",
  sliders:     ["M4 21v-7", "M4 10V3", "M12 21v-9", "M12 8V3", "M20 21v-5", "M20 12V3", "M1 14h6", "M9 8h6", "M17 16h6"],
  sun:         ["M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10z","M12 1v2","M12 21v2","M4.22 4.22l1.42 1.42","M18.36 18.36l1.42 1.42","M1 12h2","M21 12h2","M4.22 19.78l1.42-1.42","M18.36 5.64l1.42-1.42"],
  moon:        "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z",
  monitor:     ["M2 4h20v14H2z","M8 22h8","M12 18v4"],
  logout:      ["M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4", "M16 17l5-5-5-5", "M21 12H9"],
  menu:        ["M4 6h16", "M4 12h16", "M4 18h16"],
  lock:        ["M5 11h14a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1z", "M8 11V7a4 4 0 0 1 8 0v4"],
  star:        "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z",
};

const I = (name, props={}) => <Icon d={Icons[name]} {...props} />;

// ─── Tiny helpers
const cx = (...xs) => xs.filter(Boolean).join(" ");
const _toDate = (d) => d instanceof Date ? d : new Date(d);
const fmtTime = (d, opts={}) => {
  const date = _toDate(d);
  return date.toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
};
const fmtTimeOnly = (d) => _toDate(d).toLocaleString("en-US", { hour: "numeric", minute: "2-digit" });
const fmtDay = (d) => _toDate(d).toLocaleString("en-US", { month: "short", day: "numeric" });

// Local-midnight day distance between two instants (0 = same day, 1 = yesterday…).
const _startOfDay = (x) => { const d = _toDate(x); return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); };
const _dayDelta = (d, ref = Date.now()) => Math.round((_startOfDay(ref) - _startOfDay(d)) / 86400000);

// Relative "time ago" — the at-a-glance "is this new or old?" cue for the feeds.
// Always pair with title={fmtTime(ts)} so the exact stamp stays one hover away.
const fmtAgo = (d) => {
  const t = _toDate(d).getTime();
  if (!isFinite(t)) return "";
  const sec = Math.round((Date.now() - t) / 1000);
  if (sec < 45) return "just now";
  const min = Math.round(sec / 60);
  if (min < 60) return min + "m ago";
  const hr = Math.round(min / 60);
  if (hr < 24) return hr + "h ago";
  const dd = _dayDelta(t);
  if (dd <= 1) return "Yesterday";
  if (dd < 7) return dd + "d ago";
  const sameYear = new Date(t).getFullYear() === new Date().getFullYear();
  return _toDate(t).toLocaleString("en-US", sameYear ? { month: "short", day: "numeric" } : { month: "short", day: "numeric", year: "numeric" });
};

// Coarse recency buckets driving the subtle freshness styling (a "NEW" tag on
// the hottest items, today vs older). "live" = last 2h, hot off the press.
const recencyBucket = (d) => {
  const diff = Date.now() - _toDate(d).getTime();
  if (diff < 2 * 3600000) return "live";
  const dd = _dayDelta(d);
  if (dd <= 0) return "today";
  if (dd === 1) return "yesterday";
  if (dd < 7) return "week";
  return "older";
};

// Section label + key for grouping a feed by day. Mirrors watchlist's
// fmtDayLabel so day separators read identically across the app
// ("Today · Sat, Jun 21, 2026" / "Yesterday · …" / "Sat, Jun 14, 2026").
// Feed day separators group by MARKET day, so an after-hours post never lands
// under the viewer's "tomorrow" (see the market-time block above).
const fmtDayGroup = (d) => marketDayLabel(marketDayKey(d));
const dayKey = (d) => marketDayNum(d);

// Canonical option-expiry display: "Jan 15 2027". Accepts whatever the parser
// extracted — "1/15", "1/15/27", "1/15/2027", "2027-01-15" — and normalizes it.
// Falls back to the input unchanged if it isn't a recognizable date.
const _MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
// Parse a parser-supplied expiry into a LOCAL calendar date (midnight), or null
// if unrecognizable. Local matters: `new Date("2026-07-18")` is UTC midnight, so
// naive parsing shifts the day either side of the date line.
function parseExpiry(raw) {
  if (raw == null) return null;
  const s = String(raw).trim();
  if (!s) return null;
  let mo, day, yr = null, m;
  if ((m = s.match(/^(\d{1,2})[\/\-.](\d{1,2})(?:[\/\-.](\d{2,4}))?$/))) {
    mo = +m[1]; day = +m[2]; if (m[3] != null) yr = +m[3];
  } else if ((m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/))) {
    yr = +m[1]; mo = +m[2]; day = +m[3];
  } else {
    return null; // already worded or unrecognized
  }
  if (!(mo >= 1 && mo <= 12) || !(day >= 1 && day <= 31)) return null;
  if (yr == null) {
    // No year stated — assume the nearest upcoming occurrence.
    const now = new Date();
    yr = now.getFullYear();
    const cand = new Date(yr, mo - 1, day).getTime();
    if (cand < now.getTime() - 90 * 86400000) yr += 1; // well past → next year
  } else if (yr < 100) {
    yr += 2000;
  }
  return new Date(yr, mo - 1, day);   // local midnight
}
function fmtExpiry(raw) {
  if (raw == null) return "";
  const s = String(raw).trim();
  if (!s) return "";
  const d = parseExpiry(s);
  if (!d) return s;   // already worded or unrecognized — leave as-is
  return `${_MONTHS[d.getMonth()]} ${d.getDate()} ${d.getFullYear()}`;
}

const fmtNum = (n, dec=2) => n == null ? "—" : Number(n).toFixed(dec);
// Currency symbols we actually see. Anything else falls back to the ISO code so
// a price is never shown under the WRONG symbol (A$8.47 must not read $8.47).
const CCY_SYM = { USD: "$", AUD: "A$", CAD: "C$", GBP: "£", EUR: "€", JPY: "¥", HKD: "HK$", SGD: "S$", NZD: "NZ$", CHF: "CHF ", INR: "₹" };
const ccySym = (c) => { const k = String(c || "USD").toUpperCase(); return CCY_SYM[k] || (k + " "); };
const fmtCurrency = (n, ccy) => n == null ? "—" : (n < 0 ? "-" : "") + ccySym(ccy) + Math.abs(n).toFixed(2);
// A bare price with its currency prefix ONLY when it isn't USD — keeps the
// common case clean while never mislabelling a foreign-listed position.
const fmtPrice = (n, ccy) => {
  if (n == null) return "—";
  const k = String(ccy || "USD").toUpperCase();
  return (k === "USD" ? "" : ccySym(k)) + Number(n).toFixed(2);
};
const fmtPct = (n) => n == null ? "—" : (n >= 0 ? "+" : "") + (n*100).toFixed(1) + "%";

// ─── Status pill (live / idle / error)
function StatusPill({ status="ready" }) {
  return (
    <span className="status">
      <span className="dot"></span>
      {status}
    </span>
  );
}

// ─── Pill (action / confidence)
function ActionPill({ kind }) {
  const k = (kind || "").toLowerCase();
  return <span className={cx("pill", k)}>{k}</span>;
}
function ConfPill({ level }) {
  const l = (level || "").toLowerCase();
  return <span className={cx("pill", l)}>{l}</span>;
}

// ─── Analyst chip
// The analyst's face + handle. app.jsx resolves `avatarUrl` onto each analyst
// record, so every tab gets the picture for free. The colour swatch remains the
// fallback (and the colour is still what ties a row to an analyst at a glance) —
// an avatar that's missing or 404s degrades straight back to it.
function AnalystChip({ analyst, analysts }) {
  const a = (analysts || []).find(x => x.handle === analyst);
  const col = a?.color || "var(--accent)";
  const swatch = <span className="swatch" style={{ "--col": col }} />;
  // Tapping a name opens that analyst's profile — the same on desktop and touch.
  // A window event keeps this leaf component decoupled from the app's overlay
  // state; app.jsx listens and opens the flyout. stopPropagation so tapping the
  // name inside a clickable row doesn't also trigger the row.
  const open = (e) => {
    e.stopPropagation();
    if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("sinux:open-analyst", { detail: { handle: analyst } }));
  };
  return (
    <span
      className="analyst analyst-tap"
      role="button"
      tabIndex={0}
      title={`${analyst} — view profile`}
      onClick={open}
      onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); open(e); } }}
    >
      {a?.avatarUrl
        ? <DiscordAvatar url={a.avatarUrl} size={24} className="analyst-pic" style={{ "--col": col }} fallback={swatch} />
        : swatch}
      {analyst}
    </span>
  );
}

// ─── Instrument helpers (shares vs options)
const isOption = (x) => x ? (x.instrument ? x.instrument === "option" : !!x.contract) : false;

// Strike label with the option-type suffix, e.g. "14P" / "270C".
function strikeText(x) {
  if (x.strike == null) return "";
  const suf = x.optionType === "put" ? "P" : x.optionType === "call" ? "C" : "";
  return `${x.strike}${suf}`;
}

// Builds a full contract label from structured fields (expiry normalized to
// "Jan 15 2027"), falling back to the stored string if fields are missing.
function contractText(x) {
  const parts = [];
  if (x.expiry) parts.push(fmtExpiry(x.expiry));
  const strike = strikeText(x);
  if (strike) parts.push(strike);
  const built = parts.join(" ");
  return built || x.contract || "";
}

// Just the instrument-type tag — PUT / CALL / OPT / SHARES — no contract detail.
function InstrumentTag({ event }) {
  if (!isOption(event)) return <span className="inst-tag shares">SHARES</span>;
  const kind = event.optionType === "put" ? "put" : event.optionType === "call" ? "call" : "opt";
  const tag = kind === "put" ? "PUT" : kind === "call" ? "CALL" : "OPT";
  return <span className={cx("inst-tag", kind)}>{tag}</span>;
}

// Cell that cleanly distinguishes options from shares (tag + full detail).
function InstrumentCell({ event }) {
  if (!isOption(event)) {
    return <span className="inst"><span className="inst-tag shares">SHARES</span></span>;
  }
  const detail = contractText(event);
  return (
    <span className="inst">
      <InstrumentTag event={event} />
      {detail && <span className="inst-detail">{detail}</span>}
    </span>
  );
}

// Segmented All / Options / Shares filter.
function InstrumentFilter({ value, onChange }) {
  const opts = [["all", "All"], ["option", "Options"], ["shares", "Shares"]];
  return (
    <div className="seg">
      {opts.map(([v, l]) => (
        <button key={v} className={cx("seg-btn", value === v && "on")} onClick={() => onChange(v)}>{l}</button>
      ))}
    </div>
  );
}
function matchesInstrument(x, filter) {
  if (filter === "all") return true;
  return filter === "option" ? isOption(x) : !isOption(x);
}

// Dynamic analyst filter — a dropdown populated from the analysts actually
// present in the current view (`available`). `selected` is an include-set: empty
// = all (no filter). Reused across Positions / Events / Recaps / Watchlist so you
// can narrow to one or more analysts without typing into the search box.
function analystMatch(handle, selected) { return !selected || selected.size === 0 || selected.has(handle); }

function AnalystFilter({ available, selected, onChange, analysts }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    if (!open) return;
    const onClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    window.addEventListener("mousedown", onClick);
    return () => window.removeEventListener("mousedown", onClick);
  }, [open]);

  const avail = available || [];
  const sel = selected || new Set();
  const colorOf = (h) => (analysts || []).find(a => a.handle === h)?.color || "var(--accent)";

  function toggle(h) {
    const next = new Set(sel);
    if (next.has(h)) next.delete(h); else next.add(h);
    onChange(next);
  }
  function clear() { onChange(new Set()); }

  if (!avail.length) return null;
  const label = sel.size === 0 ? "All analysts" : sel.size === 1 ? [...sel][0] : `${sel.size} analysts`;

  return (
    <div ref={ref} className="analyst-filter">
      <button className="btn af-btn" onClick={() => setOpen(o => !o)} title="Filter by analyst">
        {I("user", { size: 13 })}
        <span className="af-label">{label}</span>
        {I("chevDown", { size: 12 })}
      </button>
      {open && (
        <div className="af-menu">
          <div className="af-menu-hd">
            <span>Analyst</span>
            {sel.size > 0 && <button onClick={clear}>clear</button>}
          </div>
          <button className={cx("af-opt", sel.size === 0 && "on")} onClick={clear}>
            <span className="af-check">{sel.size === 0 ? I("check", { size: 12 }) : null}</span>
            All analysts
          </button>
          {avail.map(h => (
            <button key={h} className={cx("af-opt", sel.has(h) && "on")} onClick={() => toggle(h)}>
              <span className="af-check">{sel.has(h) ? I("check", { size: 12 }) : null}</span>
              <span className="swatch" style={{ "--col": colorOf(h) }} />
              {h}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

// ─── Market time ────────────────────────────────────────────────────────────
// A trade belongs to the TRADING DAY it happened on, not to the viewer's local
// calendar day. Those differ constantly: the 4pm ET close on Mon Jul 13 is 4am
// Tue Jul 14 in Malaysia (UTC+8), so local-day bucketing filed Monday's closes
// under Tuesday. Timestamps are stored as absolute epoch ms (viewer-independent
// and already correct) — it is only BUCKETING and day labels that must resolve
// in market time. Individual clock times still display in the viewer's zone.
const MARKET_TZ = "America/New_York";
const _mdParts = new Intl.DateTimeFormat("en-CA", {
  timeZone: MARKET_TZ, year: "numeric", month: "2-digit", day: "2-digit",
});
// "YYYY-MM-DD" of the market day a timestamp falls in (DST-correct via Intl).
function marketDayKey(d) {
  const t = _toDate(d);
  if (!isFinite(t.getTime())) return "";
  return _mdParts.format(t);
}
// Sortable integer for a market day: 2026-07-13 → 20260713.
function marketDayNum(d) { const k = marketDayKey(d); return k ? Number(k.replace(/-/g, "")) : 0; }
// A market day key back to a Date at LOCAL midnight of that calendar date —
// purely for LABELLING (weekday/month names), never for bucketing math.
function marketDayDate(key) {
  const m = String(key || "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
  return m ? new Date(+m[1], +m[2] - 1, +m[3]) : null;
}
// Today's market day (what "today" means on a trading desk).
function marketToday() { return marketDayKey(Date.now()); }
// Whole trading-calendar days between two market days (b - a), date-only.
function marketDayDiff(aKey, bKey) {
  const a = marketDayDate(aKey), b = marketDayDate(bKey);
  if (!a || !b) return null;
  return Math.round((b.getTime() - a.getTime()) / 864e5);
}
// Convert an ET wall-clock string ("2026-07-17T15:47", as produced by a
// datetime-local input) into epoch ms. Two-pass: read it as if UTC, see what
// that instant looks like in New York, and subtract the resulting offset — which
// picks up EST vs EDT automatically instead of hardcoding -5/-4. Returns null on
// anything unparseable. (The one ambiguous hour when clocks fall back resolves to
// the first occurrence; irrelevant for market hours, which never sit in it.)
function etWallClockToEpoch(str) {
  const s0 = String(str || "").trim();
  // Validate the shape FIRST — Date.parse is lenient enough to turn junk like
  // ":00Z" into a real date, which would silently backdate an entry to 2000.
  if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(s0)) return null;
  const naive = Date.parse(s0 + ":00Z");
  if (!isFinite(naive)) return null;
  const s = new Date(naive).toLocaleString("en-US", {
    timeZone: MARKET_TZ, hour12: false,
    year: "numeric", month: "2-digit", day: "2-digit",
    hour: "2-digit", minute: "2-digit", second: "2-digit",
  });
  const m = s.match(/(\d+)\/(\d+)\/(\d+),?\s+(\d+):(\d+):(\d+)/);
  if (!m) return null;
  const back = Date.UTC(+m[3], +m[1] - 1, +m[2], +m[4] % 24, +m[5], +m[6]);
  return naive - (back - naive);
}
// Inverse: epoch → "YYYY-MM-DDTHH:MM" in ET, for prefilling a datetime-local.
function epochToEtWallClock(ts) {
  const t = _toDate(ts);
  if (!isFinite(t.getTime())) return "";
  const s = t.toLocaleString("en-US", {
    timeZone: MARKET_TZ, hour12: false,
    year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit",
  });
  const m = s.match(/(\d+)\/(\d+)\/(\d+),?\s+(\d+):(\d+)/);
  if (!m) return "";
  return `${m[3]}-${m[1]}-${m[2]}T${String(+m[4] % 24).padStart(2, "0")}:${m[5]}`;
}

// "Today · Mon, Jul 13, 2026" — day-separator label, in MARKET terms.
function marketDayLabel(key) {
  const d = marketDayDate(key);
  if (!d) return "";
  const base = d.toLocaleString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" });
  const delta = marketDayDiff(key, marketToday());
  if (delta === 0) return `Today · ${base}`;
  if (delta === 1) return `Yesterday · ${base}`;
  return base;
}

// ─── Evidence-ledger helpers ────────────────────────────────────────────────
// We have NO market feed: every price on screen is a mark an analyst stated in
// Discord. These helpers make that provenance visible instead of dressing stated
// prices up as live quotes.

// How stale is the last analyst update? Drives the mark's age chip.
//   fresh (<1 trading day) · aging (<2 days) · stale (older)
// Stale is amber, never red — an old mark is unverified, not a loss.
function markAge(ts) {
  const d = Date.now() - _toDate(ts).getTime();
  if (!isFinite(d)) return "fresh";
  if (d < 864e5) return "fresh";
  if (d < 2 * 864e5) return "aging";
  return "stale";
}

// Days to expiry — one of the few genuinely live numbers we can compute
// honestly (contract spec, no feed). Counted in whole TRADING days against the
// current MARKET day: a contract expires at the closing bell in New York, so a
// 7/18 expiry is 0DTE all through 7/18 ET even when the viewer's local clock has
// already rolled to the 19th. null for shares / unparseable dates; negative
// means already past expiry.
function dteOf(expiry) {
  const d = parseExpiry(expiry);
  if (!d) return null;
  const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
  return marketDayDiff(marketToday(), key);
}
// Urgency tier for a DTE: expired · today (0DTE) · soon (≤5d) · "" otherwise.
function dteTier(dte) {
  if (dte == null) return "";
  if (dte < 0) return "expired";
  if (dte === 0) return "today";
  if (dte <= 5) return "soon";
  return "";
}
function dteLabel(dte) {
  if (dte == null) return "";
  if (dte < 0) return "expired";
  if (dte === 0) return "0DTE";
  return dte + "d";
}

// ─── Empty state
function Empty({ msg }) { return <div className="empty">{msg}</div>; }

// API base — same-origin in prod, localhost:3001 in dev
function apiBase() {
  if (typeof window === "undefined") return "";
  const { hostname, origin } = window.location;
  if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "") {
    return "http://localhost:3001";
  }
  return origin;
}

// All API calls go through this so the session cookie is always sent.
function tapeFetch(url, opts = {}) {
  return fetch(url, { credentials: "include", ...opts });
}

// tapeFetch for mutations: throws on HTTP errors (carrying the server's error
// message) so the caller's catch block actually fires — a 4xx/5xx must never
// read as success in the UI.
async function tapeSend(url, opts) {
  const r = await tapeFetch(url, opts);
  if (!r.ok) {
    let msg = "HTTP " + r.status;
    try { const j = await r.json(); if (j && j.error) msg = j.error; } catch (_) {}
    throw new Error(msg);
  }
  return r;
}

// Resolve STATIC Discord avatar URLs for a set of user IDs. Returns a map
// { id: url|null } — null means "no custom avatar / couldn't resolve", so the
// caller should fall back to its own placeholder. Animated avatars come back as
// a static .png frame; nothing ever animates. Results are cached server-side.
// Resolve Discord avatars for a set of ids. The map is CUMULATIVE: results are
// merged, never replaced, and only ids we haven't resolved yet are requested.
// That matters because the id set changes every time the server picker changes —
// replacing the map made already-known faces blink out, and a single failed
// request left them missing until a full page reload.
function useAvatars(ids) {
  const [map, setMap] = useState({});
  const resolved = useRef(new Set());   // ids we've already got an answer for
  const key = useMemo(
    () => Array.from(new Set((ids || []).filter(Boolean).map(String))).sort().join(","),
    [ids]
  );
  useEffect(() => {
    const list = key ? key.split(",") : [];
    const missing = list.filter(id => !resolved.current.has(id));
    if (!missing.length) return;        // nothing new — keep what we have
    let alive = true;
    // The endpoint caps a call at 100 ids, so batch rather than silently losing
    // the overflow once the roster grows past that.
    const batches = [];
    for (let i = 0; i < missing.length; i += 100) batches.push(missing.slice(i, i + 100));
    for (const batch of batches) {
      tapeFetch(apiBase() + "/api/discord/avatars", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ ids: batch }),
      })
        .then(r => (r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status))))
        .then(j => {
          if (!alive) return;           // superseded — leave unresolved so it retries
          const got = j.avatars || {};
          // Remember which ids came back (including a null answer for "no avatar")
          // so we don't re-ask for them on every selection change.
          for (const id of batch) if (id in got) resolved.current.add(id);
          setMap(prev => ({ ...prev, ...got }));
        })
        .catch(() => { /* left unresolved on purpose → retried on the next change */ });
    }
    return () => { alive = false; };
  }, [key]);
  return map;
}

// Render a Discord avatar image when one resolved, else a caller-supplied
// fallback. Self-heals: if the image 404s it drops back to the fallback.
function DiscordAvatar({ url, size = 30, round = true, className, style, fallback = null }) {
  const [broken, setBroken] = useState(false);
  useEffect(() => { setBroken(false); }, [url]);
  if (url && !broken) {
    return (
      <img
        src={url} alt="" width={size} height={size} className={className}
        onError={() => setBroken(true)}
        style={{ borderRadius: round ? "50%" : 5, objectFit: "cover", flexShrink: 0, display: "block", ...style }}
      />
    );
  }
  return fallback;
}

// True when the viewport is phone-sized — lets a component render a dedicated
// mobile layout instead of fighting a CSS table-to-card transform.
function useIsMobile(maxWidth = 900) {
  const q = `(max-width: ${maxWidth}px)`;
  const [m, setM] = useState(() => typeof window !== "undefined" && window.matchMedia(q).matches);
  useEffect(() => {
    const mq = window.matchMedia(q);
    const on = () => setM(mq.matches);
    on();
    mq.addEventListener ? mq.addEventListener("change", on) : mq.addListener(on);
    return () => { mq.removeEventListener ? mq.removeEventListener("change", on) : mq.removeListener(on); };
  }, [q]);
  return m;
}

// Export to window so other babel scripts can use them
// ─── Recycle bin — shared by the Events + Watchlist tabs' "Deleted" view.
// Lists /api/trash for ONE source table and offers per-row Restore /
// Delete-forever plus Empty bin. `onRestored(rows)` hands the restored,
// already-client-shaped rows back so the parent can slot them into its live list
// immediately (the live event poll is forward-only and won't re-surface them).
function TrashList({ source, serverIds, onRestored, noun = "item" }) {
  const [rows, setRows] = useState(null);   // null = still loading
  const [ttl, setTtl] = useState(30);

  const load = useCallback(() => {
    const qs = "source=" + encodeURIComponent(source) + (serverIds ? "&server_ids=" + serverIds : "");
    tapeFetch(apiBase() + "/api/trash?" + qs)
      .then(r => r.json())
      .then(j => { setRows(j.entries || []); if (j.ttlDays) setTtl(j.ttlDays); })
      .catch(() => setRows([]));
  }, [source, serverIds]);
  useEffect(() => { load(); }, [load]);

  async function restore(t) {
    try {
      const r = await tapeSend(apiBase() + "/api/trash/" + encodeURIComponent(t.id) + "/restore", { method: "POST" });
      const j = await r.json();
      const row = j.event || j.entry;
      setRows(prev => (prev || []).filter(x => x.id !== t.id));
      if (row && onRestored) onRestored([row]);
      toast.success("Restored");
    } catch (e) { toast.error("Restore failed — " + e.message); }
  }
  async function purge(t) {
    if (!(await confirmDialog({ title: "Delete forever", message: "Permanently delete this " + noun + "? This can't be undone.", confirmLabel: "Delete forever", danger: true }))) return;
    try {
      await tapeSend(apiBase() + "/api/trash/" + encodeURIComponent(t.id), { method: "DELETE" });
      setRows(prev => (prev || []).filter(x => x.id !== t.id));
      toast.success("Deleted forever");
    } catch (e) { toast.error("Failed — " + e.message); }
  }
  async function empty() {
    if (!(await confirmDialog({ title: "Empty bin", message: "Permanently delete everything in this recycle bin? This can't be undone.", confirmLabel: "Empty bin", danger: true, requireText: "EMPTY" }))) return;
    try {
      // Scope the wipe to the SAME servers the list is showing (server_ids is a
      // query param on the API side), so Empty never clears servers out of view.
      const qs = serverIds ? "?server_ids=" + serverIds : "";
      const r = await tapeSend(apiBase() + "/api/trash/empty" + qs, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ source }) });
      const j = await r.json();
      setRows([]);
      toast.success("Emptied " + (j.emptied || 0) + " " + noun + ((j.emptied === 1) ? "" : "s"));
    } catch (e) { toast.error("Failed — " + e.message); }
  }

  if (rows === null) return <div className="trash-note muted">Loading…</div>;
  return (
    <div className="trash-wrap">
      <div className="trash-bar">
        <span className="muted">{rows.length ? (rows.length + " deleted " + noun + (rows.length === 1 ? "" : "s")) : "Recycle bin is empty"} · auto-removed after {ttl} days</span>
        <span style={{ flex: 1 }} />
        {rows.length > 0 && <button className="btn danger" onClick={empty}>{I("trash", { size: 13 })} Empty bin</button>}
      </div>
      {rows.length === 0 ? (
        <Empty msg={"Nothing deleted. Deleted " + noun + "s land here for " + ttl + " days so you can restore them."} />
      ) : rows.map(t => (
        <div className="trash-row" key={t.id}>
          <div className="trash-row-main">
            <div className="trash-label">{t.label || t.originalId}</div>
            <div className="trash-sub mono">deleted {fmtAgo(t.deletedAt)}{t.deletedByName ? " · by " + t.deletedByName : ""}</div>
          </div>
          <div className="trash-row-acts">
            <button className="icon-btn" title="Restore" onClick={() => restore(t)}>{I("refresh", { size: 14 })}<span className="btn-label">Restore</span></button>
            <button className="icon-btn danger" title="Delete forever" onClick={() => purge(t)}>{I("trash", { size: 14 })}<span className="btn-label">Delete forever</span></button>
          </div>
        </div>
      ))}
    </div>
  );
}

Object.assign(window, {
  Icon, I, Icons, cx, fmtTime, fmtTimeOnly, fmtDay, fmtAgo, recencyBucket, fmtDayGroup, dayKey, fmtExpiry, parseExpiry, fmtNum, fmtCurrency, fmtPrice, ccySym, fmtPct,
  StatusPill, ActionPill, ConfPill, AnalystChip, Empty, TrashList,
  markAge, dteOf, dteTier, dteLabel,
  MARKET_TZ, marketDayKey, marketDayNum, marketDayDate, marketToday, marketDayDiff, marketDayLabel,
  etWallClockToEpoch, epochToEtWallClock,
  isOption, contractText, strikeText, InstrumentTag, InstrumentCell, InstrumentFilter, matchesInstrument,
  AnalystFilter, analystMatch,
  useState, useEffect, useMemo, useRef, useCallback, Fragment,
  apiBase, tapeFetch, tapeSend, useAvatars, DiscordAvatar, useIsMobile,
});
