// Trace summary — operator triage digest over the NDJSON span trace.
//
// The NDJSON span sink (see `ndjson-span-sink.mjs`) writes one JSON line
// per span to ~/.olam/logs/host.trace.ndjson. Operators triage it today
// with hand-typed `jq` one-liners (README § Observability): "longest 5
// spans", "all failed spans", "failure-kind tally". This module codifies
// those recipes into ONE digest so the common questions get one answer
// without remembering jq incantations.
//
// Design:
//   - `summarizeSpans(spans, opts)` is PURE — no I/O. Given an array of
//     parsed span records (the exact shape the sink writes) it returns a
//     digest object. This is the unit-testable core.
//   - `parseTrace(ndjsonText)` turns raw file bytes into { spans, skipped }.
//     Malformed lines (truncated tail line, partial write mid-rotation)
//     are COUNTED, never thrown — triage tooling must survive a corrupt
//     line, not die on it.
//   - `summarizeTraceFile(path, opts)` is the thin file-reading wrapper.
//   - `formatDigest(digest)` renders a human-readable report for the CLI.
//
// Read-only + additive: this module never writes the trace, never changes
// the line schema. It only READS fields the sink already emits
// (durationMs, exit._tag, exit.reason, name, attributes.failureKind).

import { readFile } from 'node:fs/promises';

const DEFAULT_TOP_N = 5;

/**
 * Parse NDJSON trace text into spans, tolerating malformed lines.
 *
 * @param {string} text raw file contents
 * @returns {{ spans: object[], skipped: number }}
 */
export function parseTrace(text) {
  const spans = [];
  let skipped = 0;
  for (const line of String(text).split('\n')) {
    const trimmed = line.trim();
    if (trimmed === '') continue;
    try {
      spans.push(JSON.parse(trimmed));
    } catch {
      // Truncated tail line or a partial write straddling rotation — the
      // append-only log can leave one half-line. Triage must not crash on
      // it; count and move on.
      skipped += 1;
    }
  }
  return { spans, skipped };
}

function isFailure(span) {
  return span?.exit?._tag === 'Failure';
}

/**
 * Compute a triage digest over parsed spans. Pure.
 *
 * @param {object[]} spans
 * @param {{ topN?: number }} [opts]
 * @returns {{
 *   totalSpans: number,
 *   failures: number,
 *   successes: number,
 *   failureRate: number,
 *   slowest: object[],
 *   recentFailures: object[],
 *   failureReasons: { reason: string, count: number }[],
 *   failureKinds: { kind: string, count: number }[],
 *   byName: { name: string, count: number, failures: number, meanMs: number|null, maxMs: number|null }[],
 * }}
 */
export function summarizeSpans(spans, { topN = DEFAULT_TOP_N } = {}) {
  const list = Array.isArray(spans) ? spans : [];
  const totalSpans = list.length;
  const failingSpans = list.filter(isFailure);
  const failures = failingSpans.length;
  const successes = totalSpans - failures;
  const failureRate = totalSpans === 0 ? 0 : failures / totalSpans;

  // Slowest spans by durationMs. Spans with a null duration (in-flight or
  // missing endedAt) are excluded — they carry no comparable cost signal.
  const timed = list.filter((s) => typeof s?.durationMs === 'number');
  const slowest = [...timed]
    .sort((a, b) => b.durationMs - a.durationMs)
    .slice(0, topN)
    .map(projectSpan);

  // Recent failures — the trace is append-only, so the last failures in
  // file order are the most recent. Take the tail.
  const recentFailures = failingSpans.slice(-topN).reverse().map(projectSpan);

  const failureReasons = tally(
    failingSpans,
    (s) => (s?.exit?.reason != null ? String(s.exit.reason) : '(no reason)'),
    'reason',
  );

  // failureKind is the world.lifecycle attribute the README already greps
  // for; surface it as a first-class tally regardless of span name so
  // recovery-relevant failures aggregate even when span names differ.
  const failureKinds = tally(
    list.filter((s) => s?.attributes?.failureKind != null),
    (s) => String(s.attributes.failureKind),
    'kind',
  );

  const byName = aggregateByName(list);

  return {
    totalSpans,
    failures,
    successes,
    failureRate,
    slowest,
    recentFailures,
    failureReasons,
    failureKinds,
    byName,
  };
}

function projectSpan(s) {
  return {
    name: s?.name ?? null,
    traceId: s?.traceId ?? null,
    spanId: s?.spanId ?? null,
    durationMs: typeof s?.durationMs === 'number' ? s.durationMs : null,
    startedAt: typeof s?.startedAt === 'number' ? s.startedAt : null,
    reason: s?.exit?.reason != null ? String(s.exit.reason) : null,
  };
}

// Group spans by a string key and count occurrences, labelling the key
// field per the caller (`reason` for failure reasons, `kind` for failure
// kinds). Sorted by count descending so the dominant cause leads.
function tally(spans, keyFn, label) {
  const counts = new Map();
  for (const s of spans) {
    const key = keyFn(s);
    counts.set(key, (counts.get(key) ?? 0) + 1);
  }
  const out = [];
  for (const [k, count] of counts) out.push({ count, [label]: k });
  return out.sort((a, b) => b.count - a.count);
}

/**
 * Per-span-name aggregate: count, failure count, mean + max duration.
 * Sorted by count descending so the busiest spans surface first.
 */
function aggregateByName(spans) {
  const groups = new Map();
  for (const s of spans) {
    const name = s?.name != null ? String(s.name) : '(unnamed)';
    let g = groups.get(name);
    if (!g) {
      g = { name, count: 0, failures: 0, durSum: 0, durCount: 0, maxMs: null };
      groups.set(name, g);
    }
    g.count += 1;
    if (isFailure(s)) g.failures += 1;
    if (typeof s?.durationMs === 'number') {
      g.durSum += s.durationMs;
      g.durCount += 1;
      g.maxMs = g.maxMs === null ? s.durationMs : Math.max(g.maxMs, s.durationMs);
    }
  }
  return [...groups.values()]
    .map((g) => ({
      name: g.name,
      count: g.count,
      failures: g.failures,
      meanMs: g.durCount === 0 ? null : g.durSum / g.durCount,
      maxMs: g.maxMs,
    }))
    .sort((a, b) => b.count - a.count);
}

/**
 * Read + summarize a trace file. Missing file → empty digest (an operator
 * who hasn't generated any spans yet sees a clean zero-state, not a crash).
 *
 * @param {string} path
 * @param {{ topN?: number }} [opts]
 */
export async function summarizeTraceFile(path, opts = {}) {
  let text;
  try {
    text = await readFile(path, 'utf8');
  } catch (err) {
    if (err && err.code === 'ENOENT') {
      return { ...summarizeSpans([], opts), skipped: 0, missing: true };
    }
    throw err;
  }
  const { spans, skipped } = parseTrace(text);
  return { ...summarizeSpans(spans, opts), skipped, missing: false };
}

function fmtMs(ms) {
  if (ms == null) return '—';
  if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
  return `${Math.round(ms)}ms`;
}

/**
 * Render a digest as a human-readable, plain-text report for the CLI.
 *
 * @param {ReturnType<typeof summarizeSpans> & { skipped?: number, missing?: boolean, path?: string }} digest
 * @returns {string}
 */
export function formatDigest(digest) {
  const lines = [];
  const path = digest.path ? ` (${digest.path})` : '';
  lines.push(`Trace summary${path}`);
  if (digest.missing) {
    lines.push('  no trace file yet — nothing recorded.');
    return lines.join('\n');
  }
  const pct = (digest.failureRate * 100).toFixed(1);
  lines.push(
    `  ${digest.totalSpans} spans · ${digest.failures} failed (${pct}%) · ${digest.successes} ok` +
      (digest.skipped ? ` · ${digest.skipped} malformed line(s) skipped` : ''),
  );

  if (digest.slowest.length) {
    lines.push('');
    lines.push(`Top ${digest.slowest.length} slowest:`);
    for (const s of digest.slowest) {
      lines.push(`  ${fmtMs(s.durationMs).padStart(7)}  ${s.name ?? '(unnamed)'}${s.traceId ? `  [${s.traceId}]` : ''}`);
    }
  }

  if (digest.recentFailures.length) {
    lines.push('');
    lines.push(`Recent failures (${digest.recentFailures.length}):`);
    for (const f of digest.recentFailures) {
      lines.push(`  ${f.name ?? '(unnamed)'}: ${f.reason ?? '(no reason)'}${f.traceId ? `  [${f.traceId}]` : ''}`);
    }
  }

  if (digest.failureKinds.length) {
    lines.push('');
    lines.push('Failure kinds:');
    for (const k of digest.failureKinds) lines.push(`  ${String(k.count).padStart(4)}  ${k.kind}`);
  }

  if (digest.failureReasons.length) {
    lines.push('');
    lines.push('Failure reasons:');
    for (const r of digest.failureReasons) lines.push(`  ${String(r.count).padStart(4)}  ${r.reason}`);
  }

  if (digest.byName.length) {
    lines.push('');
    lines.push('By span name (count · failures · mean · max):');
    for (const n of digest.byName) {
      lines.push(
        `  ${String(n.count).padStart(5)} · ${String(n.failures).padStart(4)}f · ${fmtMs(n.meanMs).padStart(7)} · ${fmtMs(n.maxMs).padStart(7)}  ${n.name}`,
      );
    }
  }

  return lines.join('\n');
}
