// Positions-Mapping Material, Durchgang 1 und Restklassen (Kap. 8.4).

import { describe, it, expect } from 'vitest'
import { buildMaterialMappings, type MappablePosition } from '../material-mapping'

const pos = (row: number, name: string, cost: number | null): MappablePosition => ({ row, name, cost })

describe('buildMaterialMappings — eindeutige Namensgleichheit', () => {
  it('ordnet eine beidseitig eindeutige Position zu und rechnet ihr Delta', () => {
    const r = buildMaterialMappings([pos(10, 'Sensor', 5)], [pos(12, 'Sensor', 7)])
    const m = r.mappings[0]

    expect(m.matchType).toBe('exact')
    expect(m.awardRows).toEqual([10])
    expect(m.currentRows).toEqual([12])
    expect(m.delta).toBeCloseTo(2, 6)
    expect(m.matchEvidence.ruleId).toBe('name_exact_unique')
  })

  it('greift auch über Schreibweise und Mengenpräfix hinweg', () => {
    const r = buildMaterialMappings([pos(1, '3 x Schraube', 3)], [pos(1, 'SCREW', 4)])
    expect(r.mappings[0].matchType).toBe('exact')
  })

  it('überlässt mehrfach vorkommende Namen dem Gleichteil-Durchgang', () => {
    // „Screw" zweimal auf jeder Seite: keine eindeutige Namensgleichheit, also
    // nichts für Durchgang 1 — die Zuordnung übernimmt Durchgang 2 über die
    // erhaltene Reihenfolge.
    const r = buildMaterialMappings(
      [pos(1, 'Screw', 1), pos(2, 'Screw', 2)],
      [pos(1, 'Screw', 1.5), pos(2, 'Screw', 2.5)],
    )
    expect(r.counts.byMatchType.exact).toBe(0)
    expect(r.counts.byMatchType.duplicate_context).toBe(2)
    expect(r.counts.byMatchType.added).toBe(0)
    expect(r.counts.byMatchType.removed).toBe(0)
  })

  it('behandelt namenlose Positionen nicht als gleich', () => {
    const r = buildMaterialMappings([pos(1, '', 1)], [pos(1, '   ', 2)])
    expect(r.counts.byMatchType.exact).toBe(0)
  })
})

describe('buildMaterialMappings — Restklassen', () => {
  const award = [pos(1, 'Sensor', 5), pos(2, 'Dichtung', 2)]
  const current = [pos(1, 'Sensor', 6), pos(2, 'Halter', 3)]
  const r = buildMaterialMappings(award, current)

  it('führt eine nur rechts vorhandene Position als neu', () => {
    const added = r.mappings.find((m) => m.matchType === 'added')!
    expect(added.currentNames).toEqual(['Halter'])
    expect(added.awardRows).toEqual([])
    expect(added.costAward).toBeNull()
    expect(added.delta).toBeCloseTo(3, 6)
  })

  it('führt eine nur links vorhandene Position als entfallen', () => {
    const removed = r.mappings.find((m) => m.matchType === 'removed')!
    expect(removed.awardNames).toEqual(['Dichtung'])
    expect(removed.costCurrent).toBeNull()
    expect(removed.delta).toBeCloseTo(-2, 6)
  })

  it('ordnet jede Position genau einmal zu', () => {
    const awardRows = r.mappings.flatMap((m) => m.awardRows)
    const currentRows = r.mappings.flatMap((m) => m.currentRows)
    expect(awardRows.sort()).toEqual([1, 2])
    expect(currentRows.sort()).toEqual([1, 2])
  })

  it('zählt Positionen und Zuordnungsarten', () => {
    expect(r.counts.positionsAward).toBe(2)
    expect(r.counts.positionsCurrent).toBe(2)
    expect(r.counts.byMatchType.exact).toBe(1)
    expect(r.counts.byMatchType.added).toBe(1)
    expect(r.counts.byMatchType.removed).toBe(1)
  })
})

describe('buildMaterialMappings — Summen und Determinismus', () => {
  it('erklärt das Blattdelta vollständig aus den Zuordnungen', () => {
    // Die Invariante, an der die ganze Ebene hängt: Summe der Zuordnungs-Deltas
    // gleich Summe der Positionen rechts minus links.
    const award = [pos(1, 'A', 10), pos(2, 'B', 20), pos(3, 'C', 5)]
    const current = [pos(1, 'A', 12), pos(2, 'D', 8), pos(3, 'C', 5)]
    const r = buildMaterialMappings(award, current)

    const sumMappings = r.mappings.reduce((s, m) => s + (m.delta ?? 0), 0)
    const sumAward = award.reduce((s, p) => s + (p.cost ?? 0), 0)
    const sumCurrent = current.reduce((s, p) => s + (p.cost ?? 0), 0)
    expect(sumMappings).toBeCloseTo(sumCurrent - sumAward, 6)
  })

  it('vergibt bei gleicher Eingabe dieselben Zuordnungs-IDs', () => {
    // MEHRERE eindeutige Treffer, damit die Vergabereihenfolge überhaupt
    // beobachtbar ist: mit nur einem Treffer wäre der Test grün, ohne etwas
    // zu beweisen.
    const award = [pos(3, 'C', 1), pos(1, 'A', 2), pos(2, 'B', 5)]
    const current = [pos(2, 'B', 6), pos(1, 'A', 4), pos(3, 'C', 9)]
    const ids = (a: MappablePosition[], c: MappablePosition[]) =>
      buildMaterialMappings(a, c).mappings.map((m) => `${m.mappingId}:${m.matchType}:${m.awardNames.join('+')}`)

    // Die Eingabereihenfolge darf das Ergebnis nicht verändern.
    expect(ids([...award].reverse(), [...current].reverse())).toEqual(ids(award, current))
    expect(ids(award, [...current].reverse())).toEqual(ids(award, current))
  })

  it('lässt eine Position ohne Kostenangabe null statt sie als 0 zu zählen', () => {
    const r = buildMaterialMappings([pos(1, 'A', null)], [pos(1, 'A', null)])
    expect(r.mappings[0].costAward).toBeNull()
    expect(r.mappings[0].delta).toBeNull()
  })
})

describe('buildMaterialMappings — Gleichteile über die Reihenfolge (Durchgang 2)', () => {
  it('paart gleichnamige Positionen in ihrer Blattreihenfolge', () => {
    // Der Referenzfall: dieselbe Schraube an drei Stellen, beide Stände in
    // gleicher Ordnung, die Zeilennummern sind nur verschoben.
    const r = buildMaterialMappings(
      [pos(21, 'Screw BM6x25', 1), pos(22, 'Screw BM6x25', 2), pos(24, 'Screw BM6x25', 3)],
      [pos(18, 'Screw BM6x25', 1.1), pos(19, 'Screw BM6x25', 2.1), pos(21, 'Screw BM6x25', 3.1)],
    )

    expect(r.counts.byMatchType.duplicate_context).toBe(3)
    const pairs = r.mappings.map((m) => [m.awardRows[0], m.currentRows[0]])
    expect(pairs).toEqual([
      [21, 18],
      [22, 19],
      [24, 21],
    ])
  })

  it('lässt den Überhang ungepaart statt ihn zu verteilen', () => {
    // Zwei links, drei rechts: welche der drei ist die neue? Das ist nicht
    // entscheidbar, also wird es nicht entschieden.
    const r = buildMaterialMappings(
      [pos(1, 'Screw', 1), pos(2, 'Screw', 1)],
      [pos(1, 'Screw', 1), pos(2, 'Screw', 1), pos(3, 'Screw', 1)],
    )

    expect(r.counts.byMatchType.duplicate_context).toBe(2)
    expect(r.counts.byMatchType.added).toBe(1)
    expect(r.counts.byMatchType.removed).toBe(0)
  })

  it('nennt in der Begründung die Anzahl und das Vorkommen', () => {
    const r = buildMaterialMappings(
      [pos(1, 'Screw', 1), pos(2, 'Screw', 1)],
      [pos(1, 'Screw', 1), pos(2, 'Screw', 1)],
    )
    const first = r.mappings[0]
    expect(first.matchEvidence.ruleId).toBe('duplicate_order_preserved')
    expect(first.matchEvidence.detailsDe).toContain('2×')
    expect(first.matchEvidence.detailsDe).toContain('1. Vorkommen')
  })

  it('weist auf die ungleiche Anzahl hin, wenn sie vorliegt', () => {
    const r = buildMaterialMappings([pos(1, 'Screw', 1)], [pos(1, 'Screw', 1), pos(2, 'Screw', 1)])
    expect(r.mappings[0].matchEvidence.detailsDe).toContain('Ungleiche Anzahl')
  })

  it('erklärt auch mit Gleichteilen das Blattdelta vollständig', () => {
    const award = [pos(1, 'Screw', 1), pos(2, 'Screw', 2), pos(3, 'Sensor', 10)]
    const current = [pos(1, 'Screw', 1.5), pos(2, 'Screw', 2.5), pos(3, 'Sensor', 11)]
    const r = buildMaterialMappings(award, current)

    const sumMappings = r.mappings.reduce((s, m) => s + (m.delta ?? 0), 0)
    expect(sumMappings).toBeCloseTo(15 - 13, 6)
  })
})

describe('buildMaterialMappings — Zusammenfassung und Aufteilung (Durchgang 3)', () => {
  it('erkennt eine Zusammenfassung am Namen der Sammelposition', () => {
    // Der Referenzfall: zwei Wärmetauscher-Positionen werden zu einer.
    const r = buildMaterialMappings(
      [pos(15, 'Chiller', 30), pos(16, 'WCC / IHX', 25)],
      [pos(15, 'HEX (WCC IHX / Chiller)', 50)],
    )
    const merged = r.mappings.find((m) => m.matchType === 'merged')!

    expect(merged.awardRows).toEqual([15, 16])
    expect(merged.currentRows).toEqual([15])
    expect(merged.costAward).toBeCloseTo(55, 6)
    expect(merged.costCurrent).toBeCloseTo(50, 6)
    expect(merged.delta).toBeCloseTo(-5, 6)
    expect(merged.matchEvidence.detailsDe).toContain('Chiller')
  })

  it('findet die Zusammenfassung auch bei geändertem Preis', () => {
    // Über Kostengleichheit wäre dieser Fall nicht zu finden — und genau dann
    // ist er interessant.
    const r = buildMaterialMappings(
      [pos(1, 'Chiller', 30), pos(2, 'WCC / IHX', 25)],
      [pos(1, 'HEX (WCC IHX / Chiller)', 120)],
    )
    expect(r.counts.byMatchType.merged).toBe(1)
    expect(r.mappings[0].delta).toBeCloseTo(65, 6)
  })

  it('erkennt eine Aufteilung in der Gegenrichtung', () => {
    const r = buildMaterialMappings(
      [pos(1, 'HEX (WCC IHX / Chiller)', 50)],
      [pos(1, 'Chiller', 30), pos(2, 'WCC / IHX', 25)],
    )
    const split = r.mappings.find((m) => m.matchType === 'split')!

    expect(split.awardRows).toEqual([1])
    expect(split.currentRows).toEqual([1, 2])
    expect(split.delta).toBeCloseTo(5, 6)
  })

  it('macht aus einer einzelnen Namensüberschneidung keine Zusammenfassung', () => {
    // Ein Teil allein ist keine Zusammenfassung — das wäre eine Umbenennung
    // und gehört in einen anderen Durchgang mit eigenen Kriterien.
    const r = buildMaterialMappings([pos(1, 'Chiller', 30)], [pos(1, 'HEX (Chiller)', 30)])
    expect(r.counts.byMatchType.merged).toBe(0)
  })

  it('erklärt auch mit Zusammenfassungen das Blattdelta vollständig', () => {
    const award = [pos(1, 'Chiller', 30), pos(2, 'WCC / IHX', 25), pos(3, 'Sensor', 10)]
    const current = [pos(1, 'HEX (WCC IHX / Chiller)', 50), pos(2, 'Sensor', 12)]
    const r = buildMaterialMappings(award, current)

    const sumMappings = r.mappings.reduce((s, m) => s + (m.delta ?? 0), 0)
    expect(sumMappings).toBeCloseTo(62 - 65, 6)
  })

  it('ordnet jede Position auch mit Zusammenfassung genau einmal zu', () => {
    const award = [pos(1, 'Chiller', 30), pos(2, 'WCC / IHX', 25), pos(3, 'Sensor', 10)]
    const current = [pos(1, 'HEX (WCC IHX / Chiller)', 50), pos(2, 'Sensor', 12)]
    const r = buildMaterialMappings(award, current)

    expect(r.mappings.flatMap((m) => m.awardRows).sort()).toEqual([1, 2, 3])
    expect(r.mappings.flatMap((m) => m.currentRows).sort()).toEqual([1, 2])
  })
})

describe('buildMaterialMappings — Umbenennung (Durchgang 4)', () => {
  it('erkennt eine Umbenennung an Sequenzlage und Wortstamm', () => {
    // Der Referenzfall: aus „(left)" wird „(back)", der Preis ändert sich.
    const r = buildMaterialMappings(
      [pos(49, 'Anker A', 1), pos(50, 'Sound Insulation (left)', 4.6), pos(52, 'Anker B', 2)],
      [pos(46, 'Anker A', 1), pos(47, 'Sound Insulation (back)', 5.61), pos(49, 'Anker B', 2)],
    )
    const renamed = r.mappings.find((m) => m.matchType === 'renamed')!

    expect(renamed.awardNames).toEqual(['Sound Insulation (left)'])
    expect(renamed.currentNames).toEqual(['Sound Insulation (back)'])
    expect(renamed.delta).toBeCloseTo(1.01, 6)
    expect(renamed.matchEvidence.ruleId).toBe('anchor_window_and_stem')
  })

  it('verlangt beide Kriterien — Sequenzlage allein genügt nicht', () => {
    // Gleiche Stelle, aber nichts Gemeinsames im Namen und anderer Preis:
    // hier wären zwei beliebige Nachbarn verheiratet worden.
    const r = buildMaterialMappings(
      [pos(1, 'Anker', 1), pos(2, 'Dichtung', 4), pos(4, 'Anker B', 2)],
      [pos(1, 'Anker', 1), pos(2, 'Kabelbaum', 9), pos(4, 'Anker B', 2)],
    )
    expect(r.counts.byMatchType.renamed).toBe(0)
    expect(r.counts.byMatchType.added).toBe(1)
    expect(r.counts.byMatchType.removed).toBe(1)
  })

  it('nimmt gleichen Preis als zweites Kriterium an', () => {
    const r = buildMaterialMappings(
      [pos(1, 'Anker', 1), pos(2, 'Alte Bezeichnung', 4), pos(4, 'Anker B', 2)],
      [pos(1, 'Anker', 1), pos(2, 'Voellig andere Bezeichnung', 4), pos(4, 'Anker B', 2)],
    )
    expect(r.counts.byMatchType.renamed).toBe(1)
  })

  it('verheiratet keine Positionen aus verschiedenen Ankerfenstern', () => {
    // Gleicher Wortstamm, aber an ganz anderer Stelle der Liste.
    const r = buildMaterialMappings(
      [pos(1, 'Anker', 1), pos(2, 'Sound Insulation (left)', 4), pos(3, 'Anker B', 2), pos(9, 'Anker C', 3)],
      [pos(1, 'Anker', 1), pos(2, 'Anker B', 2), pos(8, 'Sound Insulation (back)', 4), pos(9, 'Anker C', 3)],
    )
    expect(r.counts.byMatchType.renamed).toBe(0)
  })

  it('erklärt auch mit Umbenennung das Blattdelta vollständig', () => {
    const award = [pos(1, 'Anker', 1), pos(2, 'Sound Insulation (left)', 4.6), pos(4, 'Anker B', 2)]
    const current = [pos(1, 'Anker', 1), pos(2, 'Sound Insulation (back)', 5.61), pos(4, 'Anker B', 2)]
    const r = buildMaterialMappings(award, current)

    const sumMappings = r.mappings.reduce((s, m) => s + (m.delta ?? 0), 0)
    expect(sumMappings).toBeCloseTo(8.61 - 7.6, 6)
  })
})

describe('buildMaterialMappings — Zusammenfassung über die Baugruppe (Durchgang 3c)', () => {
  const g = (row: number, name: string, cost: number | null, group: string): MappablePosition => ({
    row,
    name,
    cost,
    group,
  })

  it('fasst mehrere Positionen einer Gruppe in der einzigen verbliebenen zusammen', () => {
    // Der Referenzfall: fünf Leitungspositionen gehen in „AC Lines" auf. Ihre
    // Namen kommen im Sammelnamen nicht vor — über den Namen unfindbar.
    const r = buildMaterialMappings(
      [
        g(17, 'LP IHX COMPRESSOR', 10, 'AC Lines'),
        g(18, 'HP EXV - IHX', 8, 'AC Lines'),
        g(19, 'LP_CHILLER-WCC', 6, 'AC Lines'),
      ],
      [g(16, 'AC Lines', 20, 'AC Lines')],
    )
    const merged = r.mappings.find((m) => m.matchType === 'merged')!

    expect(merged.awardRows).toEqual([17, 18, 19])
    expect(merged.currentRows).toEqual([16])
    expect(merged.delta).toBeCloseTo(-4, 6)
    expect(merged.matchEvidence.ruleId).toBe('group_collapses_to_single')
  })

  it('entscheidet nichts, wenn beidseitig mehrere Positionen übrig bleiben', () => {
    // Die grosse Sammelgruppe des Referenzfalls: dort wäre jede Zuordnung
    // geraten, also wird keine getroffen.
    const r = buildMaterialMappings(
      [g(1, 'A', 1, 'Module Assy'), g(2, 'B', 2, 'Module Assy')],
      [g(1, 'C', 3, 'Module Assy'), g(2, 'D', 4, 'Module Assy')],
    )
    expect(r.counts.byMatchType.merged).toBe(0)
    expect(r.counts.byMatchType.split).toBe(0)
    expect(r.counts.byMatchType.added).toBe(2)
    expect(r.counts.byMatchType.removed).toBe(2)
  })

  it('greift nicht auf Positionen, die schon zugeordnet sind', () => {
    // „Sensor" wird über den Namen gepaart; danach bleibt links nur eine
    // Position übrig, und die Gruppenregel darf sie nicht mit dem Sensor
    // verheiraten.
    const r = buildMaterialMappings(
      [g(1, 'Sensor', 5, 'AC Lines'), g(2, 'Leitung', 3, 'AC Lines')],
      [g(1, 'Sensor', 6, 'AC Lines')],
    )
    expect(r.counts.byMatchType.exact).toBe(1)
    expect(r.counts.byMatchType.merged).toBe(0)
    expect(r.counts.byMatchType.removed).toBe(1)
  })

  it('erkennt die Aufteilung in der Gegenrichtung', () => {
    const r = buildMaterialMappings(
      [g(1, 'AC Lines', 20, 'AC Lines')],
      [g(1, 'Leitung A', 10, 'AC Lines'), g(2, 'Leitung B', 12, 'AC Lines')],
    )
    const split = r.mappings.find((m) => m.matchType === 'split')!
    expect(split.currentRows).toEqual([1, 2])
    expect(split.delta).toBeCloseTo(2, 6)
  })

  it('lässt Positionen ohne Gruppenangabe unberührt', () => {
    const r = buildMaterialMappings(
      [{ row: 1, name: 'A', cost: 1 }, { row: 2, name: 'B', cost: 2 }],
      [{ row: 1, name: 'C', cost: 5 }],
    )
    expect(r.counts.byMatchType.merged).toBe(0)
  })

  it('erklärt auch mit Gruppen-Zusammenfassung das Blattdelta vollständig', () => {
    const award = [g(1, 'L1', 10, 'AC Lines'), g(2, 'L2', 8, 'AC Lines'), g(3, 'Sensor', 5, 'Module Assy')]
    const current = [g(1, 'AC Lines', 20, 'AC Lines'), g(2, 'Sensor', 6, 'Module Assy')]
    const r = buildMaterialMappings(award, current)

    const sumMappings = r.mappings.reduce((s, m) => s + (m.delta ?? 0), 0)
    expect(sumMappings).toBeCloseTo(26 - 23, 6)
  })
})

describe('buildMaterialMappings — aufzählende Sammelnamen (Durchgang 3b2)', () => {
  it('erkennt eine Aufteilung, wenn der Sammelname seine Teile aufzählt', () => {
    // Der Referenzfall: „4 x EXV / 1 x Valve Block, 1 x pT Sensor" wird zu
    // „EXV Module" und „pT Sensor". Die strenge Regel findet nur den Sensor,
    // weil bei „EXV Module" das Wort „Module" im Sammelnamen fehlt.
    const r = buildMaterialMappings(
      [pos(14, '4 x EXV / 1 x Valve Block, 1 x pT Sensor', 40)],
      [pos(14, 'EXV Module', 30), pos(15, 'pT Sensor', 17)],
    )
    const split = r.mappings.find((m) => m.matchType === 'split')!

    expect(split.awardRows).toEqual([14])
    expect(split.currentRows).toEqual([14, 15])
    expect(split.delta).toBeCloseTo(7, 6)
    expect(split.matchEvidence.ruleId).toBe('enumerating_name')
  })

  it('verlangt Aufzählungszeichen im Sammelnamen', () => {
    // Ohne Komma oder Schrägstrich gibt sich der Name nicht als Aufzählung zu
    // erkennen — dann bliebe nur ein gemeinsames Wort, und das ist zu wenig.
    const r = buildMaterialMappings(
      [pos(1, 'EXV Valve Sensor Block', 40)],
      [pos(1, 'EXV Module', 30), pos(2, 'Sensor Halter', 17)],
    )
    expect(r.counts.byMatchType.split).toBe(0)
  })

  it('verlangt, dass die Teile verschiedene Stichwörter aufgreifen', () => {
    // Zwei Positionen, die sich beide auf dasselbe Wort stützen, sind kein
    // Beleg für eine Aufteilung.
    const r = buildMaterialMappings(
      [pos(1, 'Sensor / Halter, Klemme', 40)],
      [pos(1, 'Sensor A', 20), pos(2, 'Sensor B', 20)],
    )
    expect(r.counts.byMatchType.split).toBe(0)
  })

  it('lässt Ziffern und Kürzel nicht als Beleg gelten', () => {
    // Der Sammelname trägt zwei aussagekräftige Wörter (damit die Regel
    // überhaupt anläuft) und zusätzlich zwei Ziffern. Die beiden Teile greifen
    // je EINE dieser Ziffern auf — und nichts sonst.
    //
    // Diese Fixture ist der dritte Anlauf: Zwei frühere Fassungen waren grün,
    // ohne die Signifikanz-Bedingung zu prüfen, weil andere Bremsen (gleiches
    // Stichwort, abgetrenntes Mengenpräfix) vorher zugriffen. Erst hier fällt
    // der Test, wenn man die Bedingung entfernt.
    const r = buildMaterialMappings(
      [pos(1, 'Alpha, Beta, 2, 3', 40)],
      [pos(1, 'Teil 2', 20), pos(2, 'Teil 3', 20)],
    )
    expect(r.counts.byMatchType.split).toBe(0)
  })

  it('erklärt auch mit aufzählendem Sammelnamen das Blattdelta vollständig', () => {
    const award = [pos(1, '4 x EXV / 1 x Valve Block, 1 x pT Sensor', 40), pos(2, 'Sensor extra', 5)]
    const current = [pos(1, 'EXV Module', 30), pos(2, 'pT Sensor', 17), pos(3, 'Sensor extra', 6)]
    const r = buildMaterialMappings(award, current)

    const sumMappings = r.mappings.reduce((s, m) => s + (m.delta ?? 0), 0)
    expect(sumMappings).toBeCloseTo(53 - 45, 6)
  })
})
