// OrbitCosmo projection builders — pure folds from witnessed data (events,
// snapshots, embeddings) to the derived graph (specs/phase1-event-log.md):
// nodes, edges (navigation/related/manual/reference), sessions, concepts,
// co-visits, ghosts.
//
// One code path, two speeds: recordPage folds each live visit event as it
// happens (applyVisitEvent), and rebuildProjection replays the whole event
// log with the same fold — so `fold one event` IS `replay of length 1`, and
// a projectionVersion bump makes any rule change retroactive. Everything
// here is deterministic given its inputs: session ids come from a counter
// (never Math.random), timestamps come from the events (never Date.now).
//
// Cross-layer dependencies (decideSession, buildRelatedEdges,
// resolveConceptsExact, pruneConcepts, isSearchUrl, foldCovisit,
// foldReferences, normalizeUrl) are injected as `deps` —
// the worker passes its globals (attached by the imported pure layers), Node tests pass require()d
// modules. Dual-exported like the other pure layers (tools/test-projections.js).

(function (root) {
  "use strict";

  // Bump to force a full replay on next wake (the permanent replacement for
  // the old migration-flag ladder). v2: replay-built projections — SERP
  // exclusion applied at fold time, deterministic session ids. v3: co-visit
  // fold (topic-blind 15-min window over the event log, retroactive;
  // synthesized migration events excluded). v4: references fold — ghosts +
  // directed reference edges are replay-derived from stored snapshots.
  const PROJECTION_VERSION = 4;

  function emptyProjection() {
    return {
      id: "graph",
      projectionVersion: PROJECTION_VERSION,
      nodes: {}, edges: {}, concepts: {}, sessions: {}, covisits: {}, ghosts: {},
      meta: {},
    };
  }

  function navEdgeKey(a, b) {
    return `nav::${a}=>${b}`;
  }
  function manualEdgeKey(a, b) {
    const [x, y] = [a, b].sort();
    return `manual::${x}--${y}`;
  }

  // Session assignment for one visit — decideSession (sessions.js) stays the
  // rule-owner; this owns the bookkeeping. Deterministic: new session ids are
  // `s-<ts>-<seq>` off a counter in meta.
  function assignSession(proj, node, ts, prevUrl, deps) {
    const decision = deps.decideSession({
      now: ts,
      candidateKeywords: node.keywords,
      prevUrl,
      forceNew: !!proj.meta.forceNewSession,
      meta: proj.meta,
      graph: proj,
    });
    let sid = decision.sessionId;
    if (decision.isNew) {
      proj.meta.sessionSeq = (proj.meta.sessionSeq || 0) + 1;
      sid = `s-${ts}-${proj.meta.sessionSeq}`;
      proj.sessions[sid] = {
        id: sid,
        label: (node.title || node.domain || "New session").slice(0, 60),
        firstSeen: ts,
        lastSeen: ts,
      };
    } else if (proj.sessions[sid]) {
      proj.sessions[sid].lastSeen = ts;
    }
    node.sessionId = sid;
    proj.meta.currentSessionId = sid;
    proj.meta.lastActivityTs = ts;
    proj.meta.forceNewSession = false;
  }

  // Fold ONE visit event into the projection. The snapshot supplies the
  // page's content fields; the event supplies time and lineage. Returns the
  // upserted node. Related edges are NOT rebuilt here — buildRelatedEdges is
  // a stateless full rebuild, so callers run it once per batch (recordPage:
  // after each visit; replay: after the loop) and converge on the same state.
  function applyVisitEvent(proj, event, snapshot, deps) {
    const { ts, url, fromUrl } = event;
    const existing = proj.nodes[url];
    const node = {
      url,
      title: snapshot.title || url,
      description: snapshot.description || "",
      keywords: snapshot.keywords || [],
      content: snapshot.content || "",
      favicon: snapshot.favicon || "",
      domain: snapshot.domain || "",
      firstVisit: existing?.firstVisit || ts,
      lastVisit: ts,
      visits: (existing?.visits || 0) + 1,
      conceptIds: existing?.conceptIds || [],
      sessionId: existing?.sessionId,
      centrality: existing?.centrality ?? null,
    };
    // Additive, only ever present as true (the snapshot field is sticky —
    // pipeline.js): the panel's transcript affordance keys off it. No
    // PROJECTION_VERSION bump: only new snapshots can carry the flag, so a
    // replay of pre-existing data yields exactly today's projection.
    if (snapshot.transcriptCaptured) node.transcriptCaptured = true;
    if (existing && "embedding" in existing) node.embedding = existing.embedding;
    proj.nodes[url] = node;

    // Ghost→real merge: ghost ids ARE canonical urls (same normalizeUrl), so
    // the first real visit just deletes the ghost — reference edges already
    // point at this url and now resolve to the real node, no rewrite needed.
    if (proj.ghosts[url]) delete proj.ghosts[url];

    // Navigation edge — the SERP exclusion lives HERE, at fold time (events
    // record lineage even for search pages; the projection decides what
    // draws). lastSeen uses event time, never the clock: replay determinism.
    const prevUrl = fromUrl && fromUrl !== url && proj.nodes[fromUrl] ? fromUrl : null;
    if (prevUrl && !deps.isSearchUrl(prevUrl) && !deps.isSearchUrl(url)) {
      const key = navEdgeKey(prevUrl, url);
      const e = proj.edges[key];
      if (e) {
        e.weight += 1;
        e.lastSeen = ts;
      } else {
        proj.edges[key] = { source: prevUrl, target: url, type: "navigation", weight: 1, lastSeen: ts };
      }
    }

    deps.foldCovisit(proj, event, deps.isSearchUrl);
    assignSession(proj, node, ts, prevUrl, deps);
    deps.resolveConceptsExact(proj, node, ts);
    // References — the per-source delete-then-reinsert fold (references.js)
    // is idempotent, so replay folding a 5-times-visited page 5 times
    // converges on the same edges/ghosts as folding it once.
    deps.foldReferences(proj, url, snapshot, ts, deps);
    return node;
  }

  // Fold one manual-link event (a witnessed user act). Both pages must exist
  // in the projection — replay applies these after the visit loop.
  function applyManualLinkEvent(proj, event) {
    if (!proj.nodes[event.url] || !proj.nodes[event.otherUrl]) return;
    const key = manualEdgeKey(event.url, event.otherUrl);
    if (!proj.edges[key]) {
      proj.edges[key] = {
        source: event.url, target: event.otherUrl, type: "manual", weight: 1, lastSeen: event.ts,
      };
    }
  }

  function cosine(a, b) {
    const n = Math.min(a.length, b.length);
    let dot = 0;
    for (let i = 0; i < n; i++) dot += a[i] * b[i];
    return dot; // vectors are L2-normalized, so dot product == cosine similarity
  }

  // Embedding centrality: how close each page sits to the centre of
  // everything you've read — cosine to the L2-normalized centroid of all
  // node embeddings, min-max normalized to 0..1. Drives graph node radius.
  // Pages still awaiting an embedding get centrality: null.
  function computeCentrality(graph) {
    const urls = Object.keys(graph.nodes);
    const embedded = urls.filter((u) => Array.isArray(graph.nodes[u].embedding));
    const cen = {};
    if (embedded.length >= 2) {
      const dim = graph.nodes[embedded[0]].embedding.length;
      const centroid = new Array(dim).fill(0);
      for (const u of embedded) {
        const e = graph.nodes[u].embedding;
        for (let i = 0; i < dim; i++) centroid[i] += e[i];
      }
      let norm = 0;
      for (let i = 0; i < dim; i++) { centroid[i] /= embedded.length; norm += centroid[i] * centroid[i]; }
      norm = Math.sqrt(norm) || 1;
      for (let i = 0; i < dim; i++) centroid[i] /= norm;
      let lo = Infinity, hi = -Infinity;
      for (const u of embedded) {
        const c = cosine(graph.nodes[u].embedding, centroid);
        cen[u] = c; if (c < lo) lo = c; if (c > hi) hi = c;
      }
      const span = hi - lo || 1;
      for (const u of embedded) cen[u] = (cen[u] - lo) / span;
    }
    return cen;
  }

  // Write-time centrality (the UI's node radius): computed while vectors are
  // attached and persisted on the node, so serve time never loads embeddings.
  function stampCentrality(graph) {
    const cen = computeCentrality(graph);
    for (const u in graph.nodes) {
      graph.nodes[u].centrality = u in cen ? cen[u] : null;
    }
  }

  // Full replay: the whole event log through the same folds, then one
  // related-edge rebuild over the final snapshots + embeddings. Returns a
  // DECORATED projection (vectors attached, plain arrays) — the caller
  // stamps centrality and strips vectors on save.
  function rebuildProjection(witnessed, deps, now) {
    const { events, snapshots, embeddings } = witnessed;
    const proj = emptyProjection();
    const snapByUrl = new Map(snapshots.map((s) => [s.url, s]));

    const ordered = [...events].sort((a, b) => a.ts - b.ts);
    const manualEvents = [];
    for (const ev of ordered) {
      if (ev.kind === "visit") {
        const snap = snapByUrl.get(ev.url);
        if (snap) applyVisitEvent(proj, ev, snap, deps); // snapshot-less pages are gone pages
      } else if (ev.kind === "manual-link") {
        manualEvents.push(ev);
      }
    }
    for (const ev of manualEvents) applyManualLinkEvent(proj, ev);

    for (const row of embeddings) {
      if (proj.nodes[row.url]) proj.nodes[row.url].embedding = Array.from(row.vec);
    }
    deps.buildRelatedEdges(proj, { isExcludedUrl: deps.isSearchUrl, now });
    deps.pruneConcepts(proj);
    return proj;
  }

  // Retention policy over the witnessed event log (specs/phase1-event-log.md):
  // events touching a live page are always kept; events whose pages are all
  // gone age out — keep whichever window is larger, the newest `maxEvents`
  // of them or everything younger than `maxAgeDays`. Input rows are
  // { key, value } pairs (IDB autoincrement keys); returns the keys to drop.
  function retentionDrops(rows, liveUrls, now, opts) {
    const maxEvents = (opts && opts.maxEvents) ?? 10000;
    const maxAgeMs = ((opts && opts.maxAgeDays) ?? 180) * 86400000;
    const dead = rows.filter((r) => {
      const ev = r.value;
      return !(
        liveUrls.has(ev.url) ||
        (ev.fromUrl && liveUrls.has(ev.fromUrl)) ||
        (ev.otherUrl && liveUrls.has(ev.otherUrl))
      );
    });
    dead.sort((a, b) => b.value.ts - a.value.ts); // newest first
    const drops = [];
    for (let i = 0; i < dead.length; i++) {
      if (i < maxEvents) continue; // inside the count window
      if (now - dead[i].value.ts <= maxAgeMs) continue; // inside the age window
      drops.push(dead[i].key);
    }
    return drops;
  }

  const api = {
    PROJECTION_VERSION,
    emptyProjection,
    applyVisitEvent,
    applyManualLinkEvent,
    rebuildProjection,
    computeCentrality,
    stampCentrality,
    retentionDrops,
  };

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