import { describe, it, expect } from 'vitest'
import { TEXT_FIELDS } from '@/lib/qaf-parser'
import { NODE_W } from '@/components/wertstrom/vsm-geometry'
import { mapQafRowsToVsmNodes, MAPPING_VERSION, PRIMARY_FIELD_MAPPINGS, METADATA_ONLY_FIELDS } from '../mapper'
import { buildQafRow } from './fixtures'

describe('MAPPING_VERSION', () => {
  it('is qvs-1', () => {
    expect(MAPPING_VERSION).toBe('qvs-1')
  })
})

describe('PRIMARY_FIELD_MAPPINGS / METADATA_ONLY_FIELDS — internal self-consistency', () => {
  it('covers exactly 22 source fields together with name + sequence, no overlap', () => {
    const all = new Set<string>(['prozessbezeichnung', 'positionsnummer', ...PRIMARY_FIELD_MAPPINGS.map((m) => m.source), ...METADATA_ONLY_FIELDS])
    expect(all.size).toBe(22)
    expect(PRIMARY_FIELD_MAPPINGS).toHaveLength(12)
    expect(METADATA_ONLY_FIELDS).toHaveLength(8)
  })

  it('kind agrees with lib/qaf-parser.ts TEXT_FIELDS (no drift between the two descriptions)', () => {
    for (const { source, kind } of PRIMARY_FIELD_MAPPINGS) {
      expect(TEXT_FIELDS.has(source)).toBe(kind === 'text')
    }
  })
})

describe('mapQafRowsToVsmNodes — the 14 primary fields, individually', () => {
  it.each(PRIMARY_FIELD_MAPPINGS)('$source -> node.$target', ({ source, target, kind }) => {
    const value: string | number = kind === 'text' ? 'Fantasiewert' : 42
    const row = buildQafRow({
      prozessbezeichnung: 'Fantasieschritt',
      [source]: value,
      sourceCells: { [source]: 'Fertigungskosten!A2', prozessbezeichnung: 'Fertigungskosten!C2' },
    })
    const { nodes } = mapQafRowsToVsmNodes([row])
    expect(nodes).toHaveLength(1)
    expect(nodes[0][target]).toBe(value)
    expect(nodes[0].fieldStatus?.[target]).toBe('imported')
    expect(nodes[0].qafSource?.fields?.[source]).toMatchObject({ mappedTo: target, original: value })
  })

  it('prozessbezeichnung -> node.name', () => {
    const row = buildQafRow({ prozessbezeichnung: '  Fantasieschritt  ', sourceCells: { prozessbezeichnung: 'Fertigungskosten!C2' } })
    const { nodes } = mapQafRowsToVsmNodes([row])
    expect(nodes[0].name).toBe('Fantasieschritt')
    expect(nodes[0].fieldStatus?.name).toBe('imported')
    expect(nodes[0].qafSource?.fields?.prozessbezeichnung).toMatchObject({ mappedTo: 'name' })
  })

  it('positionsnummer drives order-evidence only — no discrete node field, mappedTo null', () => {
    const row = buildQafRow({ prozessbezeichnung: 'Fantasieschritt', positionsnummer: '10', sourceCells: { positionsnummer: 'Fertigungskosten!A2' } })
    const { nodes } = mapQafRowsToVsmNodes([row])
    const node = nodes[0] as unknown as Record<string, unknown>
    expect(node.positionsnummer).toBeUndefined()
    expect(nodes[0].qafSource?.positionNumber).toBe('10')
    expect(nodes[0].qafSource?.fields?.positionsnummer).toMatchObject({ mappedTo: null, original: '10' })
  })
})

describe('mapQafRowsToVsmNodes — the 8 metadata-only fields', () => {
  it.each(METADATA_ONLY_FIELDS)('%s never lands on the node, appears in qafSource.fields with mappedTo null', (source) => {
    const value: string | number = TEXT_FIELDS.has(source) ? 'Fantasiewert' : 7
    const row = buildQafRow({ prozessbezeichnung: 'Fantasieschritt', [source]: value })
    const { nodes } = mapQafRowsToVsmNodes([row])
    const node = nodes[0] as unknown as Record<string, unknown>
    expect(node[source]).toBeUndefined()
    expect(nodes[0].qafSource?.fields?.[source]).toMatchObject({ mappedTo: null, original: value })
    expect(nodes[0].fieldStatus?.[source as never]).toBeUndefined()
  })
})

describe('cost != time protection (explicit)', () => {
  it('ruestkosten -> setupCostPerUnit, setupTimeSec stays undefined', () => {
    const row = buildQafRow({ prozessbezeichnung: 'Rüsten Fantasiepresse', ruestkosten: 12.5 })
    const { nodes } = mapQafRowsToVsmNodes([row])
    expect(nodes[0].setupCostPerUnit).toBe(12.5)
    expect(nodes[0].setupTimeSec).toBeUndefined()
  })

  it('never sets any time field other than cycleTimeSec, even with every cost field populated', () => {
    const row = buildQafRow({
      prozessbezeichnung: 'Fantasieschritt komplett',
      zykluszeit: 30,
      ruestkosten: 5,
      ausschusskosten: 2,
      mss: 40,
      lohnkosten: 20,
      fk: 15,
      ausschuss: 1.2,
    })
    const { nodes } = mapQafRowsToVsmNodes([row])
    const n = nodes[0]
    expect(n.cycleTimeSec).toBe(30)
    expect(n.setupTimeSec).toBeUndefined()
    expect(n.waitTimeSec).toBeUndefined()
    expect(n.transportTimeSec).toBeUndefined()
    expect(n.machineTimeSec).toBeUndefined()
    expect(n.manualTimeSec).toBeUndefined()
  })
})

describe('AW/BW unit protection (review finding): ausschusskosten is AW, node.currency is BW', () => {
  it('scrapCostPerUnit (ausschusskosten) is AW-denominated and passed through as-is — distinct from node.currency (BW)', () => {
    // Same AW/BW distinction as lib/qaf-differences/internal/reconciliation.ts's
    // fkAW-vs-fk precedent ("ausschusskosten is already AW-only in the row
    // model, no BW counterpart"). node.currency comes from beschaffungswaehrung
    // (BW) and does NOT apply to scrapCostPerUnit — no wechselkurs conversion
    // happens here, ever (fabrication ban, E6). This test is a tripwire: if
    // QAF_FIELD_UNITS.ausschusskosten ever silently changes away from 'AW',
    // or scrapCostPerUnit starts getting converted, this fails.
    const row = buildQafRow({
      prozessbezeichnung: 'Fantasieschritt',
      beschaffungswaehrung: 'EUR',
      ausschusskosten: 3.5,
      mss: 40,
      ruestkosten: 5,
      fk: 8,
      sourceCells: { ausschusskosten: 'Fertigungskosten!V2' },
    })
    const { nodes } = mapQafRowsToVsmNodes([row])
    const node = nodes[0]

    expect(node.scrapCostPerUnit).toBe(3.5)
    expect(node.currency).toBe('EUR') // BW — the procurement currency, not the AW ausschusskosten is denominated in
    expect(node.qafSource?.fields?.ausschusskosten?.unit).toBe('AW')

    // Sanity: the BW-denominated cost fields stay distinct (never 'AW' too).
    expect(node.qafSource?.fields?.mss?.unit).toBe('BW/h')
    expect(node.qafSource?.fields?.ruestkosten?.unit).toBe('BW')
    expect(node.qafSource?.fields?.fk?.unit).toBe('BW')
  })
})

describe('missing-value behaviour — never 0/null-stuffed, reason recorded', () => {
  it('column never located at all -> no_column_mapped', () => {
    const row = buildQafRow({ prozessbezeichnung: 'Fantasieschritt' })
    const { nodes, missingFieldReasons } = mapQafRowsToVsmNodes([row])
    expect('machineHourRate' in nodes[0]).toBe(false)
    expect(nodes[0].machineHourRate).toBeUndefined()
    expect(missingFieldReasons).toContainEqual(
      expect.objectContaining({ field: 'mss', targetField: 'machineHourRate', reason: 'no_column_mapped' }),
    )
  })

  it('column located but cell blank -> cell_empty', () => {
    const row = buildQafRow({ prozessbezeichnung: 'Fantasieschritt', sourceCells: { mss: 'Fertigungskosten!K5' } })
    const { nodes, missingFieldReasons } = mapQafRowsToVsmNodes([row])
    expect(nodes[0].machineHourRate).toBeUndefined()
    expect(missingFieldReasons).toContainEqual(expect.objectContaining({ field: 'mss', reason: 'cell_empty' }))
  })

  it('explicit non-numeric marker (rawText) -> not_numeric[<rawText>]', () => {
    const row = buildQafRow({
      prozessbezeichnung: 'Fantasieschritt',
      sourceCells: { mss: 'Fertigungskosten!K5' },
      rawText: { mss: 'n.a.' },
    })
    const { nodes, missingFieldReasons } = mapQafRowsToVsmNodes([row])
    expect(nodes[0].machineHourRate).toBeUndefined()
    expect(missingFieldReasons).toContainEqual(expect.objectContaining({ field: 'mss', reason: 'not_numeric[n.a.]' }))
    expect(nodes[0].qafSource?.fields?.mss).toMatchObject({ original: null, rawText: 'n.a.' })
  })

  it('below_confidence excludes an otherwise-present value when minConfidence is set', () => {
    const row = buildQafRow({ prozessbezeichnung: 'Fantasieschritt', mss: 40, sourceCells: { mss: 'Fertigungskosten!K5' } })
    const parseMeta = { parseConfidence: 0.9, unmappedHeaders: [], mappedFieldCount: 20 }
    const { nodes, missingFieldReasons } = mapQafRowsToVsmNodes([row], { parseMeta, minConfidence: 0.95 })
    expect(nodes[0].machineHourRate).toBeUndefined()
    expect(missingFieldReasons).toContainEqual(expect.objectContaining({ field: 'mss', reason: 'below_confidence' }))
  })

  it('no minConfidence set (qvs-1 default) -> no cutoff, value passes through regardless of confidence', () => {
    const row = buildQafRow({ prozessbezeichnung: 'Fantasieschritt', mss: 40, sourceCells: { mss: 'Fertigungskosten!K5' } })
    const parseMeta = { parseConfidence: 0.5, unmappedHeaders: [], mappedFieldCount: 5 }
    const { nodes, missingFieldReasons } = mapQafRowsToVsmNodes([row], { parseMeta })
    expect(nodes[0].machineHourRate).toBe(40)
    // Other, unrelated fields on this row legitimately have no_column_mapped
    // (no sourceCells provided for them) — only assert on the field under test.
    expect(missingFieldReasons.find((r) => r.field === 'mss')).toBeUndefined()
  })

  it('never writes 0 for a missing numeric field', () => {
    const row = buildQafRow({ prozessbezeichnung: 'Fantasieschritt' })
    const { nodes } = mapQafRowsToVsmNodes([row])
    expect(nodes[0].scrapRate).not.toBe(0)
    expect(nodes[0].scrapRate).toBeUndefined()
  })
})

describe('row exclusion', () => {
  it('a row without prozessbezeichnung is excluded, not turned into a node', () => {
    const rows = [
      buildQafRow({ prozessbezeichnung: 'Erster Fantasieschritt' }),
      buildQafRow({ prozessbezeichnung: '   ' }),
      buildQafRow({ prozessbezeichnung: 'Dritter Fantasieschritt' }),
    ]
    const { nodes, excludedRows } = mapQafRowsToVsmNodes(rows)
    expect(nodes).toHaveLength(2)
    expect(nodes.map((n) => n.name)).toEqual(['Erster Fantasieschritt', 'Dritter Fantasieschritt'])
    expect(excludedRows).toEqual([{ rowIndex: 1, reason: 'missing_process_name' }])
  })

  it('all rows excluded -> zero nodes, zero connections, no crash', () => {
    const { nodes, connections, excludedRows } = mapQafRowsToVsmNodes([buildQafRow({}), buildQafRow({ prozessbezeichnung: '' })])
    expect(nodes).toEqual([])
    expect(connections).toEqual([])
    expect(excludedRows).toHaveLength(2)
  })

  it('empty input array -> empty result, no crash', () => {
    const result = mapQafRowsToVsmNodes([])
    expect(result).toEqual({ nodes: [], connections: [], warnings: [], excludedRows: [], missingFieldReasons: [] })
  })
})

describe('vaClass classification wiring — never auto-va', () => {
  it('vaClass is derived per row, isValueAdded mirrors it', () => {
    const rows = [buildQafRow({ prozessbezeichnung: 'Bauteil transportieren' }), buildQafRow({ prozessbezeichnung: 'Bauteil schweißen' })]
    const { nodes } = mapQafRowsToVsmNodes(rows)
    expect(nodes[0].vaClass).toBe('nva')
    expect(nodes[0].isValueAdded).toBe(false)
    // "schweißen" (welding) sounds clearly value-adding — the mapper must
    // NEVER auto-classify it as 'va'.
    expect(nodes[1].vaClass).toBe('unknown')
    expect(nodes[1].isValueAdded).toBe(false)
  })

  it('fieldStatus.vaClass is "imported" for every node', () => {
    const { nodes } = mapQafRowsToVsmNodes([buildQafRow({ prozessbezeichnung: 'Fantasieschritt' })])
    expect(nodes[0].fieldStatus?.vaClass).toBe('imported')
  })
})

describe('sequence: row_index primary, positionsnummer secondary evidence', () => {
  it('row_order when positionsnummer agrees with array order', () => {
    const rows = [
      buildQafRow({ prozessbezeichnung: 'A', positionsnummer: '10' }),
      buildQafRow({ prozessbezeichnung: 'B', positionsnummer: '20' }),
    ]
    const { nodes, warnings } = mapQafRowsToVsmNodes(rows)
    expect(nodes[0].qafSource?.sequenceConfidence).toBe('row_order')
    expect(nodes[1].qafSource?.sequenceConfidence).toBe('row_order')
    expect(warnings).toEqual([])
  })

  it('ambiguous + warning on a numeric contradiction, row order still wins for actual placement', () => {
    const rows = [
      buildQafRow({ prozessbezeichnung: 'A', positionsnummer: '30' }),
      buildQafRow({ prozessbezeichnung: 'B', positionsnummer: '10' }),
    ]
    const { nodes, warnings } = mapQafRowsToVsmNodes(rows)
    expect(nodes.map((n) => n.name)).toEqual(['A', 'B']) // source row order kept
    expect(nodes[0].x).toBeLessThan(nodes[1].x) // A still placed before B
    expect(nodes[0].qafSource?.sequenceConfidence).toBe('ambiguous')
    expect(nodes[1].qafSource?.sequenceConfidence).toBe('ambiguous')
    expect(warnings).toHaveLength(1)
    expect(warnings[0].code).toBe('sequence_ambiguous')
  })

  it('non-numeric positionsnummer is ignored for the contradiction check (no false ambiguous)', () => {
    const rows = [
      buildQafRow({ prozessbezeichnung: 'A', positionsnummer: 'Sonderfall' }),
      buildQafRow({ prozessbezeichnung: 'B', positionsnummer: '10' }),
    ]
    const { nodes, warnings } = mapQafRowsToVsmNodes(rows)
    expect(nodes[0].qafSource?.sequenceConfidence).toBe('row_order')
    expect(warnings).toEqual([])
  })

  it('rows excluded for missing process name do not participate in the contradiction check', () => {
    const rows = [
      buildQafRow({ prozessbezeichnung: 'A', positionsnummer: '10' }),
      buildQafRow({ prozessbezeichnung: '', positionsnummer: '999' }), // excluded, must not count
      buildQafRow({ prozessbezeichnung: 'B', positionsnummer: '20' }),
    ]
    const { nodes, warnings } = mapQafRowsToVsmNodes(rows)
    expect(nodes.map((n) => n.name)).toEqual(['A', 'B'])
    expect(warnings).toEqual([])
  })
})

describe('connections — linear i -> i+1', () => {
  it('n nodes produce n-1 connections in order', () => {
    const rows = [buildQafRow({ prozessbezeichnung: 'A' }), buildQafRow({ prozessbezeichnung: 'B' }), buildQafRow({ prozessbezeichnung: 'C' })]
    const { nodes, connections } = mapQafRowsToVsmNodes(rows)
    expect(connections).toHaveLength(2)
    expect(connections[0]).toMatchObject({ fromNodeId: nodes[0].id, toNodeId: nodes[1].id })
    expect(connections[1]).toMatchObject({ fromNodeId: nodes[1].id, toNodeId: nodes[2].id })
  })

  it('a single node produces zero connections', () => {
    const { connections } = mapQafRowsToVsmNodes([buildQafRow({ prozessbezeichnung: 'Solo' })])
    expect(connections).toEqual([])
  })
})

describe('layout — linear x/y chain', () => {
  it('every node has type "process"', () => {
    const rows = [buildQafRow({ prozessbezeichnung: 'A' }), buildQafRow({ prozessbezeichnung: 'B' })]
    const { nodes } = mapQafRowsToVsmNodes(rows)
    expect(nodes.every((n) => n.type === 'process')).toBe(true)
  })

  it('x is strictly ascending, y is constant, spacing = NODE_W + default gap', () => {
    const rows = [buildQafRow({ prozessbezeichnung: 'A' }), buildQafRow({ prozessbezeichnung: 'B' }), buildQafRow({ prozessbezeichnung: 'C' })]
    const { nodes } = mapQafRowsToVsmNodes(rows)
    expect(nodes[0].y).toBe(nodes[1].y)
    expect(nodes[1].y).toBe(nodes[2].y)
    expect(nodes[1].x).toBeGreaterThan(nodes[0].x)
    expect(nodes[2].x).toBeGreaterThan(nodes[1].x)
    expect(nodes[1].x - nodes[0].x).toBe(NODE_W + 40)
    expect(nodes[2].x - nodes[1].x).toBe(NODE_W + 40)
  })

  it('startX/y/xGap are overridable via options', () => {
    const rows = [buildQafRow({ prozessbezeichnung: 'A' }), buildQafRow({ prozessbezeichnung: 'B' })]
    const { nodes } = mapQafRowsToVsmNodes(rows, { startX: 1000, y: 500, xGap: 10 })
    expect(nodes[0].x).toBe(1000)
    expect(nodes[0].y).toBe(500)
    expect(nodes[1].x - nodes[0].x).toBe(NODE_W + 10)
  })
})

describe('lineage completeness', () => {
  it('every set primary field has a qafSource.fields entry with the sourced cell', () => {
    const row = buildQafRow({
      prozessbezeichnung: 'Vollstaendiger Fantasieschritt',
      zykluszeit: 12,
      teileProZyklus: 2,
      anzahlMA: 1,
      bezeichnungAnlage: 'Fantasiepresse 1',
      standort: 'Fantasiewerk A',
      beschaffungswaehrung: 'EUR',
      ausschuss: 0.5,
      ausschusskosten: 0.1,
      mss: 60,
      lohnkosten: 25,
      ruestkosten: 3,
      fk: 8,
      sourceCells: {
        prozessbezeichnung: 'Fertigungskosten!C12',
        zykluszeit: 'Fertigungskosten!G12',
        teileProZyklus: 'Fertigungskosten!H12',
        anzahlMA: 'Fertigungskosten!I12',
        bezeichnungAnlage: 'Fertigungskosten!D12',
        standort: 'Fertigungskosten!E12',
        beschaffungswaehrung: 'Fertigungskosten!F12',
        ausschuss: 'Fertigungskosten!U12',
        ausschusskosten: 'Fertigungskosten!V12',
        mss: 'Fertigungskosten!K12',
        lohnkosten: 'Fertigungskosten!J12',
        ruestkosten: 'Fertigungskosten!M12',
        fk: 'Fertigungskosten!P12',
      },
    })
    const { nodes } = mapQafRowsToVsmNodes([row])
    const node = nodes[0]
    for (const { source, target } of PRIMARY_FIELD_MAPPINGS) {
      expect(node[target], `expected node.${target} to be set`).toBeDefined()
      const entry = node.qafSource?.fields?.[source]
      expect(entry, `missing lineage for source "${source}"`).toBeDefined()
      expect(entry?.cell).toBe(row.sourceCells?.[source])
      expect(entry?.mappedTo).toBe(target)
    }
  })

  it('qafSource carries importId, rowIndex, sheet derived from sourceCells', () => {
    const rows = [
      buildQafRow({ prozessbezeichnung: 'Erster', sourceCells: { prozessbezeichnung: 'Fertigungskosten!C2' } }),
      buildQafRow({ prozessbezeichnung: 'Zweiter', sourceCells: { prozessbezeichnung: 'Fertigungskosten!C3' } }),
    ]
    const { nodes } = mapQafRowsToVsmNodes(rows, { importId: 'fixed-import-id' })
    expect(nodes[0].qafSource?.importId).toBe('fixed-import-id')
    expect(nodes[0].qafSource?.rowIndex).toBe(0)
    expect(nodes[1].qafSource?.rowIndex).toBe(1)
    expect(nodes[0].qafSource?.sheet).toBe('Fertigungskosten')
    expect(nodes[0].qafSource?.manufacturingStepId).toBeNull()
  })

  it('rowIndex reflects the ORIGINAL input array index, not the post-exclusion position', () => {
    const rows = [
      buildQafRow({ prozessbezeichnung: 'A' }),
      buildQafRow({ prozessbezeichnung: '' }), // excluded — index 1 must be skipped, not renumbered away
      buildQafRow({ prozessbezeichnung: 'B' }),
    ]
    const { nodes } = mapQafRowsToVsmNodes(rows)
    expect(nodes.map((n) => n.qafSource?.rowIndex)).toEqual([0, 2])
  })
})

describe('fieldStatus', () => {
  it('is "imported" for name, vaClass, and every set primary field — nothing else', () => {
    const row = buildQafRow({ prozessbezeichnung: 'Fantasieschritt', zykluszeit: 10 })
    const { nodes } = mapQafRowsToVsmNodes([row])
    expect(nodes[0].fieldStatus).toEqual({ name: 'imported', vaClass: 'imported', cycleTimeSec: 'imported' })
  })

  it('a field with no value gets no fieldStatus entry', () => {
    const row = buildQafRow({ prozessbezeichnung: 'Fantasieschritt' })
    const { nodes } = mapQafRowsToVsmNodes([row])
    expect(nodes[0].fieldStatus).toEqual({ name: 'imported', vaClass: 'imported' })
    expect(nodes[0].fieldStatus?.cycleTimeSec).toBeUndefined()
  })
})

describe('idFactory — injectable and deterministic', () => {
  it('produces a fully deterministic id sequence across repeated runs', () => {
    const makeIdFactory = () => {
      let n = 0
      return () => `id-${n++}`
    }
    const rows = [buildQafRow({ prozessbezeichnung: 'A' }), buildQafRow({ prozessbezeichnung: 'B' }), buildQafRow({ prozessbezeichnung: 'C' })]

    const first = mapQafRowsToVsmNodes(rows, { idFactory: makeIdFactory(), importId: 'fixed-import' })
    expect(first.nodes.map((n) => n.id)).toEqual(['id-0', 'id-1', 'id-2'])
    expect(first.connections.map((c) => c.id)).toEqual(['id-3', 'id-4'])
    expect(first.connections[0]).toMatchObject({ fromNodeId: 'id-0', toNodeId: 'id-1' })

    const second = mapQafRowsToVsmNodes(rows, { idFactory: makeIdFactory(), importId: 'fixed-import' })
    expect(second.nodes.map((n) => n.id)).toEqual(first.nodes.map((n) => n.id))
  })

  it('without an explicit importId, idFactory is also used to synthesize one (consumes the first call)', () => {
    const makeIdFactory = () => {
      let n = 0
      return () => `id-${n++}`
    }
    const { nodes } = mapQafRowsToVsmNodes([buildQafRow({ prozessbezeichnung: 'A' })], { idFactory: makeIdFactory() })
    expect(nodes[0].qafSource?.importId).toBe('id-0')
    expect(nodes[0].id).toBe('id-1')
  })

  it('defaults idFactory to crypto.randomUUID (valid, unique UUIDs)', () => {
    const rows = [buildQafRow({ prozessbezeichnung: 'A' }), buildQafRow({ prozessbezeichnung: 'B' })]
    const { nodes } = mapQafRowsToVsmNodes(rows)
    const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
    expect(nodes[0].id).toMatch(uuidRe)
    expect(nodes[0].id).not.toBe(nodes[1].id)
  })
})
