// QAF Plausibility check (KAR-799, spec B12).
//
// Deterministic sanity checks across the summary identity and the process steps
// of an ALT/NEU comparison. Each finding carries a severity so the UI and export
// can surface "Hinweis / Prüfen / Kritisch". Pure.
//
// Net-new. Some summary-metric checks (e.g. FK detail sum vs summary total) need
// the summary-metric parser and are deferred to a later step.

import type { QAFRow } from '@/lib/qaf-parser'
import { normalizeProcessName, normalizeCurrency, isBlank } from './normalizer'
import type { QafSummary } from './types'

export type PlausibilitySeverity = 'hinweis' | 'pruefen' | 'kritisch'

export interface PlausibilityIssue {
  type: string
  severity: PlausibilitySeverity
  field?: string
  step?: string
  /** DE — always present, persisted verbatim (or bilingual-encoded, see
   * bilingual-message.ts) into qaf_plausibility_issue.explanation. */
  explanation: string
  /**
   * EN counterpart of `explanation` (KAR-906/P3.2). Optional — omitted for
   * issues that have not been translated yet; the UI/export fall back to
   * `explanation` (DE) in that case ("Bestands-Issues ohne EN tolerant").
   * Persisted alongside `explanation` via bilingual-message.ts's
   * encodeBilingual, since qaf_plausibility_issue has a single TEXT column
   * (no schema change).
   */
  explanationEn?: string
}

export interface PlausibilityInput {
  altSummary: QafSummary
  neuSummary: QafSummary
  altSteps: QAFRow[]
  neuSteps: QAFRow[]
}

/** Cost/physical fields that must never be negative. */
const NON_NEGATIVE_FIELDS: ReadonlyArray<keyof QAFRow> = [
  'fk',
  'fkAW',
  'lohnkosten',
  'mss',
  'ruestkosten',
  'fek',
  'rfgk',
  'ausschusskosten',
  'zykluszeit',
  'anzahlMA',
  'teileProZyklus',
]

function bothPresentAndDiffer(a: string | null, b: string | null): boolean {
  if (isBlank(a) || isBlank(b)) return false
  return normalizeProcessName(a) !== normalizeProcessName(b)
}

function stepLabel(row: QAFRow): string {
  const pos = String(row.positionsnummer ?? '').trim()
  const name = String(row.prozessbezeichnung ?? '').trim()
  return [pos, name].filter(Boolean).join(' ') || '(unbenannt)'
}

function distinctCurrencies(steps: QAFRow[]): Set<string> {
  const out = new Set<string>()
  for (const s of steps) {
    const c = normalizeCurrency(s.angebotswaehrung)
    if (c) out.add(c)
  }
  return out
}

function setsDiffer(a: Set<string>, b: Set<string>): boolean {
  if (a.size !== b.size) return true
  for (const x of a) if (!b.has(x)) return true
  return false
}

// ── Fertigungskosten-Parser-Degradation bridge (KAR-893 / P1.2) ─────────────
//
// qaf-parser.ts's parseQAFTemplate() now has a degradation path: below the
// core-field minimum it throws (unchanged failure mode), but ABOVE it, a
// header with unmapped columns parses anyway and reports the gap via
// QAFParseMeta (parseConfidence/unmappedHeaders/mappedFieldCount) instead of
// silently pretending the template was intact. This bridge turns that
// diagnostic into the same qaf_plausibility_issue channel every other check
// in this file/module uses — same additive, no-schema-change pattern as
// reconciliation.ts's reconciliationResultToPlausibilityIssue and
// template-fingerprint.ts's templateFingerprintToPlausibilityIssue.
//
// Deliberately takes the meta as a plain object (not importing QAFParseMeta
// itself) — plausibility.ts already imports QAFRow from qaf-parser.ts, this
// avoids growing that surface further for a 3-field shape.
export interface ManufacturingParseDegradation {
  parseConfidence: number
  unmappedHeaders: readonly string[]
  mappedFieldCount: number
  /**
   * KAR-927 (Multi-QAF-Programm P0.2, "Kandidaten-Sichtbarkeit an .find()-
   * Kollaps-Stellen") — sheet names of every OTHER worksheet that also
   * matched the MANUFACTURING sheet-name predicate but was NOT parsed (see
   * qaf-parser.ts's QAFParseMeta.ignoredCandidateSheets doc comment for the
   * full rationale). `undefined`/omitted for the overwhelming common case
   * (at most one matching sheet) — see manufacturingIgnoredCandidatesToPlausibilityIssue
   * below for how this turns into a visible finding.
   */
  ignoredCandidateSheets?: readonly string[]
}

/** Pure. Returns null when nothing was lost (unmappedHeaders empty) — a
 * clean parse produces no issue, exactly like every other check here. */
export function manufacturingParseDegradationToPlausibilityIssue(
  meta: ManufacturingParseDegradation,
  side: 'ALT' | 'NEU',
): PlausibilityIssue | null {
  if (meta.unmappedHeaders.length === 0) return null
  return {
    type: 'parser_degraded_manufacturing_headers',
    severity: 'pruefen',
    step: side,
    explanation: `Fertigungskosten-Header nur teilweise erkannt (${side}, ${meta.mappedFieldCount}/22 Spalten zugeordnet, Confidence ${meta.parseConfidence.toFixed(2)}) — nicht zugeordnete Spalten: ${meta.unmappedHeaders.join(', ')}.`,
    explanationEn: `Manufacturing cost header only partially recognized (${side}, ${meta.mappedFieldCount}/22 columns mapped, confidence ${meta.parseConfidence.toFixed(2)}) — unmapped columns: ${meta.unmappedHeaders.join(', ')}.`,
  }
}

/**
 * KAR-927 sibling bridge to manufacturingParseDegradationToPlausibilityIssue
 * above, for a distinct signal: `.find()` on the MANUFACTURING sheet name
 * only ever returns the FIRST matching worksheet — this reports the OTHER
 * worksheet name(s) that also matched but were silently discarded. Kept as
 * its own function (not folded into the degradation bridge above) because
 * the two signals are independent — a file can trip either, neither, or
 * both — and the original function/its explanation text stay byte-identical
 * for every existing caller/test.
 *
 * Pure. Returns null when there is nothing to report (0 or 1 candidate —
 * the standard-QAF case, never emitted as noise).
 */
export function manufacturingIgnoredCandidatesToPlausibilityIssue(
  meta: Pick<ManufacturingParseDegradation, 'ignoredCandidateSheets'>,
  side: 'ALT' | 'NEU',
): PlausibilityIssue | null {
  const ignored = meta.ignoredCandidateSheets
  if (!ignored || ignored.length === 0) return null
  return {
    type: 'parser_ignored_manufacturing_candidate_sheets',
    severity: 'pruefen',
    step: side,
    explanation: `Mehrere Fertigungskosten-Kandidaten-Sheets im Workbook gefunden (${side}) — nur das erste passende Sheet wird ausgewertet, ignoriert: ${ignored.join(', ')}.`,
    explanationEn: `Multiple Manufacturing-cost candidate sheets found in the workbook (${side}) — only the first matching sheet is evaluated, ignored: ${ignored.join(', ')}.`,
  }
}

/**
 * The summary-IDENTITY-only subset of checkPlausibility's checks
 * (part_number_mismatch / part_name_changed / variant_changed /
 * quotation_date_order) — extracted (KAR-943 / Multi-QAF-Programm P3.2) so
 * `variant-vs-standard.ts` can reuse EXACTLY this logic for a comparison side
 * that has no `steps` at all (a Multi-QAF virtual variant — Master-Prompt §12:
 * "Multi-QAF manufacturing logic may use shared profiles instead of one
 * independent process list per variant"). The currency/negative-cost/
 * zero-cost checks below this point all read `altSteps`/`neuSteps` — calling
 * the FULL checkPlausibility with a fabricated `steps: []` on one side would
 * make `distinctCurrencies([])` differ from the real side's currencies on
 * EVERY SINGLE comparison, firing a spurious `currency_change` finding that
 * has nothing to do with an actual currency mismatch (there is no step-level
 * currency source for a virtual variant at all, see bridge.ts). This function
 * is the honest boundary: identity checks operate purely on `QafSummary`
 * fields that BOTH a real file and a bridged virtual variant genuinely carry
 * (bridge.ts's `virtualVariantSummary`), so they stay real reuse rather than a
 * duplicated copy — `checkPlausibility` itself calls this function first,
 * unchanged behavior/order for every existing caller.
 */
export function checkSummaryIdentityPlausibility(altSummary: QafSummary, neuSummary: QafSummary): PlausibilityIssue[] {
  const issues: PlausibilityIssue[] = []

  const altPn = altSummary.partNumber.value
  const neuPn = neuSummary.partNumber.value
  if (bothPresentAndDiffer(altPn, neuPn)) {
    issues.push({
      type: 'part_number_mismatch',
      severity: 'kritisch',
      field: 'partNumber',
      explanation: `Sachnummern unterschiedlich (ALT ${altPn} vs NEU ${neuPn}) — dürfen nicht verglichen werden.`,
      explanationEn: `Part numbers differ (ALT ${altPn} vs NEU ${neuPn}) — must not be compared.`,
    })
  }

  if (bothPresentAndDiffer(altSummary.partName.value, neuSummary.partName.value)) {
    issues.push({
      type: 'part_name_changed',
      severity: 'hinweis',
      field: 'partName',
      explanation: 'Teilebenennung weicht zwischen ALT und NEU ab.',
      explanationEn: 'Part name differs between ALT and NEU.',
    })
  }

  if (bothPresentAndDiffer(altSummary.variant.value, neuSummary.variant.value)) {
    issues.push({
      type: 'variant_changed',
      severity: 'hinweis',
      field: 'variant',
      explanation: 'Variante weicht zwischen ALT und NEU ab.',
      explanationEn: 'Variant differs between ALT and NEU.',
    })
  }

  const altDate = Date.parse(altSummary.quotationDate.value ?? '')
  const neuDate = Date.parse(neuSummary.quotationDate.value ?? '')
  if (!Number.isNaN(altDate) && !Number.isNaN(neuDate) && neuDate < altDate) {
    issues.push({
      type: 'quotation_date_order',
      severity: 'pruefen',
      field: 'quotationDate',
      explanation: 'NEU hat ein früheres Angebotsdatum als ALT — Baseline-Reihenfolge prüfen.',
      explanationEn: 'NEU has an earlier quotation date than ALT — review the baseline order.',
    })
  }

  // ── D16 identity_mismatch (Spec-Erhebung 08.08.2026) ────────────────────────
  //
  // Die Spezifikation fordert D16 als P0, definiert es aber nirgends (Block-6-
  // Erhebung, User-Guide-PDF geprüft). PO-Linie (Kais, 08.08.2026, TG 9970/9971):
  // weiche Befunde statt harter Gates — Identitäts-Abweichungen werden gemeldet,
  // beide Werte als Evidenz, die Paarung läuft IMMER weiter. Die vier Felder
  // ergänzen die oben gewachsenen Checks (partNumber/partName/variant) um die
  // restlichen Identitätsfelder des Summary-Kopfs. Vokabular folgt dem Bestand:
  // `_mismatch` für „gehört das zusammen?" (Lieferant), `_changed` für „hat sich
  // zwischen den Ständen geändert" (Anfrageversion, Änderungsindex — zwischen
  // ALT und NEU legitim beweglich, deshalb nur Hinweis). supplier und supplierNo
  // bleiben getrennt: Name kann ohne Nummer wechseln (Umfirmierung) und
  // umgekehrt (anderes Werk) — zwei unterscheidbare Evidenzen.
  const identityFields: ReadonlyArray<{
    key: 'supplier' | 'supplierNo' | 'requestVersion' | 'changeIndex'
    type: string
    severity: PlausibilitySeverity
    labelDe: string
    labelEn: string
  }> = [
    { key: 'supplier', type: 'supplier_mismatch', severity: 'pruefen', labelDe: 'Lieferant', labelEn: 'Supplier' },
    {
      key: 'supplierNo',
      type: 'supplier_no_mismatch',
      severity: 'pruefen',
      labelDe: 'Lieferantennummer',
      labelEn: 'Supplier number',
    },
    {
      key: 'requestVersion',
      type: 'request_version_changed',
      severity: 'hinweis',
      labelDe: 'Anfrageversion',
      labelEn: 'Request version',
    },
    {
      key: 'changeIndex',
      type: 'change_index_changed',
      severity: 'hinweis',
      labelDe: 'Änderungsindex',
      labelEn: 'Change index',
    },
  ]
  for (const f of identityFields) {
    const a = altSummary[f.key].value
    const n = neuSummary[f.key].value
    if (bothPresentAndDiffer(a, n)) {
      issues.push({
        type: f.type,
        severity: f.severity,
        field: f.key,
        explanation: `${f.labelDe} weicht zwischen ALT und NEU ab (ALT „${a}" vs NEU „${n}") — Identitäts-Abweichung als Befund, der Vergleich läuft weiter.`,
        explanationEn: `${f.labelEn} differs between ALT and NEU (ALT "${a}" vs NEU "${n}") — identity mismatch reported as a finding; the comparison continues.`,
      })
    }
  }

  return issues
}

export function checkPlausibility(input: PlausibilityInput): PlausibilityIssue[] {
  const { altSummary, neuSummary, altSteps, neuSteps } = input
  const issues: PlausibilityIssue[] = [...checkSummaryIdentityPlausibility(altSummary, neuSummary)]

  // ── Currency ────────────────────────────────────────────────────────────────
  if (setsDiffer(distinctCurrencies(altSteps), distinctCurrencies(neuSteps))) {
    issues.push({
      type: 'currency_change',
      severity: 'pruefen',
      explanation: 'Angebotswährung (AW) unterscheidet sich zwischen ALT und NEU — Deltas ohne Wechselkurs-Hinweis vermeiden.',
      explanationEn: 'Quotation currency (AW) differs between ALT and NEU — avoid deltas without an exchange-rate note.',
    })
  }

  // ── Step-level ──────────────────────────────────────────────────────────────
  for (const row of neuSteps) {
    for (const field of NON_NEGATIVE_FIELDS) {
      const v = row[field] as number | null
      if (typeof v === 'number' && v < 0) {
        issues.push({
          type: 'negative_cost',
          severity: 'kritisch',
          field: String(field),
          step: stepLabel(row),
          explanation: `Negativer Wert in ${String(field)} (${v}).`,
          explanationEn: `Negative value in ${String(field)} (${v}).`,
        })
      }
    }
    if (!isBlank(row.prozessbezeichnung) && row.fk === 0) {
      issues.push({
        type: 'zero_cost',
        severity: 'pruefen',
        field: 'fk',
        step: stepLabel(row),
        explanation: 'Prozessschritt mit Fertigungskosten 0 — auffälliger 0-Wert prüfen.',
        explanationEn: 'Process step with manufacturing cost (FK) of 0 — review the conspicuous zero value.',
      })
    }
  }

  return issues
}

// ── D15 filename_mismatch (Spec-Erhebung 08.08.2026) ─────────────────────────
//
// Wie D16 oben: P0 gefordert, nirgends definiert; PO-Linie „melden, nie
// blockieren" (Kais, 08.08.2026). Der Check ist bewusst konservativ, weil der
// Dateiname freies Operator-Territorium ist (Falsch-Positive wären Dauer-
// Rauschen): geprüft wird NUR, ob die aus dem Inhalt geparste Sachnummer im
// Dateinamen vorkommt — beides auf [A-Z0-9] normalisiert, damit Trenner
// (Leerzeichen, Punkte, Unterstriche) keine Scheintreffer-Lücken reißen.
// Fehlt eine Seite (kein Dateiname, keine Sachnummer) oder ist die Sachnummer
// nach Normalisierung kürzer als FILENAME_PN_MIN_LENGTH, entsteht KEIN Befund
// (zu kurze Nummern kollidieren zufällig; lieber schweigen als raten).

/** Mindestlänge der normalisierten Sachnummer, unterhalb derer D15 schweigt. */
const FILENAME_PN_MIN_LENGTH = 6

function normalizeForFilenameMatch(s: string): string {
  return s.toUpperCase().replace(/[^A-Z0-9]/g, '')
}

export interface FilenameIdentityInput {
  fileName: string | null
  partNumber: string | null
}

/** Pure. Returns null when nothing is checkable or the filename matches. */
export function checkFilenamePartNumber(
  input: FilenameIdentityInput,
  side: 'ALT' | 'NEU',
): PlausibilityIssue | null {
  if (isBlank(input.fileName) || isBlank(input.partNumber)) return null
  const pn = normalizeForFilenameMatch(input.partNumber as string)
  if (pn.length < FILENAME_PN_MIN_LENGTH) return null
  if (normalizeForFilenameMatch(input.fileName as string).includes(pn)) return null
  return {
    type: 'filename_part_number_mismatch',
    severity: 'hinweis',
    step: side,
    field: 'partNumber',
    explanation: `Dateiname enthält die Sachnummer aus dem Inhalt nicht (${side}: Datei „${input.fileName}", Sachnummer „${input.partNumber}") — mögliche Datei-Verwechslung prüfen.`,
    explanationEn: `File name does not contain the part number parsed from the content (${side}: file "${input.fileName}", part number "${input.partNumber}") — check for a possible file mix-up.`,
  }
}

// ── D17 make_or_buy_indication (Spec-Erhebung 08.08.2026) ────────────────────
//
// P0 gefordert, nirgends definiert. PO-Entscheid (Kais, 08.08.2026, TG 9970,
// Frage 3): „Make or buy treffen wir anhand der Analyse" — das System liefert
// das INDIZ samt Evidenz, bewertet aber nicht und blockiert nie. Gemessen wird
// die Verschiebung der Kostenstruktur zwischen ALT und NEU: der Anteil von
// Material- bzw. Fertigungskosten an den Herstellkosten je Stand. Verschiebt
// sich einer der beiden Anteile um mehr als die konfigurierte Schwelle
// (Prozentpunkte), entsteht genau EIN Befund mit beiden Anteilspaaren als
// Evidenz (kein Doppel-Befund für dieselbe Verschiebung — Material- und
// Fertigungsanteil sind über die Herstellkosten gekoppelt).
//
// Die Schwelle ist eine benannte, versionierte Engine-Config-Sektion
// (engine-config.ts, Muster P1.5/KAR-896) — Startwert 10 Prozentpunkte,
// bewusst konservativ (Rausch-Vermeidung), fachlich wie die Reconciliation-
// Toleranzen als STARTWERT freigegeben, nicht als endgültige Zahl.

export interface MakeOrBuyIndicationConfig {
  /** false = Check läuft nicht (kein Befund entsteht strukturell). */
  enabled: boolean
  /** Mindest-Verschiebung eines Kostenanteils in Prozentpunkten. */
  shareShiftPercentagePoints: number
}

export const MAKE_OR_BUY_INDICATION_CONFIG: MakeOrBuyIndicationConfig = {
  enabled: true,
  shareShiftPercentagePoints: 10,
}

/**
 * Deliberately takes a plain metric subset (not SummaryMetricDiff itself) —
 * same no-new-import-surface pattern as ManufacturingParseDegradation above.
 */
export interface MakeOrBuyShareInput {
  metricKey: string
  altValue: number | null
  neuValue: number | null
}

function shareOf(part: number | null | undefined, total: number | null | undefined): number | null {
  if (part == null || total == null || !(total > 0)) return null
  return part / total
}

function formatShare(share: number): string {
  return `${(share * 100).toFixed(1)} %`
}

/** Pure. Returns null when disabled, when a side is not computable, or when
 * every computable shift stays below the threshold. */
export function checkMakeOrBuyIndication(
  summaryDiffs: ReadonlyArray<MakeOrBuyShareInput>,
  config: MakeOrBuyIndicationConfig = MAKE_OR_BUY_INDICATION_CONFIG,
): PlausibilityIssue | null {
  if (!config.enabled) return null
  const byKey = (k: string) => summaryDiffs.find((d) => d.metricKey === k)
  const total = byKey('totalProductionCosts')
  const material = byKey('materialCosts')
  const manufacturing = byKey('manufacturingCosts')

  const matAlt = shareOf(material?.altValue, total?.altValue)
  const matNeu = shareOf(material?.neuValue, total?.neuValue)
  const mfgAlt = shareOf(manufacturing?.altValue, total?.altValue)
  const mfgNeu = shareOf(manufacturing?.neuValue, total?.neuValue)

  const matShift = matAlt != null && matNeu != null ? Math.abs(matNeu - matAlt) * 100 : null
  const mfgShift = mfgAlt != null && mfgNeu != null ? Math.abs(mfgNeu - mfgAlt) * 100 : null

  const maxShift = Math.max(matShift ?? -Infinity, mfgShift ?? -Infinity)
  if (!(maxShift >= config.shareShiftPercentagePoints)) return null

  const evidenceDe: string[] = []
  const evidenceEn: string[] = []
  if (matAlt != null && matNeu != null) {
    evidenceDe.push(`Materialanteil ALT ${formatShare(matAlt)} → NEU ${formatShare(matNeu)}`)
    evidenceEn.push(`material share ALT ${formatShare(matAlt)} → NEU ${formatShare(matNeu)}`)
  }
  if (mfgAlt != null && mfgNeu != null) {
    evidenceDe.push(`Fertigungsanteil ALT ${formatShare(mfgAlt)} → NEU ${formatShare(mfgNeu)}`)
    evidenceEn.push(`manufacturing share ALT ${formatShare(mfgAlt)} → NEU ${formatShare(mfgNeu)}`)
  }
  return {
    type: 'make_or_buy_indication',
    severity: 'hinweis',
    explanation: `Fertigungstiefen-Indiz: Kostenstruktur-Anteil an den Herstellkosten verschiebt sich um ${maxShift.toFixed(1)} Prozentpunkte (Schwelle ${config.shareShiftPercentagePoints}) — ${evidenceDe.join('; ')}. Make-or-Buy-Bewertung erfolgt durch die Analyse, nicht durch das System.`,
    explanationEn: `Make-or-buy indication: a cost-structure share of total production costs shifts by ${maxShift.toFixed(1)} percentage points (threshold ${config.shareShiftPercentagePoints}) — ${evidenceEn.join('; ')}. The make-or-buy assessment is made by the analysis, not by the system.`,
  }
}
