/**
 * SET 28 — LLM call honesty (purpose + tokens on the call line), fill-attempt
 * honesty (batch targets logged + true drop counts), and stub-scaffold
 * ordering for the superbuffer's first-case assembly.
 */
import { afterEach, describe, expect, it, vi } from 'vitest';
import { formatLlmCallEvent } from '../src/core/llmCallLog';
import { formatFillBatchTargets, formatFillDropMessage } from '../src/tier1/fillHonesty';
import { LocalLlmClient } from '../src/core/localLlm';
import type {
  FeatureNode,
  FeatureSpec,
  LlmCallRecord,
  RepairInput,
} from '../src/core/types';
import { runTier1Job } from '../src/tier1/runner';

afterEach(() => {
  vi.unstubAllGlobals();
  vi.restoreAllMocks();
});

// ---------------------------------------------------------------------------
// 28a — formatLlmCallEvent: purpose + candidate/temp/attempt + tokens ON the
// call line, matching the exact shapes from the backlog.
// ---------------------------------------------------------------------------
describe('SET 28a formatLlmCallEvent', () => {
  it('backlog ir-fill example: candidate + temp + attempt, tokens on the call line', () => {
    expect(formatLlmCallEvent({
      stage: 'ir', purpose: 'fill "CategoryModel"',
      candidate: { n: 2, k: 3 }, temp: 0.7, attempt: 1,
      inputTokens: 812, outputTokens: 140,
    })).toBe('llm call — ir: fill "CategoryModel" [candidate 2/3, t=0.7, attempt 1] (812→ / 140← tok)');
  });

  it('backlog repair example: "pass" label instead of "attempt"', () => {
    expect(formatLlmCallEvent({
      stage: 'repair', purpose: 'crash|token finding',
      candidate: { n: 1, k: 3 }, temp: 0.3, attempt: 2, attemptLabel: 'pass',
      inputTokens: 2048, outputTokens: 96,
    })).toBe('llm call — repair: crash|token finding [candidate 1/3, t=0.3, pass 2] (2,048→ / 96← tok)');
  });

  it('backlog build example: no candidate/temp/attempt → empty bracket', () => {
    expect(formatLlmCallEvent({ stage: 'build', purpose: 'src/data_model.ts' }))
      .toBe('llm call — build: src/data_model.ts');
  });

  it('never emits a half-formed token suffix when only one of in/out is known', () => {
    const line = formatLlmCallEvent({ stage: 'build', purpose: 'src/data_model.ts', inputTokens: 100 });
    expect(line).toBe('llm call — build: src/data_model.ts');
    expect(line).not.toMatch(/tok\)/);
  });

  it('single non-candidate first-attempt draw still carries its own tokens', () => {
    expect(formatLlmCallEvent({ stage: 'ir', purpose: 'fill "X"', inputTokens: 10, outputTokens: 5 }))
      .toBe('llm call — ir: fill "X" (10→ / 5← tok)');
  });
});

// ---------------------------------------------------------------------------
// 28a — LocalLlmClient: every public call site threads purpose/candidate/
// attempt through to the usage record's logLine, using the REAL tokens from
// that call's response (no drift onto adjacent events).
// ---------------------------------------------------------------------------
describe('SET 28a LocalLlmClient threads purpose/candidate/attempt/tokens into logLine', () => {
  function stubFetchOnce(tokens: { in: number; out: number }, payload: unknown) {
    vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
      model: 'test-model',
      choices: [{ message: { content: JSON.stringify(payload) } }],
      usage: { prompt_tokens: tokens.in, completion_tokens: tokens.out },
    }), { status: 200 })));
  }

  const feature: FeatureNode = {
    id: 'F1', name: 'f1', description: 'd', parentId: null, deps: [],
    files: ['src/data_model.ts'], testFile: 'tests/f1.test.ts', edgeCases: [], envVars: [],
    exports: [{ file: 'src/data_model.ts', symbol: 'f1' }], stubs: [],
  };
  const spec: FeatureSpec = { title: 'S', prompt: 'p', features: [feature] };

  it('fixTestCases: candidate + temp + attempt on the call line, with THIS call\'s tokens', async () => {
    stubFetchOnce({ in: 300, out: 60 }, { cases: [] });
    const usage: LlmCallRecord[] = [];
    const llm = new LocalLlmClient(u => usage.push(u), undefined, {
      baseURL: 'http://localhost:9999/v1', model: 'test-model', probeHost: async () => 'idle',
    });

    await llm.fixTestCases(
      spec, feature,
      [{ name: 'CategoryModel', kind: 'behavior', errors: [] }],
      [], [],
      { temperature: 0.7, candidate: { n: 2, k: 3 }, attempt: 1 },
    );

    expect(usage).toHaveLength(1);
    expect(usage[0]!.logLine).toBe(
      'llm call — ir: fill "CategoryModel" [candidate 2/3, t=0.7, attempt 1] (300→ / 60← tok)',
    );
    expect(usage[0]!.purpose).toBe('fill "CategoryModel"');
    expect(usage[0]!.candidate).toEqual({ n: 2, k: 3 });
    expect(usage[0]!.attempt).toBe(1);
  });

  it('repair: caller-supplied purpose overrides the default bugClass label; "pass" bracket', async () => {
    stubFetchOnce({ in: 2048, out: 96 }, { files: [] });
    const usage: LlmCallRecord[] = [];
    const llm = new LocalLlmClient(u => usage.push(u), undefined, {
      baseURL: 'http://localhost:9999/v1', model: 'test-model', probeHost: async () => 'idle',
    });
    const input: RepairInput = {
      spec, feature,
      findings: [{ bugClass: 'test-failure', file: 'src/data_model.ts', line: 3, message: 'boom', featureId: 'F1' }],
      parallelInstances: [],
      frozenTest: 'test content',
      ownedFiles: [],
      dependencyContext: [],
      failureOutput: 'boom',
      attempt: 2,
      lineage: [],
    };

    await llm.repair(input, {
      temperature: 0.3, candidate: { n: 1, k: 3 }, attempt: 2, attemptLabel: 'pass',
      purpose: 'crash|token finding',
    });

    expect(usage).toHaveLength(1);
    expect(usage[0]!.logLine).toBe(
      'llm call — repair: crash|token finding [candidate 1/3, t=0.3, pass 2] (2,048→ / 96← tok)',
    );
    expect(usage[0]!.purpose).toBe('crash|token finding');
    expect(usage[0]!.attemptLabel).toBe('pass');
  });

  it('generateImplementation: default purpose is the owned file list, empty bracket with no candidate/attempt', async () => {
    stubFetchOnce({ in: 500, out: 200 }, { files: [] });
    const usage: LlmCallRecord[] = [];
    const llm = new LocalLlmClient(u => usage.push(u), undefined, {
      baseURL: 'http://localhost:9999/v1', model: 'test-model', probeHost: async () => 'idle',
    });

    await llm.generateImplementation(spec, feature, 'frozen test', []);

    expect(usage).toHaveLength(1);
    expect(usage[0]!.logLine).toBe('llm call — build: src/data_model.ts (500→ / 200← tok)');
  });

  it('two sequential calls never cross-contaminate tokens — each logLine carries only its own call\'s tokens', async () => {
    const usage: LlmCallRecord[] = [];
    const llm = new LocalLlmClient(u => usage.push(u), undefined, {
      baseURL: 'http://localhost:9999/v1', model: 'test-model', probeHost: async () => 'idle',
    });

    stubFetchOnce({ in: 111, out: 22 }, { cases: [] });
    await llm.fixTestCases(spec, feature, [{ name: 'A', kind: 'behavior', errors: [] }], [], [], { attempt: 1 });
    stubFetchOnce({ in: 999, out: 88 }, { cases: [] });
    await llm.fixTestCases(spec, feature, [{ name: 'B', kind: 'behavior', errors: [] }], [], [], { attempt: 1 });

    expect(usage).toHaveLength(2);
    expect(usage[0]!.logLine).toContain('fill "A"');
    expect(usage[0]!.logLine).toContain('(111→ / 22← tok)');
    expect(usage[0]!.logLine).not.toContain('999');
    expect(usage[1]!.logLine).toContain('fill "B"');
    expect(usage[1]!.logLine).toContain('(999→ / 88← tok)');
    expect(usage[1]!.logLine).not.toContain('111');
  });
});

// ---------------------------------------------------------------------------
// 28b — fill-attempt honesty. Helper unit tests + a full-pipeline integration
// test proving every dropped planned case has ≥1 attempt event naming it, and
// drop messages never overstate the retry count.
// ---------------------------------------------------------------------------
describe('SET 28b / 32e.i formatFillBatchTargets / formatFillDropMessage', () => {
  it('lists every targeted case name, quoted', () => {
    expect(formatFillBatchTargets(['UserModel', 'CategoryModel']))
      .toBe('fill batch: targets ["UserModel", "CategoryModel"]');
  });

  it('tags a fallback batch distinctly from a K-draw batch', () => {
    expect(formatFillBatchTargets(['X'], 'fallback')).toBe('fill batch: targets ["X"] (fallback)');
  });

  it('N=0 → "never drawn" — never claims a retry that did not happen', () => {
    const msg = formatFillDropMessage('X', 0, 0, 0);
    expect(msg).toBe('planned case "X" never drawn — dropped (plan exceeded fill budget)');
    expect(msg).not.toMatch(/draws\)/);
    expect(msg).not.toMatch(/retries/);
  });

  it('SET 32e.i vocabulary: draws + candidate batches + fallback batches, singular/plural agreement', () => {
    expect(formatFillDropMessage('X', 1, 1, 0)).toBe(
      'planned case "X" unfilled after 1 draw (1 candidate batch + 0 fallback batches) — dropped',
    );
    expect(formatFillDropMessage('X', 5, 1, 1)).toBe(
      'planned case "X" unfilled after 5 draws (1 candidate batch + 1 fallback batch) — dropped',
    );
    expect(formatFillDropMessage('X', 9, 2, 3)).toBe(
      'planned case "X" unfilled after 9 draws (2 candidate batches + 3 fallback batches) — dropped',
    );
  });
});

describe('SET 28b fill-attempt honesty — full pipeline', () => {
  it('every dropped planned case has ≥1 batch-target event naming it, and drop counts are real (N>0, never "never drawn")', async () => {
    const feature: FeatureNode = {
      id: 'F1', name: 'Widget', description: 'processes a Widget item', parentId: null, deps: [],
      files: ['src/widget.ts'], testFile: 'tests/widget.test.ts',
      edgeCases: ['negative amount throws'], envVars: [],
      exports: [{ file: 'src/widget.ts', symbol: 'processWidget' }],
      stubs: [{
        path: 'src/widget.ts',
        content: 'export function processWidget(amount: number): number { throw new Error("stub"); }\n',
      }],
    };
    const spec: FeatureSpec = { title: 'Widget', prompt: 'a widget processor', features: [feature] };

    const events: string[] = [];
    const res = await runTier1Job({
      prompt: 'a widget processor',
      llm: {
        async enumerateFeatures() { return []; },
        async probeFeature() { return { clear: true, assertion: 'x', question: '' }; },
        async encodeFeatures() { return spec; },
        async repairFeatures() { return []; },
        async fixStub() { return ''; },
        // Fallback structural draw never fills either planned case.
        async generateTests() { return { content: '' }; },
        // Per-case K-draw always returns zero cases — every attempt is real
        // (fetched, counted) but never lands a body.
        async fixTestCases() { return { cases: [] }; },
        async generateImplementation() {
          return [{ path: 'src/widget.ts', content: 'export function processWidget(amount: number): number { return amount; }\n' }];
        },
        async repair(input) { return input.ownedFiles; },
      },
      gates: { maxPasses: 2, stallLimit: 2, progressStall: 2 },
      onEvent: e => events.push(e.message),
    });

    const dropMsgs = res.problems.filter(p => /^F1: planned case "/.test(p));
    expect(dropMsgs.length).toBe(2); // both processWidget + the edge case dropped

    for (const name of ['processWidget', 'negative amount throws']) {
      const dropIdx = events.findIndex(m => m.includes(`planned case "${name}"`) && /dropped/.test(m));
      expect(dropIdx).toBeGreaterThanOrEqual(0);
      const dropMsg = events[dropIdx]!;
      // Never claims a retry that didn't happen: N>0 attempts really occurred
      // (the K-loop alone draws PERMUTATION_TEMPS.length times), so it must
      // NOT be the zero-attempt "never drawn" phrasing.
      expect(dropMsg).not.toMatch(/never drawn/);
      expect(dropMsg).toMatch(/unfilled after \d+ draws? \(\d+ candidate batch(?:es)? \+ \d+ fallback batch(?:es)?\) — dropped/);

      // Requirement 3: an attempt event naming this case exists BEFORE the drop.
      const namingIdx = events.findIndex((m, i) =>
        i < dropIdx && /^fill batch: targets \[/.test(m) && m.includes(`"${name}"`));
      expect(namingIdx).toBeGreaterThanOrEqual(0);
    }

    // SET 71 — per-NODE K-draws: each case gets its own named batch event;
    // the structural fallback still bundles the leftovers.
    expect(events.some(m => m === 'fill batch: targets ["processWidget"]')).toBe(true);
    expect(events.some(m => m === 'fill batch: targets ["negative amount throws"]')).toBe(true);
    expect(events.some(m => m === 'fill batch: targets ["processWidget", "negative amount throws"] (fallback)')).toBe(true);

    void res;
  });

  it('a case with zero fill attempts drops with the honest "never drawn" message (no fabricated retries)', () => {
    // Direct unit-level proof of the zero-attempt path the pipeline falls
    // back to for any case a batch never reaches (e.g. plan exceeds budget).
    const msg = formatFillDropMessage('NeverTouched', 0, 0, 0);
    expect(msg).toBe('planned case "NeverTouched" never drawn — dropped (plan exceeded fill budget)');
  });
});

// ---------------------------------------------------------------------------
// 28c — stub scaffolding must exist before the superbuffer is packed for a
// feature's FIRST case. Regression coverage for the fix: non-function stubs
// (interface/class/type/const-only exports) previously reported "0 stubs"
// due to a signature-EXTRACTION gap, not an ordering bug.
//
// *Model exports are now substrate-templated (31d planner) — they take the
// no-hole path and never pack a superbuffer. The CategoryModel fixture below
// asserts that templated path; a non-*Model interface still exercises the
// original superbuffer stub-count ordering.
// ---------------------------------------------------------------------------
describe('SET 28c stub scaffolding precedes first-case superbuffer packing', () => {
  it('CategoryModel is substrate-templated (no superbuffer hole path)', async () => {
    const feature: FeatureNode = {
      id: 'M1', name: 'CategoryModel', description: 'a category record shape', parentId: null, deps: [],
      files: ['src/category_model.ts'], testFile: 'tests/m1.test.ts', edgeCases: [], envVars: [],
      exports: [{ file: 'src/category_model.ts', symbol: 'CategoryModel' }],
      stubs: [{
        path: 'src/category_model.ts',
        content: 'export interface CategoryModel {\n  id: string;\n  name: string;\n}\n',
      }],
    };
    const spec: FeatureSpec = { title: 'M1', prompt: 'category model', features: [feature] };

    const events: string[] = [];
    await runTier1Job({
      prompt: 'category model',
      llm: {
        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() { return { cases: [] }; },
        async generateImplementation() {
          return [{ path: 'src/category_model.ts', content: 'export interface CategoryModel { id: string; name: string; }\n' }];
        },
        async repair(input) { return input.ownedFiles; },
      },
      gates: { maxPasses: 2, stallLimit: 2, progressStall: 2 },
      onEvent: e => events.push(e.message),
    });

    // 31d: *Model anchors are substrate-templated — zero model-draw holes,
    // so the IR fill path never packs a superbuffer for this feature.
    // SET 36d — build-stage superbuffer is expected; IR freeform packing is not.
    expect(events.some(m => /^plan: \d+ cases \(\d+ substrate-templated, 0 model draws\)/.test(m))).toBe(true);
    expect(events.some(m => m.includes('templated CategoryModel via model-schema-construct'))).toBe(true);
    const irSb = events.filter(m =>
      m.startsWith('superbuffer: assembled') && !m.includes('for "build|'));
    expect(irSb).toHaveLength(0);
  });

  it('a non-Model interface-only export reports stub count > 0 on its first (only) case', async () => {
    // Non-*Model name keeps this case as a freeform hole so superbuffer
    // packing still runs — preserves the 28c stub-ordering regression.
    const feature: FeatureNode = {
      id: 'M1', name: 'CategoryShape', description: 'a category record shape', parentId: null, deps: [],
      files: ['src/category_shape.ts'], testFile: 'tests/m1.test.ts', edgeCases: [], envVars: [],
      exports: [{ file: 'src/category_shape.ts', symbol: 'CategoryShape' }],
      stubs: [{
        path: 'src/category_shape.ts',
        content: 'export interface CategoryShape {\n  id: string;\n  name: string;\n}\n',
      }],
    };
    const spec: FeatureSpec = { title: 'M1', prompt: 'category shape', features: [feature] };

    const events: string[] = [];
    await runTier1Job({
      prompt: 'category shape',
      llm: {
        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, _feature, broken) {
          return {
            cases: broken.map(b => ({
              name: b.name, kind: b.kind as 'behavior',
              body: 'const c: CategoryShape = { id: "1", name: "x" }; expect(c.name).toBe("x");',
            })),
          };
        },
        async generateImplementation() {
          return [{ path: 'src/category_shape.ts', content: 'export interface CategoryShape { id: string; name: string; }\n' }];
        },
        async repair(input) { return input.ownedFiles; },
      },
      gates: { maxPasses: 2, stallLimit: 2, progressStall: 2 },
      onEvent: e => events.push(e.message),
    });

    const superbufferLines = events.filter(m => m.startsWith('superbuffer: assembled'));
    expect(superbufferLines.length).toBeGreaterThan(0);
    // The FIRST superbuffer assembly for this feature's only case must
    // already see the interface stub (ordering: stubs exist before packing).
    const m = /^superbuffer: assembled \((\d+) stubs/.exec(superbufferLines[0]!);
    expect(m).not.toBeNull();
    expect(Number(m![1])).toBeGreaterThan(0);
  });

  it('assembleSuperbuffer extracts signatures from class/interface/type/const stubs, not just functions', async () => {
    const { assembleSuperbuffer } = await import('../src/tier1/superbuffer');
    const feature: FeatureNode = {
      id: 'F1', name: 'Shapes', description: 'd', parentId: null, deps: [],
      files: ['src/shapes.ts'], testFile: 'tests/shapes.test.ts', edgeCases: [], envVars: [],
      exports: [{ file: 'src/shapes.ts', symbol: 'Box' }],
      stubs: [{
        path: 'src/shapes.ts',
        content: [
          'export class Box { width = 0; }',
          'export interface Sized { width: number; }',
          'export type Id = string;',
          'export const DEFAULT_WIDTH: number = 10;',
        ].join('\n'),
      }],
    };
    const spec: FeatureSpec = { title: 'Shapes', prompt: 'p', features: [feature] };
    const assembly = assembleSuperbuffer({
      spec, feature, parts: null, stubs: feature.stubs, lessons: [],
      siblingParts: new Map(), target: 'Box', signature: null,
    });
    expect(assembly.stubSignatures).toEqual(expect.arrayContaining([
      'class Box', 'interface Sized', 'type Id = string', 'DEFAULT_WIDTH: number',
    ]));
    expect(assembly.packedStubCount).toBeGreaterThan(0);
    expect(assembly.logLine).toMatch(/^superbuffer: assembled \([1-9]\d* stubs/);
  });
});
