/**
 * SET 31d — the corpus does not compound. Two defects fixed:
 *
 *  (i) Lessons: corpus + locally distilled lessons must MERGE (deduped) and
 *      actually reach the runner's superbuffer retrieval — not just round-trip
 *      through chrome.storage while the merge silently drops or duplicates.
 *  (ii) Cross-run recurrence: the SAME planned case (or case family, e.g. the
 *       *Model anchor — ArticleModel/UserModel/SessionModel) dying ≥3 runs in
 *       a row must surface as a clade-candidate proposal, not vanish silently.
 */
import { describe, expect, it } from 'vitest';
import type { FeatureSpec, LlmClient } from '../src/core/types';
import { mergeLessons } from '../src/tier1/substrate';
import type { Tier1Lesson } from '../src/tier1/substrate';
import {
  caseFamily,
  extractDroppedCaseNames,
  scanRecurringFailures,
} from '../src/tier1/clades';
import type { RunFailureSnapshot } from '../src/tier1/clades';
import {
  draftCladePromotion,
  formatCladeCandidateMessage,
} from '../src/tier1/superbuffer';
import { runTier1Job } from '../src/tier1/runner';

// ---------------------------------------------------------------------------
// (i) lessons — merge helper + round trip + runner wiring
// ---------------------------------------------------------------------------

describe('SET 31d lessons — merge + persist + retrieve round trip', () => {
  it('mergeLessons dedupes by classKey+idiom, corpus first, local appended', () => {
    const corpus: Tier1Lesson[] = [
      { classKey: 'runtime|nullread|x', idiom: 'null guard', resolution: 'early return', ts: 't1' },
    ];
    const local: Tier1Lesson[] = [
      // Same class+idiom as a corpus lesson — must NOT duplicate.
      { classKey: 'runtime|nullread|x', idiom: 'null guard', resolution: 'different wording', ts: 't2' },
      { classKey: 'assertion|toBe', idiom: 'toBe', resolution: 'check ops', ts: 't3' },
    ];
    const merged = mergeLessons(corpus, local);
    expect(merged).toHaveLength(2);
    expect(merged[0]).toEqual(corpus[0]); // corpus entry wins the dedup slot
    expect(merged[1]!.classKey).toBe('assertion|toBe');
  });

  it('mergeLessons caps the result and drops malformed entries', () => {
    const local = Array.from({ length: 5 }, (_, i) => ({
      classKey: `k${i}`, idiom: `idiom${i}`, resolution: 'r', ts: '',
    }));
    const merged = mergeLessons([], local, 3);
    expect(merged).toHaveLength(3);
    const withBad = mergeLessons([], [...local, { classKey: '', idiom: 'x', resolution: 'r', ts: '' } as Tier1Lesson]);
    expect(withBad.every(l => l.classKey && l.idiom)).toBe(true);
  });

  it('simulates the background.js lessons:add / lessons:all round trip', () => {
    // Mirrors the SET-17 storage contract in extension/background.js: dedup by
    // classKey+idiom, unshift newest-first, cap 200 — verified here as a pure
    // in-memory store so the contract is pinned without needing chrome.* mocks.
    const addLesson = (store: Tier1Lesson[], lesson: Tier1Lesson): Tier1Lesson[] => {
      const dup = store.some(x => x.classKey === lesson.classKey && x.idiom === lesson.idiom);
      return dup ? store : [lesson, ...store].slice(0, 200);
    };
    let store: Tier1Lesson[] = [];
    const lesson: Tier1Lesson = {
      classKey: 'runtime|weird|x', idiom: 'guard weirdness', resolution: 'validate input', ts: 'now',
    };
    store = addLesson(store, lesson);
    store = addLesson(store, lesson); // second onLesson fire for the same class — must not duplicate
    const all = store; // "lessons:all" reads back exactly what was added
    expect(all).toHaveLength(1);
    expect(all[0]).toEqual(lesson);
  });

  it('runner receives opts.lessons and threads them into the superbuffer for a freeform hole', async () => {
    const spec: FeatureSpec = {
      title: 'Widget', prompt: 'widget lib',
      features: [{
        id: 'F1', name: 'widget', description: 'widget thing', parentId: null,
        deps: [], files: ['src/widget.ts'], testFile: 'tests/f1.test.ts',
        edgeCases: ['widget handles concurrent access safely'],
        envVars: [],
        exports: [{ file: 'src/widget.ts', symbol: 'widget' }],
        stubs: [{
          path: 'src/widget.ts',
          content: 'export function widget(x: string): string { throw new Error("stub"); }\n',
        }],
      }],
    } as unknown as FeatureSpec;

    const llm: LlmClient = {
      async enumerateFeatures() { return []; },
      async probeFeature() { return { clear: true, assertion: 'x', question: '' }; },
      async encodeFeatures() { return spec; },
      async repairFeatures() { return []; },
      async fixStub() { return ''; },
      async generateTests() { return { content: '' }; },
      async fixTestCases(_s, _f, broken) {
        return {
          cases: broken.map(b => ({
            name: b.name,
            kind: b.kind as 'behavior' | 'edge' | 'integration',
            body: b.name === 'widget'
              ? "expect(widget('a')).toBe('a');"
              : "expect(widget('concurrent')).toBe('concurrent');",
          })),
        };
      },
      async generateImplementation() {
        return [{ path: 'src/widget.ts', content: 'export function widget(x: string): string { return x; }\n' }];
      },
      async repair(input) { return input.ownedFiles; },
    };

    // Overlaps token-wise with the edge case name above ("widget", "concurrent",
    // "access") so assembleSuperbuffer's lesson filter actually matches it.
    const lessons: Tier1Lesson[] = [{
      classKey: 'runtime|widget|concurrent-access',
      idiom: 'serialize concurrent widget access',
      resolution: 'use a queue',
      ts: '',
    }];

    const events: string[] = [];
    await runTier1Job({
      prompt: 'widget lib',
      llm,
      lessons,
      onEvent: e => events.push(e.message),
    });

    const superbufferLine = events.find(
      m => m.startsWith('superbuffer: assembled') && m.includes('widget handles concurrent access safely'),
    );
    expect(superbufferLine, events.join('\n')).toBeDefined();
    const m = /(\d+) lessons/.exec(superbufferLine!);
    expect(m).toBeTruthy();
    expect(Number(m![1])).toBeGreaterThan(0);
  });
});

// ---------------------------------------------------------------------------
// (ii) cross-run recurrence scan
// ---------------------------------------------------------------------------

function runAt(id: string, ts: string, projectId: string, problems: string[]): RunFailureSnapshot {
  return { id, ts, projectId, problems };
}

describe('SET 31d cross-run recurrence scan', () => {
  it('extractDroppedCaseNames parses both drop-site wordings', () => {
    const names = extractDroppedCaseNames([
      'F1: case "ArticleModel" dropped — unparseable (coverage reduced)',
      'F1: planned case "unique-constraint" unfilled after retries — dropped (coverage reduced)',
      'F1: IR suite failed validation after 2 attempts', // not a case drop — ignored
    ]);
    expect(names).toEqual(['ArticleModel', 'unique-constraint']);
  });

  it('caseFamily anchors every *Model name to one model-schema family', () => {
    expect(caseFamily('ArticleModel')).toBe('model-schema:*Model');
    expect(caseFamily('UserModel')).toBe('model-schema:*Model');
    expect(caseFamily('SessionModel')).toBe('model-schema:*Model');
    expect(caseFamily('unique-constraint')).toBe('unique-constraint');
    expect(caseFamily('Referential Integrity!')).toBe('referential-integrity');
  });

  it('detects a case family that failed ≥3 consecutive runs in the same project and drafts the model-schema clade first', () => {
    const records: RunFailureSnapshot[] = [
      runAt('r1', '2026-07-01T00:00:00Z', 'proj-1', [
        'F1: case "ArticleModel" dropped — unparseable (coverage reduced)',
      ]),
      runAt('r2', '2026-07-02T00:00:00Z', 'proj-1', [
        'F1: case "UserModel" dropped — unparseable (coverage reduced)',
      ]),
      runAt('r3', '2026-07-03T00:00:00Z', 'proj-1', [
        'F1: planned case "SessionModel" unfilled after retries — dropped (coverage reduced)',
      ]),
    ];
    const proposals = scanRecurringFailures(records);
    const modelProposal = proposals.find(p => p.family === 'model-schema:*Model');
    expect(modelProposal).toBeDefined();
    expect(modelProposal!.wins).toBe(3);
    expect(modelProposal!.source).toBe('cross-run-failure');
    expect(modelProposal!.runIds).toEqual(['r1', 'r2', 'r3']);
    expect(modelProposal!.draft.stubSketch).toMatch(/class SessionModel/);
    expect(modelProposal!.draft.caseBody).toMatch(/new SessionModel/);
  });

  it('does not propose below the recurrence threshold', () => {
    const records: RunFailureSnapshot[] = [
      runAt('r1', '2026-07-01T00:00:00Z', 'proj-2', [
        'F1: case "ArticleModel" dropped — unparseable (coverage reduced)',
      ]),
      runAt('r2', '2026-07-02T00:00:00Z', 'proj-2', [
        'F1: case "UserModel" dropped — unparseable (coverage reduced)',
      ]),
    ];
    expect(scanRecurringFailures(records)).toHaveLength(0);
  });

  it('a healed run breaks the streak — no proposal once the failure stops recurring at the head', () => {
    const records: RunFailureSnapshot[] = [
      runAt('r1', '2026-07-01T00:00:00Z', 'proj-3', [
        'F1: case "ArticleModel" dropped — unparseable (coverage reduced)',
      ]),
      runAt('r2', '2026-07-02T00:00:00Z', 'proj-3', [
        'F1: case "ArticleModel" dropped — unparseable (coverage reduced)',
      ]),
      runAt('r3', '2026-07-03T00:00:00Z', 'proj-3', []), // healed
    ];
    expect(scanRecurringFailures(records)).toHaveLength(0);
  });

  it('groups runs without a projectId by near-identical prompt', () => {
    const base = 'Build a blog with Article, User and Session models';
    const records: RunFailureSnapshot[] = [
      { id: 'p1', ts: '2026-07-01T00:00:00Z', prompt: base, problems: ['F1: case "ArticleModel" dropped — unparseable (coverage reduced)'] },
      { id: 'p2', ts: '2026-07-02T00:00:00Z', prompt: `${base}.`, problems: ['F1: case "ArticleModel" dropped — unparseable (coverage reduced)'] },
      { id: 'p3', ts: '2026-07-03T00:00:00Z', prompt: `${base}!`, problems: ['F1: case "ArticleModel" dropped — unparseable (coverage reduced)'] },
      { id: 'unrelated', ts: '2026-07-01T12:00:00Z', prompt: 'totally different app: inventory management', problems: [] },
    ];
    const proposals = scanRecurringFailures(records);
    expect(proposals.some(p => p.family === 'model-schema:*Model' && p.wins === 3)).toBe(true);
  });

  it('respects a custom minConsecutive', () => {
    const records: RunFailureSnapshot[] = [
      runAt('r1', '2026-07-01T00:00:00Z', 'proj-4', ['F1: case "ArticleModel" dropped — unparseable (coverage reduced)']),
      runAt('r2', '2026-07-02T00:00:00Z', 'proj-4', ['F1: case "ArticleModel" dropped — unparseable (coverage reduced)']),
    ];
    expect(scanRecurringFailures(records, { minConsecutive: 2 })).toHaveLength(1);
    expect(scanRecurringFailures(records, { minConsecutive: 3 })).toHaveLength(0);
  });
});

// ---------------------------------------------------------------------------
// clade-candidate proposal shape + honesty message
// ---------------------------------------------------------------------------

describe('SET 31d clade-candidate proposal shape', () => {
  it('draftCladePromotion carries source + runIds through when provided', () => {
    const p = draftCladePromotion({
      patternKey: 'family:model-schema:*Model',
      featureId: 'F1',
      wins: 4,
      source: 'cross-run-failure',
      runIds: ['a', 'b', 'c', 'd'],
    });
    expect(p.family).toBe('model-schema:*Model');
    expect(p.patternKey).toBe('family:model-schema:*Model');
    expect(p.wins).toBe(4);
    expect(p.source).toBe('cross-run-failure');
    expect(p.runIds).toEqual(['a', 'b', 'c', 'd']);
    expect(p.draft.stubSketch.length).toBeGreaterThan(0);
    expect(p.draft.caseBody.length).toBeGreaterThan(0);
    expect(typeof p.ts).toBe('string');
  });

  it('omits source/runIds when not provided (backward compatible with SET 17 in-run callers)', () => {
    const p = draftCladePromotion({ patternKey: 'case:slugify-empty', featureId: 'F2', wins: 3 });
    expect(p.source).toBeUndefined();
    expect(p.runIds).toBeUndefined();
  });

  it('formats the required cross-run honesty line', () => {
    const p = draftCladePromotion({
      patternKey: 'family:model-schema:*Model', featureId: 'F1', wins: 3, source: 'cross-run-failure',
    });
    expect(formatCladeCandidateMessage(p)).toBe('clade-candidate: "model-schema:*Model" (failed 3 consecutive runs)');
  });

  it('formats the in-run win-streak line distinctly', () => {
    const p = draftCladePromotion({
      patternKey: 'case:slugify-empty', featureId: 'F1', wins: 3, source: 'in-run-win',
    });
    expect(formatCladeCandidateMessage(p)).toBe('clade-candidate: "slugify-empty" (won 3 times this run)');
  });
});
