// QAF Matching cascade (KAR-799, spec B3 — the Pflichtkern).
//
// Aligns the process steps of two QAFs OF THE SAME PART NUMBER (ALT vs NEU)
// into a 5-stage cascade. Low confidence / requires_review is the NORMAL case
// for real QAFs and is a RUNTIME review signal, never a development stop (B15).
//
// Distinct from lib/qaf/process-mapping.ts (which maps QAF rows to process_step
// stations). Here we align ALT-QAF steps to NEU-QAF steps. Reuses scoreCandidate
// (Jaccard token overlap) and computeFieldDiff for the cost-divergence trigger.
//
// ── Cross-language (DE vs. EN) matching — audited, unchanged (KAR-905/P3.1) ──
// classifyPair below compares prozessbezeichnung/bezeichnungAnlage/
// teilebenennung via namesEqual()/scoreCandidate() (Jaccard token overlap on
// normalizeProcessName's output) — plain FREETEXT the supplier typed, never
// translated by Kadi-v2 or by BMW's QAF template (the Leitfaden's language // allow-customer-string
// selector, Abbildung 27, only switches the surrounding FIELD LABELS, never
// row-level content the supplier wrote). When ALT is a DE-content file and
// NEU is an EN-content file (or vice versa), a genuinely identical process
// step will almost always carry two DIFFERENT strings in these three fields
// (e.g. "Schweissen"/"Schweisszelle 1" vs. "Welding Station Alpha"/"Weld Cell
// One") — Jaccard token overlap between two different-language strings that
// share no tokens scores at or near 0, exactly like an unrelated pair of DE
// strings would. This is NOT a matcher bug and is deliberately NOT "fixed"
// with any translation/transliteration layer (task instruction: "KEINE
// Übersetzungs-Magie einbauen" — Master-Prompt §2's "never silently guess"
// extends to inventing a cross-language equivalence Kadi-v2 cannot verify).
//
// The cascade already degrades gracefully for exactly this case, unchanged:
// classifyPair's posEqual branch does NOT require name agreement to produce a
// match — same Positionsnummer alone is enough to reach `possible_structure_
// change` (method 'position_divergent', confidence 0.5, requiresReview:
// true) instead of falling through to "new"/"removed" structure findings.
// So a cross-language pair with genuinely untranslated free text still
// matches (via Positionsnummer), just with LOWER confidence and an explicit
// review flag — never a silently wrong high-confidence match, never a false
// "this step disappeared"/"this step is new" structure finding purely
// because the two files are in different languages. If a real file happens
// to reuse the exact same free text on both sides (a plausible case for a
// proper-noun part/machine designation a supplier keeps verbatim across
// language variants), `namesEqual` still resolves it at full confidence —
// the degradation only bites when the text ACTUALLY differs, exactly as it
// should. Verified end-to-end with a synthetic DE/EN workbook pair in
// __tests__/cross-language-e2e.test.ts (both scenarios: genuinely
// untranslated names -> possible_structure_change; coincidentally identical
// names -> safe_match).
//
// Field-level diffs (computeFieldDiff, differ.ts) are unaffected by any of
// this — they compare canonical/numeric QAFRow fields (fk, zykluszeit, …) by
// QAFFieldKey, never by label text, so they are already language-neutral (see
// en-header-verification.test.ts for the header-matching side of that same
// canonical-id-based neutrality).

import type { QAFRow } from '@/lib/qaf-parser'
import { scoreCandidate } from '@/lib/qaf/process-mapping'
import { normalizeProcessName, normalizePosition } from './normalizer'
import { computeFieldDiff } from './differ'
import {
  DEFAULT_MATCH_CONFIG,
  type MatchConfig,
  type MatchMethod,
  type MatchStatus,
  type StepMatch,
} from './types'

function nameKey(v: unknown): string {
  return normalizeProcessName(v)
}

function namesEqual(a: unknown, b: unknown): boolean {
  const ka = nameKey(a)
  return ka !== '' && ka === nameKey(b)
}

function bothBlankNames(a: unknown, b: unknown): boolean {
  return nameKey(a) === '' && nameKey(b) === ''
}

function costDiverges(alt: QAFRow, neu: QAFRow, config: MatchConfig): boolean {
  const d = computeFieldDiff('fk', alt.fk, neu.fk)
  return d.deltaPercent !== null && Math.abs(d.deltaPercent) > config.costDivergenceReviewThreshold
}

function fieldAgreement(
  alt: QAFRow,
  neu: QAFRow,
  simProzess: number,
  simAnlage: number,
  simTeil: number,
  config: MatchConfig,
): { matchedFields: Array<keyof QAFRow>; conflictingFields: Array<keyof QAFRow> } {
  const matched: Array<keyof QAFRow> = []
  const conflicting: Array<keyof QAFRow> = []
  const t = config.similarityThreshold

  const posA = normalizePosition(alt.positionsnummer)
  const posB = normalizePosition(neu.positionsnummer)
  if (posA !== '' && posA === posB) matched.push('positionsnummer')
  else if (posA !== '' && posB !== '') conflicting.push('positionsnummer')

  const sims: Array<[keyof QAFRow, number, unknown, unknown]> = [
    ['prozessbezeichnung', simProzess, alt.prozessbezeichnung, neu.prozessbezeichnung],
    ['bezeichnungAnlage', simAnlage, alt.bezeichnungAnlage, neu.bezeichnungAnlage],
    ['teilebenennung', simTeil, alt.teilebenennung, neu.teilebenennung],
  ]
  for (const [field, sim, a, b] of sims) {
    if (bothBlankNames(a, b)) continue
    if (namesEqual(a, b) || sim >= t) matched.push(field)
    else conflicting.push(field)
  }
  return { matchedFields: matched, conflictingFields: conflicting }
}

interface PairEval {
  status: Extract<MatchStatus, 'safe_match' | 'probable_match' | 'possible_structure_change' | 'candidate_match'>
  method: MatchMethod
  confidence: number
  requiresReview: boolean
  explanation: string
  matchedFields: Array<keyof QAFRow>
  conflictingFields: Array<keyof QAFRow>
  rank: number
}

const BUCKET_WEIGHT: Record<PairEval['status'], number> = {
  safe_match: 400,
  probable_match: 300,
  possible_structure_change: 200,
  candidate_match: 100,
}

function round3(n: number): number {
  return Number(n.toFixed(3))
}

/** Classify one ALT↔NEU pair, or null if not even a candidate. */
function classifyPair(alt: QAFRow, neu: QAFRow, config: MatchConfig): PairEval | null {
  const posA = normalizePosition(alt.positionsnummer)
  const posEqual = posA !== '' && posA === normalizePosition(neu.positionsnummer)

  const simProzess = scoreCandidate(String(alt.prozessbezeichnung ?? ''), String(neu.prozessbezeichnung ?? ''))
  const simAnlage = scoreCandidate(String(alt.bezeichnungAnlage ?? ''), String(neu.bezeichnungAnlage ?? ''))
  const simTeil = scoreCandidate(String(alt.teilebenennung ?? ''), String(neu.teilebenennung ?? ''))
  const t = config.similarityThreshold

  const { matchedFields, conflictingFields } = fieldAgreement(alt, neu, simProzess, simAnlage, simTeil, config)

  const prozessEqual = namesEqual(alt.prozessbezeichnung, neu.prozessbezeichnung)
  const anlageEqual = namesEqual(alt.bezeichnungAnlage, neu.bezeichnungAnlage)
  const teilOk = namesEqual(alt.teilebenennung, neu.teilebenennung) || bothBlankNames(alt.teilebenennung, neu.teilebenennung)

  const rawIdentical =
    alt.positionsnummer === neu.positionsnummer &&
    alt.prozessbezeichnung === neu.prozessbezeichnung &&
    alt.bezeichnungAnlage === neu.bezeichnungAnlage

  if (posEqual) {
    if (prozessEqual && anlageEqual && teilOk) {
      return {
        status: 'safe_match',
        method: rawIdentical ? 'exact' : 'normalized_exact',
        confidence: 1,
        requiresReview: false,
        explanation: 'Position, Prozess und Anlage identisch.',
        matchedFields,
        conflictingFields,
        rank: BUCKET_WEIGHT.safe_match + 10,
      }
    }
    if (simProzess >= t || simAnlage >= t || prozessEqual || anlageEqual) {
      const confidence = round3(Math.min(0.9, 0.7 + 0.2 * Math.max(simProzess, simAnlage)))
      return {
        status: 'probable_match',
        method: 'position_fuzzy',
        confidence,
        requiresReview: costDiverges(alt, neu, config),
        explanation: 'Gleiche Position, Prozess oder Anlage ähnlich.',
        matchedFields,
        conflictingFields,
        rank: BUCKET_WEIGHT.probable_match + confidence * 10,
      }
    }
    return {
      status: 'possible_structure_change',
      method: 'position_divergent',
      confidence: 0.5,
      requiresReview: true,
      explanation: 'Gleiche Position, aber Prozess und Anlage stark abweichend — mögliche Strukturänderung.',
      matchedFields,
      conflictingFields,
      rank: BUCKET_WEIGHT.possible_structure_change + 5,
    }
  }

  // No position equality — only a candidate if process AND machine are similar.
  if (simProzess >= config.candidateThreshold && simAnlage >= config.candidateThreshold) {
    const confidence = round3(0.4 + 0.3 * ((simProzess + simAnlage) / 2))
    return {
      status: 'candidate_match',
      method: 'fuzzy_no_position',
      confidence,
      requiresReview: true,
      explanation: 'Keine Positionsgleichheit, aber Prozess und Anlage ähnlich — vor Übernahme bestätigen.',
      matchedFields,
      conflictingFields,
      rank: BUCKET_WEIGHT.candidate_match + confidence * 10,
    }
  }

  return null
}

/**
 * Match ALT steps to NEU steps via the 5-stage cascade. Returns one StepMatch
 * per matched pair plus a new_step / removed_step for every unmatched index, so
 * every alt and neu index is covered exactly once. Greedy best-first by stage &
 * confidence; deterministic tie-break by (altIndex, neuIndex).
 */
export function matchSteps(
  altSteps: QAFRow[],
  neuSteps: QAFRow[],
  config: MatchConfig = DEFAULT_MATCH_CONFIG,
): StepMatch[] {
  const candidates: Array<{ ai: number; bi: number; ev: PairEval }> = []
  for (let ai = 0; ai < altSteps.length; ai++) {
    for (let bi = 0; bi < neuSteps.length; bi++) {
      const ev = classifyPair(altSteps[ai], neuSteps[bi], config)
      if (ev) candidates.push({ ai, bi, ev })
    }
  }

  candidates.sort((x, y) => y.ev.rank - x.ev.rank || x.ai - y.ai || x.bi - y.bi)

  const usedAlt = new Set<number>()
  const usedNeu = new Set<number>()
  const out: StepMatch[] = []

  for (const { ai, bi, ev } of candidates) {
    if (usedAlt.has(ai) || usedNeu.has(bi)) continue
    usedAlt.add(ai)
    usedNeu.add(bi)
    out.push({
      altIndex: ai,
      neuIndex: bi,
      matchStatus: ev.status,
      confidenceScore: ev.confidence,
      matchMethod: ev.method,
      matchedFields: ev.matchedFields,
      conflictingFields: ev.conflictingFields,
      explanation: ev.explanation,
      requiresReview: ev.requiresReview,
    })
  }

  for (let bi = 0; bi < neuSteps.length; bi++) {
    if (usedNeu.has(bi)) continue
    out.push({
      altIndex: null,
      neuIndex: bi,
      matchStatus: 'new_step',
      confidenceScore: 1,
      matchMethod: 'none',
      matchedFields: [],
      conflictingFields: [],
      explanation: 'Prozessschritt nur in NEU vorhanden — neuer Schritt.',
      requiresReview: false,
    })
  }

  for (let ai = 0; ai < altSteps.length; ai++) {
    if (usedAlt.has(ai)) continue
    out.push({
      altIndex: ai,
      neuIndex: null,
      matchStatus: 'removed_step',
      confidenceScore: 1,
      matchMethod: 'none',
      matchedFields: [],
      conflictingFields: [],
      explanation: 'Prozessschritt nur in ALT vorhanden — entfallen.',
      requiresReview: false,
    })
  }

  return out.sort((a, b) => {
    const an = a.neuIndex ?? Number.MAX_SAFE_INTEGER
    const bn = b.neuIndex ?? Number.MAX_SAFE_INTEGER
    if (an !== bn) return an - bn
    return (a.altIndex ?? Number.MAX_SAFE_INTEGER) - (b.altIndex ?? Number.MAX_SAFE_INTEGER)
  })
}


// ── Manual pins (KAR-845): user-fixed alt↔neu pairs override the cascade ──────

export interface ManualPin {
  altIndex: number
  /** null = the user explicitly marked the ALT step as unmatched/removed. */
  neuIndex: number | null
}

/**
 * matchSteps with user-pinned pairs: every valid pin becomes a 'manual'
 * safe_match with full confidence, the pinned steps are removed from the
 * automatic cascade (no double use), and the cascade's indices are mapped
 * back to the original arrays. Invalid/duplicate pins are ignored.
 */
export function matchStepsWithPins(
  altSteps: QAFRow[],
  neuSteps: QAFRow[],
  pins: ManualPin[],
  config: MatchConfig = DEFAULT_MATCH_CONFIG,
): StepMatch[] {
  const usedAlt = new Set<number>()
  const usedNeu = new Set<number>()
  const accepted: ManualPin[] = []
  for (const p of pins) {
    const altOk = p.altIndex >= 0 && p.altIndex < altSteps.length
    const neuOk = p.neuIndex === null || (p.neuIndex >= 0 && p.neuIndex < neuSteps.length)
    if (!altOk || !neuOk || usedAlt.has(p.altIndex) || (p.neuIndex !== null && usedNeu.has(p.neuIndex))) continue
    accepted.push(p)
    usedAlt.add(p.altIndex)
    if (p.neuIndex !== null) usedNeu.add(p.neuIndex)
  }

  const altRest = altSteps.map((s, i) => ({ s, i })).filter((x) => !usedAlt.has(x.i))
  const neuRest = neuSteps.map((s, i) => ({ s, i })).filter((x) => !usedNeu.has(x.i))
  const auto = matchSteps(
    altRest.map((x) => x.s),
    neuRest.map((x) => x.s),
    config,
  ).map((match) => ({
    ...match,
    altIndex: match.altIndex === null ? null : altRest[match.altIndex].i,
    neuIndex: match.neuIndex === null ? null : neuRest[match.neuIndex].i,
  }))

  const manual: StepMatch[] = accepted.map((p) => ({
    altIndex: p.altIndex,
    neuIndex: p.neuIndex,
    matchStatus: p.neuIndex === null ? ('removed_step' as const) : ('safe_match' as const),
    confidenceScore: 1,
    matchMethod: 'manual' as const,
    matchedFields: [],
    conflictingFields: [],
    explanation: p.neuIndex === null ? 'Manuell als entfallen markiert' : 'Manuell zugeordnet',
    requiresReview: false,
  }))
  return [...manual, ...auto]
}
