// OrbitCosmo recall search — pure, dependency-free ranking.
//
// Powers the side panel's Memory view: given a query string, an optional
// query embedding (computed locally in the service worker — vectors never
// reach the panel), and the node map, rank pages by a blend of semantic
// similarity, lexical match, and recency. 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-search.js). The query embedding and message
// routing live in pipeline.js.

(function (root) {
  "use strict";

  // --- constants ----------------------------------------------------------
  const SEARCH_LIMIT = 20; // results returned per query
  const MIN_QUERY_EMBED_CHARS = 3; // shorter queries skip the embedding pass
  const SEM_FLOOR = 0.35; // cosine below this doesn't count as a "meaning" match
  const RECENCY_HALFLIFE_DAYS = 30; // mild tiebreaker — recall is FOR old pages

  const DAY_MS = 86400000;

  // Blend weights: meaning first, words second, freshness last.
  const W_SEM = 0.6;
  const W_LEX = 0.3;
  const W_REC = 0.1;

  // 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) {
    let s = 0;
    const n = Math.min(a.length, b.length);
    for (let i = 0; i < n; i++) s += a[i] * b[i];
    return s;
  }

  function tokenize(q) {
    return String(q || "")
      .toLowerCase()
      .split(/[^a-z0-9]+/)
      .filter(Boolean);
  }

  // Weighted best-field lexical match over the same fields the full graph's
  // search filters on (graph.js matchesSearch), so lexical behavior stays
  // consistent across surfaces. A full-query substring scores the field's
  // full weight; otherwise the weight scales by the fraction of query tokens
  // the field contains.
  const FIELDS = [
    { key: "title", weight: 1.0, reason: "title" },
    { key: "keywords", weight: 0.7, reason: "keywords" },
    { key: "domain", weight: 0.6, reason: "domain" },
    { key: "description", weight: 0.5, reason: "description" },
    { key: "content", weight: 0.35, reason: "mentions it" },
  ];

  function lexicalScore(node, q, tokens) {
    const query = String(q || "").toLowerCase();
    let best = 0;
    const reasons = [];
    for (const f of FIELDS) {
      const raw = node[f.key];
      const text = (Array.isArray(raw) ? raw.join(" ") : String(raw || "")).toLowerCase();
      if (!text) continue;
      let s = 0;
      if (query && text.includes(query)) {
        s = f.weight;
      } else if (tokens.length) {
        const hit = tokens.filter((t) => text.includes(t)).length;
        if (hit) s = f.weight * (hit / tokens.length);
      }
      if (s > 0) {
        reasons.push(f.reason);
        if (s > best) best = s;
      }
    }
    return { score: best, reasons };
  }

  function recencyScore(lastVisit, now) {
    const ageDays = Math.max(0, now - (lastVisit || now)) / DAY_MS;
    return Math.pow(0.5, ageDays / RECENCY_HALFLIFE_DAYS);
  }

  // Rank every node against the query. queryVec may be null (short query or
  // embed failure) — ranking degrades to lexical + recency. Unembedded nodes
  // still surface through their lexical score. A node is kept iff it matches
  // by meaning (sem >= SEM_FLOOR) or by words (lex > 0).
  function searchGraph(query, queryVec, nodes, now) {
    const q = String(query || "").trim();
    const tokens = tokenize(q);
    if (!q || (!tokens.length && !queryVec)) return [];
    const out = [];
    for (const url in nodes) {
      const node = nodes[url];
      const sem =
        queryVec && Array.isArray(node.embedding)
          ? Math.max(0, cosine(queryVec, node.embedding))
          : 0;
      const lex = lexicalScore(node, q, tokens);
      if (sem < SEM_FLOOR && lex.score <= 0) continue;
      const rec = recencyScore(node.lastVisit, now);
      const matchReasons = sem >= SEM_FLOOR ? ["meaning", ...lex.reasons] : lex.reasons;
      out.push({
        url,
        score: W_SEM * sem + W_LEX * lex.score + W_REC * rec,
        semantic: sem,
        lexical: lex.score,
        recency: rec,
        matchReasons,
      });
    }
    out.sort((a, b) => b.score - a.score);
    return out.slice(0, SEARCH_LIMIT);
  }

  // "3 weeks ago" — relative time for result rows and session headers.
  function timeAgo(ts, now) {
    if (typeof ts !== "number") return "";
    const ms = Math.max(0, now - ts);
    const min = Math.floor(ms / 60000);
    if (min < 1) return "just now";
    if (min < 60) return min + " min ago";
    const h = Math.floor(min / 60);
    if (h < 24) return h + " h ago";
    const days = Math.floor(h / 24);
    if (days === 1) return "yesterday";
    if (days < 14) return days + " days ago";
    const weeks = Math.floor(days / 7);
    if (days < 61) return weeks + " weeks ago";
    const months = Math.floor(days / 30);
    return months + " months ago";
  }

  const api = {
    SEARCH_LIMIT,
    MIN_QUERY_EMBED_CHARS,
    SEM_FLOOR,
    RECENCY_HALFLIFE_DAYS,
    tokenize,
    lexicalScore,
    recencyScore,
    searchGraph,
    timeAgo,
  };

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