// A11 Validation Engine (execution-prompt §17) — Wertstrom P1.
//
// Categories per §17.1–17.5, but ONLY the checks whose data basis exists
// TODAY (VsmNode/VsmConnection + the same Takt/demand/shift-model/quality
// context the rest of this module uses). Checks the current data model
// genuinely cannot support (no product concept, no shift-calendar entity,
// no safety-stock/reorder-point fields, …) are listed in
// `KNOWN_VALIDATION_GAPS` — NOT fake-implemented as always-passing checks.
// See README.md for the full mapping from every §17 bullet to either an
// implemented check or a documented gap.
//
// Wertstrom P2 (KAR-878/KAR-986): VsmConnection now carries an optional
// `kind` ('materialFlow' | 'information', absent = materialFlow — see
// lib/vsm-types.ts). `structural.broken-information-flow` is no longer a
// gap: `checkDanglingConnections` below already treats every connection the
// same regardless of `kind`, so a broken/dangling information-flow edge was
// already covered generically the moment the field existed to be broken —
// removed from KNOWN_VALIDATION_GAPS accordingly. `findFirstCycle` DOES now
// need to be kind-aware (see its own comment): a customer→PPS→supplier
// information-flow loop closing back against the material-flow chain is
// completely standard VSM topology, not a modelling error. Review-Fix F9
// (PR #353 adversarial review): `checkDanglingConnections`'s whatDe/whyDe
// now also branch on `kind` — the wording used to hardcode a
// materialFlow-specific explanation even for a broken information edge.
//
// Review-Fix F4 (PR #353 adversarial review): A8's `inventoryMaxQuantity`
// ("FIFO mit Kapazität") shipped with the Zod schema accepting it but this
// module never validating it — `checkNegativeInventoryMaxQuantity` (negative
// = critical) and `checkFifoQuantityAboveCapacity` (quantity over capacity
// on a FIFO lane = warning, not critical — a real lane CAN overflow) close
// that gap; see README.md's "Wertstrom P2 addendum" for the corrected A8
// scope note.
//
// Severity policy (§17.7 "critical validations must always be active"):
//   - 'critical' = the data is mathematically/physically IMPOSSIBLE (out of
//     valid range, negative, contradictory, dangling reference) — this is
//     either corruption or a guaranteed-wrong downstream calculation.
//   - 'warning'  = the data is PLAUSIBLE but worth a human look (missing
//     supplier/customer, capacity below demand, missing rework time, …).
//   This pure lib has no concept of "disabling" a check — Expert-Mode
//   toggling of non-critical warnings (§17.7) is a UI concern, P4+, out of
//   scope here. `runValidation` always returns the full issue list; a
//   caller decides what to surface.
//
// Several checks deliberately satisfy TWO §17 bullets at once rather than
// firing the same warning twice under different ids — each such merge is
// called out in a comment at the check site.

import type { VsmConnection, VsmNode } from '@/lib/vsm-types'
import { computeEffectiveCapacity, computeUtilization, type NodeCapacityOverrides } from './capacity'
import { computeCumulativeYield, type ProcessQualityInput } from './quality'
import { formatRound1, isStationNode, type MetricExplain } from './types'
import { computeNetAvailableTimePerDay, type ShiftModelInput } from './work-time'

export type ValidationCategory = 'structural' | 'time' | 'capacity' | 'quality' | 'inventory'
export type ValidationSeverity = 'critical' | 'warning'

/** §17.6 User-Friendly Warnings: what/why/value/fix, in German, no raw
 * technical exceptions. */
export interface ValidationIssue {
  /** Stable, machine-readable code, e.g. "structural.dangling-connection-ref". */
  id: string
  category: ValidationCategory
  severity: ValidationSeverity
  nodeId?: string
  connectionId?: string
  /** 1. Was ist falsch. */
  whatDe: string
  /** 2. Warum wichtig. */
  whyDe: string
  /** 3. Welcher Wert hat es ausgelöst. */
  valueDe: string
  /** 4. Wie beheben. */
  fixDe: string
}

export interface ValidationGap {
  id: string
  category: ValidationCategory
  specRef: string
  reasonDe: string
}

export interface ValidationContext {
  taktTimeSec?: number
  demandUnitsPerDay?: number
  shiftModel?: ShiftModelInput
  /** Per-process rework/scrap data — see quality.ts for why this is a
   * separate parameter array rather than VsmNode fields. */
  qualityInputs?: ProcessQualityInput[]
  capacityOverridesByNodeId?: Record<string, NodeCapacityOverrides>
}

export interface ValidationResult {
  issues: ValidationIssue[]
  /** Static — same list regardless of input, informational (see module doc). */
  gaps: ValidationGap[]
  explain: MetricExplain
}

function labelForType(t: VsmNode['type']): string {
  switch (t) {
    case 'process':
      return 'Prozess'
    case 'machine':
      return 'Maschine'
    case 'inventory':
      return 'Bestand'
    case 'transport':
      return 'Transport'
    case 'customer':
      return 'Kunde'
    case 'supplier':
      return 'Lieferant'
    case 'timevalue':
      return 'Zeitwert'
  }
}

// ── 17.1 Structural ──────────────────────────────────────────────────────

/** Review-Fix F9 (adversarial review, PR #353): whatDe/whyDe branch on
 * `c.kind === 'information'` — the old wording was hardcoded to a
 * "Materialfluss ... Timeline-, Engpass- und Bestandsberechnungen"
 * explanation regardless of kind, which was doubly wrong for a broken
 * information-flow edge: (1) it isn't a material flow, and (2) none of
 * bottleneck.ts/timeline-ladder.ts/inventory-coverage.ts read
 * VsmConnection at all, so those calculations were never actually affected
 * by ANY dangling connection, material or information. Same kind-awareness
 * findFirstCycle/checkCycles already apply for the same new distinction
 * (see their own comments). Detection/severity are unchanged — only the
 * user-facing explanation now matches what is actually broken. */
function checkDanglingConnections(nodes: VsmNode[], connections: VsmConnection[]): ValidationIssue[] {
  const nodeIds = new Set(nodes.map((n) => n.id))
  const issues: ValidationIssue[] = []
  for (const c of connections) {
    const fromMissing = !nodeIds.has(c.fromNodeId)
    const toMissing = !nodeIds.has(c.toNodeId)
    if (fromMissing || toMissing) {
      const isInformation = c.kind === 'information'
      issues.push({
        id: 'structural.dangling-connection-ref',
        category: 'structural',
        severity: 'critical',
        connectionId: c.id,
        whatDe: `${isInformation ? 'Informationsfluss' : 'Verbindung'} "${c.id}" verweist auf einen nicht existierenden Node (${fromMissing ? `Quelle ${c.fromNodeId}` : `Ziel ${c.toNodeId}`}).`,
        whyDe: isInformation
          ? 'Ein Informationsfluss ohne gültigen Start- oder Endpunkt macht seine Quelle/sein Ziel nicht nachvollziehbar.'
          : 'Ein Materialfluss ohne gültigen Start- oder Endpunkt macht Timeline-, Engpass- und Bestandsberechnungen unzuverlässig.',
        valueDe: fromMissing ? `fromNodeId: ${c.fromNodeId}` : `toNodeId: ${c.toNodeId}`,
        fixDe: 'Verbindung löschen oder auf einen existierenden Node ummappen.',
      })
    }
  }
  return issues
}

/** Standard white/gray/black DFS cycle detection over the connection graph.
 * Reports the FIRST cycle found (a single, clear example is more useful
 * than an exhaustive enumeration for a "does this need a human look?"
 * warning) — dangling references are skipped here, `checkDanglingConnections`
 * owns those.
 *
 * Wertstrom P2: only traverses `kind !== 'information'` edges (materialFlow,
 * including every pre-P2 connection with no `kind` at all). Information-flow
 * edges routinely close a loop BY DESIGN in standard VSM topology — e.g.
 * customer→PPS (order) and PPS→supplier (schedule) information edges, drawn
 * above/around a supplier→…→customer material-flow chain, form a directed
 * cycle across the COMBINED graph even though nothing is structurally wrong.
 * Including information edges here would make `structural.unexplained-loop`
 * fire on essentially every realistic Wertstrom that uses the very A7
 * capability this phase adds. This only ever excludes `kind === 'information'`
 * edges from the cycle SEARCH — `checkDanglingConnections`/
 * `checkNegativeTimeFields` above still validate every connection regardless
 * of kind. */
function findFirstCycle(nodes: VsmNode[], connections: VsmConnection[]): string[] | null {
  const nodeIds = new Set(nodes.map((n) => n.id))
  const adjacency = new Map<string, string[]>(nodes.map((n) => [n.id, []]))
  for (const c of connections) {
    if (c.kind === 'information') continue
    if (!nodeIds.has(c.fromNodeId) || !nodeIds.has(c.toNodeId)) continue
    adjacency.get(c.fromNodeId)!.push(c.toNodeId)
  }
  const WHITE = 0
  const GRAY = 1
  const BLACK = 2
  const color = new Map<string, number>(nodes.map((n) => [n.id, WHITE]))
  const stack: string[] = []

  function dfs(id: string): string[] | null {
    color.set(id, GRAY)
    stack.push(id)
    for (const next of adjacency.get(id) ?? []) {
      if (color.get(next) === GRAY) {
        const idx = stack.indexOf(next)
        return [...stack.slice(idx), next]
      }
      if (color.get(next) === WHITE) {
        const found = dfs(next)
        if (found) return found
      }
    }
    stack.pop()
    color.set(id, BLACK)
    return null
  }

  for (const n of nodes) {
    if (color.get(n.id) === WHITE) {
      const found = dfs(n.id)
      if (found) return found
    }
  }
  return null
}

function checkCycles(nodes: VsmNode[], connections: VsmConnection[]): ValidationIssue[] {
  const cycle = findFirstCycle(nodes, connections)
  if (!cycle) return []
  const nodesById = new Map(nodes.map((n) => [n.id, n]))
  const names = cycle.map((id) => nodesById.get(id)?.name ?? id).join(' → ')
  return [
    {
      id: 'structural.unexplained-loop',
      category: 'structural',
      severity: 'warning',
      nodeId: cycle[0],
      whatDe: `Zyklischer Materialfluss gefunden: ${names}.`,
      whyDe: 'Eine Schleife ohne erkennbaren Grund (z. B. eine Nacharbeits-Rückführung) erschwert die lineare Timeline-Berechnung und kann ein Modellierungsfehler sein.',
      valueDe: names,
      fixDe: 'Prüfen, ob die Schleife beabsichtigt ist (z. B. Nacharbeit) — wenn nicht, die verursachende Verbindung entfernen oder korrigieren.',
    },
  ]
}

function checkMissingSupplierCustomer(nodes: VsmNode[]): ValidationIssue[] {
  const issues: ValidationIssue[] = []
  if (!nodes.some((n) => n.type === 'supplier')) {
    issues.push({
      id: 'structural.missing-supplier',
      category: 'structural',
      severity: 'warning',
      whatDe: 'Kein Lieferant-Node im Wertstrom.',
      whyDe: 'Ohne Lieferanten fehlt der Startpunkt des Materialflusses — Timeline- und Reichweiten-Berechnung am Wertstrom-Anfang sind unvollständig.',
      valueDe: '0 Lieferant-Nodes',
      fixDe: 'Einen Lieferant-Node hinzufügen, falls der Wertstrom den Wareneingang abbilden soll.',
    })
  }
  if (!nodes.some((n) => n.type === 'customer')) {
    issues.push({
      id: 'structural.missing-customer',
      category: 'structural',
      severity: 'warning',
      whatDe: 'Kein Kunde-Node im Wertstrom.',
      whyDe: 'Ohne Kunden fehlt die Bedarfsquelle — Takt, Auslastung und Engpass-Berechnung haben keine Bezugsgröße.',
      valueDe: '0 Kunde-Nodes',
      fixDe: 'Einen Kunde-Node mit Bedarf (Stk/Tag) hinzufügen.',
    })
  }
  return issues
}

/** Covers §17.1 "unconnected processes" AND §17.1 "inventory without
 * connection" — nodes with ZERO connections at all. ONE general isolation
 * check across all node types rather than several near-identical ones.
 * See `checkIsolatedIslandNodes` below for the separate §17.1 "unreachable
 * nodes" case (F7): a node that DOES have a connection, but whose connected
 * sub-graph is itself cut off from the rest of the value stream — this
 * function's `touched` set treats any such node as fine, which is exactly
 * the gap F7 found (a stale doc comment here used to claim "unreachable
 * nodes" coverage this function never actually provided). */
function checkUnconnectedNodes(nodes: VsmNode[], connections: VsmConnection[]): ValidationIssue[] {
  const touched = new Set<string>()
  for (const c of connections) {
    touched.add(c.fromNodeId)
    touched.add(c.toNodeId)
  }
  return nodes
    .filter((n) => !touched.has(n.id))
    .map((n) => ({
      id: 'structural.node-without-connection',
      category: 'structural' as const,
      severity: 'warning' as const,
      nodeId: n.id,
      whatDe: `${labelForType(n.type)} "${n.name}" hat keine Verbindung zu einem anderen Node.`,
      whyDe: 'Ein isolierter Node ist nicht Teil des Materialflusses und wird von Timeline-, Engpass- und Reichweiten-Berechnungen faktisch ignoriert.',
      valueDe: '0 Verbindungen',
      fixDe: 'Node mit dem Materialfluss verbinden oder entfernen, falls nicht mehr benötigt.',
    }))
}

/** Connected-components helper for `checkIsolatedIslandNodes` — BFS over
 * the UNDIRECTED connection graph, restricted to nodes that have at least
 * one (non-dangling) connection (zero-connection nodes are
 * `checkUnconnectedNodes`'s case, not repeated here). Dangling connection
 * endpoints are skipped, same as `findFirstCycle` — `checkDanglingConnections`
 * owns reporting those. */
function findIsolatedIslandNodeIds(nodes: VsmNode[], connections: VsmConnection[]): Set<string> {
  const nodeIds = new Set(nodes.map((n) => n.id))
  const adjacency = new Map<string, Set<string>>()
  const touched = new Set<string>()
  for (const c of connections) {
    if (!nodeIds.has(c.fromNodeId) || !nodeIds.has(c.toNodeId)) continue
    touched.add(c.fromNodeId)
    touched.add(c.toNodeId)
    if (!adjacency.has(c.fromNodeId)) adjacency.set(c.fromNodeId, new Set())
    if (!adjacency.has(c.toNodeId)) adjacency.set(c.toNodeId, new Set())
    adjacency.get(c.fromNodeId)!.add(c.toNodeId)
    adjacency.get(c.toNodeId)!.add(c.fromNodeId)
  }

  const visited = new Set<string>()
  const components: string[][] = []
  for (const startId of touched) {
    if (visited.has(startId)) continue
    const component: string[] = []
    const queue = [startId]
    visited.add(startId)
    while (queue.length > 0) {
      const current = queue.shift()!
      component.push(current)
      for (const next of adjacency.get(current) ?? []) {
        if (!visited.has(next)) {
          visited.add(next)
          queue.push(next)
        }
      }
    }
    components.push(component)
  }

  if (components.length <= 1) return new Set()

  let mainIdx = 0
  for (let i = 1; i < components.length; i++) {
    if (components[i].length > components[mainIdx].length) mainIdx = i
  }

  const isolated = new Set<string>()
  components.forEach((component, i) => {
    if (i === mainIdx) return
    for (const id of component) isolated.add(id)
  })
  return isolated
}

/** §17.1 "unreachable nodes" (F7): Zusammenhangskomponenten-Analyse over the
 * undirected connection graph. "Hauptfluss" = the LARGEST connected
 * component — a documented, simple choice, not "the component containing a
 * supplier/customer node" (which would silently misclassify a legitimate,
 * still-being-modeled wertstrom that has no supplier/customer node yet —
 * `checkMissingSupplierCustomer` already owns flagging that separately).
 * Everything in a smaller, disconnected component is flagged: internally
 * wired up, but unreachable from the rest of the value stream. */
function checkIsolatedIslandNodes(nodes: VsmNode[], connections: VsmConnection[]): ValidationIssue[] {
  const isolatedIds = findIsolatedIslandNodeIds(nodes, connections)
  if (isolatedIds.size === 0) return []
  const nodesById = new Map(nodes.map((n) => [n.id, n]))
  return [...isolatedIds].map((id) => {
    const n = nodesById.get(id)!
    return {
      id: 'structural.node-unreachable-from-main-flow',
      category: 'structural' as const,
      severity: 'warning' as const,
      nodeId: n.id,
      whatDe: `${labelForType(n.type)} "${n.name}" ist verbunden, aber von der Haupt-Fluss-Komponente des Wertstroms getrennt — ein isolierter Teilgraph.`,
      whyDe: 'Ein vom Hauptfluss abgeschnittener, aber intern verbundener Teilgraph wird von Timeline-, Engpass- und Reichweiten-Berechnungen entlang des Hauptflusses faktisch nicht erreicht — vermutlich eine vergessene Verbindung oder ein Modellierungsfehler.',
      valueDe: 'Verbunden, aber in einer vom Hauptfluss getrennten Komponente',
      fixDe: 'Verbindung zum Hauptfluss herstellen, oder den isolierten Teilgraphen entfernen, falls nicht mehr benötigt.',
    }
  })
}

// ── 17.2 Time ────────────────────────────────────────────────────────────

const NUMERIC_TIME_FIELDS = ['cycleTimeSec', 'waitTimeSec', 'setupTimeSec', 'transportTimeSec', 'distance', 'machineTimeSec', 'manualTimeSec'] as const
const NUMERIC_CONNECTION_FIELDS = ['transportTimeSec', 'batchSize'] as const

function checkNegativeTimeFields(nodes: VsmNode[], connections: VsmConnection[]): ValidationIssue[] {
  const issues: ValidationIssue[] = []
  for (const n of nodes) {
    for (const field of NUMERIC_TIME_FIELDS) {
      const v = n[field]
      if (v != null && v < 0) {
        issues.push({
          id: 'time.negative-value',
          category: 'time',
          severity: 'critical',
          nodeId: n.id,
          whatDe: `${labelForType(n.type)} "${n.name}": ${field} ist negativ (${v}).`,
          whyDe: 'Negative Zeiten/Strecken sind physikalisch unmöglich und verfälschen jede darauf aufbauende Berechnung.',
          valueDe: `${field}: ${v}`,
          fixDe: 'Wert korrigieren (≥ 0) oder Feld leer lassen, wenn nicht bekannt.',
        })
      }
    }
  }
  for (const c of connections) {
    for (const field of NUMERIC_CONNECTION_FIELDS) {
      const v = c[field]
      if (v != null && v < 0) {
        issues.push({
          id: 'time.negative-value',
          category: 'time',
          severity: 'critical',
          connectionId: c.id,
          whatDe: `Verbindung "${c.id}": ${field} ist negativ (${v}).`,
          whyDe: 'Negative Zeiten/Mengen sind physikalisch unmöglich.',
          valueDe: `${field}: ${v}`,
          fixDe: 'Wert korrigieren (≥ 0).',
        })
      }
    }
  }
  return issues
}

/** F13: `isStationNode` (process OR machine), consistent with the F1 fix in
 * §17.3 `checkUtilizationAboveTakt`/`bottleneck.ts` — a `cycleTimeSec: 0` on
 * a machine node is exactly the same implausibility as on a process node. */
function checkImplausibleZeroCycleTime(nodes: VsmNode[]): ValidationIssue[] {
  return nodes
    .filter((n) => isStationNode(n) && n.cycleTimeSec === 0)
    .map((n) => ({
      id: 'time.implausible-zero-cycle-time',
      category: 'time' as const,
      severity: 'warning' as const,
      nodeId: n.id,
      whatDe: `${labelForType(n.type)} "${n.name}" hat eine Zykluszeit von exakt 0 Sekunden.`,
      whyDe: 'Ein realer Prozess/eine reale Maschine braucht in aller Regel eine messbare Zeit — 0s ist meist ein Eingabefehler oder ein noch nicht erfasster Wert.',
      valueDe: 'cycleTimeSec: 0',
      fixDe: 'Zykluszeit messen/eintragen, oder das Feld leer lassen statt 0, wenn unbekannt.',
    }))
}

function checkSetupExceedsShift(nodes: VsmNode[], shiftModel: ShiftModelInput | undefined): ValidationIssue[] {
  if (!shiftModel) return []
  const net = computeNetAvailableTimePerDay(shiftModel)
  if (net.netSecPerShift == null) return []
  const netSecPerShift = net.netSecPerShift
  return nodes
    .filter((n) => n.setupTimeSec != null && n.setupTimeSec > netSecPerShift)
    .map((n) => ({
      id: 'time.setup-exceeds-shift',
      category: 'time' as const,
      severity: 'warning' as const,
      nodeId: n.id,
      whatDe: `${labelForType(n.type)} "${n.name}": Rüstzeit (${n.setupTimeSec}s) übersteigt die verfügbare Netto-Schichtzeit (${netSecPerShift}s).`,
      whyDe: 'Wenn allein das Rüsten länger dauert als eine Schicht verfügbar ist, kann in dieser Schicht gar nicht produziert werden.',
      valueDe: `setupTimeSec: ${n.setupTimeSec}s > Netto-Schichtzeit: ${netSecPerShift}s`,
      fixDe: 'Rüstzeit prüfen, auf mehrere Schichten verteilen, oder das Arbeitszeitmodell (mehr Schichten/Stunden) anpassen.',
    }))
}

function checkTaktWithoutDemand(context: ValidationContext | undefined): ValidationIssue[] {
  if (!context?.taktTimeSec || context.taktTimeSec <= 0) return []
  if (context.demandUnitsPerDay != null) return []
  return [
    {
      id: 'time.takt-without-demand',
      category: 'time',
      severity: 'warning',
      whatDe: `Ein Kundentakt (${context.taktTimeSec}s) ist gesetzt, aber keine Bedarfsmenge hinterlegt.`,
      whyDe: 'Ein Takt ohne nachvollziehbaren Bedarf lässt sich nicht überprüfen und kann veraltet oder falsch übernommen sein.',
      valueDe: `taktTimeSec: ${context.taktTimeSec}, demandUnitsPerDay: unbekannt`,
      fixDe: 'Bedarfsmenge (Stk/Tag) erfassen, aus der sich der Takt nachvollziehen lässt.',
    },
  ]
}

// ── 17.3 Capacity ────────────────────────────────────────────────────────

function checkOeeRange(nodes: VsmNode[]): ValidationIssue[] {
  return nodes
    .filter((n) => n.oee != null && (n.oee < 0 || n.oee > 100))
    .map((n) => ({
      id: 'capacity.oee-out-of-range',
      category: 'capacity' as const,
      severity: 'critical' as const,
      nodeId: n.id,
      whatDe: `${labelForType(n.type)} "${n.name}": OEE (${n.oee}%) liegt außerhalb 0–100%.`,
      whyDe: 'OEE ist ein Prozentwert — ein Wert außerhalb 0–100% ist kein gültiges Ergebnis und verfälscht die Kapazitätsrechnung.',
      valueDe: `oee: ${n.oee}%`,
      fixDe: 'OEE korrigieren (0–100%).',
    }))
}

/** THE concrete double-counting check §8.3 demands: fires when a node has
 * BOTH a combined OEE AND a separately-supplied availability/performance/
 * quality override. `computeEffectiveCapacity` itself never double-counts
 * (OEE always wins there, see capacity.ts) — this check surfaces the
 * underlying contradictory DATA ENTRY to the user, which is the actual
 * §17.3 "double-counted availability" ask. */
function checkDoubleCountedAvailability(nodes: VsmNode[], overridesByNodeId: Record<string, NodeCapacityOverrides> | undefined): ValidationIssue[] {
  if (!overridesByNodeId) return []
  const issues: ValidationIssue[] = []
  for (const n of nodes) {
    const ov = overridesByNodeId[n.id]
    if (!ov) continue
    const hasStandalone = ov.availabilityPct != null || ov.performancePct != null || ov.qualityPct != null
    if (n.oee != null && hasStandalone) {
      issues.push({
        id: 'capacity.double-counted-availability',
        category: 'capacity',
        severity: 'critical',
        nodeId: n.id,
        whatDe: `${labelForType(n.type)} "${n.name}" hat sowohl OEE (${n.oee}%) als auch eine separate Verfügbarkeit/Leistung/Qualität hinterlegt.`,
        whyDe: 'OEE enthält Verfügbarkeit/Leistung/Qualität bereits. Beide Angaben gleichzeitig sind widersprüchlich, auch wenn die Engine selbst nicht doppelt zählt (OEE hat Vorrang, siehe capacity.ts) — die überflüssige Angabe sollte bereinigt werden.',
        valueDe: `oee: ${n.oee}%, availabilityPct: ${ov.availabilityPct ?? '–'}, performancePct: ${ov.performancePct ?? '–'}, qualityPct: ${ov.qualityPct ?? '–'}`,
        fixDe: 'Nur EINE der beiden Angaben pflegen: entweder OEE (kombiniert) oder Verfügbarkeit/Leistung/Qualität einzeln.',
      })
    }
  }
  return issues
}

function checkAvailabilityRange(nodes: VsmNode[], overridesByNodeId: Record<string, NodeCapacityOverrides> | undefined): ValidationIssue[] {
  if (!overridesByNodeId) return []
  const issues: ValidationIssue[] = []
  const fields = [
    ['availabilityPct', 'Verfügbarkeit'],
    ['performancePct', 'Leistung'],
    ['qualityPct', 'Qualität'],
  ] as const
  for (const n of nodes) {
    const ov = overridesByNodeId[n.id]
    if (!ov) continue
    for (const [key, label] of fields) {
      const v = ov[key]
      if (v != null && (v < 0 || v > 100)) {
        issues.push({
          id: 'capacity.availability-out-of-range',
          category: 'capacity',
          severity: 'critical',
          nodeId: n.id,
          whatDe: `${labelForType(n.type)} "${n.name}": ${label} (${v}%) liegt außerhalb 0–100%.`,
          whyDe: 'Prozentwerte außerhalb 0–100% sind kein gültiges Ergebnis.',
          valueDe: `${key}: ${v}%`,
          fixDe: `${label} korrigieren (0–100%).`,
        })
      }
    }
  }
  return issues
}

/** F14: negative `numParallelUnits` is 'critical' (the header severity
 * policy says so explicitly: "negative" is listed verbatim as a
 * critical-triggering condition, same bucket as `checkNegativeTimeFields`/
 * `checkNegativeInventory`) — `=== 0` stays 'warning' (a plausible, if
 * unusual, "temporarily paused" input, not a physical impossibility). */
function checkInvalidParallelUnits(nodes: VsmNode[], overridesByNodeId: Record<string, NodeCapacityOverrides> | undefined): ValidationIssue[] {
  if (!overridesByNodeId) return []
  const issues: ValidationIssue[] = []
  for (const n of nodes) {
    const v = overridesByNodeId[n.id]?.numParallelUnits
    if (v != null && v <= 0) {
      issues.push({
        id: 'capacity.invalid-parallel-units',
        category: 'capacity',
        severity: v < 0 ? 'critical' : 'warning',
        nodeId: n.id,
        whatDe: `${labelForType(n.type)} "${n.name}": Parallel-Einheiten (${v}) ist ${v < 0 ? 'negativ' : '0'}.`,
        whyDe: v < 0 ? 'Eine negative Anzahl paralleler Einheiten ist physikalisch unmöglich und verfälscht jede Kapazitätsberechnung.' : 'Mit 0 parallelen Einheiten könnte der Prozess gar nichts produzieren — vermutlich ein Eingabefehler.',
        valueDe: `numParallelUnits: ${v}`,
        fixDe: 'Parallel-Einheiten korrigieren (≥ 1) oder Feld leer lassen (Standard: 1).',
      })
    }
  }
  return issues
}

/** Covers BOTH §17.3 "capacity below demand" AND "utilization above 100
 * percent" — mathematically the same condition (effective CT > takt), one
 * detector rather than firing twice. F1: `isStationNode` (process OR
 * machine) — machine is a full station type with its own OEE input in the
 * editor; a process-only filter silently dropped every machine node from
 * this check. */
function checkUtilizationAboveTakt(nodes: VsmNode[], taktTimeSec: number | undefined, overridesByNodeId: Record<string, NodeCapacityOverrides> | undefined): ValidationIssue[] {
  if (!taktTimeSec || taktTimeSec <= 0) return []
  const issues: ValidationIssue[] = []
  for (const n of nodes.filter(isStationNode)) {
    const ec = computeEffectiveCapacity(n, overridesByNodeId?.[n.id])
    const util = computeUtilization(ec.effectiveCycleTimeSec, taktTimeSec)
    if (util.utilizationPct != null && util.utilizationPct > 100) {
      issues.push({
        id: 'capacity.utilization-above-takt',
        category: 'capacity',
        severity: 'warning',
        nodeId: n.id,
        whatDe: `${labelForType(n.type)} "${n.name}" kann den Kundentakt nicht einhalten: effektive Zykluszeit ${formatRound1(ec.effectiveCycleTimeSec)}s vs. Takt ${formatRound1(taktTimeSec)}s (Auslastung ${formatRound1(util.utilizationPct)}%).`,
        whyDe: 'Eine Auslastung über 100% bedeutet: die Kapazität dieses Prozesses/dieser Maschine reicht nicht für den Kundenbedarf — zugleich ein Engpass-Kandidat (§8.8).',
        valueDe: `Auslastung: ${formatRound1(util.utilizationPct)}%`,
        fixDe: 'Zykluszeit senken, zusätzliche Kapazität/parallele Ressourcen schaffen, oder verfügbare Arbeitszeit erhöhen.',
      })
    }
  }
  return issues
}

// ── 17.4 Quality ─────────────────────────────────────────────────────────

function checkScrapRange(nodes: VsmNode[]): ValidationIssue[] {
  return nodes
    .filter((n) => n.scrapRate != null && (n.scrapRate < 0 || n.scrapRate > 100))
    .map((n) => ({
      id: 'quality.scrap-out-of-range',
      category: 'quality' as const,
      severity: 'critical' as const,
      nodeId: n.id,
      whatDe: `${labelForType(n.type)} "${n.name}": Ausschussrate (${n.scrapRate}%) liegt außerhalb 0–100%.`,
      whyDe: 'Ausschuss ist ein Prozentwert — ein Wert außerhalb 0–100% ist kein gültiges Ergebnis.',
      valueDe: `scrapRate: ${n.scrapRate}%`,
      fixDe: 'Ausschussrate korrigieren (0–100%).',
    }))
}

function checkReworkChecks(qualityInputs: ProcessQualityInput[] | undefined, nodesById: Map<string, VsmNode>): ValidationIssue[] {
  if (!qualityInputs) return []
  const issues: ValidationIssue[] = []
  for (const qi of qualityInputs) {
    const name = nodesById.get(qi.nodeId)?.name ?? qi.nodeId
    if (qi.reworkRatePct != null && (qi.reworkRatePct < 0 || qi.reworkRatePct > 100)) {
      issues.push({
        id: 'quality.rework-out-of-range',
        category: 'quality',
        severity: 'critical',
        nodeId: qi.nodeId,
        whatDe: `Prozess "${name}": Nacharbeitsrate (${qi.reworkRatePct}%) liegt außerhalb 0–100%.`,
        whyDe: 'Nacharbeit ist ein Prozentwert — ein Wert außerhalb 0–100% ist kein gültiges Ergebnis.',
        valueDe: `reworkRatePct: ${qi.reworkRatePct}%`,
        fixDe: 'Nacharbeitsrate korrigieren (0–100%).',
      })
    }
    // F2: qi.scrapRatePct (ProcessQualityInput) is a DIFFERENT field from
    // VsmNode.scrapRate (checkScrapRange, above) — it was never
    // range-checked directly. The sum-check below only fires when
    // scrap+rework > 100, which cannot catch a negative scrapRatePct alone.
    if (qi.scrapRatePct != null && (qi.scrapRatePct < 0 || qi.scrapRatePct > 100)) {
      issues.push({
        id: 'quality.scrap-rate-pct-out-of-range',
        category: 'quality',
        severity: 'critical',
        nodeId: qi.nodeId,
        whatDe: `Prozess "${name}": Ausschussrate (${qi.scrapRatePct}%) liegt außerhalb 0–100%.`,
        whyDe: 'Ausschuss ist ein Prozentwert — ein Wert außerhalb 0–100% ist kein gültiges Ergebnis.',
        valueDe: `scrapRatePct: ${qi.scrapRatePct}%`,
        fixDe: 'Ausschussrate korrigieren (0–100%).',
      })
    }
    const scrap = qi.scrapRatePct ?? 0
    const rework = qi.reworkRatePct ?? 0
    if ((qi.scrapRatePct != null || qi.reworkRatePct != null) && scrap + rework > 100) {
      issues.push({
        id: 'quality.contradictory-yield-scrap',
        category: 'quality',
        severity: 'critical',
        nodeId: qi.nodeId,
        whatDe: `Prozess "${name}": Ausschuss (${scrap}%) + Nacharbeit (${rework}%) summieren sich auf über 100%.`,
        whyDe: 'Mehr als 100% der Teile können nicht gleichzeitig Ausschuss oder Nacharbeit sein — die Erstdurchlaufquote würde rechnerisch negativ.',
        valueDe: `${scrap}% + ${rework}% = ${scrap + rework}%`,
        fixDe: 'Ausschuss- und Nacharbeitsrate prüfen — vermutlich vertauscht oder doppelt erfasst.',
      })
    }
    if (qi.reworkRatePct != null && qi.reworkRatePct > 0 && qi.reworkTimeSec == null) {
      issues.push({
        id: 'quality.missing-rework-time',
        category: 'quality',
        severity: 'warning',
        nodeId: qi.nodeId,
        whatDe: `Prozess "${name}" hat eine Nacharbeitsrate (${qi.reworkRatePct}%), aber keine Nacharbeitszeit.`,
        whyDe: 'Ohne Nacharbeitszeit lässt sich der zusätzliche Kapazitätsbedarf durch Nacharbeit nicht beziffern — er wird sonst unterschätzt.',
        valueDe: `reworkRatePct: ${qi.reworkRatePct}%, reworkTimeSec: unbekannt`,
        fixDe: 'Nacharbeitszeit erfassen (Sekunden pro nachgearbeitetem Teil).',
      })
    }
  }
  return issues
}

function checkCumulativeYieldRange(qualityInputs: ProcessQualityInput[] | undefined): ValidationIssue[] {
  if (!qualityInputs || qualityInputs.length === 0) return []
  const result = computeCumulativeYield(qualityInputs)
  if (result.cumulativeYieldPct == null) return []
  if (result.cumulativeYieldPct < 0 || result.cumulativeYieldPct > 100) {
    return [
      {
        id: 'quality.cumulative-yield-out-of-range',
        category: 'quality',
        severity: 'critical',
        whatDe: `Kumulierte Ausbeute (${formatRound1(result.cumulativeYieldPct)}%) liegt außerhalb 0–100%.`,
        whyDe: 'Eine Ausbeute außerhalb 0–100% ist rechnerisch unmöglich und deutet auf widersprüchliche Ausschussraten in der Kette hin.',
        valueDe: `cumulativeYieldPct: ${formatRound1(result.cumulativeYieldPct)}%`,
        fixDe: 'Ausschussraten der einzelnen Prozesse prüfen (siehe "Ausschussrate außerhalb 0–100%" je Node).',
      },
    ]
  }
  return []
}

// ── 17.5 Inventory ───────────────────────────────────────────────────────

function checkNegativeInventory(nodes: VsmNode[]): ValidationIssue[] {
  return nodes
    .filter((n) => n.type === 'inventory' && n.quantity != null && n.quantity < 0)
    .map((n) => ({
      id: 'inventory.negative-quantity',
      category: 'inventory' as const,
      severity: 'critical' as const,
      nodeId: n.id,
      whatDe: `Bestand "${n.name}" hat eine negative Menge (${n.quantity}).`,
      whyDe: 'Ein negativer Bestand ist physikalisch unmöglich und verfälscht jede Reichweiten-Berechnung.',
      valueDe: `quantity: ${n.quantity}`,
      fixDe: 'Menge korrigieren (≥ 0).',
    }))
}

/** Review-Fix F4 (adversarial review, PR #353): A8's `inventoryMaxQuantity`
 * ("FIFO mit Kapazität") was accepted by the Zod schema (>= 0) but never
 * validated by the engine at all — same pattern as checkNegativeInventory
 * above, applied to the new field. A negative max capacity is 'critical'
 * (mathematically impossible, same bucket as checkNegativeInventory). */
function checkNegativeInventoryMaxQuantity(nodes: VsmNode[]): ValidationIssue[] {
  return nodes
    .filter((n) => n.type === 'inventory' && n.inventoryMaxQuantity != null && n.inventoryMaxQuantity < 0)
    .map((n) => ({
      id: 'inventory.negative-max-quantity',
      category: 'inventory' as const,
      severity: 'critical' as const,
      nodeId: n.id,
      whatDe: `Bestand "${n.name}" hat eine negative Max. Kapazität (${n.inventoryMaxQuantity}).`,
      whyDe: 'Eine negative Kapazität ist physikalisch unmöglich und verfälscht jede darauf aufbauende Berechnung.',
      valueDe: `inventoryMaxQuantity: ${n.inventoryMaxQuantity}`,
      fixDe: 'Wert korrigieren (≥ 0).',
    }))
}

/** Review-Fix F4: `quantity > inventoryMaxQuantity` on a FIFO lane is
 * 'warning', NOT 'critical' — unlike a negative value, this is not
 * physically impossible: a real FIFO lane/rack CAN genuinely overflow (a
 * modelling hint worth a look, e.g. a wrong capacity entry or a real
 * bottleneck), not a mathematically-contradictory data point. Scoped to
 * `inventoryKind === 'fifo'` only — inventoryMaxQuantity is generically
 * available on VsmNode (see its own doc comment) but only meaningful for a
 * FIFO-Bahn, same scoping the editor's Properties-Panel field uses. */
function checkFifoQuantityAboveCapacity(nodes: VsmNode[]): ValidationIssue[] {
  return nodes
    .filter(
      (n) =>
        n.type === 'inventory' &&
        n.inventoryKind === 'fifo' &&
        n.quantity != null &&
        n.inventoryMaxQuantity != null &&
        n.quantity > n.inventoryMaxQuantity,
    )
    .map((n) => ({
      id: 'inventory.fifo-capacity-exceeded',
      category: 'inventory' as const,
      severity: 'warning' as const,
      nodeId: n.id,
      whatDe: `FIFO-Bahn "${n.name}" hat mehr Bestand (${n.quantity}) als die Max. Kapazität (${n.inventoryMaxQuantity}) zulässt.`,
      whyDe: 'Ein realer FIFO-Puffer kann überlaufen — das ist ein Modell-Hinweis auf eine mögliche Fehleingabe oder einen echten Engpass, keine physikalische Unmöglichkeit wie ein negativer Bestand.',
      valueDe: `quantity: ${n.quantity} > inventoryMaxQuantity: ${n.inventoryMaxQuantity}`,
      fixDe: 'Prüfen, ob Menge und Max. Kapazität korrekt erfasst sind, oder ob der Puffer tatsächlich überläuft.',
    }))
}

/** Covers BOTH §17.2 "lead time without demand basis" AND §17.5
 * "inconsistent coverage" — both spec bullets describe the same underlying
 * gap (an inventory quantity without a demand rate to convert it into a
 * time), so this is ONE detector, category 'inventory'. */
function checkInventoryDemandBasis(nodes: VsmNode[], context: ValidationContext | undefined): ValidationIssue[] {
  const inventoryNodesWithQuantity = nodes.filter((n) => n.type === 'inventory' && n.quantity != null)
  if (inventoryNodesWithQuantity.length === 0) return []
  if (context?.demandUnitsPerDay != null && context.demandUnitsPerDay > 0) return []
  // F12: demandUnitsPerDay TRULY unknown (context/field absent) is a
  // different fact from demandUnitsPerDay KNOWN to be exactly 0 — both skip
  // the `> 0` guard above and still can't compute a coverage (division by
  // zero for the latter), but the message must say which one is true.
  const demandKnownZero = context?.demandUnitsPerDay === 0
  return inventoryNodesWithQuantity.map((n) => ({
    id: 'inventory.missing-demand-basis',
    category: 'inventory' as const,
    severity: 'warning' as const,
    nodeId: n.id,
    whatDe: demandKnownZero
      ? `Bestand "${n.name}" (${n.quantity} Stk) hat eine bekannte Bedarfsrate von 0 Stk/Tag — Reichweite nicht berechenbar (Division durch 0).`
      : `Bestand "${n.name}" (${n.quantity} Stk) hat keine Bedarfsrate — Reichweite nicht berechenbar.`,
    whyDe: 'Ohne (von Null verschiedene) Bedarfsrate lässt sich weder die Bestandsreichweite noch der Bestandsanteil der Durchlaufzeit berechnen (§8.4/§8.5).',
    valueDe: `quantity: ${n.quantity}, demandUnitsPerDay: ${demandKnownZero ? '0' : 'unbekannt'}`,
    fixDe: demandKnownZero ? 'Bedarfsrate prüfen — falls tatsächlich 0, ist keine Reichweite definiert; falls ein Datenfehler, korrigieren.' : 'Kundenbedarf (Stk/Tag) erfassen, z. B. am Kunde-Node.',
  }))
}

// ── Documented gaps — checks NOT implementable with today's data model ──

export const KNOWN_VALIDATION_GAPS: ValidationGap[] = [
  // Wertstrom P2: 'structural.broken-information-flow' resolved — VsmConnection
  // now carries `kind` (A7). checkDanglingConnections already validates every
  // connection regardless of `kind`, so this was covered generically the
  // moment the field existed to be broken (see module doc comment above).
  { id: 'structural.missing-process-sequence', category: 'structural', specRef: '§17.1 missing process sequence', reasonDe: 'Zu unscharf spezifiziert, um ohne Erfindung einer eigenen Definition prüfbar zu sein — überschneidet sich mit den bereits implementierten Checks (dangling refs, isolierte Nodes, Zyklen), die das konkret Prüfbare daraus abdecken.' },
  { id: 'structural.duplicate-process-numbers', category: 'structural', specRef: '§17.1 duplicate process numbers', reasonDe: 'Kein "Prozessnummer"-Feld auf VsmNode (nur qafSource.positionNumber als Import-Lineage, nicht als allgemeines Node-Feld für manuell angelegte Nodes).' },
  { id: 'structural.missing-products', category: 'structural', specRef: '§17.1 missing products', reasonDe: 'Kein Produkt-Konzept auf VsmNode/Graph-Ebene — das ist Multi-Produkt/Expert-Mode (Capability-Matrix B1), nicht Teil des heutigen Datenmodells.' },
  { id: 'structural.incompatible-product-transformations', category: 'structural', specRef: '§17.1 incompatible product transformations', reasonDe: 'Produkt-Transformations-Graph ist Capability-Matrix Abschnitt D (DEFER) — erst mit Multi-Produkt-Expert sinnvoll.' },
  { id: 'structural.broken-material-flow', category: 'structural', specRef: '§17.1 broken material flows', reasonDe: '§17.1 nennt "broken material flows" als eigenes Stichwort ohne weitere Definition (gegen den vollständigen Spec-Text geprüft, F11 Review) — jede Implementierung müsste eine eigene Abgrenzung zu den bereits implementierten strukturellen Checks (dangling refs, Zyklen, checkIsolatedIslandNodes) erfinden. Dieselbe Begründung wie bei "missing process sequence": ehrlich als Lücke dokumentiert statt geraten.' },
  { id: 'time.cycle-time-unit', category: 'time', specRef: '§17.2 cycle time without unit / inconsistent units', reasonDe: 'Strukturell nicht verletzbar: cycleTimeSec ist im Datenmodell bereits einheitlich Sekunden, kein Freitext-/Einheitenfeld vorhanden — kein Gap im Sinne fehlender Prüfbarkeit, sondern eine durch das Schema bereits ausgeschlossene Fehlerklasse.' },
  { id: 'time.contradictory-planned-measured', category: 'time', specRef: '§17.2 contradictory planned and measured times', reasonDe: 'VsmNode hat kein Feldpaar für "geplante" vs. "gemessene" Zykluszeit (nur ein cycleTimeSec-Feld; machineTimeSec/manualTimeSec ist eine andere Unterscheidung: Maschinen- vs. Handzeit-Anteil).' },
  { id: 'capacity.shift-calendar-conflicts', category: 'capacity', specRef: '§17.3 shift calendar conflicts', reasonDe: 'Kein Schichtkalender-Entity — bewusst außerhalb P1-Scope (Expert-Mode, Capability-Matrix B2, Phase P4+).' },
  { id: 'capacity.inconsistent-parallel-resources', category: 'capacity', specRef: '§17.3 inconsistent parallel resources', reasonDe: '§17.3 nennt "inconsistent parallel resources" ohne weitere Definition (gegen den vollständigen Spec-Text geprüft, F11 Review) — jede Implementierung müsste eine eigene Abgrenzung zu den bereits implementierten Kapazitäts-Checks (invalid-parallel-units, double-counted-availability) erfinden. Ehrlich als Lücke dokumentiert statt geraten.' },
  { id: 'inventory.safety-stock-above-max', category: 'inventory', specRef: '§17.5 safety stock above maximum stock', reasonDe: 'VsmNode hat kein safetyStock-/maxStock-Feldpaar, nur eine generische quantity.' },
  { id: 'inventory.reorder-point-above-max', category: 'inventory', specRef: '§17.5 reorder point above maximum stock', reasonDe: 'VsmNode hat kein reorderPoint-Feld — im P1-Brief selbst als Beispiel für eine dokumentierte Lücke genannt.' },
  { id: 'inventory.without-product', category: 'inventory', specRef: '§17.5 inventory without product', reasonDe: 'Kein Produkt-Konzept auf VsmNode/Graph-Ebene, siehe structural.missing-products.' },
  { id: 'inventory.missing-units', category: 'inventory', specRef: '§17.5 missing units', reasonDe: 'Strukturell nicht verletzbar: quantity hat implizit eine Einheit (Stk), kein separates Einheitenfeld vorhanden.' },
]

const FORMULA = 'Jeder Check liest VsmNode[]/VsmConnection[] plus optionalen Kontext (Takt, Bedarf, Arbeitszeitmodell, Qualitäts-/Kapazitäts-Overrides je Node) und meldet 0..n ValidationIssue. Kategorien/Schweregrad siehe Moduldoku.'
const DATA_BASIS = 'VsmNode[] + VsmConnection[] + optionaler ValidationContext (taktTimeSec, demandUnitsPerDay, shiftModel, qualityInputs, capacityOverridesByNodeId).'

/**
 * A11 Validation Engine. `context` is entirely optional — every check
 * degrades to "skipped" (not "fake-passed") when the data it needs is
 * absent; see individual check functions for exactly which context fields
 * gate which check.
 */
export function runValidation(nodes: VsmNode[], connections: VsmConnection[], context?: ValidationContext): ValidationResult {
  const nodesById = new Map(nodes.map((n) => [n.id, n]))
  const issues: ValidationIssue[] = [
    ...checkDanglingConnections(nodes, connections),
    ...checkCycles(nodes, connections),
    ...checkMissingSupplierCustomer(nodes),
    ...checkUnconnectedNodes(nodes, connections),
    ...checkIsolatedIslandNodes(nodes, connections),
    ...checkNegativeTimeFields(nodes, connections),
    ...checkImplausibleZeroCycleTime(nodes),
    ...checkSetupExceedsShift(nodes, context?.shiftModel),
    ...checkTaktWithoutDemand(context),
    ...checkOeeRange(nodes),
    ...checkDoubleCountedAvailability(nodes, context?.capacityOverridesByNodeId),
    ...checkAvailabilityRange(nodes, context?.capacityOverridesByNodeId),
    ...checkInvalidParallelUnits(nodes, context?.capacityOverridesByNodeId),
    ...checkUtilizationAboveTakt(nodes, context?.taktTimeSec, context?.capacityOverridesByNodeId),
    ...checkScrapRange(nodes),
    ...checkReworkChecks(context?.qualityInputs, nodesById),
    ...checkCumulativeYieldRange(context?.qualityInputs),
    ...checkNegativeInventory(nodes),
    ...checkNegativeInventoryMaxQuantity(nodes),
    ...checkFifoQuantityAboveCapacity(nodes),
    ...checkInventoryDemandBasis(nodes, context),
  ]
  return { issues, gaps: KNOWN_VALIDATION_GAPS, explain: { formula: FORMULA, dataBasis: DATA_BASIS, exclusions: [] } }
}
