// OrbitCosmo blob → IndexedDB migration — pure transforms, no chrome.* access
// (specs/phase1-event-log.md). Dual-exported like the other helper layers so
// tools/test-migrate.js can require() it and store.js imports it (as an ES
// side-effect module).
//
// The one-time migration turns the legacy `orbit_graph` blob into the
// witnessed stores (events, snapshots, embeddings); the derived projection
// is then REPLAYED from them (projections.js), so migrated and organic
// graphs are identical in kind. Aggregates are all the blob preserved, so
// the initial event log is *synthesized* from them — every synthesized
// event is flagged, and navigation-edge weights round-trip exactly
// (verifyMigration proves it before the blob is deleted).

(function (root) {
  "use strict";

  // FNV-1a over the embed-input text — the contentHash that gates re-embeds
  // (same page revisited with unchanged content skips the model entirely).
  function contentHash(text) {
    let h = 0x811c9dc5;
    for (let i = 0; i < text.length; i++) {
      h ^= text.charCodeAt(i);
      h = Math.imul(h, 0x01000193);
    }
    return (h >>> 0).toString(36);
  }

  // The exact text a page is embedded from (lives here so
  // snapshot hashing and embedding stay in lockstep).
  function embedInputText(node) {
    return [node.title, node.description, (node.keywords || []).join(" "), node.content]
      .filter(Boolean)
      .join(". ")
      .slice(0, 2000);
  }

  // Spread `count` visit timestamps between first and last inclusive.
  function spreadTimestamps(first, last, count) {
    if (count <= 1) return [last || first || 0];
    const a = first || last || 0;
    const b = last || first || 0;
    const out = [];
    for (let i = 0; i < count; i++) out.push(Math.round(a + ((b - a) * i) / (count - 1)));
    return out;
  }

  // blob → { events, snapshots, embeddings }
  // - events: synthesized visits (fromUrl marked so the navigation fold
  //   round-trips today's nav-edge weights exactly) + manual-link events
  // - snapshots/embeddings: witnessed content split out of the nodes
  function blobToStores(blob, now) {
    const nodes = blob.nodes || {};
    const edges = blob.edges || {};
    const events = [];
    const snapshots = [];
    const embeddings = [];

    // Per-node visit events (fromUrl filled from nav edges below).
    const eventsByUrl = {};
    for (const url of Object.keys(nodes)) {
      const n = nodes[url];
      const stamps = spreadTimestamps(n.firstVisit, n.lastVisit, Math.max(1, n.visits || 1));
      eventsByUrl[url] = stamps.map((ts) => ({
        ts, kind: "visit", url, fromUrl: null, synthesized: true,
      }));

      const hash = contentHash(embedInputText(n));
      snapshots.push({
        url,
        title: n.title || "",
        description: n.description || "",
        keywords: n.keywords || [],
        content: n.content || "",
        favicon: n.favicon || "",
        domain: n.domain || "",
        contentHash: hash,
      });
      if (Array.isArray(n.embedding)) {
        embeddings.push({ url, contentHash: hash, vec: n.embedding });
      }
    }

    // Navigation edges → fromUrl on the target's events, exactly `weight`
    // marks per (from → to) pair; synthesize extra events if the target has
    // fewer unmarked visits than its incoming weights sum to.
    for (const key of Object.keys(edges)) {
      const e = edges[key];
      if (e.type !== "navigation") continue;
      if (!nodes[e.source] || !nodes[e.target]) continue;
      let remaining = Math.max(1, Math.round(e.weight || 1));
      const targetEvents = eventsByUrl[e.target];
      for (const ev of targetEvents) {
        if (!remaining) break;
        if (ev.fromUrl === null) { ev.fromUrl = e.source; remaining--; }
      }
      const lastTs = targetEvents[targetEvents.length - 1].ts;
      while (remaining--) {
        targetEvents.push({
          ts: lastTs, kind: "visit", url: e.target, fromUrl: e.source, synthesized: true,
        });
      }
    }
    for (const url of Object.keys(eventsByUrl)) events.push(...eventsByUrl[url]);

    // Manual edges are witnessed acts — carried over as manual-link events
    // (and kept verbatim in the projection like every other edge).
    for (const key of Object.keys(edges)) {
      const e = edges[key];
      if (e.type !== "manual") continue;
      events.push({
        ts: now, kind: "manual-link", url: e.source, otherUrl: e.target, synthesized: true,
      });
    }
    events.sort((a, b) => a.ts - b.ts);

    return { events, snapshots, embeddings };
  }

  // Prove the round-trip before the blob is deleted: node/snapshot counts,
  // per-pair navigation weights vs fromUrl event counts, manual-edge counts,
  // every embedding row backed by a snapshot.
  function verifyMigration(blob, out) {
    const problems = [];
    const nodeCount = Object.keys(blob.nodes || {}).length;
    if (out.snapshots.length !== nodeCount)
      problems.push(`snapshots ${out.snapshots.length} != nodes ${nodeCount}`);

    const wantPairs = {};
    for (const key of Object.keys(blob.edges || {})) {
      const e = blob.edges[key];
      if (e.type === "navigation" && blob.nodes[e.source] && blob.nodes[e.target])
        wantPairs[`${e.source}=>${e.target}`] = Math.max(1, Math.round(e.weight || 1));
    }
    const gotPairs = {};
    for (const ev of out.events) {
      if (ev.kind === "visit" && ev.fromUrl)
        gotPairs[`${ev.fromUrl}=>${ev.url}`] = (gotPairs[`${ev.fromUrl}=>${ev.url}`] || 0) + 1;
    }
    for (const pair of Object.keys(wantPairs)) {
      if (gotPairs[pair] !== wantPairs[pair])
        problems.push(`nav ${pair}: events ${gotPairs[pair] || 0} != weight ${wantPairs[pair]}`);
    }
    for (const pair of Object.keys(gotPairs)) {
      if (!(pair in wantPairs)) problems.push(`nav ${pair}: unexpected fromUrl events`);
    }

    const manualWant = Object.values(blob.edges || {}).filter((e) => e.type === "manual").length;
    const manualGot = out.events.filter((e) => e.kind === "manual-link").length;
    if (manualWant !== manualGot)
      problems.push(`manual-link events ${manualGot} != manual edges ${manualWant}`);

    const snapUrls = new Set(out.snapshots.map((s) => s.url));
    for (const row of out.embeddings) {
      if (!snapUrls.has(row.url)) problems.push(`embedding without snapshot: ${row.url}`);
    }

    return { ok: problems.length === 0, problems };
  }

  const api = { contentHash, embedInputText, blobToStores, verifyMigration };

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