/**
 * QAF → VsmNode mapper (QVS-P1, KAR-970).
 *
 * Pure function: `QAFRow[]` in, `{nodes, connections, warnings, excludedRows,
 * missingFieldReasons}` out. No I/O, no Supabase — DB wiring is P2
 * (reports/qaf-value-stream-architecture.md §7). Rules below implement
 * `mappings/qaf-to-value-stream-mapping.yaml` (MAPPING_VERSION = 'qvs-1',
 * the versioned, verbindlich contract — kept in sync by
 * `mapping-drift.test.ts`).
 *
 * Rule summary (full rationale: reports/qaf-value-stream-field-mapping.md):
 *   - 14 primary mappings (name + sequence + 12 value fields), 8 metadata-only
 *     (land ONLY in qafSource.fields, never as a node field).
 *   - A row with no `prozessbezeichnung` is excluded, never turned into a node.
 *   - Sequence = input array order (row_index); `positionsnummer` is
 *     secondary evidence only — a numeric contradiction downgrades
 *     `sequenceConfidence` to 'ambiguous' (+ a warning) but never reorders.
 *   - Missing values are omitted (never 0/null-stuffed) — reason recorded in
 *     `missingFieldReasons`.
 *   - Cost fields are NEVER written to time fields (`ruestkosten` ≠
 *     `setupTimeSec`) — this mapper simply never touches
 *     setupTimeSec/waitTimeSec/transportTimeSec/machineTimeSec/manualTimeSec
 *     at all, so there is nothing to get wrong.
 *   - Every node gets `type: 'process'`, a linear x/y layout, and linear
 *     i→i+1 connections.
 */

import { TEXT_FIELDS, type QAFFieldKey, type QAFRow } from '@/lib/qaf-parser'
import type { VsmConnection, VsmNode, VsmNodeQafSourceField, VsmNodeEditableFieldKey } from '@/lib/vsm-types'
import { NODE_W } from '@/components/wertstrom/vsm-geometry'
import { conservativeVaClass } from './va-classification'
import type { ExcludedRow, MapperOptions, MapperResult, MissingFieldReason, MissingFieldReasonCode, QvsWarning } from './types'

/** Versioned mapping contract identifier — must equal `version:` in
 * mappings/qaf-to-value-stream-mapping.yaml (guarded by mapping-drift.test.ts). */
export const MAPPING_VERSION = 'qvs-1' as const

const DEFAULT_START_X = 40
const DEFAULT_Y = 200
/** Added to NODE_W (components/wertstrom/vsm-geometry.ts) for node spacing. */
const DEFAULT_X_GAP = 40

const PROCESS_NAME_FIELD: QAFFieldKey = 'prozessbezeichnung'
const SEQUENCE_FIELD: QAFFieldKey = 'positionsnummer'

/** The VsmNode field one primary QAF value field maps to. */
type PrimaryTarget =
  | 'cycleTimeSec'
  | 'partsPerCycle'
  | 'numWorkers'
  | 'machineType'
  | 'location'
  | 'currency'
  | 'scrapRate'
  | 'scrapCostPerUnit'
  | 'machineHourRate'
  | 'laborHourRate'
  | 'setupCostPerUnit'
  | 'costPerUnit'

interface PrimaryFieldMapping {
  source: QAFFieldKey
  target: PrimaryTarget
  /** Documentation only — the real presence check uses TEXT_FIELDS
   * (lib/qaf-parser.ts) as the single source of truth; kept here so a unit
   * test can assert the two never drift apart. */
  kind: 'number' | 'text'
}

/** The 12 value-bearing primary mappings (source → VsmNode field). `name`
 * (prozessbezeichnung) and the sequence (positionsnummer) are handled
 * separately below — together that's the 14 primary mappings from the
 * field-mapping report. Order matches the YAML/report table. */
export const PRIMARY_FIELD_MAPPINGS: readonly PrimaryFieldMapping[] = [
  { source: 'zykluszeit', target: 'cycleTimeSec', kind: 'number' },
  { source: 'teileProZyklus', target: 'partsPerCycle', kind: 'number' },
  { source: 'anzahlMA', target: 'numWorkers', kind: 'number' },
  { source: 'bezeichnungAnlage', target: 'machineType', kind: 'text' },
  { source: 'standort', target: 'location', kind: 'text' },
  { source: 'beschaffungswaehrung', target: 'currency', kind: 'text' },
  { source: 'ausschuss', target: 'scrapRate', kind: 'number' },
  // ausschusskosten is AW (Angebotswährung/quotation currency, QAF_FIELD_UNITS
  // below) — NOT node.currency, which is BW (beschaffungswaehrung). Same
  // "cost ≠ node semantics" callout discipline as ruestkosten's cost≠time
  // guard above: this is a cost≠currency guard. No wechselkurs conversion
  // happens here — never silently converted (fabrication ban, E6).
  { source: 'ausschusskosten', target: 'scrapCostPerUnit', kind: 'number' },
  { source: 'mss', target: 'machineHourRate', kind: 'number' },
  // lohnkosten is a RATE [BW/h], exactly like mss/machineHourRate above — NOT
  // a per-unit cost. Per-unit would require an FEK-style conversion (cycle
  // time, parts/cycle, SGK — canonical-fields.ts mfg_direct_manufacturing_cost
  // notes), which this mapper never does silently.
  { source: 'lohnkosten', target: 'laborHourRate', kind: 'number' },
  { source: 'ruestkosten', target: 'setupCostPerUnit', kind: 'number' },
  { source: 'fk', target: 'costPerUnit', kind: 'number' },
] as const

/** The 8 metadata-only source fields — retained ONLY in qafSource.fields
 * (mappedTo: null), never written onto a VsmNode field. */
export const METADATA_ONLY_FIELDS: readonly QAFFieldKey[] = [
  'teilebenennung',
  'lohnzuschlagssaetze',
  'fek',
  'rfgk',
  'angebotswaehrung',
  'wechselkurs',
  'anzahlProAngebotsteil',
  'fkAW',
] as const

/** All 22 QAFRowValues keys covered by qvs-1 — used to build the full
 * qafSource.fields provenance map (mapping-drift.test.ts asserts this list
 * has exactly 22 entries matching the YAML). */
const ALL_FIELD_KEYS: readonly QAFFieldKey[] = [
  PROCESS_NAME_FIELD,
  SEQUENCE_FIELD,
  ...PRIMARY_FIELD_MAPPINGS.map((m) => m.source),
  ...METADATA_ONLY_FIELDS,
]

/** `mappedTo` value per source field for qafSource.fields — a VSM field name
 * for primary fields, `null` for the sequence field (positionsnummer sets no
 * discrete node field, see module header) and the 8 metadata-only fields. */
const FIELD_MAPPED_TO: Partial<Record<QAFFieldKey, string>> = Object.fromEntries(
  PRIMARY_FIELD_MAPPINGS.map((m) => [m.source, m.target] as const),
)
FIELD_MAPPED_TO[PROCESS_NAME_FIELD] = 'name'

/** Unit per the MANUFACTURING canonical registry (canonical-fields.ts) —
 * fixed for qvs-1 (field-mapping report: "keine stille Einheiten-Heuristik"). */
const QAF_FIELD_UNITS: Partial<Record<QAFFieldKey, string>> = {
  zykluszeit: 's',
  teileProZyklus: 'Stück',
  anzahlMA: 'Stück',
  lohnkosten: 'BW/h',
  lohnzuschlagssaetze: '%',
  mss: 'BW/h',
  ruestkosten: 'BW',
  fek: 'BW',
  rfgk: 'BW/h',
  fk: 'BW',
  wechselkurs: 'AW/BW',
  anzahlProAngebotsteil: 'Stück',
  fkAW: 'AW',
  ausschuss: '%',
  ausschusskosten: 'AW',
}

interface FieldPresence {
  present: boolean
  value: string | number | null
  rawText: string | null
}

/** TEXT_FIELDS (lib/qaf-parser.ts) is the single source of truth for which
 * QAFFieldKeys are text vs. numeric — reused here, not re-derived, so this
 * module can never silently drift from the parser's own classification. */
function fieldPresence(row: QAFRow, key: QAFFieldKey): FieldPresence {
  const raw = row[key]
  const rawText = row.rawText?.[key] ?? null
  if (TEXT_FIELDS.has(key)) {
    const s = typeof raw === 'string' ? raw.trim() : ''
    return { present: s !== '', value: s === '' ? null : s, rawText }
  }
  const n = typeof raw === 'number' ? raw : null
  return { present: n !== null, value: n, rawText }
}

function sourceCell(row: QAFRow, key: QAFFieldKey): string | null {
  return row.sourceCells?.[key] ?? null
}

/** Reason a primary field's value did not land on the node — `null` means
 * the value IS set (present, and not filtered by minConfidence). Single
 * source of truth for the missing-value decision (field-mapping report's 4
 * fixed reason codes). */
function missingReasonFor(
  row: QAFRow,
  key: QAFFieldKey,
  confidence: number | null,
  minConfidence: number | undefined,
): MissingFieldReasonCode | null {
  const { present, rawText } = fieldPresence(row, key)
  if (present) {
    if (minConfidence !== undefined && confidence !== null && confidence < minConfidence) return 'below_confidence'
    return null
  }
  if (rawText) return `not_numeric[${rawText}]`
  return sourceCell(row, key) === null ? 'no_column_mapped' : 'cell_empty'
}

/** One qafSource.fields[key] entry, or null when there is genuinely nothing
 * to attach (no cell reference, no value, no raw text) — never fabricated. */
function buildFieldEntry(row: QAFRow, key: QAFFieldKey, confidence: number | null): VsmNodeQafSourceField | null {
  const { present, value, rawText } = fieldPresence(row, key)
  const cell = sourceCell(row, key)
  if (cell === null && !present && rawText === null) return null
  return {
    cell,
    original: present ? value : null,
    rawText: rawText ?? null,
    unit: QAF_FIELD_UNITS[key] ?? null,
    confidence,
    mappedTo: FIELD_MAPPED_TO[key] ?? null,
  }
}

function buildFieldsMap(row: QAFRow, confidence: number | null): Record<string, VsmNodeQafSourceField> {
  const out: Record<string, VsmNodeQafSourceField> = {}
  for (const key of ALL_FIELD_KEYS) {
    const entry = buildFieldEntry(row, key, confidence)
    if (entry) out[key] = entry
  }
  return out
}

/** Sheet name derived from any available sourceCells reference on the row
 * ("Sheet!A1" — split at the LAST '!' since column/row refs never contain
 * one). Null when the row carries no cell provenance at all (e.g. rehydrated
 * from pre-KAR-886 raw_values). */
function deriveSheetName(row: QAFRow): string | null {
  const cells = row.sourceCells ? Object.values(row.sourceCells) : []
  const anyCell = cells.find((c): c is string => !!c)
  if (!anyCell) return null
  const idx = anyCell.lastIndexOf('!')
  return idx >= 0 ? anyCell.slice(0, idx) : null
}

function parseNumericPosition(raw: string): number | null {
  const trimmed = raw.trim()
  if (!trimmed) return null
  const n = Number(trimmed.replace(',', '.'))
  return Number.isFinite(n) ? n : null
}

interface IncludedRow {
  rowIndex: number
  row: QAFRow
}

/** Primary sequence is always input array order (row_index) — this only
 * decides the DISPLAY confidence: 'row_order' when positionsnummer (where
 * parseable) agrees with array order, 'ambiguous' (+ a warning) on the first
 * numeric contradiction. Rows whose positionsnummer isn't parseable as a
 * number are skipped in the comparison (neither confirm nor contradict). */
function assessSequence(included: readonly IncludedRow[]): { sequenceConfidence: 'row_order' | 'ambiguous'; warning: QvsWarning | null } {
  const comparable = included
    .map(({ rowIndex, row }) => ({ rowIndex, pos: parseNumericPosition(row.positionsnummer) }))
    .filter((e): e is { rowIndex: number; pos: number } => e.pos !== null)

  for (let i = 1; i < comparable.length; i++) {
    if (comparable[i].pos < comparable[i - 1].pos) {
      return {
        sequenceConfidence: 'ambiguous',
        warning: {
          code: 'sequence_ambiguous',
          message: `positionsnummer (${comparable[i].pos}) at row_index ${comparable[i].rowIndex} is lower than positionsnummer (${comparable[i - 1].pos}) at the preceding row_index ${comparable[i - 1].rowIndex} — source row order (row_index) is kept as the primary sequence.`,
          rowIndex: comparable[i].rowIndex,
        },
      }
    }
  }
  return { sequenceConfidence: 'row_order', warning: null }
}

/** Table-driven assignment across heterogeneous optional VsmNode fields.
 * Safe by construction: `value`'s runtime type always matches `target`'s
 * declared type, because `fieldPresence` branches on the same TEXT_FIELDS
 * set that determines each mapping's `kind` (asserted in mapper.test.ts). */
function assignPrimaryField(node: VsmNode, target: PrimaryTarget, value: string | number): void {
  ;(node as unknown as Record<PrimaryTarget, string | number>)[target] = value
}

export function mapQafRowsToVsmNodes(rows: QAFRow[], opts: MapperOptions = {}): MapperResult {
  const idFactory = opts.idFactory ?? ((): string => crypto.randomUUID())
  const importId = opts.importId ?? idFactory()
  const startX = opts.startX ?? DEFAULT_START_X
  const y = opts.y ?? DEFAULT_Y
  const xGap = opts.xGap ?? DEFAULT_X_GAP
  const spacing = NODE_W + xGap
  const confidence = opts.parseMeta ? opts.parseMeta.parseConfidence : null
  const minConfidence = opts.minConfidence

  const excludedRows: ExcludedRow[] = []
  const included: IncludedRow[] = []
  rows.forEach((row, rowIndex) => {
    if (row.prozessbezeichnung?.trim()) {
      included.push({ rowIndex, row })
    } else {
      excludedRows.push({ rowIndex, reason: 'missing_process_name' })
    }
  })

  const { sequenceConfidence, warning: sequenceWarning } = assessSequence(included)
  const warnings: QvsWarning[] = sequenceWarning ? [sequenceWarning] : []
  const missingFieldReasons: MissingFieldReason[] = []

  const nodes: VsmNode[] = included.map(({ rowIndex, row }, idx) => {
    const id = idFactory()
    const name = row.prozessbezeichnung.trim()
    const vaClass = conservativeVaClass(name)
    const fieldStatus: Partial<Record<VsmNodeEditableFieldKey, 'imported' | 'modified'>> = {
      name: 'imported',
      vaClass: 'imported',
    }

    const node: VsmNode = {
      id,
      type: 'process',
      x: startX + idx * spacing,
      y,
      name,
      vaClass,
      isValueAdded: vaClass === 'va',
    }

    for (const mapping of PRIMARY_FIELD_MAPPINGS) {
      const reason = missingReasonFor(row, mapping.source, confidence, minConfidence)
      if (reason === null) {
        const { value } = fieldPresence(row, mapping.source)
        // reason === null guarantees `present`, so value is never null here.
        assignPrimaryField(node, mapping.target, value as string | number)
        fieldStatus[mapping.target] = 'imported'
      } else {
        missingFieldReasons.push({ rowIndex, nodeId: id, field: mapping.source, targetField: mapping.target, reason })
      }
    }

    node.fieldStatus = fieldStatus
    node.qafSource = {
      importId,
      sheet: deriveSheetName(row),
      rowIndex,
      positionNumber: row.positionsnummer?.trim() || null,
      manufacturingStepId: null,
      fields: buildFieldsMap(row, confidence),
      sequenceConfidence,
    }

    return node
  })

  const connections: VsmConnection[] = []
  for (let i = 0; i < nodes.length - 1; i++) {
    connections.push({ id: idFactory(), fromNodeId: nodes[i].id, toNodeId: nodes[i + 1].id })
  }

  return { nodes, connections, warnings, excludedRows, missingFieldReasons }
}
