// Loop 6 Etappe 1: Der kanonische Export MUSS gegen das echte Schema-Dokument
// validieren (ajv 2020-12 gegen schemas/qaf-comparison-canonical-v1.0.json —
// keine Zod-Nacherzählung: das wäre die verbotene Parallel-Registry).
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import path from 'path'
import Ajv2020 from 'ajv/dist/2020'
import addFormats from 'ajv-formats'
import {
  buildCanonicalExport,
  buildPresentationPackage,
  withCanonicalContentHash,
  decimalString,
  deriveAnalysisStatus,
  DIFFERENCE_STATUS_BY_KIND,
  type CanonicalExportInput,
} from '../canonical-export'
import type { DifferenceRecord } from '../all-differences'
import type { SummaryMetricDiff } from '../summary-metrics'
import { validateCanonicalGraph, buildCanonicalGraph, CANONICAL_VALUE_STATE } from '../canonical-graph'

const schema = JSON.parse(
  readFileSync(path.resolve(__dirname, '../../../../schemas/qaf-comparison-canonical-v1.0.json'), 'utf8'),
)
const ajv = new Ajv2020({ allErrors: true, strict: false })
addFormats(ajv)
const validate = ajv.compile(schema)

function rec(overrides: Partial<DifferenceRecord>): DifferenceRecord {
  return {
    differenceId: 'D-SUM-001',
    area: 'SUM',
    kind: 'changed',
    formulaFindingKind: null,
    descriptionDe: 'Testsatz',
    valueAward: 1,
    valueCurrent: 2,
    delta: 1,
    deltaNullReason: null,
    unit: 'EUR_per_piece',
    cells: [],
    relatedDq: [],
    anchorRow: 12,
    ...overrides,
  }
}

function minimalInput(overrides: Partial<CanonicalExportInput> = {}): CanonicalExportInput {
  const doc = (id: string, fileName: string) => ({
    id,
    fileName,
    sha256: 'a'.repeat(64),
    byteSize: 12345,
    storageKey: `proj-1/${id}-${fileName}`,
    templateType: 'QAF_LEGACY_DE_SUMMARY',
    capabilityMatrix: {
      modules: [
        { module: 'SUMMARY', status: 'AVAILABLE', confidence: 0.97 },
        { module: 'MATERIAL', status: 'DERIVABLE', confidence: 0.6 },
      ],
    },
    supplier: 'Musterlieferant',
    partNumber: '5B5F9B5',
  })
  return {
    comparisonId: 'cmp-0001',
    language: 'de-DE',
    award: doc('file-award', 'vergabe.xlsx'),
    current: doc('file-current', 'aktuell.xlsx'),
    differences: [rec({}), rec({ differenceId: 'D-MFG-001', area: 'MFG', kind: 'added', delta: null, valueAward: null })],
    findings: [
      {
        id: 'DQ-001',
        category: 'data_quality',
        severityDe: 'pruefen',
        statementDe: 'Prüf-Befund',
        differenceIds: ['D-SUM-001'],
      },
    ],
    reconciliations: [
      {
        id: 'REC-001',
        documentRole: 'current',
        ruleId: 'angebotspreis_cascade',
        statusDe: 'bestanden',
        calculated: 410.87,
        reported: 410.87,
        difference: 0,
        tolerance: 0.0005,
      },
    ],
    actions: [
      { id: 'ACT-001', titleDe: 'Materialpreis nachverhandeln', priority: 'high', potentialAmount: 9.79 },
    ],
    validationChecks: [
      { checkId: 'bridge_closes', status: 'pass', severity: 'critical' },
      { checkId: 'ids_unique', status: 'pass', severity: 'warning' },
    ],
    engineVersion: 'qaf-compare-engine@test',
    engineConfigVersion: '1.5.0',
    parserVersion: 'parser@test',
    aiSafeToSummarize: false,
    aiBlockingReasonsDe: ['KI-Leitfaden noch nicht gebaut'],
    summaryDiffs: [],
    ...overrides,
  }
}

/** Ein SUM-Metrik-Diff mit voller Zell-Provenienz — die Fact-Quelle. */
function summaryDiff(overrides: Partial<SummaryMetricDiff> = {}): SummaryMetricDiff {
  return {
    metricKey: 'materialCosts',
    altValue: 410.87,
    neuValue: 423.5,
    deltaAbsolute: 12.63,
    deltaPercent: 3.07,
    status: 'neu',
    currency: 'EUR',
    sourceAlt: 'C21',
    sourceNeu: 'C21',
    labelFileAlt: 'Materialkosten',
    labelFileNeu: 'Materialkosten',
    labelVerifiedAlt: true,
    labelVerifiedNeu: true,
    provenanceAlt: { sheet: 'Zusammenfassung', formula: '=SUM(B2:B20)', valueState: 'formula_and_cached' },
    provenanceNeu: { sheet: 'Zusammenfassung', formula: null, valueState: 'constant' },
    ...overrides,
  }
}

describe('buildCanonicalExport — Schema-Validität (Exit-Kriterium)', () => {
  it('a realistic pair export validates against the canonical schema', () => {
    const doc = withCanonicalContentHash(buildCanonicalExport(minimalInput()), 'b'.repeat(64))
    const ok = validate(doc)
    if (!ok) console.error(JSON.stringify(validate.errors?.slice(0, 12), null, 1))
    expect(ok).toBe(true)
  })

  it('an export with empty collections (no diffs/findings/recons/actions) still validates', () => {
    const doc = withCanonicalContentHash(
      buildCanonicalExport(minimalInput({ differences: [], findings: [], reconciliations: [], actions: [] })),
      'b'.repeat(64),
    )
    const ok = validate(doc)
    if (!ok) console.error(JSON.stringify(validate.errors?.slice(0, 12), null, 1))
    expect(ok).toBe(true)
  })

  it('a legacy file without storage key validates and is marked legacy_reference_missing', () => {
    const input = minimalInput()
    const doc = withCanonicalContentHash(
      buildCanonicalExport({ ...input, award: { ...input.award, storageKey: null } }),
      'b'.repeat(64),
    )
    expect(validate(doc)).toBe(true)
    const artifacts = doc.source_artifacts as Array<{ availability: string }>
    expect(artifacts[0].availability).toBe('legacy_reference_missing')
  })
})

describe('Schema-Gegenproben (der Validator MUSS ablehnen — sonst wäre grün wertlos)', () => {
  it('rejects a document with a required top-level key removed', () => {
    const doc = withCanonicalContentHash(buildCanonicalExport(minimalInput()), 'b'.repeat(64)) as Record<
      string,
      unknown
    >
    delete doc.run_manifest
    expect(validate(doc)).toBe(false)
  })

  it('rejects a foreign enum value on difference_status', () => {
    const doc = withCanonicalContentHash(buildCanonicalExport(minimalInput()), 'b'.repeat(64)) as {
      differences: Array<{ difference_status: string }>
    }
    doc.differences[0].difference_status = 'voellig_fremd'
    expect(validate(doc)).toBe(false)
  })

  it('rejects an unknown top-level property (additionalProperties:false is live)', () => {
    const doc = withCanonicalContentHash(buildCanonicalExport(minimalInput()), 'b'.repeat(64)) as Record<
      string,
      unknown
    >
    doc.eingeschmuggelt = true
    expect(validate(doc)).toBe(false)
  })
})

describe('Determinismus (Exit-Kriterium: gleicher Input → gleicher Content)', () => {
  it('the same input produces byte-identical JSON (before hash insertion)', () => {
    const a = JSON.stringify(buildCanonicalExport(minimalInput()))
    const b = JSON.stringify(buildCanonicalExport(minimalInput()))
    expect(a).toBe(b)
  })
})

describe('decimalString', () => {
  it('formats once, strips trailing zeros, never emits -0 or a bare dot', () => {
    expect(decimalString(9.79)).toBe('9.79')
    expect(decimalString(410.87)).toBe('410.87')
    expect(decimalString(0.0005)).toBe('0.0005')
    expect(decimalString(12)).toBe('12')
    expect(decimalString(-0)).toBe('0')
    expect(decimalString(-3.5)).toBe('-3.5')
  })

  it('every output matches the schema decimal pattern', () => {
    const pattern = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?$/
    for (const n of [0, 1, -1, 9.79, 0.0001, 123456.789, -0.25]) {
      expect(decimalString(n)).toMatch(pattern)
    }
  })

  it('is unit-aware per QAF_CONVENTIONS.rounding: EUR snaps float artifacts to cents, pct_decimal keeps 6', () => {
    // Das F-11-Motivbeispiel: ein EUR-Betrag mit Gleitkomma-Artefakt darf
    // keine bedeutungslosen Nachkommastellen in den Vertrag tragen.
    expect(decimalString(5579440.0003921185, 'EUR')).toBe('5579440')
    expect(decimalString(12.345, 'EUR')).toBe('12.35')
    expect(decimalString(0.1234567, 'pct_decimal')).toBe('0.123457')
    expect(decimalString(1.23456, 'EUR_per_piece')).toBe('1.2346')
  })

  it('throws instead of silently emitting exponent notation for absurd magnitudes', () => {
    expect(() => decimalString(1e21)).toThrow()
    expect(() => decimalString(Number.NaN)).toThrow()
  })
})

describe('deriveAnalysisStatus (Exit-Kriterium: complete nur bei zulässigen Zuständen)', () => {
  it('complete only when every area is populated/empty_verified/not_applicable AND validation passed', () => {
    expect(deriveAnalysisStatus({ a: 'populated', b: 'empty_verified', c: 'not_applicable' }, true)).toBe('complete')
  })
  it('not_computed anywhere → partial (honest incompleteness, never complete)', () => {
    expect(deriveAnalysisStatus({ a: 'populated', b: 'not_computed' }, true)).toBe('partial')
  })
  it('blocked dominates; failed validation → failed', () => {
    expect(deriveAnalysisStatus({ a: 'blocked', b: 'populated' }, true)).toBe('blocked')
    expect(deriveAnalysisStatus({ a: 'populated' }, false)).toBe('failed')
  })
})

describe('presentation_package (Loop 12 PR 1)', () => {
  it('is populated with slides whose reference IDs all exist in the canonical collections', () => {
    const doc = buildCanonicalExport(minimalInput()) as {
      differences: Array<{ difference_id: string }>
      findings: Array<{ finding_id: string }>
      actions: Array<{ action_id: string }>
      analysis_areas: Record<string, { state: string; content: { slides?: Array<Record<string, unknown>> } | null }>
    }
    const area = doc.analysis_areas.presentation_package
    expect(area.state).toBe('populated')
    const diffIds = new Set(doc.differences.map((d) => d.difference_id))
    const findingIds = new Set(doc.findings.map((f) => f.finding_id))
    const actionIds = new Set(doc.actions.map((a) => a.action_id))
    for (const slide of area.content!.slides!) {
      for (const id of slide.difference_ids as string[]) expect(diffIds.has(id)).toBe(true)
      for (const id of slide.finding_ids as string[]) expect(findingIds.has(id)).toBe(true)
      for (const id of slide.action_ids as string[]) expect(actionIds.has(id)).toBe(true)
    }
  })

  it('orders top differences by |delta| descending, deterministic tiebreak by id', () => {
    const { slides } = buildPresentationPackage(
      [
        { difference_id: 'D-B', metric_id: 'm', difference_status: 'changed', delta_absolute: '2' },
        { difference_id: 'D-A', metric_id: 'm', difference_status: 'changed', delta_absolute: '-5' },
        { difference_id: 'D-C', metric_id: 'm', difference_status: 'new' },
      ],
      [],
      [],
      2,
    )
    const top = slides.find((s) => s.kind === 'top_differences')!
    expect(top.difference_ids).toEqual(['D-A', 'D-B'])
  })

  it('counter-probe: no findings/actions → those slides do not exist (nothing fabricated)', () => {
    const { slides } = buildPresentationPackage([], [], [])
    expect(slides.map((s) => s.kind)).toEqual(['overview', 'top_differences'])
  })

  it('the schema still validates with the populated presentation_package', () => {
    const doc = withCanonicalContentHash(buildCanonicalExport(minimalInput()), 'b'.repeat(64))
    expect(validate(doc)).toBe(true)
  })
})

describe('difference status mapping', () => {
  it('cleared maps to zero_cost and added to new (no silent identity mapping)', () => {
    expect(DIFFERENCE_STATUS_BY_KIND.cleared).toBe('zero_cost')
    expect(DIFFERENCE_STATUS_BY_KIND.added).toBe('new')
  })
  it('a named delta gap overrides the kind mapping', () => {
    const doc = buildCanonicalExport(
      minimalInput({
        differences: [rec({ differenceId: 'D-X-1', kind: 'changed', delta: null, deltaNullReason: 'blockiert' })],
      }),
    )
    const diffs = doc.differences as Array<{ difference_status: string }>
    expect(diffs[0].difference_status).toBe('not_available')
  })
})

// ── Loop 6 Etappe 2a: Entity/Fact-Graph ─────────────────────────────────────

function graphInput(overrides: Partial<CanonicalExportInput> = {}): CanonicalExportInput {
  return minimalInput({
    summaryDiffs: [summaryDiff()],
    metricIdByDifferenceId: new Map([['D-SUM-001', 'summary.materialCosts']]),
    ...overrides,
  })
}

describe('Etappe 2a — Entity/Fact-Graph', () => {
  it('builds part entities, SUM facts with full provenance, and the relationship — schema-valid', () => {
    const doc = withCanonicalContentHash(buildCanonicalExport(graphInput()), 'b'.repeat(64))
    expect(validate(doc)).toBe(true)
    const entities = doc.entities as Array<Record<string, unknown>>
    const facts = doc.facts as Array<Record<string, unknown>>
    const relationships = doc.relationships as Array<Record<string, unknown>>
    expect(entities).toHaveLength(2)
    expect(entities.map((e) => e.entity_type)).toEqual(['part', 'part'])
    expect(relationships).toHaveLength(1)
    expect(relationships[0].relationship_type).toBe('same_part_as')
    expect(facts).toHaveLength(2)
    const award = facts[0]
    expect(award.metric_id).toBe('summary.materialCosts')
    expect(award.raw_value).toBe('410.87')
    expect(award.normalized_value).toBe('410.87')
    expect(award.value_state).toBe('formula')
    expect(award.formula_raw).toBe('=SUM(B2:B20)')
    expect((award.source as { sheet: string; cell: string }).sheet).toBe('Zusammenfassung')
    expect((award.source as { sheet: string; cell: string }).cell).toBe('C21')
    expect(facts[1].value_state).toBe('hardcoded')
  })

  it('links SUM differences to their facts via base/comparison_fact_ids', () => {
    const doc = buildCanonicalExport(graphInput())
    const diffs = doc.differences as Array<Record<string, unknown>>
    const sum = diffs.find((d) => d.difference_id === 'D-SUM-001')!
    const facts = doc.facts as Array<Record<string, unknown>>
    expect(sum.base_fact_ids).toEqual([facts[0].fact_id])
    expect(sum.comparison_fact_ids).toEqual([facts[1].fact_id])
    const mfg = diffs.find((d) => d.difference_id === 'D-MFG-001')!
    expect(mfg.base_fact_ids).toBeUndefined()
    expect(mfg.comparison_fact_ids).toBeUndefined()
  })

  it('withholds a side without sheet provenance — visibly counted, never invented', () => {
    const doc = withCanonicalContentHash(
      buildCanonicalExport(
        graphInput({
          summaryDiffs: [summaryDiff({ provenanceNeu: undefined })],
        }),
      ),
      'b'.repeat(64),
    )
    expect(validate(doc)).toBe(true)
    const facts = doc.facts as Array<Record<string, unknown>>
    expect(facts).toHaveLength(1)
    const ext = (doc.extensions as Record<string, Record<string, unknown>>)['supplierpulse.current_engine']
    expect(ext.facts_withheld).toEqual([
      { metric_id: 'summary.materialCosts', document_role: 'current', reason: 'missing_sheet_provenance' },
    ])
    const sum = (doc.differences as Array<Record<string, unknown>>).find((d) => d.difference_id === 'D-SUM-001')!
    expect(sum.base_fact_ids).toBeDefined()
    expect(sum.comparison_fact_ids).toBeUndefined()
    const checks = (doc.validation as { checks: Array<{ check_id: string; status: string }> }).checks
    expect(checks.find((c) => c.check_id === 'entity_graph_integrity')?.status).toBe('pass')
  })

  it('counter-probe: a fact missing its source is schema-INVALID', () => {
    const doc = withCanonicalContentHash(buildCanonicalExport(graphInput()), 'b'.repeat(64))
    delete (doc.facts as Array<Record<string, unknown>>)[0].source
    expect(validate(doc)).toBe(false)
  })

  it('counter-probe: a foreign data_type value is schema-INVALID', () => {
    const doc = withCanonicalContentHash(buildCanonicalExport(graphInput()), 'b'.repeat(64))
    ;(doc.facts as Array<Record<string, unknown>>)[0].data_type = 'float64'
    expect(validate(doc)).toBe(false)
  })

  it('counter-probe: an extra property smuggled onto an entity is schema-INVALID', () => {
    const doc = withCanonicalContentHash(buildCanonicalExport(graphInput()), 'b'.repeat(64))
    ;(doc.entities as Array<Record<string, unknown>>)[0].smuggled = true
    expect(validate(doc)).toBe(false)
  })

  it('is deterministic for identical input', () => {
    const a = JSON.stringify(buildCanonicalExport(graphInput()))
    const b = JSON.stringify(buildCanonicalExport(graphInput()))
    expect(a).toBe(b)
  })

  it('validateCanonicalGraph flags broken references and duplicate ids', () => {
    const graph = buildCanonicalGraph({
      award: { id: 'doc-a', partNumber: 'P1', partName: null, variant: null, supplier: null },
      current: { id: 'doc-b', partNumber: 'P1', partName: null, variant: null, supplier: null },
      summaryDiffs: [summaryDiff()],
      unitByMetricId: new Map([['summary.materialCosts', 'EUR_per_piece']]),
      decimal: (n) => String(n),
    })
    expect(validateCanonicalGraph(graph, ['doc-a', 'doc-b'])).toEqual([])
    const broken = {
      ...graph,
      facts: graph.facts.map((f, i) => (i === 0 ? { ...f, entity_id: 'E-GIBT-ES-NICHT' } : f)),
    }
    const issues = validateCanonicalGraph(broken, ['doc-a', 'doc-b'])
    expect(issues.some((i) => i.includes('unbekannte entity_id'))).toBe(true)
  })

  it('both sides without provenance: no facts, no fact links, doc stays schema-valid', () => {
    const doc = withCanonicalContentHash(
      buildCanonicalExport(
        graphInput({ summaryDiffs: [summaryDiff({ provenanceAlt: undefined, provenanceNeu: undefined })] }),
      ),
      'b'.repeat(64),
    )
    expect(validate(doc)).toBe(true)
    expect(doc.facts).toEqual([])
    const sum = (doc.differences as Array<Record<string, unknown>>).find((d) => d.difference_id === 'D-SUM-001')!
    expect(sum.base_fact_ids).toBeUndefined()
    expect(sum.comparison_fact_ids).toBeUndefined()
    const ext = (doc.extensions as Record<string, Record<string, unknown>>)['supplierpulse.current_engine']
    expect((ext.facts_withheld as unknown[]).length).toBe(2)
  })

  it('unknown unit: raw_value stays exact, normalized_value is omitted — never guessed', () => {
    // Kein SUM-Differenz-Record für diese Metrik → keine unit-Ableitung.
    const doc = withCanonicalContentHash(
      buildCanonicalExport(
        graphInput({
          differences: [rec({ differenceId: 'D-MFG-001', area: 'MFG', kind: 'added', delta: null, valueAward: null })],
          metricIdByDifferenceId: new Map(),
        }),
      ),
      'b'.repeat(64),
    )
    expect(validate(doc)).toBe(true)
    const facts = doc.facts as Array<Record<string, unknown>>
    expect(facts).toHaveLength(2)
    expect(facts[0].raw_value).toBe('410.87')
    expect(facts[0].normalized_value).toBeUndefined()
    expect(facts[0].unit).toBeNull()
  })

  it('maps every internal value state onto the schema enum (exhaustive)', () => {
    const schemaEnum = ['blank', 'hardcoded', 'formula', 'formula_error', 'external_link', 'missing', 'not_applicable']
    for (const mapped of Object.values(CANONICAL_VALUE_STATE)) {
      expect(schemaEnum).toContain(mapped)
    }
  })
})
