// OrbitCosmo knowledge graph — the stage of the app window (app.html), beside the
// rail owned by app.js. Filled nodes are the pages you've read; hollow ghost
// nodes are pages your reading references but you haven't opened (Phase 4 —
// the graph must never blur "read" with "merely mentioned"). Edges are the
// relationships between them, spring-weighted by blended relatedness (the
// mutual-kNN "related" edge carries a 0..1 blend of embedding cosine, shared
// keywords, and click evidence — see related.js); reference edges draw thin,
// dashed and muted — quieter than everything else. Node size is embedding
// centrality. The layout is computed to rest BEFORE the first paint (no
// animation); interactions are pan, zoom, node drag, hover (title), and
// click, which opens the detail sheet (⇧-click a second node to see how two
// pages connect; the sheet's Open button opens the page itself — for a
// ghost, opening the page is exactly how it becomes real).

const canvas = document.getElementById("graph");
const ctx = canvas.getContext("2d");
const emptyEl = document.getElementById("empty");

// Open a page in the user's normal browser window — never in this popup
// window (a tab created here would be invisible: popups have no tab strip).
// Shared with the rail (app.js loads after this script).
function openPage(url) {
  if (!chrome?.windows?.getLastFocused) { window.open(url, "_blank"); return; }
  chrome.windows.getLastFocused({ windowTypes: ["normal"] }, (win) => {
    if (!chrome.runtime.lastError && win?.id != null) {
      chrome.tabs.create({ windowId: win.id, url, active: true });
      chrome.windows.update(win.id, { focused: true });
    } else {
      chrome.windows.create({ url });
    }
  });
}

// Mouse events arrive in viewport coordinates; the canvas sits beside the
// rail, so convert to canvas-local coordinates before hit-testing/zooming.
function canvasPoint(e) {
  const r = canvas.getBoundingClientRect();
  return { x: e.clientX - r.left, y: e.clientY - r.top };
}

// Frozen palette (see graph-theme decision): Eragon electric blue on charcoal;
// deep bronze variants on paper in light. One node colour — plain. Ghosts and
// reference edges introduce NO new hue: same colour, distinguished by form
// (hollow stroke, dashes) and muted alpha; ghost hover labels use the muted
// text tone.
const GRAPH_PALETTES = {
  dark:  { node: "#0099ff", nodeLine: "rgba(0,153,255,0.9)", edge: "rgba(0,153,255,0.28)", ghostLine: "rgba(0,153,255,0.5)", refEdge: "rgba(0,153,255,0.16)", label: "rgba(244,247,252,0.95)", ghostLabel: "rgba(226,232,243,0.6)", labelHalo: "rgba(28,28,32,0.85)" },
  light: { node: "#8a6d3b", nodeLine: "rgba(138,109,59,0.9)", edge: "rgba(138,109,59,0.30)", ghostLine: "rgba(138,109,59,0.5)", refEdge: "rgba(138,109,59,0.18)", label: "#1f1b14", ghostLabel: "rgba(43,38,30,0.62)", labelHalo: "rgba(250,248,244,0.9)" },
};
let PAL = GRAPH_PALETTES[OrbitTheme.get()];
OrbitTheme.onChange((t) => { PAL = GRAPH_PALETTES[t]; draw(); });

let nodes = []; // { url, title, x, y, r }
let edges = []; // { a, b, weight } (indices into nodes)
let raw = null; // the full graph payload — the detail sheet reads from it
let selected = []; // 1 or 2 node urls shown in the sheet
let view = { scale: 1, x: 0, y: 0 }; // world -> screen: screen = world*scale + offset
let hover = null;
let W = 0, H = 0, dpr = 1;
// Ego view state — the default graph is the neighborhood around one page
// (TODO.md Phase 2); the full map is an explicit "overview" gesture.
let mode = "ego"; // "ego" | "overview"
let center = null; // the ego view's focus url
let trail = []; // breadcrumb of previous centers, oldest first
let anim = null; // one-shot recenter animation (settle-then-rest, never loops)

function resize() {
  dpr = window.devicePixelRatio || 1;
  W = canvas.clientWidth; H = canvas.clientHeight;
  canvas.width = W * dpr; canvas.height = H * dpr;
  draw();
}

// --- data --------------------------------------------------------------------
function loadGraph() {
  if (!chrome?.runtime?.sendMessage) {
    emptyEl.textContent = "Open this page from the OrbitCosmo extension to see your graph.";
    emptyEl.hidden = false;
    return;
  }
  chrome.runtime.sendMessage({ type: "orbit:getGraph" }, (g) => {
    if (!g || !g.nodes || !Object.keys(g.nodes).length) {
      emptyEl.innerHTML = "No pages yet.<br>Browse for a while and come back.";
      emptyEl.hidden = false;
      hideSheet();
      return;
    }
    emptyEl.hidden = true;
    raw = g;
    if (mode === "ego") {
      if (!center || !raw.nodes[center]) center = mostRecentUrl(raw);
      trail = trail.filter((u) => raw.nodes[u]);
      egoBuild(raw, center);
      frameAll();
    } else {
      build(g);
      settle();
      frameAll();
    }
    renderTrail();
    syncModeBtn();
    // Keep the sheet in sync across live reloads; drop pages (and ghosts —
    // visiting one merges it into a real node) that vanished. A wiki-page
    // view stays put — its content isn't graph data.
    selected = selected.filter((u) => raw.nodes[u] || raw.ghosts?.[u]);
    if (wikiView) { /* leave the sheet as is */ }
    else if (selected.length) renderSheet(); else hideSheet();
    draw();
  });
}

// The page you were most recently on — the ego view's natural starting focus.
function mostRecentUrl(g) {
  let best = null, bestTs = -1;
  for (const u in g.nodes) {
    const ts = g.nodes[u].lastVisit || 0;
    if (ts > bestTs) { bestTs = ts; best = u; }
  }
  return best;
}

// A ghost's display title: what the citing pages called it, else its
// domain + path (a bare hostname says too little for a paper URL).
function ghostTitle(gh) {
  if (gh.title) return gh.title;
  try {
    const u = new URL(gh.url);
    return u.hostname + (u.pathname !== "/" ? u.pathname : "");
  } catch { return gh.url; }
}

function build(g) {
  const index = new Map();
  nodes = Object.values(g.nodes).map((n, i) => {
    index.set(n.url, i);
    // Radius from embedding centrality (how central the page is in vector
    // space); pages without an embedding yet get the minimum size.
    const c = n.centrality == null ? 0 : n.centrality;
    return { url: n.url, title: n.title || n.domain || n.url, x: 0, y: 0, r: 4 + c * 10 };
  });
  // Ghosts in the overview: only pages referenced at least twice — one-off
  // mentions are noise (the reading queue's own premise). Slightly smaller
  // than the smallest real node, drawn hollow.
  for (const gh of Object.values(g.ghosts || {})) {
    if ((gh.refCount || 0) < 2) continue;
    index.set(gh.url, nodes.length);
    nodes.push({ url: gh.url, title: ghostTitle(gh), x: 0, y: 0, r: 3.5, ghost: true });
  }
  edges = collapseEdges(g, index);
  // Deterministic starting positions on a spiral (no randomness, no flicker).
  nodes.forEach((n, i) => {
    const ang = i * 2.399963; // golden angle
    const rad = 18 * Math.sqrt(i + 1);
    n.x = Math.cos(ang) * rad;
    n.y = Math.sin(ang) * rad;
  });
}

// Normalize each type's raw weight to 0..1 so relatedness is actually
// visible: related scores live in ~[0.25, 0.75] (spread across the full
// range), navigation counts saturate at 3 traversals, references sit at a
// fixed quiet 0.15 (a citation is one piece of evidence, never a strong
// pull). Multiple edges per pair (related + the two nav directions +
// reference) collapse to one drawn line at the max strength — a pair draws
// in the dashed reference style only when reference is ALL the evidence it
// has. Only pairs where BOTH ends are in `index` are drawn (ego mode passes
// the neighborhood subset; overview passes everything).
function collapseEdges(g, index) {
  const out = [];
  const pairIdx = new Map();
  for (const e of Object.values(g.edges || {})) {
    const a = index.get(e.source), b = index.get(e.target);
    if (a == null || b == null || a === b) continue;
    const isRef = e.type === "reference";
    const w = e.weight || 1;
    const v = e.type === "related"
      ? Math.max(0, Math.min(1, (w - 0.25) / 0.5))
      : e.type === "navigation" ? Math.min(1, w / 3)
      : isRef ? 0.15
      : Math.min(1, w);
    const key = a < b ? a + ":" + b : b + ":" + a;
    const at = pairIdx.get(key);
    if (at == null) {
      pairIdx.set(key, out.length);
      out.push({ a, b, weight: v, ref: isRef });
    } else {
      if (v > out[at].weight) out[at].weight = v;
      out[at].ref = out[at].ref && isRef;
    }
  }
  return out;
}

// --- ego mode: concentric, deterministic, no physics --------------------------
// The center sits at the origin; hop 1 rings it sorted by blended weight
// (strongest at 12 o'clock, clockwise), with the center's cited-but-unvisited
// ghosts (at most 8 — ego.js caps them) taking the ring's last slots; hop-2
// pages sit on an outer ring in the angular sector of the hop-1 parent that
// reached them. Layout is pure arithmetic over the ego helper's ordered
// output — same input, same picture.
const EGO_R1 = 170;
const EGO_R2 = 320;

function egoBuild(g, centerUrl) {
  const ego = getEgoNeighborhood(g, centerUrl);
  const index = new Map();
  nodes = [];
  const push = (url, x, y, rBoost = 0) => {
    const n = g.nodes[url];
    index.set(url, nodes.length);
    nodes.push({
      url,
      title: n.title || n.domain || url,
      x, y,
      r: 4 + (n.centrality ?? 0) * 10 + rBoost,
    });
  };
  const pushGhost = (url, x, y) => {
    const gh = g.ghosts[url];
    index.set(url, nodes.length);
    nodes.push({ url, title: ghostTitle(gh), x, y, r: 3.5, ghost: true });
  };
  push(centerUrl, 0, 0, 4); // the focus reads bigger — orientation, not data

  const hop1 = ego.nodes.filter((n) => n.hop === 1);
  const hop2 = ego.nodes.filter((n) => n.hop === 2);
  const ghosts = ego.ghosts || [];
  const n1 = Math.max(1, hop1.length + ghosts.length);
  const angleOf = new Map();
  hop1.forEach((n, i) => {
    const a = -Math.PI / 2 + (i * 2 * Math.PI) / n1;
    angleOf.set(n.url, a);
    push(n.url, Math.cos(a) * EGO_R1, Math.sin(a) * EGO_R1);
  });
  ghosts.forEach((n, i) => {
    const a = -Math.PI / 2 + ((hop1.length + i) * 2 * Math.PI) / n1;
    pushGhost(n.url, Math.cos(a) * EGO_R1, Math.sin(a) * EGO_R1);
  });

  const byParent = new Map();
  for (const n of hop2) {
    if (!byParent.has(n.parent)) byParent.set(n.parent, []);
    byParent.get(n.parent).push(n);
  }
  const sector = (2 * Math.PI) / n1;
  for (const [parent, kids] of byParent) {
    const base = angleOf.get(parent) ?? -Math.PI / 2;
    const step = Math.min((sector * 0.8) / Math.max(1, kids.length), 0.35);
    kids.forEach((k, j) => {
      const a = base + (j - (kids.length - 1) / 2) * step;
      push(k.url, Math.cos(a) * EGO_R2, Math.sin(a) * EGO_R2);
    });
  }

  edges = collapseEdges(g, index);
}

// Re-focus the ego view. Nodes that survive the move glide to their new ring
// position (one short settle, honoring prefers-reduced-motion); new arrivals
// appear in place.
function setCenter(url, opts = {}) {
  if (!raw?.nodes?.[url] || url === center) return;
  const prevPos = new Map(nodes.map((n) => [n.url, { x: n.x, y: n.y }]));
  if (opts.truncateTo != null) trail = trail.slice(0, opts.truncateTo);
  else if (center) trail.push(center);
  center = url;
  egoBuild(raw, center);
  frameAll();
  renderTrail();
  startRecenterAnim(prevPos);
}

function startRecenterAnim(prevPos) {
  if (anim) { cancelAnimationFrame(anim); anim = null; }
  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { draw(); return; }
  const parts = nodes
    .map((n) => {
      const from = prevPos.get(n.url);
      return from ? { n, fx: from.x, fy: from.y, tx: n.x, ty: n.y } : null;
    })
    .filter(Boolean);
  if (!parts.length) { draw(); return; }
  const T = 220;
  const t0 = performance.now();
  const ease = (t) => 1 - Math.pow(1 - t, 3); // ease-out, settles and rests
  const tick = (now) => {
    const t = Math.min(1, (now - t0) / T);
    const k = ease(t);
    for (const p of parts) {
      p.n.x = p.fx + (p.tx - p.fx) * k;
      p.n.y = p.fy + (p.ty - p.fy) * k;
    }
    draw();
    anim = t < 1 ? requestAnimationFrame(tick) : null;
  };
  for (const p of parts) { p.n.x = p.fx; p.n.y = p.fy; }
  anim = requestAnimationFrame(tick);
}

// --- mode toggle + breadcrumb chrome ------------------------------------------
const trailEl = document.getElementById("trail");
const modeBtn = document.getElementById("modeBtn");

function syncModeBtn() {
  if (!modeBtn) return;
  modeBtn.textContent = mode === "ego" ? "◎ Overview" : "⊙ Focus";
  modeBtn.title = mode === "ego"
    ? "See the whole map"
    : "Back to the neighborhood around one page";
}

function setMode(next) {
  if (mode === next || !raw) return;
  if (anim) { cancelAnimationFrame(anim); anim = null; } // no stale glide over the new layout
  mode = next;
  if (mode === "overview") {
    // Local-only usage counter — TODO.md wants the full map's fate decided
    // with data, not vibes. Never leaves the device like everything else.
    chrome?.storage?.local?.get?.("orbit_fullmapOpens", (r) => {
      chrome.storage.local.set({ orbit_fullmapOpens: (r?.orbit_fullmapOpens || 0) + 1 });
    });
    build(raw);
    settle();
    frameAll();
  } else {
    if (!center || !raw.nodes[center]) center = mostRecentUrl(raw);
    egoBuild(raw, center);
    frameAll();
  }
  syncModeBtn();
  renderTrail();
  draw();
}
modeBtn?.addEventListener("click", () => setMode(mode === "ego" ? "overview" : "ego"));

// Breadcrumb: the walk back out of the graph. Chips are previous centers,
// oldest first; clicking one jumps back and truncates the trail there.
function renderTrail() {
  if (!trailEl) return;
  trailEl.innerHTML = "";
  trailEl.hidden = mode !== "ego" || !trail.length;
  if (trailEl.hidden) return;
  const shown = trail.slice(-4); // the last few steps are the useful ones
  const offset = trail.length - shown.length;
  shown.forEach((url, i) => {
    const b = document.createElement("button");
    b.className = "crumb";
    const n = raw.nodes[url];
    b.textContent = ((n && (n.title || n.domain)) || url).slice(0, 24);
    b.title = (n && n.title) || url;
    b.addEventListener("click", () => setCenter(url, { truncateTo: offset + i }));
    trailEl.appendChild(b);
  });
}

// --- layout: run the force simulation to rest before painting ----------------
// Repulsion between all pairs, springs along edges with strength proportional
// to the normalized edge weight (higher blended relatedness pulls pages
// closest), mild centering. No frames are shown while it runs — the page
// opens on the settled tree.
function settle() {
  const N = nodes.length;
  const REST = 90;
  const vx = new Float64Array(N), vy = new Float64Array(N);
  for (let iter = 0; iter < 300; iter++) {
    for (let i = 0; i < N; i++) {
      for (let j = i + 1; j < N; j++) {
        const a = nodes[i], b = nodes[j];
        let dx = a.x - b.x, dy = a.y - b.y;
        let d2 = dx * dx + dy * dy;
        if (d2 < 0.01) { d2 = 0.01; dx = 0.1; }
        const d = Math.sqrt(d2);
        const f = 2400 / d2;
        vx[i] += (dx / d) * f; vy[i] += (dy / d) * f;
        vx[j] -= (dx / d) * f; vy[j] -= (dy / d) * f;
      }
    }
    for (const e of edges) {
      const a = nodes[e.a], b = nodes[e.b];
      const dx = b.x - a.x, dy = b.y - a.y;
      const d = Math.sqrt(dx * dx + dy * dy) || 0.01;
      const f = 0.03 * (0.3 + 0.7 * e.weight) * (d - REST);
      vx[e.a] += (dx / d) * f; vy[e.a] += (dy / d) * f;
      vx[e.b] -= (dx / d) * f; vy[e.b] -= (dy / d) * f;
    }
    let moved = 0;
    for (let i = 0; i < N; i++) {
      vx[i] += -nodes[i].x * 0.0006; vy[i] += -nodes[i].y * 0.0006;
      vx[i] *= 0.85; vy[i] *= 0.85;
      nodes[i].x += vx[i]; nodes[i].y += vy[i];
      moved = Math.max(moved, Math.abs(vx[i]) + Math.abs(vy[i]));
    }
    if (moved < 0.08) break;
  }
}

// Fit the whole settled tree in the viewport once, with padding.
function frameAll() {
  if (!nodes.length) return;
  let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
  for (const n of nodes) {
    minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x);
    minY = Math.min(minY, n.y); maxY = Math.max(maxY, n.y);
  }
  const pad = 60;
  const sw = (W - pad * 2) / Math.max(1, maxX - minX);
  const sh = (H - pad * 2) / Math.max(1, maxY - minY);
  view.scale = Math.min(1.5, Math.max(0.05, Math.min(sw, sh)));
  view.x = W / 2 - ((minX + maxX) / 2) * view.scale;
  view.y = H / 2 - ((minY + maxY) / 2) * view.scale;
}

// --- draw ---------------------------------------------------------------------
function draw() {
  ctx.save();
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.scale(dpr, dpr);
  ctx.translate(view.x, view.y);
  ctx.scale(view.scale, view.scale);

  ctx.lineWidth = 1 / view.scale;
  const refDash = [4 / view.scale, 3 / view.scale];
  for (const e of edges) {
    const a = nodes[e.a], b = nodes[e.b];
    if (e.ref) {
      // Reference-only pairs read quieter than everything else: thin,
      // dashed, muted — the same hue, just less of it.
      ctx.setLineDash(refDash);
      ctx.strokeStyle = PAL.refEdge;
      ctx.globalAlpha = 1;
    } else {
      ctx.setLineDash([]);
      ctx.strokeStyle = PAL.edge;
      ctx.globalAlpha = 0.35 + 0.65 * e.weight; // stronger relation, stronger line
    }
    ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
  }
  ctx.setLineDash([]);
  ctx.globalAlpha = 1;
  for (const n of nodes) {
    ctx.beginPath();
    ctx.arc(n.x, n.y, n.r, 0, Math.PI * 2);
    if (n.ghost) {
      // Hollow, muted stroke: merely mentioned, never read.
      ctx.lineWidth = 1.5 / view.scale;
      ctx.strokeStyle = PAL.ghostLine;
      ctx.stroke();
    } else {
      ctx.fillStyle = PAL.node;
      ctx.fill();
    }
    if (n === hover) { ctx.lineWidth = 2 / view.scale; ctx.strokeStyle = PAL.nodeLine; ctx.stroke(); }
    if (selected.includes(n.url)) {
      // Halo ring set off from the fill, so selection reads at any zoom.
      ctx.beginPath();
      ctx.arc(n.x, n.y, n.r + 4 / view.scale, 0, Math.PI * 2);
      ctx.lineWidth = 2 / view.scale;
      ctx.strokeStyle = PAL.nodeLine;
      ctx.stroke();
    }
  }
  if (hover) {
    const fs = 12 / view.scale;
    ctx.font = `600 ${fs}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
    ctx.textAlign = "center";
    const label = hover.title.slice(0, 60);
    const y = hover.y - hover.r - 8 / view.scale;
    ctx.lineWidth = 3 / view.scale;
    ctx.strokeStyle = PAL.labelHalo;
    ctx.strokeText(label, hover.x, y);
    ctx.fillStyle = hover.ghost ? PAL.ghostLabel : PAL.label; // muted tone for the merely-mentioned
    ctx.fillText(label, hover.x, y);
  }
  ctx.restore();
}

// --- interaction: pan, zoom, drag a node, hover, click-to-open -----------------
function toWorld(sx, sy) {
  return { x: (sx - view.x) / view.scale, y: (sy - view.y) / view.scale };
}
function nodeAt(sx, sy) {
  const p = toWorld(sx, sy);
  const slack = 3 / view.scale;
  for (let i = nodes.length - 1; i >= 0; i--) {
    const n = nodes[i];
    const dx = n.x - p.x, dy = n.y - p.y;
    if (dx * dx + dy * dy <= (n.r + slack) * (n.r + slack)) return n;
  }
  return null;
}

let dragNode = null, panning = false, moved = false, lastX = 0, lastY = 0;

canvas.addEventListener("mousedown", (e) => {
  lastX = e.clientX; lastY = e.clientY; moved = false;
  const p = canvasPoint(e);
  dragNode = nodeAt(p.x, p.y);
  panning = !dragNode;
});
window.addEventListener("mousemove", (e) => {
  const dx = e.clientX - lastX, dy = e.clientY - lastY;
  if (dragNode || panning) {
    if (Math.abs(dx) + Math.abs(dy) > 2) moved = true;
  }
  if (dragNode) {
    dragNode.x += dx / view.scale; dragNode.y += dy / view.scale;
    lastX = e.clientX; lastY = e.clientY;
    draw();
    return;
  }
  if (panning) {
    view.x += dx; view.y += dy;
    lastX = e.clientX; lastY = e.clientY;
    draw();
    return;
  }
  const p = canvasPoint(e);
  const h = nodeAt(p.x, p.y);
  if (h !== hover) {
    hover = h;
    canvas.style.cursor = h ? "pointer" : "default";
    draw();
  }
});
window.addEventListener("mouseup", (e) => {
  if (dragNode && !moved) {
    // Ego mode: a plain click walks the graph (re-center) AND opens the
    // sheet; ⇧-click compares without moving. Overview keeps today's
    // click-for-sheet behavior. Ghosts can't be walked to (an unvisited
    // page has no neighborhood) — clicking one just opens its sheet.
    if (mode === "ego" && !e.shiftKey && !dragNode.ghost && dragNode.url !== center) setCenter(dragNode.url);
    select(dragNode.url, e.shiftKey);
  } else if (panning && !moved) hideSheet(); // click on empty space dismisses
  dragNode = null; panning = false;
});
canvas.addEventListener("wheel", (e) => {
  e.preventDefault();
  const factor = Math.exp(-e.deltaY * 0.0015);
  const next = Math.min(4, Math.max(0.05, view.scale * factor));
  // Zoom about the cursor: keep the world point under it fixed.
  const p = canvasPoint(e);
  view.x = p.x - ((p.x - view.x) / view.scale) * next;
  view.y = p.y - ((p.y - view.y) / view.scale) * next;
  view.scale = next;
  draw();
}, { passive: false });

window.addEventListener("resize", resize);

// --- detail sheet: what OrbitCosmo knows about a page (or a pair of pages) --------
// Everything here is composed from the stored graph — edge provenance,
// sessions, keywords, timestamps. The ask field sends free-form questions to
// the wiki (orbit:wikiAsk); with the wiki off or unconfigured it replays the
// deterministic facts instead of pretending to converse. The sheet also hosts
// the wiki-page view (showWikiPage — the rail and [[slug]] links call it).
const sheetEl = document.getElementById("sheet");
const sheetTitleEl = document.getElementById("sheetTitle");
const sheetDomainEl = document.getElementById("sheetDomain");
const sheetBodyEl = document.getElementById("sheetBody");
const sheetOpenBtn = document.getElementById("sheetOpen");
const sheetBackBtn = document.getElementById("sheetBack");
const sheetAskForm = document.getElementById("sheetAsk");
const sheetQEl = document.getElementById("sheetQ");
let sheetFacts = []; // facts backing the current sheet — the ask fallback replays them
let sheetSeq = 0; // async sitting facts land only on the sheet that asked for them
let askSeq = 0; // wiki answers land only on the sheet that asked (same guard pattern)
let wikiView = null; // slug shown in the sheet's wiki-page view, or null for facts

function nodeName(url) {
  const n = raw.nodes[url];
  return (n && (n.title || n.domain)) || url;
}
function edgesTouching(url) {
  const out = [];
  for (const e of Object.values(raw.edges || {})) {
    const other = e.source === url ? e.target : e.target === url ? e.source : null;
    if (other && raw.nodes[other]) out.push({ e, other });
  }
  return out;
}

function factsFor(url) {
  const n = raw.nodes[url];
  const facts = [];
  const sess = raw.sessions && raw.sessions[n.sessionId];
  if (sess) facts.push(`Part of your “${sess.label}” thread.`);
  // timeAgo comes from search.js (loaded first in app.html).
  facts.push(`Last opened ${timeAgo(n.lastVisit, Date.now()) || "a while ago"}.`);
  const touch = edgesTouching(url);
  const by = { navigation: 0, related: 0, manual: 0, reference: 0 };
  for (const { e } of touch) by[e.type] = (by[e.type] || 0) + 1;
  const distinct = new Set(touch.map((t) => t.other)).size;
  if (distinct) {
    const parts = [];
    if (by.navigation) parts.push(`${by.navigation} you clicked through`);
    if (by.related) parts.push(`${by.related} related by subject`);
    if (by.reference) parts.push(`${by.reference} cited in the text`);
    if (by.manual) parts.push(`${by.manual} you linked yourself`);
    facts.push(`Connected to ${distinct} page${distinct === 1 ? "" : "s"} (${parts.join(", ")}).`);
  } else {
    facts.push("No connections yet — it stands alone in your graph so far.");
  }
  let cites = 0;
  for (const e of Object.values(raw.edges || {})) {
    if (e.type === "reference" && e.source === url && raw.ghosts?.[e.target]) cites++;
  }
  if (cites) facts.push(`Its text references ${cites} page${cites === 1 ? "" : "s"} you haven't opened yet.`);
  const closest = touch.filter((t) => t.e.type === "related")
    .sort((a, b) => (b.e.weight || 0) - (a.e.weight || 0))[0];
  if (closest) facts.push(`Closest by subject: “${nodeName(closest.other)}” (${closest.e.weight}).`);
  if (n.keywords && n.keywords.length)
    facts.push(`Its keywords: ${n.keywords.slice(0, 5).join(", ")}.`);
  return facts;
}

// Shortest path between two pages over all edges, capped at 4 hops.
function shortestPath(from, to, maxHops) {
  const adj = new Map();
  for (const e of Object.values(raw.edges || {})) {
    if (!raw.nodes[e.source] || !raw.nodes[e.target]) continue;
    if (!adj.has(e.source)) adj.set(e.source, []);
    if (!adj.has(e.target)) adj.set(e.target, []);
    adj.get(e.source).push(e.target);
    adj.get(e.target).push(e.source);
  }
  const prev = new Map([[from, null]]);
  let frontier = [from];
  for (let hop = 0; hop < maxHops && frontier.length; hop++) {
    const next = [];
    for (const u of frontier) {
      for (const v of adj.get(u) || []) {
        if (prev.has(v)) continue;
        prev.set(v, u);
        if (v === to) {
          const path = [v];
          for (let p = u; p != null; p = prev.get(p)) path.unshift(p);
          return path;
        }
        next.push(v);
      }
    }
    frontier = next;
  }
  return null;
}

function factsPair(ua, ub) {
  const A = raw.nodes[ua], B = raw.nodes[ub];
  const facts = [];
  const sess = raw.sessions && raw.sessions[A.sessionId];
  if (sess && A.sessionId === B.sessionId) facts.push(`Both live in your “${sess.label}” thread.`);
  let direct = false;
  for (const e of Object.values(raw.edges || {})) {
    const hit = (e.source === ua && e.target === ub) || (e.source === ub && e.target === ua);
    if (!hit) continue;
    direct = true;
    if (e.type === "related")
      facts.push(`Related by subject — blended score ${e.weight} (meaning, shared keywords, your clicks between them, and same-sitting co-visits).`);
    else if (e.type === "navigation")
      facts.push(`You clicked from “${nodeName(e.source)}” to “${nodeName(e.target)}” ${e.weight === 1 ? "once" : `${e.weight} times`}.`);
    else if (e.type === "manual")
      facts.push("You linked these two yourself.");
    else if (e.type === "reference")
      facts.push(`“${nodeName(e.source)}” references “${nodeName(e.target)}” in its text.`);
  }
  const shared = (A.keywords || []).filter((k) => (B.keywords || []).includes(k)).slice(0, 6);
  if (shared.length) facts.push(`They share keywords: ${shared.join(", ")}.`);
  if (!direct) {
    const path = shortestPath(ua, ub, 4);
    if (path) facts.push(`They connect through: ${path.map(nodeName).map((s) => `“${s}”`).join(" → ")}.`);
    else facts.push("No path between these two in your graph yet.");
  }
  return facts;
}

function addFactLine(text, cls) {
  const p = document.createElement("p");
  p.className = cls || "fact";
  p.textContent = text; // titles are page-supplied — never innerHTML
  sheetBodyEl.appendChild(p);
  return p;
}

// Reduced "ghost" sheet: a page your reading cites but you haven't opened.
// Only the facts the reference edges carry — who cites it, from how many
// sites — plus Open page: clicking through is exactly how a ghost becomes a
// real node (the worker merges it on first visit).
function renderGhostSheet(gh) {
  sheetTitleEl.textContent = ghostTitle(gh);
  sheetDomainEl.textContent = gh.domain || "";
  sheetOpenBtn.hidden = false;
  const nRefs = gh.refCount || 0;
  const nSites = (gh.sourceDomains || []).length;
  const fact = `Referenced by ${nRefs} page${nRefs === 1 ? "" : "s"}` +
    (nSites >= 2 ? ` across ${nSites} sites` : "") + ".";
  sheetFacts = [fact];
  addFactLine(fact);
  const referrers = Object.values(raw.edges || {})
    .filter((e) => e.type === "reference" && e.target === gh.url && raw.nodes[e.source])
    .sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0) || (a.source < b.source ? -1 : 1));
  for (const e of referrers.slice(0, 8)) {
    const line = `Referenced in “${nodeName(e.source)}”.`;
    sheetFacts.push(line);
    addFactLine(line);
  }
  if (referrers.length > 8) addFactLine(`…and ${referrers.length - 8} more.`, "sheet-hint");
  addFactLine("You haven't opened this page yet — Open page adds it to your graph.", "sheet-hint");
  sheetEl.hidden = false;
}

function renderSheet() {
  askSeq++; // an answer in flight belonged to the previous sheet body
  wikiView = null;
  sheetBackBtn.hidden = true;
  sheetAskForm.hidden = false;
  const [ua, ub] = selected;
  sheetBodyEl.innerHTML = "";
  const gh = !ub && !raw.nodes[ua] ? raw.ghosts?.[ua] : null;
  sheetAskForm.hidden = !!gh; // nothing to ask about a page OrbitCosmo hasn't read
  if (gh) {
    sheetSeq++; // no sitting fact for a ghost — cancel any in flight
    renderGhostSheet(gh);
    return;
  }
  if (ub) {
    sheetTitleEl.textContent = `${nodeName(ua)} ↔ ${nodeName(ub)}`;
    sheetDomainEl.textContent = "how these two connect";
    sheetOpenBtn.hidden = true;
    sheetFacts = factsPair(ua, ub);
    sheetQEl.placeholder = "Ask how these connect…";
  } else {
    const n = raw.nodes[ua];
    sheetTitleEl.textContent = n.title || n.domain || ua;
    sheetDomainEl.textContent = n.domain || "";
    sheetOpenBtn.hidden = false;
    sheetFacts = factsFor(ua);
    sheetQEl.placeholder = "Ask about this page…";
  }
  sheetFacts.forEach((f) => addFactLine(f));
  if (!ub) addFactLine("⇧-click another node to see how two pages connect.", "sheet-hint");
  sheetEl.hidden = false;
  fetchSittingFact(ua, ub);
  fetchWikiChips(selected.slice());
}

// Same-sitting recall — the one fact the sheet has to ask the worker for (the
// covisits map deliberately never rides orbit:getGraph). Arrives async, so a
// seq guard keeps a stale answer off a newer sheet; the fact also joins
// sheetFacts so the ask flow sends and replays it. Empty answers add nothing.
function fetchSittingFact(ua, ub) {
  const seq = ++sheetSeq;
  if (!chrome?.runtime?.sendMessage) return; // standalone page — no worker to ask
  const msg = ub ? { type: "orbit:sitting", url: ua, other: ub } : { type: "orbit:sitting", url: ua };
  chrome.runtime.sendMessage(msg, (resp) => {
    if (seq !== sheetSeq || !resp || !resp.ok) return;
    let fact = null;
    if (ub) {
      if (resp.pair)
        fact = `You had these open in the same sitting ${resp.pair.count === 1 ? "once" : `${resp.pair.count} times`}, last ${timeAgo(resp.pair.lastSeen, Date.now())}.`;
    } else if (resp.items && resp.items.length) {
      const tops = resp.items.slice(0, 3).map((it) => `“${nodeName(it.url)}” (${it.count}×)`);
      fact = `In the same sitting: ${tops.join(", ")}.`;
    }
    if (!fact) return;
    sheetFacts.push(fact);
    addFactLine(fact);
  });
}

function select(url, addSecond) {
  // Pair comparison is for pages OrbitCosmo has actually read — a ghost has no
  // keywords/sessions/embedding to compare, so it always selects alone.
  const isGhost = (u) => !!(raw?.ghosts?.[u]) && !raw.nodes[u];
  selected = addSecond && selected.length && selected[0] !== url &&
    !isGhost(url) && !isGhost(selected[0])
    ? [selected[0], url]
    : [url];
  renderSheet();
  draw();
}
// The reading queue's click-through (app.js loads after this script, like
// openPage): put the ghost in context — focus the page that cited it most
// recently, then open the ghost's sheet. The ghost itself can't be a center
// (nothing radiates from an unvisited page).
function focusReference(url) {
  if (!raw?.ghosts?.[url]) return;
  let best = null;
  for (const e of Object.values(raw.edges || {})) {
    if (e.type !== "reference" || e.target !== url || !raw.nodes[e.source]) continue;
    if (!best || (e.lastSeen || 0) > (best.lastSeen || 0) ||
        ((e.lastSeen || 0) === (best.lastSeen || 0) && e.source < best.source)) best = e;
  }
  if (mode !== "ego") setMode("ego");
  if (best && best.source !== center) setCenter(best.source);
  select(url, false);
}

function hideSheet() {
  if (sheetEl.hidden && !selected.length) return;
  sheetSeq++; // a sitting answer in flight has no sheet to land on
  askSeq++;
  wikiView = null;
  sheetBackBtn.hidden = true;
  sheetAskForm.hidden = false;
  selected = [];
  sheetEl.hidden = true;
  draw();
}

sheetOpenBtn.addEventListener("click", () => {
  if (selected[0]) openPage(selected[0]);
});
document.getElementById("sheetClose").addEventListener("click", hideSheet);
window.addEventListener("keydown", (e) => { if (e.key === "Escape") hideSheet(); });

sheetAskForm.addEventListener("submit", (e) => {
  e.preventDefault();
  const q = sheetQEl.value.trim();
  if (!q) return;
  const you = document.createElement("p");
  you.className = "msg you";
  you.textContent = q;
  sheetBodyEl.appendChild(you);
  sheetQEl.value = "";
  askWiki(q);
});

// Free-form ask → the wiki answers from filed pages (takes 3–10s). The reply
// element holds a placeholder until the response lands; the seq guard keeps a
// stale answer off a newer sheet, same pattern as fetchSittingFact.
function askWiki(question) {
  const seq = ++askSeq;
  const reply = document.createElement("div");
  reply.className = "msg orbit pending";
  reply.textContent = "thinking…";
  sheetBodyEl.appendChild(reply);
  reply.scrollIntoView({ block: "nearest" });
  // Wiki off or unconfigured: the deterministic facts, like before the wiki.
  const factsReply = () => {
    reply.textContent =
      "Free-form answers aren't wired up yet. What OrbitCosmo knows for sure:\n• " +
      sheetFacts.join("\n• ") +
      "\nTurn on the wiki in ⚙ settings for real answers.";
  };
  if (!chrome?.runtime?.sendMessage) { reply.classList.remove("pending"); factsReply(); return; }
  chrome.runtime.sendMessage(
    { type: "orbit:wikiAsk", urls: selected.slice(), question, facts: sheetFacts },
    (resp) => {
      if (seq !== askSeq) return; // the sheet moved on — this answer has no home
      reply.classList.remove("pending");
      reply.textContent = "";
      if (resp?.ok) renderAnswer(reply, resp);
      else if (resp && (resp.code === "disabled" || resp.code === "no-key")) factsReply();
      else reply.textContent = (resp && resp.message) || "No answer came back — try again.";
      reply.scrollIntoView({ block: "nearest" });
    }
  );
}

function renderAnswer(el, resp) {
  if (resp.confident === false) {
    const note = document.createElement("p");
    note.className = "low-conf";
    note.textContent = "(low confidence)";
    el.appendChild(note);
  }
  const md = document.createElement("div");
  md.className = "wiki-md";
  renderMarkdownInto(md, resp.answer, { onWikiLink: showWikiPage });
  el.appendChild(md);
  // One chip per citation that resolves to a node in the current graph.
  const cites = (resp.citations || []).filter((c) => c && raw?.nodes?.[c.url]);
  if (cites.length) {
    const row = document.createElement("div");
    row.className = "chip-row";
    for (const c of cites) {
      const n = raw.nodes[c.url];
      const chip = document.createElement("button");
      chip.type = "button";
      chip.className = "chip";
      chip.textContent = n.title || n.domain || c.url;
      if (c.quote) chip.title = c.quote;
      chip.addEventListener("click", () => focusNode(c.url));
      row.appendChild(chip);
    }
    el.appendChild(row);
  }
  const file = document.createElement("button");
  file.type = "button";
  file.className = "linkbtn";
  file.textContent = "File to wiki";
  file.addEventListener("click", () => {
    file.disabled = true;
    chrome.runtime.sendMessage(
      { type: "orbit:wikiFile", question: resp.question, answer: resp.answer, citations: resp.citations || [] },
      (r) => {
        if (r?.ok) file.textContent = "Saved ✓";
        else { file.disabled = false; file.textContent = (r && r.message) || "Couldn't save — retry"; }
      }
    );
  });
  el.appendChild(file);
}

// Center + select — the same walk a canvas click takes (mouseup above).
function focusNode(url) {
  if (!raw?.nodes?.[url]) return;
  if (mode === "ego" && url !== center) setCenter(url);
  select(url, false);
}

// "In your wiki" — pages already written about the selected URL(s), matched by
// cited URL or shared concept. The row is created synchronously and filled
// async: if the sheet re-rendered meanwhile the row is detached, so a stale
// response lands nowhere.
function fetchWikiChips(urls) {
  if (!chrome?.runtime?.sendMessage) return;
  const host = document.createElement("div");
  host.className = "chip-row";
  host.hidden = true;
  sheetBodyEl.appendChild(host);
  const want = new Set(urls);
  const concepts = new Set();
  for (const u of urls) for (const c of raw.nodes[u]?.conceptIds || []) concepts.add(c);
  chrome.runtime.sendMessage({ type: "orbit:wikiList" }, (pages) => {
    if (!host.isConnected || !Array.isArray(pages)) return;
    const hits = pages.filter((p) =>
      (p.kind === "concept" || p.kind === "session" || p.kind === "answer") &&
      ((p.citedUrls || []).some((u) => want.has(u)) ||
       (p.conceptIds || []).some((c) => concepts.has(c)))
    ).slice(0, 4);
    if (!hits.length) return;
    const label = document.createElement("span");
    label.className = "chip-label";
    label.textContent = "In your wiki:";
    host.appendChild(label);
    for (const p of hits) {
      const chip = document.createElement("button");
      chip.type = "button";
      chip.className = "chip";
      chip.textContent = p.title || p.slug;
      chip.addEventListener("click", () => showWikiPage(p.slug));
      host.appendChild(chip);
    }
    host.hidden = false;
  });
}

// --- wiki-page view: a wiki page rendered inside the same sheet ---------------
// Reached from the rail's Wiki section, "In your wiki" chips, and [[slug]]
// links; Back returns to the facts view of whatever is still selected.
function showWikiPage(slug) {
  if (!chrome?.runtime?.sendMessage) return;
  chrome.runtime.sendMessage({ type: "orbit:wikiPage", slug }, (resp) => {
    if (!resp || !resp.page) return;
    sheetSeq++; askSeq++; // facts and answers in flight belong to the view we're leaving
    wikiView = slug;
    renderWikiView(resp.page, resp.versions || []);
  });
}

function renderWikiView(page, versions) {
  sheetTitleEl.textContent = page.title || page.slug;
  sheetDomainEl.textContent =
    `${page.kind} · updated ${new Date(page.updatedTs).toLocaleDateString()} · v${page.version}`;
  sheetOpenBtn.hidden = true;
  sheetBackBtn.hidden = false;
  sheetAskForm.hidden = true;
  sheetBodyEl.innerHTML = "";
  const md = document.createElement("div");
  md.className = "wiki-md";
  renderMarkdownInto(md, page.markdown, { onWikiLink: showWikiPage });
  sheetBodyEl.appendChild(md);
  if (versions.length) {
    const hist = document.createElement("details");
    hist.className = "wiki-history";
    const sum = document.createElement("summary");
    sum.textContent = `History (${versions.length})`;
    hist.appendChild(sum);
    for (const v of versions) {
      const row = document.createElement("div");
      row.className = "wiki-ver";
      const label = document.createElement("span");
      label.textContent =
        `v${v.version} · ${new Date(v.ts).toLocaleDateString()}${v.reason ? ` · ${v.reason}` : ""}`;
      label.title = v.reason || "";
      const restore = document.createElement("button");
      restore.type = "button";
      restore.className = "chip";
      restore.textContent = "restore";
      restore.addEventListener("click", () => {
        restore.disabled = true;
        chrome.runtime.sendMessage(
          { type: "orbit:wikiRevert", slug: page.slug, version: v.version },
          (r) => {
            if (r?.ok) showWikiPage(page.slug);
            else restore.disabled = false;
          }
        );
      });
      row.appendChild(label);
      row.appendChild(restore);
      hist.appendChild(row);
    }
    sheetBodyEl.appendChild(hist);
  }
  sheetEl.hidden = false;
}

sheetBackBtn.addEventListener("click", () => {
  wikiView = null;
  if (selected.length) renderSheet(); else hideSheet();
});

// Live reload when the projection changes (the worker bumps orbit_graph_rev
// on every save — IndexedDB itself fires no cross-context event).
if (chrome?.storage?.onChanged) {
  chrome.storage.onChanged.addListener((changes, area) => {
    if (area === "local" && changes.orbit_graph_rev) loadGraph();
  });
}

resize();
loadGraph();
