package com.ffii.fpsms.modules.deliveryOrder.service

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus
import com.ffii.fpsms.modules.deliveryOrder.web.models.ReleaseDoRequest
import com.ffii.fpsms.modules.deliveryOrder.web.models.ReleaseDoResult
import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchMergeTicketCandidate
import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchMergeTicketCandidatesResponse
import com.ffii.fpsms.modules.master.web.models.MessageResponse
import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService
import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Instant
import java.sql.SQLException
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.Semaphore
import java.util.concurrent.atomic.AtomicInteger
import jakarta.persistence.OptimisticLockException
import org.hibernate.StaleObjectStateException
import org.springframework.orm.ObjectOptimisticLockingFailureException

private const val WORKBENCH_RELEASE_RETRY_MAX = 3

/** 與 workbench 車線摘要一致：`車線-X`（預設車）不帶樓層 `storeId`。 */
private const val WORKBENCH_DEFAULT_TRUCK_LANCE_CODE = "車線-X"

/** 票號 `TI-B-yyyyMMdd-{floor}-nnn` 中段；無樓層時用 `X` 與 2F/4F 區隔。 */
private const val WORKBENCH_TICKET_FLOOR_SEGMENT_DEFAULT_TRUCK = "X"

/** Merged batch/single-family workbench ticket prefix. */
private const val WORKBENCH_MERGE_TICKET_PREFIX = "TI-M-"

private fun isWorkbenchOptimisticLockFailure(t: Throwable?): Boolean {
    var c: Throwable? = t
    while (c != null) {
        when (c) {
            is StaleObjectStateException -> return true
            is OptimisticLockException -> return true
            is ObjectOptimisticLockingFailureException -> return true
        }
        if (c.message?.contains("Row was updated or deleted by another transaction", ignoreCase = true) == true) {
            return true
        }
        c = c.cause
    }
    return false
}

private fun isWorkbenchDeadlockFailure(t: Throwable?): Boolean {
    var c: Throwable? = t
    while (c != null) {
        if (c.message?.contains("Deadlock", ignoreCase = true) == true) return true
        if (c.message?.contains("try restarting transaction", ignoreCase = true) == true) return true
        (c as? SQLException)?.let { sql ->
            if (sql.sqlState == "40001") return true
            if (sql.errorCode == 1213) return true
        }
        c = c.cause
    }
    return false
}

private fun isWorkbenchRetriableFailure(t: Throwable?): Boolean =
    isWorkbenchOptimisticLockFailure(t) || isWorkbenchDeadlockFailure(t)

data class WorkbenchBatchReleaseSummary(
    val total: Int,
    val success: Int,
    val failed: List<Pair<Long, String>>,
    val createdDeliveryOrderPickOrders: Int,
)

data class WorkbenchBatchReleaseJobStatus(
    val jobId: String,
    val total: Int,
    val success: AtomicInteger = AtomicInteger(0),
    val failed: MutableList<Pair<Long, String>> = mutableListOf(),
    @Volatile var running: Boolean = true,
    val startedAt: Long = Instant.now().toEpochMilli(),
    @Volatile var finishedAt: Long? = null,
)

@Service
open class DoWorkbenchReleaseService(
    private val deliveryOrderService: DeliveryOrderService,
    private val doReplenishmentService: DoReplenishmentService,
    private val jdbcDao: JdbcDao,
    private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService,
    private val stockOutLineWorkbenchService: StockOutLineWorkbenchService,
    private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService,
) {
    private val poolSize = Runtime.getRuntime().availableProcessors()
    private val executor = Executors.newFixedThreadPool(kotlin.math.min(poolSize, 4))
    /** Only one workbench batch-release job runs at a time (per JVM). */
    private val batchReleaseConcurrencyGate = Semaphore(1, true)
    private val jobs = ConcurrentHashMap<String, WorkbenchBatchReleaseJobStatus>()

    private fun releaseDeliveryOrderWorkbenchWithRetries(deliveryOrderId: Long, userId: Long, useV2: Boolean): ReleaseDoResult? {
        var released: ReleaseDoResult? = null
        for (attempt in 1..WORKBENCH_RELEASE_RETRY_MAX) {
            try {
                released = if (useV2) {
                    deliveryOrderService.releaseDeliveryOrderWithoutTicketNoHoldV2(
                        ReleaseDoRequest(id = deliveryOrderId, userId = userId)
                    )
                } else {
                    deliveryOrderService.releaseDeliveryOrderWithoutTicketNoHold(
                        ReleaseDoRequest(id = deliveryOrderId, userId = userId)
                    )
                }
                break
            } catch (e: Exception) {
                if (isWorkbenchRetriableFailure(e) && attempt < WORKBENCH_RELEASE_RETRY_MAX) {
                    Thread.sleep(50L * attempt)
                } else {
                    throw e
                }
            }
        }
        return released
    }

    open fun startBatchReleaseAsync(ids: List<Long>, userId: Long): MessageResponse =
        startBatchReleaseAsyncInternal(ids, userId, useV2 = false, dopReleaseType = "batch")

    /**
     * V2: deferred suggested pick / stock out / stock out lines until [DoWorkbenchDopoAssignmentService] assigns the ticket.
     */
    open fun startBatchReleaseAsyncV2(ids: List<Long>, userId: Long, mergeExtraIntoLaneTicket: Boolean = true): MessageResponse =
        startBatchReleaseAsyncInternal(ids, userId, useV2 = true, dopReleaseType = "batch", mergeExtraIntoLaneTicket = mergeExtraIntoLaneTicket)

    /**
     * V2 async for one (or few) DOs: [delivery_order_pick_order.releaseType] = `single`, ticket prefix `TI-S-` (aligned with legacy single DO pick tickets).
     */
    open fun startBatchReleaseAsyncSingleV2(ids: List<Long>, userId: Long): MessageResponse =
        startBatchReleaseAsyncInternal(ids, userId, useV2 = true, dopReleaseType = "single")

    private fun startBatchReleaseAsyncInternal(
        ids: List<Long>,
        userId: Long,
        useV2: Boolean,
        dopReleaseType: String,
        mergeExtraIntoLaneTicket: Boolean = true,
    ): MessageResponse {
        if (ids.isEmpty()) {
            return MessageResponse(
                id = null,
                code = "NO_IDS",
                name = null,
                type = asyncJobType(useV2, dopReleaseType),
                message = "No delivery order ids provided",
                errorPosition = null,
                entity = null
            )
        }

        val jobId = UUID.randomUUID().toString()
        val status = WorkbenchBatchReleaseJobStatus(jobId = jobId, total = ids.size)
        jobs[jobId] = status

        executor.submit {
            batchReleaseConcurrencyGate.acquireUninterruptibly()
            try {
                val orderedIds = getOrderedDeliveryOrderIds(ids)
                val successResults = mutableListOf<ReleaseDoResult>()

                orderedIds.forEach { deliveryOrderId ->
                    try {
                        val statusRows = jdbcDao.queryForList(
                            """
                            SELECT status
                            FROM fpsmsdb.delivery_order
                            WHERE id = :id AND deleted = 0
                            """.trimIndent(),
                            mapOf("id" to deliveryOrderId)
                        )
                        val currentStatus = statusRows.firstOrNull()?.get("status")?.toString()?.lowercase()
                        if (currentStatus == DeliveryOrderStatus.COMPLETED.value || currentStatus == DeliveryOrderStatus.RECEIVING.value) {
                            return@forEach
                        }

                        val released = releaseDeliveryOrderWorkbenchWithRetries(deliveryOrderId, userId, useV2)

                        if (released != null) {
                            successResults += released
                            status.success.incrementAndGet()
                        } else {
                            synchronized(status.failed) {
                                status.failed.add(deliveryOrderId to "Release returned null")
                            }
                        }
                    } catch (e: Exception) {
                        synchronized(status.failed) {
                            status.failed.add(deliveryOrderId to (e.message ?: "Unknown error"))
                        }
                    }
                }

                try {
                    createAndLinkDeliveryOrderPickOrders(successResults, dopReleaseType, mergeExtraIntoLaneTicket)
                } catch (e: Exception) {
                    // header-link failure shouldn't crash job; status.failed already includes per-DO failures
                    println("❌ workbench createAndLinkDeliveryOrderPickOrders failed: ${e.message}")
                }

                val replenishmentPickOrderIds = runWorkbenchReplenishmentRelease(successResults)

                if (!useV2) {
                    val pickOrdersForDownstream = (successResults.map { it.pickOrderId } + replenishmentPickOrderIds).toSet()
                    pickOrdersForDownstream.forEach { pickOrderId ->
                        try {
                            suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrder(pickOrderId)
                            stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(pickOrderId, userId)
                        } catch (e: Exception) {
                            val deliveryOrderId = successResults.firstOrNull { it.pickOrderId == pickOrderId }?.deliveryOrderId
                                ?: 0L
                            synchronized(status.failed) {
                                status.failed.add(
                                    deliveryOrderId to ("Downstream workbench step failed for pick order $pickOrderId: ${e.message}")
                                )
                            }
                        }
                    }
                }
            } finally {
                status.running = false
                status.finishedAt = Instant.now().toEpochMilli()
                batchReleaseConcurrencyGate.release()
            }
        }

        return MessageResponse(
            id = null,
            code = "STARTED",
            name = null,
            type = asyncJobType(useV2, dopReleaseType),
            message = asyncJobMessage(useV2, dopReleaseType),
            errorPosition = null,
            entity = mapOf("jobId" to jobId, "total" to ids.size)
        )
    }

    open fun getBatchReleaseProgress(jobId: String): MessageResponse {
        val s = jobs[jobId] ?: return MessageResponse(
            id = null,
            code = "NOT_FOUND",
            name = null,
            type = "workbench_batch_release_progress",
            message = "Job not found",
            errorPosition = null,
            entity = null
        )
        val finished = s.success.get() + s.failed.size
        val progress = (finished.toDouble() / s.total).coerceIn(0.0, 1.0)

        return MessageResponse(
            id = null,
            code = if (s.running) "RUNNING" else "FINISHED",
            name = null,
            type = "workbench_batch_release_progress",
            message = null,
            errorPosition = null,
            entity = mapOf(
                "jobId" to s.jobId,
                "total" to s.total,
                "finished" to finished,
                "success" to s.success.get(),
                "failedCount" to s.failed.size,
                "failed" to s.failed.take(50),
                "running" to s.running,
                "progress" to progress,
                "startedAt" to s.startedAt,
                "finishedAt" to s.finishedAt
            )
        )
    }

    /**
     * New batch release flow for workbench:
     * 1) release DO -> create pick_order (existing flow),
     * 2) create grouped header in delivery_order_pick_order,
     * 3) backfill pick_order.deliveryOrderPickOrderId.
     */
    @Transactional(rollbackFor = [Exception::class])
    open fun releaseBatch(ids: List<Long>, userId: Long): MessageResponse =
        releaseBatchInternal(ids, userId, useV2 = false)

    /**
     * Synchronous batch release V2: same as [releaseBatch] but uses [DeliveryOrderService.releaseDeliveryOrderWithoutTicketNoHoldV2]
     * and skips suggested pick + stock out line creation (done on ticket assign).
     */
    @Transactional(rollbackFor = [Exception::class])
    open fun releaseBatchV2(ids: List<Long>, userId: Long, mergeExtraIntoLaneTicket: Boolean = true): MessageResponse =
        releaseBatchInternal(ids, userId, useV2 = true, mergeExtraIntoLaneTicket = mergeExtraIntoLaneTicket)

    private fun releaseBatchInternal(
        ids: List<Long>,
        userId: Long,
        useV2: Boolean,
        mergeExtraIntoLaneTicket: Boolean = true,
    ): MessageResponse {
        if (ids.isEmpty()) {
            return MessageResponse(
                id = null,
                code = "NO_IDS",
                name = null,
                type = if (useV2) "workbench_batch_release_v2" else "workbench_batch_release",
                message = "No delivery order ids provided",
                errorPosition = null,
                entity = null
            )
        }

        val orderedIds = getOrderedDeliveryOrderIds(ids)
        val successResults = mutableListOf<ReleaseDoResult>()
        val failed = mutableListOf<Pair<Long, String>>()

        orderedIds.forEach { deliveryOrderId ->
            try {
                // Skip completed-like status explicitly to keep batch idempotent.
                val statusRows = jdbcDao.queryForList(
                    """
                    SELECT status
                    FROM fpsmsdb.delivery_order
                    WHERE id = :id AND deleted = 0
                    """.trimIndent(),
                    mapOf("id" to deliveryOrderId)
                )
                val currentStatus = statusRows.firstOrNull()?.get("status")?.toString()?.lowercase()
                if (currentStatus == DeliveryOrderStatus.COMPLETED.value || currentStatus == DeliveryOrderStatus.RECEIVING.value) {
                    return@forEach
                }

                val released = releaseDeliveryOrderWorkbenchWithRetries(deliveryOrderId, userId, useV2)

                if (released == null) {
                    failed += deliveryOrderId to "Release returned null"
                } else {
                    successResults += released
                }
            } catch (e: Exception) {
                failed += deliveryOrderId to (e.message ?: "Unknown error")
            }
        }

        val createdHeaders = createAndLinkDeliveryOrderPickOrders(successResults, "batch", mergeExtraIntoLaneTicket)
        val replenishmentPickOrderIds = runWorkbenchReplenishmentRelease(successResults)
        if (!useV2) {
            val pickOrdersForDownstream = (successResults.map { it.pickOrderId } + replenishmentPickOrderIds).toSet()
            pickOrdersForDownstream.forEach { pickOrderId ->
                try {
                    suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrder(pickOrderId)
                    stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(pickOrderId, userId)
                } catch (_: Exception) {
                    // keep batch release resilient; failures are reflected by missing downstream data and can be re-triggered
                }
            }
        }
        val summary = WorkbenchBatchReleaseSummary(
            total = ids.size,
            success = successResults.size,
            failed = failed,
            createdDeliveryOrderPickOrders = createdHeaders
        )

        return MessageResponse(
            id = null,
            code = if (failed.isEmpty()) "SUCCESS" else "PARTIAL_SUCCESS",
            name = if (useV2) "workbench_batch_release_v2" else "workbench_batch_release",
            type = if (useV2) "workbench_batch_release_v2" else "workbench_batch_release",
            message = "Released ${successResults.size}/${ids.size} delivery orders",
            errorPosition = null,
            entity = summary
        )
    }

    /**
     * `TI-B-yyyyMMdd-2F-001` (batch / isExtrabatch) or `TI-S-yyyyMMdd-2F-001` (single / isExtrasingle),
     * same suffix rules as [DoReleaseCoordinatorService] / legacy `do_pick_order`.
     */
    private fun nextDeliveryOrderPickOrderTicketNo(
        requiredDate: LocalDate,
        storeDisplay: String,
        ticketLetter: String,
    ): String {
        require(ticketLetter == "B" || ticketLetter == "S" || ticketLetter == "E" || ticketLetter == "M") {
            "ticketLetter must be B, S, E, or M"
        }
        val ymd = requiredDate.format(DateTimeFormatter.ofPattern("yyyyMMdd"))
        val floor = storeDisplay.replace("/", "").trim()
        val prefix = "TI-$ticketLetter-$ymd-$floor-"
        val sql = """
    SELECT ticket_no AS t FROM fpsmsdb.do_pick_order
    WHERE ticket_no LIKE CONCAT(:prefix, '%')
    UNION ALL
    SELECT ticketNo AS t FROM fpsmsdb.delivery_order_pick_order
    WHERE ticketNo LIKE CONCAT(:prefix, '%')
""".trimIndent()
        val rows = try {
            jdbcDao.queryForList(sql, mapOf("prefix" to prefix))
        } catch (_: Exception) {
            emptyList()
        }
        val suffixPattern = Regex("^${Regex.escape(prefix)}(\\d+)$")
        var maxSeq = 0
        for (row in rows) {
            val key = row.keys.find { it.equals("t", ignoreCase = true) } ?: row.keys.firstOrNull() ?: continue
            val ticket = row[key]?.toString() ?: continue
            suffixPattern.find(ticket)?.groupValues?.getOrNull(1)?.toIntOrNull()?.let { if (it > maxSeq) maxSeq = it }
        }
        val next = maxSeq + 1
        return "$prefix${next.toString().padStart(3, '0')}"
    }

    private fun nextDeliveryOrderPickOrderBatchTicketNo(requiredDate: LocalDate, storeDisplay: String): String =
        nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "B")

    private fun nextDeliveryOrderPickOrderSingleTicketNo(requiredDate: LocalDate, storeDisplay: String): String =
        nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "S")

    private fun nextDeliveryOrderPickOrderEtraTicketNo(requiredDate: LocalDate, storeDisplay: String): String =
        nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "E")

    private fun nextDeliveryOrderPickOrderMergeTicketNo(requiredDate: LocalDate, storeDisplay: String): String =
        nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "M")

    private fun isWorkbenchMergeTicketNo(ticketNo: String?): Boolean =
        ticketNo?.trim()?.uppercase()?.startsWith(WORKBENCH_MERGE_TICKET_PREFIX) == true

    private fun asyncJobType(useV2: Boolean, dopReleaseType: String): String {
        val single = dopReleaseType.equals("single", ignoreCase = true)
        return when {
            useV2 && single -> "workbench_single_release_async_v2"
            useV2 -> "workbench_batch_release_async_v2"
            single -> "workbench_single_release_async"
            else -> "workbench_batch_release_async"
        }
    }

    private fun asyncJobMessage(useV2: Boolean, dopReleaseType: String): String {
        val single = dopReleaseType.equals("single", ignoreCase = true)
        return when {
            useV2 && single -> "Workbench single release V2 started"
            useV2 -> "Workbench batch release V2 started"
            single -> "Workbench single release started"
            else -> "Workbench batch release started"
        }
    }

    private fun getOrderedDeliveryOrderIds(ids: List<Long>): List<Long> {
        if (ids.isEmpty()) return emptyList()
        return try {
            val sql = """
                SELECT do.id
                FROM fpsmsdb.delivery_order do
                WHERE do.deleted = 0
                  AND do.id IN (${ids.joinToString(",")})
                ORDER BY DATE(do.estimatedArrivalDate), do.id
            """.trimIndent()
            val sorted = jdbcDao.queryForList(sql).mapNotNull { (it["id"] as? Number)?.toLong() }
            if (sorted.isEmpty()) ids else sorted
        } catch (_: Exception) {
            ids
        }
    }

    private fun runWorkbenchReplenishmentRelease(successResults: List<ReleaseDoResult>): Set<Long> {
        if (successResults.isEmpty()) return emptySet()
        return try {
            doReplenishmentService.releasePendingReplenishmentsForWorkbenchBatch(successResults)
        } catch (e: Exception) {
            println("❌ workbench replenishment release failed: ${e.message}")
            emptySet()
        }
    }

    private fun laneGroupKey(result: ReleaseDoResult): String =
        listOf(
            result.shopId?.toString() ?: "",
            result.estimatedArrivalDate?.toString() ?: "",
            result.preferredFloor,
            result.truckId?.toString() ?: "",
            result.truckDepartureTime?.toString() ?: "",
            result.truckLanceCode ?: "",
            result.loadingSequence?.toString() ?: "",
        ).joinToString("|")

    private fun resolveStoreId(first: ReleaseDoResult): String? {
        val isDefaultTruckLane =
            first.usedDefaultTruck == true ||
                first.truckLanceCode?.trim() == WORKBENCH_DEFAULT_TRUCK_LANCE_CODE
        if (isDefaultTruckLane) return null
        return when (first.preferredFloor) {
            "2F" -> "2/F"
            "4F" -> "4/F"
            else -> "2/F"
        }
    }

    private fun resolveTicketFloorSegment(storeId: String?, isDefaultTruckLane: Boolean): String =
        if (isDefaultTruckLane) {
            WORKBENCH_TICKET_FLOOR_SEGMENT_DEFAULT_TRUCK
        } else {
            (storeId ?: "2/F").replace("/", "").trim()
        }

    private data class MergeableWorkbenchTicket(
        val id: Long,
        val releaseType: String?,
        val ticketNo: String?,
    )

    private fun appendLaneMatchClauses(
        sql: StringBuilder,
        first: ReleaseDoResult,
        storeId: String?,
        params: MutableMap<String, Any>,
    ) {
        if (storeId.isNullOrBlank()) {
            // Truck X: storeId stays null, but do not merge across supplier preferred floors (2F vs 4F).
            sql.append(" AND (storeId IS NULL OR TRIM(COALESCE(storeId, '')) = '') ")
            appendTruckXPreferredFloorMatchClauses(sql, first.preferredFloor)
        } else {
            sql.append(" AND storeId = :storeId ")
            params["storeId"] = storeId
        }
        if (first.truckId != null) {
            sql.append(" AND truckId = :truckId ")
            params["truckId"] = first.truckId!!
        } else {
            sql.append(" AND truckId IS NULL ")
        }
        if (first.truckDepartureTime != null) {
            sql.append(" AND truckDepartureTime = :truckDepartureTime ")
            params["truckDepartureTime"] = first.truckDepartureTime!!
        } else {
            sql.append(" AND truckDepartureTime IS NULL ")
        }
        if (!first.truckLanceCode.isNullOrBlank()) {
            sql.append(" AND truckLanceCode = :truckLanceCode ")
            params["truckLanceCode"] = first.truckLanceCode!!.trim()
        } else {
            sql.append(" AND (truckLanceCode IS NULL OR TRIM(truckLanceCode) = '') ")
        }
        if (first.loadingSequence != null) {
            sql.append(" AND loadingSequence = :loadingSequence ")
            params["loadingSequence"] = first.loadingSequence!!
        }
    }

    /**
     * For Truck X headers (`storeId` null): only merge into a ticket whose linked DO suppliers
     * all resolve to the same preferred floor as [preferredFloor] (4F list wins; else 2F),
     * matching normal lane separation by floor / [resolveStoreId].
     */
    private fun appendTruckXPreferredFloorMatchClauses(
        sql: StringBuilder,
        preferredFloor: String?,
    ) {
        val floor = when (preferredFloor?.trim()?.uppercase(java.util.Locale.ROOT)?.replace("/", "")) {
            "4F" -> "4F"
            else -> "2F"
        }
        val (_, s4f) = doFloorSupplierSettingsService.loadDoFloorSupplierLists()
        val in4 = s4f.joinToString(", ") { "'" + it.replace("'", "''") + "'" }
            .ifEmpty { "'__NO_4F_SUPPLIER__'" }
        val floorExpr = "CASE WHEN s.code IN ($in4) THEN '4F' ELSE '2F' END"
        // Reject tickets that already have any DO on a different floor.
        sql.append(
            """
              AND NOT EXISTS (
                SELECT 1
                FROM fpsmsdb.pick_order po
                INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0
                LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0
                WHERE po.deliveryOrderPickOrderId = delivery_order_pick_order.id
                  AND po.deleted = 0
                  AND ($floorExpr) <> '$floor'
              )
            """.trimIndent()
        )
        // Prefer an existing same-floor ticket; allow empty header (no pick_order yet).
        sql.append(
            """
              AND (
                NOT EXISTS (
                  SELECT 1 FROM fpsmsdb.pick_order po0
                  WHERE po0.deliveryOrderPickOrderId = delivery_order_pick_order.id AND po0.deleted = 0
                )
                OR EXISTS (
                  SELECT 1
                  FROM fpsmsdb.pick_order po
                  INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0
                  LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0
                  WHERE po.deliveryOrderPickOrderId = delivery_order_pick_order.id
                    AND po.deleted = 0
                    AND ($floorExpr) = '$floor'
                )
              )
            """.trimIndent()
        )
    }

    private fun mapRowToMergeableTicket(row: Map<String, Any?>): MergeableWorkbenchTicket? {
        fun cell(name: String): String? {
            val key = row.keys.find { it.equals(name, true) } ?: return null
            return row[key]?.toString()?.trim()?.takeIf { it.isNotEmpty() }
        }
        val idKey = row.keys.find { it.equals("id", true) } ?: return null
        val id = (row[idKey] as? Number)?.toLong() ?: return null
        return MergeableWorkbenchTicket(id = id, releaseType = cell("releaseType"), ticketNo = cell("ticketNo"))
    }

    /**
     * Active batch/single-family ticket for the same lane.
     * Strategy 1: prefer [TI-M] over legacy [TI-B] / in-place [isExtrabatch].
     */
    private fun findMergeableWorkbenchTicket(
        first: ReleaseDoResult,
        storeId: String?,
        isSingleRelease: Boolean,
    ): MergeableWorkbenchTicket? {
        if (first.shopId == null || first.estimatedArrivalDate == null) return null

        val familyTypes = if (isSingleRelease) {
            WorkbenchReleaseTypeSupport.singleFamilyTypes()
        } else {
            WorkbenchReleaseTypeSupport.batchFamilyTypes()
        }
        val typePlaceholders = familyTypes.joinToString(",") { "'${it.lowercase()}'" }

        val sql = StringBuilder(
            """
            SELECT id, releaseType, ticketNo
            FROM fpsmsdb.delivery_order_pick_order
            WHERE deleted = 0
              AND shopId = :shopId
              AND requiredDeliveryDate = :requiredDate
              AND ticketStatus IN ('pending', 'released')
              AND LOWER(COALESCE(releaseType, '')) IN ($typePlaceholders)
            """.trimIndent()
        )
        val params = mutableMapOf<String, Any>(
            "shopId" to first.shopId!!,
            "requiredDate" to first.estimatedArrivalDate!!,
        )
        appendLaneMatchClauses(sql, first, storeId, params)
        sql.append(
            """
             ORDER BY
               CASE
                 WHEN ticketNo LIKE 'TI-M-%' THEN 0
                 WHEN LOWER(COALESCE(releaseType, '')) IN ('isextrabatch', 'isextrasingle') THEN 1
                 ELSE 2
               END,
               id ASC
            """.trimIndent()
        )

        val rows = try {
            jdbcDao.queryForList(sql.toString(), params)
        } catch (_: Exception) {
            emptyList()
        }
        return rows.firstOrNull()?.let { mapRowToMergeableTicket(it) }
    }

    /** Pending [TI-M] merge ticket on the same lane (D1 follow-up / extra merge target). */
    private fun findActiveMergeTicket(
        first: ReleaseDoResult,
        storeId: String?,
        isSingleRelease: Boolean,
    ): MergeableWorkbenchTicket? {
        if (first.shopId == null || first.estimatedArrivalDate == null) return null
        val mergeType = WorkbenchReleaseTypeSupport.mergeTicketReleaseType(isSingleRelease).lowercase()

        val sql = StringBuilder(
            """
            SELECT id, releaseType, ticketNo
            FROM fpsmsdb.delivery_order_pick_order
            WHERE deleted = 0
              AND shopId = :shopId
              AND requiredDeliveryDate = :requiredDate
              AND ticketStatus IN ('pending', 'released')
              AND ticketNo LIKE 'TI-M-%'
              AND LOWER(COALESCE(releaseType, '')) = :mergeType
            """.trimIndent()
        )
        val params = mutableMapOf<String, Any>(
            "shopId" to first.shopId!!,
            "requiredDate" to first.estimatedArrivalDate!!,
            "mergeType" to mergeType,
        )
        appendLaneMatchClauses(sql, first, storeId, params)
        sql.append(" ORDER BY id ASC ")

        val rows = try {
            jdbcDao.queryForList(sql.toString(), params)
        } catch (_: Exception) {
            emptyList()
        }
        return rows.firstOrNull()?.let { mapRowToMergeableTicket(it) }
    }

    /**
     * Plain `batch` / `single` ticket (not [isExtrabatch]) on the same lane — candidate to retire into [TI-M].
     */
    private fun findPlainFamilyTicketForMergeRetire(
        first: ReleaseDoResult,
        storeId: String?,
        isSingleRelease: Boolean,
    ): MergeableWorkbenchTicket? {
        if (first.shopId == null || first.estimatedArrivalDate == null) return null
        val plainType = if (isSingleRelease) WorkbenchReleaseTypeSupport.SINGLE else WorkbenchReleaseTypeSupport.BATCH

        val sql = StringBuilder(
            """
            SELECT id, releaseType, ticketNo
            FROM fpsmsdb.delivery_order_pick_order
            WHERE deleted = 0
              AND shopId = :shopId
              AND requiredDeliveryDate = :requiredDate
              AND ticketStatus IN ('pending', 'released')
              AND handledBy IS NULL
              AND LOWER(COALESCE(releaseType, '')) = :plainType
            """.trimIndent()
        )
        val params = mutableMapOf<String, Any>(
            "shopId" to first.shopId!!,
            "requiredDate" to first.estimatedArrivalDate!!,
            "plainType" to plainType.lowercase(),
        )
        appendLaneMatchClauses(sql, first, storeId, params)
        sql.append(" ORDER BY id ASC ")

        val rows = try {
            jdbcDao.queryForList(sql.toString(), params)
        } catch (_: Exception) {
            emptyList()
        }
        return rows.firstOrNull()?.let { mapRowToMergeableTicket(it) }
    }

    /** Legacy in-place upgraded header (`isExtrabatch` on `TI-B-`) to retire when creating [TI-M]. */
    private fun findLegacyUpgradedFamilyTicket(
        first: ReleaseDoResult,
        storeId: String?,
        isSingleRelease: Boolean,
    ): MergeableWorkbenchTicket? {
        if (first.shopId == null || first.estimatedArrivalDate == null) return null
        val upgradedType = WorkbenchReleaseTypeSupport.mergeTicketReleaseType(isSingleRelease).lowercase()

        val sql = StringBuilder(
            """
            SELECT id, releaseType, ticketNo
            FROM fpsmsdb.delivery_order_pick_order
            WHERE deleted = 0
              AND shopId = :shopId
              AND requiredDeliveryDate = :requiredDate
              AND ticketStatus IN ('pending', 'released')
              AND handledBy IS NULL
              AND LOWER(COALESCE(releaseType, '')) = :upgradedType
              AND (ticketNo IS NULL OR ticketNo NOT LIKE 'TI-M-%')
            """.trimIndent()
        )
        val params = mutableMapOf<String, Any>(
            "shopId" to first.shopId!!,
            "requiredDate" to first.estimatedArrivalDate!!,
            "upgradedType" to upgradedType,
        )
        appendLaneMatchClauses(sql, first, storeId, params)
        sql.append(" ORDER BY id ASC ")

        val rows = try {
            jdbcDao.queryForList(sql.toString(), params)
        } catch (_: Exception) {
            emptyList()
        }
        return rows.firstOrNull()?.let { mapRowToMergeableTicket(it) }
    }

    /** Pending standalone `isExtra` ticket on the same lane (Case 2 grouping; excludes batch family). */
    private fun findMergeableStandaloneIsExtraTicket(
        first: ReleaseDoResult,
        storeId: String?,
    ): MergeableWorkbenchTicket? {
        if (first.shopId == null || first.estimatedArrivalDate == null) return null

        val sql = StringBuilder(
            """
            SELECT id, releaseType, ticketNo
            FROM fpsmsdb.delivery_order_pick_order
            WHERE deleted = 0
              AND shopId = :shopId
              AND requiredDeliveryDate = :requiredDate
              AND ticketStatus IN ('pending', 'released')
              AND LOWER(COALESCE(releaseType, '')) = 'isextra'
            """.trimIndent()
        )
        val params = mutableMapOf<String, Any>(
            "shopId" to first.shopId!!,
            "requiredDate" to first.estimatedArrivalDate!!,
        )
        appendLaneMatchClauses(sql, first, storeId, params)
        sql.append(" ORDER BY id ASC ")

        val rows = try {
            jdbcDao.queryForList(sql.toString(), params)
        } catch (_: Exception) {
            emptyList()
        }
        return rows.firstOrNull()?.let { mapRowToMergeableTicket(it) }
    }

    private fun assertTicketUnassignedForMerge(ticketId: Long) {
        val rows = jdbcDao.queryForList(
            """
            SELECT handledBy
            FROM fpsmsdb.delivery_order_pick_order
            WHERE id = :id AND deleted = 0
            """.trimIndent(),
            mapOf("id" to ticketId),
        )
        val handledBy = rows.firstOrNull()?.let { row ->
            val key = row.keys.find { it.equals("handledBy", true) } ?: return@let null
            row[key]
        }
        if (handledBy != null) {
            throw IllegalStateException("Ticket $ticketId is already assigned; merge not allowed")
        }
    }

    private fun setRelationshipIdSelf(headerId: Long) {
        jdbcDao.executeUpdate(
            """
            UPDATE fpsmsdb.delivery_order_pick_order
            SET relationshipId = :headerId,
                modified = :modified,
                modifiedBy = :modifiedBy
            WHERE id = :headerId
            """.trimIndent(),
            mapOf(
                "headerId" to headerId,
                "modified" to LocalDateTime.now(),
                "modifiedBy" to "system",
            ),
        )
    }

    private fun softDeleteMergedSourceTicket(sourceId: Long, successorId: Long) {
        jdbcDao.executeUpdate(
            """
            UPDATE fpsmsdb.delivery_order_pick_order
            SET deleted = 1,
                relationshipId = :successorId,
                modified = :modified,
                modifiedBy = :modifiedBy
            WHERE id = :sourceId AND deleted = 0
            """.trimIndent(),
            mapOf(
                "sourceId" to sourceId,
                "successorId" to successorId,
                "modified" to LocalDateTime.now(),
                "modifiedBy" to "system",
            ),
        )
    }

    private fun reparentPickOrdersFromHeader(sourceHeaderId: Long, targetHeaderId: Long) {
        jdbcDao.executeUpdate(
            """
            UPDATE fpsmsdb.pick_order
            SET deliveryOrderPickOrderId = :targetHeaderId
            WHERE deliveryOrderPickOrderId = :sourceHeaderId AND deleted = 0
            """.trimIndent(),
            mapOf(
                "sourceHeaderId" to sourceHeaderId,
                "targetHeaderId" to targetHeaderId,
            ),
        )
    }

    private fun retireSourceTicketIntoMergeHeader(sourceId: Long, mergeHeaderId: Long) {
        assertTicketUnassignedForMerge(sourceId)
        reparentPickOrdersFromHeader(sourceId, mergeHeaderId)
        softDeleteMergedSourceTicket(sourceId, mergeHeaderId)
    }

    /** Strategy 1: fold unassigned plain `batch` tickets into an active [TI-M] header. */
    private fun consolidateUnassignedPlainBatchTicketsIntoMergeHeader(
        first: ReleaseDoResult,
        storeId: String?,
        isSingleRelease: Boolean,
        mergeHeaderId: Long,
    ) {
        if (isSingleRelease) return
        while (true) {
            val stray = findPlainFamilyTicketForMergeRetire(first, storeId, isSingleRelease) ?: break
            if (stray.id == mergeHeaderId) break
            retireSourceTicketIntoMergeHeader(stray.id, mergeHeaderId)
        }
    }

    private fun createNewMergeHeader(
        first: ReleaseDoResult,
        storeId: String?,
        isSingleRelease: Boolean,
        requiredDate: LocalDate,
        ticketFloorSegment: String,
    ): Long? {
        val ticketNo = nextDeliveryOrderPickOrderMergeTicketNo(requiredDate, ticketFloorSegment)
        val releaseType = WorkbenchReleaseTypeSupport.mergeTicketReleaseType(isSingleRelease)
        return insertNewDeliveryOrderPickOrderHeader(first, storeId, releaseType, ticketNo)
    }

    private fun linkPickOrdersToHeader(headerId: Long, group: List<ReleaseDoResult>) {
        group.forEach { r ->
            jdbcDao.executeUpdate(
                """
                UPDATE fpsmsdb.pick_order
                SET deliveryOrderPickOrderId = :headerId
                WHERE id = :pickOrderId
                """.trimIndent(),
                mapOf(
                    "headerId" to headerId,
                    "pickOrderId" to r.pickOrderId
                )
            )
        }
    }

    private fun insertNewDeliveryOrderPickOrderHeader(
        first: ReleaseDoResult,
        storeId: String?,
        releaseTypeCol: String,
        ticketNo: String,
    ): Long? {
        val now = LocalDateTime.now()
        jdbcDao.executeUpdate(
            """
            INSERT INTO fpsmsdb.delivery_order_pick_order (
                truckId, shopId, storeId, requiredDeliveryDate, truckDepartureTime,
                truckLanceCode, shopCode, shopName, loadingSequence, ticketNo,
                ticketReleaseTime, ticketStatus, releaseType, handledBy, handlerName,
                created, createdBy, version, modified, modifiedBy, deleted
            ) VALUES (
                :truckId, :shopId, :storeId, :requiredDeliveryDate, :truckDepartureTime,
                :truckLanceCode, :shopCode, :shopName, :loadingSequence, :ticketNo,
                NULL, 'pending', :releaseType, NULL, NULL,
                :created, :createdBy, 0, :modified, :modifiedBy, 0
            )
            """.trimIndent(),
            mapOf(
                "truckId" to first.truckId,
                "shopId" to first.shopId,
                "storeId" to storeId,
                "requiredDeliveryDate" to (first.estimatedArrivalDate ?: LocalDate.now()),
                "truckDepartureTime" to first.truckDepartureTime,
                "truckLanceCode" to first.truckLanceCode,
                "shopCode" to first.shopCode,
                "shopName" to first.shopName,
                "loadingSequence" to first.loadingSequence,
                "ticketNo" to ticketNo,
                "releaseType" to releaseTypeCol,
                "created" to now,
                "createdBy" to "system",
                "modified" to now,
                "modifiedBy" to "system",
            )
        )
        return jdbcDao.queryForList(
            """
            SELECT id
            FROM fpsmsdb.delivery_order_pick_order
            WHERE ticketNo = :ticketNo
            ORDER BY id DESC
            LIMIT 1
            """.trimIndent(),
            mapOf("ticketNo" to ticketNo)
        ).firstOrNull()?.get("id")?.let { (it as Number).toLong() }?.also { setRelationshipIdSelf(it) }
    }

    /**
     * isExtra + merge: link to existing [TI-M], or create [TI-M] and retire plain/legacy batch-family sources.
     */
    private fun processExtraMergeIntoLaneTicket(
        group: List<ReleaseDoResult>,
        first: ReleaseDoResult,
        storeId: String?,
        isSingleRelease: Boolean,
        requiredDate: LocalDate,
        ticketFloorSegment: String,
    ): Int {
        val activeMerge = findActiveMergeTicket(first, storeId, isSingleRelease)
        if (activeMerge != null) {
            linkPickOrdersToHeader(activeMerge.id, group)
            consolidateUnassignedPlainBatchTicketsIntoMergeHeader(first, storeId, isSingleRelease, activeMerge.id)
            return 0
        }

        val plainBatch = findPlainFamilyTicketForMergeRetire(first, storeId, isSingleRelease)
        val legacyUpgraded = findLegacyUpgradedFamilyTicket(first, storeId, isSingleRelease)
        val sourceToRetire = plainBatch ?: legacyUpgraded

        if (sourceToRetire != null) {
            val mergeHeaderId = createNewMergeHeader(first, storeId, isSingleRelease, requiredDate, ticketFloorSegment)
                ?: return 0
            retireSourceTicketIntoMergeHeader(sourceToRetire.id, mergeHeaderId)
            linkPickOrdersToHeader(mergeHeaderId, group)
            return 1
        }

        val mergeHeaderId = createNewMergeHeader(first, storeId, isSingleRelease, requiredDate, ticketFloorSegment)
            ?: return 0
        linkPickOrdersToHeader(mergeHeaderId, group)
        return 1
    }

    /**
     * Link released pick orders to [delivery_order_pick_order] headers.
     * Normal DOs merge into batch/single-family tickets ([TI-M] preferred).
     * isExtra + merge: new [TI-M] with [relationshipId] lineage (no in-place releaseType upgrade).
     * isExtra + no merge: standalone `isExtra` + `TI-E-` (Case 2).
     */
    private fun createAndLinkDeliveryOrderPickOrders(
        results: List<ReleaseDoResult>,
        dopReleaseType: String = "batch",
        mergeExtraIntoLaneTicket: Boolean = true,
    ): Int {
        if (results.isEmpty()) return 0

        val isSingleRelease = dopReleaseType.equals("single", ignoreCase = true)
        val normalGroups = results.filter { !it.isExtra }.groupBy { laneGroupKey(it) }
        val extraGroups = results.filter { it.isExtra }.groupBy { laneGroupKey(it) }

        var createdHeaders = 0

        fun processGroup(
            group: List<ReleaseDoResult>,
            isExtraRelease: Boolean,
            mergeExtra: Boolean,
        ): Int {
            if (group.isEmpty()) return 0
            val first = group.first()
            val storeId = resolveStoreId(first)
            val isDefaultTruckLane =
                first.usedDefaultTruck == true ||
                    first.truckLanceCode?.trim() == WORKBENCH_DEFAULT_TRUCK_LANCE_CODE
            val ticketFloorSegment = resolveTicketFloorSegment(storeId, isDefaultTruckLane)
            val requiredDate = first.estimatedArrivalDate ?: LocalDate.now()

            if (isExtraRelease && mergeExtra) {
                return processExtraMergeIntoLaneTicket(
                    group,
                    first,
                    storeId,
                    isSingleRelease,
                    requiredDate,
                    ticketFloorSegment,
                )
            }

            if (isExtraRelease && !mergeExtra) {
                val existingStandalone = findMergeableStandaloneIsExtraTicket(first, storeId)
                if (existingStandalone != null) {
                    linkPickOrdersToHeader(existingStandalone.id, group)
                    return 0
                }
                val ticketNo = nextDeliveryOrderPickOrderEtraTicketNo(requiredDate, ticketFloorSegment)
                val headerId = insertNewDeliveryOrderPickOrderHeader(
                    first,
                    storeId,
                    WorkbenchReleaseTypeSupport.LEGACY_IS_EXTRA,
                    ticketNo,
                ) ?: return 0
                linkPickOrdersToHeader(headerId, group)
                return 1
            }

            val existing = findMergeableWorkbenchTicket(first, storeId, isSingleRelease)
            if (existing != null) {
                linkPickOrdersToHeader(existing.id, group)
                if (isWorkbenchMergeTicketNo(existing.ticketNo)) {
                    consolidateUnassignedPlainBatchTicketsIntoMergeHeader(
                        first,
                        storeId,
                        isSingleRelease,
                        existing.id,
                    )
                }
                return 0
            }

            val releaseTypeCol = WorkbenchReleaseTypeSupport.newHeaderReleaseType(isExtraRelease, isSingleRelease)
            val ticketNo = if (isSingleRelease) {
                nextDeliveryOrderPickOrderSingleTicketNo(requiredDate, ticketFloorSegment)
            } else {
                nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, ticketFloorSegment)
            }
            val headerId = insertNewDeliveryOrderPickOrderHeader(first, storeId, releaseTypeCol, ticketNo)
                ?: return 0
            linkPickOrdersToHeader(headerId, group)
            return 1
        }

        normalGroups.values.forEach { group ->
            createdHeaders += processGroup(group, isExtraRelease = false, mergeExtra = true)
        }
        extraGroups.values.forEach { group ->
            createdHeaders += processGroup(group, isExtraRelease = true, mergeExtra = mergeExtraIntoLaneTicket)
        }

        return createdHeaders
    }

    // --- Case 3: post-hoc merge plain batch/single + standalone isExtra → new TI-M ---

    open fun getMergeTicketCandidates(
        requiredDate: LocalDate,
        shopSearch: String?,
    ): WorkbenchMergeTicketCandidatesResponse {
        val batchFamily = queryMergeTicketCandidates(
            requiredDate = requiredDate,
            shopSearch = shopSearch,
            side = "batchFamily",
        )
        val isExtra = queryMergeTicketCandidates(
            requiredDate = requiredDate,
            shopSearch = shopSearch,
            side = "isExtra",
        )
        return WorkbenchMergeTicketCandidatesResponse(
            batchFamilyTickets = batchFamily,
            isExtraTickets = isExtra,
        )
    }

    private fun isValidMergeLeftTicket(row: DopoMergeRow): Boolean {
        val rt = row.releaseType?.trim()?.lowercase().orEmpty()
        if (isWorkbenchMergeTicketNo(row.ticketNo)) {
            return rt == WorkbenchReleaseTypeSupport.IS_EXTRA_BATCH.lowercase() ||
                rt == WorkbenchReleaseTypeSupport.IS_EXTRA_SINGLE.lowercase()
        }
        return rt == WorkbenchReleaseTypeSupport.BATCH.lowercase() ||
            rt == WorkbenchReleaseTypeSupport.SINGLE.lowercase()
    }

    @Transactional(rollbackFor = [Exception::class])
    open fun mergeTicketsCase3(batchOrSingleDopoId: Long, isExtraDopoId: Long): MessageResponse {
        if (batchOrSingleDopoId == isExtraDopoId) {
            return mergeTicketsError("SAME_TICKET", "Cannot merge a ticket with itself")
        }
        val batchRow = loadDopoRowForMerge(batchOrSingleDopoId)
            ?: return mergeTicketsError("NOT_FOUND", "Left ticket not found")
        val extraRow = loadDopoRowForMerge(isExtraDopoId)
            ?: return mergeTicketsError("NOT_FOUND", "isExtra ticket not found")

        if (!isValidMergeLeftTicket(batchRow)) {
            return mergeTicketsError(
                "INVALID_BATCH",
                "Left ticket must be plain batch/single or an active TI-M merge ticket",
            )
        }
        if (extraRow.releaseType?.trim()?.lowercase() != WorkbenchReleaseTypeSupport.LEGACY_IS_EXTRA.lowercase()) {
            return mergeTicketsError("INVALID_EXTRA", "Right ticket must be releaseType isExtra")
        }

        try {
            assertTicketUnassignedForMerge(batchOrSingleDopoId)
            assertTicketUnassignedForMerge(isExtraDopoId)
        } catch (e: IllegalStateException) {
            return mergeTicketsError("ASSIGNED", e.message ?: "Ticket already assigned")
        }

        if (!sameLaneForMerge(batchRow, extraRow)) {
            return mergeTicketsError(
                "LANE_MISMATCH",
                "Tickets are not on the same shop, floor, truck lane, and departure time",
            )
        }

        val leftIsExistingMerge = isWorkbenchMergeTicketNo(batchRow.ticketNo)
        val batchRt = batchRow.releaseType?.trim()?.lowercase().orEmpty()
        val isSingleRelease = when {
            leftIsExistingMerge ->
                batchRt == WorkbenchReleaseTypeSupport.IS_EXTRA_SINGLE.lowercase()
            else -> batchRt == WorkbenchReleaseTypeSupport.SINGLE.lowercase()
        }

        val mergeHeaderId = if (leftIsExistingMerge) {
            batchOrSingleDopoId
        } else {
            val requiredDate = batchRow.requiredDeliveryDate ?: LocalDate.now()
            val storeId = batchRow.storeId
            val isDefaultTruckLane = storeId.isNullOrBlank()
            val ticketFloorSegment = resolveTicketFloorSegment(storeId, isDefaultTruckLane)
            insertMergeHeaderFromDopoRow(
                template = batchRow,
                isSingleRelease = isSingleRelease,
                requiredDate = requiredDate,
                ticketFloorSegment = ticketFloorSegment,
            ) ?: return mergeTicketsError("CREATE_FAILED", "Failed to create TI-M merge ticket")
        }

        if (!leftIsExistingMerge) {
            retireSourceTicketIntoMergeHeader(batchOrSingleDopoId, mergeHeaderId)
        }
        retireSourceTicketIntoMergeHeader(isExtraDopoId, mergeHeaderId)

        val mergeTicketNo = jdbcDao.queryForList(
            "SELECT ticketNo AS t FROM fpsmsdb.delivery_order_pick_order WHERE id = :id",
            mapOf("id" to mergeHeaderId),
        ).firstOrNull()?.let { row ->
            row.entries.find { it.key.equals("t", true) }?.value?.toString()
        }

        return MessageResponse(
            id = mergeHeaderId,
            code = "SUCCESS",
            name = "workbench_merge_tickets",
            type = "workbench_merge_tickets",
            message = "Merged into $mergeTicketNo",
            errorPosition = null,
            entity = mapOf(
                "mergeDeliveryOrderPickOrderId" to mergeHeaderId,
                "ticketNo" to mergeTicketNo,
                "batchOrSingleDopoId" to batchOrSingleDopoId,
                "isExtraDopoId" to isExtraDopoId,
                "mergedIntoExistingTiM" to leftIsExistingMerge,
            ),
        )
    }

    private fun mergeTicketsError(code: String, message: String): MessageResponse =
        MessageResponse(
            id = null,
            code = code,
            name = "workbench_merge_tickets",
            type = "workbench_merge_tickets",
            message = message,
            errorPosition = null,
            entity = null,
        )

    private data class DopoMergeRow(
        val id: Long,
        val ticketNo: String?,
        val releaseType: String?,
        val shopId: Long?,
        val shopCode: String?,
        val shopName: String?,
        val storeId: String?,
        val truckId: Long?,
        val requiredDeliveryDate: LocalDate?,
        val truckLanceCode: String?,
        val truckDepartureTime: LocalTime?,
        val loadingSequence: Int?,
    )

    private fun queryMergeTicketCandidates(
        requiredDate: LocalDate,
        shopSearch: String?,
        side: String,
    ): List<WorkbenchMergeTicketCandidate> {
        val params = mutableMapOf<String, Any>("requiredDate" to requiredDate)
        val sql = StringBuilder(
            """
            SELECT
              dop.id AS id,
              dop.ticketNo AS ticketNo,
              dop.releaseType AS releaseType,
              dop.shopId AS shopId,
              dop.shopCode AS shopCode,
              dop.shopName AS shopName,
              dop.storeId AS storeId,
              dop.truckId AS truckId,
              dop.requiredDeliveryDate AS requiredDeliveryDate,
              dop.truckLanceCode AS truckLanceCode,
              dop.truckDepartureTime AS truckDepartureTime,
              dop.loadingSequence AS loadingSequence,
              (
                SELECT GROUP_CONCAT(DISTINCT d.code ORDER BY d.code SEPARATOR ',')
                FROM fpsmsdb.pick_order po
                INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0
                WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0
              ) AS doCodesStr
            FROM fpsmsdb.delivery_order_pick_order dop
            WHERE dop.deleted = 0
              AND dop.requiredDeliveryDate = :requiredDate
              AND dop.ticketStatus IN ('pending', 'released')
              AND dop.handledBy IS NULL
            """.trimIndent()
        )
        when (side) {
            "batchFamily" -> sql.append(
                """
                 AND (
                   LOWER(COALESCE(dop.releaseType, '')) IN ('batch', 'single')
                   OR dop.ticketNo LIKE 'TI-M-%'
                 )
                """.trimIndent().let { " $it" },
            )
            "isExtra" -> sql.append(" AND LOWER(COALESCE(dop.releaseType, '')) = 'isextra' ")
            else -> return emptyList()
        }
        if (!shopSearch.isNullOrBlank()) {
            sql.append(
                """
                 AND (
                   LOWER(COALESCE(dop.shopName, '')) LIKE :shopPatLower
                   OR LOWER(COALESCE(dop.shopCode, '')) LIKE :shopPatLower
                 )
                """.trimIndent().let { " $it" },
            )
            params["shopPatLower"] = "%${shopSearch.trim().lowercase()}%"
        }
        sql.append(" ORDER BY dop.shopName, dop.truckDepartureTime, dop.truckLanceCode, dop.id ")

        val rows = try {
            jdbcDao.queryForList(sql.toString(), params)
        } catch (e: Exception) {
            println("❌ queryMergeTicketCandidates($side): ${e.message}")
            emptyList()
        }
        return rows.mapNotNull { mapRowToMergeTicketCandidate(it) }
    }

    private fun mapRowToMergeTicketCandidate(row: Map<String, Any?>): WorkbenchMergeTicketCandidate? {
        fun str(n: String) = row.keys.find { it.equals(n, true) }?.let { row[it]?.toString()?.trim() }?.takeIf { it.isNotEmpty() }
        fun long(n: String) = row.keys.find { it.equals(n, true) }?.let { row[it] }?.let {
            when (it) {
                is Number -> it.toLong()
                is String -> it.trim().toLongOrNull()
                else -> it.toString().trim().toLongOrNull()
            }
        }
        fun int(n: String) = row.keys.find { it.equals(n, true) }?.let { row[it] }?.let {
            when (it) {
                is Number -> it.toInt()
                is String -> it.trim().toIntOrNull()
                else -> it.toString().trim().toIntOrNull()
            }
        }
        val id = long("id") ?: return null
        val reqDate = row.keys.find { it.equals("requiredDeliveryDate", true) }?.let { row[it] }.let { v ->
            when (v) {
                null -> null
                is java.sql.Date -> v.toLocalDate()
                is LocalDate -> v
                else -> runCatching { LocalDate.parse(v.toString().take(10)) }.getOrNull()
            }
        }
        val tt = row.keys.find { it.equals("truckDepartureTime", true) }?.let { row[it] }
        val truckDepartureTime = when (tt) {
            null -> null
            is java.sql.Time -> tt.toLocalTime()
            is LocalTime -> tt
            is java.sql.Timestamp -> tt.toLocalDateTime().toLocalTime()
            else -> runCatching { LocalTime.parse(tt.toString().take(8)) }.getOrNull()
                ?: runCatching { LocalTime.parse(tt.toString()) }.getOrNull()
        }
        val doCodesStr = str("doCodesStr")
        val codes = doCodesStr?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
        val shopId = long("shopId")
        val storeId = str("storeId")
        val truckId = long("truckId")
        val truckLanceCode = str("truckLanceCode")
        val loadingSequence = int("loadingSequence")
        val laneKey = buildLaneKey(
            shopId = shopId,
            requiredDeliveryDate = reqDate,
            storeId = storeId,
            truckId = truckId,
            truckLanceCode = truckLanceCode,
            truckDepartureTime = truckDepartureTime,
            loadingSequence = loadingSequence,
        )
        return WorkbenchMergeTicketCandidate(
            id = id,
            ticketNo = str("ticketNo"),
            releaseType = str("releaseType"),
            shopId = shopId,
            shopCode = str("shopCode"),
            shopName = str("shopName"),
            storeId = storeId,
            truckId = truckId,
            requiredDeliveryDate = reqDate,
            truckLanceCode = truckLanceCode,
            truckDepartureTime = truckDepartureTime,
            loadingSequence = loadingSequence,
            deliveryOrderCodes = codes,
            laneKey = laneKey,
        )
    }

    private fun buildLaneKey(
        shopId: Long?,
        requiredDeliveryDate: LocalDate?,
        storeId: String?,
        truckId: Long?,
        truckLanceCode: String?,
        truckDepartureTime: LocalTime?,
        loadingSequence: Int?,
    ): String {
        val storeNorm = normalizeStoreIdForLaneKey(storeId)
        val is4F = storeNorm.replace("/", "").equals("4F", ignoreCase = true)
        val seqPart = if (is4F) loadingSequence?.toString() ?: "" else ""
        return listOf(
            shopId?.toString() ?: "",
            requiredDeliveryDate?.toString() ?: "",
            storeNorm,
            truckId?.toString() ?: "",
            truckDepartureTime?.toString() ?: "",
            truckLanceCode?.trim() ?: "",
            seqPart,
        ).joinToString("|")
    }

    private fun normalizeStoreIdForLaneKey(storeId: String?): String {
        val s = storeId?.trim().orEmpty()
        return if (s.isEmpty()) "" else s
    }

    private fun sameLaneForMerge(a: DopoMergeRow, b: DopoMergeRow): Boolean =
        buildLaneKey(
            a.shopId, a.requiredDeliveryDate, a.storeId, a.truckId,
            a.truckLanceCode, a.truckDepartureTime, a.loadingSequence,
        ) == buildLaneKey(
            b.shopId, b.requiredDeliveryDate, b.storeId, b.truckId,
            b.truckLanceCode, b.truckDepartureTime, b.loadingSequence,
        )

    private fun loadDopoRowForMerge(id: Long): DopoMergeRow? {
        val rows = jdbcDao.queryForList(
            """
            SELECT
              id, ticketNo, releaseType, shopId, shopCode, shopName, storeId, truckId,
              requiredDeliveryDate, truckLanceCode, truckDepartureTime, loadingSequence
            FROM fpsmsdb.delivery_order_pick_order
            WHERE id = :id AND deleted = 0
            """.trimIndent(),
            mapOf("id" to id),
        )
        val row = rows.firstOrNull() ?: return null
        val candidate = mapRowToMergeTicketCandidate(
            row + mapOf("doCodesStr" to null),
        ) ?: return null
        return DopoMergeRow(
            id = candidate.id,
            ticketNo = candidate.ticketNo,
            releaseType = candidate.releaseType,
            shopId = candidate.shopId,
            shopCode = candidate.shopCode,
            shopName = candidate.shopName,
            storeId = candidate.storeId,
            truckId = candidate.truckId,
            requiredDeliveryDate = candidate.requiredDeliveryDate,
            truckLanceCode = candidate.truckLanceCode,
            truckDepartureTime = candidate.truckDepartureTime,
            loadingSequence = candidate.loadingSequence,
        )
    }

    private fun insertMergeHeaderFromDopoRow(
        template: DopoMergeRow,
        isSingleRelease: Boolean,
        requiredDate: LocalDate,
        ticketFloorSegment: String,
    ): Long? {
        val ticketNo = nextDeliveryOrderPickOrderMergeTicketNo(requiredDate, ticketFloorSegment)
        val releaseType = WorkbenchReleaseTypeSupport.mergeTicketReleaseType(isSingleRelease)
        val now = LocalDateTime.now()
        jdbcDao.executeUpdate(
            """
            INSERT INTO fpsmsdb.delivery_order_pick_order (
                truckId, shopId, storeId, requiredDeliveryDate, truckDepartureTime,
                truckLanceCode, shopCode, shopName, loadingSequence, ticketNo,
                ticketReleaseTime, ticketStatus, releaseType, handledBy, handlerName,
                created, createdBy, version, modified, modifiedBy, deleted
            ) VALUES (
                :truckId, :shopId, :storeId, :requiredDeliveryDate, :truckDepartureTime,
                :truckLanceCode, :shopCode, :shopName, :loadingSequence, :ticketNo,
                NULL, 'pending', :releaseType, NULL, NULL,
                :created, :createdBy, 0, :modified, :modifiedBy, 0
            )
            """.trimIndent(),
            mapOf(
                "truckId" to template.truckId,
                "shopId" to template.shopId,
                "storeId" to template.storeId,
                "requiredDeliveryDate" to (template.requiredDeliveryDate ?: requiredDate),
                "truckDepartureTime" to template.truckDepartureTime,
                "truckLanceCode" to template.truckLanceCode,
                "shopCode" to template.shopCode,
                "shopName" to template.shopName,
                "loadingSequence" to template.loadingSequence,
                "ticketNo" to ticketNo,
                "releaseType" to releaseType,
                "created" to now,
                "createdBy" to "system",
                "modified" to now,
                "modifiedBy" to "system",
            ),
        )
        return jdbcDao.queryForList(
            """
            SELECT id FROM fpsmsdb.delivery_order_pick_order
            WHERE ticketNo = :ticketNo ORDER BY id DESC LIMIT 1
            """.trimIndent(),
            mapOf("ticketNo" to ticketNo),
        ).firstOrNull()?.get("id")?.let { (it as Number).toLong() }?.also { setRelationshipIdSelf(it) }
    }
}
