// OrbitCosmo visit/embed pipeline — how a page you read becomes memory: tab
// lineage, the witnessed writes (event + snapshot), the projection fold, the
// offscreen embedding round-trip, and recall search. One reason to change:
// how visits are captured and enriched.
//
// The pure layers attach to globalThis (dual-export IIFEs); store.js imports
// the graph-building ones, and search.js (searchGraph, MIN_QUERY_EMBED_CHARS)
// is imported here — this module is its only worker-side consumer.
import "./search.js";
import { PROJ_DEPS, loadGraph, loadDecoratedGraph, saveGraph, shapeForWire } from "./store.js";

// Event retention (specs/phase1-event-log.md): events touching live pages are
// always kept; for pages with no snapshot, keep the larger of these windows.
const RETAIN_EVENTS = 10000;
const RETAIN_DAYS = 180;

// Tracks where each tab came from, so we can draw navigation edges.
// tabId -> { url, pending? }. Mirrored in chrome.storage.session: MV3 kills
// this worker after ~30s idle, and an in-memory-only map would silently drop
// the navigation edge for the first click after any suspension.
// storage.session survives worker restarts and clears when the browser
// closes — the correct lifetime for tab lineage.
const TABSTATE_KEY = "orbit_tabState";
let tabState = null; // hydrated Map, valid once tabStateReady resolves
let tabStateReady = null;
function getTabState() {
  if (!tabStateReady) {
    tabStateReady = chrome.storage.session
      .get(TABSTATE_KEY)
      .then((d) => {
        tabState = new Map(
          Object.entries(d[TABSTATE_KEY] || {}).map(([k, v]) => [Number(k), v])
        );
        return tabState;
      })
      .catch(() => (tabState = new Map()));
  }
  return tabStateReady;
}
function persistTabState() {
  const obj = {};
  for (const [k, v] of tabState) obj[k] = v;
  chrome.storage.session.set({ [TABSTATE_KEY]: obj }).catch(() => {});
}

// Track navigation so new tabs link back to their opener.
chrome.webNavigation.onCreatedNavigationTarget?.addListener(async (details) => {
  const tabs = await getTabState();
  const sourceTab = tabs.get(details.sourceTabId);
  if (sourceTab) {
    tabs.set(details.tabId, { url: sourceTab.url, pending: true });
    persistTabState();
  }
});
chrome.tabs.onRemoved.addListener(async (tabId) => {
  const tabs = await getTabState();
  if (tabs.delete(tabId)) persistTabState();
});

// Serialize every projection load-mutate-save cycle. recordPage and
// embedAndConnect interleave awaits between loadGraph and saveGraph, so two
// in-flight cycles clobber each other's writes (last save wins — lost
// navigation edges, lost embeddings). Read-only paths (getGraph, search)
// stay outside the lock: a slightly stale read is harmless. The one-time
// migration/replay in ensureMigrated needs no lock — every load awaits it
// before anything else runs.
let graphLock = Promise.resolve();
function withGraph(fn) {
  const run = graphLock.then(fn, fn);
  graphLock = run.catch(() => {}); // keep the chain alive after a failed cycle
  return run;
}

// --- maintenance: the chrome.alarms sweep ------------------------------------
// One periodic alarm does the deferred work: (1) the dirty sweep — pages
// whose content changed but whose re-embed never landed (worker killed
// mid-flight; the queue is persisted, so the rebuild survives restarts);
// (2) event retention for pages that no longer have a snapshot.
const MAINTENANCE_ALARM = "orbit-maintenance";
chrome.alarms?.create?.(MAINTENANCE_ALARM, { periodInMinutes: 60 });
chrome.alarms?.onAlarm?.addListener((alarm) => {
  if (alarm.name === MAINTENANCE_ALARM) runMaintenance().catch(() => {});
});

async function runMaintenance() {
  // Dirty sweep: embedAndConnect is hash-gated and locked, so re-driving a
  // row is safe; an embed failure leaves the row for the next sweep.
  const dirty = await dbGetAll("dirty");
  for (const row of dirty) {
    const snap = await dbGet("snapshots", row.url);
    if (!snap) { await dbDelete("dirty", row.url); continue; }
    await embedAndConnect(row.url, snap);
  }
  // Retention: pure policy (projections.js), IDB keys via cursor.
  const rows = await dbGetAllWithKeys("events");
  const live = new Set((await dbGetAll("snapshots")).map((s) => s.url));
  const drops = retentionDrops(rows, live, Date.now(), {
    maxEvents: RETAIN_EVENTS, maxAgeDays: RETAIN_DAYS,
  });
  if (drops.length) await dbDeleteMany("events", drops);
}

// --- Record a visited page (from the content script) ------------------------
export async function recordPage(payload, sender) {
  const url = normalizeUrl(payload.url);
  if (!url || !url.startsWith("http")) return;

  const node = await withGraph(() => recordPageLocked(url, payload, sender));

  // Asynchronously enrich the page with a semantic embedding and
  // meaning-based edges (runs the local model in the offscreen document).
  // Outside the lock: it queues its own write cycle via withGraph.
  embedAndConnect(url, node).catch(() => {});
}

// The load-mutate-save cycle for a visit — only ever runs under withGraph.
async function recordPageLocked(url, payload, sender) {
  const graph = await loadDecoratedGraph();
  const now = Date.now();

  // Tab lineage: where did this tab come from?
  const tabId = sender?.tab?.id;
  const tabs = await getTabState();
  let prevUrl = null;
  if (tabId != null) {
    const prev = tabs.get(tabId);
    if (prev && prev.url && prev.url !== url) prevUrl = prev.url;
    tabs.set(tabId, { url });
    persistTabState();
  }

  // Witnessed record first: the real visit event (fromUrl kept even for SERP
  // pairs — the projection fold applies the exclusion rule, not the log) and
  // the latest-wins snapshot. A content change queues a re-embed.
  const event = { ts: now, kind: "visit", url, fromUrl: prevUrl };
  await dbAdd("events", event);
  const prevSnap = await dbGet("snapshots", url);
  const snapshot = {
    url,
    title: payload.title || prevSnap?.title || url,
    description: payload.description || prevSnap?.description || "",
    keywords: payload.keywords && payload.keywords.length
      ? payload.keywords
      : prevSnap?.keywords || [],
    content: (payload.content || prevSnap?.content || "").slice(0, 2500),
    // What the page references (content.js) — latest-wins like the other
    // fields; older snapshots simply lack it (every consumer treats it as
    // optional). Carries refs.isbns too: witnessed-only for now, nothing
    // derives from them yet. NOT embed input: embedInputText picks title/
    // description/keywords/content by name, so refs never perturb the
    // contentHash and a refs-only change never triggers a re-embed.
    refs: payload.refs || prevSnap?.refs || null,
    // A transcript capture marks the page for good (sticky latest-wins —
    // present only as true). The transcript TEXT needs no field of its own:
    // it rides `content` above, so the contentHash changes and the dirty →
    // re-embed path below fires with zero transcript-aware plumbing.
    ...(payload.transcriptCaptured || prevSnap?.transcriptCaptured
      ? { transcriptCaptured: true }
      : {}),
    favicon: payload.favicon || prevSnap?.favicon || "",
    domain: (() => {
      try { return new URL(url).hostname; } catch { return ""; }
    })(),
  };
  snapshot.contentHash = contentHash(embedInputText(snapshot));
  await dbPut("snapshots", snapshot);
  if (!prevSnap || prevSnap.contentHash !== snapshot.contentHash) {
    await dbPut("dirty", { url, ts: now });
  }

  // Derived second: fold the event into the projection — node upsert, the
  // navigation edge (SERP exclusion at fold time), session assignment, and
  // exact concept resolution, all via the same pure fold replay uses
  // (projections.js: fold one event == replay of length 1).
  const node = applyVisitEvent(graph, event, snapshot, PROJ_DEPS);

  // Related edges — incremental blended mutual-kNN pass around this page
  // (O(N), see related.js). Runs here so lexical + click relatedness lands
  // immediately (and is the only rebuild path when embedding fails); the
  // semantic term joins once the async embedding lands in embedAndConnect.
  rebuildRelatedEdgesForPage(graph, url, { isExcludedUrl: isSearchUrl, now });

  stampCentrality(graph);
  await saveGraph(graph);
  return node;
}

// --- Local semantic embeddings (offscreen document) --------------------------
let offscreenSetup = null;
async function ensureOffscreen() {
  const contexts = await chrome.runtime.getContexts({
    contextTypes: ["OFFSCREEN_DOCUMENT"],
  });
  if (contexts.length) return;
  if (!offscreenSetup) {
    offscreenSetup = chrome.offscreen
      .createDocument({
        url: "src/offscreen.html",
        reasons: ["WORKERS"],
        justification:
          "Run the local embedding model that connects pages by meaning.",
      })
      .catch((e) => {
        // Ignore the race where another call already created it.
        if (!String(e).includes("Only a single offscreen")) throw e;
      })
      .finally(() => {
        offscreenSetup = null;
      });
  }
  await offscreenSetup;
}

export function embedText(text) {
  return ensureOffscreen()
    .then(
      () =>
        new Promise((resolve) => {
          chrome.runtime.sendMessage(
            { type: "orbit:embed", target: "offscreen", text },
            (resp) => {
              if (chrome.runtime.lastError || !resp || !resp.ok) {
                resolve(null);
                return;
              }
              resolve(resp.embedding);
            }
          );
        })
    )
    .catch(() => null);
}

async function embedAndConnect(url, node) {
  const text = embedInputText(node);
  if (!text) return;
  // The contentHash gate: unchanged content is already embedded — skip the
  // model entirely (kills the old re-embed-on-every-revisit waste).
  const hash = contentHash(text);
  const existing = await dbGet("embeddings", url);
  if (existing && existing.contentHash === hash) {
    // Already embedded for this exact content — the dirty row (if any) is
    // satisfied; clear it so the maintenance sweep doesn't re-drive it.
    await dbDelete("dirty", url);
    return;
  }
  // Model work stays outside the graph lock — a cold-start embedding takes
  // seconds and must not stall queued visit writes.
  const vec = await embedText(text);
  if (!vec) return;
  await withGraph(() => embedAndConnectLocked(url, hash, vec));
}

// The embedding's load-mutate-save cycle — only ever runs under withGraph.
// The concept-embed awaits inside are mid-cycle awaits (the exact lost-update
// hazard), which is why the whole cycle holds the lock.
async function embedAndConnectLocked(url, hash, vec) {
  const graph = await loadDecoratedGraph();
  const target = graph.nodes[url];
  if (!target) return; // node was removed while embedding
  await dbPut("embeddings", { url, contentHash: hash, vec: Float32Array.from(vec) });
  await dbDelete("dirty", url);
  target.embedding = vec;

  // Incremental related-edge pass now the semantic term is available —
  // O(N) around this page instead of the old full O(N²) rebuild (the cost
  // that forced the 500-page cap). Bystander-pair staleness self-heals; see
  // rebuildRelatedEdgesForPage in related.js.
  rebuildRelatedEdgesForPage(graph, url, { isExcludedUrl: isSearchUrl, now: Date.now() });

  // Concept layer: embed any new concepts this page introduced, then fold
  // near-duplicates into existing concepts (embedding-based entity resolution —
  // "ML" / "machine learning" / "Machine Learning" become one).
  for (const cid of (target.conceptIds || []).slice()) {
    const c = graph.concepts[cid];
    if (!c || Array.isArray(c.embedding)) continue; // gone, or already embedded
    const cvec = await embedText(c.label);
    if (!cvec || !graph.concepts[cid]) continue;
    graph.concepts[cid].embedding = cvec;
    const match = bestConceptMatch(cvec, graph.concepts, CONCEPT_MERGE_MIN, cid);
    if (match.id) mergeConcept(graph, cid, match.id);
  }
  pruneConcepts(graph);

  stampCentrality(graph);
  await saveGraph(graph);
}

// --- Recall search (the panel's and app window's Memory view) ----------------
// Embeds the query locally (offscreen MiniLM — no key, no network) and ranks
// via the pure searchGraph blend. Returns stripped nodes only: embeddings
// stay in the worker. embedText already resolves null on any failure, so a
// cold-start hiccup or too-short query degrades to lexical ranking.
// --- Temporal recall ("what else was I reading in that sitting?") ------------
// Ranks the pages that shared sittings with `url` (rankSitting, covisit.js);
// `other` optionally fetches the raw pair fact for one companion. Read-only,
// OUTSIDE withGraph like handleSearch — a slightly stale read is harmless.
// Decorated read: the similarity boost ranks against the stored vectors, but
// the response carries the ranked slice only — no embeddings, no node bodies
// (the panel already holds the wire graph for titles).
export async function handleSitting(rawUrl, rawOther) {
  try {
    const url = normalizeUrl(rawUrl);
    const graph = await loadDecoratedGraph();
    const items = rankSitting(graph, url, Date.now());
    let pair = null;
    if (rawOther) {
      const p = graph.covisits[covisitPairKey(url, normalizeUrl(rawOther))];
      if (p) pair = { count: p.count, lastSeen: p.lastSeen };
    }
    return { ok: true, items, pair };
  } catch {
    return { ok: false };
  }
}

// --- Reading queue ("referenced by things you've read, never opened") --------
// Ranks the ghosts (references.js) for the orbit:queue message. Read-only,
// OUTSIDE withGraph like the other reads — a slightly stale read is
// harmless. Plain loadGraph, not the decorated one: the ranking needs no
// embeddings (ghosts have none by definition), so the vectors stay unloaded.
export async function handleQueue() {
  try {
    const graph = await loadGraph();
    return { ok: true, items: rankReadingQueue(graph, Date.now()) };
  } catch {
    return { ok: false };
  }
}

export async function handleSearch(rawQuery) {
  const q = String(rawQuery || "").trim();
  // Decorated read: the semantic term ranks against the stored vectors.
  const graph = await loadDecoratedGraph();
  if (!q || !Object.keys(graph.nodes).length) {
    return { ok: true, results: [], semantic: false };
  }
  const queryVec = q.length >= MIN_QUERY_EMBED_CHARS ? await embedText(q) : null;
  const ranked = searchGraph(q, queryVec, graph.nodes, Date.now());
  const stripped = shapeForWire(graph);
  return {
    ok: true,
    semantic: !!queryVec,
    results: ranked.map((r) => ({
      node: stripped.nodes[r.url],
      score: r.score,
      matchReasons: r.matchReasons,
    })),
  };
}
