// Manual field-mapping-override persistence (KAR-912 / P4.2 — backlog item
// "[P4.2] Manuelle Mapping-Override-Persistenz für neue Sheet-Typen").
//
// ── Distinct from the existing manual matching mechanism (KAR-845) ─────────
// matcher.ts's matchStepsWithPins/ManualPin lets a user PAIR an ALT
// Fertigungskosten step to a NEU step (or mark it as removed) — a ROW-level
// correction of the matching cascade, applied at compare time.
//
// This module is a FIELD-level correction: qaf-parser.ts's header→field
// mapping (KAR-893/P1.2, matchHeaderColumnSync) is best-effort — a header the
// parser cannot map (neither exact nor normalized-alias match, see
// QAFParseMeta.unmappedHeaders) leaves that field `null`/empty for every row,
// and there was previously no way for a user to correct it. A
// FieldMappingOverride says "canonical field K on the row identified by
// Positionsnummer P should have value V" — independent of, and orthogonal
// to, step-matching. Both mechanisms can be in effect on the same
// comparison at once.
//
// ── Persistence + reproducibility (task instruction, KAR-899 lesson) ───────
// Overrides are NOT re-derived from the original Excel file at
// recompute/replace time (qaf_manufacturing_step.raw_values is the only
// source of truth recompareComparison rehydrates from — the workbook itself
// is never re-read on a plain pin-recompare). Instead they are persisted as
// qaf_file.g60_meta.fieldMappingOverrides — the same "misc JSONB bag" every
// other detail-module facet (material/sbm/rmr/logistics/lccn/co2e/
// manufacturingParseMeta/workbookSafety) already uses (see actions.ts
// ingestQafUpload's g60_meta insert) — and applyFieldMappingOverrides below
// is called at BOTH integration points that (re)produce a file's QAFRow[]:
//   1. app/qaf-differences/actions.ts's ingestQafUpload, when replacing a
//      file (replaceComparisonFile carries the OLD file's overrides forward
//      onto the NEW file's freshly-parsed rows before they are persisted).
//   2. app/qaf-differences/actions.ts's recompareComparison sideOf(), which
//      rehydrates QAFRow[] from raw_values on every recompute — exactly the
//      KAR-899 rehydration-staleness pattern already fixed once for
//      material/sbm/rmr/logistics/workbookSafety (a correction that only
//      lived in the live in-memory IngestResult would silently vanish the
//      first time refreshPlausibility:true reran the pipeline without it).
// Both call sites pass the SAME persisted overrides array, so applying it is
// idempotent (see the "survives being applied twice" test) — no ordering
// hazard between the two integration points.

import type { QAFRow, QAFFieldKey } from '@/lib/qaf-parser'
import { TEXT_FIELDS, OVERRIDABLE_MANUFACTURING_FIELD_KEYS } from '@/lib/qaf-parser'
import { normalizePosition, normalizeProcessName } from './normalizer'
import type { PlausibilityIssue } from './plausibility'

/** One user-authored field-mapping correction. `rowKey` is the row's
 * normalized Positionsnummer (normalizeOverrideRowKey) — stable across a
 * recompute (the persisted qaf_manufacturing_step rows don't change) and, in
 * the common case, across a file replace too (a supplier resubmission
 * usually keeps the same Positionsnummer for the same process step). An
 * override whose rowKey no longer matches any row after a replace is simply
 * never applied again — see applyFieldMappingOverrides below — never an
 * error, since a genuinely restructured file can legitimately drop a
 * position number. */
export interface FieldMappingOverride {
  /** Canonical MANUFACTURING field this override sets (QAFFieldKey domain —
   * see lib/qaf-parser.ts MANUFACTURING_FIELD_KEYS for the valid set). */
  fieldKey: QAFFieldKey
  /** Row identity: normalizeOverrideRowKey(row.positionsnummer). */
  rowKey: string
  /** The corrected value, as free text the user typed. Text fields
   * (TEXT_FIELDS) are stored verbatim; every other field is parsed as a
   * locale number (comma or dot decimal) — see coerceOverrideValue. */
  value: string
  /** Free-text pointer to where the user found this — the "Quell-Spalte/
   * Zelle" the task instruction asks for. Kept as free text (not a strict
   * A1 cell reference) because, unlike the live-ingest parse, there is no
   * open Excel grid at recompute/replace time to validate a cell address
   * against — the user describes what they saw (e.g. "Spalte T, Header
   * 'Bezeichnung Anlage/Maschine/Typ'" or a literal "Fertigungskosten!W15"). */
  sourceDescription: string
  /** Audit "who" — auth.uid() of the user who set this override (same value
   * the caller writes into qaf_audit_log.detail.by, see actions.ts). */
  setBy: string
  /** Audit "when" — ISO-8601 timestamp. */
  setAt: string
  /**
   * KAR-912 adversarial-review F1 fix (PR #291): 'active' (default, omitted
   * for every override created via setManualFieldMappingOverride) or
   * 'dropped_on_replace' — set by carryForwardFieldMappingOverrides when a
   * file replace's row at this rowKey no longer identifies the same process
   * step (different prozessbezeichnung, or the Positionsnummer no longer
   * exists at all). Without this guard, a BMW re-quote that renumbers // allow-customer-string
   * Positionsnummer assignments (common on resubmission) would silently
   * apply the OLD override's literal value to an unrelated NEW process
   * step's field — exactly the "silent wrong number" failure class this
   * feature exists to close. A dropped override is PRESERVED (not deleted)
   * so it stays visible as audit history and so the "override was dropped"
   * plausibility finding (fieldMappingOverrideDroppedIssues below) can be
   * regenerated from this persisted flag on every read — never re-applied,
   * never re-evaluated once dropped (see carryForwardFieldMappingOverrides).
   */
  status?: 'active' | 'dropped_on_replace'
}

/** Minimal old/new-row identity signal carryForwardFieldMappingOverrides
 * needs — just Positionsnummer + Prozessbezeichnung, not a full QAFRow, so
 * callers can supply it from either a freshly parsed QAFRow[] or a lighter
 * DB projection (file-replace-actions.ts' replaceComparisonFile reads it straight off
 * qaf_manufacturing_step's dedicated position_number/process_name columns,
 * no raw_values fetch needed). A plain QAFRow satisfies this structurally. */
export interface RowIdentity {
  positionsnummer: string | null
  prozessbezeichnung: string | null
}

/** Row-identity key for a Positionsnummer, shared by every override lookup
 * below. Deliberately the SAME normalization matcher.ts's ManualPin cascade
 * already relies on (normalizePosition — "must remain text so '2a'/'007'
 * survive intact, only trims") — using a different normalization here would
 * make an override silently stop matching a row that visibly still carries
 * the same Positionsnummer. */
export function normalizeOverrideRowKey(positionsnummer: unknown): string {
  return normalizePosition(positionsnummer)
}

/**
 * KAR-912 adversarial-review F2+F6 fix (PR #291, Sev 45 / Sev 62): true only
 * for a fieldKey a manual override may legitimately target —
 * OVERRIDABLE_MANUFACTURING_FIELD_KEYS (qaf-parser.ts, see its doc comment
 * for the full rationale), i.e. every MANUFACTURING field EXCEPT
 * `positionsnummer` (the row-identity key `rowKey`/every lookup in this
 * module is keyed by) and `prozessbezeichnung` (the identity ANCHOR
 * carryForwardFieldMappingOverrides below compares — overridable, it could
 * poison that anchor via qaf_manufacturing_step.process_name across a 2-hop
 * replace chain). Exported so both the setManualFieldMappingOverride server
 * action (validation) and the override-setting UI (field picker) share one
 * source of truth instead of two independently-maintained checks that could
 * drift.
 */
export function isOverridableFieldKey(fieldKey: string): fieldKey is QAFFieldKey {
  return (OVERRIDABLE_MANUFACTURING_FIELD_KEYS as readonly string[]).includes(fieldKey)
}

/** Text fields are stored verbatim; everything else is parsed as a locale
 * number (comma-decimal accepted, same tolerance QAF source files use).
 * An unparsable numeric value collapses to `null` — never `NaN` (differ.ts's
 * computeNumericDelta and every downstream numeric consumer assume `null`
 * for "no value", not `NaN`). */
function coerceOverrideValue(fieldKey: QAFFieldKey, value: string): string | number | null {
  if (TEXT_FIELDS.has(fieldKey)) return value
  const trimmed = value.trim()
  if (trimmed === '') return null
  const n = Number(trimmed.replace(',', '.'))
  return Number.isNaN(n) ? null : n
}

/** Single, controlled dynamic-key write — QAFRowValues' per-key value types
 * (string for text fields, number|null for numeric fields) can't be
 * expressed as one assignable union without this cast; coerceOverrideValue
 * is the only caller and already returns the correct kind per fieldKey. */
function setField(row: QAFRow, fieldKey: QAFFieldKey, value: string | number | null): void {
  ;(row as unknown as Record<QAFFieldKey, string | number | null>)[fieldKey] = value
}

/**
 * Apply persisted field-mapping overrides onto a parsed/rehydrated QAFRow[].
 * Pure and non-mutating: rows with no matching override are returned by the
 * SAME reference (so `overrides: []` — the overwhelming majority of files,
 * which never had a correction set — is a true byte-identical no-op, and a
 * caller can cheaply tell "nothing changed" via reference equality on
 * individual rows if it ever needs to).
 *
 * Multiple overrides for the same fieldKey+rowKey are applied in array
 * order, last one wins (upsertFieldMappingOverride already enforces at most
 * one entry per fieldKey+rowKey in the persisted array, so this only matters
 * for a caller that hands in a raw, non-deduplicated list).
 */
export function applyFieldMappingOverrides(rows: readonly QAFRow[], overrides: readonly FieldMappingOverride[]): QAFRow[] {
  // KAR-912 F1 fix: a 'dropped_on_replace' override is kept in the
  // persisted array (audit trail + drives fieldMappingOverrideDroppedIssues
  // below) but must never be applied again — the row it was set on may no
  // longer be the row it now shares a rowKey with (see FieldMappingOverride.
  // status doc comment).
  const active = overrides.filter((o) => o.status !== 'dropped_on_replace')
  if (active.length === 0) return rows as QAFRow[]

  const byRowKey = new Map<string, FieldMappingOverride[]>()
  for (const o of active) {
    const list = byRowKey.get(o.rowKey)
    if (list) list.push(o)
    else byRowKey.set(o.rowKey, [o])
  }
  if (byRowKey.size === 0) return rows as QAFRow[]

  return rows.map((row) => {
    const matches = byRowKey.get(normalizeOverrideRowKey(row.positionsnummer))
    if (!matches || matches.length === 0) return row

    const next: QAFRow = { ...row, manualOverride: { ...row.manualOverride } }
    for (const o of matches) {
      setField(next, o.fieldKey, coerceOverrideValue(o.fieldKey, o.value))
      next.manualOverride = {
        ...next.manualOverride,
        [o.fieldKey]: { sourceDescription: o.sourceDescription, setBy: o.setBy, setAt: o.setAt },
      }
    }
    return next
  })
}

/** Merge one override into a persisted list — replaces an existing entry for
 * the same fieldKey+rowKey (so re-correcting the same field never grows the
 * array), otherwise appends. Used by the setManualFieldMappingOverride
 * server action (actions.ts) to build the next qaf_file.g60_meta.
 * fieldMappingOverrides value before writing it back. */
export function upsertFieldMappingOverride(
  existing: readonly FieldMappingOverride[],
  next: FieldMappingOverride,
): FieldMappingOverride[] {
  const filtered = existing.filter((o) => !(o.fieldKey === next.fieldKey && o.rowKey === next.rowKey))
  return [...filtered, next]
}

/** Drop one override (e.g. a user decides a manual correction was wrong and
 * wants the parser's own mapping back). No-op when nothing matches. */
export function removeFieldMappingOverride(
  existing: readonly FieldMappingOverride[],
  fieldKey: QAFFieldKey,
  rowKey: string,
): FieldMappingOverride[] {
  return existing.filter((o) => !(o.fieldKey === fieldKey && o.rowKey === rowKey))
}

/** Row-identity resolver shared by carryForwardFieldMappingOverrides below —
 * a real identity confirmation requires a NON-BLANK Prozessbezeichnung on
 * both sides (two blank names "matching" is not evidence of anything, same
 * caution matcher.ts's own bothBlankNames/namesEqual pair already applies to
 * the identical field for the row-matching cascade). */
function identityAt(rows: readonly RowIdentity[], rowKey: string): string | null {
  const row = rows.find((r) => normalizeOverrideRowKey(r.positionsnummer) === rowKey)
  if (!row) return null
  const name = normalizeProcessName(row.prozessbezeichnung)
  return name === '' ? null : name
}

export interface CarryForwardResult {
  /** Same length/order as the input `overrides` — every entry's `status` is
   * either preserved (already 'dropped_on_replace') or freshly decided
   * ('active' on an identity match, 'dropped_on_replace' on a mismatch/
   * missing row). This is what gets persisted into the NEW file's
   * qaf_file.g60_meta.fieldMappingOverrides. */
  overrides: FieldMappingOverride[]
  /** Subset of `overrides` that were dropped BY THIS CALL (were 'active' or
   * unset going in, are 'dropped_on_replace' coming out) — NOT overrides
   * that were already dropped by an earlier replace. Informational only
   * (e.g. for a caller that wants to log/audit just this transition);
   * fieldMappingOverrideDroppedIssues below reads `status` off the full
   * `overrides` list instead, so a dropped override keeps surfacing its
   * finding on every later read, not just the replace that dropped it. */
  droppedNow: FieldMappingOverride[]
}

/**
 * KAR-912 adversarial-review F1 fix (PR #291, Sev 85): guards the file-
 * replace carry-forward (replaceComparisonFile, file-replace-actions.ts) against silently
 * re-pointing an override at an unrelated process step. A BMW re-quote can // allow-customer-string
 * renumber Positionsnummer assignments — matching an override to the NEW
 * file purely by rowKey (as the pre-fix code did) risks writing the OLD
 * override's literal value onto a completely different NEU row that happens
 * to reuse the same Positionsnummer text.
 *
 * For every 'active' (or status-less/legacy) override: resolve the
 * Prozessbezeichnung at that rowKey on BOTH the old and the new row set. A
 * normalized-equal, non-blank match confirms it's still "the same" process
 * step → the override stays 'active'. Anything else (no old row, no new
 * row, or a genuinely different name) → 'dropped_on_replace'. An override
 * already 'dropped_on_replace' is passed through untouched — once dropped,
 * never re-evaluated (see FieldMappingOverride.status doc comment).
 *
 * Pure — callers persist `overrides` and use `droppedNow` however they like
 * (this module does not touch qaf_plausibility_issue or the DB itself).
 */
export function carryForwardFieldMappingOverrides(
  oldRows: readonly RowIdentity[],
  newRows: readonly RowIdentity[],
  overrides: readonly FieldMappingOverride[],
): CarryForwardResult {
  const droppedNow: FieldMappingOverride[] = []
  const nextOverrides = overrides.map((o) => {
    if (o.status === 'dropped_on_replace') return o

    const oldName = identityAt(oldRows, o.rowKey)
    const newName = identityAt(newRows, o.rowKey)
    const identityConfirmed = oldName !== null && newName !== null && oldName === newName
    if (identityConfirmed) return { ...o, status: 'active' as const }

    const dropped: FieldMappingOverride = { ...o, status: 'dropped_on_replace' as const }
    droppedNow.push(dropped)
    return dropped
  })
  return { overrides: nextOverrides, droppedNow }
}

/**
 * Derives a bilingual `pruefen`-severity plausibility issue for every
 * 'dropped_on_replace' override on this file — regenerated fresh from the
 * persisted `status` flag on every call (never inserted into
 * qaf_plausibility_issue itself), the same "live, never-stale, computed
 * from g60_meta on every read" pattern templateFingerprintToPlausibilityIssue/
 * workbookSafetyToPlausibilityIssues already use (app/qaf-differences/[id]/
 * page.tsx). Because it is derived fresh rather than persisted-and-
 * refreshed, it is automatically KAR-899-safe: there is no stale-row risk to
 * begin with, since nothing about it is ever written to qaf_plausibility_issue.
 * Renders nothing (empty array) when no override on this file is dropped —
 * the overwhelming majority of files.
 */
export function fieldMappingOverrideDroppedIssues(
  overrides: readonly FieldMappingOverride[],
  side: 'ALT' | 'NEU',
  fileLabel: string,
): PlausibilityIssue[] {
  return overrides
    .filter((o) => o.status === 'dropped_on_replace')
    .map((o) => ({
      type: 'field_mapping_override_dropped_on_replace',
      severity: 'pruefen' as const,
      step: `${side} · ${fileLabel}`,
      field: o.fieldKey,
      explanation:
        `Manueller Override für „${o.fieldKey}" auf Position ${o.rowKey} wurde beim Datei-Ersetzen verworfen — ` +
        `die Zeile an dieser Positionsnummer identifiziert in der neuen Datei einen anderen/keinen Prozessschritt ` +
        `(Prozessbezeichnung stimmt nicht überein). Bitte den Override bei Bedarf neu setzen.`,
      explanationEn:
        `Manual override for "${o.fieldKey}" on position ${o.rowKey} was dropped on file replace — ` +
        `the row at this position number identifies a different/no process step in the new file ` +
        `(process designation does not match). Re-apply the override if it is still needed.`,
    }))
}
