// A4 Timeline-Sequenz — hand-calculated tests + consistency proof against
// computeTimelineLadder (never a second, diverging source of truth).
// FIXTURE-DATEN-REGEL: every id/name/number below is FREE INVENTION.

import { describe, expect, it } from 'vitest'
import type { VsmConnection, VsmNode } from '@/lib/vsm-types'
import { computeTimelineLadder } from '../internal/timeline-ladder'
import { computeTimelineSequence } from '../internal/timeline-sequence'

function makeNode(id: string, overrides: Partial<VsmNode> = {}): VsmNode {
  return { id, type: 'process', x: 0, y: 0, name: id, ...overrides }
}
function makeConn(id: string, fromNodeId: string, toNodeId: string, overrides: Partial<VsmConnection> = {}): VsmConnection {
  return { id, fromNodeId, toNodeId, ...overrides }
}

describe('computeTimelineSequence', () => {
  it('orders a simple linear chain: supplier -> process -> inventory -> process -> customer', () => {
    const nodes = [
      makeNode('sup', { type: 'supplier', name: 'Lieferant' }),
      makeNode('p1', { name: 'Prozess 1', cycleTimeSec: 30, isValueAdded: true }),
      makeNode('inv', { type: 'inventory', name: 'Puffer', quantity: 100 }),
      makeNode('p2', { name: 'Prozess 2', cycleTimeSec: 20, isValueAdded: true }),
      makeNode('cust', { type: 'customer', name: 'Kunde' }),
    ]
    const connections = [
      makeConn('c1', 'sup', 'p1'),
      makeConn('c2', 'p1', 'inv'),
      makeConn('c3', 'inv', 'p2'),
      makeConn('c4', 'p2', 'cust'),
    ]
    const result = computeTimelineSequence(nodes, connections)
    // supplier/customer excluded, order preserved
    expect(result.blocks.map((b) => b.nodeId)).toEqual(['p1', 'inv', 'p2'])
    expect(result.blocks[0]).toMatchObject({ level: 'lower', category: 'value-add', seconds: 30 })
    expect(result.blocks[1]).toMatchObject({ level: 'upper', category: 'inventory' })
    expect(result.blocks[2]).toMatchObject({ level: 'lower', category: 'value-add', seconds: 20 })
  })

  it('a merge point (two predecessors feeding one node) waits for BOTH before continuing, not just whichever path is discovered first', () => {
    // gehaeusevorb is fed by BOTH a FIFO buffer (after leiterplattenbestueckung)
    // AND a direct supermarket supply (eingang_gehaeuse) — a naive DFS that
    // dives down whichever edge it meets first would place gehaeusevorb (and
    // everything after it) BEFORE leiterplattenbestueckung/zw_fifo1, which is
    // chronologically wrong (this is exactly why Kahn's algorithm is used,
    // not a plain DFS — see module doc).
    const nodes = [
      makeNode('eingang_leiterplatten', { type: 'inventory', quantity: 100 }),
      makeNode('eingang_gehaeuse', { type: 'inventory', quantity: 100 }),
      makeNode('leiterplattenbestueckung', { cycleTimeSec: 10, isValueAdded: true }),
      makeNode('zw_fifo1', { type: 'inventory', quantity: 50 }),
      makeNode('gehaeusevorb', { cycleTimeSec: 20, isValueAdded: true }),
    ]
    const connections = [
      makeConn('c1', 'eingang_leiterplatten', 'leiterplattenbestueckung'),
      makeConn('c2', 'leiterplattenbestueckung', 'zw_fifo1'),
      makeConn('c3', 'zw_fifo1', 'gehaeusevorb'),
      makeConn('c4', 'eingang_gehaeuse', 'gehaeusevorb'),
    ]
    const result = computeTimelineSequence(nodes, connections)
    const gehaeusevorbIdx = result.blocks.findIndex((b) => b.nodeId === 'gehaeusevorb')
    const bestueckungIdx = result.blocks.findIndex((b) => b.nodeId === 'leiterplattenbestueckung')
    const zwFifo1Idx = result.blocks.findIndex((b) => b.nodeId === 'zw_fifo1')
    expect(bestueckungIdx).toBeLessThan(gehaeusevorbIdx)
    expect(zwFifo1Idx).toBeLessThan(gehaeusevorbIdx)
  })

  it('a rework loop (EOL -> Nacharbeit -> EOL) does not deadlock the ordering, and everything downstream of EOL still appears in sequence', () => {
    const nodes = [
      makeNode('endmontage', { name: 'Endmontage', cycleTimeSec: 105, isValueAdded: true }),
      makeNode('eol', { name: 'EOL-Prüfung', cycleTimeSec: 125, isValueAdded: true }),
      makeNode('nacharbeit', { name: 'Nacharbeit', cycleTimeSec: 180, isValueAdded: false }),
      makeNode('verpackung', { name: 'Verpackung', cycleTimeSec: 60, isValueAdded: false }),
      makeNode('fertigwarenlager', { type: 'inventory', name: 'Fertigwarenlager', quantity: 480 }),
    ]
    const connections = [
      makeConn('c1', 'endmontage', 'eol'),
      makeConn('c2', 'eol', 'nacharbeit'),
      makeConn('c3', 'nacharbeit', 'eol'),
      makeConn('c4', 'eol', 'verpackung'),
      makeConn('c5', 'verpackung', 'fertigwarenlager'),
    ]
    const result = computeTimelineSequence(nodes, connections)
    // The pure loop-appendage node (nacharbeit) is excluded from the main
    // sequence but everything AFTER the cycle (verpackung, fertigwarenlager)
    // still resolves in correct order — this is the exact deadlock
    // vsm-auto-layout.ts's Kahn's-algorithm topoOrder would hit (see module
    // doc comment): a DFS with a visited-guard cannot deadlock on a 2-node
    // back-edge.
    expect(result.blocks.map((b) => b.nodeId)).toEqual(['endmontage', 'eol', 'verpackung', 'fertigwarenlager'])
    expect(result.reworkLoops).toEqual([
      { hostNodeId: 'eol', hostNodeName: 'EOL-Prüfung', loopNodeId: 'nacharbeit', loopNodeName: 'Nacharbeit', cycleTimeSec: 180 },
    ])
  })

  it('setupTimeSec never contributes to a block\'s seconds, only carried as an annotation', () => {
    const nodes = [makeNode('p1', { cycleTimeSec: 45, setupTimeSec: 2700, isValueAdded: true })]
    const connections: VsmConnection[] = []
    const result = computeTimelineSequence(nodes, connections)
    expect(result.blocks[0].seconds).toBe(45)
    expect(result.blocks[0].setupTimeSec).toBe(2700)
  })

  it('a process with both cycleTimeSec and waitTimeSec contributes two blocks: lower (process) then upper (wait)', () => {
    const nodes = [makeNode('p1', { cycleTimeSec: 30, waitTimeSec: 600, isValueAdded: false })]
    const result = computeTimelineSequence(nodes, [])
    expect(result.blocks).toHaveLength(2)
    expect(result.blocks[0]).toMatchObject({ level: 'lower', category: 'non-value-add-process', seconds: 30 })
    expect(result.blocks[1]).toMatchObject({ level: 'upper', category: 'wait', seconds: 600 })
  })

  it('an inventory node with no demand basis reports seconds: null (never a fabricated 0)', () => {
    const nodes = [makeNode('inv', { type: 'inventory', quantity: 500 })]
    const result = computeTimelineSequence(nodes, [])
    expect(result.blocks[0]).toMatchObject({ category: 'inventory', seconds: null })
  })

  it('a transport node reports its own transportTimeSec, not cycleTimeSec', () => {
    const nodes = [makeNode('t1', { type: 'transport', cycleTimeSec: 999, transportTimeSec: 14400 })]
    const result = computeTimelineSequence(nodes, [])
    expect(result.blocks[0]).toMatchObject({ category: 'transport', seconds: 14400 })
  })

  it('consistency: summing blocks by category equals computeTimelineLadder\'s own bestEffortSec sums for the same input (never a second, diverging source of truth)', () => {
    const nodes = [
      makeNode('sup', { type: 'supplier' }),
      makeNode('p1', { cycleTimeSec: 45, isValueAdded: true }),
      makeNode('inv1', { type: 'inventory', quantity: 960 }),
      makeNode('p2', { cycleTimeSec: 75, isValueAdded: false, waitTimeSec: 120 }),
      makeNode('t1', { type: 'transport', transportTimeSec: 14400 }),
      makeNode('cust', { type: 'customer', demand: 480 }),
    ]
    const connections = [
      makeConn('c1', 'sup', 'p1'),
      makeConn('c2', 'p1', 'inv1'),
      makeConn('c3', 'inv1', 'p2'),
      makeConn('c4', 'p2', 't1'),
      makeConn('c5', 't1', 'cust'),
    ]
    const shiftModel = { hoursPerShift: 8, shiftsPerDay: 2, breakMinPerShift: 30 }
    const demandUnitsPerDay = 480
    const sequence = computeTimelineSequence(nodes, connections, demandUnitsPerDay, shiftModel)
    const ladder = computeTimelineLadder(nodes, demandUnitsPerDay, shiftModel)

    const sumBy = (category: string) => sequence.blocks.filter((b) => b.category === category).reduce((s, b) => s + (b.seconds ?? 0), 0)
    expect(sumBy('value-add')).toBe(ladder.vaTimeSec.bestEffortSec)
    expect(sumBy('non-value-add-process')).toBe(ladder.nonVaProcessTimeSec.bestEffortSec)
    expect(sumBy('wait')).toBe(ladder.waitingTimeSec.bestEffortSec)
    expect(sumBy('transport')).toBe(ladder.transportTimeSec.bestEffortSec)
    expect(sumBy('inventory')).toBe(ladder.inventoryTime.seconds)
  })

  it('a disconnected island node is still included (never silently dropped), appended after the reachable chain', () => {
    const nodes = [makeNode('p1', { cycleTimeSec: 10, isValueAdded: true }), makeNode('island', { cycleTimeSec: 20, isValueAdded: true })]
    const result = computeTimelineSequence(nodes, [])
    expect(result.blocks.map((b) => b.nodeId)).toEqual(['p1', 'island'])
  })

  // Referenzwertstrom-Fixrunde (KAR-878, C4≡C8≡C10): a pure Informationsfluss-/
  // Steuerungsknoten (e.g. a PPS-Element) has no materialFlow edge at all —
  // it must not appear as a chronologically-meaningless "?" block on a
  // MATERIAL-flow timeline, regardless of its position in the node array.
  it('a node with connections but ZERO materialFlow edges (only kind: "information") is excluded from the sequence entirely', () => {
    const nodes = [
      makeNode('p1', { name: 'Prozess 1', cycleTimeSec: 30, isValueAdded: true }),
      makeNode('p2', { name: 'Prozess 2', cycleTimeSec: 20, isValueAdded: true }),
      makeNode('pps', { name: 'Produktionssteuerung (PPS)', isValueAdded: false }),
    ]
    const connections = [
      makeConn('c1', 'p1', 'p2'),
      // pps touches BOTH process nodes, but only via information-kind edges —
      // zero materialFlow edges.
      makeConn('c2', 'pps', 'p1', { kind: 'information' }),
      makeConn('c3', 'pps', 'p2', { kind: 'information' }),
    ]
    const result = computeTimelineSequence(nodes, connections)
    expect(result.blocks.map((b) => b.nodeId)).toEqual(['p1', 'p2'])
    expect(result.blocks.some((b) => b.nodeId === 'pps')).toBe(false)
  })

  it('a node with NO connections at all (a genuinely disconnected island — different from the information-only case above) is still included, NOT excluded (same doctrine as the pre-existing "disconnected island" test)', () => {
    const nodes = [makeNode('p1', { cycleTimeSec: 10, isValueAdded: true }), makeNode('island-no-edges-yet', { cycleTimeSec: 999, isValueAdded: true })]
    const result = computeTimelineSequence(nodes, [])
    expect(result.blocks.map((b) => b.nodeId)).toEqual(['p1', 'island-no-edges-yet'])
  })

  // Referenzwertstrom-Fixrunde (KAR-878, C1≡C3): the real seed's Nacharbeit
  // topology is EOL → Nacharbeit → Prüfpuffer (NOT EOL → Nacharbeit → EOL) —
  // a side-branch that re-converges into a node the host ALSO reaches
  // directly, rather than a literal 2-node cycle. Generic fixture (not the
  // seed's own ids) proves the detection generalizes.
  it('a parallel side-path (host -> N -> H, with a DIRECT host -> H edge too) is recognized as a rework-loop appendage attached to the host — even though it is NOT a graph cycle', () => {
    const nodes = [
      makeNode('vorgänger', { name: 'Vorgänger', cycleTimeSec: 40, isValueAdded: true }),
      makeNode('host', { name: 'EOL-Prüfung', cycleTimeSec: 125, isValueAdded: true }),
      makeNode('seitenpfad', { name: 'Nacharbeit', type: 'timevalue', cycleTimeSec: 180 }),
      makeNode('puffer', { name: 'Prüf-/Freigabepuffer', type: 'inventory', quantity: 96 }),
      makeNode('nachgelagert', { name: 'Verpackung', cycleTimeSec: 60, isValueAdded: false }),
    ]
    const connections = [
      makeConn('c1', 'vorgänger', 'host'),
      makeConn('c2', 'host', 'puffer'), // direct host -> H edge (the i.O.-parts path)
      makeConn('c3', 'host', 'seitenpfad'), // host -> N
      makeConn('c4', 'seitenpfad', 'puffer'), // N -> H (SAME downstream node, not back to host)
      makeConn('c5', 'puffer', 'nachgelagert'),
    ]
    const result = computeTimelineSequence(nodes, connections)
    // The side-path node is excluded from the main sequence...
    expect(result.blocks.map((b) => b.nodeId)).toEqual(['vorgänger', 'host', 'puffer', 'nachgelagert'])
    // ...and shows up as a rework-loop annotation attached to the HOST (not
    // the downstream convergence node).
    expect(result.reworkLoops).toEqual([
      { hostNodeId: 'host', hostNodeName: 'EOL-Prüfung', loopNodeId: 'seitenpfad', loopNodeName: 'Nacharbeit', cycleTimeSec: 180 },
    ])
  })

  // Negativtest (Referenzwertstrom-Fixrunde, K-REWORK-Entscheidung): a plain
  // 1-in/1-out pass-through node WITHOUT a parallel shortcut edge (e.g. a
  // transport step strictly between two processes) must NOT be misdetected
  // as a rework-loop appendage — it is genuine, 100%-of-units main flow.
  it('a plain pass-through node (1 incoming + 1 outgoing materialFlow edge, NO parallel host->downstream shortcut) is NOT misdetected as a rework loop — stays normal main flow', () => {
    const nodes = [
      makeNode('vorgänger', { name: 'Vorgänger', cycleTimeSec: 40, isValueAdded: true }),
      makeNode('transport', { name: 'Lkw-Transport', type: 'transport', transportTimeSec: 7200 }),
      makeNode('nachgelagert', { name: 'Nachgelagerter Prozess', cycleTimeSec: 50, isValueAdded: true }),
    ]
    const connections = [
      makeConn('c1', 'vorgänger', 'transport'),
      makeConn('c2', 'transport', 'nachgelagert'),
      // Deliberately NO direct vorgänger -> nachgelagert edge — no shortcut
      // exists for the transport node to be a "side-path" alongside.
    ]
    const result = computeTimelineSequence(nodes, connections)
    expect(result.blocks.map((b) => b.nodeId)).toEqual(['vorgänger', 'transport', 'nachgelagert'])
    expect(result.reworkLoops).toEqual([])
  })

  // P8.2a (KAR-878/KAR-986, Baustein 1 "isVaNode-unknown-Alignment"):
  // vaClass='unknown' ("Ungeklärt") used to render as the SAME
  // 'non-value-add-process' category as a genuine NVA node — now its own
  // 'unknown-process' category, so a consumer never colors/labels it as NVA.
  describe('P8.2a Baustein 1 (unknown-Alignment)', () => {
    it('a vaClass=\'unknown\' process node gets category \'unknown-process\', not \'non-value-add-process\'', () => {
      const nodes = [makeNode('p1', { vaClass: 'unknown', cycleTimeSec: 30 })]
      const result = computeTimelineSequence(nodes, [])
      expect(result.blocks[0]).toMatchObject({ category: 'unknown-process', seconds: 30 })
    })

    // P8.2a-Review Fix-Runde (K1): 'unset' used to be asserted as
    // 'non-value-add-process' ("derived non-VA") — that was the exact bug
    // this fix-round closed (a never-classified node silently read as NVA,
    // contradicting resolveVaBadgeState's own 'unknown' reading for the
    // identical input). Inverted (not deleted), alongside a NEW explicit
    // isValueAdded=false node so the "genuinely NVA" category is still
    // exercised in this same regression test.
    it('K1-Fix (P8.2a-Review): unset nodes now get "unknown-process" (not "non-value-add-process"); va/explicit-nva/explicit-unknown nodes keep their EXACT previous category', () => {
      const nodes = [
        makeNode('unset', { cycleTimeSec: 10 }), // no vaClass, no isValueAdded -> never classified -> unknown
        makeNode('explicit-nonva', { isValueAdded: false, cycleTimeSec: 15 }), // legacy boolean fallback -> STILL nonVa
        makeNode('va', { vaClass: 'va', cycleTimeSec: 20 }),
        makeNode('nva', { vaClass: 'nva', cycleTimeSec: 30 }),
        makeNode('unknown', { vaClass: 'unknown', cycleTimeSec: 40 }),
      ]
      const result = computeTimelineSequence(nodes, [])
      const byId = new Map(result.blocks.map((b) => [b.nodeId, b.category]))
      expect(byId.get('unset')).toBe('unknown-process')
      expect(byId.get('explicit-nonva')).toBe('non-value-add-process')
      expect(byId.get('va')).toBe('value-add')
      expect(byId.get('nva')).toBe('non-value-add-process')
      expect(byId.get('unknown')).toBe('unknown-process')
    })

    it('consistency: summing \'unknown-process\' blocks equals computeTimelineLadder\'s own unknownTimeSec.bestEffortSec (never a second, diverging source of truth)', () => {
      const nodes = [makeNode('a', { vaClass: 'unknown', cycleTimeSec: 45 }), makeNode('b', { vaClass: 'va', cycleTimeSec: 10 })]
      const sequence = computeTimelineSequence(nodes, [])
      const ladder = computeTimelineLadder(nodes)
      const sumBy = (category: string) => sequence.blocks.filter((b) => b.category === category).reduce((s, b) => s + (b.seconds ?? 0), 0)
      expect(sumBy('unknown-process')).toBe(ladder.unknownTimeSec.bestEffortSec)
    })
  })
})
