// OrbitCosmo concept layer — pure, dependency-free logic.
//
// Powers "promote keywords to first-class Concept nodes + merge duplicates"
// (see specs/concept-layer.md, grounded in Neo4j's KG guide). Kept free of any
// chrome/* access and of the async embedding step, so the service worker can
// load it via a side-effect import and Node tests can require() it. The stateful
// orchestration (storage, calling the offscreen embedder) lives in
// pipeline.js/store.js.

(function (root) {
  "use strict";

  // --- constants ----------------------------------------------------------
  const CONCEPT_MERGE_MIN = 0.82; // cosine to fold a concept into an existing one
  const CONCEPTS_PER_PAGE = 8; // max concepts attached to a single page
  const MAX_CONCEPTS = 1500; // cap; prune lowest-df, least-recently-seen first

  // A compact stopword set. Page keywords from content.js are already
  // stopword-filtered; this guards any other candidate source.
  const STOP = new Set(
    ("the a an and or but of to in on at for with from by as is are was were be " +
     "been being this that these those it its their his her our your my we you " +
     "they he she them us not no yes can will would should could may might do " +
     "does did has have had into out over under about more most some any all " +
     "each other than then what which who whom how why when where")
      .split(" ")
  );

  // --- candidate normalization & extraction -------------------------------

  // Normalize a surface form to a comparable canonical token. Lowercase,
  // collapse whitespace, strip surrounding punctuation, conservatively
  // singularize. Returns "" for things that shouldn't become concepts.
  function normalizeConcept(s) {
    if (s == null) return "";
    let w = String(s).toLowerCase().trim().replace(/\s+/g, " ");
    w = w.replace(/^[^a-z0-9]+/, "").replace(/[^a-z0-9]+$/, "");
    if (w.length < 3) return "";
    if (/^\d+$/.test(w)) return "";
    // Conservative singularization: a lone trailing "s" on a long-enough word,
    // not -ss/-us/-is. Subtler cases are left to the embedding-merge step.
    if (
      w.length >= 5 &&
      w.endsWith("s") &&
      !w.endsWith("ss") &&
      !w.endsWith("us") &&
      !w.endsWith("is")
    ) {
      w = w.slice(0, -1);
    }
    if (STOP.has(w)) return "";
    return w;
  }

  // Stable id for a concept's canonical label.
  function slugify(label) {
    return String(label)
      .toLowerCase()
      .trim()
      .replace(/[^a-z0-9]+/g, "-")
      .replace(/^-+|-+$/g, "")
      .replace(/-{2,}/g, "-");
  }

  // Concept candidates for a page node. Increment 1: the page's existing top
  // keywords (already extracted + stopword-filtered in content.js). Returns
  // de-duplicated { surface, norm } pairs, capped at `limit`.
  function conceptCandidates(node, limit) {
    const cap = limit || CONCEPTS_PER_PAGE;
    const seen = new Set();
    const out = [];
    const kws = node && Array.isArray(node.keywords) ? node.keywords : [];
    for (const kw of kws) {
      const norm = normalizeConcept(kw);
      if (!norm || seen.has(norm)) continue;
      seen.add(norm);
      out.push({ surface: String(kw).trim(), norm });
      if (out.length >= cap) break;
    }
    return out;
  }

  // --- embedding-resolution decision --------------------------------------

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

  // Best existing concept whose embedding is >= `min` similar to `vec`,
  // excluding `excludeId`. Returns { id, score } (id null if none clear it).
  function bestConceptMatch(vec, concepts, min, excludeId) {
    let bestId = null;
    let best = min;
    for (const id in concepts) {
      if (id === excludeId) continue;
      const e = concepts[id] && concepts[id].embedding;
      if (!e) continue;
      const s = dot(vec, e);
      if (s >= best) {
        best = s;
        bestId = id;
      }
    }
    return { id: bestId, score: bestId ? best : 0 };
  }

  // --- graph transforms (pure: operate on a plain { nodes, edges, concepts }) ---

  // Index every concept's normalized label + aliases -> id, for exact-match
  // resolution of a candidate surface form.
  function buildAliasIndex(concepts) {
    const idx = new Map();
    for (const id in concepts) {
      const c = concepts[id];
      const ln = normalizeConcept(c.label);
      if (ln) idx.set(ln, id);
      for (const a of c.aliases || []) {
        const an = normalizeConcept(a);
        if (an) idx.set(an, id);
      }
    }
    return idx;
  }

  // Synchronous local entity resolution by exact normalized form: set
  // node.conceptIds and create/update concept records. `now` is passed in so the
  // function stays pure/deterministic. The fuzzier embedding-based merge runs
  // later (pipeline.js, after the offscreen embedder returns).
  function resolveConceptsExact(graph, node, now) {
    const t = now || 0;
    const idx = buildAliasIndex(graph.concepts);
    const ids = [];
    for (const { surface, norm } of conceptCandidates(node, CONCEPTS_PER_PAGE)) {
      let id = idx.get(norm);
      if (!id || !graph.concepts[id]) {
        id = slugify(norm);
        if (graph.concepts[id]) {
          // slug collision with a different surface form — treat as the same concept
          const c = graph.concepts[id];
          if (!c.aliases.includes(surface)) c.aliases.push(surface);
          c.lastSeen = t;
        } else {
          graph.concepts[id] = {
            id,
            label: surface,
            aliases: [surface],
            embedding: undefined,
            firstSeen: t,
            lastSeen: t,
          };
        }
        idx.set(norm, id); // later candidates on this page dedupe to it
      } else {
        const c = graph.concepts[id];
        if (!c.aliases.includes(surface)) c.aliases.push(surface);
        c.lastSeen = t;
      }
      if (!ids.includes(id)) ids.push(id);
    }
    node.conceptIds = ids;
    return ids;
  }

  // Document frequency: how many pages mention each concept. Derived from
  // node.conceptIds (not a maintained counter), so revisits never drift it.
  function conceptDf(graph) {
    const df = new Map();
    for (const url in graph.nodes) {
      for (const id of graph.nodes[url].conceptIds || []) {
        df.set(id, (df.get(id) || 0) + 1);
      }
    }
    return df;
  }

  // Fold concept `fromId` into `intoId`: union aliases, repoint every page, drop
  // the duplicate. The survivor keeps its (established) embedding.
  function mergeConcept(graph, fromId, intoId) {
    if (fromId === intoId) return;
    const from = graph.concepts[fromId];
    const into = graph.concepts[intoId];
    if (!from || !into) return;
    for (const a of from.aliases || []) {
      if (!into.aliases.includes(a)) into.aliases.push(a);
    }
    into.lastSeen = Math.max(into.lastSeen || 0, from.lastSeen || 0);
    into.firstSeen = Math.min(
      into.firstSeen || Infinity,
      from.firstSeen || Infinity
    );
    for (const url in graph.nodes) {
      const ids = graph.nodes[url].conceptIds;
      if (!ids || !ids.includes(fromId)) continue;
      graph.nodes[url].conceptIds = [
        ...new Set(ids.map((x) => (x === fromId ? intoId : x))),
      ];
    }
    delete graph.concepts[fromId];
  }

  // Cap the concept set: drop lowest-df, least-recently-seen, scrub from pages.
  function pruneConcepts(graph) {
    const ids = Object.keys(graph.concepts);
    if (ids.length <= MAX_CONCEPTS) return;
    const df = conceptDf(graph);
    ids.sort((a, b) => {
      const d = (df.get(a) || 0) - (df.get(b) || 0);
      if (d !== 0) return d;
      return (
        (graph.concepts[a].lastSeen || 0) - (graph.concepts[b].lastSeen || 0)
      );
    });
    const drop = new Set(ids.slice(0, ids.length - MAX_CONCEPTS));
    for (const id of drop) delete graph.concepts[id];
    for (const url in graph.nodes) {
      const cids = graph.nodes[url].conceptIds;
      if (cids && cids.some((x) => drop.has(x))) {
        graph.nodes[url].conceptIds = cids.filter((x) => !drop.has(x));
      }
    }
  }

  // Strip concept embeddings for the UI and attach derived df.
  function stripConcepts(graph) {
    const df = conceptDf(graph);
    const out = {};
    for (const id in graph.concepts) {
      const { embedding, ...rest } = graph.concepts[id];
      rest.df = df.get(id) || 0;
      out[id] = rest;
    }
    return out;
  }

  const api = {
    CONCEPT_MERGE_MIN,
    CONCEPTS_PER_PAGE,
    MAX_CONCEPTS,
    normalizeConcept,
    slugify,
    conceptCandidates,
    dot,
    bestConceptMatch,
    buildAliasIndex,
    resolveConceptsExact,
    conceptDf,
    mergeConcept,
    pruneConcepts,
    stripConcepts,
  };

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