/**
 * SET 19 — pure stats aggregation + metrics grid builders.
 */
import { describe, expect, it } from 'vitest';
import {
  aggregateInstallStats,
  aggregateProjectStats,
  buildMetricsGrid,
  classifyRunOutcome,
  emptyStatsTotals,
  formatCell,
  metricsGridToCsv,
  metricsGridToJson,
  substrateHitRate,
  type RunRecordLike,
} from '../src/tier1/statsAggregate';

function rec(partial: RunRecordLike): RunRecordLike {
  return {
    id: partial.id ?? 'r1',
    ts: partial.ts ?? '2026-07-26T12:00:00.000Z',
    mode: partial.mode ?? 'rha',
    problems: partial.problems ?? 0,
    error: partial.error ?? null,
    metrics: partial.metrics ?? {},
    cost: partial.cost,
    ...partial,
  };
}

describe('classifyRunOutcome', () => {
  it('classifies clean / partial / failed / cancelled', () => {
    expect(classifyRunOutcome(rec({ problems: 0 }))).toBe('successful');
    expect(classifyRunOutcome(rec({ problems: 2 }))).toBe('partial');
    expect(classifyRunOutcome(rec({ error: 'boom' }))).toBe('failed');
    expect(classifyRunOutcome(rec({ error: 'cancelled by user' }))).toBe('cancelled');
  });
});

describe('aggregateInstallStats', () => {
  it('aggregates tokens, cycles, substrate, and cost facets', () => {
    const records: RunRecordLike[] = [
      rec({
        id: 'a',
        ts: '2026-07-26T10:00:00.000Z',
        mode: 'rha',
        problems: 0,
        metrics: {
          tokensIn: 100, tokensOut: 50, llmCalls: 2,
          avgIn: 50, avgOut: 25, maxIn: 60, maxOut: 30,
          passesTotal: 3, irGenAttempts: 1,
          mechanicalFixes: 2, repairDraws: 1,
          substrateAttempts: 3, substrateFixes: 2, substrateRefusals: 0, substrateNoHandler: 1,
          templatedCases: 4, superbufferDraws: 1, superbufferWins: 1,
          permBatches: 2, permCandidates: 6, permImproved: 1,
          irSurgeries: 1, elapsedMs: 60_000,
        },
        cost: { usdTotal: 0.01 },
      }),
      rec({
        id: 'b',
        ts: '2026-07-26T12:00:00.000Z',
        mode: 'vanilla',
        problems: 1,
        metrics: {
          tokensIn: 200, tokensOut: 100, llmCalls: 3,
          avgIn: 70, avgOut: 40, maxIn: 90, maxOut: 50,
          passesTotal: 5, irGenAttempts: 2,
          mechanicalFixes: 0, repairDraws: 2,
          substrateAttempts: 2, substrateFixes: 0, substrateRefusals: 1, substrateNoHandler: 1,
          templatedCases: 0, permBatches: 1, permCandidates: 3, permImproved: 0,
          irSurgeries: 0, elapsedMs: 120_000,
        },
        cost: { usdTotal: 0.03 },
      }),
    ];

    const summary = aggregateInstallStats(records);
    expect(summary.runs.total).toBe(2);
    expect(summary.runs.successful).toBe(1);
    expect(summary.runs.partial).toBe(1);
    expect(summary.byMode.rha).toBe(1);
    expect(summary.byMode.vanilla).toBe(1);
    expect(summary.latestId).toBe('b');
    expect(summary.facets.tokensTotal.lifetime).toBe(450);
    expect(summary.facets.tokensTotal.avg).toBe(225);
    expect(summary.facets.tokensTotal.min).toBe(150);
    expect(summary.facets.tokensTotal.max).toBe(300);
    expect(summary.facets.tokensTotal.latest).toBe(300);
    expect(summary.facets.cycles.lifetime).toBe(8);
    expect(summary.facets.cycles.max).toBe(5);
    expect(summary.facets.substrateFixes.lifetime).toBe(2);
    expect(summary.facets.substrateNoHandler.lifetime).toBe(2);
    // SET 43f — hit-rate = lifetime fixes/attempts = 2/5
    expect(summary.facets.substrateHitRate.lifetime).toBeCloseTo(0.4);
    expect(summary.facets.substrateHitRate.latest).toBeCloseTo(0); // run b: 0/2
    expect(substrateHitRate(2, 5)).toBeCloseTo(0.4);
    expect(summary.facets.costUsd.lifetime).toBeCloseTo(0.04);
    expect(summary.facets.costUsd.avg).toBeCloseTo(0.02);
    expect(summary.latestVsAvgPct).not.toBeNull();
    // latest 0.03 vs avg 0.02 → +50%
    expect(summary.latestVsAvgPct).toBeCloseTo(50);
  });

  it('prefers lifetime totals when they exceed the ring', () => {
    const records = [
      rec({
        id: 'only',
        metrics: { tokensIn: 10, tokensOut: 5, llmCalls: 1, passesTotal: 1 },
      }),
    ];
    const life = emptyStatsTotals();
    life.runs.total = 5;
    life.runs.successful = 5;
    life.tokens.inSum = 1000;
    life.tokens.outSum = 500;
    life.tokens.totalSum = 1500;
    life.tokens.runsWithTokens = 5;
    life.tokens.inMax = 400;
    life.tokens.outMax = 200;
    life.tokens.totalMax = 600;
    life.tokens.inMin = 10;
    life.tokens.outMin = 5;
    life.tokens.totalMin = 15;

    const summary = aggregateInstallStats(records, life);
    expect(summary.totalsRuns).toBe(5);
    expect(summary.facets.tokensTotal.lifetime).toBe(1500);
    expect(summary.facets.tokensTotal.latest).toBe(15);
    expect(summary.ringSize).toBe(1);
  });

  // SET 30b — "reset your-install stats" stamps a statsBaseline ISO ts; records
  // (and any residual lifetimeTotals) older than it must not contribute.
  describe('statsBaseline (SET 30b reset)', () => {
    const oldRuns: RunRecordLike[] = [
      rec({ id: 'old-1', ts: '2026-01-01T00:00:00.000Z', metrics: { tokensIn: 500, tokensOut: 500, llmCalls: 9, passesTotal: 9 } }),
      rec({ id: 'old-2', ts: '2026-02-01T00:00:00.000Z', metrics: { tokensIn: 500, tokensOut: 500, llmCalls: 9, passesTotal: 9 } }),
    ];
    const baseline = '2026-07-26T00:00:00.000Z';

    it('aggregates to zero right after reset — old records are excluded, no lifetime carry-over', () => {
      const summary = aggregateInstallStats(oldRuns, emptyStatsTotals(), { statsBaseline: baseline });
      expect(summary.totalsRuns).toBe(0);
      expect(summary.facets.tokensTotal.lifetime).toBe(0);
      expect(summary.latestId).toBeNull();
      expect(summary.ringSize).toBe(0);
    });

    it('the next run after reset counts itself', () => {
      const newRun = rec({ id: 'new-1', ts: '2026-07-26T01:00:00.000Z', metrics: { tokensIn: 10, tokensOut: 20, llmCalls: 1, passesTotal: 1 } });
      const summary = aggregateInstallStats([...oldRuns, newRun], emptyStatsTotals(), { statsBaseline: baseline });
      expect(summary.totalsRuns).toBe(1);
      expect(summary.latestId).toBe('new-1');
      expect(summary.facets.tokensTotal.lifetime).toBe(30);
      expect(summary.ringSize).toBe(1);
    });

    it('old runs are merely excluded from aggregation, not deleted from the input — they stay openable', () => {
      // aggregateInstallStats never mutates or drops entries from the caller's
      // record array — "run history untouched" is a property of the CALLER
      // (background.js never removes tier1Run:* keys on reset), which this
      // asserts indirectly: the same old records are still fully intact and
      // individually inspectable after being passed through.
      const before = JSON.stringify(oldRuns);
      aggregateInstallStats(oldRuns, emptyStatsTotals(), { statsBaseline: baseline });
      expect(JSON.stringify(oldRuns)).toBe(before);
      expect(oldRuns.map(r => r.id)).toEqual(['old-1', 'old-2']);
    });

    it('without a baseline, old records aggregate normally (no regression)', () => {
      const summary = aggregateInstallStats(oldRuns, emptyStatsTotals());
      expect(summary.totalsRuns).toBe(2);
      expect(summary.facets.tokensTotal.lifetime).toBe(2000);
    });
  });
});

// SET 33b.2 — Tier1View's "this project" scope: aggregate only records that
// belong to one project (root run founds it; continuations share its id).
describe('aggregateProjectStats', () => {
  const projA: RunRecordLike[] = [
    rec({
      id: 'a-root', ts: '2026-07-26T10:00:00.000Z', projectId: 'a-root',
      metrics: { tokensIn: 100, tokensOut: 50, llmCalls: 1, passesTotal: 2 },
    }),
    rec({
      id: 'a-cont', ts: '2026-07-26T11:00:00.000Z', projectId: 'a-root',
      metrics: { tokensIn: 40, tokensOut: 20, llmCalls: 1, passesTotal: 1 },
    }),
  ];
  const projB: RunRecordLike[] = [
    rec({
      id: 'b-root', ts: '2026-07-26T09:00:00.000Z', projectId: 'b-root',
      metrics: { tokensIn: 900, tokensOut: 900, llmCalls: 5, passesTotal: 9 },
    }),
  ];
  const all = [...projA, ...projB];

  it('only sums records belonging to the given project', () => {
    const summary = aggregateProjectStats(all, 'a-root');
    expect(summary.totalsRuns).toBe(2);
    expect(summary.facets.tokensTotal.lifetime).toBe(210); // (100+50)+(40+20)
    expect(summary.latestId).toBe('a-cont');
  });

  it('a project with a single (root) run aggregates to just that run', () => {
    const summary = aggregateProjectStats(all, 'b-root');
    expect(summary.totalsRuns).toBe(1);
    expect(summary.facets.tokensTotal.lifetime).toBe(1800);
    expect(summary.latestId).toBe('b-root');
  });

  it('an unknown projectId aggregates to zero, not a crash', () => {
    const summary = aggregateProjectStats(all, 'no-such-project');
    expect(summary.totalsRuns).toBe(0);
    expect(summary.latestId).toBeNull();
  });

  it('a root run with no explicit projectId is scoped by its own id', () => {
    const rootOnly = [rec({ id: 'solo', ts: '2026-07-26T08:00:00.000Z', projectId: null, metrics: { tokensIn: 5, tokensOut: 5 } })];
    const summary = aggregateProjectStats(rootOnly, 'solo');
    expect(summary.totalsRuns).toBe(1);
    expect(summary.facets.tokensTotal.lifetime).toBe(10);
  });
});

describe('buildMetricsGrid + serializers', () => {
  it('builds hierarchical rows with Latest/Avg/Min/Max/Lifetime columns', () => {
    const summary = aggregateInstallStats([
      rec({
        metrics: {
          tokensIn: 100, tokensOut: 50, llmCalls: 1,
          passesTotal: 2, elapsedMs: 5000,
        },
        cost: { usdTotal: 0.005 },
      }),
    ]);
    const grid = buildMetricsGrid(summary);
    expect(grid.some(r => r.kind === 'header' && r.category === 'Tokens')).toBe(true);
    expect(grid.some(r => r.kind === 'header' && r.category === 'Substrate')).toBe(true);
    const tokenTotal = grid.find(r => r.kind === 'row' && r.key === 'tokensTotal');
    expect(tokenTotal?.kind).toBe('row');
    if (tokenTotal?.kind === 'row') {
      expect(formatCell(tokenTotal.cells.lifetime, tokenTotal.format)).toBe('150');
      expect(formatCell(tokenTotal.cells.latest, tokenTotal.format)).toBe('150');
    }
    const dur = grid.find(r => r.kind === 'row' && r.key === 'durationMs');
    if (dur?.kind === 'row') {
      expect(formatCell(dur.cells.latest, dur.format)).toBe('0:00:05');
    }

    const json = metricsGridToJson({ ...summary, installHash: 'abc123' }, grid) as {
      installHash: string;
      rows: { key: string }[];
    };
    expect(json.installHash).toBe('abc123');
    expect(json.rows.length).toBeGreaterThan(10);

    const csv = metricsGridToCsv({ ...summary, installHash: 'abc123' }, grid);
    expect(csv).toContain('category,facet,latest,avg,min,max,lifetime');
    expect(csv).toContain('Tokens');
    expect(csv).toContain('# installHash=abc123');
  });

  it('renders — for inapplicable cells', () => {
    expect(formatCell(null, 'int')).toBe('—');
    expect(formatCell(Infinity, 'ratio')).toBe('∞');
  });
});
