// 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,
        columns: [
          { name: 'order_id',   data_type: 'INTEGER' },
          { name: 'user_id',    data_type: 'INTEGER' },
          { name: 'amount',     data_type: 'DOUBLE' },
          { name: 'region',     data_type: 'VARCHAR' },
          { name: 'created_at', data_type: 'TIMESTAMP' },
          { name: 'status',     data_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: [
          { name: 'user_id',        data_type: 'INTEGER' },
          { name: 'name',           data_type: 'VARCHAR' },
          { name: 'region',         data_type: 'VARCHAR' },
          { name: 'last_active_at', data_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. A personal (user-account) session would be
// rejected, and several always-mounted consumers (the TopBar LearningTicker,
// the global LearnedConceptToast) fetch the snapshot on their own. Guard
// centrally: in a personal session resolve an empty snapshot so nobody issues
// a request that 403s. Org and mock sessions are unaffected.
const EMPTY_GRAPH_SNAPSHOT = {
  factual_nodes: [], concept_nodes: [],
  structure_edges: [], mapping_edges: [], dependency_edges: [],
  relation_edges: [], category_edges: [],
};
function parcleGraphAllowed() {
  return !(typeof parcleMode === 'function' && parcleMode() === 'personal');
}

function fetchGraphSnapshot() {
  if (graphApiMockEnabled()) return fetchGraphSnapshotMock();
  if (!parcleGraphAllowed()) return Promise.resolve(JSON.parse(JSON.stringify(EMPTY_GRAPH_SNAPSHOT)));
  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
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;
  _reloadPending = fetchGraphSnapshot()
    .then(s => { _graphSnapshotCache = { promise: Promise.resolve(s), data: s }; _notifyGraphSnapshotSubs(s); return s; })
    .catch(() => null)                        // keep the existing cache on failure
    .finally(() => { _reloadPending = null; });
  return _reloadPending;
}

// 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' }]);
    });
  });
  // `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;
  };
}

// 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.
// Returns the merged snapshot (or null if nothing to apply / no cache).
function applyMutationResponse(resp) {
  if (!resp || !resp.changed) return null;
  return _mergeNodesAndEdges(resp.nodes_upserted, resp.edges_upserted);
}

// 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';
  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;
  }
  return res.json();
}

// ─────────────────────────────────────────────────────────────────────────
// 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 {
    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(),
  });
}

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); }

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