// Graph API — snapshot fetch + derivation helpers + layout.
// Contract: api_contract_graph.md

// ─────────────────────────────────────────────────────────────────────────
// MOCK SNAPSHOT — small e-commerce demo: orders + users + handbook doc.
// ─────────────────────────────────────────────────────────────────────────
const MOCK_GRAPH_SNAPSHOT = {
  factual_nodes: [
    // DB — tables
    { id: 'table_orders', name: 'orders', type: 'table',
      properties: {
        db_path: 'sales.duckdb',
        row_count: 48210,
        // Contract shape: dict keyed by column name (see api_contract_graph.md)
        columns: {
          order_id:   { type: 'INTEGER' },
          user_id:    { type: 'INTEGER' },
          amount:     { type: 'DOUBLE' },
          region:     { type: 'VARCHAR' },
          created_at: { type: 'TIMESTAMP' },
          status:     { type: 'VARCHAR' },
        },
        description: 'Customer orders with amount, region and status.',
      },
      origin: 'exploration' },
    { id: 'table_users', name: 'users', type: 'table',
      properties: {
        db_path: 'sales.duckdb',
        row_count: 12450,
        columns: {
          user_id:        { type: 'INTEGER' },
          name:           { type: 'VARCHAR' },
          region:         { type: 'VARCHAR' },
          last_active_at: { type: 'TIMESTAMP' },
        },
        description: 'User accounts with region and last activity timestamp.',
      },
      origin: 'exploration' },

    // DB — columns
    { id: 'column_orders_order_id',   name: 'order_id',   type: 'column', properties: { data_type: 'INTEGER',   table: 'orders' }, origin: 'exploration' },
    { id: 'column_orders_user_id',    name: 'user_id',    type: 'column', properties: { data_type: 'INTEGER',   table: 'orders' }, origin: 'exploration' },
    { id: 'column_orders_amount',     name: 'amount',     type: 'column', properties: { data_type: 'DOUBLE',    table: 'orders' }, origin: 'exploration' },
    { id: 'column_orders_region',     name: 'region',     type: 'column', properties: { data_type: 'VARCHAR',   table: 'orders' }, origin: 'exploration' },
    { id: 'column_orders_created_at', name: 'created_at', type: 'column', properties: { data_type: 'TIMESTAMP', table: 'orders' }, origin: 'exploration' },
    { id: 'column_orders_status',     name: 'status',     type: 'column', properties: { data_type: 'VARCHAR',   table: 'orders' }, origin: 'exploration' },
    { id: 'column_users_user_id',        name: 'user_id',        type: 'column', properties: { data_type: 'INTEGER',   table: 'users' }, origin: 'exploration' },
    { id: 'column_users_name',           name: 'name',           type: 'column', properties: { data_type: 'VARCHAR',   table: 'users' }, origin: 'exploration' },
    { id: 'column_users_region',         name: 'region',         type: 'column', properties: { data_type: 'VARCHAR',   table: 'users' }, origin: 'exploration' },
    { id: 'column_users_last_active_at', name: 'last_active_at', type: 'column', properties: { data_type: 'TIMESTAMP', table: 'users' }, origin: 'exploration' },

    // Docs
    { id: 'doc_handbook', name: 'product-handbook.md', type: 'document',
      properties: { path: 'docs/product-handbook.md', word_count: 3200 }, origin: 'exploration' },
    { id: 'chunk_hb_1', name: 'product-handbook.md#1', type: 'chunk',
      properties: { document: 'doc_handbook', text: 'Pricing tiers define enterprise commitments with monthly or annual billing.' }, origin: 'exploration' },
    { id: 'chunk_hb_2', name: 'product-handbook.md#2', type: 'chunk',
      properties: { document: 'doc_handbook', text: 'An active user has at least one interaction within the last 24 hours.' }, origin: 'exploration' },
    { id: 'chunk_hb_3', name: 'product-handbook.md#3', type: 'chunk',
      properties: { document: 'doc_handbook', text: 'Retention is measured cohort-wise based on signup month and last_active_at.' }, origin: 'exploration' },
  ],

  concept_nodes: (() => {
    // Mock concepts use canonical SynonymEntry shape so the synonym editor
    // (which calls undo on the backing dedup_event) has matching data to
    // operate on. Each seed synonym pairs with a `mock_seed:<concept>:<name>`
    // dedup event referenced by the same evidence_ref.
    const seed = (id, name, synonymNames, properties, domain_hint) => {
      const at = '2026-04-01T00:00';
      const synonyms = synonymNames.map(n => ({
        name: n, at, origin: 'exploration',
        evidence_ref: `mock_seed:${id}:${n}`, state: 'active',
      }));
      const dedup_events = synonymNames.map(n => ({
        at, attempted_name: n, attempted_synonyms: [],
        added_synonyms: [n], origin: 'exploration',
        reason: 'mock_seed', evidence_ref: `mock_seed:${id}:${n}`, state: 'active',
      }));
      const props = domain_hint ? { ...properties, domain_hint } : properties;
      return { id, name, synonyms, properties: props, origin: 'exploration',
               state: 'active', status: 'active', merged_from: [], dedup_events };
    };
    return [
      seed('concept_gmv',         'GMV',           ['sales', 'revenue', 'merchandise volume'],
           { description: 'Gross merchandise volume — total sales amount across completed orders.' }),
      seed('concept_region',      'region',        ['area', 'zone'],
           { description: 'Customer geographic region.' }),
      seed('concept_region_east', 'East Region',   ['east china', 'EC'],
           { description: 'East China region.' }),
      seed('concept_region_south','South Region',  ['south china', 'SC'],
           { description: 'South China region.' }),
      seed('concept_active_user', 'active user',   ['DAU', 'active users'],
           { description: 'Users with at least one action in 24h.' }, 'db'),
      seed('concept_retention',   'retention',     ['cohort retention'],
           { description: 'Cohort retention over time.' }, 'doc'),
      seed('concept_pricing',     'pricing tier',  ['tier', 'plan'],
           { description: 'Pricing tiers of the product.' }, 'doc'),
    ];
  })(),

  structure_edges: [
    // table ↔ columns
    { source: 'table_orders', target: 'column_orders_order_id',   origin: 'exploration' },
    { source: 'table_orders', target: 'column_orders_user_id',    origin: 'exploration' },
    { source: 'table_orders', target: 'column_orders_amount',     origin: 'exploration' },
    { source: 'table_orders', target: 'column_orders_region',     origin: 'exploration' },
    { source: 'table_orders', target: 'column_orders_created_at', origin: 'exploration' },
    { source: 'table_orders', target: 'column_orders_status',     origin: 'exploration' },
    { source: 'table_users',  target: 'column_users_user_id',        origin: 'exploration' },
    { source: 'table_users',  target: 'column_users_name',           origin: 'exploration' },
    { source: 'table_users',  target: 'column_users_region',         origin: 'exploration' },
    { source: 'table_users',  target: 'column_users_last_active_at', origin: 'exploration' },
    // FK
    { source: 'table_orders', target: 'table_users',
      properties: { from_column: 'user_id', to_column: 'user_id' }, origin: 'exploration' },
    // doc ↔ chunks
    { source: 'doc_handbook', target: 'chunk_hb_1', origin: 'exploration' },
    { source: 'doc_handbook', target: 'chunk_hb_2', origin: 'exploration' },
    { source: 'doc_handbook', target: 'chunk_hb_3', origin: 'exploration' },
  ],

  mapping_edges: [
    { source: 'concept_gmv',         target: 'column_orders_amount',       confidence: 0.93, evidence_ref: 'evidence/exp_orders.yaml',    origin: 'exploration' },
    { source: 'concept_region',      target: 'column_orders_region',       confidence: 0.99, evidence_ref: 'evidence/exp_orders.yaml',    origin: 'exploration' },
    { source: 'concept_region',      target: 'column_users_region',        confidence: 0.97, evidence_ref: 'evidence/exp_users.yaml',     origin: 'exploration' },
    { source: 'concept_active_user', target: 'column_users_last_active_at',confidence: 0.82, evidence_ref: 'evidence/exp_users.yaml',     origin: 'exploration' },
    { source: 'concept_active_user', target: 'chunk_hb_2',                 confidence: 0.88, evidence_ref: 'evidence/graphify/handbook.yaml', origin: 'exploration' },
    { source: 'concept_retention',   target: 'chunk_hb_3',                 confidence: 0.85, origin: 'exploration' },
    { source: 'concept_pricing',     target: 'chunk_hb_1',                 confidence: 0.91, origin: 'exploration' },
  ],

  dependency_edges: [],

  relation_edges: [
    { source: 'concept_gmv', target: 'concept_region', relation: 'conceptually_related_to', confidence: 0.6, origin: 'exploration' },
  ],

  category_edges: [
    { source: 'concept_region_east',  target: 'concept_region', value: 'East', data_type: 'VARCHAR', confidence: 0.98, origin: 'exploration' },
    { source: 'concept_region_south', target: 'concept_region', value: 'South', data_type: 'VARCHAR', confidence: 0.98, origin: 'exploration' },
  ],
};

// ─────────────────────────────────────────────────────────────────────────
// Fetch
// ─────────────────────────────────────────────────────────────────────────
async function fetchGraphSnapshotReal() {
  const base = window.PARCLE_API_BASE || '/api';
  const res = await fetch(`${base}/skill/graph`, { headers: parcleAuthHeaders() });
  if (res.status === 401 && typeof parcleNotifyUnauthorized === 'function') parcleNotifyUnauthorized();
  if (!res.ok) {
    let msg = `HTTP ${res.status}`;
    try {
      const j = await res.json();
      if (j && j.detail) msg = j.detail;
    } catch (e) { /* ignore */ }
    throw new Error(msg);
  }
  return res.json();
}

function graphApiMockEnabled() {
  if (typeof window.PARCLE_GRAPH_MOCK === 'boolean') return window.PARCLE_GRAPH_MOCK;
  return !window.PARCLE_API_BASE;
}

function fetchGraphSnapshotMock() {
  return new Promise(resolve => {
    setTimeout(() => resolve(JSON.parse(JSON.stringify(MOCK_GRAPH_SNAPSHOT))), 320);
  });
}

// /skill/graph is org-only, and this console only ever holds an org session,
// so the snapshot is fetched unconditionally outside mock mode.
function fetchGraphSnapshot() {
  if (graphApiMockEnabled()) return fetchGraphSnapshotMock();
  return fetchGraphSnapshotReal();
}

// Application-lifetime cache. Primed once at App mount so chat / fabric /
// learning land on already-resolved data when the user navigates to them.
// Successful results stick until explicit invalidation; a failed fetch
// clears itself so the next caller retries.
//
// `data` is also retained alongside `promise` so that AskGraphDelta turn
// upserts (api_contract_ask.md "AskGraphDelta") can mutate the cached
// snapshot in place and notify subscribers without re-fetching.
let _graphSnapshotCache = null;       // { promise, data } | null
// Bumped ONLY when the account changes (resetGraphAccountState) — never by an
// ordinary invalidation such as the user's manual reload on Fabric, which wants
// an in-flight refetch to still land. A reload started under the previous
// account can still be in flight when the session changes; the generation it
// captured no longer matches, so its result is dropped instead of being
// published to the new account's cache and subscribers.
let _graphGeneration = 0;
const _graphSnapshotSubs = new Set();

function fetchGraphSnapshotCached() {
  if (_graphSnapshotCache) return _graphSnapshotCache.promise;
  const promise = fetchGraphSnapshot();
  const slot = { promise, data: null };
  _graphSnapshotCache = slot;
  promise.then(s => {
    if (_graphSnapshotCache === slot) _graphSnapshotCache.data = s;
  }).catch(() => {
    if (_graphSnapshotCache === slot) _graphSnapshotCache = null;
  });
  return promise;
}

function primeGraphSnapshot() {
  return fetchGraphSnapshotCached().catch(() => { /* silent — consumers handle their own UI */ });
}

function invalidateGraphSnapshot() { _graphSnapshotCache = null; }

// Subscribe to live snapshot updates produced by applyAskGraphDelta. The
// callback fires with the new snapshot object whenever a turn merges
// upserts into the cache. Returns an unsubscribe handle.
function subscribeGraphSnapshot(cb) {
  _graphSnapshotSubs.add(cb);
  return () => { _graphSnapshotSubs.delete(cb); };
}

function _notifyGraphSnapshotSubs(snapshot) {
  _graphSnapshotSubs.forEach(cb => {
    try { cb(snapshot); } catch (e) { /* subscriber errors don't break others */ }
  });
}

// Dedicated channel for "a turn just learned brand-new concept(s)" — distinct
// from the generic snapshot subscription so a global notification can react to
// learning specifically (not to every delta merge). Callback receives an array
// of the newly-added concept node DTOs.
const _conceptsLearnedSubs = new Set();
function subscribeConceptsLearned(cb) {
  _conceptsLearnedSubs.add(cb);
  return () => { _conceptsLearnedSubs.delete(cb); };
}
// Cross-source dedup: the same new concept can arrive both via our own ask
// `final` graph_delta AND via the global /skill/events broadcast (with a delay).
// A short-TTL id set ensures each concept fires the toast at most once, even if
// the second arrival lands after the toast already dismissed.
const _recentlyLearnedIds = new Map();   // concept id -> timestamp
const _LEARNED_DEDUP_MS = 60000;
function _notifyConceptsLearned(concepts) {
  const now = Date.now();
  for (const [id, ts] of _recentlyLearnedIds) {
    if (now - ts > _LEARNED_DEDUP_MS) _recentlyLearnedIds.delete(id);
  }
  const fresh = (concepts || []).filter(c => c && c.id && !_recentlyLearnedIds.has(c.id));
  if (!fresh.length) return;
  fresh.forEach(c => _recentlyLearnedIds.set(c.id, now));
  _conceptsLearnedSubs.forEach(cb => {
    try { cb(fresh); } catch (e) { /* subscriber errors don't break others */ }
  });
}

// Refetch the authoritative snapshot from the server, replace the cache, and
// notify subscribers so live views (fabric / learning) re-render. Used when a
// concept is created on ANOTHER terminal (api_contract_ask.md GET /skill/events
// → "重新加载图谱"), since that node isn't in our locally-merged cache.
let _reloadPending = null;
function reloadGraphSnapshot() {
  // Coalesce: a burst of concept_created events (or a backend-restart replay)
  // collapses into ONE in-flight GET /skill/graph instead of N parallel fetches
  // that could resolve out of order and regress the cache to stale data.
  if (_reloadPending) return _reloadPending;
  const gen = _graphGeneration;
  let pending;
  pending = fetchGraphSnapshot()
    .then(s => {
      // Signed out (or into another account) while this was in flight — the
      // snapshot belongs to the previous account, so drop it.
      if (gen !== _graphGeneration) return null;
      _graphSnapshotCache = { promise: Promise.resolve(s), data: s };
      _notifyGraphSnapshotSubs(s);
      return s;
    })
    .catch(() => null)                        // keep the existing cache on failure
    // Only clear the slot if it is still OURS. resetGraphAccountState() drops
    // it on sign-out, and the next account may already have a reload of its own
    // registered by the time this one settles — clearing unconditionally would
    // release the coalescing lock and let a second parallel /skill/graph start.
    .finally(() => { if (_reloadPending === pending) _reloadPending = null; });
  _reloadPending = pending;
  return pending;
}

// Global event stream (api_contract_ask.md "GET /skill/events"): a long-lived
// SSE the page subscribes to once. The backend broadcasts `concept_created`
// whenever ANY terminal learns a new concept — so concepts created by other
// users/sessions reach us here (the local /skill/ask `final` graph_delta only
// covers our own turns). On `concept_created` we reload the graph snapshot and
// fan the concept out to the same learned-concept channel that drives the
// global toast + reveal. Singleton; EventSource auto-reconnects on drop.
let _graphEventsSource = null;
function connectGraphEvents() {
  if (typeof graphApiMockEnabled === 'function' && graphApiMockEnabled()) return; // no backend in mock
  if (_graphEventsSource) return;                      // already connected
  if (typeof EventSource === 'undefined') return;      // unsupported environment
  const base = window.PARCLE_API_BASE || '/api';
  let es;
  try { es = new EventSource(base + '/skill/events'); }
  catch (e) { return; }
  _graphEventsSource = es;
  es.addEventListener('concept_created', (ev) => {
    let payload;
    try { payload = JSON.parse(ev.data); } catch (e) { return; }
    const c = payload && payload.concept;
    if (!c || !c.id) return;
    // Reload first so the graph (and the reveal highlight) can find the node,
    // then announce it. `_notifyConceptsLearned` dedups downstream by id, so a
    // self-created concept that also arrives via our own `final` is harmless.
    reloadGraphSnapshot().finally(() => {
      _notifyConceptsLearned([{ id: c.id, name: c.name || c.id, origin: c.origin, kind: 'concept' }]);
    });
  });
  // Hard deletes from other sessions: drop the rows from the cached snapshot
  // so this terminal stops offering mutations on nodes/edges that no longer
  // exist. Self-originated deletes arrive here too — removal is idempotent.
  es.addEventListener('graph_deleted', (ev) => {
    let payload;
    try { payload = JSON.parse(ev.data); } catch (e) { return; }
    _removeNodesAndEdges(payload.nodes_deleted, payload.edges_deleted);
  });
  // `ready` / `ping` events are intentionally ignored; EventSource reconnects
  // automatically on transient errors. If the browser gives up (permanent
  // close, e.g. a non-retryable status), clear the singleton so a future
  // connectGraphEvents() can re-establish.
  es.onerror = () => {
    if (es.readyState === EventSource.CLOSED) _graphEventsSource = null;
  };
}

// Tear the stream down. The connection outlives any single page/session — on
// sign-out it would keep firing `concept_created`, each one triggering a
// snapshot refetch with whatever token is (or isn't) in storage. The prime
// effect in index.html reconnects after the next sign-in.
function disconnectGraphEvents() {
  if (!_graphEventsSource) return;
  try { _graphEventsSource.close(); } catch (e) { /* already closed */ }
  _graphEventsSource = null;
}

// Everything in this module that holds ONE account's knowledge graph. Called
// from parcleResetAccountScopedState() (auth.jsx) on every session change,
// because the console switches accounts without reloading the page — module
// state survives sign-out and would otherwise be served to the next account.
// The mergers themselves (applyAskGraphDelta / applyMutationResponse) hold no
// generation check — each producer of a delta guards its own result instead:
// ask streams are aborted by ChatPage on unmount, which is what a session
// change does to it; mutation responses are neutered in _postGraphMutationReal.
// A new producer of graph deltas needs its own guard, or the delta lands in the
// next account's snapshot and is broadcast to its subscribers.
function resetGraphAccountState() {
  disconnectGraphEvents();
  _graphGeneration++;                 // voids any reload still in flight for the outgoing account
  invalidateGraphSnapshot();
  _reloadPending = null;
  _recentlyLearnedIds.clear();
}

// Shared upsert core for both AskGraphDelta and GraphMutationResponse.
// Both protocols return full node DTOs (by `id`) and edge DTOs (per-type
// composite key) — we just merge them into the cached snapshot. Returned
// snapshot is a shallow-cloned object with shallow-cloned arrays, so React
// subscribers see a new identity even though items are kept by reference.
function _mergeNodesAndEdges(nodes, edges) {
  if (!_graphSnapshotCache || !_graphSnapshotCache.data) return null;
  if ((!nodes || nodes.length === 0) && (!edges || edges.length === 0)) return null;

  const snap = _graphSnapshotCache.data;
  const next = {
    ...snap,
    factual_nodes:    (snap.factual_nodes    || []).slice(),
    concept_nodes:    (snap.concept_nodes    || []).slice(),
    structure_edges:  (snap.structure_edges  || []).slice(),
    mapping_edges:    (snap.mapping_edges    || []).slice(),
    dependency_edges: (snap.dependency_edges || []).slice(),
    relation_edges:   (snap.relation_edges   || []).slice(),
    category_edges:   (snap.category_edges   || []).slice(),
  };

  const upsertById = (arr, item) => {
    const idx = arr.findIndex(x => x.id === item.id);
    if (idx >= 0) arr[idx] = item;
    else arr.push(item);
  };

  // Always use the per-type composite key, never the backend's `key` field.
  // /skill/graph snapshot edges don't carry `key`, but /skill/ask deltas and
  // some mutation responses do — mixing the two would mismatch identities
  // and merger would push a duplicate, doubling rerank/relation events.
  // Composite key alone is sufficient per the contract's edge dedup spec.
  const edgeIdent = (e, t) => {
    const ty = e.type || t || '';
    if (ty === 'structure') {
      const fc = (e.properties && e.properties.from_column) || '';
      const tc = (e.properties && e.properties.to_column)   || '';
      return `${e.source}|${e.target}|s:${fc}|${tc}`;
    }
    if (ty === 'relation') return `${e.source}|${e.target}|r:${e.relation || ''}`;
    if (ty === 'category') return `${e.source}|${e.target}|c:${e.value    || ''}`;
    return `${e.source}|${e.target}`;
  };

  const upsertEdge = (arr, item, t) => {
    const id = edgeIdent(item, t);
    const idx = arr.findIndex(x => edgeIdent(x, t) === id);
    if (idx >= 0) arr[idx] = item;
    else arr.push(item);
  };

  (nodes || []).forEach(n => {
    if (!n) return;
    if (n.kind === 'concept') upsertById(next.concept_nodes, n);
    else                      upsertById(next.factual_nodes, n);
  });

  (edges || []).forEach(e => {
    if (!e) return;
    switch (e.type) {
      case 'structure':  upsertEdge(next.structure_edges,  e, 'structure');  break;
      case 'mapping':    upsertEdge(next.mapping_edges,    e, 'mapping');    break;
      case 'dependency': upsertEdge(next.dependency_edges, e, 'dependency'); break;
      case 'relation':   upsertEdge(next.relation_edges,   e, 'relation');   break;
      case 'category':   upsertEdge(next.category_edges,   e, 'category');   break;
      default: /* unknown edge type — skip rather than corrupt arrays */     break;
    }
  });

  _graphSnapshotCache.data    = next;
  _graphSnapshotCache.promise = Promise.resolve(next);
  _notifyGraphSnapshotSubs(next);
  return next;
}

// AskGraphDelta merger — see api_contract_ask.md "AskGraphDelta".
//   - Upserts node DTOs into factual_nodes / concept_nodes by `id`
//     (dispatched on `kind`).
//   - Upserts edge DTOs into the per-type edge arrays. Identity is the
//     backend-provided `key` when present, falling back to per-type
//     composite keys.
// If no snapshot is cached yet the delta is dropped — the next consumer
// will fetch the full updated state from /skill/graph.
function applyAskGraphDelta(delta) {
  if (!delta || !delta.changed) return;
  // Detect genuinely-new learned concepts (not dedup / synonym-merge updates)
  // by diffing the upserted concept ids against ids already in the cached
  // snapshot — so a global notification fires only on truly new concepts.
  const conceptUpserts = (delta.nodes_upserted || []).filter(n => n && n.kind === 'concept');
  let learned = [];
  if (conceptUpserts.length) {
    const cache = _graphSnapshotCache && _graphSnapshotCache.data;
    if (cache) {
      const existing = new Set((cache.concept_nodes || []).map(n => n.id));
      learned = conceptUpserts.filter(n => !existing.has(n.id));
    } else {
      // No snapshot to diff against — best-effort: treat conversation-learned
      // concept upserts as new (can't distinguish dedup hits without a cache).
      learned = conceptUpserts.filter(n => n.origin === 'conversation_learning');
    }
  }
  _mergeNodesAndEdges(delta.nodes_upserted, delta.edges_upserted);
  if (learned.length) _notifyConceptsLearned(learned);
}

// GraphMutationResponse merger — same wire shape as AskGraphDelta but
// returned by /skill/graph/edit, /skill/graph/undo, /skill/graph/recover,
// /skill/graph/create. Also accepts a GraphDeleteResponse (nodes_deleted /
// edges_deleted from /skill/graph/delete) so callers can pipe every mutation
// through one applier. Returns the merged snapshot (or null if nothing to
// apply / no cache).
function applyMutationResponse(resp) {
  if (!resp || !resp.changed) return null;
  if (resp.nodes_deleted || resp.edges_deleted) {
    return _removeNodesAndEdges(resp.nodes_deleted, resp.edges_deleted);
  }
  return _mergeNodesAndEdges(resp.nodes_upserted, resp.edges_upserted);
}

// Removal core for GraphDeleteResponse — drops node DTOs by id and edge DTOs
// by the same per-type composite key the merger uses. Snapshot cloning /
// subscriber notification mirrors _mergeNodesAndEdges.
function _removeNodesAndEdges(nodeIds, edgeSelectors) {
  if (!_graphSnapshotCache || !_graphSnapshotCache.data) return null;
  if ((!nodeIds || nodeIds.length === 0) && (!edgeSelectors || edgeSelectors.length === 0)) return null;

  const snap = _graphSnapshotCache.data;
  const doomedNodes = new Set(nodeIds || []);
  const edgeIdent = (e, t) => {
    const ty = e.type || t || '';
    if (ty === 'structure') {
      const props = e.properties || {};
      const fc = e.from_column != null ? e.from_column : (props.from_column || '');
      const tc = e.to_column   != null ? e.to_column   : (props.to_column   || '');
      return `${e.source}|${e.target}|s:${fc}|${tc}`;
    }
    if (ty === 'relation') return `${e.source}|${e.target}|r:${e.relation || ''}`;
    if (ty === 'category') return `${e.source}|${e.target}|c:${e.value    || ''}`;
    return `${e.source}|${e.target}`;
  };
  const doomedByType = {};
  (edgeSelectors || []).forEach(sel => {
    if (!sel || !sel.type) return;
    (doomedByType[sel.type] = doomedByType[sel.type] || new Set()).add(edgeIdent(sel, sel.type));
  });

  const keepEdges = (arr, t) => (arr || []).filter(e => {
    if (e.source && doomedNodes.has(e.source)) return false;
    if (e.target && doomedNodes.has(e.target)) return false;
    const doomed = doomedByType[t];
    return !(doomed && doomed.has(edgeIdent(e, t)));
  });

  const next = {
    ...snap,
    factual_nodes:    (snap.factual_nodes || []).filter(n => !doomedNodes.has(n.id)),
    concept_nodes:    (snap.concept_nodes || []).filter(n => !doomedNodes.has(n.id)),
    structure_edges:  keepEdges(snap.structure_edges,  'structure'),
    mapping_edges:    keepEdges(snap.mapping_edges,    'mapping'),
    dependency_edges: keepEdges(snap.dependency_edges, 'dependency'),
    relation_edges:   keepEdges(snap.relation_edges,   'relation'),
    category_edges:   keepEdges(snap.category_edges,   'category'),
  };

  _graphSnapshotCache.data    = next;
  _graphSnapshotCache.promise = Promise.resolve(next);
  _notifyGraphSnapshotSubs(next);
  return next;
}

// Build a GraphEdgeSelector from a tagged edge DTO (one of the per-type
// arrays after `allGraphEdges`-style tagging). Centralizes the structure /
// relation / category special-case logic so callers stay shape-agnostic.
function edgeSelectorFromDTO(edge) {
  const sel = { type: edge.type, source: edge.source, target: edge.target };
  if (edge.type === 'relation') sel.relation = edge.relation || '';
  if (edge.type === 'category') sel.value    = edge.value    || '';
  if (edge.type === 'structure') {
    const props = edge.properties || {};
    sel.from_column = props.from_column || '';
    sel.to_column   = props.to_column   || '';
  }
  return sel;
}

// ─────────────────────────────────────────────────────────────────────────
// Graph mutations — POST /skill/graph/{edit,undo,recover}.
// All three return GraphMutationResponse {changed, nodes_upserted, edges_upserted}
// which callers should pipe through applyMutationResponse(resp) to update the
// cached snapshot and notify subscribers. Errors surface as Error with
// `.status` set to the HTTP code (matches chat_api convention).
// ─────────────────────────────────────────────────────────────────────────
async function _postGraphMutationReal(path, body) {
  const base = window.PARCLE_API_BASE || '/api';
  // Nothing aborts these requests when their page unmounts, so one can still be
  // in flight after the account changes — sign-out is instant, and the demo
  // sign-in that may follow it needs no round trip at all. The response then
  // describes the OUTGOING account's graph, and applyMutationResponse would
  // merge it into the incoming account's cached snapshot AND push it to every
  // live subscriber. Report it as "nothing changed" instead: callers pipe that
  // through the applier as a no-op.
  const gen = _graphGeneration;
  const res = await fetch(`${base}${path}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', ...parcleAuthHeaders() },
    body: JSON.stringify(body),
  });
  if (res.status === 401 && typeof parcleNotifyUnauthorized === 'function') parcleNotifyUnauthorized();
  if (!res.ok) {
    let msg = `HTTP ${res.status}`;
    try {
      const j = await res.json();
      if (j && j.detail) msg = typeof j.detail === 'string' ? j.detail : JSON.stringify(j.detail);
    } catch (e) { /* non-JSON body */ }
    const err = new Error(msg);
    err.status = res.status;
    throw err;
  }
  const data = await res.json();
  return _graphGeneration === gen ? data : { changed: false };
}

// ─────────────────────────────────────────────────────────────────────────
// Mock mutator — operates on the cached snapshot DTOs in place and packs
// the changed items into a GraphMutationResponse. Demo-grade; no FK
// cascades beyond what the contract spells out (incident edges flip with
// their owning node).
// ─────────────────────────────────────────────────────────────────────────
function _mockTimestamp() {
  return new Date().toISOString().slice(0, 16);
}

function _mockFindNode(snap, id) {
  return (snap.concept_nodes || []).find(n => n.id === id)
      || (snap.factual_nodes || []).find(n => n.id === id)
      || null;
}

function _mockFindEdge(snap, sel) {
  const arrByType = {
    structure:  snap.structure_edges,
    mapping:    snap.mapping_edges,
    dependency: snap.dependency_edges,
    relation:   snap.relation_edges,
    category:   snap.category_edges,
  };
  const arr = arrByType[sel.type] || [];
  return arr.find(e => {
    if (e.source !== sel.source || e.target !== sel.target) return false;
    if (sel.type === 'relation' && (e.relation || '') !== (sel.relation || '')) return false;
    if (sel.type === 'category' && (e.value    || '') !== (sel.value    || '')) return false;
    if (sel.type === 'structure') {
      const props = e.properties || {};
      if ((props.from_column || '') !== (sel.from_column || '')) return false;
      if ((props.to_column   || '') !== (sel.to_column   || '')) return false;
    }
    return true;
  }) || null;
}

function _mockIncidentEdges(snap, nodeId) {
  const all = [];
  ['structure_edges','mapping_edges','dependency_edges','relation_edges','category_edges'].forEach(k => {
    (snap[k] || []).forEach(e => {
      if (e.source === nodeId || e.target === nodeId) all.push(e);
    });
  });
  return all;
}

function _mockNormalizedNodeDTO(node) {
  // Snapshot stores nodes without `kind`; mutation responses need it. Stamp
  // it lazily so mock callers get the same DTO shape as the real backend.
  if (node.kind) return node;
  const isConcept = node.synonyms !== undefined || node.dedup_events !== undefined
                  || (node.id || '').startsWith('concept_');
  return { ...node, kind: isConcept ? 'concept' : 'factual' };
}

function _mockMutate(path, body) {
  if (!_graphSnapshotCache || !_graphSnapshotCache.data) {
    return Promise.reject(Object.assign(new Error('Graph snapshot not loaded'), { status: 503 }));
  }
  const snap = _graphSnapshotCache.data;
  const changedNodes = new Map();   // id → node DTO
  const changedEdges = [];
  const stamp = _mockTimestamp();

  const touchNode = (n, state) => {
    n.state = state;
    changedNodes.set(n.id, _mockNormalizedNodeDTO(n));
  };
  const touchEdge = (e, state) => {
    e.state = state;
    changedEdges.push(e);
  };

  if (path === '/skill/graph/edit') {
    const { target_type, target_id, edge: edgeSel, patch } = body;
    if (target_type === 'concept' || target_type === 'table') {
      const node = _mockFindNode(snap, target_id);
      if (!node) return Promise.reject(Object.assign(new Error('Target not found'), { status: 404 }));
      Object.entries(patch || {}).forEach(([k, v]) => {
        if (k === 'name') node.name = v;
        else if (k === 'synonyms' && target_type === 'concept') {
          // Only allow additions/restorations. Existing active synonyms must
          // remain (contract). We append new entries as SynonymEntry, with a
          // matching DedupEvent so the manual edit can be undone later.
          const existing = (node.synonyms || []).filter(s => s.state !== 'undo').map(s => s.name);
          const incoming = Array.isArray(v) ? v : [];
          const added = incoming.filter(name => !existing.includes(name));
          added.forEach(name => {
            const evRef = `manual_edit:${stamp}:${name}`;
            (node.synonyms = node.synonyms || []).push({
              name, at: stamp, origin: 'manual',
              evidence_ref: evRef, state: 'active',
            });
            (node.dedup_events = node.dedup_events || []).push({
              at: stamp, attempted_name: name, attempted_synonyms: [],
              added_synonyms: [name], origin: 'manual',
              reason: 'manual_synonym_edit', evidence_ref: evRef, state: 'active',
            });
          });
        } else if (k.startsWith('properties.')) {
          const path = k.slice('properties.'.length).split('.');
          let cur = (node.properties = node.properties || {});
          for (let i = 0; i < path.length - 1; i++) {
            cur[path[i]] = cur[path[i]] || {};
            cur = cur[path[i]];
          }
          cur[path[path.length - 1]] = v;
        }
      });
      changedNodes.set(node.id, _mockNormalizedNodeDTO(node));
    } else if (target_type === 'edge') {
      const e = _mockFindEdge(snap, edgeSel);
      if (!e) return Promise.reject(Object.assign(new Error('Edge not found'), { status: 404 }));
      if ('confidence' in (patch || {})) {
        if (e.type === 'structure') {
          return Promise.reject(Object.assign(new Error('structure edge confidence is not editable'), { status: 400 }));
        }
        const newVal = patch.confidence;
        e.confidence = newVal;
        const evRef = `manual_edit:${stamp}`;
        (e.confidence_history = e.confidence_history || []).push({
          value: newVal, at: stamp, origin: 'manual',
          reason: 'manual_edit', evidence_ref: evRef, state: 'active',
        });
        changedEdges.push(e);
      }
    } else {
      return Promise.reject(Object.assign(new Error(`Unsupported target_type ${target_type}`), { status: 400 }));
    }
  } else if (path === '/skill/graph/undo' || path === '/skill/graph/recover') {
    const newState = path === '/skill/graph/undo' ? 'undo' : 'active';
    const { target_type, target_id, edge: edgeSel, index, event_ref } = body;

    if (target_type === 'node') {
      const node = _mockFindNode(snap, target_id);
      if (!node) return Promise.reject(Object.assign(new Error('Node not found'), { status: 404 }));
      touchNode(node, newState);
      // Cascade: incident edges flip on undo. Recover only restores the node
      // itself (per contract — keeps user's per-edge fine-grained undo safe).
      if (newState === 'undo') {
        _mockIncidentEdges(snap, node.id).forEach(e => touchEdge(e, 'undo'));
      }
    } else if (target_type === 'edge') {
      const e = _mockFindEdge(snap, edgeSel);
      if (!e) return Promise.reject(Object.assign(new Error('Edge not found'), { status: 404 }));
      touchEdge(e, newState);
    } else if (target_type === 'dedup') {
      const node = _mockFindNode(snap, target_id);
      if (!node) return Promise.reject(Object.assign(new Error('Concept not found'), { status: 404 }));
      const events = node.dedup_events || [];
      let evIdx = (typeof index === 'number') ? index : -1;
      if (evIdx < 0 && event_ref) evIdx = events.findIndex(d => d.evidence_ref === event_ref);
      if (evIdx < 0 || evIdx >= events.length) {
        return Promise.reject(Object.assign(new Error('Dedup event not found'), { status: 404 }));
      }
      const ev = events[evIdx];
      ev.state = newState;
      // Flip the synonyms this event added.
      const added = new Set(ev.added_synonyms || []);
      (node.synonyms || []).forEach(s => {
        if (added.has(s.name) && s.evidence_ref === ev.evidence_ref) s.state = newState;
      });
      changedNodes.set(node.id, _mockNormalizedNodeDTO(node));
    } else if (target_type === 'rerank') {
      const e = _mockFindEdge(snap, edgeSel);
      if (!e) return Promise.reject(Object.assign(new Error('Edge not found'), { status: 404 }));
      const hist = e.confidence_history || [];
      if (typeof index !== 'number' || index < 0 || index >= hist.length) {
        return Promise.reject(Object.assign(new Error('Rerank entry not found'), { status: 404 }));
      }
      if (hist[index].reason === 'initial') {
        return Promise.reject(Object.assign(new Error('initial entry is not undoable'), { status: 400 }));
      }
      hist[index].state = newState;
      // Recompute edge.confidence from the most recent active entry.
      const active = hist.filter(h => h.state !== 'undo');
      if (active.length > 0) e.confidence = active[active.length - 1].value;
      changedEdges.push(e);
    } else {
      return Promise.reject(Object.assign(new Error(`Unsupported target_type ${target_type}`), { status: 400 }));
    }
  } else if (path === '/skill/graph/create') {
    return _mockCreate(snap, body, stamp);
  } else if (path === '/skill/graph/delete') {
    return _mockDelete(snap, body);
  } else {
    return Promise.reject(Object.assign(new Error(`Unknown mutation path ${path}`), { status: 400 }));
  }

  return Promise.resolve({
    changed: changedNodes.size > 0 || changedEdges.length > 0,
    nodes_upserted: Array.from(changedNodes.values()),
    edges_upserted: changedEdges.slice(),
  });
}

// Mock /skill/graph/create — mirrors the backend contract: concept creation
// dedups on name/synonym, edge creation validates endpoint kinds and rejects
// duplicates. DTOs are pushed into the cached snapshot in place.
function _mockCreate(snap, body, stamp) {
  const reject = (msg, status) => Promise.reject(Object.assign(new Error(msg), { status }));
  if (body.target_type === 'concept') {
    const spec = body.concept || {};
    const name = String(spec.name || '').trim();
    if (!name) return reject('Concept name cannot be empty', 400);
    const taken = new Set();
    (snap.concept_nodes || []).forEach(n => {
      // Matches backend find_concept_by_name: merged tombstones and
      // soft-undone concepts don't hold their names.
      if (n.status === 'merged' || n.state === 'undo') return;
      taken.add((n.name || '').toLowerCase());
      (n.synonyms || []).forEach(s => {
        const sn = typeof s === 'string' ? s : (s.state !== 'undo' ? s.name : null);
        if (sn) taken.add(sn.toLowerCase());
      });
    });
    const requested = [name, ...(spec.synonyms || [])];
    for (const cand of requested) {
      if (taken.has(String(cand).toLowerCase())) return reject(`A concept already uses the name or synonym: ${cand}`, 409);
    }
    const base = 'concept_' + (name.replace(/[^A-Za-z0-9_]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase() || 'node');
    let id = base;
    let suffix = 2;
    while (_mockFindNode(snap, id)) { id = `${base}_${suffix}`; suffix += 1; }
    const properties = {};
    if (spec.description) properties.description = spec.description;
    if (spec.formula)     properties.formula     = spec.formula;
    const evRef = `manual_edit:${stamp}`;
    const node = {
      id, kind: 'concept', name,
      synonyms: (spec.synonyms || []).filter(s => String(s).toLowerCase() !== name.toLowerCase())
        .map(s => ({ name: s, at: stamp, origin: 'manual', evidence_ref: evRef, state: 'active' })),
      properties, origin: 'manual', created_at: stamp, state: 'active',
      status: 'active', merged_into: null, merged_at: null, merged_from: [], dedup_events: [],
    };
    snap.concept_nodes = snap.concept_nodes || [];
    snap.concept_nodes.push(node);
    return Promise.resolve({ changed: true, nodes_upserted: [node], edges_upserted: [] });
  }
  if (body.target_type === 'edge') {
    const spec = body.edge || {};
    const src = _mockFindNode(snap, spec.source);
    const tgt = _mockFindNode(snap, spec.target);
    if (!src) return reject(`Source node not found: ${spec.source}`, 404);
    if (!tgt) return reject(`Target node not found: ${spec.target}`, 404);
    const isConcept = n => (snap.concept_nodes || []).includes(n);
    if (spec.type === 'mapping') {
      if (!isConcept(src) || isConcept(tgt)) return reject('mapping edges connect a concept (source) to a factual node (target)', 400);
    } else if (['dependency', 'relation', 'category'].includes(spec.type)) {
      if (!isConcept(src) || !isConcept(tgt)) return reject(`${spec.type} edges connect two concept nodes`, 400);
    } else {
      return reject(`Unsupported edge type ${spec.type}`, 400);
    }
    if (spec.type === 'relation' && !(spec.relation || '').trim()) return reject('relation edges require a non-empty relation', 400);
    if (spec.type === 'category' && !(spec.value    || '').trim()) return reject('category edges require a non-empty value', 400);
    const sel = { type: spec.type, source: spec.source, target: spec.target,
                  relation: (spec.relation || '').trim(), value: (spec.value || '').trim() };
    if (_mockFindEdge(snap, sel)) return reject('Edge already exists (it may be in undo state — use recover)', 409);
    const conf = spec.confidence != null ? Number(spec.confidence) : 0.8;
    const evRef = `manual_edit:${stamp}`;
    const edge = {
      type: spec.type, source: spec.source, target: spec.target,
      confidence: conf, evidence_ref: evRef, origin: 'manual', created_at: stamp, state: 'active',
      confidence_history: [{ value: conf, at: stamp, origin: 'manual', reason: 'initial', evidence_ref: evRef, state: 'active' }],
    };
    if (spec.type === 'relation') edge.relation = sel.relation;
    if (spec.type === 'category') { edge.value = sel.value; edge.data_type = spec.data_type || 'TEXT'; }
    const arrKey = { mapping: 'mapping_edges', dependency: 'dependency_edges', relation: 'relation_edges', category: 'category_edges' }[spec.type];
    snap[arrKey] = snap[arrKey] || [];
    snap[arrKey].push(edge);
    return Promise.resolve({ changed: true, nodes_upserted: [], edges_upserted: [edge] });
  }
  return reject(`Unsupported target_type ${body.target_type}`, 400);
}

// Mock /skill/graph/delete — hard removal from the cached snapshot. Node
// deletes cascade to owned column/chunk children (matching the backend) and
// every incident edge; edge deletes remove exactly the selected edge.
function _mockDelete(snap, body) {
  const reject = (msg, status) => Promise.reject(Object.assign(new Error(msg), { status }));
  const EDGE_ARRAYS = ['structure_edges', 'mapping_edges', 'dependency_edges', 'relation_edges', 'category_edges'];
  const selectorOf = (e, key) => {
    const type = key.replace('_edges', '');
    const dto = { type, source: e.source, target: e.target };
    if (type === 'relation') dto.relation = e.relation || '';
    if (type === 'category') dto.value    = e.value    || '';
    if (type === 'structure') {
      const props = e.properties || {};
      dto.from_column = props.from_column || '';
      dto.to_column   = props.to_column   || '';
    }
    return dto;
  };

  if (body.target_type === 'node') {
    const root = _mockFindNode(snap, body.target_id);
    if (!root) return reject('Node not found', 404);
    const doomed = new Set([root.id]);
    if (root.type === 'table' || root.type === 'document') {
      const childType = root.type === 'table' ? 'column' : 'chunk';
      (snap.structure_edges || []).forEach(e => {
        const other = e.source === root.id ? e.target : (e.target === root.id ? e.source : null);
        if (!other) return;
        const child = (snap.factual_nodes || []).find(n => n.id === other);
        if (child && child.type === childType) doomed.add(child.id);
      });
    }
    const edgesDeleted = [];
    EDGE_ARRAYS.forEach(key => {
      const keep = [];
      (snap[key] || []).forEach(e => {
        if (doomed.has(e.source) || doomed.has(e.target)) edgesDeleted.push(selectorOf(e, key));
        else keep.push(e);
      });
      snap[key] = keep;
    });
    snap.factual_nodes = (snap.factual_nodes || []).filter(n => !doomed.has(n.id));
    snap.concept_nodes = (snap.concept_nodes || []).filter(n => !doomed.has(n.id));
    return Promise.resolve({ changed: true, nodes_deleted: Array.from(doomed).sort(), edges_deleted: edgesDeleted });
  }
  if (body.target_type === 'edge') {
    const e = _mockFindEdge(snap, body.edge || {});
    if (!e) return reject('Edge not found', 404);
    const key = { structure: 'structure_edges', mapping: 'mapping_edges', dependency: 'dependency_edges',
                  relation: 'relation_edges', category: 'category_edges' }[(body.edge || {}).type];
    snap[key] = (snap[key] || []).filter(x => x !== e);
    return Promise.resolve({ changed: true, nodes_deleted: [], edges_deleted: [selectorOf(e, key)] });
  }
  return reject(`Unsupported target_type ${body.target_type}`, 400);
}

function editGraph(req)    { return graphApiMockEnabled() ? _mockMutate('/skill/graph/edit',    req) : _postGraphMutationReal('/skill/graph/edit',    req); }
function undoGraph(req)    { return graphApiMockEnabled() ? _mockMutate('/skill/graph/undo',    req) : _postGraphMutationReal('/skill/graph/undo',    req); }
function recoverGraph(req) { return graphApiMockEnabled() ? _mockMutate('/skill/graph/recover', req) : _postGraphMutationReal('/skill/graph/recover', req); }
function createGraph(req)  { return graphApiMockEnabled() ? _mockMutate('/skill/graph/create', req) : _postGraphMutationReal('/skill/graph/create', req); }
function deleteGraph(req)  { return graphApiMockEnabled() ? _mockMutate('/skill/graph/delete', req) : _postGraphMutationReal('/skill/graph/delete', req); }

Object.assign(window, {
  fetchGraphSnapshot, fetchGraphSnapshotCached, primeGraphSnapshot, invalidateGraphSnapshot,
  subscribeGraphSnapshot, subscribeConceptsLearned, applyAskGraphDelta, applyMutationResponse,
  reloadGraphSnapshot, connectGraphEvents, disconnectGraphEvents, resetGraphAccountState,
  editGraph, undoGraph, recoverGraph, createGraph, deleteGraph, edgeSelectorFromDTO,
  graphApiMockEnabled, MOCK_GRAPH_SNAPSHOT,
});
