/**
 * SET 88 — legible agent failure.
 *
 * The defect these tests pin: three resident agents caught their engine's
 * exception with a bare `} catch {`, incremented a counter that was never
 * reset, and retired permanently at two failures. A run whose watcher died 77
 * seconds in reported only "retired after repeated failures" — the run log
 * could not say what threw, so the cause was undiagnosable from the artifact
 * the run produced.
 *
 * Three properties: the message REACHES THE LOG, the counter is CONSECUTIVE,
 * and retirement is never PERMANENT.
 */
import { describe, expect, it } from 'vitest';
import type { FeatureSpec, LlmClient } from '../src/core/types';
import { runTier1Job } from '../src/tier1/runner';
import { makeEvalSuiteRunner } from '../src/tier1/testExec';
import {
  createAgentHealth,
  errorMessage,
  noteFailure,
  noteSuccess,
} from '../src/tier1/agentHealth';

const WRONG = 'export function add(a: number, b: number): number { return a - b; }';
const RIGHT = 'export function add(a: number, b: number): number { return a + b; }';

function makeSpec(): FeatureSpec {
  return {
    title: 'Adder', prompt: 'adder',
    features: [{
      id: 'F1', name: 'add', description: 'adds two numbers', parentId: null,
      deps: [], files: ['src/adder.ts'], testFile: 'tests/f1-add.test.ts',
      edgeCases: ['zero'], envVars: [],
      exports: [{ file: 'src/adder.ts', symbol: 'add' }],
      stubs: [{ path: 'src/adder.ts', content: `export function add(a: number, b: number): number { throw new Error('stub: not implemented'); }` }],
    }],
  } as unknown as FeatureSpec;
}

function baseLlm(extra: Record<string, unknown> = {}): LlmClient {
  return {
    async encodeFeatures() { return makeSpec(); },
    async generateTests() { return { content: '', parts: { imports: [], cases: [] } }; },
    async fixTestCases(_s: unknown, _f: unknown, req: { name: string }[]) {
      const n = req[0]!.name;
      return { cases: [{ name: n, kind: (n === 'zero' ? 'edge' : 'behavior') as 'edge' | 'behavior',
        body: n === 'zero' ? 'expect(add(7, 0)).toBe(7);' : 'expect(add(2, 3)).toBe(5);' }] };
    },
    async generateImplementation() { return [{ path: 'src/adder.ts', content: WRONG }]; },
    async repair() { return [{ path: 'src/adder.ts', content: RIGHT }]; },
    ...extra,
  } as unknown as LlmClient;
}

describe('SET 88a — the exception reaches the log', () => {
  it('names what the watcher engine threw, verbatim', async () => {
    const res = await runTier1Job({
      prompt: 'adder',
      llm: baseLlm({
        async watchEpoch() { throw new Error('provider desync persisted after 2 reissues'); },
      }),
      runSuite: makeEvalSuiteRunner(),
    });
    const msgs = res.log.map(e => e.message);
    // THE test. The pre-SET-88 code discarded the error and could not pass it.
    expect(msgs.some(m => m.includes('provider desync persisted after 2 reissues'))).toBe(true);
    expect(msgs.some(m => /reading engine threw — provider desync/.test(m))).toBe(true);
    expect(res.metrics.watcherEngineFailures).toBeGreaterThan(0);
    // An agent that dies leaves a body in the ledger.
    expect(res.supergit!.rows.some(r =>
      (r.summary ?? '').includes('watcher engine threw')
      && (r.summary ?? '').includes('provider desync'))).toBe(true);
    // The run itself is unharmed — these agents are advisory.
    expect(res.problems).toEqual([]);
  });

  it('names what an observatory poll threw', async () => {
    const res = await runTier1Job({
      prompt: 'adder',
      llm: baseLlm({
        async observe() { throw new Error('Local LLM 400: schema rejected'); },
      }),
      runSuite: makeEvalSuiteRunner(),
    });
    const msgs = res.log.map(e => e.message);
    expect(msgs.some(m => m.includes('Local LLM 400: schema rejected'))).toBe(true);
    expect(res.metrics.observatoryEngineFailures).toBeGreaterThan(0);
    expect(res.supergit!.rows.some(r => (r.summary ?? '').includes('observatory poll'))).toBe(true);
    expect(res.problems).toEqual([]);
  });

  it('a non-Error throw is still legible', async () => {
    const res = await runTier1Job({
      prompt: 'adder',
      llm: baseLlm({ async watchEpoch() { throw 'bare string rejection'; } }),
      runSuite: makeEvalSuiteRunner(),
    });
    expect(res.log.some(e => e.message.includes('bare string rejection'))).toBe(true);
  });
});

describe('SET 88b — the counter is consecutive, not cumulative', () => {
  it('a success between two failures means the agent never backs off', () => {
    const h = createAgentHealth();
    noteFailure(h, new Error('one'));
    expect(h.consecutiveFailures).toBe(1);
    noteSuccess(h);
    noteFailure(h, new Error('two'));
    // Under the OLD cumulative counter this was failure #2 → permanent death.
    expect(h.consecutiveFailures).toBe(1);
    expect(h.totalFailures).toBe(2); // still counted for the metrics strip
  });

  it('the run survives failures separated by successes', async () => {
    let n = 0;
    const res = await runTier1Job({
      prompt: 'adder',
      llm: baseLlm({
        async watchEpoch() {
          n++;
          if (n === 1 || n === 3) throw new Error(`sporadic ${n}`);
          return 'IDIOM: add — returns a difference\nUNION: still red';
        },
      }),
      runSuite: makeEvalSuiteRunner(),
    });
    // Readings kept being taken after the failures — the engine was never retired.
    expect(res.metrics.watcherReadings).toBeGreaterThan(1);
    expect(res.log.some(e => /reading engine retired/.test(e.message))).toBe(false);
  });
});

describe('SET 88f — the engine is attempted EVERY trigger, no ladder', () => {
  it('agentHealth counts and remembers; it exposes nothing that can gate', () => {
    const h = createAgentHealth();
    noteFailure(h, new Error('a'));
    noteFailure(h, new Error('b'));
    noteFailure(h, new Error('c'));
    // Three consecutive failures buy exactly zero skipped triggers, because
    // there is no skipping. The module has no gate to consult.
    expect(Object.keys(h).sort()).toEqual(['consecutiveFailures', 'lastError', 'totalFailures']);
    expect(h.consecutiveFailures).toBe(3);
    noteSuccess(h);
    expect(h.consecutiveFailures).toBe(0);
  });

  it('a permanently broken engine is called on every single synthesis', async () => {
    let calls = 0;
    let syntheses = 0;
    const res = await runTier1Job({
      prompt: 'adder',
      llm: baseLlm({
        async watchEpoch() { calls++; throw new Error('engine down hard'); },
      }),
      runSuite: makeEvalSuiteRunner(),
      gates: { maxPasses: 6, stallLimit: 5, progressStall: 5 },
    });
    syntheses = res.metrics.watcherSyntheses;
    // THE property: attempts == triggers. Not 2 (retirement), not a
    // 1/2/4/8 subset (backoff). Every single time.
    expect(calls).toBe(syntheses);
    expect(calls).toBeGreaterThan(2);
    expect(res.metrics.watcherEngineFailures).toBe(calls);
    // And no trigger is ever described as skipped or deferred.
    expect(res.log.some(e => /backing off|retrying in|not re-attempted|retired/.test(e.message))).toBe(false);
    expect(res.problems).toEqual([]);
  });

  it('a repeated failure reports its streak without announcing a wait', async () => {
    const res = await runTier1Job({
      prompt: 'adder',
      llm: baseLlm({ async watchEpoch() { throw new Error('engine down hard'); } }),
      runSuite: makeEvalSuiteRunner(),
      gates: { maxPasses: 6, stallLimit: 5, progressStall: 5 },
    });
    const lines = res.log.filter(e => /reading engine threw/.test(e.message));
    expect(lines.length).toBeGreaterThan(1);
    expect(lines.some(e => /engine down hard \(\d+ consecutive\)/.test(e.message))).toBe(true);
    expect(lines.every(e => !/trigger/.test(e.message))).toBe(true);
  });

  it('an engine that recovers is trusted again immediately', async () => {
    let n = 0;
    const res = await runTier1Job({
      prompt: 'adder',
      llm: baseLlm({
        async watchEpoch() {
          n++;
          // Cold start: the first two throw. Under the ladder, the trigger
          // right after them was SKIPPED — a working engine sat idle.
          if (n <= 2) throw new Error('cold model load timed out');
          return 'IDIOM: add — subtracts\nUNION: F1 still red';
        },
      }),
      runSuite: makeEvalSuiteRunner(),
    });
    expect(res.metrics.watcherReadings).toBeGreaterThan(0);
    expect(res.metrics.watcherEngineFailures).toBe(2);
    expect(res.log.some(e => /fresh reading/.test(e.message))).toBe(true);
    expect(res.log.some(e => e.message.includes('cold model load timed out'))).toBe(true);
  });

  it('every observatory poll is attempted every sweep, even while failing', async () => {
    let polls = 0;
    const res = await runTier1Job({
      prompt: 'adder',
      llm: baseLlm({
        async observe() { polls++; throw new Error('poll engine down'); },
      }),
      runSuite: makeEvalSuiteRunner(),
    });
    // Four decoupled questions → four attempts per sweep. The old code broke
    // out of the sweep on the second failure, abandoning the other questions.
    expect(polls % 4).toBe(0);
    expect(polls).toBeGreaterThanOrEqual(4);
    expect(res.metrics.observatoryEngineFailures).toBe(polls);
    expect(res.problems).toEqual([]);
  });
});

describe('SET 88 — helpers', () => {
  it('errorMessage flattens anything throwable', () => {
    expect(errorMessage(new Error('a\n  b'))).toBe('a b');
    expect(errorMessage('plain')).toBe('plain');
    expect(errorMessage(undefined)).toBe('undefined');
    expect(errorMessage(new Error(''))).toBe('unknown error');
    expect(errorMessage(new Error('x'.repeat(400))).length).toBe(200);
  });
});

/**
 * SET 88e — THE REACHABILITY TEST.
 *
 * The 2026-07-27 run, with 88a shipped, finally named its own cause:
 *   `reading engine threw — Cannot read properties of undefined (reading 'call')`
 * The runner extracted `llm.watchEpoch` and called it BARE, so `this` was
 * undefined and the real client's `this.call(...)` threw on every trigger. All
 * three resident agents had therefore never taken a single reading in
 * production, since the day each was written.
 *
 * The suite could not see it because every LLM double in this repo is an object
 * literal whose methods close over their data and never touch `this`. These
 * doubles are CLASSES that use `this`, exactly like `LocalLlmClient` — the only
 * shape that can catch an unbound receiver.
 */
describe('SET 88e — engines are called with their receiver bound', () => {
  class ClassLlm {
    private readonly right = RIGHT;
    private readonly wrong = WRONG;
    readonly seen: string[] = [];

    // Mirrors LocalLlmClient: the public engine delegates through `this`.
    private record(kind: string): void { this.seen.push(kind); }

    async encodeFeatures() { return makeSpec(); }
    async generateTests() { return { content: '', parts: { imports: [], cases: [] } }; }
    async fixTestCases(_s: unknown, _f: unknown, req: { name: string }[]) {
      const n = req[0]!.name;
      return { cases: [{ name: n, kind: (n === 'zero' ? 'edge' : 'behavior') as 'edge' | 'behavior',
        body: n === 'zero' ? 'expect(add(7, 0)).toBe(7);' : 'expect(add(2, 3)).toBe(5);' }] };
    }
    async generateImplementation() { return [{ path: 'src/adder.ts', content: this.wrong }]; }
    async repair() { return [{ path: 'src/adder.ts', content: this.right }]; }

    async watchEpoch(): Promise<string> {
      this.record('watch');                       // throws if `this` is undefined
      return 'IDIOM: add — subtracts\nUNION: F1 red on the sum assertion';
    }
    async observe(): Promise<string> {
      this.record('observe');
      return 'ITEM: add — subtracts where the contract requires a sum';
    }
  }

  it('a class-based client (like the real one) takes readings instead of throwing', async () => {
    const llm = new ClassLlm();
    const res = await runTier1Job({
      prompt: 'adder',
      llm: llm as unknown as LlmClient,
      runSuite: makeEvalSuiteRunner(),
    });
    // Before the bind, every one of these was 0 and the log carried
    // "Cannot read properties of undefined (reading 'call')".
    expect(res.metrics.watcherReadings).toBeGreaterThan(0);
    expect(res.metrics.watcherEngineFailures).toBe(0);
    expect(llm.seen).toContain('watch');
    expect(res.log.some(e => /watcher synthesis @ .*fresh reading/.test(e.message))).toBe(true);
    expect(res.log.some(e => /Cannot read properties of undefined/.test(e.message))).toBe(false);
  });

  it('the observatory is bound too', async () => {
    const llm = new ClassLlm();
    const res = await runTier1Job({
      prompt: 'adder',
      llm: llm as unknown as LlmClient,
      runSuite: makeEvalSuiteRunner(),
    });
    expect(llm.seen).toContain('observe');
    expect(res.metrics.observationPolls).toBeGreaterThan(0);
    expect(res.metrics.observatoryEngineFailures).toBe(0);
  });
});
