// OrbitCosmo ego neighborhood — pure, dependency-free (TODO.md Phase 2).
//
// The graph you actually need at any moment is the one around where you are:
// getEgoNeighborhood computes it. Hop 1 includes every edge type touching
// the center (navigation / related / manual / reference — if it connects to
// here, it belongs); hop 2 expands only through navigation edges and related
// edges above a weight threshold (weak second-degree relatedness is noise, a
// click-trail is not) — never through reference edges. The total is capped,
// strongest blended weight first, so the view stays readable at any graph
// size.
//
// Ghosts (Phase 4): pages the center CITES but you haven't visited join at
// hop 1 only, in a separate `ghosts` list, filling at most EGO_MAX_GHOSTS
// slots of the same cap — a page can carry 40 reference edges and the view
// must never drown in them. Ranked refCount desc, then lastSeen desc, then
// url asc. Ghosts have no outgoing edges and are never hop-1 parents, so
// hop 2 cannot expand through them.
//
// One helper, two renderers: the app window's concentric ego layout and the
// side panel's "Connects to" list both consume this (the panel is just hop 1).
// Deterministic given its inputs: strength-desc then URL-asc everywhere.
//
// Kept free of any chrome/* access so pages can load it as a plain script,
// the worker could import it, and Node tests can require() it
// (tools/test-ego.js).

(function (root) {
  "use strict";

  const EGO_MAX_NODES = 40; // total cap (center + both hops) — readability, not storage
  const EGO_MAX_GHOSTS = 8; // ghost slots inside that cap — hop 1, center's citations only
  const EGO_HOP2_RELATED_MIN = 0.4; // raw related weight a hop-2 expansion must clear

  // Normalize an edge's raw weight to 0..1 per type so unbounded navigation
  // counts (saturating at 3 traversals) don't drown the 0..1 blended
  // "related" weights in the strongest-first sort. A reference edge always
  // carries weight 1 — score it like a single click-through (a citation is
  // one piece of evidence), so it reads quieter than a solid related edge.
  function edgeStrength(e) {
    const w = e.weight || 1;
    if (e.type === "navigation") return Math.min(1, w / 3);
    if (e.type === "reference") return 1 / 3;
    return Math.min(1, w);
  }

  // Distinct neighbours of `url`, strongest aggregate connection first.
  // Multiple edges to the same page (related + both nav directions) fold into
  // one entry: weight = summed strengths, type = the strongest edge's type.
  function neighborsOf(graph, url, edgeFilter) {
    const map = new Map();
    for (const key of Object.keys(graph.edges || {})) {
      const e = graph.edges[key];
      if (edgeFilter && !edgeFilter(e)) continue;
      let other = null;
      if (e.source === url) other = e.target;
      else if (e.target === url) other = e.source;
      if (!other || !graph.nodes[other]) continue;
      const cur = map.get(other) || { url: other, type: e.type, weight: 0, max: 0 };
      const s = edgeStrength(e);
      cur.weight += s;
      if (s >= cur.max || (s === cur.max && e.type < cur.type)) { cur.max = s; cur.type = e.type; }
      map.set(other, cur);
    }
    return [...map.values()].sort((a, b) => b.weight - a.weight || (a.url < b.url ? -1 : 1));
  }

  // A hop-2 expansion follows a click-trail or a solidly related pair —
  // never a weak related edge, never a manual edge (hand-links belong to the
  // page they were made on), never a reference edge (a citation puts a page
  // at hop 1 of the page that cites it; it doesn't ripple outward).
  function hop2Eligible(e, relatedMin) {
    if (e.type === "navigation") return true;
    if (e.type === "related") return (e.weight || 0) >= relatedMin;
    return false;
  }

  // The unvisited pages `center` cites, ranked refCount desc, then lastSeen
  // desc, then url asc — deterministic under the slot cap.
  function ghostRefsOf(graph, center) {
    const gmap = graph.ghosts;
    if (!gmap) return [];
    const out = [];
    for (const key of Object.keys(graph.edges || {})) {
      const e = graph.edges[key];
      if (e.type !== "reference" || e.source !== center) continue;
      const g = gmap[e.target];
      if (!g || graph.nodes[e.target]) continue;
      out.push({ url: e.target, refCount: g.refCount || 0, lastSeen: g.lastSeen || 0 });
    }
    return out.sort(
      (a, b) => b.refCount - a.refCount || b.lastSeen - a.lastSeen || (a.url < b.url ? -1 : 1)
    );
  }

  // The neighborhood around `center`:
  //   { center,
  //     nodes:  [{ url, hop, parent, type, weight }, ...],
  //     ghosts: [{ url, refCount }, ...] }
  // nodes ordered hop-asc, then weight-desc, then URL-asc. hop-1 parents are
  // null (the center); each hop-2 node carries the hop-1 page that reached
  // it most strongly. ghosts are the center's cited-but-unvisited pages
  // (hop 1 by construction), capped at maxGhosts slots taken OUT of the node
  // budget — real pages and ghosts share one readability cap. Missing center
  // or empty graph → { center, nodes: [], ghosts: [] }.
  function getEgoNeighborhood(graph, center, opts) {
    const hops = (opts && opts.hops) ?? 2;
    const maxNodes = (opts && opts.maxNodes) ?? EGO_MAX_NODES;
    const maxGhosts = (opts && opts.maxGhosts) ?? EGO_MAX_GHOSTS;
    const relatedMin = (opts && opts.hop2RelatedMin) ?? EGO_HOP2_RELATED_MIN;
    if (!graph || !graph.nodes || !graph.nodes[center]) return { center, nodes: [], ghosts: [] };

    const budget = Math.max(0, maxNodes - 1); // minus the center itself
    const ghosts = (maxGhosts > 0 ? ghostRefsOf(graph, center) : [])
      .slice(0, Math.min(maxGhosts, budget))
      .map(({ url, refCount }) => ({ url, refCount }));
    const nodeBudget = budget - ghosts.length;
    const hop1 = neighborsOf(graph, center)
      .slice(0, nodeBudget)
      .map((n) => ({ url: n.url, hop: 1, parent: null, type: n.type, weight: n.weight }));

    const nodes = [...hop1];
    if (hops >= 2 && nodes.length < nodeBudget) {
      const seen = new Set([center, ...hop1.map((n) => n.url)]);
      // Best reach per hop-2 page across all hop-1 parents (strongest wins;
      // parent order breaks ties deterministically because hop1 is ordered).
      // Only real hop-1 pages parent hop 2 — ghosts never appear here.
      const best = new Map();
      for (const p of hop1) {
        for (const n of neighborsOf(graph, p.url, (e) => hop2Eligible(e, relatedMin))) {
          if (seen.has(n.url)) continue;
          const cur = best.get(n.url);
          if (!cur || n.weight > cur.weight) {
            best.set(n.url, { url: n.url, hop: 2, parent: p.url, type: n.type, weight: n.weight });
          }
        }
      }
      const hop2 = [...best.values()]
        .sort((a, b) => b.weight - a.weight || (a.url < b.url ? -1 : 1))
        .slice(0, nodeBudget - nodes.length);
      nodes.push(...hop2);
    }
    return { center, nodes, ghosts };
  }

  const api = { EGO_MAX_NODES, EGO_MAX_GHOSTS, EGO_HOP2_RELATED_MIN, edgeStrength, neighborsOf, getEgoNeighborhood };

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