/**
 * SET 85 — the observatory (Andy, 2026-07-27: "the passive watcher agent
 * should be append only … each of those should be a decoupled LLM poll … so
 * that the passive agent only accumulates more examples of tropes and schemes
 * that are extant. and this is at the AST level; in the supercache, this data
 * should be explicit artifactual").
 *
 * Four properties under test: APPEND-ONLY (never replaces), DECOUPLED (one
 * question per call), AST-GROUNDED (evidence from declarations/imports/calls),
 * ARTIFACTUAL (records with subject + evidence + sighting provenance).
 */
import { describe, expect, it } from 'vitest';
import type { FeatureSpec, LlmClient, RepairInput } from '../src/core/types';
import {
  ObservationLedger,
  OBSERVATION_POLLS,
  OBSERVATORY_PAGE_CAP,
  astFacts,
  formatAstFacts,
  formatObservatoryPage,
  parsePoll,
  pollPrompt,
} from '../src/tier1/observatory';
import type { Observation } from '../src/tier1/observatory';
import { runTier1Job } from '../src/tier1/runner';
import { makeEvalSuiteRunner } from '../src/tier1/testExec';

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; }';

describe('SET 85 — AST facts are deterministic and structural', () => {
  const files = [
    { path: 'src/adder.ts', content: 'export function add(a: number, b: number): number { return a + b; }' },
    { path: 'src/double.ts', content: "import { add } from './adder';\nexport function double(a: number): number { return add(a, a); }" },
  ];

  it('extracts declarations, imports, and cross-file call edges', () => {
    const f = astFacts(files);
    expect(f.declarations).toContainEqual({
      path: 'src/adder.ts', kind: 'function', symbol: 'add', params: 'a: number, b: number', exported: true,
    });
    expect(f.imports).toContainEqual({ from: 'src/double.ts', to: './adder', symbols: ['add'] });
    // `add` is declared in adder.ts and CALLED from double.ts — a real edge.
    expect(f.callEdges).toContainEqual({ callee: 'add', callers: ['src/double.ts'] });
    // A symbol called only in its own file is not an edge.
    expect(f.callEdges.some(e => e.callee === 'double')).toBe(false);
  });

  it('is a pure function — same input, byte-identical output', () => {
    expect(astFacts(files)).toEqual(astFacts(files));
    const page = formatAstFacts(astFacts(files));
    expect(page).toContain('AST facts');
    expect(page).toContain('export function add(a: number, b: number)');
    expect(page).toContain('add is called from src/double.ts');
  });
});

describe('SET 85 — the ledger is APPEND-ONLY', () => {
  it('a repeat sighting corroborates; it never replaces', () => {
    const l = new ObservationLedger();
    expect(l.append('idiom', 'add', 'returns without validating input', ['src/adder.ts'], 't1')).toBe(true);
    // Same claim again → NOT new; the existing record gains a sighting.
    expect(l.append('idiom', 'add', 'returns  without validating   input', ['src/other.ts'], 't2')).toBe(false);
    const all = l.all();
    expect(all).toHaveLength(1);
    expect(all[0]!.timesSeen).toBe(2);
    expect(all[0]!.firstSeenAt).toBe('t1');
    expect(all[0]!.lastSeenAt).toBe('t2');
    // Evidence ACCUMULATES rather than overwriting.
    expect(all[0]!.evidence).toEqual(['src/adder.ts', 'src/other.ts']);
  });

  it('different kinds and subjects are different observations; nothing is lost', () => {
    const l = new ObservationLedger();
    l.append('idiom', 'add', 'claim A', [], 't');
    l.append('trope', 'add', 'claim A', [], 't');
    l.append('idiom', 'double', 'claim A', [], 't');
    expect(l.size).toBe(3);
    expect(l.ofKind('idiom')).toHaveLength(2);
  });

  it('seeds from prior epochs — the corpus carries across runs', () => {
    const seed: Observation[] = [{
      kind: 'scheme', subject: 'src/adder.ts', claim: 'pure functions throughout',
      evidence: [], firstSeenAt: 'r1', lastSeenAt: 'r1', timesSeen: 3,
    }];
    const l = new ObservationLedger(seed);
    expect(l.size).toBe(1);
    l.append('scheme', 'src/adder.ts', 'pure functions throughout', [], 'r2');
    expect(l.all()[0]!.timesSeen).toBe(4); // continued, not restarted
  });

  it('the page ranks corroborated observations first so the cap cuts one-offs', () => {
    const l = new ObservationLedger();
    for (let i = 0; i < 30; i++) l.append('idiom', `sym${i}`, `one-off claim ${i}`, [], 't');
    l.append('trope', 'add', 'assertion classes recur after heals', [], 't');
    l.append('trope', 'add', 'assertion classes recur after heals', [], 't');
    const page = formatObservatoryPage(l)!;
    expect(page).toContain('assertion classes recur after heals [seen 2×]');
    expect(page.length).toBeLessThanOrEqual(OBSERVATORY_PAGE_CAP);
    expect(formatObservatoryPage(new ObservationLedger())).toBeNull();
  });
});

describe('SET 85 — polls are DECOUPLED', () => {
  it('each poll asks exactly one question with its own system prompt', () => {
    expect(OBSERVATION_POLLS.map(p => p.kind)).toEqual(['idiom', 'dependency', 'trope', 'scheme']);
    for (const p of OBSERVATION_POLLS) {
      const prompt = pollPrompt(p, 'AST facts:\nsrc/a.ts: export function f()', 'x: failed');
      expect(prompt).toContain(p.question);
      expect(prompt).toContain('Answer ONLY this question');
      // One question per prompt — no other poll's question leaks in.
      for (const other of OBSERVATION_POLLS) {
        if (other.kind !== p.kind) expect(prompt).not.toContain(other.question);
      }
      expect(p.system).toContain('One question only');
    }
  });

  it('parses only the canonical ITEM form and caps the harvest', () => {
    expect(parsePoll('ITEM: add — returns a difference, not a sum'))
      .toEqual([{ subject: 'add', claim: 'returns a difference, not a sum' }]);
    expect(parsePoll('I noticed that add seems wrong')).toEqual([]);
    expect(parsePoll(Array.from({ length: 9 }, (_, i) => `ITEM: s${i} — c${i}`).join('\n'))).toHaveLength(4);
  });
});

describe('SET 85 — end to end', () => {
  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(_i: RepairInput) { return [{ path: 'src/adder.ts', content: RIGHT }]; },
      ...extra,
    } as unknown as LlmClient;
  }

  it('issues one call PER QUESTION and accumulates artifactual observations', async () => {
    const asked: string[] = [];
    const systems: string[] = [];
    const captured: Observation[] = [];
    const res = await runTier1Job({
      prompt: 'adder',
      llm: baseLlm({
        async observe(prompt: string, system: string) {
          asked.push(prompt);
          systems.push(system);
          return 'ITEM: add — subtracts where the contract requires a sum';
        },
      }),
      runSuite: makeEvalSuiteRunner(),
      onObservation: (o: Observation) => captured.push(o),
    });
    // Four decoupled calls, one per question — not one call for all four.
    expect(res.metrics.observationPolls).toBe(OBSERVATION_POLLS.length);
    expect(new Set(asked).size).toBe(OBSERVATION_POLLS.length);
    expect(new Set(systems).size).toBe(OBSERVATION_POLLS.length);
    // Every poll saw the SAME AST evidence.
    expect(asked.every(p => p.includes('AST facts'))).toBe(true);
    // The observation is an ARTIFACT: subject, claim, evidence, provenance.
    expect(res.metrics.observationsNew).toBeGreaterThan(0);
    const o = captured[0]!;
    expect(o.subject).toBe('add');
    expect(o.claim).toContain('subtracts');
    expect(o.evidence.some(e => e.includes('src/adder.ts'))).toBe(true);
    expect(o.timesSeen).toBeGreaterThanOrEqual(1);
    expect(res.log.some(e => /^observatory: 4 decoupled poll\(s\)/.test(e.message))).toBe(true);
    expect(res.problems).toEqual([]);
  });

  it('a prior corpus is continued, not restarted; no engine leaves it untouched', async () => {
    const seed: Observation[] = [{
      kind: 'trope', subject: 'add', claim: 'subtracts where the contract requires a sum',
      evidence: [], firstSeenAt: 'r1', lastSeenAt: 'r1', timesSeen: 2,
    }];
    const captured: Observation[] = [];
    const res = await runTier1Job({
      prompt: 'adder',
      llm: baseLlm({
        async observe() { return 'ITEM: add — subtracts where the contract requires a sum'; },
      }),
      runSuite: makeEvalSuiteRunner(),
      observations: seed,
      onObservation: (o: Observation) => captured.push(o),
    });
    const carried = captured.find(o => o.kind === 'trope' && o.subject === 'add');
    expect(carried!.timesSeen).toBeGreaterThan(2);   // continued
    expect(carried!.firstSeenAt).toBe('r1');          // original provenance kept
    expect(res.metrics.observationsCorroborated).toBeGreaterThan(0);

    // No engine at all → no polls, no growth, run unaffected.
    const bare = await runTier1Job({ prompt: 'adder', llm: baseLlm(), runSuite: makeEvalSuiteRunner() });
    expect(bare.metrics.observationPolls).toBe(0);
    expect(bare.metrics.observationsNew).toBe(0);
    expect(bare.problems).toEqual([]);
  });

  it('a throwing poll engine is contained after two failures', async () => {
    let calls = 0;
    const res = await runTier1Job({
      prompt: 'adder',
      llm: baseLlm({
        async observe() { calls++; throw new Error('poll engine exploded'); },
      }),
      runSuite: makeEvalSuiteRunner(),
    });
    expect(calls).toBeLessThanOrEqual(OBSERVATION_POLLS.length);
    expect(res.metrics.observationsNew).toBe(0);
    expect(res.problems).toEqual([]);
  });
});
