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

function formatRelativeTime(iso) {
  if (!iso) return '';
  const t = new Date(iso).getTime();
  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(iso).toLocaleDateString();
}

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

  // ── 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 = (props.columns || []).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 },
    });
  });

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

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

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

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

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

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