// OrbitCosmo wiki orchestration — the stateful side of the LLM-maintained wiki.
// Pure decisions (prompts, validation, the upsert fold) live in
// wiki-schema.js; the network call lives in claude.js; this module owns
// storage, sequencing, and the message surface:
//
//   ingest    — one browsing session → one Claude call → validated page
//               upserts → one atomic IDB write → local re-embed → rev bump
//   ask       — rank wiki pages for a question, answer with citations that
//               resolve to graph nodes
//   file/revert/list/page/status/export — the panel/app surface
//
// Crash-safety: an ingest records nothing until its single dbAtomicWrite
// commits (wikiState ledger included), so a killed worker retries cleanly
// on the next sweep. Its own hourly alarm (not pipeline's) keeps the import
// graph acyclic: wiki → {store, pipeline}, never the reverse.

import "./wiki-schema.js";
import "./claude.js";
import { loadGraph } from "./store.js";
import { embedText } from "./pipeline.js";

const WIKI_REV_KEY = "orbit_wiki_rev"; // storage.local stamp the UI listens to
const WIKI_ALARM = "orbit-wiki-sweep";
const EMBED_CHARS = 2400; // title+body budget for a wiki page's local vector

function bumpWikiRev() {
  return chrome.storage.local.set({ [WIKI_REV_KEY]: Date.now() });
}

// --- gather ------------------------------------------------------------------

async function wikiMap() {
  const rows = await dbGetAll("wiki");
  const map = {};
  for (const r of rows) map[r.slug] = r;
  return map;
}

async function readState() {
  return (await dbGet("wikiState", "state")) || { id: "state", ingested: {} };
}

// A session's ingestable view: graph nodes + their snapshots.
async function gatherSession(graph, sid) {
  const nodes = Object.values(graph.nodes).filter((n) => n.sessionId === sid);
  const pages = [];
  for (const n of nodes) {
    const snap = await dbGet("snapshots", n.url);
    pages.push({
      url: n.url,
      title: n.title,
      description: n.description,
      keywords: n.keywords || [],
      content: (snap && snap.content) || n.content || "",
      contentHash: (snap && snap.contentHash) || "",
      conceptIds: n.conceptIds || [],
    });
  }
  const session = graph.sessions[sid];
  return { label: (session && session.label) || sid, pages };
}

// Up to 5 existing pages the model should update rather than duplicate:
// scored by shared concept ids, then simple title-word overlap.
function relevantExistingPages(map, sessionPages) {
  const sessionConcepts = new Set(sessionPages.flatMap((p) => p.conceptIds));
  const words = new Set(
    sessionPages.flatMap((p) => String(p.title || "").toLowerCase().split(/[^a-z0-9]+/))
  );
  return Object.values(map)
    .filter((p) => p.kind === "concept" || p.kind === "session")
    .map((p) => {
      const cOverlap = (p.conceptIds || []).filter((c) => sessionConcepts.has(c)).length;
      const wOverlap = p.title.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 3 && words.has(w)).length;
      return { p, score: cOverlap * 3 + wOverlap };
    })
    .filter((x) => x.score > 0)
    .sort((a, b) => b.score - a.score)
    .slice(0, 5)
    .map((x) => x.p);
}

// --- ingest --------------------------------------------------------------------

let ingestLock = null; // one ingest at a time; a fresh worker starts unlocked

export function ingestSession(sessionId) {
  const run = (ingestLock || Promise.resolve()).catch(() => {}).then(() => ingestOne(sessionId));
  ingestLock = run;
  return run;
}

async function ingestOne(sessionId) {
  const graph = await loadGraph();
  const sid = sessionId || graph.meta.currentSessionId;
  if (!sid || !graph.sessions[sid]) return { ok: false, code: "no-session", message: "No session to ingest yet." };

  const { label, pages } = await gatherSession(graph, sid);
  if (pages.length < MIN_SESSION_PAGES) {
    return { ok: false, code: "too-small", message: `That session has under ${MIN_SESSION_PAGES} pages — browse a bit more first.` };
  }

  const inputHash = sessionInputHash(pages);
  const state = await readState();
  if (state.ingested[sid] && state.ingested[sid].inputHash === inputHash) {
    return { ok: true, skipped: "unchanged", message: "That session is already in the wiki." };
  }

  const map = await wikiMap();
  const prompt = buildIngestPrompt({
    schemaDoc: WIKI_SCHEMA_DOC,
    indexMd: map.index ? map.index.markdown : "",
    existingPages: relevantExistingPages(map, pages),
    sessionLabel: label,
    sessionPages: pages,
  });

  const resp = await callClaude({
    system: prompt.system,
    messages: prompt.messages,
    outputSchema: INGEST_OUTPUT_SCHEMA,
  });
  if (!resp.ok) return resp;

  const sessionUrls = new Set(pages.map((p) => p.url));
  const valid = validateIngestOutput(resp.data, { sessionUrls, normalizeUrl, wikiMap: map });
  if (valid.error) return { ok: false, code: "invalid-output", message: "The model returned nothing usable — try again." };

  // Stamp concept ids from the cited graph nodes so sheet chips can match.
  const byUrl = {};
  for (const p of pages) byUrl[p.url] = p;
  for (const u of valid.pages) {
    const ids = new Set();
    for (const url of u.citedUrls) for (const c of (byUrl[url] || {}).conceptIds || []) ids.add(c);
    u.conceptIds = [...ids];
  }

  const { puts, versionPuts, versionDeletes } = applyPageUpserts(map, valid.pages, {
    now: Date.now(),
    logLine: valid.logLine,
    reason: "ingest",
    schemaDoc: WIKI_SCHEMA_DOC,
  });

  const nextState = await readState();
  nextState.ingested[sid] = { inputHash, ts: Date.now() };
  await dbAtomicWrite(["wiki", "wikiVersions", "wikiState"], (wiki, versions, st) => {
    for (const p of puts) wiki.put(p);
    for (const v of versionPuts) versions.put(v);
    for (const key of versionDeletes) versions.delete(key);
    st.put(nextState);
  });

  await embedPages(puts.filter((p) => p.kind === "concept" || p.kind === "session" || p.kind === "answer"));
  await bumpWikiRev();
  return { ok: true, pages: puts.map((p) => p.slug), spend: resp.spend };
}

// Local MiniLM vectors for wiki pages — same shape as the page embeddings
// store; failures are fine, the sweep backfills by hash.
async function embedPages(pages) {
  for (const p of pages) {
    const vec = await embedText(`${p.title}\n${p.markdown}`.slice(0, EMBED_CHARS));
    if (vec) await dbPut("wikiEmbeddings", { slug: p.slug, contentHash: p.contentHash, vec });
  }
}

// --- ask -----------------------------------------------------------------------

function dot(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;
}

export async function answerQuestion({ urls, question, facts }) {
  const map = await wikiMap();
  const candidates = Object.values(map).filter(
    (p) => p.kind === "concept" || p.kind === "session" || p.kind === "answer"
  );
  if (!candidates.length) {
    return { ok: false, code: "empty-wiki", message: "The wiki has no pages yet — run \"Update wiki now\" first." };
  }

  const graph = await loadGraph();
  const selConcepts = new Set(
    (urls || []).flatMap((u) => (graph.nodes[u] ? graph.nodes[u].conceptIds || [] : []))
  );
  const qVec = await embedText(question);
  const vecs = {};
  for (const row of await dbGetAll("wikiEmbeddings")) vecs[row.slug] = row.vec;
  const qWords = new Set(question.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 3));

  const ranked = candidates
    .map((p) => {
      const sem = qVec && vecs[p.slug] ? dot(Array.from(qVec), Array.from(vecs[p.slug])) : 0;
      const cOverlap = (p.conceptIds || []).filter((c) => selConcepts.has(c)).length;
      const citesSel = (urls || []).some((u) => (p.citedUrls || []).includes(u)) ? 1 : 0;
      const lex = p.title.toLowerCase().split(/[^a-z0-9]+/).filter((w) => qWords.has(w)).length;
      return { p, score: sem + 0.3 * cOverlap + 0.5 * citesSel + 0.2 * lex };
    })
    .sort((a, b) => b.score - a.score)
    .slice(0, 5)
    .map((x) => x.p);

  const prompt = buildQueryPrompt({ question, candidatePages: ranked, facts: facts || [] });
  const resp = await callClaude({
    system: prompt.system,
    messages: prompt.messages,
    outputSchema: ANSWER_OUTPUT_SCHEMA,
    maxTokens: 1024,
  });
  if (!resp.ok) return resp;

  const knownUrls = new Set([...Object.keys(graph.nodes), ...Object.keys(graph.ghosts || {})]);
  const valid = validateAnswer(resp.data, { knownUrls, normalizeUrl });
  if (valid.error) return { ok: false, code: "invalid-output", message: "The model returned nothing usable — try again." };
  return { ok: true, answer: valid.answer, citations: valid.citations, confident: valid.confident, question };
}

// --- file / revert ---------------------------------------------------------------

async function upsertOutsideIngest(upsert, { logLine, reason }) {
  const map = await wikiMap();
  const { puts, versionPuts, versionDeletes } = applyPageUpserts(map, [upsert], {
    now: Date.now(),
    logLine,
    reason,
    schemaDoc: WIKI_SCHEMA_DOC,
  });
  await dbAtomicWrite(["wiki", "wikiVersions"], (wiki, versions) => {
    for (const p of puts) wiki.put(p);
    for (const v of versionPuts) versions.put(v);
    for (const key of versionDeletes) versions.delete(key);
  });
  await embedPages(puts.filter((p) => !RESERVED_SLUGS.includes(p.slug)));
  await bumpWikiRev();
  return puts;
}

export async function fileAnswer({ question, answer, citations }) {
  const map = await wikiMap();
  const title = `Q: ${String(question).slice(0, 100)}`;
  const md = [
    `> ${question}`,
    "",
    answer,
    ...(citations && citations.length
      ? ["", "## Sources", ...citations.map((c) => `- ${c.url}${c.quote ? ` — "${c.quote}"` : ""}`)]
      : []),
  ].join("\n");
  const slug = slugForTitle(title, map);
  await upsertOutsideIngest(
    { slug, title, kind: "answer", markdown: md, citedUrls: (citations || []).map((c) => c.url), links: [] },
    { logLine: "answer saved from the ask box", reason: "filed" }
  );
  return { ok: true, slug };
}

export async function revertPage({ slug, version }) {
  const old = await dbGet("wikiVersions", [slug, version]);
  const cur = await dbGet("wiki", slug);
  if (!old || !cur) return { ok: false, code: "no-version", message: "That version is gone." };
  await upsertOutsideIngest(
    { slug, title: cur.title, kind: cur.kind, markdown: old.markdown, citedUrls: cur.citedUrls, links: cur.links },
    { logLine: `reverted to v${version}`, reason: "revert" }
  );
  return { ok: true };
}

// --- surface ---------------------------------------------------------------------

export async function listPages() {
  const rows = await dbGetAll("wiki");
  return rows
    .map(({ slug, title, kind, updatedTs, version, conceptIds, citedUrls }) => ({
      slug, title, kind, updatedTs, version, conceptIds, citedUrls,
    }))
    .sort((a, b) => b.updatedTs - a.updatedTs);
}

export async function getPage(slug) {
  const page = await dbGet("wiki", slug);
  if (!page) return null;
  const all = await dbGetAll("wikiVersions");
  const versions = all
    .filter((v) => v.slug === slug)
    .map(({ version, ts, reason }) => ({ version, ts, reason }))
    .sort((a, b) => b.version - a.version);
  return { page, versions };
}

export async function wikiStatus() {
  const state = await readState();
  const settings = await getSettings();
  const pages = await dbGetAll("wiki");
  return {
    enabled: settings.wikiEnabled,
    route: settings.llmRoute,
    configured: settings.llmRoute === "byo" ? !!settings.anthropicKey : !!settings.proxyToken,
    model: settings.model,
    pageCount: pages.filter((p) => !RESERVED_SLUGS.includes(p.slug)).length,
    spend: state.spend || null,
    capUsd: settings.monthlyCapUsd,
    lastIngest: Object.values(state.ingested).reduce((m, r) => Math.max(m, r.ts), 0) || null,
    lastError: state.lastError || null,
  };
}

export async function exportWiki() {
  const rows = await dbGetAll("wiki");
  return rows.map(({ slug, markdown }) => ({ slug, markdown }));
}

// --- the hourly sweep ---------------------------------------------------------

chrome.alarms?.create?.(WIKI_ALARM, { periodInMinutes: 60 });
chrome.alarms?.onAlarm?.addListener((alarm) => {
  if (alarm.name === WIKI_ALARM) wikiSweep().catch(() => {});
});

export async function wikiSweep() {
  const settings = await getSettings();
  if (!settings.wikiEnabled) return;

  const graph = await loadGraph();
  const state = await readState();

  // Ledger hygiene: sessions can be re-derived under new ids by a
  // PROJECTION_VERSION replay; entries for vanished sessions just age out.
  let dirtyState = false;
  for (const sid of Object.keys(state.ingested)) {
    if (!graph.sessions[sid]) { delete state.ingested[sid]; dirtyState = true; }
  }
  if (dirtyState) await dbPut("wikiState", state);

  const candidates = [];
  for (const sid of Object.keys(graph.sessions)) {
    const session = graph.sessions[sid];
    const nodes = Object.values(graph.nodes).filter((n) => n.sessionId === sid);
    if (!nodes.length) continue;
    const pages = [];
    for (const n of nodes) {
      const snap = await dbGet("snapshots", n.url);
      pages.push({ url: n.url, contentHash: (snap && snap.contentHash) || "" });
    }
    candidates.push({ sid, lastSeen: session.lastSeen, pages });
  }

  const picks = pickIdleSessions(candidates, {
    currentSessionId: graph.meta.currentSessionId,
    ingested: state.ingested,
    now: Date.now(),
    idleMs: SESSION_IDLE_MS,
  });
  for (const pick of picks) {
    const res = await ingestSession(pick.sid);
    if (!res.ok && res.code !== "too-small") {
      const st = await readState();
      st.lastError = { ts: Date.now(), code: res.code, message: res.message };
      await dbPut("wikiState", st);
      break; // don't burn the second slot on a failing route
    }
  }

  // Embedding backfill: any page whose vector is missing or hash-stale.
  const pages = await dbGetAll("wiki");
  const vecs = {};
  for (const row of await dbGetAll("wikiEmbeddings")) vecs[row.slug] = row;
  const stale = pages.filter(
    (p) => !RESERVED_SLUGS.includes(p.slug) && (!vecs[p.slug] || vecs[p.slug].contentHash !== p.contentHash)
  );
  if (stale.length) await embedPages(stale);
}
