// Stationsmapping und Parameteridentität (Spezifikation Kap. 7.4, R-10/R-16).
//
// Die Kernaussage einer Fertigungsanalyse lautet oft „die Hauptlinie ist
// unverändert, das Delta kommt aus neuen Stationen". Das ist eine starke
// Behauptung — und sie lässt sich belegen oder widerlegen, wenn man die
// Parameter vergleicht statt der Namen: Zykluszeit, Mitarbeiterzahl,
// Maschinen- und Gemeinkostensatz.
//
// Zwei Stationen mit identischen Parametern sind dieselbe Station, auch wenn
// jemand sie umbenannt hat. Zwei Stationen mit gleichem Namen, aber anderen
// Parametern sind es nicht — dort hat sich etwas geändert, und das gehört
// ausgewiesen statt eingeebnet.
//
// Pure Funktionen, keine I/O.

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

/** Die Parameter, die eine Station wirtschaftlich beschreiben. */
export interface StationParameters {
  cycleSeconds: number | null
  workers: number | null
  /** Maschinenstundensatz. */
  machineRatePerHour: number | null
  /** Fertigungsgemeinkostensatz je Stunde. */
  overheadRatePerHour: number | null
}

export interface MappableStation extends StationParameters {
  row: number
  name: string
  /** Fertigungskosten je Stück in Angebotswährung. */
  costPerPiece: number | null
}

export type StationMatchType = 'exact' | 'renamed' | 'added' | 'removed'

export interface StationMapping {
  mappingId: string
  awardRow: number | null
  currentRow: number | null
  awardName: string | null
  currentName: string | null
  matchType: StationMatchType
  /** Sind alle vier Parameter innerhalb der Toleranz gleich? */
  parametersIdentical: boolean
  matchEvidence: { ruleId: string; detailsDe: string }
  costAward: number | null
  costCurrent: number | null
  delta: number | null
}

export interface StationMappingResult {
  mappings: StationMapping[]
  counts: {
    stationsAward: number
    stationsCurrent: number
    byMatchType: Record<StationMatchType, number>
  }
  /**
   * Trägt die Kernaussage: Sind alle wiedergefundenen Stationen
   * parameteridentisch? Dann kommt das Delta ausschliesslich aus neuen und
   * entfallenen Stationen — und genau das lässt sich dann sagen.
   */
  mainLineUnchanged: boolean
  reconciliation: {
    sumStationDeltas: number
    sheetDelta: number | null
    residual: number | null
    passed: boolean | null
    /** Anteil des Deltas, der auf neue Stationen entfällt. */
    newStationsSum: number
  }
}

/**
 * Toleranzen je Parameter.
 *
 * Zykluszeiten stehen in ganzen oder halben Sekunden, Sätze mit vier
 * Nachkommastellen — eine gemeinsame Toleranz für beide wäre für das eine zu
 * grob und für das andere zu fein.
 */
const TOLERANCE = {
  cycleSeconds: 0.01,
  workers: 0.001,
  ratePerHour: 0.005,
} as const

function near(a: number | null, b: number | null, tolerance: number): boolean {
  if (a === null && b === null) return true
  if (a === null || b === null) return false
  return Math.abs(a - b) <= tolerance
}

/**
 * Sind zwei Stationen parameteridentisch?
 *
 * Beidseitig fehlende Werte gelten als gleich — zwei Stationen ohne
 * Gemeinkostensatz unterscheiden sich in diesem Punkt nicht. Einseitig
 * fehlende gelten als ungleich: dort ist eine Angabe verschwunden oder
 * hinzugekommen, und das ist eine Änderung.
 */
export function parametersIdentical(a: StationParameters, b: StationParameters): boolean {
  return (
    near(a.cycleSeconds, b.cycleSeconds, TOLERANCE.cycleSeconds) &&
    near(a.workers, b.workers, TOLERANCE.workers) &&
    near(a.machineRatePerHour, b.machineRatePerHour, TOLERANCE.ratePerHour) &&
    near(a.overheadRatePerHour, b.overheadRatePerHour, TOLERANCE.ratePerHour)
  )
}

const EMPTY_COUNTS: Record<StationMatchType, number> = { exact: 0, renamed: 0, added: 0, removed: 0 }

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

function build(
  id: number,
  award: MappableStation | null,
  current: MappableStation | null,
  matchType: StationMatchType,
  ruleId: string,
  detailsDe: string,
): StationMapping {
  const identical = award !== null && current !== null ? parametersIdentical(award, current) : false
  return {
    mappingId: `STA-${String(id).padStart(3, '0')}`,
    awardRow: award?.row ?? null,
    currentRow: current?.row ?? null,
    awardName: award?.name ?? null,
    currentName: current?.name ?? null,
    matchType,
    parametersIdentical: identical,
    matchEvidence: { ruleId, detailsDe },
    costAward: award?.costPerPiece ?? null,
    costCurrent: current?.costPerPiece ?? null,
    delta: delta(award?.costPerPiece ?? null, current?.costPerPiece ?? null),
  }
}

/**
 * Stationen beider Stände einander zuordnen.
 *
 * Durchgang 1 — beidseitig eindeutiger Name.
 * Durchgang 2 — Umbenennung: identische Parameter bei abweichendem Namen. Eine
 * Station, deren Zykluszeit, Personal und Sätze übereinstimmen, ist dieselbe
 * Station; der Name ist das schwächste ihrer Merkmale.
 * Rest — neu beziehungsweise entfallen.
 *
 * `sheetDelta` ist die Fertigungszeile der Zusammenfassung. Ohne sie wird nicht
 * rekonsiliert, und das steht als „nicht geprüft" da.
 */
export function mapStations(
  award: MappableStation[],
  current: MappableStation[],
  sheetDelta: number | null = null,
): StationMappingResult {
  const key = (s: MappableStation) => normalizeMaterialName(s.name).normalized
  const index = (list: MappableStation[]) => {
    const m = new Map<string, MappableStation[]>()
    for (const s of list) {
      const k = key(s)
      if (k === '') continue
      m.set(k, [...(m.get(k) ?? []), s])
    }
    return m
  }

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

  // Durchgang 1: beidseitig eindeutiger Name. Nach Blattreihenfolge, damit zwei
  // Läufe dieselben Zuordnungs-IDs vergeben.
  const uniqueKeys = [...awardByName.entries()]
    .filter(([k, hits]) => hits.length === 1 && currentByName.get(k)?.length === 1)
    .sort(([, a], [, b]) => a[0].row - b[0].row)
    .map(([k]) => k)

  for (const k of uniqueKeys) {
    const a = awardByName.get(k)![0]
    const c = currentByName.get(k)![0]
    const identical = parametersIdentical(a, c)
    mappings.push(
      build(
        ++id,
        a,
        c,
        'exact',
        'name_exact_unique',
        identical
          ? `Gleiche Bezeichnung „${a.name}", identische Parameter.`
          : `Gleiche Bezeichnung „${a.name}", aber abweichende Parameter — die Station hat sich verändert.`,
      ),
    )
    usedAward.add(a.row)
    usedCurrent.add(c.row)
  }

  // Durchgang 2: Umbenennung über Parameteridentität.
  for (const a of [...award].sort((x, y) => x.row - y.row)) {
    if (usedAward.has(a.row)) continue
    const c = [...current]
      .sort((x, y) => x.row - y.row)
      .find((cand) => !usedCurrent.has(cand.row) && parametersIdentical(a, cand))
    if (!c) continue
    mappings.push(
      build(
        ++id,
        a,
        c,
        'renamed',
        'parameters_identical',
        `„${a.name}" und „${c.name}" stimmen in Zykluszeit, Personal und Sätzen überein — dieselbe Station unter anderem Namen.`,
      ),
    )
    usedAward.add(a.row)
    usedCurrent.add(c.row)
  }

  for (const c of [...current].sort((x, y) => x.row - y.row)) {
    if (usedCurrent.has(c.row)) continue
    mappings.push(build(++id, null, c, 'added', 'unmatched_current', `Station „${c.name}" ist neu hinzugekommen.`))
  }
  for (const a of [...award].sort((x, y) => x.row - y.row)) {
    if (usedAward.has(a.row)) continue
    mappings.push(build(++id, a, null, 'removed', 'unmatched_award', `Station „${a.name}" ist entfallen.`))
  }

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

  const paired = mappings.filter((m) => m.matchType === 'exact' || m.matchType === 'renamed')
  const sumStationDeltas = mappings.reduce((s, m) => s + (m.delta ?? 0), 0)
  const newStationsSum = mappings
    .filter((m) => m.matchType === 'added')
    .reduce((s, m) => s + (m.delta ?? 0), 0)
  const residual = sheetDelta === null ? null : sheetDelta - sumStationDeltas

  return {
    mappings,
    counts: { stationsAward: award.length, stationsCurrent: current.length, byMatchType },
    // Ohne ein einziges Paar ist die Aussage nicht „unverändert", sondern
    // gegenstandslos — dann gibt es keine Hauptlinie, über die man reden könnte.
    mainLineUnchanged: paired.length > 0 && paired.every((m) => m.parametersIdentical),
    reconciliation: {
      sumStationDeltas,
      sheetDelta,
      residual,
      passed: residual === null ? null : Math.abs(residual) <= 0.005,
      newStationsSum,
    },
  }
}
