// news.jsx — Market News as a heatmap.
//
// THE SHAPE: a strip of lead stories and a ticker search across the top, a two-level treemap
// below (sector groups, each containing one cell per newsworthy ticker), and — once you click
// something — that selection's headlines underneath. Nothing is a river of text until asked for.
//
// WHAT THE TILES ENCODE, and the one honest caveat:
//   size   = how many of today's stories name that ticker / sector.
//   colour = that ticker's move today.
// TradingView sizes by MARKET CAP. Alpaca does not give us market cap, so that cannot be
// copied literally — and on a news page it would be the wrong weight anyway: the question
// here is "where is today's news landing", not "what is the index made of". Story volume
// answers that and needs nothing we do not already have. A big red tile therefore means
// "a lot is being written about this and it is down", which is the sentence a reader wants.
//
// COLOUR uses the app's own semantic P&L tokens (--c-up / --c-down), mixed toward the THEME'S
// OWN surface rather than to a fixed white or black. That is what lets one code path read
// correctly in light and dark, and per the branding rules P&L colours are never rebranded per
// client — so the heatmap reads identically in every white-label, which is the point of a
// signal colour.
//
// The desk join still happens HERE, in the browser, never on the server. Which tickers an
// analyst holds is per-server data; the API returns only public headlines and public quotes
// about a static symbol list, identical for every user. Moving the join server-side would make
// a cached public response tenant-specific and turn it into a cross-tenant leak.
//
// Deliberately absent, because each is the tell of a generic feed: thumbnails, an icon rail,
// a hero, a skeleton loader (this app has none anywhere), per-child entrance animation
// (styles.css carries a written decision against it).

// app.jsx renders tabs as {tab === "news" && <NewsTab/>}, so leaving the tab UNMOUNTS this and
// coming back would re-download the whole payload (~80 KB) every time — measured, not guessed.
// The server already holds each sweep for a 5-minute slot, so a second fetch inside that window
// returns byte-identical data anyway; this just stops paying for it on the wire. Module-level
// rather than component state precisely because the component does not survive.
let _newsMemo = null;   // { at, payload }
const NEWS_MEMO_MS = 5 * 60e3;

// ── Squarified treemap (Bruls, Huizing & van Wijk, 2000) ─────────────────────
// Lays weighted items into a rectangle, preferring near-square cells. Worked in REAL PIXELS,
// not percentages: squarifying in a normalised space and then stretching it to the container
// silently defeats the algorithm, because "as square as possible" is a statement about the
// RENDERED aspect ratio. That is why NewsMap measures itself first.
function squarify(items, x, y, w, h) {
  const out = [];
  const total = items.reduce((s, i) => s + (i.weight || 0), 0);
  if (!(total > 0) || !(w > 0) || !(h > 0)) return out;

  // Descending is what makes the layout stable and keeps the big cells square.
  let rest = items
    .filter(i => i.weight > 0)
    .slice()
    .sort((a, b) => b.weight - a.weight)
    .map(i => ({ item: i, area: (i.weight / total) * w * h }));

  let rx = x, ry = y, rw = w, rh = h;
  const worst = (row, side) => {
    const s = row.reduce((a, b) => a + b.area, 0);
    if (!(s > 0) || !(side > 0)) return Infinity;
    let mx = -Infinity, mn = Infinity;
    for (const r of row) { if (r.area > mx) mx = r.area; if (r.area < mn) mn = r.area; }
    if (!(mn > 0)) return Infinity;
    return Math.max((side * side * mx) / (s * s), (s * s) / (side * side * mn));
  };

  // The loop always consumes at least one item, but a guard keeps a pathological weight set
  // from hanging the tab rather than merely looking wrong.
  let guard = 0;
  while (rest.length && guard++ < 5000) {
    const side = Math.min(rw, rh);
    let row = [rest[0]], i = 1;
    while (i < rest.length) {
      const next = row.concat([rest[i]]);
      if (worst(next, side) > worst(row, side)) break;
      row = next; i++;
    }
    const s = row.reduce((a, b) => a + b.area, 0);
    if (rw >= rh) {
      const stripW = rh > 0 ? s / rh : rw;
      let cy = ry;
      for (const r of row) {
        const hh = stripW > 0 ? r.area / stripW : 0;
        out.push({ ...r.item, x: rx, y: cy, w: stripW, h: hh });
        cy += hh;
      }
      rx += stripW; rw -= stripW;
    } else {
      const stripH = rw > 0 ? s / rw : rh;
      let cx = rx;
      for (const r of row) {
        const ww = stripH > 0 ? r.area / stripH : 0;
        out.push({ ...r.item, x: cx, y: ry, w: ww, h: stripH });
        cx += ww;
      }
      ry += stripH; rh -= stripH;
    }
    rest = rest.slice(row.length);
  }
  return out;
}

// A cell's fill from its day move. ±3% saturates: past that the eye cannot read more
// intensity anyway, and letting one outlier own the scale flattens everything else — the same
// reason the Recaps chart uses a signed-log scale.
function moveFill(pct) {
  if (pct == null || !isFinite(pct)) return "var(--bg-2)";
  const t = Math.min(1, Math.abs(pct) / 3);
  const mix = Math.round(14 + t * 54);
  return "color-mix(in oklab, var(" + (pct >= 0 ? "--c-up" : "--c-down") + ") " + mix + "%, var(--bg-2))";
}
const fmtChg = (p) => (p == null || !isFinite(p)) ? "" : (p >= 0 ? "+" : "") + p.toFixed(2) + "%";

function NewsTab({ positions = [] }) {
  const fresh = _newsMemo && Date.now() - _newsMemo.at < NEWS_MEMO_MS ? _newsMemo.payload : null;
  const [data, setData] = React.useState(fresh);
  const [err, setErr] = React.useState(null);
  const [loading, setLoading] = React.useState(!fresh);
  const [tick, setTick] = React.useState(0);
  const [sel, setSel] = React.useState(null);        // { kind: sector|ticker|search, key }
  const [term, setTerm] = React.useState("");
  const [found, setFound] = React.useState(null);    // { symbol, news, quote, loading }
  const drillRef = React.useRef(null);

  React.useEffect(() => {
    if (tick === 0 && fresh) return;                 // a warm memo already satisfied the mount
    let alive = true;
    setLoading(true);
    tapeFetch(apiBase() + "/api/news/sectors")
      .then(r => r.json())
      .then(j => {
        if (j && j.sectors && j.sectors.length) _newsMemo = { at: Date.now(), payload: j };
        if (!alive) return;
        setData(j); setErr(j && j.stale ? "stale" : null); setLoading(false);
      })
      .catch(e => { if (!alive) return; setErr(e.message || "failed"); setLoading(false); });
    return () => { alive = false; };
  }, [tick]);

  const quotes = (data && data.quotes) || {};

  // The desk: every ticker the (already scope-filtered, already analyst-filtered) positions
  // touch, mapped to who holds it. A Set would draw the marker, but the rows print WHO —
  // that attribution is the whole point.
  const desk = React.useMemo(() => {
    const m = new Map();
    for (const p of positions || []) {
      const t = String(p.ticker || "").toUpperCase();
      if (!t) continue;
      if (!m.has(t)) m.set(t, []);
      m.get(t).push({ analyst: p.analyst, status: p.status });
    }
    return m;
  }, [positions]);

  // One pass over the payload builds everything the page needs: the deduped story list, a
  // ticker → stories index for drill-down, and per-sector ticker weights for the treemap.
  const model = React.useMemo(() => {
    const sectors = (data && data.sectors) || [];
    const byId = new Map();
    const byTicker = new Map();
    const groups = [];

    for (const s of sectors) {
      const counts = new Map();
      for (const it of (s.items || [])) {
        if (!byId.has(it.id)) byId.set(it.id, it);
        // Counted against THIS SECTOR's symbols. Counting every symbol on the story instead
        // put a SPY cell inside Semiconductors and — because nearly every desk holds SPY —
        // marked about half of every page, which distinguishes nothing.
        for (const raw of (it.sectorSymbols || [])) {
          const sym = String(raw).toUpperCase();
          counts.set(sym, (counts.get(sym) || 0) + 1);
          if (!byTicker.has(sym)) byTicker.set(sym, new Map());
          byTicker.get(sym).set(it.id, it);
        }
      }
      const cells = [...counts.entries()].map(([sym, n]) => ({
        sym, weight: n, chg: quotes[sym] != null ? quotes[sym] : null, held: desk.has(sym),
      }));
      // Story-weighted, so a sector's colour reflects what is being WRITTEN about rather than
      // an equal vote from a name nobody mentioned today.
      let num = 0, den = 0;
      for (const c of cells) if (c.chg != null) { num += c.chg * c.weight; den += c.weight; }
      groups.push({
        name: s.name,
        items: s.items || [],
        cells,
        weight: (s.items || []).length,
        chg: den > 0 ? num / den : null,
        deskCount: (s.items || []).filter(it =>
          (it.sectorSymbols || []).some(x => desk.has(String(x).toUpperCase()))).length,
      });
    }
    return { groups, byId, byTicker };
  }, [data, desk, quotes]);

  // Lead stories. Touching the desk dominates, then breadth (one story naming several names is
  // the interesting kind), then freshness.
  const lead = React.useMemo(() => {
    const all = [...model.byId.values()];
    const now = Date.now();
    const score = (it) => {
      const hits = (it.symbols || []).filter(x => desk.has(String(x).toUpperCase())).length;
      const ageH = it.ts ? (now - it.ts) / 3600e3 : 48;
      return hits * 100 + Math.min((it.symbols || []).length, 6) * 6 - ageH * 1.5;
    };
    return all.sort((a, b) => score(b) - score(a)).slice(0, 3);
  }, [model, desk]);

  const runSearch = React.useCallback((raw) => {
    const symbol = String(raw || "").toUpperCase().replace(/[^A-Z0-9.]/g, "").slice(0, 12);
    if (!symbol) return;
    setSel({ kind: "search", key: symbol });
    setFound({ symbol, news: null, quote: null, loading: true });
    tapeFetch(apiBase() + "/api/news/ticker?symbol=" + encodeURIComponent(symbol))
      .then(r => r.json())
      .then(j => setFound({ symbol, news: (j && j.news) || [], quote: j && j.quote, loading: false }))
      .catch(() => setFound({ symbol, news: [], quote: null, loading: false }));
  }, []);

  // What the panel under the map is showing.
  const shown = React.useMemo(() => {
    if (!sel) return null;
    if (sel.kind === "search") {
      return {
        title: sel.key, sub: "search",
        chg: found && found.quote != null ? found.quote : (quotes[sel.key] != null ? quotes[sel.key] : null),
        items: (found && found.news) || [],
        loading: !!(found && found.loading),
      };
    }
    if (sel.kind === "ticker") {
      const m = model.byTicker.get(sel.key);
      return {
        title: sel.key, sub: "ticker",
        chg: quotes[sel.key] != null ? quotes[sel.key] : null,
        items: m ? [...m.values()].sort((a, b) => (b.ts || 0) - (a.ts || 0)) : [],
        loading: false,
      };
    }
    const g = model.groups.find(x => x.name === sel.key);
    return g ? { title: g.name, sub: "sector", chg: g.chg, items: g.items, loading: false } : null;
  }, [sel, model, found, quotes]);

  // Bring the stories into view when something is selected. Without this the feature reads as
  // broken: the map fills the scroll container, so the panel opens just past its bottom edge
  // and nothing moves. Measured at 1440x900 before this existed: .content is 816px of visible
  // height and the panel's top landed at 815px, with 1395px of it hidden below and scrollTop
  // still 0. Clicking a tile and searching a ticker BOTH looked like dead controls.
  //
  // scrollIntoView walks to whichever ancestor actually scrolls, which matters because that is
  // .content on desktop and the document itself below 900px.
  React.useEffect(() => {
    if (!sel || !drillRef.current) return;
    let quiet = false;
    try { quiet = window.matchMedia("(prefers-reduced-motion: reduce)").matches; } catch (_) {}
    drillRef.current.scrollIntoView({ behavior: quiet ? "auto" : "smooth", block: "start" });
  }, [sel]);

  const totalItems = model.byId.size;
  const totalDesk = model.groups.reduce((n, g) => n + g.deskCount, 0);

  if (data && data.enabled === false) {
    return (
      <div className="panel">
        <div className="panel-hdr"><span className="panel-title">Market news</span></div>
        <div className="empty">Market data isn’t switched on for this deployment, so there’s no news feed to show.</div>
      </div>
    );
  }

  return (
    <div className="panel news-paper">
      <div className="panel-hdr">
        <span className="panel-title">Market news</span>
        <div className="panel-hdr-tools">
          <span className="panel-meta">
            {loading ? "LOADING" : err ? "COULDN’T REFRESH" :
              totalItems + " STORIES · " + model.groups.length + " SECTORS" +
              (totalDesk ? " · " + totalDesk + " ON YOUR DESK" : "")}
          </span>
          <button className="icon-btn" title="Refresh" onClick={() => setTick(t => t + 1)} disabled={loading}>
            {I("refresh", { size: 14 })}
          </button>
        </div>
      </div>

      {loading && !data && <div className="empty">Fetching the wire…</div>}

      {!loading && !model.groups.length && (
        <div className="empty">
          {err ? "Couldn’t reach the news feed just now — try again in a minute."
               : "Nothing on the wire for the names tracked here in the last day and a half."}
        </div>
      )}

      {!!model.groups.length && (
        <React.Fragment>
          <div className="news-strip">
            <NewsSearchTile term={term} setTerm={setTerm} onSearch={runSearch}
              active={!!sel && sel.kind === "search"} />
            {lead.map(it => (
              <NewsLead key={it.id} it={it} desk={desk}
                onPick={(sym) => setSel({ kind: "ticker", key: sym })} />
            ))}
          </div>

          <NewsMap groups={model.groups} sel={sel} onSelect={setSel} />

          <div className="news-legend mono">
            <span>TILE SIZE · STORIES TODAY</span>
            <span className="news-legend-scale">
              <i style={{ background: moveFill(-3) }} /><i style={{ background: moveFill(-1.2) }} />
              <i style={{ background: moveFill(0) }} />
              <i style={{ background: moveFill(1.2) }} /><i style={{ background: moveFill(3) }} />
            </span>
            <span>CELL COLOUR · THAT TICKER TODAY</span>
          </div>
        </React.Fragment>
      )}

      {shown && (
        <div className="news-drill" ref={drillRef}>
          <div className="news-drill-hd">
            <button className="icon-btn" title="Close" onClick={() => setSel(null)}>{I("close", { size: 14 })}</button>
            <span className="news-drill-title">{shown.title}</span>
            <span className="news-drill-kind mono">{shown.sub}</span>
            {shown.chg != null && shown.sub !== "sector" && (
              <span className={cx("news-drill-chg mono", shown.chg >= 0 ? "up" : "down")}>{fmtChg(shown.chg)}</span>
            )}
            <span className="news-drill-n mono">{shown.loading ? "…" : shown.items.length + " STORIES"}</span>
          </div>
          {shown.loading && <div className="empty">Looking up {shown.title}…</div>}
          {!shown.loading && !shown.items.length && (
            <div className="empty">Nothing on the wire for {shown.title} in the last day and a half.</div>
          )}
          {shown.items.map(it => <NewsRow key={shown.title + ":" + it.id} it={it} desk={desk} />)}
        </div>
      )}
    </div>
  );
}

// The search tile. Deliberately the ACCENT, not a P&L colour: red and green mean "the market
// moved" everywhere else in this app, and a control that turned green would be lying.
function NewsSearchTile({ term, setTerm, onSearch, active }) {
  return (
    <form className={cx("news-tile-search", active && "on")}
      onSubmit={(e) => { e.preventDefault(); onSearch(term); }}>
      <div className="news-tile-search-hd mono">{I("search", { size: 12 })} TICKER</div>
      <div className="news-tile-search-row">
        <input
          className="news-tile-search-in"
          value={term}
          onChange={(e) => setTerm(e.target.value)}
          placeholder="NVDA"
          aria-label="Search news by ticker"
          spellCheck={false}
          autoCapitalize="characters"
        />
        <button className="btn btn-sm" type="submit" disabled={!term.trim()}>News</button>
      </div>
    </form>
  );
}

// A lead story: bigger type than a list row and no summary — it is a pointer, not the read.
function NewsLead({ it, desk, onPick }) {
  const syms = (it.symbols || []).map(s => String(s).toUpperCase());
  const hits = syms.filter(s => desk.has(s));
  const shown = (hits.length ? hits : syms).slice(0, 4);
  return (
    <a className={cx("news-tile-lead", hits.length && "on-desk")}
      href={it.url || "#"} target="_blank" rel="noopener noreferrer">
      <div className="news-tile-lead-hl">{it.headline}</div>
      <div className="news-tile-lead-meta">
        {shown.map(s => (
          <span key={s} className={cx("news-tk", hits.includes(s) && "held")}
            onClick={(e) => { e.preventDefault(); e.stopPropagation(); onPick(s); }}>{s}</span>
        ))}
        <span className="news-when">{relAge(it.ts)}</span>
      </div>
    </a>
  );
}

// The heatmap. Measures itself in real pixels via ResizeObserver, then squarifies — see the
// note on squarify() for why normalised coordinates would quietly ruin the aspect ratios.
function NewsMap({ groups, sel, onSelect }) {
  const ref = React.useRef(null);
  const [box, setBox] = React.useState({ w: 0, h: 0 });

  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const read = () => setBox({ w: el.clientWidth, h: el.clientHeight });
    read();
    // ResizeObserver catches the sidebar drawer, the server picker resizing the column and a
    // phone rotating; a window resize listener alone misses all three.
    if (typeof ResizeObserver === "undefined") {
      window.addEventListener("resize", read);
      return () => window.removeEventListener("resize", read);
    }
    const ro = new ResizeObserver(read);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const laid = React.useMemo(
    () => ((box.w > 0 && box.h > 0) ? squarify(groups, 0, 0, box.w, box.h) : []),
    [groups, box]
  );

  return (
    <div className="news-map" ref={ref}>
      {laid.map(g => {
        const HD = 22;                                  // the group's name band
        const innerH = g.h - HD - 4;
        // Below this the ticker cells would be unreadable confetti, so the group stays a
        // single labelled block instead. Its name and move still read, which is the point.
        const cells = (innerH > 26 && g.w > 60) ? squarify(g.cells, 0, 0, g.w - 4, innerH) : [];
        const on = !!sel && ((sel.kind === "sector" && sel.key === g.name) ||
                             (sel.kind === "ticker" && g.cells.some(c => c.sym === sel.key)));
        return (
          <div key={g.name}
            className={cx("news-grp", on && "on", g.deskCount && "has-desk")}
            style={{ left: g.x + "px", top: g.y + "px", width: g.w + "px", height: g.h + "px" }}>
            <button className="news-grp-hd"
              title={g.name + " · " + g.weight + (g.weight === 1 ? " story" : " stories") +
                (g.chg != null ? " · the names in the news here average " + fmtChg(g.chg) + " today" : "") +
                " — click for all its headlines"}
              onClick={() => onSelect({ kind: "sector", key: g.name })}>
              <span className="news-grp-name">{g.name}</span>
              <span className="news-grp-chg mono">{g.weight}</span>
            </button>
            <div className="news-grp-body">
              {cells.map(c => {
                const tiny = c.w < 48 || c.h < 28;
                return (
                  <button key={c.sym}
                    className={cx("news-cell", c.held && "held",
                      sel && sel.kind === "ticker" && sel.key === c.sym && "on")}
                    style={{
                      left: c.x + "px", top: c.y + "px",
                      width: Math.max(0, c.w - 2) + "px", height: Math.max(0, c.h - 2) + "px",
                      background: moveFill(c.chg),
                    }}
                    title={c.sym + " " + fmtChg(c.chg) + " · " + c.weight +
                      (c.weight === 1 ? " story" : " stories") + (c.held ? " · on your desk" : "")}
                    onClick={() => onSelect({ kind: "ticker", key: c.sym })}>
                    <span className="news-cell-sym">{c.sym}</span>
                    {!tiny && <span className="news-cell-chg mono">{fmtChg(c.chg)}</span>}
                  </button>
                );
              })}
            </div>
          </div>
        );
      })}
    </div>
  );
}

function NewsRow({ it, desk }) {
  const syms = (it.symbols || []).map(s => String(s).toUpperCase());
  const hits = syms.filter(s => desk.has(s));
  const shown = [...hits, ...syms.filter(s => !hits.includes(s))].slice(0, 5);
  return (
    <a className={cx("news-row", hits.length && "on-desk")} href={it.url || "#"} target="_blank" rel="noopener noreferrer">
      <div className="news-row-main">
        <div className="news-hl">{it.headline}</div>
        {it.summary && <div className="news-sum">{it.summary}</div>}
        <div className="news-meta">
          {shown.map(s => <span key={s} className={cx("news-tk", hits.includes(s) && "held")}>{s}</span>)}
          {syms.length > shown.length && <span className="news-more">+{syms.length - shown.length}</span>}
          <span className="news-when">{relAge(it.ts)}</span>
        </div>
      </div>
    </a>
  );
}

// "4m" / "3h" / "2d" — the app prints ages in mono shorthand everywhere else.
function relAge(ts) {
  if (!ts) return "";
  const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
  if (s < 90) return "just now";
  const m = Math.floor(s / 60);
  if (m < 60) return m + "m";
  const h = Math.floor(m / 60);
  if (h < 48) return h + "h";
  return Math.floor(h / 24) + "d";
}

Object.assign(window, { NewsTab });
