package com.ffii.fpsms.modules.stock.trace

import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceGraph
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceGraphEdge
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceGraphNodeRef
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceResponse
import org.springframework.stereotype.Component
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

@Component
class TraceGraphSemanticsBuilder {
    fun build(response: ItemLotTraceResponse): ItemLotTraceGraph {
        val rawNodes = TraceGraphNodeBuilder.buildNodes(response)
        val layout = TraceGraphLayoutSupport.buildLayout(response, rawNodes)
        val edges = TraceGraphEdgeSemantics.buildTraceFlowEdgePairs(layout.nodes, layout.phaseOrder)
        val nodeRefs =
            layout.nodes.map { n ->
                ItemLotTraceGraphNodeRef(
                    key = n.id,
                    kind = n.kind,
                    scopeKey = productionScopeKey(n),
                    refCode = n.refCode,
                    traceLotNo = n.traceLotNo,
                    traceItemCode = n.traceItemCode,
                    warehouseCode = n.warehouseCode,
                )
            }
        return ItemLotTraceGraph(nodes = nodeRefs, edges = edges)
    }

    private fun productionScopeKey(n: TraceGraphLayoutNode): String? {
        val lot = n.traceLotNo?.trim()
        return when {
            n.kind == "PRODUCTION_STEP" || n.kind == "SCRAP" || n.kind == "DEFECT" || n.kind == "MATERIAL_PICK" -> lot?.ifEmpty { null } ?: "__fg__"
            lot != null && lot.isNotEmpty() -> lot
            else -> null
        }
    }
}

internal data class TraceGraphLayout(
    val nodes: List<TraceGraphLayoutNode>,
    val phaseOrder: List<String>,
)

internal data class TraceGraphLayoutNode(
    val id: String,
    val kind: String,
    val timestamp: String?,
    val sortKey: Long,
    val refCode: String? = null,
    val refId: Long? = null,
    val traceLotNo: String? = null,
    val traceItemCode: String? = null,
    val consoCode: String? = null,
    val jobOrderCode: String? = null,
    val bomProcessId: Long? = null,
    val bomProcessSeqNo: Long? = null,
    val feedsProductionScopeLotNo: String? = null,
    var doGroupId: String? = null,
    val refType: String? = null,
    val warehouseCode: String? = null,
    val transferFromWarehouse: String? = null,
    val transferToWarehouse: String? = null,
    val replenishmentStockOutLineId: Long? = null,
    var phase: String = "",
    var column: Int = 0,
    var dayKey: String = "",
    var laneIndex: Int = 0,
    var sequenceIndex: Int = 0,
    var branchIndex: Int = 0,
    var branchSize: Int = 1,
    var dayPhaseStaggerIndex: Int = 0,
)

internal object TraceGraphLayoutSupport {
    private const val DO_GROUP_MIN_SIZE = 3
    private const val PICK_GROUP_MIN_SIZE = 1
    private val DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
    private val DATE_ONLY = DateTimeFormatter.ISO_LOCAL_DATE

    // 採購 → 收貨 → 品檢 → 上架(轉倉,盤點) → 出倉/提料 → 過期
    private val FG_PHASE_ORDER =
        listOf("PURCHASE", "INBOUND", "QC", "PUTAWAY", "WAREHOUSE", "STOCK_TAKE", "OUTBOUND")

    fun buildLayout(
        data: ItemLotTraceResponse,
        rawNodes: List<TraceGraphNode>,
    ): TraceGraphLayout {
        val hasPrelude = data.joPrelude != null
        val hasScrap = data.productionSteps.any { (it.scrapQty + it.defectQty).signum() > 0 }
        val hasMaterialProduction =
            data.joPrelude?.materialInputs?.let { TraceGraphNodeBuilder.materialInputsHaveProduction(it) } == true
        val hasProduction =
            data.productionSteps.isNotEmpty() ||
                data.byproductLots.isNotEmpty() ||
                hasScrap ||
                hasMaterialProduction
        val phaseOrder = getPhaseOrder(hasPrelude, hasProduction)
        val assigned = assignColumns(rawNodes, phaseOrder)
        val grouped = applyFlowNodeGrouping(assigned)
        return TraceGraphLayout(nodes = grouped, phaseOrder = phaseOrder)
    }

    private fun getPhaseOrder(hasPrelude: Boolean, hasProduction: Boolean): List<String> {
        if (hasPrelude) return preludePhaseOrder(hasProduction)
        val phases = FG_PHASE_ORDER.toMutableList()
        if (hasProduction) phases.add(phases.indexOf("QC") + 1, "PRODUCTION")
        return phases
    }

    // JO: 採購 → 原料收貨 → 品檢 → 上架(原料) → 建立工單 → 原料提料 → 生產步驟
    //     → 上架/轉倉/盤點(成品) → 出倉/提料 → 過期/已用完
    private fun preludePhaseOrder(hasProduction: Boolean): List<String> {
        val phases =
            mutableListOf(
                "PURCHASE",
                "INBOUND",
                "QC",
                "PUTAWAY",        // material putaway (上架原料)
                "MATERIAL_PICK", // includes JO_CREATED (建立工單)
            )
        if (hasProduction) phases += "PRODUCTION"
        // FG section: FG putaway is mapped to WAREHOUSE by phaseFromKind
        phases += listOf("WAREHOUSE", "STOCK_TAKE", "OUTBOUND")
        return phases
    }

    private fun assignColumns(
        sorted: List<TraceGraphNode>,
        phaseOrder: List<String>,
    ): List<TraceGraphLayoutNode> {
        val dayOrder = mutableListOf<String>()
        val columnByDayKey = mutableMapOf<String, Int>()

        val withMeta =
            sorted.mapIndexed { sequenceIndex, node ->
                val dayKey = dayKeyFromTimestamp(node.timestamp)
                val column =
                    columnByDayKey.getOrPut(dayKey) {
                        dayOrder += dayKey
                        dayOrder.size - 1
                    }
                val phase = phaseFromKind(node.kind, phaseOrder, node.traceLotNo)
                TraceGraphLayoutNode(
                    id = node.id,
                    kind = node.kind,
                    timestamp = node.timestamp,
                    sortKey = node.sortKey,
                    refCode = node.refCode,
                    refId = node.refId,
                    traceLotNo = node.traceLotNo,
                    traceItemCode = node.traceItemCode,
                    consoCode = node.consoCode,
                    jobOrderCode = node.jobOrderCode,
                    bomProcessId = node.bomProcessId,
                    bomProcessSeqNo = node.bomProcessSeqNo,
                    feedsProductionScopeLotNo = node.feedsProductionScopeLotNo,
                    doGroupId = node.doGroupId,
                    refType = node.refType,
                    warehouseCode = node.warehouseCode,
                    transferFromWarehouse = node.transferFromWarehouse,
                    transferToWarehouse = node.transferToWarehouse,
                    replenishmentStockOutLineId = node.replenishmentStockOutLineId,
                    phase = phase,
                    column = column,
                    dayKey = dayKey,
                    laneIndex = phaseOrder.indexOf(phase),
                    sequenceIndex = sequenceIndex,
                )
            }

        val staggerByDay = mutableMapOf<String, List<String>>()
        withMeta.forEach { node ->
            val ordered =
                staggerByDay.getOrPut(node.dayKey) {
                    phasesOnDay(withMeta, node.dayKey, phaseOrder)
                }
            node.dayPhaseStaggerIndex = ordered.indexOf(node.phase).coerceAtLeast(0)
        }

        val groups = mutableMapOf<String, MutableList<TraceGraphLayoutNode>>()
        withMeta.forEach { node ->
            val key = "${node.column}:${node.laneIndex}"
            groups.getOrPut(key) { mutableListOf() } += node
        }
        groups.values.forEach { list ->
            list.sortWith(sortNodesInPhase)
            list.forEachIndexed { idx, node ->
                node.branchIndex = idx
                node.branchSize = list.size
            }
        }

        return withMeta
    }

    private fun applyFlowNodeGrouping(nodes: List<TraceGraphLayoutNode>): List<TraceGraphLayoutNode> =
        applyMaterialPickGrouping(applyDoOutboundGrouping(nodes))

    private fun applyDoOutboundGrouping(nodes: List<TraceGraphLayoutNode>): List<TraceGraphLayoutNode> {
        val doByColumn = mutableMapOf<Int, MutableList<TraceGraphLayoutNode>>()
        nodes.forEach { node ->
            if (node.kind != "DO_OUT" || !node.doGroupId.isNullOrBlank()) return@forEach
            doByColumn.getOrPut(node.column) { mutableListOf() } += node
        }

        val groupNodes = mutableListOf<TraceGraphLayoutNode>()
        val childGroupIds = mutableMapOf<String, String>()

        doByColumn.forEach { (column, columnNodes) ->
            if (columnNodes.size < DO_GROUP_MIN_SIZE) return@forEach
            val groupId = "do-group-col-$column"
            val sorted = columnNodes.sortedWith(sortNodesInPhase)
            val minSort = sorted.minOf { it.sortKey }
            sorted.forEach { child -> childGroupIds[child.id] = groupId }
            val first = sorted.first()
            groupNodes +=
                TraceGraphLayoutNode(
                    id = groupId,
                    kind = "DO_GROUP",
                    timestamp = first.timestamp,
                    sortKey = minSort,
                    refCode = first.refCode,
                    phase = first.phase,
                    column = column,
                    dayKey = first.dayKey,
                    laneIndex = first.laneIndex,
                    sequenceIndex = first.sequenceIndex,
                    dayPhaseStaggerIndex = first.dayPhaseStaggerIndex,
                )
        }

        if (groupNodes.isEmpty()) return nodes
        return (nodes.map { node ->
            val groupId = childGroupIds[node.id]
            if (groupId != null) node.copy(doGroupId = groupId) else node
        } + groupNodes).sortedWith(compareBy<TraceGraphLayoutNode> { it.sortKey }.thenBy { it.id })
    }

    private fun applyMaterialPickGrouping(nodes: List<TraceGraphLayoutNode>): List<TraceGraphLayoutNode> {
        val picksByKey = mutableMapOf<String, MutableList<TraceGraphLayoutNode>>()
        nodes.forEach { node ->
            if (node.kind != "MATERIAL_PICK" || !node.doGroupId.isNullOrBlank()) return@forEach
            val pickCode = node.refCode?.trim().orEmpty()
            if (pickCode.isEmpty()) return@forEach
            val key = "${node.column}::$pickCode"
            picksByKey.getOrPut(key) { mutableListOf() } += node
        }

        val groupNodes = mutableListOf<TraceGraphLayoutNode>()
        val childGroupIds = mutableMapOf<String, String>()

        picksByKey.forEach { (key, columnNodes) ->
            if (columnNodes.size < PICK_GROUP_MIN_SIZE) return@forEach
            val groupId = "pick-group-${key.replace(Regex("[^a-zA-Z0-9_-]"), "_")}"
            val sorted = columnNodes.sortedWith(sortNodesInPhase)
            val minSort = sorted.minOf { it.sortKey }
            sorted.forEach { child -> childGroupIds[child.id] = groupId }
            val first = sorted.first()
            groupNodes +=
                TraceGraphLayoutNode(
                    id = groupId,
                    kind = "PICK_GROUP",
                    timestamp = first.timestamp,
                    sortKey = minSort,
                    refCode = first.refCode,
                    refId = first.refId,
                    consoCode = first.consoCode,
                    jobOrderCode = first.jobOrderCode,
                    phase = first.phase,
                    column = first.column,
                    dayKey = first.dayKey,
                    laneIndex = first.laneIndex,
                    sequenceIndex = first.sequenceIndex,
                    dayPhaseStaggerIndex = first.dayPhaseStaggerIndex,
                )
        }

        if (groupNodes.isEmpty()) return nodes
        return (nodes.map { node ->
            val groupId = childGroupIds[node.id]
            if (groupId != null) node.copy(doGroupId = groupId) else node
        } + groupNodes).sortedWith(compareBy<TraceGraphLayoutNode> { it.sortKey }.thenBy { it.id })
    }

    private fun phasesOnDay(
        nodes: List<TraceGraphLayoutNode>,
        dayKey: String,
        phaseOrder: List<String>,
    ): List<String> {
        val present = nodes.filter { it.dayKey == dayKey }.map { it.phase }.toSet()
        return sortPhasesByEarliestEvent(nodes, present.toList(), phaseOrder, dayKey)
    }

    fun dayKeyFromTimestamp(timestamp: String?): String {
        if (timestamp.isNullOrBlank()) return "—"
        return runCatching { LocalDateTime.parse(timestamp, DATE_TIME).toLocalDate().format(DATE_ONLY) }
            .getOrElse {
                timestamp.take(10).ifBlank { "—" }
            }
    }

    private fun phaseFromKind(
        kind: String,
        phaseOrder: List<String>,
        traceLotNo: String?,
    ): String {
        val materialPrelude = !traceLotNo.isNullOrBlank()
        val isPrelude = phaseOrder.contains("MATERIAL_PICK")
        return when (kind) {
            "MATERIAL_IN" -> "INBOUND"
            "JO_CREATED" -> {
                // Keep 建立工單 in 工單提料 lane with pick groups when prelude is shown.
                if (phaseOrder.contains("MATERIAL_PICK")) "MATERIAL_PICK"
                else "INBOUND"
            }
            "MATERIAL_QC", "QC", "FAIL" -> "QC"
            "MATERIAL_PICK", "PICK_GROUP" -> "MATERIAL_PICK"
            "PRODUCTION_STEP", "BYPRODUCT", "SCRAP", "DEFECT" -> "PRODUCTION"
            "OPEN" -> if (isPrelude) "PUTAWAY" else "INBOUND"
            "DO_OUT", "DO_GROUP", "RETURN", "REPLENISHMENT_CREATED" -> "OUTBOUND"
            "REPACK" -> "WAREHOUSE"
            "PURCHASE" -> "PURCHASE"
            "RECEIPT", "IN" -> "INBOUND"
            "PUTAWAY" -> {
                // Material putaways (with traceLotNo) stay in the material section.
                // FG putaways in prelude go to WAREHOUSE (after PRODUCTION).
                if (!materialPrelude && isPrelude) "WAREHOUSE"
                else "PUTAWAY"
            }
            "TRANSFER", "ADJUSTMENT" -> "WAREHOUSE"
            "OUT" -> "OUTBOUND"
            "STOCK_TAKE" -> "STOCK_TAKE"
            "EXPIRED", "DEPLETED" -> "OUTBOUND"
            else -> if (phaseOrder.contains("WAREHOUSE")) "WAREHOUSE" else phaseOrder.first()
        }
    }

    val sortNodesInPhase: Comparator<TraceGraphLayoutNode> =
        Comparator { a, b ->
            if (a.sortKey != b.sortKey) {
                a.sortKey.compareTo(b.sortKey)
            } else {
                a.sequenceIndex.compareTo(b.sequenceIndex)
            }
        }

    fun sortPhasesByEarliestEvent(
        nodes: List<TraceGraphLayoutNode>,
        phases: List<String>,
        phaseOrder: List<String>,
        dayKey: String? = null,
    ): List<String> =
        phases.sortedWith { a, b ->
            val ta = earliestSortKey(nodes, a, dayKey)
            val tb = earliestSortKey(nodes, b, dayKey)
            if (ta != tb) ta.compareTo(tb) else phaseOrder.indexOf(a).compareTo(phaseOrder.indexOf(b))
        }

    fun sortFgPhasesForCrossDayFlow(
        nodes: List<TraceGraphLayoutNode>,
        phases: List<String>,
        phaseOrder: List<String>,
    ): List<String> =
        phases.sortedWith { a, b ->
            val nodeA = earliestNodeInPhase(nodes, a, null)
            val nodeB = earliestNodeInPhase(nodes, b, null)
            val ta = nodeA?.sortKey ?: Long.MAX_VALUE
            val tb = nodeB?.sortKey ?: Long.MAX_VALUE
            val sameDay =
                nodeA != null &&
                    nodeB != null &&
                    nodeA.dayKey != "—" &&
                    nodeA.dayKey == nodeB.dayKey
            when {
                sameDay -> phaseOrder.indexOf(a).compareTo(phaseOrder.indexOf(b))
                ta != tb -> ta.compareTo(tb)
                else -> phaseOrder.indexOf(a).compareTo(phaseOrder.indexOf(b))
            }
        }

    private fun earliestNodeInPhase(
        nodes: List<TraceGraphLayoutNode>,
        phase: String,
        dayKey: String?,
    ): TraceGraphLayoutNode? {
        var best: TraceGraphLayoutNode? = null
        nodes.forEach { n ->
            if (n.phase != phase) return@forEach
            if (dayKey != null && n.dayKey != dayKey) return@forEach
            if (best == null || n.sortKey < best!!.sortKey) best = n
        }
        return best
    }

    private fun earliestSortKey(
        nodes: List<TraceGraphLayoutNode>,
        phase: String,
        dayKey: String?,
    ): Long = earliestNodeInPhase(nodes, phase, dayKey)?.sortKey ?: Long.MAX_VALUE
}

internal object TraceGraphEdgeSemantics {
    private val NO_FLOW_EDGE_KINDS = setOf("EXPIRED", "DEPLETED", "REPACK", "BYPRODUCT")
    private val WAREHOUSE_FLOW_KINDS = setOf("TRANSFER", "ADJUSTMENT")
    private val INVENTORY_STATE_KINDS =
        setOf(
            "PUTAWAY",
            "TRANSFER",
            "ADJUSTMENT",
            "OUT",
            "DO_OUT",
            "DO_GROUP",
            "STOCK_TAKE",
            "RETURN",
            "RECEIPT",
            "IN",
            "OPEN",
        )
    private val PRELUDE_MATERIAL_PHASES =
        listOf("PURCHASE", "INBOUND", "QC", "PUTAWAY", "MATERIAL_PICK", "PRODUCTION")

    private fun shouldSkipTraceFlowEdge(
        from: TraceGraphLayoutNode,
        to: TraceGraphLayoutNode,
    ): Boolean {
        if (from.kind == "JO_CREATED" && !isMaterialPreludeNode(from)) {
            if (to.kind == "MATERIAL_PICK" || to.kind == "PICK_GROUP") return false
            return true
        }
        if (
            to.kind == "PRODUCTION_STEP" &&
                from.kind != "MATERIAL_PICK" &&
                from.kind != "PICK_GROUP" &&
                from.kind != "PRODUCTION_STEP"
        ) {
            return true
        }
        if (
            from.kind in listOf("QC", "FAIL", "MATERIAL_QC") &&
                to.kind == "PRODUCTION_STEP"
        ) {
            return true
        }
        if (from.kind == "PRODUCTION_STEP" && to.kind == "JO_CREATED") {
            return true
        }
        if (from.kind == "PUTAWAY" && to.kind in listOf("QC", "FAIL")) {
            return true
        }
        if (
            from.kind == "PRODUCTION_STEP" &&
                to.kind in
                    listOf(
                        "MATERIAL_PICK",
                        "PICK_GROUP",
                        "DO_OUT",
                        "DO_GROUP",
                        "JO_OUT",
                        "PO_OUT",
                        "OUT",
                    )
        ) {
            return true
        }
        if (to.kind == "REPLENISHMENT_CREATED") {
            return from.kind !in
                listOf("PUTAWAY", "TRANSFER", "ADJUSTMENT", "OPEN", "RECEIPT", "IN")
        }
        if (from.kind == "REPLENISHMENT_CREATED") {
            if (to.kind == "DO_GROUP") return false
            if (to.kind != "DO_OUT") return true
            val targetSolId = from.replenishmentStockOutLineId
            if (targetSolId != null &&
                (to.id == "do-$targetSolId" || to.id.endsWith("-$targetSolId"))
            ) {
                return false
            }
            val doCode = from.refCode?.trim().orEmpty()
            if (doCode.isNotEmpty() && doOutMatchesDeliveryCode(to, doCode)) return false
            return true
        }
        return false
    }

    private fun doOutMatchesDeliveryCode(doOut: TraceGraphLayoutNode, doCode: String): Boolean {
        val code = doCode.trim()
        if (code.isEmpty()) return false
        if (doOut.refCode?.trim() == code) return true
        return false
    }

    private fun normWh(w: String?): String = w?.trim()?.uppercase() ?: ""

    private fun normRefType(refType: String?): String = refType?.trim()?.uppercase() ?: ""

    private fun warehouseCodesMatch(a: String?, b: String?): Boolean {
        val x = normWh(a)
        val y = normWh(b)
        if (x.isEmpty() || y.isEmpty()) return false
        if (x == y) return true
        return x.startsWith("$y-") || y.startsWith("$x-")
    }

    private fun pickSourcePutawayForTransfer(
        putaways: List<TraceGraphLayoutNode>,
        transfer: TraceGraphLayoutNode,
    ): TraceGraphLayoutNode? {
        val fromWh = normWh(transfer.transferFromWarehouse)
        val candidates = putaways.filter { it.sortKey <= transfer.sortKey }
        val whMatched =
            if (fromWh.isNotEmpty()) {
                candidates.filter { warehouseCodesMatch(it.warehouseCode, fromWh) }
            } else {
                candidates
            }
        if (whMatched.isEmpty()) return null
        val nonTransfer = whMatched.filter { normRefType(it.refType) != "TRANSFER" }
        val pool = if (nonTransfer.isNotEmpty()) nonTransfer else whMatched
        return pool.maxWithOrNull(TraceGraphLayoutSupport.sortNodesInPhase)
    }

    private fun pickDestPutawayForTransfer(
        putaways: List<TraceGraphLayoutNode>,
        transfer: TraceGraphLayoutNode,
    ): TraceGraphLayoutNode? {
        val toWh = normWh(transfer.transferToWarehouse)
        val candidates = putaways.filter { it.sortKey >= transfer.sortKey }
        val whMatched =
            if (toWh.isNotEmpty()) {
                candidates.filter { warehouseCodesMatch(it.warehouseCode, toWh) }
            } else {
                candidates
            }
        if (whMatched.isEmpty()) return null
        val transferPutaways = whMatched.filter { normRefType(it.refType) == "TRANSFER" }
        val pool = if (transferPutaways.isNotEmpty()) transferPutaways else whMatched
        return pool.minWithOrNull(TraceGraphLayoutSupport.sortNodesInPhase)
    }

    private fun connectPutawayToWarehousePhase(
        fromList: List<TraceGraphLayoutNode>,
        toList: List<TraceGraphLayoutNode>,
        add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit,
    ) {
        val transfers = toList.filter { it.kind == "TRANSFER" }
        if (transfers.isNotEmpty()) {
            transfers.forEach { tr ->
                pickSourcePutawayForTransfer(fromList, tr)?.let { src -> add(src, tr) }
            }
            return
        }
        add(fromList.last(), toList.first())
    }

    private fun buildTransferPutawayEdges(
        nodes: List<TraceGraphLayoutNode>,
        add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit,
    ) {
        val putaways = nodes.filter { it.kind == "PUTAWAY" && !isMaterialPreludeNode(it) }
        val transfers = nodes.filter { it.kind == "TRANSFER" }
        transfers.forEach { tr ->
            pickSourcePutawayForTransfer(putaways, tr)?.let { src -> add(src, tr) }
        }
    }

    fun buildTraceFlowEdgePairs(
        nodes: List<TraceGraphLayoutNode>,
        phaseOrder: List<String>,
    ): List<ItemLotTraceGraphEdge> {
        val pairs = mutableListOf<ItemLotTraceGraphEdge>()
        val seen = mutableSetOf<String>()

        fun add(from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) {
            if (NO_FLOW_EDGE_KINDS.contains(from.kind) || NO_FLOW_EDGE_KINDS.contains(to.kind)) return
            if (shouldSkipTraceFlowEdge(from, to)) return
            val key = "${from.id}->${to.id}"
            if (!seen.add(key)) return
            pairs += ItemLotTraceGraphEdge(fromKey = from.id, toKey = to.id)
        }

        val byDay = mutableMapOf<String, MutableList<TraceGraphLayoutNode>>()
        nodes.forEach { n -> byDay.getOrPut(n.dayKey) { mutableListOf() } += n }

        val sortedDays =
            byDay.keys.sortedWith { a, b ->
                when {
                    a == "—" -> 1
                    b == "—" -> -1
                    else -> a.compareTo(b)
                }
            }

        fun nodesInPhase(dayNodes: List<TraceGraphLayoutNode>, phase: String) =
            dayNodes
                .filter { it.phase == phase && !NO_FLOW_EDGE_KINDS.contains(it.kind) && !isDoGroupChild(it) }
                .sortedWith(TraceGraphLayoutSupport.sortNodesInPhase)

        for (dayKey in sortedDays) {
            val dayNodes = byDay[dayKey] ?: continue
            val phasesPresent =
                TraceGraphLayoutSupport.sortPhasesByEarliestEvent(
                    dayNodes,
                    phaseOrder.filter { p -> nodesInPhase(dayNodes, p).isNotEmpty() },
                    phaseOrder,
                    dayKey,
                )

            for (i in 0 until phasesPresent.size - 1) {
                val fromList = nodesInPhase(dayNodes, phasesPresent[i])
                val toList = nodesInPhase(dayNodes, phasesPresent[i + 1])
                if (fromList.isEmpty() || toList.isEmpty()) continue
                if (isMaterialPreludeNode(fromList.last()) || isMaterialPreludeNode(toList.first())) continue
                // STOCK_TAKE now precedes OUTBOUND in phase order; edge flows naturally
                if (phasesPresent[i] == "PUTAWAY" && phasesPresent[i + 1] == "WAREHOUSE") {
                    connectPutawayToWarehousePhase(fromList, toList, ::add)
                    continue
                }
                add(fromList.last(), toList.first())
            }
        }

        buildMaterialLotFlowEdgePairs(nodes, phaseOrder, ::add)
        buildProductionStepChainEdges(nodes, ::add)
        buildProductionToFgQcEdges(nodes, ::add)
        buildJoCreatedToMaterialPickEdges(nodes, ::add)
        buildReplenishmentToDoOutEdges(nodes, ::add)
        buildMaterialPickToProductionStepEdges(nodes, ::add)
        buildProductionStepLossEdges(nodes, ::add)
        buildFgLotFlowEdgePairs(nodes, phaseOrder, ::add)
        buildPutawayToOutboundEdges(nodes, ::add)
        buildPutawayToStockTakeEdges(nodes, ::add)
        buildTransferPutawayEdges(nodes, ::add)
        connectWarehousePhaseChainEdges(nodes, ::add)
        connectStockTakeInboundEdges(nodes, ::add)

        return pairs
    }

    private fun buildMaterialLotFlowEdgePairs(
        nodes: List<TraceGraphLayoutNode>,
        phaseOrder: List<String>,
        add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit,
    ) {
        if (!phaseOrder.contains("MATERIAL_PICK")) return
        val preludePhases = PRELUDE_MATERIAL_PHASES.filter { phaseOrder.contains(it) }
        val byLot = mutableMapOf<String, MutableList<TraceGraphLayoutNode>>()

        nodes.forEach { n ->
            if (!isMaterialPreludeNode(n)) return@forEach
            val key = materialLotKey(n) ?: return@forEach
            byLot.getOrPut(key) { mutableListOf() } += n
        }

        fun nodesInPhase(lotNodes: List<TraceGraphLayoutNode>, phase: String) =
            lotNodes
                .filter { it.phase == phase && !NO_FLOW_EDGE_KINDS.contains(it.kind) }
                .sortedWith(TraceGraphLayoutSupport.sortNodesInPhase)

        byLot.values.forEach { lotNodes ->
            val phasesPresent =
                TraceGraphLayoutSupport.sortPhasesByEarliestEvent(
                    lotNodes,
                    preludePhases.filter { p -> nodesInPhase(lotNodes, p).isNotEmpty() },
                    phaseOrder,
                )

            for (i in 0 until phasesPresent.size - 1) {
                val fromPhase = phasesPresent[i]
                val toPhase = phasesPresent[i + 1]
                val fromList = nodesInPhase(lotNodes, fromPhase)
                val toList = nodesInPhase(lotNodes, toPhase)
                if (fromList.isEmpty() || toList.isEmpty()) continue

                if (toPhase == "MATERIAL_PICK") {
                    val upstream = fromList.last()
                    val seenPickTargets = mutableSetOf<String>()
                    toList.forEach { pick ->
                        val target = resolvePickFlowTarget(pick, nodes)
                        if (!seenPickTargets.add(target.id)) return@forEach
                        add(upstream, target)
                    }
                } else {
                    add(fromList.last(), toList.first())
                }
            }
        }
    }

    private fun buildFgLotFlowEdgePairs(
        nodes: List<TraceGraphLayoutNode>,
        phaseOrder: List<String>,
        add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit,
    ) {
        val fgPhases = phaseOrder.filter { it != "MATERIAL_PICK" }
        val fgNodes =
            nodes.filter {
                !isMaterialPreludeNode(it) &&
                    !NO_FLOW_EDGE_KINDS.contains(it.kind) &&
                    !isDoGroupChild(it)
            }
        if (fgNodes.size < 2) return

        fun nodesInPhase(phase: String) =
            fgNodes.filter { it.phase == phase }.sortedWith(TraceGraphLayoutSupport.sortNodesInPhase)

        val phasesPresent =
            TraceGraphLayoutSupport.sortFgPhasesForCrossDayFlow(
                fgNodes,
                fgPhases.filter { p -> nodesInPhase(p).isNotEmpty() },
                phaseOrder,
            )

        fun pickUpstreamForTarget(
            fromList: List<TraceGraphLayoutNode>,
            target: TraceGraphLayoutNode,
        ): TraceGraphLayoutNode {
            val eligible = fromList.filter { it.column <= target.column || it.sortKey <= target.sortKey }
            return eligible.lastOrNull() ?: fromList.last()
        }

        for (i in 0 until phasesPresent.size - 1) {
            val fromPhase = phasesPresent[i]
            val toPhase = phasesPresent[i + 1]
            val fromList = nodesInPhase(fromPhase)
            val toList = nodesInPhase(toPhase)
            if (fromList.isEmpty() || toList.isEmpty()) continue

            when {
                fromPhase == "OUTBOUND" && toPhase == "STOCK_TAKE" -> Unit
                fromPhase == "OUTBOUND" &&
                    toPhase in listOf("WAREHOUSE", "PUTAWAY", "STOCK_TAKE") -> Unit
                fromPhase == "PUTAWAY" && toPhase == "WAREHOUSE" ->
                    connectPutawayToWarehousePhase(fromList, toList, add)
                toPhase == "OUTBOUND" && toList.size > 1 ->
                    toList.forEach { out -> add(pickUpstreamForTarget(fromList, out), out) }
                toList.size > 1 -> {
                    val targets =
                        if (toPhase == "PUTAWAY") {
                            val nonTransfer = toList.filter { normRefType(it.refType) != "TRANSFER" }
                            if (nonTransfer.isNotEmpty()) nonTransfer else toList
                        } else {
                            toList
                        }
                    targets.forEach { to -> add(pickUpstreamForTarget(fromList, to), to) }
                }
                else -> add(fromList.last(), toList.first())
            }
        }
    }

    private fun buildMaterialPickToProductionStepEdges(
        nodes: List<TraceGraphLayoutNode>,
        add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit,
    ) {
        val productionByScopeAndProcess = mutableMapOf<String, MutableList<TraceGraphLayoutNode>>()
        nodes.forEach { n ->
            if (n.kind != "PRODUCTION_STEP" || n.bomProcessId == null) return@forEach
            val key = "${productionScopeKey(n)}::${n.bomProcessId}"
            productionByScopeAndProcess.getOrPut(key) { mutableListOf() } += n
        }

        fun connectPickSource(source: TraceGraphLayoutNode, bomProcessId: Long) {
            val scope = source.feedsProductionScopeLotNo?.trim()?.ifEmpty { null } ?: "__fg__"
            val key = "$scope::$bomProcessId"
            val targets = productionByScopeAndProcess[key] ?: return
            if (targets.isEmpty()) return
            val target = targets.sortedWith(TraceGraphLayoutSupport.sortNodesInPhase).first()
            add(source, target)
        }

        val seenGroupProcess = mutableSetOf<String>()
        nodes.filter { it.kind == "PICK_GROUP" }.forEach { group ->
            val children =
                nodes.filter {
                    it.doGroupId == group.id && it.kind == "MATERIAL_PICK" && it.bomProcessId != null
                }
            children.forEach { child ->
                val edgeKey = "${group.id}::${child.bomProcessId}"
                if (!seenGroupProcess.add(edgeKey)) return@forEach
                connectPickSource(
                    group.copy(feedsProductionScopeLotNo = child.feedsProductionScopeLotNo),
                    child.bomProcessId!!,
                )
            }
        }

        nodes
            .filter { it.kind == "MATERIAL_PICK" && it.doGroupId.isNullOrBlank() && it.bomProcessId != null }
            .forEach { pick -> connectPickSource(pick, pick.bomProcessId!!) }
    }

    private fun buildProductionToFgQcEdges(
        nodes: List<TraceGraphLayoutNode>,
        add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit,
    ) {
        val prodSteps =
            nodes.filter {
                it.kind == "PRODUCTION_STEP" && !isMaterialPreludeNode(it) && !isDoGroupChild(it)
            }
        val qcNodes = nodes.filter { it.kind == "QC" && !isMaterialPreludeNode(it) }
        if (prodSteps.isEmpty() || qcNodes.isEmpty()) return

        val lastProd = prodSteps.sortedWith(TraceGraphLayoutSupport.sortNodesInPhase).last()
        val firstQc = qcNodes.sortedWith(TraceGraphLayoutSupport.sortNodesInPhase).first()
        if (firstQc.sortKey >= lastProd.sortKey) add(lastProd, firstQc)
    }

    private fun buildProductionStepChainEdges(
        nodes: List<TraceGraphLayoutNode>,
        add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit,
    ) {
        val byScope = mutableMapOf<String, MutableList<TraceGraphLayoutNode>>()
        nodes
            .filter { it.kind == "PRODUCTION_STEP" && !isDoGroupChild(it) }
            .forEach { n ->
                byScope.getOrPut(productionScopeKey(n)) { mutableListOf() } += n
            }
        byScope.values.forEach { list ->
            val sorted =
                list.sortedWith { a, b ->
                    val sa = a.bomProcessSeqNo
                    val sb = b.bomProcessSeqNo
                    when {
                        sa != null && sb != null && sa != sb -> sa.compareTo(sb)
                        sa != null && sb == null -> -1
                        sa == null && sb != null -> 1
                        else -> TraceGraphLayoutSupport.sortNodesInPhase.compare(a, b)
                    }
                }
            for (i in 0 until sorted.size - 1) {
                add(sorted[i], sorted[i + 1])
            }
        }
    }

    private fun buildProductionStepLossEdges(
        nodes: List<TraceGraphLayoutNode>,
        add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit,
    ) {
        val stepNodes = nodes.filter { it.kind == "PRODUCTION_STEP" && !isDoGroupChild(it) }
        val scrapNodes = nodes.filter { it.kind == "SCRAP" && !isDoGroupChild(it) }
        val defectNodes = nodes.filter { it.kind == "DEFECT" && !isDoGroupChild(it) }

        stepNodes.forEach { step ->
            val processLineId = step.id.removePrefix("prod-step-")
            scrapNodes.filter { it.id == "scrap-$processLineId" }.forEach { scrap ->
                add(step, scrap)
            }
            defectNodes.filter { it.id == "defect-$processLineId" }.forEach { defect ->
                add(step, defect)
            }
        }
    }

    private fun joCodeForPickTarget(
        target: TraceGraphLayoutNode,
        nodes: List<TraceGraphLayoutNode>,
    ): String? {
        // Prefer jobOrderCode — po.consoCode is often a TI ticket or blank, and refCode is PI-*.
        fun fromNode(n: TraceGraphLayoutNode): String? {
            val jo = n.jobOrderCode?.trim()?.ifEmpty { null }
            if (jo != null) return jo
            val conso = n.consoCode?.trim()?.ifEmpty { null } ?: return null
            return if (conso.startsWith("JO-", ignoreCase = true) || conso.startsWith("JO_")) conso else null
        }

        if (target.kind == "PICK_GROUP") {
            fromNode(target)?.let { return it }
            return nodes.firstOrNull { it.doGroupId == target.id && fromNode(it) != null }?.let { fromNode(it) }
        }
        if (target.kind == "MATERIAL_PICK") {
            return fromNode(target)
        }
        return null
    }

    private fun buildPutawayToOutboundEdges(
        nodes: List<TraceGraphLayoutNode>,
        add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit,
    ) {
        val putaways =
            nodes
                .filter { it.kind == "PUTAWAY" && !isMaterialPreludeNode(it) }
                .sortedWith(TraceGraphLayoutSupport.sortNodesInPhase)
        if (putaways.isEmpty()) return

        val outboundTargets =
            nodes.filter {
                !isDoGroupChild(it) &&
                    it.kind in listOf("DO_OUT", "DO_GROUP", "JO_OUT", "PO_OUT", "OUT", "REPLENISHMENT_CREATED")
            }
        if (outboundTargets.isEmpty()) return

        outboundTargets.forEach { target ->
            val from = pickUpstreamForPutaway(putaways, target)
            if (!shouldSkipTraceFlowEdge(from, target)) add(from, target)
        }
    }

    private fun buildPutawayToStockTakeEdges(
        nodes: List<TraceGraphLayoutNode>,
        add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit,
    ) {
        val putaways =
            nodes
                .filter { it.kind == "PUTAWAY" && !isMaterialPreludeNode(it) }
                .sortedWith(TraceGraphLayoutSupport.sortNodesInPhase)
        if (putaways.isEmpty()) return

        nodes.filter { it.kind == "STOCK_TAKE" && !isDoGroupChild(it) }.forEach { stockTake ->
            val from = pickUpstreamForPutaway(putaways, stockTake)
            if (!shouldSkipTraceFlowEdge(from, stockTake)) add(from, stockTake)
        }
    }

    private fun pickUpstreamForPutaway(
        fromList: List<TraceGraphLayoutNode>,
        target: TraceGraphLayoutNode,
    ): TraceGraphLayoutNode {
        val eligible = fromList.filter { it.column <= target.column || it.sortKey <= target.sortKey }
        return eligible.lastOrNull() ?: fromList.last()
    }

    private fun resolveDoOutFlowTarget(
        doOut: TraceGraphLayoutNode,
        nodes: List<TraceGraphLayoutNode>,
    ): TraceGraphLayoutNode {
        val groupId = doOut.doGroupId?.trim()?.ifEmpty { null } ?: return doOut
        return nodes.firstOrNull { it.id == groupId } ?: doOut
    }

    private fun findDoOutForReplenishment(
        replenishment: TraceGraphLayoutNode,
        nodes: List<TraceGraphLayoutNode>,
    ): TraceGraphLayoutNode? {
        val doOuts = nodes.filter { it.kind == "DO_OUT" }
        val stockOutLineId = replenishment.replenishmentStockOutLineId
        if (stockOutLineId != null) {
            val bySol =
                doOuts.firstOrNull {
                    it.id == "do-$stockOutLineId" || it.id.endsWith("-$stockOutLineId")
                }
            if (bySol != null) return bySol
        }
        val doCode = replenishment.refCode?.trim().orEmpty()
        if (doCode.isEmpty()) return null
        return doOuts.firstOrNull { doOutMatchesDeliveryCode(it, doCode) }
    }

    private fun buildReplenishmentToDoOutEdges(
        nodes: List<TraceGraphLayoutNode>,
        add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit,
    ) {
        nodes.filter { it.kind == "REPLENISHMENT_CREATED" }.forEach { replenishment ->
            val doOut = findDoOutForReplenishment(replenishment, nodes) ?: return@forEach
            add(replenishment, resolveDoOutFlowTarget(doOut, nodes))
        }
    }

    private fun buildJoCreatedToMaterialPickEdges(
        nodes: List<TraceGraphLayoutNode>,
        add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit,
    ) {
        // Include material-scoped JO_CREATED so nested JO stock-in links to that JO’s 提料.
        val joCreatedNodes = nodes.filter { it.kind == "JO_CREATED" }
        if (joCreatedNodes.isEmpty()) return

        val pickTargets =
            nodes.filter {
                !isDoGroupChild(it) &&
                    (it.kind == "PICK_GROUP" || (it.kind == "MATERIAL_PICK" && it.doGroupId.isNullOrBlank()))
            }
        if (pickTargets.isEmpty()) return

        pickTargets.forEach { target ->
            val pickJoCode = joCodeForPickTarget(target, nodes)
            val matches =
                joCreatedNodes.filter { jo ->
                    val joCode = jo.refCode?.trim()
                    !joCode.isNullOrEmpty() && joCode == pickJoCode
                }
            if (matches.isEmpty()) return@forEach

            val feedLot =
                when (target.kind) {
                    "PICK_GROUP" ->
                        nodes
                            .firstOrNull {
                                it.doGroupId == target.id && !it.feedsProductionScopeLotNo.isNullOrBlank()
                            }
                            ?.feedsProductionScopeLotNo
                            ?.trim()
                            .orEmpty()
                    else -> target.feedsProductionScopeLotNo?.trim().orEmpty()
                }

            val pool =
                if (feedLot.isNotEmpty()) {
                    val scoped = matches.filter { it.traceLotNo?.trim() == feedLot }
                    if (scoped.isNotEmpty()) scoped else matches
                } else {
                    // FG 工單提料: prefer FG 建立工單 (no material lot), not nested JO output.
                    val fg = matches.filter { it.traceLotNo.isNullOrBlank() }
                    if (fg.isNotEmpty()) fg else matches
                }
            val joCreated = pool.sortedWith(TraceGraphLayoutSupport.sortNodesInPhase).first()
            add(joCreated, target)
        }
    }

    private fun connectWarehousePhaseChainEdges(
        nodes: List<TraceGraphLayoutNode>,
        add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit,
    ) {
        val chainNodes =
            nodes
                .filter {
                    !isMaterialPreludeNode(it) &&
                        !NO_FLOW_EDGE_KINDS.contains(it.kind) &&
                        !isDoGroupChild(it)
                }
                .sortedWith(TraceGraphLayoutSupport.sortNodesInPhase)

        chainNodes.filter { it.kind in WAREHOUSE_FLOW_KINDS }.forEach { warehouseNode ->
            if (warehouseNode.kind == "TRANSFER") {
                val putaways =
                    chainNodes.filter { it.kind == "PUTAWAY" && !isMaterialPreludeNode(it) }
                pickSourcePutawayForTransfer(putaways, warehouseNode)?.let { src ->
                    add(src, warehouseNode)
                    return@forEach
                }
            }

            val predecessors = chainNodes.filter { it.sortKey < warehouseNode.sortKey }
            val warehousePredecessors = predecessors.filter { it.kind in WAREHOUSE_FLOW_KINDS }
            if (warehousePredecessors.isNotEmpty()) {
                add(warehousePredecessors.last(), warehouseNode)
                return@forEach
            }

            val inventoryPredecessors = predecessors.filter { it.kind in INVENTORY_STATE_KINDS }
            val lastBefore =
                inventoryPredecessors.lastOrNull()
                    ?: predecessors.lastOrNull()
            if (lastBefore != null) add(lastBefore, warehouseNode)
        }
    }

    private fun connectStockTakeInboundEdges(
        nodes: List<TraceGraphLayoutNode>,
        add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit,
    ) {
        val chainNodes =
            nodes
                .filter {
                    !isMaterialPreludeNode(it) &&
                        !NO_FLOW_EDGE_KINDS.contains(it.kind) &&
                        !isDoGroupChild(it)
                }
                .sortedWith(TraceGraphLayoutSupport.sortNodesInPhase)

        val inventoryPredecessorKinds =
            setOf("PUTAWAY", "TRANSFER", "ADJUSTMENT", "REPACK", "RECEIPT", "IN", "OPEN")

        chainNodes.filter { it.kind == "STOCK_TAKE" }.forEach { stockTake ->
            val predecessors = chainNodes.filter { it.kind != "STOCK_TAKE" && it.sortKey < stockTake.sortKey }
            if (predecessors.isEmpty()) return@forEach

            val putawayPredecessors =
                predecessors.filter { it.kind == "PUTAWAY" && !isMaterialPreludeNode(it) }
            val inventoryPredecessors = predecessors.filter { it.kind in inventoryPredecessorKinds }
            val from =
                putawayPredecessors.lastOrNull()
                    ?: inventoryPredecessors.lastOrNull()
                    ?: predecessors.lastOrNull()
            if (from != null) add(from, stockTake)
        }
    }

    private fun isMaterialPreludeNode(n: TraceGraphLayoutNode): Boolean =
        n.kind in listOf("MATERIAL_IN", "MATERIAL_QC", "MATERIAL_PICK") ||
            (n.kind == "JO_CREATED" && !n.traceLotNo.isNullOrBlank()) ||
            (n.kind == "PUTAWAY" && !n.traceLotNo.isNullOrBlank()) ||
            (n.kind == "PRODUCTION_STEP" && !n.traceLotNo.isNullOrBlank()) ||
            ((n.kind == "SCRAP" || n.kind == "DEFECT") && !n.traceLotNo.isNullOrBlank()) ||
            ((n.kind == "PURCHASE" || n.kind == "RECEIPT") && !n.traceLotNo.isNullOrBlank())

    private fun materialLotKey(n: TraceGraphLayoutNode): String? {
        val lot = n.traceLotNo?.trim()?.ifEmpty { null } ?: return null
        val item = n.traceItemCode?.trim().orEmpty()
        return "$item::$lot"
    }

    private fun resolvePickFlowTarget(
        pick: TraceGraphLayoutNode,
        nodes: List<TraceGraphLayoutNode>,
    ): TraceGraphLayoutNode {
        val groupId = pick.doGroupId?.trim()?.ifEmpty { null }
        if (groupId == null) return pick
        return nodes.firstOrNull { it.id == groupId } ?: pick
    }

    private fun productionScopeKey(n: TraceGraphLayoutNode): String = n.traceLotNo?.trim()?.ifEmpty { null } ?: "__fg__"

    private fun isDoGroupChild(n: TraceGraphLayoutNode): Boolean = !n.doGroupId.isNullOrBlank()
}
