// OrbitCosmo co-visit layer — pure, dependency-free logic.
//
// "Same sitting" memory: two pages visited within the same 15-minute window
// were on your desk at the same time, whatever their topics. That temporal
// co-presence is witnessed-order evidence the topic layers can't see — it
// feeds the related-edge blend (related.js) and powers temporal recall
// ("what else was I reading when I had this open?") via rankSitting.
//
// The fold is deterministic: timestamps come ONLY from event.ts, never
// Date.now() — fold one event == replay of length 1, same as every other
// projection fold. Query-time decay (rankSitting) is the only place a live
// clock enters, supplied by the caller.
//
// 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-covisit.js). Storage and orchestration live in
// store.js/pipeline.js.

(function (root) {
  "use strict";

  // Two visits belong to the same sitting when they land within this window.
  const COVISIT_WINDOW_MS = 15 * 60 * 1000; // 15 min

  // How many recent distinct URLs the fold keeps in its sliding window
  // (proj.meta.covisitRecent) — bounds per-event pairing work.
  const COVISIT_MAX_RECENT = 30;

  // Cap on stored pairs; pruneCovisits trims the stalest/weakest beyond it.
  const COVISIT_MAX_PAIRS = 20000;

  // 3 distinct sittings together = full co-visit evidence (one sitting could
  // be coincidence; three is a habit).
  const SITTING_COUNT_SAT = 3;

  // Recency half-life for rankSitting: a sitting from two weeks ago carries
  // half the pull of one from today.
  const COVISIT_HALFLIFE_MS = 14 * 24 * 60 * 60 * 1000; // 14 days

  // How many companions rankSitting returns.
  const SITTING_TOP_N = 5;

  // Sorted, space-joined — byte-identical to related.js's internal pairKey,
  // so the blend reads the covisit map with zero transform.
  function covisitPairKey(a, b) {
    return a < b ? a + " " + b : b + " " + a;
  }

  // Fold ONE visit event's co-presence into the projection.
  //
  // Skips synthesized migration events (they share fabricated timestamps —
  // folding them would fabricate one giant fake sitting) and SERPs (a search
  // page is how you got to content, not content you sat with — the same
  // rationale as the navigation/related exclusions).
  //
  // A URL pairs with the window's occupants only when it ENTERS the window;
  // while it stays inside, revisits just refresh its timestamp (so
  // A@0, A@10min, B@20min still pairs A-B). Re-entry after a gap counts
  // again — `count` means "distinct sittings together". No self-pairs by
  // construction: a URL never pairs with an entry for itself.
  function foldCovisit(proj, event, isSearchUrl) {
    if (event.synthesized) return;
    const { ts, url } = event;
    if (isSearchUrl(url)) return;

    // Strict <: a visit exactly 15:00 later is a new sitting.
    const recent = (proj.meta.covisitRecent || []).filter(
      (r) => ts - r.ts < COVISIT_WINDOW_MS
    );

    if (!recent.some((r) => r.url === url)) {
      for (const r of recent) {
        const k = covisitPairKey(url, r.url);
        const pair = proj.covisits[k];
        if (pair) {
          pair.count += 1;
          pair.lastSeen = ts;
        } else {
          proj.covisits[k] = { count: 1, lastSeen: ts };
        }
      }
      if (Object.keys(proj.covisits).length > COVISIT_MAX_PAIRS) {
        pruneCovisits(proj.covisits);
      }
    }

    // Refresh the window: drop any stale entry for this URL, append at ts.
    const kept = recent.filter((r) => r.url !== url);
    kept.push({ ts, url });
    if (kept.length > COVISIT_MAX_RECENT) kept.shift();
    proj.meta.covisitRecent = kept;
  }

  // Trim the pair map back to COVISIT_MAX_PAIRS. Deterministic victim order:
  // stalest first (lastSeen asc), then weakest (count asc), then key asc.
  function pruneCovisits(covisits) {
    const keys = Object.keys(covisits);
    if (keys.length <= COVISIT_MAX_PAIRS) return;
    keys.sort((a, b) => {
      const pa = covisits[a], pb = covisits[b];
      return (
        pa.lastSeen - pb.lastSeen || pa.count - pb.count || (a < b ? -1 : 1)
      );
    });
    for (let i = 0; i < keys.length - COVISIT_MAX_PAIRS; i++) {
      delete covisits[keys[i]];
    }
  }

  // Vectors are L2-normalized (MiniLM, normalize:true), so dot == cosine.
  // Private — projections.js/related.js declare their own `cosine`;
  // exporting this would collide on the global attach.
  function cosine(a, b) {
    const n = Math.min(a.length, b.length);
    let s = 0;
    for (let i = 0; i < n; i++) s += a[i] * b[i];
    return s;
  }

  // Rank the pages that shared sittings with `url`: repeat co-presence
  // (saturating at SITTING_COUNT_SAT), decayed by how long ago the last
  // sitting was, with semantic similarity as a BOOST, not a gate —
  // cross-topic companions are the whole point, so a cos≈0 pair still ranks
  // at half strength, and pairs missing an embedding sit at the same neutral
  // 0.5. Partners whose page no longer exists are skipped. Returns the top
  // SITTING_TOP_N of { url, count, lastSeen, score } — score desc, url asc.
  function rankSitting(graph, url, now) {
    const covisits = graph.covisits || {};
    const nodes = graph.nodes || {};
    const out = [];
    for (const k in covisits) {
      const sep = k.indexOf(" ");
      const a = k.slice(0, sep);
      const b = k.slice(sep + 1);
      const partner = a === url ? b : b === url ? a : null;
      if (!partner || !nodes[partner]) continue;
      const { count, lastSeen } = covisits[k];
      const me = nodes[url];
      const sim =
        me && Array.isArray(me.embedding) && Array.isArray(nodes[partner].embedding)
          ? 0.5 + 0.5 * Math.max(0, cosine(me.embedding, nodes[partner].embedding))
          : 0.5;
      // Clamp the age at 0 so a backwards clock (lastSeen in the future)
      // decays to at most 1 instead of amplifying the score.
      const score =
        Math.min(1, count / SITTING_COUNT_SAT) *
        Math.pow(0.5, Math.max(0, now - lastSeen) / COVISIT_HALFLIFE_MS) *
        sim;
      out.push({ url: partner, count, lastSeen, score });
    }
    out.sort((x, y) => y.score - x.score || (x.url < y.url ? -1 : 1));
    return out.slice(0, SITTING_TOP_N);
  }

  const api = {
    COVISIT_WINDOW_MS,
    COVISIT_MAX_RECENT,
    COVISIT_MAX_PAIRS,
    SITTING_COUNT_SAT,
    COVISIT_HALFLIFE_MS,
    SITTING_TOP_N,
    covisitPairKey,
    foldCovisit,
    pruneCovisits,
    rankSitting,
  };

  // 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);
