// OrbitCosmo store — everything about persisting and serving the graph: the
// projection's load/save/decorate cycle over IndexedDB (db.js), the one-time
// blob migration, the version-gated replay wake path, and the wire shaping
// the UI is served. One reason to change: how the graph is stored and served.
//
// The pure layers attach to globalThis (dual-export IIFEs, shared verbatim
// with the Node tests) — imported here for their side effects.
import "./urls.js";
import "./sessions.js";
import "./covisit.js";
import "./related.js";
import "./references.js";
import "./concepts.js";
import "./db.js";
import "./migrate.js";
import "./projections.js";

const LEGACY_BLOB_KEY = "orbit_graph"; // pre-IndexedDB blob, migrated then deleted
const REV_KEY = "orbit_graph_rev"; // storage.local change stamp the UI listens to

// Cross-layer dependencies the pure projection folds need — the worker's
// globals (Node tests inject require()d modules instead).
export const PROJ_DEPS = {
  decideSession, isSearchUrl, buildRelatedEdges, resolveConceptsExact, pruneConcepts,
  foldCovisit, foldReferences, normalizeUrl,
};

// Signal UI pages (side panel, app window) that the projection changed —
// they listen for this stamp in chrome.storage.onChanged, since IndexedDB
// writes fire no cross-context event.
function bumpRev() {
  return chrome.storage.local.set({ [REV_KEY]: Date.now() });
}

let migrationDone = null;
export function ensureMigrated() {
  if (!migrationDone) migrationDone = migrateThenReplayIfStale();
  return migrationDone;
}
async function migrateThenReplayIfStale() {
  await migrateBlobIfPresent();
  await replayIfStale();
}

// The wake path's version gate — the permanent replacement for the old
// migration-flag ladder: a PROJECTION_VERSION bump forces one full replay of
// the event log through the current folds, making every derived-rule change
// retroactive. Safe without the write lock: every loadGraph (reader or
// writer) awaits ensureMigrated first, and writers only write after their
// own load, so nothing interleaves with this one-time rebuild.
async function replayIfStale() {
  const rec = await dbGet("projections", "graph");
  if (!rec || rec.projectionVersion === PROJECTION_VERSION) return;
  const witnessed = {
    events: await dbGetAll("events"),
    snapshots: await dbGetAll("snapshots"),
    embeddings: await dbGetAll("embeddings"),
  };
  const proj = rebuildProjection(witnessed, PROJ_DEPS, Date.now());
  stampCentrality(proj);
  await saveGraph(proj);
}

// One-time, on the first wake with no `orbit` database: legacy blob →
// witnessed stores (specs/phase1-event-log.md), then the projection is
// REPLAYED from them with the same folds the live path uses — migrated and
// organic graphs are identical in kind. Counts are verified before the blob
// is deleted; on a verification failure the stores are still written (the
// product keeps working) but the blob is kept for diagnosis and the failure
// is logged loudly.
async function migrateBlobIfPresent() {
  const existing = await dbGet("projections", "graph");
  if (existing) return;
  const data = await chrome.storage.local.get(LEGACY_BLOB_KEY);
  const blob = data[LEGACY_BLOB_KEY];
  if (!blob) {
    await dbPut("projections", emptyProjection());
    return;
  }
  // Canonicalize URLs before extraction — the only piece of the old flag
  // ladder that touches witnessed data; every derived flag is subsumed by
  // replay. The long-orphaned housekeeping keys go regardless of blob age.
  if (!blob.nodes) blob.nodes = {};
  if (!blob.edges) blob.edges = {};
  if (!blob.meta) blob.meta = {};
  if (!blob.meta.dedupV1) dedupeGraph(blob);
  chrome.storage.local.remove(["orbit_settings", "orbit_tour_seen"]);
  chrome.storage.session?.remove?.("orbit_dwell_active");

  const out = blobToStores(blob, Date.now());
  const check = verifyMigration(blob, out);
  if (!check.ok) console.error("orbit: migration verification failed", check.problems);
  const proj = rebuildProjection(out, PROJ_DEPS, Date.now());
  stampCentrality(proj);
  for (const u in proj.nodes) delete proj.nodes[u].embedding;
  // All four stores commit in ONE transaction: ensureMigrated keys off the
  // projection record, so a crash mid-migration must leave either nothing
  // (clean retry) or everything — separate transactions would let a retry
  // append the synthesized event log twice, silently doubling nav weights.
  await dbAtomicWrite(
    ["events", "snapshots", "embeddings", "projections"],
    (ev, sn, em, pr) => {
      for (const e of out.events) ev.add(e);
      for (const s of out.snapshots) sn.put(s);
      for (const r of out.embeddings) em.put({ ...r, vec: Float32Array.from(r.vec) });
      pr.put(proj);
    }
  );
  if (check.ok) await chrome.storage.local.remove(LEGACY_BLOB_KEY);
  await bumpRev();
}

export async function loadGraph() {
  await ensureMigrated();
  return (await dbGet("projections", "graph")) || emptyProjection();
}

// Fold node `b` into node `a` (same page reached two ways). The later-visited
// one supplies the canonical descriptive fields; counts and sets combine.
function foldNodes(a, b) {
  const primary = (b.lastVisit || 0) > (a.lastVisit || 0) ? b : a;
  const other = primary === a ? b : a;
  const merged = {
    ...other, ...primary, // primary's descriptive fields (title/domain/sessionId/…) win
    visits: (a.visits || 0) + (b.visits || 0),
    firstVisit: Math.min(
      a.firstVisit || a.lastVisit || 0,
      b.firstVisit || b.lastVisit || 0
    ) || (primary.lastVisit || 0),
    lastVisit: Math.max(a.lastVisit || 0, b.lastVisit || 0),
    keywords: [...new Set([...(primary.keywords || []), ...(other.keywords || [])])].slice(0, 12),
    conceptIds: [...new Set([...(a.conceptIds || []), ...(b.conceptIds || [])])],
    embedding: Array.isArray(a.embedding) ? a.embedding : b.embedding,
  };
  return merged;
}

// Re-key every node by its canonical URL and merge collisions; re-point and
// dedup edges onto the survivors. Mutates `graph` in place; returns whether it
// changed anything. Only runs at migration time, on the legacy blob.
function dedupeGraph(graph) {
  if (!graph.nodes) return false;
  const remap = {}; // old key -> canonical key
  for (const oldUrl of Object.keys(graph.nodes)) {
    remap[oldUrl] = normalizeUrl(graph.nodes[oldUrl].url || oldUrl);
  }
  let changed = false;
  const newNodes = {};
  for (const oldUrl of Object.keys(graph.nodes)) {
    const canon = remap[oldUrl];
    if (canon !== oldUrl) changed = true;
    const node = { ...graph.nodes[oldUrl], url: canon };
    if (newNodes[canon]) {
      newNodes[canon] = { ...foldNodes(newNodes[canon], node), url: canon };
      changed = true;
    } else {
      newNodes[canon] = node;
    }
  }
  const newEdges = {};
  for (const key of Object.keys(graph.edges || {})) {
    const e = graph.edges[key];
    const s = remap[e.source] || e.source;
    const t = remap[e.target] || e.target;
    if (s === t || !newNodes[s] || !newNodes[t]) { changed = true; continue; } // self/orphan
    const nk = edgeKey(s, t, e.type);
    const ex = newEdges[nk];
    if (ex) {
      ex.weight = (ex.weight || 1) + (e.weight || 1);
      ex.lastSeen = Math.max(ex.lastSeen || 0, e.lastSeen || 0);
      changed = true;
    } else {
      newEdges[nk] = { source: s, target: t, type: e.type, weight: e.weight || 1, lastSeen: e.lastSeen || 0 };
    }
  }
  graph.nodes = newNodes;
  graph.edges = newEdges;
  return changed;
}

function edgeKey(a, b, type) {
  // Undirected for related/manual; directed for navigation.
  if (type === "navigation") return `nav::${a}=>${b}`;
  const [x, y] = [a, b].sort();
  return `${type}::${x}--${y}`;
}

// Persist the derived projection — never with inline vectors — and wake the
// UI via the rev stamp.
export async function saveGraph(graph) {
  graph.id = "graph";
  graph.projectionVersion = PROJECTION_VERSION;
  for (const u in graph.nodes) delete graph.nodes[u].embedding;
  await dbPut("projections", graph);
  await bumpRev();
}

// Attach stored vectors to projection nodes for write-path math (the related
// blend's semantic term, centrality) and semantic search ranking. saveGraph
// strips them again — vectors never persist inside the projection. Plain
// arrays because the pure layers test embeddings with Array.isArray.
export async function loadDecoratedGraph() {
  const graph = await loadGraph();
  const rows = await dbGetAll("embeddings");
  for (const row of rows) {
    if (graph.nodes[row.url]) graph.nodes[row.url].embedding = Array.from(row.vec);
  }
  return graph;
}

// Shape the projection for the UI. Centrality is stamped at write time and
// persisted on the node. The embedding destructure is load-bearing on the
// search path (handleSearch shapes a *decorated* graph — without the strip,
// the transient vectors would ship to the panel).
export function shapeForWire(graph) {
  const nodes = {};
  for (const url of Object.keys(graph.nodes)) {
    const { embedding, ...rest } = graph.nodes[url];
    rest.centrality = rest.centrality ?? null;
    rest.conceptIds = rest.conceptIds || [];
    nodes[url] = rest;
  }
  // The reference layer ships (Phase 4 PR B): live ghosts plus every
  // reference edge whose endpoints resolve in nodes ∪ ghosts. Edges
  // targeting a DORMANT (budget-evicted) ghost stay worker-side — they are
  // durable facts for the recount, not something the UI can draw. Each wire
  // ghost carries its derived refCount and sourceDomains so the UI never
  // recounts edges.
  const ghosts = {};
  for (const url of Object.keys(graph.ghosts || {})) {
    const { url: u, title, domain, firstSeen, lastSeen } = graph.ghosts[url];
    ghosts[url] = { url: u, title, domain, firstSeen, lastSeen, refCount: 0, sourceDomains: [] };
  }
  const domainsByGhost = {};
  const edges = {};
  for (const key of Object.keys(graph.edges)) {
    const e = graph.edges[key];
    if (e.type !== "reference") { edges[key] = e; continue; }
    if (!nodes[e.source] || !(nodes[e.target] || ghosts[e.target])) continue; // dormant target
    edges[key] = e;
    const g = ghosts[e.target];
    if (g) {
      g.refCount += 1;
      const set = domainsByGhost[e.target] || (domainsByGhost[e.target] = new Set());
      set.add(nodes[e.source].domain || "");
    }
  }
  for (const url of Object.keys(domainsByGhost)) {
    ghosts[url].sourceDomains = [...domainsByGhost[url]].sort();
  }
  return {
    nodes,
    edges,
    ghosts,
    concepts: stripConcepts(graph),
    sessions: graph.sessions || {},
    currentSessionId: graph.meta?.currentSessionId || null,
    // covisits deliberately does not ship (multi-MB at cap); the UI gets the
    // ranked slice via orbit:sitting instead.
  };
}
