// Pure geometry helpers for the VSM editor canvas.
// No React, no DOM, no state — everything here is deterministic and testable.
import type { NodeType, VsmNode, VsmConnection } from '@/lib/vsm-types'
import type { ShiftModelInput, TimelineBlock } from '@/lib/vsm-engine'

export const NODE_W = 160

export function getNodeH(type: NodeType): number {
  return type === 'process' || type === 'machine' ? 110 : 80
}

export function getOutputPortPos(node: VsmNode): { x: number; y: number } {
  return { x: node.x + NODE_W, y: node.y + getNodeH(node.type) / 2 }
}

export function getInputPortPos(node: VsmNode): { x: number; y: number } {
  return { x: node.x, y: node.y + getNodeH(node.type) / 2 }
}

export function buildPath(x1: number, y1: number, x2: number, y2: number): string {
  const dx = Math.max(80, Math.abs(x2 - x1) * 0.45)
  return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`
}

// ── Wertstrom P0 (KAR-878): Fit-View + layout.viewport persistence ─────────

/** Existing wheel-zoom clamp range (vsm-editor.tsx handleWheel) — shared here
 * so Fit-View computes/clamps to the SAME bounds instead of a second
 * hand-copied pair of magic numbers. */
export const ZOOM_MIN = 0.2
export const ZOOM_MAX = 3

/** Pre-Fit-View default pan/zoom (vsm-editor.tsx's original "Zurücksetzen"
 * values) — reused as the Fit-View fallback when there is nothing to fit
 * (no nodes) or the canvas has not been measured yet. */
export const DEFAULT_PAN = { x: 300, y: 150 }
export const DEFAULT_ZOOM = 1

/** Screen-space breathing room (px) around the fitted content's bounding box. */
const FIT_VIEW_MARGIN_PX = 60

export interface VsmViewport {
  x: number
  y: number
  zoom: number
}

export interface VsmViewportSize {
  width: number
  height: number
}

export interface VsmContentBounds {
  minX: number
  minY: number
  width: number
  height: number
}

function clamp(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value))
}

/**
 * World-space bounding box (min corner + width/height) spanning every
 * node's position+size — the SAME box `computeFitView` fits into a
 * viewport. Exported (Wertstrom P7 fix-round) so a caller that already has
 * a `zoom` factor from `computeFitView` (e.g. vsm-presentation-mode.tsx's
 * fit-to-screen `<img>` sizing) can derive the correct on-screen size
 * (`contentBounds.width * zoom`) instead of multiplying by the VIEWPORT
 * size — `zoom` is a content-space -> screen-space factor, not the other
 * way round. `{0,0,0,0}` for an empty node list (no content to bound). */
export function computeContentBounds(nodes: VsmNode[]): VsmContentBounds {
  if (nodes.length === 0) return { minX: 0, minY: 0, width: 0, height: 0 }
  let minX = Infinity
  let minY = Infinity
  let maxX = -Infinity
  let maxY = -Infinity
  for (const node of nodes) {
    minX = Math.min(minX, node.x)
    minY = Math.min(minY, node.y)
    maxX = Math.max(maxX, node.x + NODE_W)
    maxY = Math.max(maxY, node.y + getNodeH(node.type))
  }
  return { minX, minY, width: maxX - minX, height: maxY - minY }
}

/**
 * Computes the pan/zoom that fits every node's bounding box into the given
 * canvas viewport, with margin, clamped to the existing zoom range
 * (0.2–3). Falls back to DEFAULT_PAN/DEFAULT_ZOOM when there are no nodes
 * or the viewport has not been measured yet (width/height <= 0) — the same
 * values "Zurücksetzen" used before it started calling this function.
 *
 * Pure geometry: node screen position = pan + node.{x,y} * zoom (matches
 * the world div's `translate(pan) scale(zoom)` transform with
 * transformOrigin '0 0' — see vsm-editor.tsx's handleWheel, which anchors
 * the same relationship around the mouse position during a zoom step).
 */
export function computeFitView(nodes: VsmNode[], viewport: VsmViewportSize): VsmViewport {
  if (nodes.length === 0 || viewport.width <= 0 || viewport.height <= 0) {
    return { x: DEFAULT_PAN.x, y: DEFAULT_PAN.y, zoom: DEFAULT_ZOOM }
  }

  const { minX, minY, width: contentWidth, height: contentHeight } = computeContentBounds(nodes)

  const zoomX = (viewport.width - 2 * FIT_VIEW_MARGIN_PX) / contentWidth
  const zoomY = (viewport.height - 2 * FIT_VIEW_MARGIN_PX) / contentHeight
  const zoom = clamp(Math.min(zoomX, zoomY), ZOOM_MIN, ZOOM_MAX)

  return {
    x: (viewport.width - contentWidth * zoom) / 2 - minX * zoom,
    y: (viewport.height - contentHeight * zoom) / 2 - minY * zoom,
    zoom,
  }
}

/**
 * Reads `layout.viewport` (the shape vsm-editor.tsx now writes on save —
 * `layout: { viewport: { x, y, zoom } }`) back out of the freeform `layout`
 * jsonb column. Returns null for anything that is not exactly this shape —
 * including the pre-existing `{}` DB default, an entirely different legacy
 * shape (e.g. the demo seed's flat `{ zoom, panX, panY }`), or a
 * `null`/`undefined` layout — so the caller can fall back to Fit-View
 * without guessing at a malformed/foreign layout blob.
 */
export function parseViewportFromLayout(layout: Record<string, unknown> | null | undefined): VsmViewport | null {
  if (!layout || typeof layout !== 'object') return null
  const viewport = (layout as { viewport?: unknown }).viewport
  if (!viewport || typeof viewport !== 'object') return null
  const { x, y, zoom } = viewport as Record<string, unknown>
  if (typeof x !== 'number' || typeof y !== 'number' || typeof zoom !== 'number') return null
  if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(zoom)) return null
  return { x, y, zoom }
}

/**
 * Wertstrom P4 (B2, Capability-Matrix, KAR-878/KAR-986): reads
 * `layout.shiftModel` (the shape vsm-editor.tsx now writes on save —
 * `layout: { viewport, shiftModel }`) back out of the freeform `layout`
 * jsonb column, same "parse defensively, null on anything unexpected"
 * posture as `parseViewportFromLayout` above (including the pre-existing
 * `{}` DB default, a legacy/foreign layout shape, or a `null`/`undefined`
 * layout). `plannedDowntimeMinPerShift` is optional on `ShiftModelInput`
 * itself — present only when the persisted value is actually a finite
 * number, never coerced to 0 (undefined stays undefined, matching
 * `computeNetAvailableTimePerDay`'s own "not confirmed zero" doctrine).
 */
export function parseShiftModelFromLayout(layout: Record<string, unknown> | null | undefined): ShiftModelInput | undefined {
  if (!layout || typeof layout !== 'object') return undefined
  const shiftModel = (layout as { shiftModel?: unknown }).shiftModel
  if (!shiftModel || typeof shiftModel !== 'object') return undefined
  const { hoursPerShift, shiftsPerDay, breakMinPerShift, plannedDowntimeMinPerShift } = shiftModel as Record<string, unknown>
  if (typeof hoursPerShift !== 'number' || typeof shiftsPerDay !== 'number' || typeof breakMinPerShift !== 'number') return undefined
  if (!Number.isFinite(hoursPerShift) || !Number.isFinite(shiftsPerDay) || !Number.isFinite(breakMinPerShift)) return undefined
  const hasValidDowntime = typeof plannedDowntimeMinPerShift === 'number' && Number.isFinite(plannedDowntimeMinPerShift)
  return {
    hoursPerShift,
    shiftsPerDay,
    breakMinPerShift,
    ...(hasValidDowntime ? { plannedDowntimeMinPerShift: plannedDowntimeMinPerShift as number } : {}),
  }
}

/**
 * Review-Fix F3+F5 (adversarial review, PR #355): the wire representation of
 * `layout.shiftModel` for a PUT — distinguishes "this save doesn't touch
 * shiftModel" (server keeps whatever's already there) from "the user
 * explicitly cleared a previously-complete model" (server deletes it). A
 * bare `ShiftModelInput | undefined` local state cannot express this on its
 * own: `JSON.stringify` drops an `undefined` property the exact same way
 * whether the field was NEVER touched this session or WAS complete and then
 * got cleared — both collapse to "key absent" on the wire, which is why the
 * shiftModel form's Leeren used to be a silent no-op (the finding this fixes).
 *
 * `everComplete` is true once the local shiftModel state has held a
 * complete model at any point in this session — either loaded from the
 * server already complete, or completed by the user — see vsm-editor.tsx's
 * `shiftModelEverCompleteRef`. A form that was NEVER complete this session
 * (the user typed into 1-2 fields and abandoned it, or never touched it at
 * all) still sends `undefined` — there was never a saved value to delete,
 * so there is nothing honest to signal with an explicit `null`.
 */
export function shiftModelWireValue(current: ShiftModelInput | undefined, everComplete: boolean): ShiftModelInput | null | undefined {
  if (current !== undefined) return current
  return everComplete ? null : undefined
}

// ── Referenzwertstrom-Fixrunde (KAR-878, K-ZONEN): layout.zones ────────────

/**
 * Referenzwertstrom-Fixrunde (KAR-878, K-ZONEN): an optional, purely
 * additive grouping of nodes into a visually-distinguishable zone (Kais
 * §11 "Die Grenzen zwischen 2nd Tier, Tier 1 und BMW müssen visuell // allow-customer-string
 * erkennbar sein") — same "new key inside the existing `layout` jsonb
 * bucket, no migration" pattern `shiftModel` established (P4, B2). Absent on
 * every pre-existing Wertstrom (no schema-Zwang for Bestandskarten) — the
 * canvas renders no boundary lines/labels at all when `zones` is undefined,
 * byte-identical to before this feature.
 *
 * `role` lets a feature that needs to reason about WHICH zone is "the
 * Tier-1 supplier" (e.g. Kais §9's CT-Takt-Diagramm scope, see
 * `vsm-ct-takt-chart.tsx`'s `tier1NodeIds` prop) do so structurally,
 * without string-matching a free-text `id`/`label` — absent = a purely
 * visual grouping with no further meaning attached.
 */
export interface VsmZone {
  id: string
  label: string
  nodeIds: string[]
  role?: 'second-tier-supplier' | 'tier-1-supplier' | 'customer'
}

/**
 * Reads `layout.zones` (the shape vsm-editor.tsx renders — additive, see
 * `VsmZone` doc) back out of the freeform `layout` jsonb column, same
 * "parse defensively, undefined on anything unexpected" posture as
 * `parseShiftModelFromLayout` above. A malformed zone entry is dropped
 * individually (not the whole array) — a single bad entry never hides every
 * OTHER valid zone. Returns `undefined` (not `[]`) when there is nothing
 * usable, so callers can use simple `zones?.length` / `zones?.find(...)`
 * without an extra empty-array special case.
 */
export function parseZonesFromLayout(layout: Record<string, unknown> | null | undefined): VsmZone[] | undefined {
  if (!layout || typeof layout !== 'object') return undefined
  const raw = (layout as { zones?: unknown }).zones
  if (!Array.isArray(raw)) return undefined
  const zones: VsmZone[] = []
  for (const entry of raw) {
    if (!entry || typeof entry !== 'object') continue
    const { id, label, nodeIds, role } = entry as Record<string, unknown>
    if (typeof id !== 'string' || typeof label !== 'string' || !Array.isArray(nodeIds)) continue
    if (!nodeIds.every((n) => typeof n === 'string')) continue
    if (role !== undefined && role !== 'second-tier-supplier' && role !== 'tier-1-supplier' && role !== 'customer') continue
    zones.push({ id, label, nodeIds: nodeIds as string[], ...(role !== undefined ? { role } : {}) })
  }
  return zones.length > 0 ? zones : undefined
}

export interface VsmZoneBounds {
  id: string
  label: string
  minX: number
  minY: number
  maxX: number
  maxY: number
}

/**
 * World-space bounding box per zone, from its member nodes' CURRENT
 * positions (so dragging a node keeps the boundary lines/labels correct,
 * not frozen at the layout's saved positions). A `nodeId` no longer present
 * (e.g. the node was deleted since the zone was defined) is silently
 * skipped — never throws. A zone with ZERO resolvable members is omitted
 * from the result entirely (nothing to draw a boundary around).
 */
export function computeZoneBounds(nodes: VsmNode[], zones: VsmZone[]): VsmZoneBounds[] {
  const byId = new Map(nodes.map((n) => [n.id, n]))
  const result: VsmZoneBounds[] = []
  for (const zone of zones) {
    const members = zone.nodeIds.map((id) => byId.get(id)).filter((n): n is VsmNode => n != null)
    if (members.length === 0) continue
    let minX = Infinity
    let minY = Infinity
    let maxX = -Infinity
    let maxY = -Infinity
    for (const n of members) {
      minX = Math.min(minX, n.x)
      minY = Math.min(minY, n.y)
      maxX = Math.max(maxX, n.x + NODE_W)
      maxY = Math.max(maxY, n.y + getNodeH(n.type))
    }
    result.push({ id: zone.id, label: zone.label, minX, minY, maxX, maxY })
  }
  return result
}

/**
 * Vertical divider x-position between each ADJACENT pair of zone bounds,
 * sorted left-to-right by their own minX (Kais §11 "grundsätzlich von
 * links nach rechts" — independent of the `zones` array's own input order)
 * — the midpoint between the left zone's right edge and the right zone's
 * left edge. One fewer divider than zones (n-1 for n zones); `[]` for 0 or
 * 1 zone (nothing to divide).
 */
export function computeZoneDividers(zoneBounds: VsmZoneBounds[]): number[] {
  const sorted = [...zoneBounds].sort((a, b) => a.minX - b.minX)
  const dividers: number[] = []
  for (let i = 0; i < sorted.length - 1; i++) {
    dividers.push((sorted[i].maxX + sorted[i + 1].minX) / 2)
  }
  return dividers
}

/**
 * Review-Fix F4 (adversarial review, Wertstrom P0): drops connections whose
 * fromNodeId/toNodeId does not resolve to any node in the given list —
 * "dangling" connections a Wertstrom row can already carry in the DB
 * (historical bugs/races), predating the server-side referential-integrity
 * check (lib/api/schemas.ts UpdateValueStreamMapBody). Without this, that
 * check rejects every subsequent save of such a row with 400, making the
 * Wertstrom permanently unsavable — the editor loads it, and any PUT
 * (which always resends the full nodes+connections graph) carries the same
 * dangling reference right back.
 *
 * A dangling connection has no valid nodes to draw between anyway — it is
 * not rendered, not editable, functionally dead in the canvas. Dropping it
 * on load is cleanup, not data loss: the next save persists the cleaned-up
 * connections array and heals the DB row. The server-side check stays in
 * place as the actual gate; this is what keeps a client that only ever
 * reads/writes through this editor from ever tripping it on an old row.
 *
 * Pure/exported for the same reason parseViewportFromLayout is — called
 * once from vsm-editor.tsx's initial `connections` state.
 */
export function sanitizeConnections(nodes: VsmNode[], connections: VsmConnection[]): VsmConnection[] {
  const nodeIds = new Set(nodes.map((n) => n.id))
  return connections.filter((c) => nodeIds.has(c.fromNodeId) && nodeIds.has(c.toNodeId))
}

// ── VSM-Standard-Visualisierung, Baustein 2 (Kais-Live-Feedback 23.07.,
// KAR-878): Canvas-integrierte Standard-Zeitlinie — Geometrie ────────────
//
// Kais wörtlich: "Die Zeitlinie ist nicht nach dem Standard. Es muss direkt
// unter dem Wertstrom sein, nur nach oben/unten verschiebbar, damit es mit
// dem Wertstrom verlinkt ist und erkannt wird." Classic VSM zigzag/Treppen-
// Zeitlinien (Rother/Shook) draw ONE contiguous band below the whole process
// row, x-aligned per element, split by a horizontal midline into an upper
// lane (NVA: Wartezeit/Bestand/Transport — computeTimelineSequence's
// `level: 'upper'`) and a lower lane (VA/NVA-Prozesszeit — `level: 'lower'`).
// TIMELINE_BAND_UPPER_LANE_H reserves headroom above the midline for the
// tallest possible upper-lane bar (same constant vsm-timeline-zigzag.tsx's
// MAX_H used, kept in sync here since that component's schematic
// log-scale segment height is unchanged) so the band's DEFAULT resting
// position (offsetY 0) never overlaps the node row above it — the user can
// still pull it closer (down to TIMELINE_BAND_MIN_GAP) via
// resolveTimelineOffsetY's floor, satisfying Kais' "direkt … kleben"
// without a silent full overlap.

/** Gap (px, world-space) between the lowest node's bottom edge and the TOP
 * of the band's upper lane, at the default offsetY (0). */
export const TIMELINE_BAND_GAP = 16
/** Floor for the SAME gap once a negative offsetY pulls the band upward —
 * never fully zero, so the band stays visually distinct from the node row
 * even when dragged as close as possible. */
export const TIMELINE_BAND_MIN_GAP = 4
/** Headroom reserved above the band's midline — mirrors
 * vsm-timeline-zigzag.tsx's MAX_H (the tallest a single upper-lane bar can
 * render). */
export const TIMELINE_BAND_UPPER_LANE_H = 92

/** K4-Fix (Referenzwertstrom-Fixrunde 2, KAR-878): upper bound (px,
 * world-space) for a manually-dragged/stepped/persisted offsetY —
 * `resolveTimelineOffsetY` only clamped a LOWER floor before this fix, so
 * the band could be dragged/stepped arbitrarily far below any viewport
 * (the drag handler divides screen-space delta by the live zoom, and the
 * shipped reference Wertstrom itself persists zoom 0.32 — an ordinary drag
 * there is amplified up to ~3x into world-space offset). A static, generous
 * ceiling (not tied to live content height) keeps this a pure, single-
 * argument function shared unchanged by the drag handler, the keyboard/
 * button stepper, the Zod schema's matching upper bound
 * (lib/api/schemas.ts), AND the server-side merge clamp (route.ts) — same
 * "sanity ceiling, not a business-rule guess" posture as
 * VsmShiftModelSchema's own `.max()` bounds. 2000px is comfortably more
 * than 2x the reference Wertstrom's own content height (~660px, 24 nodes) —
 * enough headroom for a much taller real-world Wertstrom while still being
 * a bounded, recoverable value (the dedicated "Zeitlinie zurücksetzen"
 * control, vsm-editor.tsx, is the actual fast way back to 0 regardless of
 * where this ceiling sits — the ceiling's job is only to keep the
 * PERSISTED value plausible, not to make manual stepper-recovery
 * convenient). */
export const TIMELINE_BAND_OFFSET_MAX = 2000

/** Bottom (world-space y) of the LOWEST node's bounding box — `0` for an
 * empty graph (nothing to anchor the band to; the caller renders no band at
 * all in that case). */
export function computeTimelineBandNodesBottom(nodes: VsmNode[]): number {
  if (nodes.length === 0) return 0
  return Math.max(...nodes.map((n) => n.y + getNodeH(n.type)))
}

/** Clamps a persisted/live offset so the band's upper lane can never crash
 * into the node row above it (TIMELINE_BAND_MIN_GAP floor — see module
 * doc) AND so it can never be pushed further than TIMELINE_BAND_OFFSET_MAX
 * below that (K4-Fix — see its own doc comment). `null`/`undefined`/a
 * non-finite value (nothing persisted yet, or a corrupted number) resolves
 * to `0`, the band's default resting position — never NaN, never a
 * fabricated non-zero guess. */
export function resolveTimelineOffsetY(raw: number | null | undefined): number {
  if (raw == null || !Number.isFinite(raw)) return 0
  return Math.min(TIMELINE_BAND_OFFSET_MAX, Math.max(-(TIMELINE_BAND_GAP - TIMELINE_BAND_MIN_GAP), raw))
}

/** The band's horizontal midline (world-space y), i.e. the boundary between
 * its upper lane (drawn upward from this line) and lower lane (drawn
 * downward) — see module doc. `offsetY` is the RAW (possibly unclamped/
 * unresolved) persisted-or-live value; this resolves it internally so
 * callers never have to remember to pre-clamp. */
export function computeTimelineBandBaselineY(nodes: VsmNode[], offsetY: number | null | undefined): number {
  return computeTimelineBandNodesBottom(nodes) + TIMELINE_BAND_GAP + TIMELINE_BAND_UPPER_LANE_H + resolveTimelineOffsetY(offsetY)
}

/**
 * Reads `layout.timelineOffsetY` (the shape vsm-editor.tsx now writes on
 * save) back out of the freeform `layout` jsonb column, same "parse
 * defensively, undefined on anything unexpected" posture as
 * `parseShiftModelFromLayout`/`parseZonesFromLayout` above. `undefined`
 * (absent, wrong type, or non-finite) lets the caller fall back to
 * `resolveTimelineOffsetY`'s own `0` default.
 */
export function parseTimelineOffsetYFromLayout(layout: Record<string, unknown> | null | undefined): number | undefined {
  if (!layout || typeof layout !== 'object') return undefined
  const raw = (layout as { timelineOffsetY?: unknown }).timelineOffsetY
  if (typeof raw !== 'number' || !Number.isFinite(raw)) return undefined
  return raw
}

/**
 * Wertstrom P8.2b Baustein 5 (Preferred Future State, KAR-878/KAR-986/
 * KAR-987): reads `layout.preferredScenarioId` back out — same "parse
 * defensively, undefined on anything unexpected" posture as
 * `parseTimelineOffsetYFromLayout` above. Only meaningful on a PARENT row
 * (scenario_kind === 'current'); callers reading a scenario's own layout
 * simply get `undefined` (the field is never written there).
 */
export function parsePreferredScenarioIdFromLayout(layout: Record<string, unknown> | null | undefined): string | undefined {
  if (!layout || typeof layout !== 'object') return undefined
  const raw = (layout as { preferredScenarioId?: unknown }).preferredScenarioId
  if (typeof raw !== 'string' || raw.length === 0) return undefined
  return raw
}

export interface TimelineBandSegmentPosition {
  x: number
  width: number
}

/** Horizontal nudge (px) applied to each ADDITIONAL segment that lands on
 * the same (level, rounded node-x) as one already placed — see doc below. */
const TIMELINE_BAND_COLLISION_NUDGE_PX = 10

/**
 * x/width for each block in `blocks` (same order/length), taken DIRECTLY
 * from its own node's canvas `x` (Kais: "jedes Segment x-aligniert unter
 * SEINEM Element" — never a running sequence-index layout the way the old
 * vsm-timeline-zigzag.tsx computed its segment x). A block whose `nodeId`
 * does not resolve in `nodes` defensively falls back to `x: 0` rather than
 * throwing — should not happen in practice (blocks are always derived from
 * this SAME nodes array by computeTimelineSequence), but this function must
 * never crash a render over stale/mismatched inputs.
 *
 * Two blocks at the SAME lane (level) whose node shares the same rounded
 * canvas x — a genuine parallel-branch case (e.g. two supermarkets feeding
 * one downstream process at an identical x, see wertstrom-seed.ts's
 * Eingangssupermarkt Aluminiumgehäuse/Leiterplatten) — would otherwise
 * render as two fully-overlapping, indistinguishable rects. Nudging each
 * ADDITIONAL one a few px to the right keeps both visible without
 * inventing a real collision-avoidance layout. This is a cosmetic fan-out
 * only, same "documented, not solved" posture as computeTimelineSequence's
 * own Gap G6 ("Parallelpfade werden nicht taktkorrekt abgebildet") — see
 * CHANGELOG.md/PRODUCT_SPEC.md.
 */
export function computeTimelineBandSegmentPositions(
  nodes: VsmNode[],
  blocks: ReadonlyArray<Pick<TimelineBlock, 'nodeId' | 'level'>>,
): TimelineBandSegmentPosition[] {
  const byId = new Map(nodes.map((n) => [n.id, n]))
  const claimed = new Map<string, number>()
  return blocks.map((b) => {
    const node = byId.get(b.nodeId)
    const baseX = node ? node.x : 0
    const key = `${b.level}:${Math.round(baseX)}`
    const count = claimed.get(key) ?? 0
    claimed.set(key, count + 1)
    return { x: baseX + count * TIMELINE_BAND_COLLISION_NUDGE_PX, width: NODE_W }
  })
}

// ── VSM-Standard-Visualisierung, Baustein 4 (Kais-Live-Feedback 23.07.,
// KAR-878): Informationsfluss entwirren — orthogonales Bündel-Routing ─────
//
// Kais wörtlich (auf den Informationsfluss-Bereich gezeichnet): "Viel zu
// unübersichtlich." The pre-existing rendering reused buildPath's bezier
// curve for BOTH materialFlow and information edges — with a central
// PPS-Node fanning out to several targets at very different x/y, the result
// is free-crossing "Bezier-Spinnen" exactly as described. Conservative fix
// (Brief): ONLY information-kind edges get new routing; materialFlow keeps
// buildPath verbatim, untouched.

export interface InformationEdgeRoute {
  connectionId: string
  /** SVG path `d` — an orthogonal (right-angle) route: up from the source's
   * top-center to a shared horizontal collection lane, across, then down
   * into the target's top-center. */
  path: string
  /** Midpoint of the route's horizontal (collection-lane) segment — where a
   * caller places the connection's label/electronic-info glyph. */
  labelX: number
  labelY: number
}

const INFORMATION_LANE_BASE_GAP = 28
const INFORMATION_LANE_STEP = 14

function isMaterialFlowConnection(c: VsmConnection): boolean {
  return c.kind === undefined || c.kind === 'materialFlow'
}

/**
 * Orthogonal "bus" routing for every `kind: 'information'` connection —
 * replaces materialFlow's shared left/right-port bezier (`buildPath`) with
 * a route anchored at each node's TOP-center (information flow is
 * conventionally drawn ABOVE the process row, Kais §5/§11), through a
 * shared horizontal collection lane above the topmost node, so parallel
 * edges bundle instead of crossing freely.
 *
 * Deterministic lane fan-out: edges are sorted by their own (from.x + to.x)
 * mid-position (tie-broken by connection id) so spatially-nearby edges land
 * in ADJACENT lanes rather than a random-looking scatter, then distributed
 * round-robin around the base lane (0, +1, -1, +2, -2, …) — a graph with
 * many information edges (e.g. a central PPS node) fans symmetrically
 * outward instead of drifting monotonically upward. A dangling connection
 * (fromNodeId/toNodeId not resolving to a node in `nodes`) is silently
 * skipped, never a crash/NaN path — same defensive posture as this
 * module's own `sanitizeConnections`.
 *
 * K9-Fix (Referenzwertstrom-Fixrunde 2, KAR-878): the round-robin fan-out's
 * "below the base lane" half (negative laneIndex) drifts BACK DOWN toward
 * `topY` — for enough connections it re-enters the topmost node's OWN
 * bounding box whenever that node is itself one of the routed edges'
 * endpoints (empirically confirmed against DEMO_VSM_REFERENCE_ECU's PPS
 * hub, touched by all 7 information edges and itself the topmost node —
 * see finding). The reserved gap above `topY` is therefore sized for the
 * WORST (most-downward) lane THIS connection count can produce, not just
 * the base lane, so every lane stays strictly above `topY` regardless of
 * whether the topmost node participates in the routed edges at all.
 */
export function computeInformationEdgeRoutes(nodes: VsmNode[], connections: VsmConnection[]): InformationEdgeRoute[] {
  if (nodes.length === 0) return []
  const infoConnections = connections.filter((c) => !isMaterialFlowConnection(c))
  if (infoConnections.length === 0) return []

  const byId = new Map(nodes.map((n) => [n.id, n]))
  const resolved = infoConnections
    .map((c) => ({ c, from: byId.get(c.fromNodeId), to: byId.get(c.toNodeId) }))
    .filter((e): e is { c: VsmConnection; from: VsmNode; to: VsmNode } => e.from != null && e.to != null)
  if (resolved.length === 0) return []

  const topY = Math.min(...nodes.map((n) => n.y))
  const sorted = [...resolved].sort((a, b) => a.from.x + a.to.x - (b.from.x + b.to.x) || a.c.id.localeCompare(b.c.id))

  // laneIndex for the largest odd i in [0, sorted.length) has magnitude
  // floor(sorted.length / 2) — the most-negative (most-downward) lane the
  // round-robin below produces. Widening the base gap so THAT lane still
  // clears topY by a full INFORMATION_LANE_STEP keeps every lane above it;
  // for sorted.length <= 3 this is <= INFORMATION_LANE_BASE_GAP, so the
  // gap is unchanged from before this fix (Math.max is a no-op there).
  const maxDownwardLaneSteps = Math.floor(sorted.length / 2)
  const laneBaseGap = Math.max(INFORMATION_LANE_BASE_GAP, (maxDownwardLaneSteps + 1) * INFORMATION_LANE_STEP)
  const laneBaseY = topY - laneBaseGap

  return sorted.map((e, i) => {
    const fromX = e.from.x + NODE_W / 2
    const toX = e.to.x + NODE_W / 2
    const fromY = e.from.y
    const toY = e.to.y
    const laneIndex = i % 2 === 0 ? i / 2 : -((i + 1) / 2)
    const laneY = laneBaseY - laneIndex * INFORMATION_LANE_STEP
    return {
      connectionId: e.c.id,
      path: `M ${fromX} ${fromY} L ${fromX} ${laneY} L ${toX} ${laneY} L ${toX} ${toY}`,
      labelX: (fromX + toX) / 2,
      labelY: laneY,
    }
  })
}
