// Persistence-contract drift guard (Review-Fixes 4/8/9, KAR-971, PR #336
// adversarial review). Analogous to mapping-drift.test.ts (which guards the
// P1 mapper against mappings/qaf-to-value-stream-mapping.yaml): that test
// reads the YAML contract and cross-checks the mapper's actual runtime
// output against it with hand-written structural assertions, not a formal
// YAML-schema validator. This test does the same for the P2 PERSISTED ROW
// shape — it reads schemas/qaf-value-stream-import.schema.json (the
// canonical contract for the value_stream_imports table) and cross-checks
// creation.ts's ACTUAL, real (only the RPC boundary + loadQafSourceRows are
// mocked, exactly like creation.test.ts) `p_payload` output against it.
//
// No new dependency: ajv exists in node_modules only as a transitive
// dependency of something else (confirmed via `grep '"ajv"' package-lock.json`
// — never a direct devDependency here, and unused anywhere else in this
// repo), so importing it directly in committed test code would be a latent
// fragility (it could vanish on a future `npm install` if the tree
// reshuffles). Structural assertions are the repo's own established pattern
// for this class of drift test (mapping-drift.test.ts) and need nothing new.
//
// The specific regressions this test catches (found + fixed by the review):
//   - missing_field_reasons persisted as a raw per-occurrence ARRAY where
//     the schema requires an OBJECT keyed by field name (Review-Fix 4/8).
//   - warnings items missing the schema-required `severity` field, and
//     carrying `rowIndex` instead of the schema's `stepRef` (Review-Fix 9).

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import type { QAFRow } from '@/lib/qaf-parser'
import type { QafSourceRows } from '../qaf-source'
import { buildQafRow } from './fixtures'

const { loadQafSourceRowsMock } = vi.hoisted(() => ({ loadQafSourceRowsMock: vi.fn() }))

vi.mock('../qaf-source', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../qaf-source')>()
  return { ...actual, loadQafSourceRows: loadQafSourceRowsMock }
})

import { createValueStreamFromQaf } from '../creation'
import { mapAndFingerprint } from '../qaf-source'

const SCHEMA_PATH = path.join(process.cwd(), 'schemas/qaf-value-stream-import.schema.json')

interface JsonSchemaLike {
  required: string[]
  properties: {
    warnings: { items: { required: string[]; properties: { severity: { enum: string[] } } } }
    missing_field_reasons: {
      type: string
      additionalProperties: { required: string[]; properties: { reason: { enum: string[] } } }
    }
  }
}

function loadSchema(): JsonSchemaLike {
  return JSON.parse(readFileSync(SCHEMA_PATH, 'utf8')) as JsonSchemaLike
}

const QAF_FILE_ID = '11111111-1111-4111-8111-111111111111'

// Deliberately provokes both fixed regressions at once, against REAL mapper
// output (not a hand-rolled fake payload):
//   - positionsnummer out of row-order on row 1 (pos 5, after row 0's pos
//     10) -> a real sequence_ambiguous warning, so the warnings transform
//     actually runs (not just an always-empty happy path).
//   - ruestkosten missing on rows 0 and 1 with 2 DIFFERENT reasons (row 0: a
//     located-but-blank cell -> cell_empty; row 1: an explicit "n.a."
//     marker -> not_numeric[n.a.]) so the aggregator's mixed-reason-per-
//     field path (the trickiest part of Review-Fix 4/8) actually runs.
//   - row 2 is deselected in the confirmation below, so its (otherwise
//     numerous) missing-field entries must NOT appear in the aggregated
//     result — cross-checks the same "describe only what was actually
//     imported" filter creation.ts applies.
const ROWS: QAFRow[] = [
  buildQafRow({ prozessbezeichnung: 'Schweißen', positionsnummer: '10', sourceCells: { ruestkosten: 'Fertigungskosten!C2' } }),
  buildQafRow({ prozessbezeichnung: 'Montage', positionsnummer: '5', rawText: { ruestkosten: 'n.a.' } }),
  buildQafRow({ prozessbezeichnung: 'Prüfen', positionsnummer: '20', ruestkosten: 12 }),
]

const SOURCE: QafSourceRows = {
  qafFile: {
    id: QAF_FILE_ID,
    projectId: 'project-1',
    fileHash: 'hash-1',
    originalFileName: 'x.xlsx',
    parserVersion: 'p1',
    fileLevelContext: { plannedCapacityPartsPerYear: null, lotSizeParts: null },
  },
  rows: ROWS,
  stepIds: ['step-0', 'step-1', 'step-2'],
}

function makeSupabase(rpcImpl: (...args: unknown[]) => unknown) {
  return { rpc: vi.fn(rpcImpl) }
}

beforeEach(() => {
  loadQafSourceRowsMock.mockReset()
  loadQafSourceRowsMock.mockResolvedValue(SOURCE)
})

describe('persistence-drift: creation.ts p_payload vs schemas/qaf-value-stream-import.schema.json', () => {
  it('warnings items carry the schema-required severity + stepRef (not rowIndex) — Review-Fix 9', async () => {
    const schema = loadSchema()
    const supabase = makeSupabase(() =>
      Promise.resolve({ data: { value_stream_id: 'vsm-1', import_id: 'import-1', idempotent_hit: false }, error: null }),
    )

    const previewToken = mapAndFingerprint(SOURCE).previewToken
    const result = await createValueStreamFromQaf(supabase as never, {
      qafFileId: QAF_FILE_ID,
      previewToken,
      title: 'Drift-Test Wertstrom',
      deselectedRowIndexes: [2],
    })
    expect(result.ok).toBe(true)

    const [, capturedArgs] = (supabase.rpc as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>]
    const warnings = (capturedArgs.p_payload as Record<string, unknown>).warnings as Array<Record<string, unknown>>

    // Sanity: this test is only meaningful if the fixture actually produced
    // a warning — an empty array would make every assertion below vacuous.
    expect(warnings.length).toBeGreaterThan(0)

    const requiredKeys = schema.properties.warnings.items.required
    const severityEnum = schema.properties.warnings.items.properties.severity.enum

    for (const warning of warnings) {
      for (const key of requiredKeys) {
        expect(warning, `warnings item missing schema-required "${key}": ${JSON.stringify(warning)}`).toHaveProperty(key)
      }
      expect(severityEnum, `severity "${warning.severity}" not in schema enum`).toContain(warning.severity)
      expect(warning).not.toHaveProperty('rowIndex') // internal QvsWarning field name — must not leak into persistence
      expect(typeof warning.stepRef === 'string' || warning.stepRef === null).toBe(true)
    }
  })

  it('missing_field_reasons is a Map keyed by field name, never the internal per-occurrence array — Review-Fix 4/8', async () => {
    const schema = loadSchema()
    const supabase = makeSupabase(() =>
      Promise.resolve({ data: { value_stream_id: 'vsm-1', import_id: 'import-1', idempotent_hit: false }, error: null }),
    )

    const previewToken = mapAndFingerprint(SOURCE).previewToken
    await createValueStreamFromQaf(supabase as never, {
      qafFileId: QAF_FILE_ID,
      previewToken,
      title: 'Drift-Test Wertstrom',
      deselectedRowIndexes: [2],
    })

    const [, capturedArgs] = (supabase.rpc as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>]
    const missingFieldReasons = (capturedArgs.p_payload as Record<string, unknown>).missingFieldReasons

    // Hardcoded, not schema-sourced: this exact "object, not array" distinction
    // IS the regression this test exists to catch — reading it back out of
    // the schema would make the test blind to the schema itself regressing.
    expect(schema.properties.missing_field_reasons.type).toBe('object')
    expect(Array.isArray(missingFieldReasons)).toBe(false)
    expect(typeof missingFieldReasons).toBe('object')
    expect(missingFieldReasons).not.toBeNull()

    const entries = Object.entries(missingFieldReasons as Record<string, Record<string, unknown>>)
    expect(entries.length).toBeGreaterThan(0) // sanity — must not be vacuously empty

    const requiredKeys = schema.properties.missing_field_reasons.additionalProperties.required
    const reasonEnum = schema.properties.missing_field_reasons.additionalProperties.properties.reason.enum

    for (const [field, entry] of entries) {
      for (const key of requiredKeys) {
        expect(entry, `missing_field_reasons.${field} missing schema-required "${key}"`).toHaveProperty(key)
      }
      expect(reasonEnum, `missing_field_reasons.${field}.reason "${entry.reason}" not in schema enum`).toContain(entry.reason)
      expect(typeof entry.count).toBe('number')
      expect(entry.count as number).toBeGreaterThan(0)
    }

    // The mixed-reason field (ruestkosten: row 0 cell_empty, row 1
    // not_numeric[n.a.]) must aggregate to ONE entry with count 2 (both
    // occurrences), the canonical (bracket-stripped) reason, and the raw
    // text collected into rawTextSamples rather than silently dropped.
    const ruestkosten = (missingFieldReasons as Record<string, { reason: string; count: number; rawTextSamples?: string[] }>).ruestkosten
    expect(ruestkosten).toBeDefined()
    expect(ruestkosten.count).toBe(2)
    expect(['cell_empty', 'not_numeric']).toContain(ruestkosten.reason) // canonicalized — never the raw `not_numeric[n.a.]` template form
    expect(ruestkosten.rawTextSamples).toEqual(['n.a.'])

    // Row 2 was deselected — its own missing-field occurrences (it lacks
    // the same 11 other primary fields as rows 0/1; only ruestkosten is set
    // on row 2) must NOT be counted. zykluszeit is null on all 3 rows, so a
    // leaked row 2 would inflate its count to 3 instead of the correct 2
    // (rows 0 + 1 only) — this is the concrete, checkable form of the
    // "describe only what was actually imported" filter documented above.
    const zykluszeit = (missingFieldReasons as Record<string, { count: number }>).zykluszeit
    expect(zykluszeit).toBeDefined()
    expect(zykluszeit.count).toBe(2)
  })

  it('creation.ts supplies valid values for every schema-required field it owns (parser_version/mapping_version/imported+excluded_step_count)', async () => {
    const schema = loadSchema()
    const supabase = makeSupabase(() =>
      Promise.resolve({ data: { value_stream_id: 'vsm-1', import_id: 'import-1', idempotent_hit: false }, error: null }),
    )

    const previewToken = mapAndFingerprint(SOURCE).previewToken
    await createValueStreamFromQaf(supabase as never, {
      qafFileId: QAF_FILE_ID,
      previewToken,
      title: 'Drift-Test Wertstrom',
      deselectedRowIndexes: [2],
    })
    const [, capturedArgs] = (supabase.rpc as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Record<string, unknown>]

    // creation.ts's own required-field contribution — the RPC/DB owns the
    // rest of the schema's top-level `required` list (id, value_stream_id,
    // created_at: gen_random_uuid()/the value_stream_maps insert's returned
    // id/now()), not creation.ts's job to produce.
    const creationTsOwnedRequired = ['parser_version', 'mapping_version', 'imported_step_count', 'excluded_step_count']
    for (const key of creationTsOwnedRequired) {
      expect(schema.required, `test fixture drifted: "${key}" is no longer schema-required — update this test`).toContain(key)
    }

    expect(typeof capturedArgs.p_parser_version).toBe('string')
    expect((capturedArgs.p_parser_version as string).length).toBeGreaterThan(0)
    expect(capturedArgs.p_mapping_version).toBe('qvs-1')
    expect(Number.isInteger(capturedArgs.p_imported_step_count)).toBe(true)
    expect(capturedArgs.p_imported_step_count as number).toBeGreaterThanOrEqual(0)
    expect(Number.isInteger(capturedArgs.p_excluded_step_count)).toBe(true)
    expect(capturedArgs.p_excluded_step_count as number).toBeGreaterThanOrEqual(0)
  })
})
