import { describe, it } from 'node:test';
import { strict as assert } from 'node:assert';
import {
  VerifierPipeline,
  type Claim,
  type ClaimExtractor,
  type ClaimVerdict,
  type DeterministicChecker,
  type EvidenceJudge,
  type HardClaim,
  type SoftClaim,
} from '@omadia/verifier';

// --- Stubs ---------------------------------------------------------------

function stubExtractor(claims: Claim[]): ClaimExtractor {
  return {
    extract(): Promise<Claim[]> {
      return Promise.resolve(claims);
    },
  } as unknown as ClaimExtractor;
}

function stubDeterministic(
  verdictFor: (c: HardClaim) => ClaimVerdict,
): DeterministicChecker {
  return {
    check(c: HardClaim): Promise<ClaimVerdict> {
      return Promise.resolve(verdictFor(c));
    },
    checkAll(claims: HardClaim[]): Promise<ClaimVerdict[]> {
      return Promise.all(claims.map((c) => Promise.resolve(verdictFor(c))));
    },
  } as unknown as DeterministicChecker;
}

function stubJudge(
  verdictFor: (c: SoftClaim) => ClaimVerdict,
): EvidenceJudge {
  return {
    check(c: SoftClaim): Promise<ClaimVerdict> {
      return Promise.resolve(verdictFor(c));
    },
    checkAll(claims: SoftClaim[]): Promise<ClaimVerdict[]> {
      return Promise.all(claims.map((c) => Promise.resolve(verdictFor(c))));
    },
  } as unknown as EvidenceJudge;
}

function hardClaim(overrides: Partial<HardClaim> = {}): HardClaim {
  return {
    id: 'c_h',
    text: '1.234,56 €',
    type: 'amount',
    expectedSource: 'odoo',
    value: 1234.56,
    odooRecord: { model: 'account.move', id: 42 },
    relatedEntities: [],
    ...overrides,
  } as HardClaim;
}

function softClaim(overrides: Partial<SoftClaim> = {}): SoftClaim {
  return {
    id: 'c_s',
    text: 'John Doe ist Senior Dev',
    type: 'qualitative',
    expectedSource: 'graph',
    relatedEntities: [],
    ...overrides,
  } as SoftClaim;
}

const SILENT_LOG = (): void => {
  /* silent */
};

// --- Tests ---------------------------------------------------------------

describe('verifier/pipeline', () => {
  it('approves smalltalk without triggering extractor', async () => {
    let called = false;
    const extractor = {
      extract(): Promise<Claim[]> {
        called = true;
        return Promise.resolve([]);
      },
    } as unknown as ClaimExtractor;
    const pipeline = new VerifierPipeline({
      extractor,
      deterministic: stubDeterministic(() => ({
        status: 'verified',
        claim: hardClaim(),
        source: 'odoo',
      })),
      judge: stubJudge(() => ({
        status: 'verified',
        claim: softClaim(),
        source: 'graph',
      })),
      log: SILENT_LOG,
    });
    const verdict = await pipeline.verify({
      runId: 'r1',
      userMessage: 'Hallo',
      answer: 'Hallo, wie kann ich helfen?',
    });
    assert.equal(verdict.status, 'approved');
    assert.equal(called, false);
  });

  it('approves when all claims verify', async () => {
    const pipeline = new VerifierPipeline({
      extractor: stubExtractor([hardClaim(), softClaim()]),
      deterministic: stubDeterministic((c) => ({
        status: 'verified',
        claim: c,
        source: 'odoo',
      })),
      judge: stubJudge((c) => ({
        status: 'verified',
        claim: c,
        source: 'graph',
      })),
      log: SILENT_LOG,
    });
    const verdict = await pipeline.verify({
      runId: 'r2',
      userMessage: 'Was?',
      answer: 'Die Rechnung beträgt 1.234,56 € und John bestätigt.',
    });
    assert.equal(verdict.status, 'approved');
    assert.equal(verdict.claims.length, 2);
  });

  it('blocks on contradiction', async () => {
    const pipeline = new VerifierPipeline({
      extractor: stubExtractor([hardClaim()]),
      deterministic: stubDeterministic((c) => ({
        status: 'contradicted',
        claim: c,
        truth: 9999,
        source: 'odoo',
      })),
      judge: stubJudge(
        () =>
          ({ status: 'verified', claim: softClaim(), source: 'graph' }) as ClaimVerdict,
      ),
      log: SILENT_LOG,
    });
    const verdict = await pipeline.verify({
      runId: 'r3',
      userMessage: 'was?',
      answer: 'Die Rechnung beträgt 1.234,56 €.',
    });
    assert.equal(verdict.status, 'blocked');
    if (verdict.status === 'blocked') {
      assert.equal(verdict.contradictions.length, 1);
    }
  });

  it('approves_with_disclaimer when unverified but no contradictions', async () => {
    const pipeline = new VerifierPipeline({
      extractor: stubExtractor([hardClaim(), softClaim()]),
      deterministic: stubDeterministic((c) => ({
        status: 'unverified',
        claim: c,
        reason: 'record missing',
      })),
      judge: stubJudge((c) => ({
        status: 'verified',
        claim: c,
        source: 'graph',
      })),
      log: SILENT_LOG,
    });
    const verdict = await pipeline.verify({
      runId: 'r4',
      userMessage: 'was?',
      answer: 'Die Rechnung beträgt 1.234,56 € für John Doe.',
    });
    assert.equal(verdict.status, 'approved_with_disclaimer');
    if (verdict.status === 'approved_with_disclaimer') {
      assert.equal(verdict.unverified.length, 1);
    }
  });

  it('approves when extractor returns no claims (trigger fired but nothing structured)', async () => {
    const pipeline = new VerifierPipeline({
      extractor: stubExtractor([]),
      deterministic: stubDeterministic(() => ({
        status: 'verified',
        claim: hardClaim(),
        source: 'odoo',
      })),
      judge: stubJudge(() => ({
        status: 'verified',
        claim: softClaim(),
        source: 'graph',
      })),
      log: SILENT_LOG,
    });
    const verdict = await pipeline.verify({
      runId: 'r5',
      userMessage: 'was?',
      answer: 'Die Rechnung beträgt 1.234,56 €.',
    });
    assert.equal(verdict.status, 'approved');
  });

  it('blocks odoo-amount claim when the turn never called an odoo tool (context-replay)', async () => {
    let checkerCalled = false;
    const pipeline = new VerifierPipeline({
      extractor: stubExtractor([hardClaim()]),
      deterministic: {
        check(): Promise<ClaimVerdict> {
          checkerCalled = true;
          return Promise.resolve({
            status: 'verified',
            claim: hardClaim(),
            source: 'odoo',
          });
        },
        checkAll(claims: HardClaim[]): Promise<ClaimVerdict[]> {
          if (claims.length > 0) checkerCalled = true;
          return Promise.resolve([]);
        },
      } as unknown as DeterministicChecker,
      judge: stubJudge((c) => ({
        status: 'verified',
        claim: c,
        source: 'graph',
      })),
      log: SILENT_LOG,
    });
    const verdict = await pipeline.verify({
      runId: 'r-replay',
      userMessage: 'was?',
      answer: 'Die Rechnung beträgt 1.234,56 €.',
      // Note: only memory tool — no query_odoo_* / odoo_execute
      domainToolsCalled: ['memory'],
    });
    assert.equal(verdict.status, 'blocked');
    if (verdict.status === 'blocked') {
      assert.equal(verdict.contradictions.length, 1);
      assert.match(
        verdict.contradictions[0]!.status === 'contradicted'
          ? (verdict.contradictions[0]!.detail ?? '')
          : '',
        /Fach-Agent-Call/,
      );
    }
    assert.equal(
      checkerCalled,
      false,
      'deterministic checker must be skipped when the replay rule fires',
    );
  });

  it('lets odoo-amount claim through when query_odoo_accounting was called', async () => {
    const pipeline = new VerifierPipeline({
      extractor: stubExtractor([hardClaim()]),
      deterministic: stubDeterministic((c) => ({
        status: 'verified',
        claim: c,
        source: 'odoo',
      })),
      judge: stubJudge((c) => ({
        status: 'verified',
        claim: c,
        source: 'graph',
      })),
      log: SILENT_LOG,
    });
    const verdict = await pipeline.verify({
      runId: 'r-withtool',
      userMessage: 'was?',
      answer: 'Die Rechnung beträgt 1.234,56 €.',
      domainToolsCalled: ['query_odoo_accounting', 'memory'],
    });
    assert.equal(verdict.status, 'approved');
  });

  it('skips trace-cross-check when domainToolsCalled is undefined (no evidence either way)', async () => {
    const pipeline = new VerifierPipeline({
      extractor: stubExtractor([hardClaim()]),
      deterministic: stubDeterministic((c) => ({
        status: 'verified',
        claim: c,
        source: 'odoo',
      })),
      judge: stubJudge((c) => ({
        status: 'verified',
        claim: c,
        source: 'graph',
      })),
      log: SILENT_LOG,
    });
    const verdict = await pipeline.verify({
      runId: 'r-notrace',
      userMessage: 'was?',
      answer: 'Die Rechnung beträgt 1.234,56 €.',
      // domainToolsCalled intentionally omitted
    });
    assert.equal(verdict.status, 'approved');
  });

  it('tolerates extractor throwing', async () => {
    const pipeline = new VerifierPipeline({
      extractor: {
        extract(): Promise<Claim[]> {
          return Promise.reject(new Error('rate limit'));
        },
      } as unknown as ClaimExtractor,
      deterministic: stubDeterministic(() => ({
        status: 'verified',
        claim: hardClaim(),
        source: 'odoo',
      })),
      judge: stubJudge(() => ({
        status: 'verified',
        claim: softClaim(),
        source: 'graph',
      })),
      log: SILENT_LOG,
    });
    const verdict = await pipeline.verify({
      runId: 'r6',
      userMessage: 'was?',
      answer: 'Die Rechnung beträgt 1.234,56 €.',
    });
    assert.equal(verdict.status, 'approved');
  });

  // #130 — postcondition violation flips the verdict to blocked even when
  // the answer text has no claims of its own. The synthetic claim never
  // hits the extractor; it is manufactured from the runTrace marker.
  it('blocks on tool_postcondition violation without invoking extractor', async () => {
    let extractorCalled = false;
    const extractor = {
      extract(): Promise<Claim[]> {
        extractorCalled = true;
        return Promise.resolve([]);
      },
    } as unknown as ClaimExtractor;
    const pipeline = new VerifierPipeline({
      extractor,
      deterministic: stubDeterministic(() => ({
        status: 'verified',
        claim: hardClaim(),
        source: 'odoo',
      })),
      judge: stubJudge(() => ({
        status: 'verified',
        claim: softClaim(),
        source: 'graph',
      })),
      log: SILENT_LOG,
    });
    const verdict = await pipeline.verify({
      runId: 'r7',
      userMessage: 'List products',
      answer: 'Hier sind sie: Foo, Bar.',
      toolPostconditionViolations: [
        {
          toolName: 'list_products',
          callId: 'call_xyz',
          agentContext: 'agent-shop',
          issues: ['<root>: expected array, received object'],
        },
      ],
    });
    assert.equal(verdict.status, 'blocked');
    if (verdict.status === 'blocked') {
      assert.equal(verdict.contradictions.length, 1);
      assert.equal(verdict.contradictions[0]?.claim.type, 'tool_postcondition');
      assert.equal(verdict.contradictions[0]?.claim.id, 'c_postcond_call_xyz');
    }
    // Verifier never asked the extractor — the synthetic claim doesn't need
    // answer parsing. (Trigger router may also short-circuit if the answer
    // doesn't look factual; either way extractor stays untouched.)
    assert.equal(extractorCalled, false);
  });

  // Backwards-compat: no postcondition violations + clean claims → still
  // approves. Guards against accidental tightening of the verdict-builder.
  it('approves cleanly when toolPostconditionViolations is absent', async () => {
    const pipeline = new VerifierPipeline({
      extractor: stubExtractor([hardClaim()]),
      deterministic: stubDeterministic((c) => ({
        status: 'verified',
        claim: c,
        source: 'odoo',
      })),
      judge: stubJudge((c) => ({
        status: 'verified',
        claim: c,
        source: 'graph',
      })),
      log: SILENT_LOG,
    });
    const verdict = await pipeline.verify({
      runId: 'r8',
      userMessage: 'was?',
      answer: 'Die Rechnung beträgt 1.234,56 €.',
    });
    assert.equal(verdict.status, 'approved');
  });
});
