// Real aggregates derived from a `/skill/graph` snapshot — the numbers Home
// and Fabric put on screen. Loads after graph_layout.jsx / graph_learning.jsx
// (it uses allGraphNodes / allGraphEdges / deriveLearningEvents) and before
// fabric.jsx / pages.jsx.
//
// Everything here is computed from data the backend actually returned. There is
// no demo/mock branch: a figure the snapshot cannot support is not rendered at
// all rather than filled in with a plausible-looking number.
//
// Timestamps (`created_at`, `dedup_events[].at`, …) arrive as the backend's
// zone-less `YYYY-MM-DDTHH:MM`, which is UTC wall-clock — they go through
// parcleParseGraphTime() (graph_learning.jsx) rather than `new Date()`, which
// would read them as browser-local and skew every age by the viewer's offset.
//
// Windows are rolling ("last 24h", "last N days"), never calendar days: the
// viewer's midnight, the server's, and the retrieval API's UTC days are three
// different instants, and a rolling window means the same thing in all of them.

const FAB_DAY_MS = 86400000;
const FAB_SPARK_DAYS = 14;

// Undone (soft-deleted) rows still travel in the snapshot so the Learning page
// can offer "Recover". They are not part of what the org currently knows, so
// every count here skips them.
function fabIsActive(x) { return !x || x.state !== 'undo'; }

function fabTime(iso) {
  return parcleParseGraphTime(iso);
}

// Active factual + concept nodes. Merge tombstones (`status: 'merged'`) are
// excluded: the concept still exists in the graph so the merge stays
// discoverable, but counting it would double-count knowledge that has already
// been folded into its survivor.
function fabActiveNodes(snapshot) {
  const factual = (snapshot.factual_nodes || []).filter(fabIsActive);
  const concepts = (snapshot.concept_nodes || []).filter(n => fabIsActive(n) && n.status !== 'merged');
  return { factual, concepts };
}

// Structure edges (table↔column, document↔chunk) are the graph's own plumbing.
// Counting them as "relationships" would drown the number the user cares about
// — the same rule connectors.jsx and the Learning stream already follow.
const FAB_SEMANTIC_EDGE_KEYS = ['mapping_edges', 'dependency_edges', 'relation_edges', 'category_edges'];

function fabSemanticEdges(snapshot) {
  return FAB_SEMANTIC_EDGE_KEYS.reduce(
    (acc, key) => acc.concat((snapshot[key] || []).filter(fabIsActive)),
    []
  );
}

// Rolling day buckets ending at "now", oldest first, always `days` long.
// A timestamp in the future (server clock ahead of the browser's) lands in the
// newest bucket rather than being dropped.
function fabDayBuckets(times, days) {
  const now = Date.now();
  const out = new Array(days).fill(0);
  times.forEach(t => {
    if (!isFinite(t)) return;
    const age = now - t;
    if (age < 0) { out[days - 1] += 1; return; }
    const idx = days - 1 - Math.floor(age / FAB_DAY_MS);
    if (idx >= 0) out[idx] += 1;
  });
  return out;
}

function fabCountSince(times, windowMs) {
  const cutoff = Date.now() - windowMs;
  return times.filter(t => isFinite(t) && t >= cutoff).length;
}

// One metric: the total, when each item appeared, and the derived 24h delta +
// sparkline. `spark` is null when nothing in the window carries a usable
// timestamp — the card then draws no trend line instead of a flat fake one.
function fabMetric(times) {
  const usable = times.filter(t => isFinite(t));
  const buckets = fabDayBuckets(usable, FAB_SPARK_DAYS);
  const inSpark = buckets.reduce((a, b) => a + b, 0);
  return {
    total: times.length,
    last24h: fabCountSince(usable, FAB_DAY_MS),
    spark: inSpark > 0 ? buckets : null,
  };
}

// Cross-source merges: a `dedup_events` entry (a would-be duplicate folded into
// this concept) or a `merged_from` entry (an existing concept tombstoned into
// it). Both are counted on the surviving node, including tombstoned ones —
// their history is still part of what the graph merged.
function fabMergeTimes(snapshot) {
  const times = [];
  (snapshot.concept_nodes || []).forEach(n => {
    (n.dedup_events || []).forEach(d => { if (fabIsActive(d)) times.push(fabTime(d.at)); });
    (n.merged_from || []).forEach(m => times.push(fabTime(m.at)));
  });
  return times;
}

// The four Fabric overview metrics + the composition counts the hero shows.
// `learningEvents` is passed in (not re-derived) so a caller that already has
// the stream — every one of them does — pays for it once.
function deriveFabricStats(snapshot, learningEvents) {
  if (!snapshot) return null;
  const { factual, concepts } = fabActiveNodes(snapshot);
  const edges = fabSemanticEdges(snapshot);
  const events = (learningEvents || []).filter(e => e.undo_state !== 'undo');

  const nodeTimes = factual.map(n => fabTime(n.created_at))
    .concat(concepts.map(n => fabTime(n.created_at)));
  const ofType = t => factual.filter(n => n.type === t).length;

  const allTimes = nodeTimes
    .concat(edges.map(e => fabTime(e.created_at)))
    .filter(t => isFinite(t));

  return {
    entities:  fabMetric(nodeTimes),
    relations: fabMetric(edges.map(e => fabTime(e.created_at))),
    merges:    fabMetric(fabMergeTimes(snapshot)),
    learnings: fabMetric(events.map(e => fabTime(e.at))),
    composition: {
      tables:    ofType('table'),
      columns:   ofType('column'),
      documents: ofType('document'),
      chunks:    ofType('chunk'),
      concepts:  concepts.length,
      edges:     edges.length,
    },
    // Newest node/edge in the graph — "last updated", not a heartbeat.
    lastUpdatedAt: allTimes.length ? Math.max(...allTimes) : null,
    isEmpty: factual.length === 0 && concepts.length === 0,
  };
}

// Where the knowledge came from. `origin` is the producer that created the node
// (parcle/core/knowledge/ontology/models.py: Origin), which is the only
// source attribution the graph protocol carries — there are no vendor logos to
// hang off it, so these render as labelled counts.
const FAB_ORIGIN_LABELS = {
  schema_scan:           'Database scan',
  schema_inference:      'Schema inference',
  document_extraction:   'Document extraction',
  conversation_learning: 'Conversation learning',
  manual:                'Manual edits',
};

function fabOriginLabel(origin) {
  return FAB_ORIGIN_LABELS[origin] || origin || 'Unknown';
}

// Node counts per origin, biggest first. Unknown origins are kept under their
// raw key rather than lumped into "other" — a new backend producer should show
// up here by name instead of silently disappearing.
function fabOriginBreakdown(snapshot) {
  if (!snapshot) return [];
  const { factual, concepts } = fabActiveNodes(snapshot);
  const counts = new Map();
  factual.concat(concepts).forEach(n => {
    const key = n.origin || 'unknown';
    counts.set(key, (counts.get(key) || 0) + 1);
  });
  const total = factual.length + concepts.length;
  return [...counts.entries()]
    .map(([origin, count]) => ({
      origin,
      label: fabOriginLabel(origin),
      count,
      share: total ? count / total : 0,
    }))
    .sort((a, b) => b.count - a.count);
}

// The most connected nodes — the graph's actual hubs, by semantic degree.
// Structure edges are excluded for the same reason they don't count as
// relationships, otherwise every table would outrank every concept purely by
// owning columns.
function fabTopNodes(snapshot, limit) {
  if (!snapshot) return [];
  const { factual, concepts } = fabActiveNodes(snapshot);
  const nodes = factual.map(n => ({ ...n, category: 'factual' }))
    .concat(concepts.map(n => ({ ...n, category: 'concept' })));
  const degree = new Map();
  fabSemanticEdges(snapshot).forEach(e => {
    degree.set(e.source, (degree.get(e.source) || 0) + 1);
    degree.set(e.target, (degree.get(e.target) || 0) + 1);
  });
  return nodes
    .map(n => ({
      id: n.id,
      name: n.name || n.id,
      type: n.type || n.category,
      category: n.category,
      origin: n.origin,
      degree: degree.get(n.id) || 0,
    }))
    .filter(n => n.degree > 0)
    .sort((a, b) => b.degree - a.degree || a.name.localeCompare(b.name))
    .slice(0, limit);
}

// Learning events in the last 24h, split into the buckets Home's digest names.
// `rerank` and `schema` events are counted in the total but have no headline of
// their own — the digest line stays readable at three items.
function fabRecentLearningDigest(events) {
  const cutoff = Date.now() - FAB_DAY_MS;
  const recent = (events || []).filter(e =>
    e.undo_state !== 'undo' && isFinite(fabTime(e.at)) && fabTime(e.at) >= cutoff);
  return {
    total: recent.length,
    entities:  recent.filter(e => e.type === 'entity').length,
    relations: recent.filter(e => e.type === 'relation').length,
    merges:    recent.filter(e => e.type === 'dedupe').length,
  };
}

// The signed-in organization's own display name — the console is org-only, so
// the session account IS the org. A session that carries neither field gets the
// generic word, NOT the deploy's brand name: the brand comes from config.js, and
// printing it here would put a config string where the page promises the org's
// own identity.
function fabOrgName() {
  const acct = (typeof parcleAccount === 'function' && parcleAccount()) || null;
  return (acct && (acct.display_name || acct.email)) || 'Organization';
}

Object.assign(window, {
  FAB_DAY_MS, FAB_SPARK_DAYS,
  fabIsActive, fabTime, fabActiveNodes, fabSemanticEdges,
  fabDayBuckets, fabCountSince, fabMetric,
  deriveFabricStats, fabOriginLabel, fabOriginBreakdown, fabTopNodes,
  fabRecentLearningDigest, fabOrgName,
});
