// Graph learning-event derivation. Split out of graph_layout.jsx.
// Depends on graph_api.jsx (edgeSelectorFromDTO); load after it.
//
// Reconstruct the learning event timeline purely from a `/skill/graph`
// snapshot — no SSE needed (api_contract_graph.md, "完整 graph snapshot 即可
// 还原历史"). Each event maps onto one of the sidebar buckets used by the
// Learning page (entity / relation / rerank / dedupe / schema). The
// `gap` bucket is intentionally not derived — protocol has no field for it.

function _learningConfToStatus(conf) {
  if (conf == null || isNaN(conf)) return 'auto';
  if (conf >= 0.9) return 'auto';
  if (conf >= 0.7) return 'applied';
  return 'needs_review';
}

const _LEARNING_REASON_HUMAN = {
  initial:                 'Initial registration',
  implicit_confirm:        'Implicitly confirmed',
  explicit_confirm:        'Explicitly confirmed',
  explicit_correct:        'Explicitly corrected',
  repeat:                  'Reaffirmed by repetition',
  post_turn:               'Post-turn learning',
  'agent_grade:right':     'Agent graded right',
  'agent_grade:wrong':     'Agent graded wrong',
  name_or_synonym_overlap: 'Name or synonym overlap',
  graphify_node_overlap:   'Graphify node overlap',
  llm_merge_proposal:      'LLM proposed merge',
  post_turn_dedup:         'Post-turn dedup',
  manual_synonym_edit:     'Manual edit',
  manual_edit:             'Manual edit',
};

function humanizeLearningReason(reason) {
  if (!reason) return 'Auto';
  if (_LEARNING_REASON_HUMAN[reason]) return _LEARNING_REASON_HUMAN[reason];
  if (reason.startsWith('explicit_reuse:')) {
    return `Reuse of "${reason.slice('explicit_reuse:'.length)}"`;
  }
  if (reason.startsWith('agent_register_rejected:')) {
    return `Register rejected (${reason.slice('agent_register_rejected:'.length)})`;
  }
  return reason;
}

// Graph timestamps (`created_at`, `dedup_events[].at`, `confidence_history[].at`)
// are the backend's `YYYY-MM-DDTHH:MM` — produced by `datetime.now()` with no
// zone marker (parcle/core/knowledge/ontology/models.py, now_minute). The
// backend container runs UTC, so that string IS UTC wall-clock; plain
// `new Date(str)` would read it as BROWSER-local and put every age off by the
// viewer's offset — in UTC+8 a node written seconds ago reads "8 h ago".
// So: anything already carrying a zone is parsed as-is, and a bare
// date-and-time is pinned to UTC.
function parcleParseGraphTime(iso) {
  if (!iso) return NaN;
  if (typeof iso !== 'string') {
    const raw = new Date(iso).getTime();
    return isNaN(raw) ? NaN : raw;
  }
  const s = iso.trim();
  const zoned = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(s);
  const bareDateTime = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(s);
  // A date-only string is already UTC per the ECMAScript date-time format, so
  // it needs no help here.
  const t = new Date(!zoned && bareDateTime ? s.replace(' ', 'T') + 'Z' : s).getTime();
  return isNaN(t) ? NaN : t;
}

function formatRelativeTime(iso) {
  if (!iso) return '';
  const t = parcleParseGraphTime(iso);
  if (isNaN(t)) return '';
  const diff = Math.max(0, Date.now() - t);
  const min = 60000, hr = 60 * min, day = 24 * hr;
  if (diff < min)         return 'just now';
  if (diff < hr)          return `${Math.floor(diff / min)} min ago`;
  if (diff < day)         return `${Math.floor(diff / hr)} h ago`;
  if (diff < 30 * day)    return `${Math.floor(diff / day)} d ago`;
  return new Date(t).toLocaleDateString();
}

// Hard-delete blast radius per node, computed once per snapshot in O(N+E) so
// the confirmation can tell the user how much a delete takes with it.
// `POST /skill/graph/delete` on a node cascades to the node's owned children
// (a table's columns, a document's chunks) and to every edge incident to the
// node OR to one of those children — so an edge is counted against the child
// AND against the child's parent.
// Returns Map<nodeId, { children, edges }>.
function _buildDeleteImpact(snapshot) {
  const typeById = new Map();
  (snapshot.factual_nodes || []).forEach(n => typeById.set(n.id, n.type));

  // Ownership, both directions. Sets (not counters) so a duplicated structure
  // edge between the same pair doesn't inflate the count — the backend
  // collects owned children into a set too. A child with two owners belongs to
  // both, so `parents` is a set as well.
  const childrenOf = new Map();   // parent id → Set<child id>
  const parentsOf  = new Map();   // child id  → Set<parent id>
  const addOwned = (parent, child) => {
    if (!childrenOf.has(parent)) childrenOf.set(parent, new Set());
    childrenOf.get(parent).add(child);
    if (!parentsOf.has(child)) parentsOf.set(child, new Set());
    parentsOf.get(child).add(parent);
  };
  (snapshot.structure_edges || []).forEach(e => {
    [[e.source, e.target], [e.target, e.source]].forEach(([a, b]) => {
      if (!a || !b) return;
      const ta = typeById.get(a), tb = typeById.get(b);
      if ((ta === 'table' && tb === 'column') || (ta === 'document' && tb === 'chunk')) addOwned(a, b);
    });
  });

  const edgeCount = new Map();    // node id → edges that die with it
  const EDGE_KEYS = ['structure_edges', 'mapping_edges', 'dependency_edges', 'relation_edges', 'category_edges'];
  EDGE_KEYS.forEach(k => {
    (snapshot[k] || []).forEach(e => {
      // Every node whose deletion would take this edge with it — deduped, so
      // the edge between a table and its own column is counted once for the
      // table, matching the backend's `source or target in doomed_node_ids`.
      const roots = new Set();
      [e.source, e.target].forEach(id => {
        if (!id) return;
        roots.add(id);
        (parentsOf.get(id) || []).forEach(p => roots.add(p));
      });
      roots.forEach(id => edgeCount.set(id, (edgeCount.get(id) || 0) + 1));
    });
  });

  const impact = new Map();
  const ids = new Set([...childrenOf.keys(), ...edgeCount.keys()]);
  ids.forEach(id => impact.set(id, {
    children: (childrenOf.get(id) || new Set()).size,
    edges:    edgeCount.get(id) || 0,
  }));
  return impact;
}

function deriveLearningEvents(snapshot) {
  if (!snapshot) return [];
  const events = [];

  const nameById = new Map();
  (snapshot.factual_nodes || []).forEach(n => nameById.set(n.id, n.name || n.id));
  (snapshot.concept_nodes || []).forEach(n => nameById.set(n.id, n.name || n.id));

  const deleteImpact = _buildDeleteImpact(snapshot);

  // ── 1. Entity / Schema events from node creations ─────────────────────
  // Document / chunk factual nodes are intentionally excluded from the
  // Learning entity stream — they're file-system artifacts, not learning
  // outcomes, and the table/column + concept signal is what users care about.
  (snapshot.factual_nodes || []).forEach(n => {
    if (n.type !== 'table' && n.type !== 'column') return;
    let title = `New ${n.type}: ${n.name}`;
    let body = '';
    const props = n.properties || {};
    if (n.type === 'table') {
      const colCount = tableColumnsList(props).length;
      const rowCount = props.row_count;
      const parts = [];
      if (colCount > 0) parts.push(`${colCount} column${colCount === 1 ? '' : 's'}`);
      if (rowCount != null) parts.push(`${Number(rowCount).toLocaleString()} rows`);
      if (props.db_path) parts.push(props.db_path);
      body = parts.join(' · ') + (props.description ? `\n${props.description}` : '');
    } else {  // column
      const tbl = props.table || '';
      title = `New column: ${tbl ? tbl + '.' : ''}${n.name}`;
      body = `Type ${props.data_type || 'unknown'}` + (props.description ? ` — ${props.description}` : '');
    }
    events.push({
      id: `entity:${n.id}`,
      type: 'entity',
      at: n.created_at,
      title, body,
      conf: 1.0,
      evidence: 1,
      status: 'auto',
      origin: n.origin,
      undo_state: n.state || 'active',
      // For edit: only `table` factual nodes have a backend-supported edit
      // surface (column descriptions). column / chunk / document have none.
      edit_kind: n.type === 'table' ? 'table' : null,
      edit_target_id: n.type === 'table' ? n.id : null,
      undo_payload: { target_type: 'node', target_id: n.id },
      // Hard delete (POST /skill/graph/delete) — cascades to owned children
      // and every incident edge, and cannot be recovered.
      delete_payload: { target_type: 'node', target_id: n.id },
      delete_impact: deleteImpact.get(n.id) || null,
      // A table's owned children are its columns; a column owns nothing.
      delete_child_label: n.type === 'table' ? 'column' : 'child node',
      // Tables / columns mirror a physical source: the delete removes graph
      // rows only, and a re-scan of the source re-creates them.
      delete_note: 'Graph rows only — re-scanning the source re-creates them.',
    });
  });

  (snapshot.concept_nodes || []).forEach(n => {
    if (n.status === 'merged') return;  // tombstone — surfaced via dedupe instead
    const isSchema = n.origin === 'schema_inference';
    const synonyms = n.synonyms || [];
    const desc = (n.properties || {}).description || '';
    const title = isSchema ? `Inferred concept: ${n.name}` : `New concept: ${n.name}`;
    // Synonym shape was string[] in legacy mock, SynonymEntry[] in protocol —
    // accept both so the same code path renders mock and real data. Hide
    // undone synonyms from the summary line; full editor still sees them.
    const synonymNames = synonyms
      .filter(s => !(s && typeof s === 'object' && s.state === 'undo'))
      .map(s => (typeof s === 'string' ? s : (s && s.name)))
      .filter(Boolean);
    const body = [
      desc,
      synonymNames.length > 0 ? `Also known as ${synonymNames.join(', ')}` : '',
    ].filter(Boolean).join(' · ');
    events.push({
      id: `${isSchema ? 'schema' : 'entity'}:${n.id}`,
      type: isSchema ? 'schema' : 'entity',
      at: n.created_at,
      title,
      body: body || 'Concept registered.',
      conf: 1.0,
      evidence: 1 + synonymNames.length,
      status: 'auto',
      origin: n.origin,
      undo_state: n.state || 'active',
      edit_kind: 'concept',
      edit_target_id: n.id,
      undo_payload: { target_type: 'node', target_id: n.id },
      // Concept nodes have no physical mirror — a hard delete also drops the
      // concept-search entry. The agent may still learn a similar concept
      // again from future conversations, so no "stays gone forever" promise.
      delete_payload: { target_type: 'node', target_id: n.id },
      delete_impact: deleteImpact.get(n.id) || null,
    });
  });

  // ── 2. Relation creation events + 3. Rerank events ───────────────────
  // Skip structure_edges — they're infrastructural (table↔column / doc↔chunk
  // come implicitly with the entity event; FK edges have no learning signal
  // beyond the tables themselves).
  const edgeBuckets = [
    { arr: snapshot.mapping_edges,    type: 'mapping' },
    { arr: snapshot.relation_edges,   type: 'relation' },
    { arr: snapshot.dependency_edges, type: 'dependency' },
    { arr: snapshot.category_edges,   type: 'category' },
  ];

  edgeBuckets.forEach(({ arr, type }) => {
    (arr || []).forEach(e => {
      const sName = nameById.get(e.source) || e.source;
      const tName = nameById.get(e.target) || e.target;
      const edgeKey = type === 'relation'
        ? `${type}:${e.source}->${e.target}:${e.relation || ''}`
        : type === 'category'
          ? `${type}:${e.source}->${e.target}:${e.value || ''}`
          : `${type}:${e.source}->${e.target}`;

      const history = e.confidence_history || [];
      const initial = history.find(h => h.reason === 'initial');
      const createdAt = (initial && initial.at) || e.created_at;
      const conf = e.confidence != null ? e.confidence : (initial ? initial.value : null);
      const edgeSel = edgeSelectorFromDTO({ type, ...e });

      let title, body;
      if (type === 'mapping') {
        title = `Mapped: ${sName} → ${tName}`;
        body = `Concept linked to data` +
          (conf != null ? ` · confidence ${conf.toFixed(2)}` : '') +
          (e.evidence_ref ? ` · ${e.evidence_ref}` : '');
      } else if (type === 'relation') {
        const rel = e.relation || 'related_to';
        title = `Relation: ${sName} ${rel.replace(/_/g, ' ')} ${tName}`;
        body = (conf != null ? `Confidence ${conf.toFixed(2)}` : 'New relation registered.');
      } else if (type === 'dependency') {
        title = `Dependency: ${sName} depends on ${tName}`;
        body = (conf != null ? `Confidence ${conf.toFixed(2)}` : 'New dependency registered.');
      } else {  // category
        const valLabel = e.value ? `"${e.value}"` : sName;
        title = `Category value: ${valLabel} → ${tName}`;
        const parts = [];
        if (conf != null) parts.push(`confidence ${conf.toFixed(2)}`);
        if (e.data_type) parts.push(e.data_type);
        body = parts.join(' · ') || 'Category value registered.';
      }

      events.push({
        id: `relation:${edgeKey}`,
        type: 'relation',
        at: createdAt,
        title, body,
        conf: conf != null ? conf : 1.0,
        evidence: history.length || 1,
        status: _learningConfToStatus(conf),
        origin: e.origin,
        evidence_ref: e.evidence_ref,
        undo_state: e.state || 'active',
        // Edit confidence is supported on every non-structure edge.
        edit_kind: 'edge_confidence',
        edit_edge: edgeSel,
        edit_current_confidence: conf,
        undo_payload: { target_type: 'edge', edge: edgeSel },
        // Hard delete removes exactly this edge (with its confidence history).
        delete_payload: { target_type: 'edge', edge: edgeSel },
      });

      // Rerank: each non-initial confidence_history entry. `index` MUST be
      // the original full-length list position (contract); hIdx is exactly
      // that here because we don't filter the array before iterating.
      history.forEach((h, hIdx) => {
        if (h.reason === 'initial') return;
        const reasonHuman = humanizeLearningReason(h.reason);
        const v = h.value != null ? h.value : 0;
        events.push({
          id: `rerank:${edgeKey}:${hIdx}`,
          type: 'rerank',
          at: h.at,
          title: `Re-ranked: ${sName} → ${tName}`,
          body: `Confidence updated to ${v.toFixed(2)} · ${reasonHuman}`,
          conf: v,
          evidence: 1,
          status: _learningConfToStatus(v),
          origin: h.origin,
          evidence_ref: h.evidence_ref,
          undo_state: h.state || 'active',
          edit_kind: null,
          undo_payload: { target_type: 'rerank', edge: edgeSel, index: hIdx },
          // A confidence-history entry is not a deletable row — /skill/graph/
          // delete only accepts node / edge targets. Undo is the only reversal.
          delete_payload: null,
        });
      });
    });
  });

  // ── 4. Dedupe events ─────────────────────────────────────────────────
  // `index` MUST be the original full-length list position (contract); idx
  // is exactly that because we don't filter dedup_events before iterating.
  (snapshot.concept_nodes || []).forEach(n => {
    (n.dedup_events || []).forEach((d, idx) => {
      const reasonHuman = humanizeLearningReason(d.reason);
      const added = d.added_synonyms || [];
      // Manual synonym edits share the DedupEvent shape with system-detected
      // dedups (so they reuse the `target_type:"dedup"` undo path), but the
      // "Folded duplicate" wording reads wrong when the user just added a
      // synonym themselves. Render manual edits with their own copy.
      let title, body;
      if (d.reason === 'manual_synonym_edit') {
        const synLabel = added.length > 0 ? added.join(', ') : (d.attempted_name || 'synonym');
        title = `Synonym added to ${n.name}`;
        body  = `Added "${synLabel}" · Manual edit`;
      } else {
        const attempted = d.attempted_name || '(unnamed)';
        const bodyParts = [];
        if (added.length > 0) {
          bodyParts.push(`Added synonym${added.length === 1 ? '' : 's'}: ${added.join(', ')}`);
        } else {
          bodyParts.push('No new synonyms — exact match.');
        }
        bodyParts.push(reasonHuman);
        title = `Folded duplicate "${attempted}" into ${n.name}`;
        body  = bodyParts.join(' · ');
      }
      events.push({
        id: `dedupe:event:${n.id}:${idx}`,
        type: 'dedupe',
        at: d.at,
        title, body,
        conf: 1.0,
        evidence: 1 + added.length,
        status: 'auto',
        origin: d.origin,
        evidence_ref: d.evidence_ref,
        undo_state: d.state || 'active',
        edit_kind: null,
        undo_payload: { target_type: 'dedup', target_id: n.id, index: idx },
        // Same as rerank — a dedup event is history on the concept, not a
        // row /skill/graph/delete can target.
        delete_payload: null,
      });
    });

    // merged_from has no per-entry undo in the protocol — surface as a
    // read-only history note (no undo button, no edit).
    (n.merged_from || []).forEach(m => {
      events.push({
        id: `dedupe:merge:${n.id}:${m.id}`,
        type: 'dedupe',
        at: m.at,
        title: `Merged concept ${m.id} → ${n.name}`,
        body: `${m.id} tombstoned; ${n.name} is now canonical.`,
        conf: 1.0,
        evidence: 1,
        status: 'auto',
        origin: n.origin,
        undo_state: 'active',
        edit_kind: null,
        undo_payload: null,
        delete_payload: null,
      });
    });
  });

  // Sort newest first; missing/invalid timestamps sink to the bottom.
  events.sort((a, b) => {
    const aT = a.at ? new Date(a.at).getTime() : 0;
    const bT = b.at ? new Date(b.at).getTime() : 0;
    if (isNaN(aT) && isNaN(bT)) return 0;
    if (isNaN(aT)) return 1;
    if (isNaN(bT)) return -1;
    return bT - aT;
  });

  return events;
}


Object.assign(window, { deriveLearningEvents, formatRelativeTime, humanizeLearningReason, parcleParseGraphTime });
