// Effekt-Klassen und Rekonsiliation der Materialebene (Kap. 8.5, R-09/R-16).
//
// 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".

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

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

function effectsOf(award: MappablePosition[], current: MappablePosition[], sheetDelta: number | null = null) {
  return classifyMaterialEffects(buildMaterialMappings(award, current).mappings, sheetDelta)
}

describe('classifyMaterialEffects — Wirkungsarten', () => {
  it('führt eine neue Position mit Preis als Umfangszuwachs', () => {
    const r = effectsOf([], [pos(1, 'Sensor', 5)])
    expect(r.buckets.scope_added).toEqual({ delta: 5, count: 1 })
  })

  it('führt eine neue Position ohne Preis NICHT als Umfangszuwachs', () => {
    // Benannt, aber kostenlos: kein Zuwachs, sondern ein Datenbefund. Würde man
    // erst auf das Delta prüfen, fiele sie als „unverändert" durch und niemand
    // sähe sie an.
    const r = effectsOf([], [pos(1, 'Sensor', 0)])
    expect(r.buckets.scope_added.count).toBe(0)
    expect(r.mappings[0].zeroCost).toBe(true)
    expect(r.mappings[0].effect).toBe('unchanged')
  })

  it('führt eine entfallene Position als Wegfall', () => {
    const r = effectsOf([pos(1, 'Sensor', 5)], [])
    expect(r.buckets.price_removed).toEqual({ delta: -5, count: 1 })
  })

  it('führt Zusammenfassung und Aufteilung als Umbuchung', () => {
    const merged = effectsOf(
      [pos(1, 'Chiller', 30), pos(2, 'WCC / IHX', 25)],
      [pos(1, 'HEX (WCC IHX / Chiller)', 50)],
    )
    expect(merged.buckets.consolidation).toEqual({ delta: -5, count: 1 })
  })

  it('erkennt die erstmalige Bepreisung als Aktivierung, nicht als Preisänderung', () => {
    // Der grösste Einzeltreiber des Referenzfalls: die Position war seit der
    // Vergabe benannt, wird aber erst jetzt bepreist. Das ist keine
    // Preiserhöhung des Lieferanten.
    const r = effectsOf([pos(1, 'Bracket KF1', 0)], [pos(1, 'Bracket KF1', 9.84)])
    expect(r.buckets.scope_activated).toEqual({ delta: 9.84, count: 1 })
    expect(r.buckets.price_change.count).toBe(0)
  })

  it('führt eine echte Preisbewegung als Preisänderung', () => {
    const r = effectsOf([pos(1, 'Sensor', 5)], [pos(1, 'Sensor', 7)])
    expect(r.buckets.price_change).toEqual({ delta: 2, count: 1 })
  })

  it('führt eine unveränderte Position als unverändert', () => {
    const r = effectsOf([pos(1, 'Sensor', 5)], [pos(1, 'Sensor', 5)])
    expect(r.buckets.unchanged).toEqual({ delta: 0, count: 1 })
    expect(r.buckets.price_change.count).toBe(0)
  })
})

describe('classifyMaterialEffects — Rekonsiliation gegen das Blattdelta', () => {
  const award = [pos(1, 'A', 10), pos(2, 'B', 20), pos(3, 'C', 0)]
  const current = [pos(1, 'A', 12), pos(2, 'D', 8), pos(3, 'C', 4)]
  // A +2 (Preis) · B -20 (Wegfall) · D +8 (Zuwachs) · C +4 (Aktivierung) = -6

  it('summiert die Wirkungsarten auf die Summe aller Zuordnungen', () => {
    const r = effectsOf(award, current)
    const sumBuckets = Object.values(r.buckets).reduce((s, b) => s + b.delta, 0)
    expect(sumBuckets).toBeCloseTo(r.reconciliation.sumMappingDeltas, 6)
    expect(sumBuckets).toBeCloseTo(-6, 6)
  })

  it('bestätigt eine Positionsebene, die das Blattdelta erklärt', () => {
    const r = effectsOf(award, current, -6)
    expect(r.reconciliation.residual).toBeCloseTo(0, 6)
    expect(r.reconciliation.passed).toBe(true)
  })

  it('meldet einen Widerspruch, statt ihn zu glätten', () => {
    // Wenn die Positionen die Blattzahl nicht erklären, steht jede Aussage über
    // „den grössten Treiber" auf tönernen Füssen — das muss auffallen.
    const r = effectsOf(award, current, -4)
    expect(r.reconciliation.residual).toBeCloseTo(2, 6)
    expect(r.reconciliation.passed).toBe(false)
  })

  it('sagt „nicht geprüft" statt „bestanden", wenn keine Blattzahl vorliegt', () => {
    const r = effectsOf(award, current, null)
    expect(r.reconciliation.passed).toBeNull()
    expect(r.reconciliation.residual).toBeNull()
  })

  it('zählt jede Zuordnung genau einer Wirkungsart zu', () => {
    const r = effectsOf(award, current)
    const totalCount = Object.values(r.buckets).reduce((s, b) => s + b.count, 0)
    expect(totalCount).toBe(r.mappings.length)
  })
})

describe('classifyMaterialEffects — Wegfall auf null (Spiegelfall zur Aktivierung)', () => {
  it('führt eine auf null gefallene Position als Wegfall, nicht als Preisänderung', () => {
    // „Der Posten kostet nichts mehr" ist eine andere Aussage als „der Posten
    // ist billiger geworden".
    const r = effectsOf([pos(1, 'Shipping cap', 0.074)], [pos(1, 'Shipping cap', 0)])
    expect(r.buckets.price_removed).toEqual({ delta: -0.074, count: 1 })
    expect(r.buckets.price_change.count).toBe(0)
  })

  it('bleibt symmetrisch zur erstmaligen Bepreisung', () => {
    const activated = effectsOf([pos(1, 'A', 0)], [pos(1, 'A', 5)])
    const removed = effectsOf([pos(1, 'A', 5)], [pos(1, 'A', 0)])
    expect(activated.buckets.scope_activated.count).toBe(1)
    expect(removed.buckets.price_removed.count).toBe(1)
  })

  it('lässt eine Position, die von null auf null bleibt, unverändert', () => {
    const r = effectsOf([pos(1, 'A', 0)], [pos(1, 'A', 0)])
    expect(r.buckets.unchanged.count).toBe(1)
    expect(r.buckets.price_removed.count).toBe(0)
  })

  it('folgt bei Nullkosten-Zugängen der Spezifikation, nicht dem Referenzlauf', () => {
    // Kap. 8.5: „added ∧ cost_current = 0 → unchanged mit zero_cost: true".
    // Der Referenzlauf führt solche Positionen als Zuwachs; bei Widerspruch
    // gilt die Spezifikation.
    const r = effectsOf([], [pos(1, 'Guide Pin', 0)])
    expect(r.mappings[0].effect).toBe('unchanged')
    expect(r.mappings[0].zeroCost).toBe(true)
    expect(r.buckets.scope_added.count).toBe(0)
  })
})
