// OrbitCosmo session layer — pure, dependency-free logic.
//
// A "session" is a contiguous run of browsing about one topic. When you pivot
// to something unrelated (a fresh search, a new subject) OrbitCosmo starts a new
// session, so the graph and Topics stay grouped by what you were actually
// researching instead of smearing everything you saw in the same 5 minutes
// together. Boundaries are detected automatically and can be forced manually.
//
// 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. Storage +
// orchestration live in store.js/pipeline.js.

(function (root) {
  "use strict";

  // A new session begins after this much wall-clock silence, regardless of
  // topic — picking the browser back up later is a new train of thought.
  const SESSION_IDLE_MS = 30 * 60 * 1000; // 30 min

  // How many of a session's most-recent pages we compare a new page against
  // when deciding "is this the same topic?".
  const TOPIC_WINDOW = 6;

  // Hosts whose pages are navigation hubs, not topics: arriving *from* one
  // doesn't tie a page to the previous session (a Google search is how you
  // start a new subject, not continue the old one).
  const SEARCH_HOST_RE =
    /(^|\.)(google|bing|duckduckgo|yahoo|ecosia|baidu|yandex|brave|startpage|qwant|ask)\.[a-z.]+$/i;

  function hostOf(url) {
    try {
      return new URL(url).hostname;
    } catch {
      return "";
    }
  }

  // Is this URL a search engine / SERP? Such pages don't anchor a session.
  function isSearchUrl(url) {
    return SEARCH_HOST_RE.test(hostOf(url));
  }

  // Keywords of the `TOPIC_WINDOW` most-recently-visited pages in `sessionId`.
  function recentSessionKeywords(graph, sessionId, limit) {
    const cap = limit || TOPIC_WINDOW;
    const inSession = [];
    const nodes = (graph && graph.nodes) || {};
    for (const url in nodes) {
      if (nodes[url].sessionId === sessionId) inSession.push(nodes[url]);
    }
    inSession.sort((a, b) => (b.lastVisit || 0) - (a.lastVisit || 0));
    const kws = new Set();
    for (const n of inSession.slice(0, cap)) {
      for (const k of n.keywords || []) kws.add(k);
    }
    return kws;
  }

  // Does the candidate page share at least one keyword with the recent set?
  // How many keywords the candidate shares with a session's recent pages.
  function topicOverlap(candidateKeywords, recentSet) {
    let n = 0;
    for (const k of candidateKeywords || []) if (recentSet.has(k)) n++;
    return n;
  }
  function sharesTopic(candidateKeywords, recentSet) {
    return topicOverlap(candidateKeywords, recentSet) > 0;
  }

  // The existing session (excluding `excludeId`) whose recent pages overlap the
  // candidate's keywords the most. Used to resume an earlier thread you've
  // returned to instead of fragmenting it into a new session.
  function bestSessionByOverlap(graph, candidateKeywords, excludeId) {
    const sessions = (graph && graph.sessions) || {};
    let bestId = null;
    let best = 0;
    for (const id in sessions) {
      if (id === excludeId) continue;
      const overlap = topicOverlap(candidateKeywords, recentSessionKeywords(graph, id));
      if (overlap > best) {
        best = overlap;
        bestId = id;
      }
    }
    return { id: bestId, overlap: best };
  }

  // Decide which session a freshly-recorded page belongs to. Pure: all state
  // (now, meta, graph) is passed in. Returns { isNew, sessionId, reason }:
  //   - isNew=true  → caller mints a new session id (sessionId is null)
  //   - isNew=false → use `sessionId` (continue the current one, or resume an
  //                   earlier one you've returned to)
  //
  // Priority:
  //   first       — no current session yet → new
  //   manual      — forced override → new
  //   continue    — on the current topic, or a link-click trail within it
  //   resume      — off the current topic but matches an EARLIER session (you
  //                 came back to it) → rejoin that session, don't fragment
  //   idle        — nothing to continue/resume and it's been a while → new
  //   topic-switch— a genuinely unrelated fresh/search navigation → new
  function decideSession(opts) {
    const {
      now = 0,
      candidateKeywords = [],
      prevUrl = null,
      forceNew = false,
      meta = {},
      graph = {},
    } = opts || {};

    const cur = meta.currentSessionId;
    const sessions = (graph && graph.sessions) || {};

    if (!cur || !sessions[cur]) return { isNew: true, sessionId: null, reason: "first" };
    if (forceNew) return { isNew: true, sessionId: null, reason: "manual" };

    // Continue the current session if on-topic or you followed a link within it.
    const related = sharesTopic(candidateKeywords, recentSessionKeywords(graph, cur));
    const prev = prevUrl && graph.nodes ? graph.nodes[prevUrl] : null;
    const cameFromSession = !!prev && prev.sessionId === cur && !isSearchUrl(prevUrl);
    if (related || cameFromSession)
      return { isNew: false, sessionId: cur, reason: "continue" };

    // Off the current topic — but if it clearly belongs to an earlier session,
    // resume that one rather than spawning a near-duplicate.
    const best = bestSessionByOverlap(graph, candidateKeywords, cur);
    if (best.id) return { isNew: false, sessionId: best.id, reason: "resume" };

    // Genuinely new: a fresh topic, or a long idle gap with nothing to resume.
    const idle = now - (meta.lastActivityTs || 0) > SESSION_IDLE_MS;
    return { isNew: true, sessionId: null, reason: idle ? "idle" : "topic-switch" };
  }

  const api = {
    SESSION_IDLE_MS,
    TOPIC_WINDOW,
    isSearchUrl,
    recentSessionKeywords,
    sharesTopic,
    topicOverlap,
    bestSessionByOverlap,
    decideSession,
  };

  if (typeof module !== "undefined" && module.exports) {
    module.exports = api;
  } else {
    Object.assign(root, api);
  }
})(typeof globalThis !== "undefined" ? globalThis : self);
