// OrbitCosmo related-edge builder — pure, dependency-free logic.
//
// Connects pages with ONE undirected "related" edge per pair, carrying a
// blended weight in [0,1]: meaning first (local embedding cosine), shared
// specific keywords second, behavior last (clicks between them, then
// same-sitting co-visits) — the same meaning-first spirit as the recall
// blend in search.js. Which pairs get an edge is decided
// by MUTUAL kNN over the blended score (each page must be among the other's
// top K), not a global similarity threshold, so dense topics don't hairball
// and sparse ones don't orphan.
//
// 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-related.js). Storage, embeddings, and orchestration live in
// store.js/pipeline.js.

(function (root) {
  "use strict";

  // --- constants ----------------------------------------------------------
  // Blend weights: meaning first, words second, behavior last — and within
  // behavior, clicks over co-presence: a click is directed intent between
  // two specific pages, a shared sitting is circumstantial. Both behavioral
  // terms saturated sum to 0.20 < the 0.25 floor, so behavior alone can
  // never create an edge — it lifts pairs that already have some topical
  // evidence.
  const W_SEM_REL = 0.55;
  const W_LEX_REL = 0.25;
  const W_NAV_REL = 0.12;
  const W_COVISIT_REL = 0.08;

  // Saturation points: 4 shared specific keywords = full lexical evidence
  // (keywords cap at 12/page; the old edge threshold of 2 lands mid-scale);
  // 3 traversals of a link = full click evidence (one click is a nudge);
  // 3 distinct sittings together = full co-visit evidence (covisit.js).
  const LEX_SAT = 4;
  const NAV_SAT = 3;
  const COVISIT_SAT = 3;

  // Mutual kNN: an edge exists only when each page is in the other's top K.
  // K=4 bounds average degree at ~2-3 after mutuality pruning — matching real
  // research-cluster sizes.
  const K_RELATED = 4;

  // Absolute floor: mutuality alone would connect a tiny graph of unrelated
  // pages (everyone is in everyone's top K). A sem-only pair needs cosine
  // >= 0.25/0.55 ≈ 0.46; an un-embedded pair with 4 shared specific keywords
  // (lex=1) still passes exactly, so pages whose embedding failed don't
  // become islands.
  const RELATED_MIN = 0.25;

  // A keyword is "generic" once it appears on a large share of pages (nav
  // chrome like "home", "login", "search", "menu") — it stops being evidence
  // that two pages are about the same thing.
  const GENERIC_DF_FRAC = 0.5; // a keyword on >= this fraction of pages is "generic"
  const GENERIC_DF_MIN = 5;    // ...but only once it's also on at least this many pages

  // Vectors are L2-normalized (MiniLM, normalize:true), so dot == cosine.
  // Private — projections.js declares its 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;
  }

  // Document frequency of each keyword across the corpus: how many pages
  // contain it. Used to discount generic/boilerplate words when linking pages.
  function keywordDf(graph) {
    const df = Object.create(null);
    for (const u in graph.nodes) {
      for (const k of new Set(graph.nodes[u].keywords || [])) df[k] = (df[k] || 0) + 1;
    }
    return df;
  }

  function isGenericKeyword(k, df, n) {
    return (df[k] || 0) >= Math.max(GENERIC_DF_MIN, GENERIC_DF_FRAC * n);
  }

  // Count the SPECIFIC (non-generic) keywords two pages share — the real
  // topical overlap. Generic words are ignored so they can't fabricate
  // cross-topic links.
  function specificOverlap(a = [], b = [], df, n) {
    const setB = new Set(b);
    let shared = 0;
    for (const k of a) if (setB.has(k) && !isGenericKeyword(k, df, n)) shared++;
    return shared;
  }

  function pairKey(a, b) {
    return a < b ? a + " " + b : b + " " + a;
  }

  // Summed navigation weight per unordered pair (A->B plus B->A), so the
  // click-evidence boost is symmetric like the edge it feeds.
  function navCounts(edges) {
    const counts = Object.create(null);
    for (const key in edges) {
      const e = edges[key];
      if (e.type !== "navigation") continue;
      const pk = pairKey(e.source, e.target);
      counts[pk] = (counts[pk] || 0) + (e.weight || 1);
    }
    return counts;
  }

  // Blended relatedness of two pages, in [0,1]. Either page missing an
  // embedding zeroes the semantic term — lex+nav+covisit (max 0.45) still
  // count.
  function relatedScore(a, b, df, n, navPairCount, covisitCount) {
    const sem =
      Array.isArray(a.embedding) && Array.isArray(b.embedding)
        ? Math.max(0, cosine(a.embedding, b.embedding))
        : 0;
    const lex = Math.min(1, specificOverlap(a.keywords, b.keywords, df, n) / LEX_SAT);
    const nav = Math.min(1, (navPairCount || 0) / NAV_SAT);
    const cov = Math.min(1, (covisitCount || 0) / COVISIT_SAT);
    return W_SEM_REL * sem + W_LEX_REL * lex + W_NAV_REL * nav + W_COVISIT_REL * cov;
  }

  // Full recompute of every "related" edge: drop the old ones, score all
  // pairs, keep only mutual top-K neighbours above the floor. Recomputing
  // everything answers kNN order-sensitivity — a new page can evict others'
  // top K, so every neighbourhood is rebuilt each call. Navigation and manual
  // edges pass through untouched. `isExcludedUrl` keeps search-result pages
  // out entirely (a SERP shares keywords with every result it spawned — the
  // same false-hub failure the navigation-edge exclusion targets).
  function buildRelatedEdges(graph, opts) {
    const isExcludedUrl = (opts && opts.isExcludedUrl) || (() => false);
    const now = (opts && opts.now) || 0;
    if (!graph.edges) graph.edges = {};
    for (const key of Object.keys(graph.edges)) {
      if (graph.edges[key].type === "related") delete graph.edges[key];
    }
    const nodes = graph.nodes || {};
    const n = Object.keys(nodes).length; // df corpus size includes excluded pages
    const urls = Object.keys(nodes).filter((u) => !isExcludedUrl(u)).sort();
    if (urls.length < 2) return;

    const df = keywordDf(graph);
    const nav = navCounts(graph.edges);
    const cov = graph.covisits || {};

    // Score every pair once; collect each node's candidate neighbours.
    const cand = urls.map(() => []);
    for (let i = 0; i < urls.length; i++) {
      for (let j = i + 1; j < urls.length; j++) {
        const pk = pairKey(urls[i], urls[j]);
        const score = relatedScore(
          nodes[urls[i]], nodes[urls[j]], df, n, nav[pk], (cov[pk] || {}).count
        );
        if (score < RELATED_MIN) continue;
        cand[i].push({ peer: j, score });
        cand[j].push({ peer: i, score });
      }
    }

    // Top K per node (score desc, URL asc on ties — deterministic regardless
    // of node insertion order).
    const topk = cand.map((list) => {
      list.sort((a, b) => b.score - a.score || (urls[a.peer] < urls[b.peer] ? -1 : 1));
      const set = new Set();
      for (const c of list.slice(0, K_RELATED)) set.add(c.peer);
      return set;
    });

    for (let i = 0; i < urls.length; i++) {
      for (const c of cand[i]) {
        const j = c.peer;
        if (j < i) continue; // each pair once
        if (!topk[i].has(j) || !topk[j].has(i)) continue; // mutual only
        const [x, y] = [urls[i], urls[j]].sort();
        graph.edges[`related::${x}--${y}`] = {
          source: x,
          target: y,
          type: "related",
          weight: Math.round(c.score * 100) / 100,
          lastSeen: now,
        };
      }
    }
  }

  function relatedEdgeKey(a, b) {
    const [x, y] = [a, b].sort();
    return `related::${x}--${y}`;
  }

  // Incremental recompute around ONE page — the visit/embed hot path:
  // O((K + degree) · N) scoring instead of the full O(N²) pass (the cost
  // that used to force the 500-page cap). Steps: score `url` against every
  // page for its top K; recompute top K for each affected peer (url's
  // candidates ∪ its current neighbours); enforce mutuality on every edge
  // touching url or those peers. A bystander pair whose edge url's arrival
  // would evict from BOTH ends' top K is not touched here — that staleness
  // self-heals when either page is next visited, re-embedded (dirty sweep),
  // or a projection replay runs. Deletions are always ones the full pass
  // would also make; this path never invents an edge the full pass wouldn't.
  function rebuildRelatedEdgesForPage(graph, url, opts) {
    const isExcludedUrl = (opts && opts.isExcludedUrl) || (() => false);
    const now = (opts && opts.now) || 0;
    if (!graph.edges) graph.edges = {};
    const nodes = graph.nodes || {};
    if (!nodes[url]) return;

    // url's current related neighbours — every edge we must confirm or drop.
    const current = new Set();
    for (const key in graph.edges) {
      const e = graph.edges[key];
      if (e.type !== "related") continue;
      if (e.source === url) current.add(e.target);
      else if (e.target === url) current.add(e.source);
    }

    const n = Object.keys(nodes).length; // df corpus size includes excluded pages
    const urls = Object.keys(nodes).filter((u) => !isExcludedUrl(u)).sort();
    if (isExcludedUrl(url) || urls.length < 2) {
      for (const v of current) delete graph.edges[relatedEdgeKey(url, v)];
      return;
    }

    const df = keywordDf(graph);
    const nav = navCounts(graph.edges);
    const cov = graph.covisits || {};

    // Top-K neighbour set (and scores) of one page — O(N). Same ordering rule
    // as the full pass: score desc, URL asc on ties.
    function topKOf(u) {
      const list = [];
      for (const v of urls) {
        if (v === u) continue;
        const pk = pairKey(u, v);
        const score = relatedScore(nodes[u], nodes[v], df, n, nav[pk], (cov[pk] || {}).count);
        if (score >= RELATED_MIN) list.push({ peer: v, score });
      }
      list.sort((a, b) => b.score - a.score || (a.peer < b.peer ? -1 : 1));
      return {
        top: new Set(list.slice(0, K_RELATED).map((c) => c.peer)),
        score: new Map(list.map((c) => [c.peer, c.score])),
      };
    }

    const mine = topKOf(url);
    const affected = new Set([...mine.top, ...current]);
    for (const v of affected) {
      const theirs = topKOf(v);
      // The (url, v) edge itself: mutual top-K or gone.
      if (mine.top.has(v) && theirs.top.has(url)) {
        const [x, y] = [url, v].sort();
        graph.edges[relatedEdgeKey(url, v)] = {
          source: x, target: y, type: "related",
          weight: Math.round(mine.score.get(v) * 100) / 100,
          lastSeen: now,
        };
      } else {
        delete graph.edges[relatedEdgeKey(url, v)];
      }
      // url entering v's top K can evict a third page from it — enforce v's
      // fresh top K on all of v's existing related edges.
      for (const key of Object.keys(graph.edges)) {
        const e = graph.edges[key];
        if (e.type !== "related") continue;
        const peer = e.source === v ? e.target : e.target === v ? e.source : null;
        if (peer && peer !== url && !theirs.top.has(peer)) delete graph.edges[key];
      }
    }
  }

  const api = {
    W_SEM_REL,
    W_LEX_REL,
    W_NAV_REL,
    W_COVISIT_REL,
    LEX_SAT,
    NAV_SAT,
    COVISIT_SAT,
    K_RELATED,
    RELATED_MIN,
    GENERIC_DF_FRAC,
    GENERIC_DF_MIN,
    keywordDf,
    isGenericKeyword,
    specificOverlap,
    navCounts,
    relatedScore,
    buildRelatedEdges,
    rebuildRelatedEdgesForPage,
  };

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