// Semantisches Positions-Mapping Material (Spezifikation Kap. 7.3/8.4).
//
// Befund F-06/F-07: Die gesamte Positionsebene fehlte. Der Vergleich sagte
// „Material +9,79" und blieb die Antwort schuldig, WELCHE Position das treibt —
// genau die Frage, für die der Vergleich existiert.
//
// Diese Datei ordnet die Positionen beider Stände einander zu. Sie tut das in
// festgelegten Durchgängen, und jede Zuordnung trägt ihren Grund mit: die
// Spezifikation verbietet ausdrücklich, eine Zuordnung allein auf einen
// Ähnlichkeitswert zu stützen. Was hier entschieden wird, muss nachlesbar sein.
//
// Stand dieses Schnitts: Durchgang 1 (eindeutige Namensgleichheit) sowie die
// Restklassen neu/entfallen. Die schwierigeren Durchgänge — mehrfach gleiche
// Namen, Zusammenfassungen, Aufteilungen, Umbenennungen — folgen und sind hier
// bereits im Typ vorgesehen, damit sie nicht nachträglich angeflanscht werden.

import { normalizeMaterialName } from './material-name'

/** Zuordnungsart einer Position (Kap. 8.4). */
export type MaterialMatchType =
  | 'exact'
  | 'duplicate_context'
  | 'merged'
  | 'split'
  | 'renamed'
  | 'added'
  | 'removed'

/** Eine Position, wie das Mapping sie braucht — bewusst schmal gehalten. */
export interface MappablePosition {
  /** Zeilennummer im Blatt; dient auch als stabile Sortierung. */
  row: number
  name: string
  /** Positionskosten in Angebotswährung; null wenn nicht ermittelbar. */
  cost: number | null
  /**
   * Baugruppe der Position (Spalte „Baugruppe" des Materialblatts).
   *
   * Trägt die Information, die ein Name nicht hergibt: dass „LP IHX COMPRESSOR"
   * und „HP_EXV_HVAC" beide zur Gruppe „AC Lines" gehören und deshalb in
   * derselben Sammelposition aufgehen können. Optional — fehlt sie, greifen
   * die gruppenbasierten Regeln schlicht nicht.
   */
  group?: string | null
}

export interface MaterialMapping {
  mappingId: string
  awardRows: number[]
  currentRows: number[]
  awardNames: string[]
  currentNames: string[]
  matchType: MaterialMatchType
  /** Warum diese Zuordnung getroffen wurde — Regel plus konkreter Beleg. */
  matchEvidence: { ruleId: string; detailsDe: string }
  costAward: number | null
  costCurrent: number | null
  delta: number | null
}

export interface MaterialMappingResult {
  mappings: MaterialMapping[]
  counts: {
    positionsAward: number
    positionsCurrent: number
    byMatchType: Record<MaterialMatchType, number>
  }
}

function sumCosts(positions: MappablePosition[]): number | null {
  const known = positions.filter((p) => p.cost !== null)
  if (known.length === 0) return null
  return known.reduce((s, p) => s + (p.cost as number), 0)
}

function delta(costAward: number | null, costCurrent: number | null): number | null {
  if (costAward === null && costCurrent === null) return null
  return (costCurrent ?? 0) - (costAward ?? 0)
}

function mapping(
  id: number,
  award: MappablePosition[],
  current: MappablePosition[],
  matchType: MaterialMatchType,
  ruleId: string,
  detailsDe: string,
): MaterialMapping {
  const costAward = sumCosts(award)
  const costCurrent = sumCosts(current)
  return {
    mappingId: `MAP-${String(id).padStart(3, '0')}`,
    awardRows: award.map((p) => p.row),
    currentRows: current.map((p) => p.row),
    awardNames: award.map((p) => p.name),
    currentNames: current.map((p) => p.name),
    matchType,
    matchEvidence: { ruleId, detailsDe },
    costAward,
    costCurrent,
    delta: delta(costAward, costCurrent),
  }
}

const EMPTY_COUNTS: Record<MaterialMatchType, number> = {
  exact: 0,
  duplicate_context: 0,
  merged: 0,
  split: 0,
  renamed: 0,
  added: 0,
  removed: 0,
}

/**
 * Positionen beider Stände einander zuordnen.
 *
 * Durchgang 1 — eindeutige Namensgleichheit: Ein Name, der auf **beiden**
 * Seiten genau einmal vorkommt, ist zweifelsfrei dieselbe Position. Kommt er
 * mehrfach vor, bleibt er diesem Durchgang entzogen; ihn hier über die
 * Reihenfolge zu greifen wäre geraten, und die Spezifikation sieht dafür einen
 * eigenen Durchgang mit Ankerlogik vor (Kap. 8.4 Pass 2).
 *
 * Rest: was nur rechts steht, ist neu; was nur links steht, ist entfallen.
 * Beide Restklassen sind hier noch grob — die feineren Durchgänge werden sie
 * verkleinern, indem sie Zusammenfassungen und Umbenennungen herauslösen.
 */
/**
 * Geparste Materialzeilen in die Form bringen, die das Mapping erwartet
 * (Block 2, Erstverdrahtung des Material-Mappings in den Vergleich).
 *
 * Feld-Zuordnung empirisch am Golden-Fall entschieden, nicht semantisch
 * geraten (Messlauf 07.08.2026, material-mapping-golden-case):
 *   - `name` = `materialDesignation` — reproduziert price_change,
 *     scope_activated und scope_removed EXAKT gegen die Referenz;
 *     der Kandidat `partDesignation` verfehlte alle drei deutlich.
 *   - `cost` = `materialCost` (CORE-Feld, canonical-fields
 *     `mat_calc_material_cost` „Kalkulatorische Materialkosten [AW]" —
 *     die Positions-Gesamtkosten, auf denen die Blattsumme beruht).
 *   - `row` aus der Zell-Provenienz (`sourceCells`), mit festem
 *     Feld-Vorrang — deterministisch, kein Array-Index.
 *   - `group` = null: Der Material-Parser kennt keine Baugruppen-Spalte
 *     (die 29 Leitfaden-Felder führen keine). Die gruppenbasierten
 *     Sammelpositions-Regeln greifen auf diesem Pfad nicht — gemessene
 *     Folge am Golden-Fall: eine consolidation-Zuordnung weniger, deren
 *     Positionen stattdessen ehrlich als Zu-/Abgang erscheinen. Nichts
 *     verschwindet, die Verdichtung ist konservativer.
 */
export function mappablePositionsFromMaterialRows(rows: readonly MaterialRowLike[]): MappablePosition[] {
  const out: MappablePosition[] = []
  for (const r of rows) {
    const row = rowFromSourceCells(r.sourceCells)
    if (row === null) continue // ohne Blattzeile keine stabile Sortiergrundlage
    out.push({ row, name: r.materialDesignation ?? '', cost: r.materialCost, group: null })
  }
  return out
}

/** Schmaler struktureller Blick auf MaterialRow — hält das Mapping frei vom Parser-Modul. */
export interface MaterialRowLike {
  materialDesignation: string
  materialCost: number | null
  sourceCells: Partial<Record<string, string>>
}

const ROW_SOURCE_PRIORITY = ['materialCost', 'positionNumber', 'materialDesignation', 'partDesignation'] as const

function rowFromSourceCells(sourceCells: Partial<Record<string, string>>): number | null {
  for (const key of ROW_SOURCE_PRIORITY) {
    const ref = sourceCells[key]
    if (ref) {
      const m = /(\d+)\s*$/.exec(ref)
      if (m) return Number(m[1])
    }
  }
  return null
}

export function buildMaterialMappings(
  award: MappablePosition[],
  current: MappablePosition[],
): MaterialMappingResult {
  const byName = (positions: MappablePosition[]) => {
    const index = new Map<string, MappablePosition[]>()
    for (const p of positions) {
      const key = normalizeMaterialName(p.name).normalized
      if (key === '') continue // namenlose Position ist mit nichts gleich
      index.set(key, [...(index.get(key) ?? []), p])
    }
    return index
  }

  const awardByName = byName(award)
  const currentByName = byName(current)
  const usedAward = new Set<number>()
  const usedCurrent = new Set<number>()
  const mappings: MaterialMapping[] = []
  let id = 0

  // Durchgang 1: beidseitig eindeutige Namensgleichheit.
  //
  // Die Kandidaten werden nach der Zeilennummer im Vergabestand abgearbeitet,
  // nicht in der Reihenfolge, in der sie hereinkamen. Sonst hinge die Vergabe
  // der Zuordnungs-IDs an der Eingabereihenfolge — zwei Läufe über dieselben
  // Dateien lieferten dann dieselben Zuordnungen unter anderen IDs, und der
  // Determinismus-Nachweis (R-19) wäre nicht zu halten.
  const pass1Keys = [...awardByName.entries()]
    .filter(([, hits]) => hits.length === 1)
    .sort(([, a], [, b]) => a[0].row - b[0].row)
    .map(([key]) => key)

  for (const key of pass1Keys) {
    const awardHits = awardByName.get(key)!
    const currentHits = currentByName.get(key)
    if (!currentHits || currentHits.length !== 1) continue

    mappings.push(
      mapping(++id, awardHits, currentHits, 'exact', 'name_exact_unique', `Name „${awardHits[0].name}" kommt auf beiden Seiten genau einmal vor.`),
    )
    usedAward.add(awardHits[0].row)
    usedCurrent.add(currentHits[0].row)
  }

  // Durchgang 2: Gleichteile — derselbe Name mehrfach auf beiden Seiten.
  //
  // Schrauben, Scheiben, Clips tragen in einer Stückliste denselben Namen und
  // unterscheiden sich nur durch ihre Stelle. Zugeordnet wird über die
  // erhaltene Reihenfolge: das n-te Vorkommen links gehört zum n-ten rechts.
  // Das ist keine Ähnlichkeitsannahme, sondern die Beobachtung, dass eine
  // Stückliste ihre Reihenfolge zwischen zwei Ständen beibehält — im
  // Referenzfall trifft das auf alle 21 Gleichteil-Paare zu.
  //
  // Sind die Anzahlen ungleich, werden nur so viele Paare gebildet, wie beide
  // Seiten hergeben; der Überhang fällt in die Restklassen. Ihn zu verteilen
  // hiesse raten, welche Schraube weggefallen ist.
  // Greift, sobald EINE der beiden Seiten den Namen mehrfach trägt. Die
  // Bedingung nur auf den Vergabestand zu stellen wäre ein Loch: kommt ein
  // Name links einmal und rechts zweimal vor, ist er weder für Durchgang 1
  // eindeutig noch für Durchgang 2 ein Gleichteil — er fiele grundlos in die
  // Restklassen, obwohl eine Zuordnung möglich ist.
  const pass2Keys = [...awardByName.entries()]
    .filter(([key, hits]) => {
      const right = currentByName.get(key)?.length ?? 0
      return right > 0 && (hits.length > 1 || right > 1)
    })
    .sort(([, a], [, b]) => a[0].row - b[0].row)
    .map(([key]) => key)

  for (const key of pass2Keys) {
    const awardHits = [...awardByName.get(key)!].sort((a, b) => a.row - b.row)
    const currentHits = [...(currentByName.get(key) ?? [])].sort((a, b) => a.row - b.row)
    const pairs = Math.min(awardHits.length, currentHits.length)
    const uneven = awardHits.length !== currentHits.length

    for (let i = 0; i < pairs; i++) {
      const a = awardHits[i]
      const c = currentHits[i]
      if (usedAward.has(a.row) || usedCurrent.has(c.row)) continue
      mappings.push(
        mapping(
          ++id,
          [a],
          [c],
          'duplicate_context',
          'duplicate_order_preserved',
          `Name „${a.name}" kommt links ${awardHits.length}×, rechts ${currentHits.length}× vor; zugeordnet über die erhaltene Reihenfolge (${i + 1}. Vorkommen)` +
            (uneven ? '. Ungleiche Anzahl — der Überhang bleibt ungepaart.' : '.'),
        ),
      )
      usedAward.add(a.row)
      usedCurrent.add(c.row)
    }
  }

  // Durchgang 3: Zusammenfassungen und Aufteilungen.
  //
  // Ein Lieferant fasst mehrere Positionen zu einer zusammen („Chiller" und
  // „WCC / IHX" werden zu „HEX (WCC IHX / Chiller)") oder teilt eine auf. Ohne
  // diesen Durchgang erscheint beides als Streichung plus Neuzugang, und die
  // Aussage „Position X ist weggefallen" wäre schlicht falsch.
  //
  // Erkannt wird über den NAMEN, nicht über die Kosten: die zusammengefasste
  // Bezeichnung nennt ihre Bestandteile. Eine Erkennung über Kostengleichheit
  // („zwei Positionen ergeben zusammen den Betrag einer dritten") würde bei
  // gleichzeitiger Preisänderung versagen — und genau dann ist der Fall
  // interessant. Im Referenzfall sinkt der Betrag der Zusammenfassung um 4,64
  // gegenüber der Summe seiner Teile; über Kosten wäre er nicht zu finden.
  const remainingAward = () => award.filter((p) => !usedAward.has(p.row)).sort((a, b) => a.row - b.row)
  const remainingCurrent = () => current.filter((p) => !usedCurrent.has(p.row)).sort((a, b) => a.row - b.row)

  /** Enthält der Sammelname die Tokens aller Teile — und mehr als eines davon? */
  function partsCoveredBy(whole: MappablePosition, parts: MappablePosition[]): MappablePosition[] {
    const wholeTokens = new Set(normalizeMaterialName(whole.name).tokens)
    if (wholeTokens.size === 0) return []
    return parts.filter((part) => {
      const partTokens = normalizeMaterialName(part.name).tokens
      // Jedes bedeutungstragende Token des Teils muss im Sammelnamen stehen.
      return partTokens.length > 0 && partTokens.every((t) => wholeTokens.has(t))
    })
  }

  // 3a: mehrere links → eine rechts (Zusammenfassung).
  for (const whole of remainingCurrent()) {
    const parts = partsCoveredBy(whole, remainingAward())
    if (parts.length < 2) continue
    mappings.push(
      mapping(
        ++id,
        parts,
        [whole],
        'merged',
        'name_contains_parts',
        `Bezeichnung „${whole.name}" nennt die Bestandteile ${parts.map((p) => `„${p.name}"`).join(' und ')}.`,
      ),
    )
    for (const p of parts) usedAward.add(p.row)
    usedCurrent.add(whole.row)
  }

  // 3b: eine links → mehrere rechts (Aufteilung).
  for (const whole of remainingAward()) {
    const parts = partsCoveredBy(whole, remainingCurrent())
    if (parts.length < 2) continue
    mappings.push(
      mapping(
        ++id,
        [whole],
        parts,
        'split',
        'name_contains_parts',
        `Bezeichnung „${whole.name}" nennt die Bestandteile ${parts.map((p) => `„${p.name}"`).join(' und ')}.`,
      ),
    )
    usedAward.add(whole.row)
    for (const p of parts) usedCurrent.add(p.row)
  }

  // Durchgang 3b2: Aufzählende Sammelnamen.
  //
  // Manche Sammelposition zählt ihre Bestandteile auf, ohne sie vollständig zu
  // benennen: „4 x EXV / 1 x Valve Block, 1 x pT Sensor" wird zu „EXV Module"
  // und „pT Sensor". Die strenge Regel (jedes Wort des Teils steht im
  // Sammelnamen) findet nur den Sensor — bei „EXV Module" fehlt das Wort
  // „Module" — und bricht dann ab, weil ein einzelnes Teil keine Aufteilung ist.
  //
  // Drei Bedingungen halten die Lockerung eng:
  //   1. Der Sammelname trägt Aufzählungszeichen (Komma oder Schrägstrich) —
  //      er gibt sich selbst als Aufzählung zu erkennen.
  //   2. Jedes Teil teilt mindestens ein AUSSAGEKRÄFTIGES Wort mit ihm
  //      (mindestens drei Zeichen, nicht rein numerisch) — „1" und „x" aus
  //      Mengenangaben taugen nicht als Beleg.
  //   3. Die Teile belegen VERSCHIEDENE Wörter. Zwei Positionen, die sich auf
  //      dasselbe Wort stützen, sind kein Beleg für eine Aufteilung.
  const ENUMERATION_MARK = /[,/]/
  const significant = (t: string) => t.length >= 3 && !/^\d+$/.test(t)

  function enumeratedParts(whole: MappablePosition, parts: MappablePosition[]): MappablePosition[] {
    if (!ENUMERATION_MARK.test(whole.name)) return []
    const wholeTokens = new Set(normalizeMaterialName(whole.name).tokens.filter(significant))
    if (wholeTokens.size < 2) return []

    const claimed = new Set<string>()
    const found: MappablePosition[] = []
    for (const part of parts) {
      const hit = normalizeMaterialName(part.name)
        .tokens.filter(significant)
        .find((t) => wholeTokens.has(t) && !claimed.has(t))
      if (!hit) continue
      claimed.add(hit)
      found.push(part)
    }
    return found.length >= 2 ? found : []
  }

  for (const whole of remainingAward()) {
    const parts = enumeratedParts(whole, remainingCurrent())
    if (parts.length < 2) continue
    mappings.push(
      mapping(
        ++id,
        [whole],
        parts,
        'split',
        'enumerating_name',
        `Bezeichnung „${whole.name}" zählt Bestandteile auf; ${parts.map((p) => `„${p.name}"`).join(' und ')} greifen je ein eigenes Stichwort daraus auf.`,
      ),
    )
    usedAward.add(whole.row)
    for (const p of parts) usedCurrent.add(p.row)
  }

  for (const whole of remainingCurrent()) {
    const parts = enumeratedParts(whole, remainingAward())
    if (parts.length < 2) continue
    mappings.push(
      mapping(
        ++id,
        parts,
        [whole],
        'merged',
        'enumerating_name',
        `Bezeichnung „${whole.name}" zählt Bestandteile auf; ${parts.map((p) => `„${p.name}"`).join(' und ')} greifen je ein eigenes Stichwort daraus auf.`,
      ),
    )
    for (const p of parts) usedAward.add(p.row)
    usedCurrent.add(whole.row)
  }

  // Durchgang 3c: Zusammenfassung über die Baugruppe.
  //
  // Der Namensweg (3a/3b) findet nur, was der Sammelname auch nennt. Im
  // Referenzfall gehen fünf Leitungspositionen in einer Position „AC Lines"
  // auf — „LP IHX COMPRESSOR" oder „HP_EXV_HVAC" stehen dort nirgends. Über den
  // Namen ist das nicht zu sehen; über die Baugruppe schon: alle fünf tragen
  // dieselbe Gruppe wie die Sammelposition.
  //
  // Die Regel greift bewusst nur im eindeutigen Fall: bleiben in einer Gruppe
  // auf der einen Seite mehrere und auf der anderen genau EINE Position übrig,
  // ist die Zuordnung zwingend. Bleiben beidseitig mehrere übrig (im
  // Referenzfall die grosse Gruppe „Module Assy"), wird nichts entschieden —
  // dort wäre jede Zuordnung geraten.
  const groupOf = (p: MappablePosition) => (p.group ?? '').trim().toLowerCase()

  const groupKeys = [...new Set([...award, ...current].map(groupOf))].filter((g) => g !== '').sort()

  for (const g of groupKeys) {
    const leftOver = remainingAward().filter((p) => groupOf(p) === g)
    const rightOver = remainingCurrent().filter((p) => groupOf(p) === g)

    if (leftOver.length >= 2 && rightOver.length === 1) {
      mappings.push(
        mapping(
          ++id,
          leftOver,
          rightOver,
          'merged',
          'group_collapses_to_single',
          `Baugruppe „${leftOver[0].group}": ${leftOver.length} Positionen des Vergabestands gehen in der einzigen verbliebenen Position „${rightOver[0].name}" auf.`,
        ),
      )
      for (const p of leftOver) usedAward.add(p.row)
      usedCurrent.add(rightOver[0].row)
      continue
    }

    if (rightOver.length >= 2 && leftOver.length === 1) {
      mappings.push(
        mapping(
          ++id,
          leftOver,
          rightOver,
          'split',
          'group_expands_from_single',
          `Baugruppe „${rightOver[0].group}": die einzige verbliebene Position „${leftOver[0].name}" des Vergabestands geht in ${rightOver.length} Positionen auf.`,
        ),
      )
      usedAward.add(leftOver[0].row)
      for (const p of rightOver) usedCurrent.add(p.row)
    }
  }

  // Durchgang 4: Umbenennungen.
  //
  // Eine Position wird anders benannt, bleibt aber dieselbe: im Referenzfall
  // wird aus „Sound Insulation (left)" ein „(back)" und aus „(right)" ein
  // „(front)" — eine Geometrie-Umbenennung, beide mit geändertem Preis.
  //
  // Über den Preis ist das nicht zu finden (er ändert sich ja), über den Namen
  // allein auch nicht (die Klammerinhalte widersprechen sich sogar). Zwei
  // unabhängige Kriterien tragen die Zuordnung:
  //
  //   1. Sequenzlage — beide Positionen liegen zwischen denselben bereits
  //      zugeordneten Nachbarn. Eine Stückliste behält ihre Ordnung; wer an
  //      derselben Stelle steht, ist mit hoher Wahrscheinlichkeit dasselbe Teil.
  //   2. Gemeinsamer Wortstamm — mindestens ein bedeutungstragendes Token teilen
  //      sich beide Namen, oder der Preis ist unverändert.
  //
  // Eines allein genügt nicht: Sequenzlage ohne Namensbezug würde beliebige
  // Nachbarn verheiraten, Namensbezug ohne Sequenzlage würde „Screw" quer durch
  // die Liste zuordnen. Die Spezifikation verlangt genau diese Doppelung.
  // Das Ankerfenster wird über die ZUORDNUNG benannt, nicht über Zeilennummern:
  // die verschieben sich zwischen zwei Ständen (im Referenzfall Zeile 50 links
  // gegen 47 rechts), und ein Fenster „49..52" gegen „46..49" fände nie
  // zusammen. Benannt wird es nach der Zuordnungs-ID der Nachbarn — die ist auf
  // beiden Seiten dieselbe, genau darum geht es.
  const mappingIdOfAwardRow = new Map<number, string>()
  const mappingIdOfCurrentRow = new Map<number, string>()
  for (const m of mappings) {
    for (const r of m.awardRows) mappingIdOfAwardRow.set(r, m.mappingId)
    for (const r of m.currentRows) mappingIdOfCurrentRow.set(r, m.mappingId)
  }

  const anchorWindow = (
    row: number,
    all: MappablePosition[],
    mappingIdOfRow: Map<number, string>,
  ): string => {
    const sorted = [...all].sort((a, b) => a.row - b.row)
    const before = sorted.filter((p) => p.row < row && mappingIdOfRow.has(p.row)).pop()
    const after = sorted.find((p) => p.row > row && mappingIdOfRow.has(p.row))
    const beforeId = before ? mappingIdOfRow.get(before.row) : 'start'
    const afterId = after ? mappingIdOfRow.get(after.row) : 'end'
    return `${beforeId}..${afterId}`
  }

  const PRICE_EQUAL_TOLERANCE = 0.01
  const unmatchedAward = remainingAward()
  const unmatchedCurrent = remainingCurrent()

  for (const a of unmatchedAward) {
    if (usedAward.has(a.row)) continue
    const windowAward = anchorWindow(a.row, award, mappingIdOfAwardRow)
    const aName = normalizeMaterialName(a.name)

    const candidate = unmatchedCurrent.find((c) => {
      if (usedCurrent.has(c.row)) return false
      if (anchorWindow(c.row, current, mappingIdOfCurrentRow) !== windowAward) return false
      const cName = normalizeMaterialName(c.name)
      const sharesToken = aName.tokens.some((t) => cName.tokens.includes(t))
      const samePrice =
        a.cost !== null && c.cost !== null && Math.abs(a.cost - c.cost) <= PRICE_EQUAL_TOLERANCE
      return sharesToken || samePrice
    })
    if (!candidate) continue

    mappings.push(
      mapping(
        ++id,
        [a],
        [candidate],
        'renamed',
        'anchor_window_and_stem',
        `„${a.name}" und „${candidate.name}" stehen zwischen denselben zugeordneten Nachbarn (${windowAward}) und teilen sich Wortstamm oder Preis.`,
      ),
    )
    usedAward.add(a.row)
    usedCurrent.add(candidate.row)
  }

  // Rest rechts: neu. In Blattreihenfolge, damit zwei Läufe dieselben IDs vergeben.
  for (const p of [...current].sort((a, b) => a.row - b.row)) {
    if (usedCurrent.has(p.row)) continue
    mappings.push(mapping(++id, [], [p], 'added', 'unmatched_current', `Position „${p.name}" hat keine Entsprechung im Vergabestand.`))
  }

  // Rest links: entfallen.
  for (const p of [...award].sort((a, b) => a.row - b.row)) {
    if (usedAward.has(p.row)) continue
    mappings.push(mapping(++id, [p], [], 'removed', 'unmatched_award', `Position „${p.name}" hat keine Entsprechung im aktuellen Stand.`))
  }

  const byMatchType = { ...EMPTY_COUNTS }
  for (const m of mappings) byMatchType[m.matchType] += 1

  return {
    mappings,
    counts: { positionsAward: award.length, positionsCurrent: current.length, byMatchType },
  }
}

// ── Effekt-Klassen und Rekonsiliation (Kap. 8.5, R-09/R-16) ──────────────────

/**
 * Wirkungsart einer Zuordnung.
 *
 * Die Klassen trennen, was in der Summe sonst verschwimmt: ob eine Position
 * teurer wurde, ob überhaupt erst Umfang hinzukam, oder ob nur umgebucht wurde.
 * Für eine Verhandlung ist das der Unterschied zwischen „der Lieferant hat den
 * Preis erhöht" und „wir haben mehr bestellt".
 */
export type MaterialEffectBucket =
  | 'scope_added'
  | 'scope_activated'
  | 'price_change'
  | 'price_removed'
  | 'scope_removed'
  | 'consolidation'
  | 'unchanged'

export interface ClassifiedMapping extends MaterialMapping {
  effect: MaterialEffectBucket
  /** Position ist benannt, kostet aber nichts — verhandlungsrelevant (Detektor D02). */
  zeroCost: boolean
}

export interface MaterialEffectResult {
  mappings: ClassifiedMapping[]
  buckets: Record<MaterialEffectBucket, { delta: number; count: number }>
  /** Abgleich gegen das Blattdelta (R-16). */
  reconciliation: {
    sumMappingDeltas: number
    sheetDelta: number | null
    residual: number | null
    passed: boolean | null
  }
}

/** Ab hier gilt ein Delta als echte Preisbewegung und nicht als Rundungsrest. */
const EFFECT_TOLERANCE = 0.000001

/**
 * Wirkungsart einer einzelnen Zuordnung bestimmen.
 *
 * Die Prüfreihenfolge ist Teil der Spezifikation und nicht beliebig: eine neu
 * hinzugekommene Position mit Preis 0 ist eben KEIN Umfangszuwachs, sondern ein
 * Datenqualitätsbefund — würde man erst auf das Delta prüfen, fiele sie als
 * „unverändert" durch und niemand sähe sie an.
 */
function classify(m: MaterialMapping): { effect: MaterialEffectBucket; zeroCost: boolean } {
  const award = m.costAward ?? 0
  const current = m.costCurrent ?? 0
  const delta = m.delta ?? 0

  if (m.matchType === 'added') {
    if (current > EFFECT_TOLERANCE) return { effect: 'scope_added', zeroCost: false }
    // Benannt, aber ohne Kosten: kein Umfangszuwachs, sondern ein Befund.
    //
    // Hier weicht die Umsetzung bewusst vom forensischen Referenzlauf ab, der
    // solche Positionen als Zuwachs führt. Die Spezifikation ist an dieser
    // Stelle ausdrücklich (Kap. 8.5): „added ∧ cost_current = 0 → unchanged mit
    // zero_cost: true + DQ D02". Sie ist das verbindliche Dokument, der
    // Referenzlauf liefert Sollwerte — bei Widerspruch gilt die Spezifikation.
    // Sachlich trägt sie auch: eine Position ohne Kosten erweitert den Umfang
    // wirtschaftlich nicht, sie ist ein Datenbefund.
    return { effect: 'unchanged', zeroCost: true }
  }

  if (m.matchType === 'removed') {
    // Ersatzloser Wegfall (DIF-005, „Struktur- und Kostenstatus trennen"):
    // Der Posten war benannt, aber nie bepreist — sein Verschwinden bewegt
    // keinen Preis, es verkleinert den Umfang. Zahlenbasiert als Spiegelfall
    // der added-Regel aus Kap. 8.5 (added ∧ cost_current = 0), die die
    // Spezifikation für removed nicht ausformuliert; PO-Entscheidung Kais
    // 07.08.2026 („Option a, bau scope_removed zahlenbasiert"). Der
    // Referenzlauf führt den Fall ebenso als scope_removed.
    if (Math.abs(award) <= EFFECT_TOLERANCE) return { effect: 'scope_removed', zeroCost: false }
    return { effect: 'price_removed', zeroCost: false }
  }

  if (m.matchType === 'merged' || m.matchType === 'split') return { effect: 'consolidation', zeroCost: false }

  // Position war schon benannt, wurde aber erstmals bepreist — im Referenzfall
  // der grösste Einzeltreiber überhaupt.
  if (award === 0 && current > EFFECT_TOLERANCE) return { effect: 'scope_activated', zeroCost: false }

  // Der Spiegelfall: eine Position, die auf null fällt, ist wirtschaftlich
  // weggefallen und keine Preisänderung. „Der Posten kostet nichts mehr" ist in
  // einer Verhandlung eine andere Aussage als „der Posten ist billiger
  // geworden" — und die Symmetrie zur erstmaligen Bepreisung ist keine
  // Kosmetik: beide Male wechselt eine Position zwischen „im Preis enthalten"
  // und „nicht enthalten".
  if (award > EFFECT_TOLERANCE && current === 0) return { effect: 'price_removed', zeroCost: false }

  if (Math.abs(delta) > EFFECT_TOLERANCE) return { effect: 'price_change', zeroCost: false }
  return { effect: 'unchanged', zeroCost: award === 0 && current === 0 }
}

const EMPTY_BUCKETS = (): Record<MaterialEffectBucket, { delta: number; count: number }> => ({
  scope_added: { delta: 0, count: 0 },
  scope_activated: { delta: 0, count: 0 },
  price_change: { delta: 0, count: 0 },
  price_removed: { delta: 0, count: 0 },
  scope_removed: { delta: 0, count: 0 },
  consolidation: { delta: 0, count: 0 },
  unchanged: { delta: 0, count: 0 },
})

/**
 * Zuordnungen klassifizieren und gegen das Blattdelta rekonsilieren.
 *
 * `sheetDelta` ist die Materialzeile der Zusammenfassung. Geht die Summe der
 * Positions-Deltas dagegen nicht auf, ist das ein Befund und keine Formsache:
 * dann erklärt die Positionsebene die Blattzahl nicht, und jede Aussage über
 * „den grössten Treiber" steht auf tönernen Füssen. Ohne Blattzahl wird nicht
 * geprüft — und das steht als `null` da, nicht als bestanden.
 */
export function classifyMaterialEffects(
  mappings: MaterialMapping[],
  sheetDelta: number | null,
): MaterialEffectResult {
  const classified: ClassifiedMapping[] = mappings.map((m) => ({ ...m, ...classify(m) }))
  const buckets = EMPTY_BUCKETS()

  for (const m of classified) {
    buckets[m.effect].delta += m.delta ?? 0
    buckets[m.effect].count += 1
  }

  const sumMappingDeltas = classified.reduce((s, m) => s + (m.delta ?? 0), 0)
  const residual = sheetDelta === null ? null : sheetDelta - sumMappingDeltas

  return {
    mappings: classified,
    buckets,
    reconciliation: {
      sumMappingDeltas,
      sheetDelta,
      residual,
      passed: residual === null ? null : Math.abs(residual) <= 0.005,
    },
  }
}
