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

import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceJoPrelude
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceMaterialInput
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceQcResult
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceResponse
import java.math.BigDecimal
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

internal data class TraceGraphNode(
    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,
)

internal object TraceGraphNodeBuilder {
    private val MOVEMENT_REF_TYPES_IN_DEDICATED_ARRAYS = setOf("TRANSFER", "ADJ", "OPEN", "TKE")
    private val DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
    private val DATE_ONLY = DateTimeFormatter.ISO_LOCAL_DATE

    fun buildNodes(data: ItemLotTraceResponse): List<TraceGraphNode> {
        val hasPrelude = data.joPrelude != null
        val hasScrap = data.productionSteps.any { (it.scrapQty + it.defectQty) > BigDecimal.ZERO }
        val hasMaterialProduction =
            data.joPrelude?.materialInputs?.let { materialInputsHaveProduction(it) } == true
        val hasProduction =
            data.productionSteps.isNotEmpty() ||
                data.byproductLots.isNotEmpty() ||
                hasScrap ||
                hasMaterialProduction

        val preludeNodes = data.joPrelude?.let { buildJoPreludeGraphNodes(it) } ?: emptyList()
        val productionNodes =
            if (hasProduction) {
                buildProductionGraphNodes(
                    data.productionSteps,
                    data.byproductLots,
                    data.lot.uom,
                )
            } else {
                emptyList()
            }
        val extendedNodes = buildExtendedTraceGraphNodes(data)
        val fgNodes = buildTraceGraphNodes(data)
        val locationBlockNodes = buildLocationBlockGraphNodes(data.locationBlocks)

        return (preludeNodes + productionNodes + extendedNodes + fgNodes + locationBlockNodes).sortedWith(
            compareBy<TraceGraphNode> { it.sortKey }.thenBy { it.id },
        )
    }

    internal fun materialInputsHaveProduction(inputs: List<ItemLotTraceMaterialInput>): Boolean =
        inputs.any { m ->
            m.productionSteps.isNotEmpty() ||
                (m.nestedJoPrelude?.materialInputs?.let { materialInputsHaveProduction(it) } == true)
        }

    private fun buildTraceGraphNodes(data: ItemLotTraceResponse): List<TraceGraphNode> {
        val nodes = mutableListOf<TraceGraphNode>()
        var seq = 0
        val stockUom = data.lot.uom

        data.purchaseEvents.forEachIndexed { i, p ->
            nodes +=
                TraceGraphNode(
                    id = "purchase-$i-${p.purchaseOrderLineId}",
                    kind = "PURCHASE",
                    timestamp = p.orderDate,
                    sortKey = parseSortKey(p.orderDate, seq++),
                    refCode = p.purchaseOrderCode,
                    refId = p.purchaseOrderId,
                )
        }

        data.movements.forEachIndexed { i, m ->
            val refType = norm(m.refType)
            if (MOVEMENT_REF_TYPES_IN_DEDICATED_ARRAYS.contains(refType)) return@forEachIndexed
            if (isStockTakeLedgerMovement(m.movementType)) return@forEachIndexed
            if (refType == "DO" && data.doDeliveries.isNotEmpty()) return@forEachIndexed
            if (refType == "RETURN" && data.returnEvents.isNotEmpty()) return@forEachIndexed

            val kind =
                when {
                    m.direction == "IN" && refType == "PO" -> "RECEIPT"
                    m.direction == "IN" && refType == "JO" -> "JO_CREATED"
                    m.direction == "IN" -> "IN"
                    m.direction == "OUT" && refType == "DO" -> "DO_OUT"
                    m.direction == "OUT" && refType == "JO_PICK" -> "JO_OUT"
                    m.direction == "OUT" && refType == "PO_PICK" -> "PO_OUT"
                    else -> "OUT"
                }
            nodes +=
                TraceGraphNode(
                    id = "mov-$i-${m.refCode}-${m.timestamp}",
                    kind = kind,
                    timestamp = m.timestamp,
                    sortKey = parseSortKey(m.timestamp, seq++),
                    refCode = m.refCode,
                    refId = m.refId,
                )
        }

        buildQcNodes(data.qcResults, seq).let { (qcNodes, nextSeq) ->
            nodes += qcNodes
            seq = nextSeq
        }

        data.putawayEvents.forEachIndexed { i, p ->
            if (norm(p.refType) == "TRANSFER") return@forEachIndexed
            nodes +=
                TraceGraphNode(
                    id = "putaway-$i-${p.inventoryLotLineId}-${p.timestamp}",
                    kind = "PUTAWAY",
                    timestamp = p.timestamp,
                    sortKey = parseSortKey(p.timestamp, seq++),
                    refCode = p.refCode,
                    refId = p.refId,
                    refType = p.refType,
                    warehouseCode = p.warehouseCode,
                )
        }

        data.stockTakeEvents.forEachIndexed { e, st ->
            nodes +=
                TraceGraphNode(
                    id = "st-$e-${st.stockTakeCode}-${st.timestamp}",
                    kind = "STOCK_TAKE",
                    timestamp = st.timestamp,
                    sortKey = parseSortKey(st.timestamp, seq++),
                    refCode = st.stockTakeCode,
                )
        }

        data.adjustments.forEachIndexed { i, a ->
            if (norm(a.adjustmentType) == "TKE" && data.stockTakeEvents.isNotEmpty()) return@forEachIndexed
            nodes +=
                TraceGraphNode(
                    id = "adj-$i-${a.refCode}-${a.timestamp}-${a.direction}",
                    kind = "ADJUSTMENT",
                    timestamp = a.timestamp,
                    sortKey = parseSortKey(a.timestamp, seq++),
                    refCode = a.refCode,
                )
        }

        data.transfers.forEachIndexed { i, tr ->
            nodes +=
                TraceGraphNode(
                    id = "tr-$i-${tr.transferCode}-${tr.timestamp}",
                    kind = "TRANSFER",
                    timestamp = tr.timestamp,
                    sortKey = parseSortKey(tr.timestamp, seq++),
                    refCode = tr.transferCode,
                    transferFromWarehouse = tr.fromWarehouse,
                    transferToWarehouse = tr.toWarehouse,
                )
        }

        val totalAvailable = data.warehouseLines.fold(BigDecimal.ZERO) { acc, w -> acc + w.availableQty }
        val expiry = data.lot.expiryDate
        if (!expiry.isNullOrBlank()) {
            runCatching { LocalDate.parse(expiry, DATE_ONLY) }.getOrNull()?.let { expDate ->
                if (expDate.isBefore(LocalDate.now())) {
                    nodes +=
                        TraceGraphNode(
                            id = "terminal-expired-${data.lot.lotNo}",
                            kind = "EXPIRED",
                            timestamp = expiry,
                            sortKey = parseExpirySortKey(expiry, seq + 1_000_000),
                        )
                }
            }
        }
        if (totalAvailable <= BigDecimal.ZERO && data.warehouseLines.isNotEmpty()) {
            val lastTs =
                data.movements.firstOrNull()?.timestamp
                    ?: data.outboundUsage.firstOrNull()?.timestamp
                    ?: data.lot.stockInDate
            nodes +=
                TraceGraphNode(
                    id = "terminal-depleted-${data.lot.lotNo}",
                    kind = "DEPLETED",
                    timestamp = lastTs,
                    sortKey = parseSortKey(lastTs, seq + 2_000_000),
                )
        }

        return nodes
    }

    private fun buildQcNodes(
        qcResults: List<ItemLotTraceQcResult>,
        seqStart: Int,
    ): Pair<List<TraceGraphNode>, Int> {
        val nodes = mutableListOf<TraceGraphNode>()
        var seq = seqStart
        groupQcResultsBySession(qcResults).forEachIndexed { i, group ->
            val first = group.firstOrNull() ?: return@forEachIndexed
            nodes +=
                TraceGraphNode(
                    id = "qc-$i-${first.stockInLineId}-${first.created}",
                    kind = "QC",
                    timestamp = first.created,
                    sortKey = parseSortKey(first.created, seq++),
                )
        }
        return nodes to seq
    }

    private fun buildProductionGraphNodes(
        productionSteps: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceProductionStep>,
        byproductLots: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceByproductLot>,
        lotUom: String,
    ): List<TraceGraphNode> {
        val nodes = mutableListOf<TraceGraphNode>()
        var seq = 0

        productionSteps.forEach { step ->
            val ts = step.endTime ?: step.startTime
            nodes +=
                TraceGraphNode(
                    id = "prod-step-${step.processLineId}",
                    kind = "PRODUCTION_STEP",
                    timestamp = ts,
                    sortKey = parseSortKey(ts, seq++),
                    refCode = null,
                    bomProcessId = step.bomProcessId,
                    bomProcessSeqNo = step.seqNo,
                )
            if (step.scrapQty > BigDecimal.ZERO) {
                nodes +=
                    TraceGraphNode(
                        id = "scrap-${step.processLineId}",
                        kind = "SCRAP",
                        timestamp = ts,
                        sortKey = parseSortKey(ts, seq++) + 1,
                        bomProcessId = step.bomProcessId,
                        bomProcessSeqNo = step.seqNo,
                    )
            }
            if (step.defectQty > BigDecimal.ZERO) {
                nodes +=
                    TraceGraphNode(
                        id = "defect-${step.processLineId}",
                        kind = "DEFECT",
                        timestamp = ts,
                        sortKey = parseSortKey(ts, seq++) + 1,
                        bomProcessId = step.bomProcessId,
                        bomProcessSeqNo = step.seqNo,
                    )
            }
        }

        byproductLots.forEachIndexed { i, bp ->
            nodes +=
                TraceGraphNode(
                    id = "byproduct-${bp.inventoryLotId ?: i}-${bp.lotNo}",
                    kind = "BYPRODUCT",
                    timestamp = bp.producedAt,
                    sortKey = parseSortKey(bp.producedAt, seq++),
                    refCode = bp.jobOrderCode,
                    refId = bp.jobOrderId,
                    traceLotNo = bp.lotNo,
                    traceItemCode = bp.itemCode,
                )
        }

        return nodes
    }

    private fun buildExtendedTraceGraphNodes(data: ItemLotTraceResponse): List<TraceGraphNode> {
        val nodes = mutableListOf<TraceGraphNode>()
        var seq = 0
        val stockUom = data.lot.uom

        data.openMovements.forEach { o ->
            nodes +=
                TraceGraphNode(
                    id = "open-${o.stockInLineId}",
                    kind = "OPEN",
                    timestamp = o.timestamp,
                    sortKey = parseSortKey(o.timestamp, seq++),
                    refCode = o.refCode,
                    warehouseCode = o.warehouseCode,
                )
        }

        data.failEvents.forEach { f ->
            nodes +=
                TraceGraphNode(
                    id = "fail-${f.failId}",
                    kind = "FAIL",
                    timestamp = f.recordDate,
                    sortKey = parseSortKey(f.recordDate, seq++),
                    refCode = f.pickOrderCode,
                    refId = f.pickOrderId,
                    consoCode = f.pickOrderCode,
                    warehouseCode = f.warehouseCode,
                )
        }

        data.doDeliveries.forEach { d ->
            nodes +=
                TraceGraphNode(
                    id = "do-${d.stockOutLineId}",
                    kind = "DO_OUT",
                    timestamp = d.timestamp,
                    sortKey = parseSortKey(d.timestamp, seq++),
                    refCode = d.deliveryOrderCode.ifBlank { d.pickOrderCode },
                    refId = d.deliveryOrderId ?: d.pickOrderId,
                    consoCode = d.consoCode.ifBlank { d.pickOrderCode },
                )
        }

        data.replenishmentEvents.forEach { r ->
            nodes +=
                TraceGraphNode(
                    id = "replenish-${r.replenishmentId}",
                    kind = "REPLENISHMENT_CREATED",
                    timestamp = r.timestamp,
                    sortKey = parseSortKey(r.timestamp, seq++),
                    refCode = r.sourceDoCode.ifBlank { r.replenishmentCode },
                    refId = r.sourceDoId,
                    replenishmentStockOutLineId = r.stockOutLineId,
                )
        }

        data.returnEvents.forEach { r ->
            nodes +=
                TraceGraphNode(
                    id = "return-${r.stockOutLineId}-${r.timestamp}",
                    kind = "RETURN",
                    timestamp = r.timestamp,
                    sortKey = parseSortKey(r.timestamp, seq++),
                    refCode = r.refCode,
                    warehouseCode = r.warehouseCode,
                )
        }

        data.lotRelations.forEachIndexed { i, rel ->
            nodes +=
                TraceGraphNode(
                    id = "repack-${rel.inventoryLotId ?: i}-${rel.lotNo}",
                    kind = "REPACK",
                    timestamp = rel.timestamp,
                    sortKey = parseSortKey(rel.timestamp, seq++),
                    traceLotNo = rel.lotNo,
                    traceItemCode = rel.itemCode,
                )
        }

        return nodes
    }

    private data class PreludeBuildContext(
        val seenPurchaseLines: MutableSet<Long> = mutableSetOf(),
        val seenInboundLots: MutableSet<Long> = mutableSetOf(),
        val seenReceiptLots: MutableSet<Long> = mutableSetOf(),
        val seenQcKeys: MutableSet<String> = mutableSetOf(),
        val seenPutawayKeys: MutableSet<String> = mutableSetOf(),
        var seq: Int = 0,
    )

    private data class LotPickRef(
        val pickOrderCode: String,
        val pickOrderId: Long?,
        val consoCode: String,
    )

    private fun buildJoPreludeGraphNodes(joPrelude: ItemLotTraceJoPrelude): List<TraceGraphNode> {
        val ctx = PreludeBuildContext()
        val lotPicks = buildLotPickOrderMap(joPrelude.materialInputs)
        val chainNodes = buildMaterialInputChainNodes(joPrelude.materialInputs, lotPicks, ctx)
        val pickNodes =
            joPrelude.materialInputs.mapIndexed { i, m ->
                createMaterialPickNode(m, i, ctx)
            }
        return (chainNodes + pickNodes).sortedWith(compareBy<TraceGraphNode> { it.sortKey }.thenBy { it.id })
    }

    private fun buildLotPickOrderMap(
        materialInputs: List<ItemLotTraceMaterialInput>,
    ): Map<String, List<LotPickRef>> {
        val map = mutableMapOf<String, MutableList<LotPickRef>>()
        materialInputs.forEach { m ->
            val code = m.pickOrderCode.trim()
            if (code.isEmpty()) return@forEach
            val key = materialLotKey(m.materialItemCode, m.materialLotNo)
            val list = map.getOrPut(key) { mutableListOf() }
            if (list.none { it.pickOrderCode == code }) {
                list +=
                    LotPickRef(
                        pickOrderCode = code,
                        pickOrderId = m.pickOrderId,
                        consoCode = m.consoCode,
                    )
            }
            val nestedInputs = m.nestedJoPrelude?.materialInputs ?: emptyList()
            if (nestedInputs.isNotEmpty()) {
                mergeLotPickMaps(map, buildLotPickOrderMap(nestedInputs))
            }
        }
        return map
    }

    private fun mergeLotPickMaps(
        target: MutableMap<String, MutableList<LotPickRef>>,
        source: Map<String, List<LotPickRef>>,
    ) {
        source.forEach { (key, picks) ->
            val list = target.getOrPut(key) { mutableListOf() }
            picks.forEach { p ->
                if (list.none { it.pickOrderCode == p.pickOrderCode }) {
                    list += p
                }
            }
        }
    }

    private fun buildMaterialInputChainNodes(
        materialInputs: List<ItemLotTraceMaterialInput>,
        lotPicks: Map<String, List<LotPickRef>>,
        ctx: PreludeBuildContext,
    ): List<TraceGraphNode> {
        val nodes = mutableListOf<TraceGraphNode>()
        materialInputs.forEach { m ->
            val joProduced = isJoProducedMaterial(m)
            val nestedInputs = m.nestedJoPrelude?.materialInputs ?: emptyList()

            if (joProduced && m.nestedJoPrelude != null) {
                buildNestedJoCreatedNode(m, ctx)?.let { nodes += it }
            }

            if (joProduced && nestedInputs.isNotEmpty()) {
                val nestedLotPicks = buildLotPickOrderMap(nestedInputs)
                nodes += buildMaterialInputChainNodes(nestedInputs, nestedLotPicks, ctx)
                nestedInputs.forEachIndexed { ni, nm ->
                    nodes += createMaterialPickNode(nm, ni, ctx, m.materialLotNo)
                }
            }

            if (joProduced && m.productionSteps.isNotEmpty()) {
                nodes += buildMaterialProductionNodes(m, ctx)
            }

            nodes += buildMaterialStockInPreludeNodes(m, lotPicks, ctx)
        }
        return nodes
    }

    private fun buildNestedJoCreatedNode(
        m: ItemLotTraceMaterialInput,
        ctx: PreludeBuildContext,
    ): TraceGraphNode? {
        val nested = m.nestedJoPrelude ?: return null
        val jo = nested.jobOrder
        val joCode = jo.jobOrderCode.trim()
        if (joCode.isEmpty()) return null

        val nestedInputs = nested.materialInputs
        val nestedPickTs = nestedInputs.mapNotNull { it.pickedAt?.trim() }.firstOrNull()
        val nestedProdTs = m.productionSteps.mapNotNull { it.startTime?.trim() }.firstOrNull()
        val ts =
            jo.createdAt?.trim()?.takeIf { it.isNotEmpty() }
                ?: jo.planStart?.trim()?.takeIf { it.isNotEmpty() }
                ?: nestedPickTs
                ?: nestedProdTs
                ?: m.stockInOrigin?.receiptDate?.trim()?.takeIf { it.isNotEmpty() }

        return TraceGraphNode(
            id = "mat-jo-created-${jo.jobOrderId}-${m.materialLotNo}",
            kind = "JO_CREATED",
            timestamp = ts,
            sortKey = parseSortKey(ts, ctx.seq++),
            refType = "JO",
            refCode = jo.jobOrderCode,
            refId = jo.jobOrderId,
            traceLotNo = m.materialLotNo,
            traceItemCode = m.materialItemCode,
        )
    }

    private fun buildMaterialProductionNodes(
        m: ItemLotTraceMaterialInput,
        ctx: PreludeBuildContext,
    ): List<TraceGraphNode> {
        if (m.productionSteps.isEmpty()) return emptyList()
        val matUom = m.materialUom.trim()
        return buildProductionGraphNodes(m.productionSteps, emptyList(), matUom).map { node ->
            node.copy(
                id = "mat-prod-${m.materialLotNo}-${node.id}",
                traceLotNo = m.materialLotNo,
                traceItemCode = m.materialItemCode,
            )
        }
    }

    private fun buildMaterialStockInPreludeNodes(
        m: ItemLotTraceMaterialInput,
        lotPicks: Map<String, List<LotPickRef>>,
        ctx: PreludeBuildContext,
    ): List<TraceGraphNode> {
        val nodes = mutableListOf<TraceGraphNode>()
        val lotId = m.materialInventoryLotId
        val origin = m.stockInOrigin
        val matUom = m.materialUom.trim()

        m.purchaseEvents.forEach { p ->
            if (ctx.seenPurchaseLines.contains(p.purchaseOrderLineId)) return@forEach
            ctx.seenPurchaseLines += p.purchaseOrderLineId
            nodes +=
                TraceGraphNode(
                    id = "mat-purchase-${p.purchaseOrderLineId}-${m.materialLotNo}",
                    kind = "PURCHASE",
                    timestamp = p.orderDate,
                    sortKey = parseSortKey(p.orderDate, ctx.seq++),
                    refCode = p.purchaseOrderCode,
                    refId = p.purchaseOrderId,
                    traceLotNo = m.materialLotNo,
                    traceItemCode = m.materialItemCode,
                )
        }

        if (origin != null && lotId != null) {
            val originType = origin.type.trim().uppercase()
            if (originType == "PO" && !ctx.seenReceiptLots.contains(lotId)) {
                ctx.seenReceiptLots += lotId
                nodes +=
                    TraceGraphNode(
                        id = "mat-receipt-$lotId-${origin.stockInLineId}",
                        kind = "RECEIPT",
                        timestamp = origin.receiptDate,
                        sortKey = parseSortKey(origin.receiptDate, ctx.seq++),
                        refCode = origin.refCode,
                        refId = origin.refId,
                        traceLotNo = m.materialLotNo,
                        traceItemCode = m.materialItemCode,
                    )
            } else if (originType != "PO" && !ctx.seenInboundLots.contains(lotId)) {
                val skipJoOriginBecauseNested =
                    originType == "JO" &&
                        !m.nestedJoPrelude?.jobOrder?.jobOrderCode?.trim().isNullOrEmpty()
                if (!skipJoOriginBecauseNested) {
                    ctx.seenInboundLots += lotId
                    val isJoOrigin = originType == "JO"
                    nodes +=
                        TraceGraphNode(
                            id = "mat-in-$lotId-${origin.stockInLineId}",
                            kind = if (isJoOrigin) "JO_CREATED" else "MATERIAL_IN",
                            timestamp = origin.receiptDate,
                            sortKey = parseSortKey(origin.receiptDate, ctx.seq++),
                            refCode = origin.refCode,
                            refId = origin.refId,
                            traceLotNo = m.materialLotNo,
                            traceItemCode = m.materialItemCode,
                        )
                }
            }
        }

        groupQcResultsBySession(m.qcResults).forEach { group ->
            val first = group.firstOrNull() ?: return@forEach
            val key = "${first.stockInLineId}-${first.created}"
            if (ctx.seenQcKeys.contains(key)) return@forEach
            ctx.seenQcKeys += key
            nodes +=
                TraceGraphNode(
                    id = "mat-qc-$key",
                    kind = "MATERIAL_QC",
                    timestamp = first.created,
                    sortKey = parseSortKey(first.created, ctx.seq++),
                    traceLotNo = m.materialLotNo,
                    traceItemCode = m.materialItemCode,
                )
        }

        m.putawayEvents.forEach { p ->
            val key = "${p.inventoryLotLineId}-${p.timestamp}"
            if (ctx.seenPutawayKeys.contains(key)) return@forEach
            if (norm(p.refType) == "TRANSFER") return@forEach
            ctx.seenPutawayKeys += key
            nodes +=
                TraceGraphNode(
                    id = "mat-putaway-${p.inventoryLotLineId}-${p.timestamp}",
                    kind = "PUTAWAY",
                    timestamp = p.timestamp,
                    sortKey = parseSortKey(p.timestamp, ctx.seq++),
                    refCode = p.refCode,
                    refId = p.refId,
                    traceLotNo = m.materialLotNo,
                    traceItemCode = m.materialItemCode,
                )
        }

        return nodes
    }

    private fun createMaterialPickNode(
        m: ItemLotTraceMaterialInput,
        index: Int,
        ctx: PreludeBuildContext,
        feedsProductionScopeLotNo: String? = null,
    ): TraceGraphNode =
        TraceGraphNode(
            id = "mat-pick-$index-${m.pickOrderId}-${m.materialLotNo}-${m.pickedAt}",
            kind = "MATERIAL_PICK",
            timestamp = m.pickedAt,
            sortKey = parseSortKey(m.pickedAt, ctx.seq++),
            refCode = m.pickOrderCode,
            refId = m.pickOrderId,
            traceLotNo = m.materialLotNo,
            traceItemCode = m.materialItemCode,
            consoCode = m.consoCode,
            jobOrderCode = m.jobOrderCode.trim().ifEmpty { null },
            bomProcessId = m.bomProcessId,
            bomProcessSeqNo = m.bomProcessSeqNo,
            feedsProductionScopeLotNo = feedsProductionScopeLotNo,
        )

    private fun isJoProducedMaterial(m: ItemLotTraceMaterialInput): Boolean =
        m.productionSteps.isNotEmpty() ||
            m.nestedJoPrelude != null ||
            m.stockInOrigin?.type?.trim()?.uppercase() == "JO"

    private fun groupQcResultsBySession(results: List<ItemLotTraceQcResult>): List<List<ItemLotTraceQcResult>> {
        val map = linkedMapOf<String, MutableList<ItemLotTraceQcResult>>()
        results.forEach { q ->
            val key = "${q.stockInLineId}:${q.created ?: ""}"
            map.getOrPut(key) { mutableListOf() } += q
        }
        return map.values.toList()
    }

    /**
     * Builds trace graph nodes for all alternate location blocks, tagged with warehouseCode
     * so the frontend can visually distinguish multi-location flows.
     */
    private fun buildLocationBlockGraphNodes(
        locationBlocks: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceLocationBlock>,
    ): List<TraceGraphNode> {
        if (locationBlocks.isEmpty()) return emptyList()
        val nodes = mutableListOf<TraceGraphNode>()
        var seq = 0

        locationBlocks.forEach { block ->
            val whCode = block.warehouseLines.firstOrNull()?.warehouseCode ?: ""
            val locPrefix = "loc-${block.inventoryLotId}"

            // Purchase events
            block.purchaseEvents.forEachIndexed { i, p ->
                nodes += TraceGraphNode(
                    id = "$locPrefix-purchase-$i-${p.purchaseOrderLineId}",
                    kind = "PURCHASE", timestamp = p.orderDate,
                    sortKey = parseSortKey(p.orderDate, seq++),
                    refCode = p.purchaseOrderCode, refId = p.purchaseOrderId,
                    warehouseCode = whCode,
                )
            }

            // Origins (receipts)
            block.origins.forEachIndexed { i, o ->
                val kind = when (norm(o.type)) {
                    "PO" -> "RECEIPT"
                    "JO" -> "JO_CREATED"
                    "OPEN" -> "OPEN"
                    else -> "IN"
                }
                nodes += TraceGraphNode(
                    id = "$locPrefix-origin-$i-${o.stockInLineId}",
                    kind = kind, timestamp = o.receiptDate,
                    sortKey = parseSortKey(o.receiptDate, seq++),
                    refCode = o.refCode, refId = o.refId,
                    warehouseCode = whCode,
                )
            }

            // QC results
            val qcBySession = groupQcResultsBySession(block.qcResults)
            qcBySession.forEachIndexed { i, group ->
                val first = group.firstOrNull() ?: return@forEachIndexed
                nodes += TraceGraphNode(
                    id = "$locPrefix-qc-$i-${first.stockInLineId}",
                    kind = "QC", timestamp = first.created,
                    sortKey = parseSortKey(first.created, seq++),
                    warehouseCode = whCode,
                )
            }

            // Putaway events
            block.putawayEvents.forEachIndexed { i, p ->
                if (norm(p.refType) == "TRANSFER") return@forEachIndexed
                nodes += TraceGraphNode(
                    id = "$locPrefix-putaway-$i-${p.inventoryLotLineId}",
                    kind = "PUTAWAY", timestamp = p.timestamp,
                    sortKey = parseSortKey(p.timestamp, seq++),
                    refCode = p.refCode, refId = p.refId,
                    refType = p.refType, warehouseCode = whCode,
                )
            }

            // Movements (non-transfer, non-ADJ, non-TKE)
            block.movements.forEachIndexed { i, m ->
                val refType = norm(m.refType)
                if (refType in MOVEMENT_REF_TYPES_IN_DEDICATED_ARRAYS) return@forEachIndexed
                if (isStockTakeLedgerMovement(m.movementType)) return@forEachIndexed
                val kind = when {
                    m.direction == "IN" && refType == "PO" -> "RECEIPT"
                    m.direction == "IN" && refType == "JO" -> "JO_CREATED"
                    m.direction == "IN" -> "IN"
                    m.direction == "OUT" && refType == "DO" -> "DO_OUT"
                    m.direction == "OUT" -> "OUT"
                    else -> "OUT"
                }
                nodes += TraceGraphNode(
                    id = "$locPrefix-mov-$i-${m.refCode}",
                    kind = kind, timestamp = m.timestamp,
                    sortKey = parseSortKey(m.timestamp, seq++),
                    refCode = m.refCode, refId = m.refId,
                    warehouseCode = whCode,
                )
            }

            // Stock take events
            block.stockTakeEvents.forEachIndexed { i, st ->
                nodes += TraceGraphNode(
                    id = "$locPrefix-st-$i-${st.stockTakeCode}",
                    kind = "STOCK_TAKE", timestamp = st.timestamp,
                    sortKey = parseSortKey(st.timestamp, seq++),
                    refCode = st.stockTakeCode,
                    warehouseCode = whCode,
                )
            }

            // Adjustments
            block.adjustments.forEachIndexed { i, a ->
                nodes += TraceGraphNode(
                    id = "$locPrefix-adj-$i-${a.refCode}-${a.timestamp}",
                    kind = "ADJUSTMENT", timestamp = a.timestamp,
                    sortKey = parseSortKey(a.timestamp, seq++),
                    refCode = a.refCode,
                    warehouseCode = whCode,
                )
            }

            // Primary buildNodes already emits every TR-* for the traced lot; skip here to avoid duplicates.

            // DO deliveries
            block.doDeliveries.forEachIndexed { i, dd ->
                nodes += TraceGraphNode(
                    id = "$locPrefix-dodel-$i-${dd.stockOutLineId}",
                    kind = "DO_OUT", timestamp = dd.timestamp,
                    sortKey = parseSortKey(dd.timestamp, seq++),
                    refCode = dd.pickOrderCode,
                    warehouseCode = whCode,
                )
            }

            block.replenishmentEvents.forEach { r ->
                nodes +=
                    TraceGraphNode(
                        id = "$locPrefix-replenish-${r.replenishmentId}",
                        kind = "REPLENISHMENT_CREATED",
                        timestamp = r.timestamp,
                        sortKey = parseSortKey(r.timestamp, seq++),
                        refCode = r.sourceDoCode.ifBlank { r.replenishmentCode },
                        refId = r.sourceDoId,
                        replenishmentStockOutLineId = r.stockOutLineId,
                        warehouseCode = whCode,
                    )
            }

            // Fail events
            block.failEvents.forEachIndexed { i, fe ->
                nodes += TraceGraphNode(
                    id = "$locPrefix-fail-$i-${fe.failId}",
                    kind = "FAIL", timestamp = fe.recordDate,
                    sortKey = parseSortKey(fe.recordDate, seq++),
                    refCode = fe.pickOrderCode,
                    warehouseCode = whCode,
                )
            }

            // Return events
            block.returnEvents.forEachIndexed { i, re ->
                nodes += TraceGraphNode(
                    id = "$locPrefix-return-$i-${re.stockOutLineId}",
                    kind = "RETURN", timestamp = re.timestamp,
                    sortKey = parseSortKey(re.timestamp, seq++),
                    refCode = re.refCode,
                    warehouseCode = whCode,
                )
            }

            // Open movements
            block.openMovements.forEachIndexed { i, om ->
                nodes += TraceGraphNode(
                    id = "$locPrefix-open-$i-${om.stockInLineId}",
                    kind = "OPEN", timestamp = om.timestamp,
                    sortKey = parseSortKey(om.timestamp, seq++),
                    refCode = om.refCode,
                    warehouseCode = whCode,
                )
            }
        }

        return nodes
    }

    private fun materialLotKey(itemCode: String, lotNo: String): String = "$itemCode::$lotNo"

    private fun norm(v: String?): String = v?.trim()?.uppercase() ?: ""

    private fun isStockTakeLedgerMovement(movementType: String): Boolean {
        val mt = movementType.trim().uppercase()
        return mt == "TKE" || mt == "STOCKTAKE"
    }

    private fun parseSortKey(ts: String?, seq: Int): Long {
        if (ts.isNullOrBlank()) return seq.toLong()
        return runCatching { LocalDateTime.parse(ts, DATE_TIME).toEpochSecond(java.time.ZoneOffset.UTC) * 1000 }
            .getOrElse {
                runCatching { LocalDate.parse(ts, DATE_ONLY).atStartOfDay().toEpochSecond(java.time.ZoneOffset.UTC) * 1000 }
                    .getOrElse { seq.toLong() }
            }
    }

    /** Expiry is date-only; sort at end of that calendar day (23:59:59). */
    private fun parseExpirySortKey(expiry: String?, seq: Int): Long {
        if (expiry.isNullOrBlank()) return seq.toLong()
        return runCatching {
            val dateStr = if (expiry.length >= 10) expiry.substring(0, 10) else expiry.trim()
            LocalDate.parse(dateStr, DATE_ONLY)
                .atTime(23, 59, 59)
                .toEpochSecond(java.time.ZoneOffset.UTC) * 1000
        }.getOrElse { seq.toLong() }
    }
}
