// OrbitCosmo reference layer — pure, dependency-free logic.
//
// The pages your pages POINT AT: scholarly citations (DOI / arXiv) and
// substantive in-body links, mined from stored snapshots. Directed
// `reference` edges are the derived FACTS (source page cites target);
// proj.ghosts is the materialized top-GHOST_MAX PRESENTATION of unvisited
// targets — a budget-evicted target goes dormant (its edges survive) and is
// re-minted with its full referrer count when any page cites it again.
// rankReadingQueue turns the live ghosts into the emergent reading queue
// ("referenced by four things you've read, never opened").
//
// The bright line (TODO.md Phase 4): OrbitCosmo reads what's rendered, it never
// requests. Everything here works off snapshot.refs (captured from the DOM
// by content.js) and snapshot.content — no URL in this file is ever fetched.
// References are DERIVED: improving extraction later + a replay re-yields
// them from every old snapshot (older snapshots lack `refs` entirely; the
// pattern pass still mines their stored content).
//
// Kept free of any chrome/* access so the service worker can import it (as
// an ES side-effect module) and Node tests can require() it
// (tools/test-references.js). Storage and orchestration live in
// store.js/pipeline.js.

(function (root) {
  "use strict";

  // Ghost budget — separate from the (now deleted) page cap: title + URL
  // only, ~200 bytes each. pruneGhosts evicts beyond it (edges survive —
  // see the fact/presentation split at pruneGhosts).
  const GHOST_MAX = 1000;

  // Per-page reference cap: one publisher page can render a 1,500-entry
  // bibliography — uncapped, a single visit would flush the entire ghost
  // budget. Scholarly identifiers outrank in-body anchors when truncating.
  // This also bounds reference-edge growth at REFS_PER_PAGE × live pages.
  const REFS_PER_PAGE = 40;

  // A real DOI suffix is short; anything longer is markup soup that would
  // also break the "~200 bytes per ghost" budget math.
  const DOI_MAX_CHARS = 300;

  // How many ghosts rankReadingQueue returns.
  const REF_QUEUE_SIZE = 50;

  // Recency half-life for the queue: a reference last seen a month ago
  // carries roughly half the pull of one seen today.
  const REF_HALFLIFE_DAYS = 28;

  // A DOI is "10.<registrant>/<suffix>"; the suffix runs to whitespace or
  // markup, so trailing sentence punctuation must be stripped afterwards.
  const DOI_RE = /\b10\.\d{4,9}\/[^\s"'<>]+/g;
  const TRAIL_PUNCT_RE = /[.,;:!?)\]}>"']+$/;

  // Require the "arXiv:" prefix — a bare NNNN.NNNNN in prose (coordinates,
  // version numbers, prices) would false-positive constantly. Version
  // suffixes are matched but stripped: the abs page is version-blind.
  const ARXIV_RE = /arXiv:\s*(\d{4}\.\d{4,5})(?:v\d+)?/gi;

  function refEdgeKey(source, target) {
    return `ref::${source}=>${target}`;
  }

  function domainOf(url) {
    try {
      return new URL(url).hostname;
    } catch {
      return "";
    }
  }

  // The bare arXiv id inside a meta value ("2101.12345", "arXiv:2101.12345v2").
  function arxivIdOf(s) {
    const m = /(\d{4}\.\d{4,5})/.exec(s || "");
    return m ? m[1] : "";
  }

  // Whitespace and bidi/control characters have no business in a hostname —
  // normalizeUrl returns its input verbatim when new URL throws, so without
  // this gate "https://exa mple.com/x" or a U+202E-spoofed host would become
  // a ghost id.
  const BAD_HOST_RE = /[\s\u200e\u200f\u202a-\u202e\u2066-\u2069]/;

  // Mine one snapshot for the pages it references → [{ url, title }],
  // deduped by canonical url (longest non-empty title wins — same rule the
  // ghost upsert applies across folds), capped at REFS_PER_PAGE. Sources, in
  // truncation-priority order:
  //   - pattern pass over snapshot.content AND each refs.citations string:
  //     DOIs (lowercased — DOIs are case-insensitive per spec, so
  //     10.1/AbC and 10.1/abc are one ghost) → https://doi.org/<doi>,
  //     arXiv ids → https://arxiv.org/abs/<id>
  //     (title = the citation string when mined from one, else "")
  //   - refs.anchors, in captured order (title = anchor text)
  // Excluded: the page itself (canonical url match, or DOI/arXiv matching
  // refs.meta — a paper's landing page cites its own DOI everywhere), SERPs
  // (deps.isSearchUrl), non-http(s), unparseable/spoofed hosts. ISBNs are
  // deliberately absent: no canonical URL scheme decided yet (TODO.md
  // Phase 4).
  function extractReferences(snapshot, deps) {
    const out = new Map(); // canonical url -> title, insertion order = priority
    const refs = snapshot.refs || null;
    const meta = (refs && refs.meta) || {};
    const selfDoi = (meta.doi || "").trim().toLowerCase();
    const selfArxiv = arxivIdOf(meta.arxiv);

    function add(rawUrl, title) {
      if (typeof rawUrl !== "string" || !/^https?:/i.test(rawUrl)) return;
      const url = deps.normalizeUrl(rawUrl);
      let host;
      try {
        const u = new URL(url);
        if (u.protocol !== "http:" && u.protocol !== "https:") return;
        host = u.hostname;
      } catch {
        return; // normalizeUrl passed junk through verbatim
      }
      if (BAD_HOST_RE.test(host)) return;
      if (url === snapshot.url) return;
      if (deps.isSearchUrl(url)) return;
      const t = (title || "").replace(/\s+/g, " ").trim().slice(0, 140);
      const prev = out.get(url);
      if (prev === undefined || t.length > prev.length) out.set(url, t);
    }

    function minePatterns(text, title) {
      for (const m of text.matchAll(DOI_RE)) {
        const doi = m[0].replace(TRAIL_PUNCT_RE, "").toLowerCase();
        if (doi.length > DOI_MAX_CHARS) continue;
        if (selfDoi && doi === selfDoi) continue;
        add("https://doi.org/" + doi, title);
      }
      for (const m of text.matchAll(ARXIV_RE)) {
        if (m[1] === selfArxiv) continue;
        add("https://arxiv.org/abs/" + m[1], title);
      }
    }
    // Scholarly identifiers first: when REFS_PER_PAGE truncates, a DOI in
    // the bibliography outranks the page's Nth in-body link. A url mined by
    // both keeps its pattern-pass slot (Map insertion order is stable) while
    // the anchor may still upgrade its title.
    minePatterns(snapshot.content || "", "");
    for (const c of (refs && refs.citations) || []) {
      if (typeof c === "string") minePatterns(c, c);
    }
    for (const a of (refs && refs.anchors) || []) {
      if (a) add(a.href, a.text);
    }

    return [...out.entries()]
      .slice(0, REFS_PER_PAGE)
      .map(([url, title]) => ({ url, title }));
  }

  // Fold ONE page's references into the projection — the incremental,
  // per-source pass (the related.js single-node pattern): delete every
  // existing reference edge FROM `url`, re-derive from the snapshot,
  // reinsert. Delete-then-reinsert makes the fold idempotent, which replay
  // needs — a page visited 5 times folds its references 5 times.
  //
  // SERP sources fold no references at all: a results page "cites" every
  // result it lists — the same false-hub failure the navigation/covisit
  // exclusions target.
  //
  // Targets not in proj.nodes get/refresh a ghost; targets that ARE real
  // nodes just get the edge (reference edges between two visited pages are
  // legitimate). Timestamps come from the event (ts), never the clock —
  // replay determinism, like every other fold.
  function foldReferences(proj, url, snapshot, ts, deps) {
    for (const key of Object.keys(proj.edges)) {
      const e = proj.edges[key];
      if (e.type === "reference" && e.source === url) delete proj.edges[key];
    }
    if (!deps.isSearchUrl(url)) {
      for (const ref of extractReferences(snapshot, deps)) {
        proj.edges[refEdgeKey(url, ref.url)] = {
          source: url, target: ref.url, type: "reference", weight: 1, lastSeen: ts,
        };
        if (!proj.nodes[ref.url]) {
          const g = proj.ghosts[ref.url];
          if (g) {
            g.lastSeen = ts;
            if (ref.title.length > (g.title || "").length) g.title = ref.title;
          } else {
            proj.ghosts[ref.url] = {
              url: ref.url,
              title: ref.title,
              domain: domainOf(ref.url),
              firstSeen: ts,
              lastSeen: ts,
            };
          }
        }
      }
    }
    pruneGhosts(proj);
  }

  // Ghost accounting — the fact/presentation split. Reference EDGES are the
  // derived facts: an edge dies only when its source page's fold no longer
  // yields it (delete-then-reinsert in foldReferences) or its source node is
  // gone (step 1 here) — NEVER on budget eviction, or a recurring target's
  // referrer count would restart at 1 every time the budget churned past it.
  // proj.ghosts is just the materialized top-GHOST_MAX presentation of the
  // unvisited targets, recounted strictly from the edges every fold:
  //   1. reference edges whose citing page no longer exists are dropped;
  //   2. a ghost dies with its last referrer — zero edges targeting it;
  //   3. over GHOST_MAX, evict lowest referrer count, then oldest
  //      newest-referring-edge lastSeen, then url asc — fully deterministic
  //      regardless of insertion order, so replay converges on the same
  //      ghosts. An evicted ghost goes DORMANT: its edges survive, and when
  //      a later page cites the same target foldReferences re-mints it —
  //      this recount then restores its true refCount, old citers included.
  // Invariant: a reference edge targets a real node, a live ghost, or a
  // dormant (evicted) url — the wire (store.js shapeForWire) ships the first
  // two and keeps dormant-target edges worker-side. Edge growth stays
  // bounded at REFS_PER_PAGE × live pages.
  function pruneGhosts(proj) {
    const { nodes, edges, ghosts } = proj;
    for (const key of Object.keys(edges)) {
      const e = edges[key];
      if (e.type === "reference" && !nodes[e.source]) delete edges[key];
    }
    const refCount = Object.create(null);
    const newest = Object.create(null); // newest referring edge's lastSeen
    for (const key in edges) {
      const e = edges[key];
      if (e.type !== "reference" || nodes[e.target]) continue;
      refCount[e.target] = (refCount[e.target] || 0) + 1;
      if (!(e.target in newest) || e.lastSeen > newest[e.target]) {
        newest[e.target] = e.lastSeen;
      }
    }
    for (const u of Object.keys(ghosts)) {
      if (!refCount[u]) delete ghosts[u];
    }
    const urls = Object.keys(ghosts);
    if (urls.length > GHOST_MAX) {
      urls.sort(
        (a, b) =>
          refCount[a] - refCount[b] ||
          newest[a] - newest[b] ||
          (a < b ? -1 : 1)
      );
      for (const u of urls.slice(0, urls.length - GHOST_MAX)) delete ghosts[u];
    }
  }

  // The emergent reading queue: LIVE ghosts ranked by how much of your read
  // material points at them, recounted strictly from the edges (dormant —
  // budget-evicted — targets keep their edges but never rank until a fresh
  // citation re-mints them). Score composes three terms (the rankSitting
  // pattern in covisit.js):
  //   - referrer saturation: 1 - 1/(1+refCount) — the 4th citation matters
  //     less than the 2nd, and a single mention never dominates;
  //   - source diversity: sqrt(distinct referrer domains) — three different
  //     sites citing one page beats one site citing it three times;
  //   - recency: half-life decay from the NEWEST referring edge's lastSeen,
  //     age clamped at 0 so a backwards clock (lastSeen in the future) decays
  //     to at most 1 instead of amplifying the score.
  // Returns the top REF_QUEUE_SIZE of { url, title, domain, refCount,
  // sourceDomains (sorted distinct referrer domains), score } — score desc,
  // url asc on ties.
  function rankReadingQueue(proj, now) {
    const ghosts = proj.ghosts || {};
    const nodes = proj.nodes || {};
    const acc = Object.create(null); // ghost url -> { count, domains, newest }
    for (const key in proj.edges || {}) {
      const e = proj.edges[key];
      if (e.type !== "reference" || !ghosts[e.target]) continue;
      const src = nodes[e.source];
      if (!src) continue;
      const a = acc[e.target] || (acc[e.target] = { count: 0, domains: new Set(), newest: 0 });
      a.count += 1;
      a.domains.add(src.domain || domainOf(e.source));
      if (e.lastSeen > a.newest) a.newest = e.lastSeen;
    }
    const out = [];
    for (const u in acc) {
      const g = ghosts[u];
      const a = acc[u];
      const ageDays = Math.max(0, now - a.newest) / 86400000;
      const score =
        (1 - 1 / (1 + a.count)) *
        Math.sqrt(a.domains.size) *
        Math.pow(0.5, ageDays / REF_HALFLIFE_DAYS);
      out.push({
        url: u,
        title: g.title,
        domain: g.domain,
        refCount: a.count,
        sourceDomains: [...a.domains].sort(),
        score,
      });
    }
    out.sort((x, y) => y.score - x.score || (x.url < y.url ? -1 : 1));
    return out.slice(0, REF_QUEUE_SIZE);
  }

  const api = {
    GHOST_MAX,
    REFS_PER_PAGE,
    DOI_MAX_CHARS,
    REF_QUEUE_SIZE,
    REF_HALFLIFE_DAYS,
    refEdgeKey,
    extractReferences,
    foldReferences,
    pruneGhosts,
    rankReadingQueue,
  };

  // Dual export: CommonJS for Node tests, global attach for the worker.
  if (typeof module !== "undefined" && module.exports) {
    module.exports = api;
  } else {
    Object.assign(root, api);
  }
})(typeof globalThis !== "undefined" ? globalThis : self);
