package com.ffii.fpsms.modules.pickOrder.service

import com.ffii.core.support.AbstractBaseEntityService
import com.ffii.core.support.JdbcDao
import com.ffii.core.utils.ExcelUtils
import org.apache.poi.ss.usermodel.Sheet
import org.apache.poi.ss.usermodel.Workbook
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.springframework.stereotype.Service
import com.ffii.fpsms.modules.logistic.entity.Logistic
import com.ffii.fpsms.modules.logistic.entity.LogisticRepository
import com.ffii.fpsms.modules.pickOrder.entity.Truck
import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLineRepository
import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionRepository
import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository
import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckRequest
import com.ffii.fpsms.modules.pickOrder.web.models.CreateTruckWithoutShopRequest
import com.ffii.fpsms.modules.pickOrder.web.models.UpdateLaneLogisticRequest
import com.ffii.fpsms.modules.pickOrder.web.models.UpdateTruckShopDetailsRequest
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import com.ffii.fpsms.modules.master.entity.ShopRepository
import com.ffii.fpsms.modules.master.entity.projections.ShopAndTruck
import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckLane
import jakarta.transaction.Transactional
import java.io.ByteArrayOutputStream
import java.text.Collator
import java.time.LocalDate
import java.util.Locale
import com.ffii.fpsms.modules.pickOrder.web.models.DiffFieldChange
import com.ffii.fpsms.modules.pickOrder.web.models.ParseRouteLanesExcelResponse
import com.ffii.fpsms.modules.pickOrder.web.models.RouteLaneImportPreviewRow
import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneVersionDiffLine


@Service
open class TruckService(
    private val jdbcDao: JdbcDao,
    private val truckRepository: TruckRepository,
    private val shopRepository: ShopRepository,
    private val logisticRepository: LogisticRepository,
    private val truckLaneVersionRepository: TruckLaneVersionRepository,
    private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository,
) : AbstractBaseEntityService<Truck, Long, TruckRepository>(jdbcDao, truckRepository) {

    private fun logisticRefOrNull(id: Long?): Logistic? {
        if (id == null) return null
        return logisticRepository.findById(id).orElseThrow {
            IllegalArgumentException("Logistic not found with id: $id")
        }
    }
    open fun saveTruck(request: SaveTruckRequest): Truck {
        val truck = request.id?.let {
            truckRepository.findById(it).orElse(null)
        } ?: Truck()
        val shop = shopRepository.findById(request.shopId).orElse(null)
        if (shop == null) {
            throw IllegalArgumentException("Shop not found with id: ${request.shopId}")
        }

        truck.apply {
            this.storeId = request.store_id
            this.truckLanceCode = request.truckLanceCode
            this.departureTime = request.departureTime
            this.shop = shop
            this.shopName = normalizeTruckShopDisplayName(request.shopName)
            this.shopCode = request.shopCode
            this.loadingSequence = request.loadingSequence
            this.remark = request.remark
            this.districtReference = request.districtReference
            this.logistic = logisticRefOrNull(request.logisticId)
        }

        return truckRepository.save(truck);
    }

    /**
     * 同 (truckLanceCode, remark) 桶內僅用來佔位的列：`shop` 為 null 且店名/代碼為空或舊版 Unassign。
     * 新增店鋪時應 **先 UPDATE 此列**，避免再 INSERT 一筆造成多條「空列」。
     */
    private fun isLanePlaceholderTruck(truck: Truck): Boolean {
        if (truck.shop != null) return false
        val nm = truck.shopName?.trim().orEmpty()
        val cd = truck.shopCode?.trim().orEmpty()
        if (nm.isEmpty() && cd.isEmpty()) return true
        if (nm.equals("unassign", ignoreCase = true) || cd.equals("unassign", ignoreCase = true)) return true
        if (nm.equals("unassigned", ignoreCase = true) || cd.equals("unassigned", ignoreCase = true)) return true
        return false
    }

    /** 與 [findAllByTruckLanceCodeAndRemarkAndDeletedFalse] 相同的 remark 桶規則 */
    private fun trucksInSameLaneBucket(
        truckLanceCode: String,
        storeId: String,
        remark: String?,
    ): List<Truck> {
        val bucketRemark = if (storeId == "4F") remark?.trim()?.takeIf { it.isNotEmpty() } else null
        val trimmed = bucketRemark?.trim().orEmpty()
        val blankRemark = trimmed.isEmpty()
        return truckRepository.findAllByTruckLanceCodeAndRemarkAndDeletedFalse(
            truckLanceCode.trim(),
            blankRemark,
            if (blankRemark) null else trimmed,
        )
    }

    private fun softDeleteTruckRow(t: Truck) {
        t.deleted = true
        truckRepository.save(t)
    }

    private fun parseDepartureTime(timeStr: String?): LocalTime? {
        if (timeStr.isNullOrBlank()) return null

        return try {
            val cleaned = timeStr.trim().uppercase().replace(" ", "")

            // 12 小時制：3:00AM、5:30:15PM（含秒）
            if (cleaned.contains("AM") || cleaned.contains("PM")) {
                val isPM = cleaned.contains("PM")
                val timePart = cleaned.replace("AM", "").replace("PM", "")
                val parts = timePart.split(":")
                when (parts.size) {
                    2 -> {
                        var hour = parts[0].toInt()
                        val minute = parts[1].toIntOrNull() ?: 0
                        if (isPM && hour != 12) hour += 12
                        if (!isPM && hour == 12) hour = 0
                        return LocalTime.of(hour, minute)
                    }
                    3 -> {
                        var hour = parts[0].toInt()
                        val minute = parts[1].toIntOrNull() ?: 0
                        val second = parts[2].toIntOrNull() ?: 0
                        if (isPM && hour != 12) hour += 12
                        if (!isPM && hour == 12) hour = 0
                        return LocalTime.of(hour, minute, second)
                    }
                }
            }
            // 24 小時制：須接受匯出欄位 formatDepartureForExcel 的 HH:mm:ss（如 17:30:00）
            val t = timeStr.trim()
            try {
                LocalTime.parse(t)
            } catch (_: Exception) {
                try {
                    LocalTime.parse(t, DateTimeFormatter.ofPattern("H:mm:ss"))
                } catch (_: Exception) {
                    try {
                        LocalTime.parse(t, DateTimeFormatter.ofPattern("HH:mm:ss"))
                    } catch (_: Exception) {
                        try {
                            LocalTime.parse(t, DateTimeFormatter.ofPattern("H:mm"))
                        } catch (_: Exception) {
                            LocalTime.parse(t, DateTimeFormatter.ofPattern("HH:mm"))
                        }
                    }
                }
            }
        } catch (e: Exception) {
            logger.warn("Failed to parse departure time: $timeStr", e)
            null
        }
    }

    private fun normalizeShopCode(shopCode: String): String {
        val firstDigitIndex = shopCode.indexOfFirst { it.isDigit() }
        if (firstDigitIndex == -1) {
            return shopCode
        }

        val letterPart = shopCode.substring(0, firstDigitIndex)
        val numberPart = shopCode.substring(firstDigitIndex)

        val normalizedNumber = if (numberPart.startsWith("0") && numberPart.length > 1) {
            numberPart.substring(1)
        } else {
            numberPart
        }
        return letterPart + normalizedNumber
    }

    /** MTMS 車線匯入：判斷 truck 列是否與 Excel 指向同一 shop（shopId 或 shopCode 多寫法）。 */
    private fun truckRowMatchesImportShop(
        truck: Truck,
        shopId: Long,
        shopCodeRaw: String,
        normalizedShopCode: String,
    ): Boolean {
        if (truck.shop?.id != null && truck.shop?.id == shopId) return true
        val tc = truck.shopCode?.trim().orEmpty()
        if (tc.isEmpty()) return false
        val rawTrim = shopCodeRaw.trim()
        return tc == rawTrim ||
            tc == normalizedShopCode ||
            normalizeShopCode(tc) == normalizedShopCode
    }

    /**
     * 同 (truckLanceCode, remark) 桶內同店至多一筆：保留 id 最小，其餘 soft delete。
     * 桶內無則 fallback 全域 findFirst（搬移他線列進當前桶）。
     */
    private fun resolveExistingTruckForRouteLaneImport(
        truckLanceCode: String,
        storeId: String,
        laneRemark: String?,
        shopId: Long,
        shopCodeRaw: String,
        normalizedShopCode: String,
    ): Truck? {
        val bucket = trucksInSameLaneBucket(truckLanceCode, storeId, laneRemark)
        val matched =
            bucket
                .filter { truckRowMatchesImportShop(it, shopId, shopCodeRaw, normalizedShopCode) }
                .sortedBy { it.id ?: Long.MAX_VALUE }
        if (matched.isEmpty()) {
            return truckRepository.findFirstByShopCodeAndStoreIdAndDeletedFalseOrderByIdAsc(
                shopCodeRaw,
                storeId,
            )
                ?: truckRepository.findFirstByShopCodeAndStoreIdAndDeletedFalseOrderByIdAsc(
                    normalizedShopCode,
                    storeId,
                )
        }
        val primary = matched.first()
        for (dup in matched.drop(1)) {
            logger.warn(
                "Route lane import: soft-delete duplicate truck id=${dup.id} shopCode=${dup.shopCode} " +
                    "storeId=$storeId lane=$truckLanceCode; keep id=${primary.id}",
            )
            softDeleteTruckRow(dup)
        }
        val pid = primary.id ?: return primary
        return truckRepository.findById(pid).orElse(primary)
    }

    /**
     * MTMS 車線匯入：Excel「板」欄 → `districtReference`。
     * 空白、`未分類`、舊版合成「板一…」皆視同未分類（存 null）；其餘 trim 後原樣寫入。
     */
    private fun normalizeDistrictReferenceForRouteLaneImport(plateColumn: String): String? {
        val t = plateColumn.trim()
        if (t.isEmpty() || t == "未分類") return null
        for (i in 0 until 64) {
            if (t == RouteLaneExcelSupport.plateLabel(i)) return null
        }
        return t
    }

    /**
     * M18 / combo 店名可能是 `CF001 - 雞檔-健威坊店`；`truck.ShopName` 應存分店短名（如 `健威`）。
     * 規則：若為 SKU 前綴開頭或分段數≥3，取最後一段並去掉尾綴「坊店」。
     */
    private fun normalizeTruckShopDisplayName(raw: String?): String {
        if (raw.isNullOrBlank()) return ""
        val s = raw.trim()
        val parts = s.split(Regex("\\s*-\\s*")).map { it.trim() }.filter { it.isNotEmpty() }
        if (parts.size < 2) return s
        val first = parts[0]
        val codeSkuLike = first.matches(Regex("^[A-Za-z]{1,6}\\d+$"))
        val takeLast = parts.size >= 3 || codeSkuLike
        if (!takeLast) return s
        var last = parts.last()
        if (last.endsWith("坊店")) {
            last = last.removeSuffix("坊店").trim()
        }
        return last.ifEmpty { s }
    }

    /**
     * MTMS「品牌」欄：M18 全名多為 `店鋪編 - 品牌 - 分店短名/…` 或 `編號 - 品牌-分店名`；
     * 與 [normalizeTruckShopDisplayName] 用同一套 ` - ` 分段，取品牌段（非首段之 SKU 前綴、非最末段之顯示名）。
     */
    private fun deriveBrandFromShopFullName(raw: String?): String {
        if (raw.isNullOrBlank()) return ""
        val s = raw.trim()
        val parts = s.split(Regex("\\s*-\\s*")).map { it.trim() }.filter { it.isNotEmpty() }
        if (parts.size >= 3) {
            return parts[1]
        }
        if (parts.size == 2) {
            val first = parts[0]
            val codeSkuLike = first.matches(Regex("^[A-Za-z]{1,6}\\d+$"))
            if (codeSkuLike) {
                val sub = parts[1].split('-').map { it.trim() }.filter { it.isNotEmpty() }
                if (sub.size >= 2) {
                    return sub[0]
                }
            }
        }
        return ""
    }

    open fun importExcel(workbook: Workbook?): String {
        logger.info("--------- Start - Import Warehouse Excel -------");

        if (workbook == null) {
            logger.error("No Excel Import");
            return "Import Excel failure";
        }
        val sheet: Sheet = workbook.getSheetAt(0);

        // Columns
        val COLUMN_STORE_ID_INDEX = 0;
        val COLUMN_REQUIRED_DELIVERY_DATE_INDEX = 1;
        val COLUMN_DEPARTURE_TIME_INDEX = 2;
        val COLUMN_TRUCK_LANCE_CODE_INDEX = 3;
        val COLUMN_SHOP_CODE_INDEX = 4;
        val COLUMN_SHOP_NAME_INDEX = 5;
        val COLUMN_LOADING_SEQUENCE_INDEX = 6;
        val COLUMN_REMARK_INDEX = 7;

        val START_ROW_INDEX = 3;
        logger.info("Total rows in sheet: ${sheet.lastRowNum + 1}, Processing from row ${START_ROW_INDEX + 1} to ${sheet.lastRowNum + 1}");
        // Start Import
        for (i in START_ROW_INDEX..sheet.lastRowNum) {
            val row = sheet.getRow(i)

            if (row == null) {
                logger.warn("Row ${i + 1}: Skipped - row is null or empty")
                continue
            }

            try {
                val truckLanceCode = ExcelUtils.getStringValue(row.getCell(COLUMN_TRUCK_LANCE_CODE_INDEX)).trim()
                val departureTimeStr = ExcelUtils.getStringValue(row.getCell(COLUMN_DEPARTURE_TIME_INDEX)).trim()
                val shopName = ExcelUtils.getStringValue(row.getCell(COLUMN_SHOP_NAME_INDEX)).trim()
                val shopCode = ExcelUtils.getStringValue(row.getCell(COLUMN_SHOP_CODE_INDEX)).trim()
                val loadingSequence = ExcelUtils.getIntValue(row.getCell(COLUMN_LOADING_SEQUENCE_INDEX))
                val store_id = ExcelUtils.getStringValue(row.getCell(COLUMN_STORE_ID_INDEX)).trim()
                val remark = ExcelUtils.getStringValue(row.getCell(COLUMN_REMARK_INDEX)).trim()

                val storeIdInt = when (store_id.uppercase()) {
                    "2F" -> 2
                    "4F" -> 4
                    "3F" -> 3
                    else -> {
                        logger.warn("Invalid store_id '${store_id}', defaulting to 2")
                        2
                    }
                }
                val departureTime = parseDepartureTime(departureTimeStr)
                if (departureTime == null) {
                    logger.warn("Row ${i + 1}: Skipped - invalid departure time '$departureTimeStr'")
                    continue
                }
                val normalizedShopCode = normalizeShopCode(shopCode)
                val allShops = shopRepository.findAllByDeletedIsFalse()
                val shop = allShops.firstOrNull { it.code == shopCode }
                    ?: allShops.firstOrNull { it.code == normalizedShopCode }
                if (shop == null) {
                    logger.warn("Row ${i + 1}: Skipped - no shop found for shopCode '$shopCode' (normalized: '$normalizedShopCode')")
                    continue
                }
                val existingTruck =
                    truckRepository.findFirstByShopCodeAndStoreIdAndDeletedFalseOrderByIdAsc(
                        shopCode,
                        store_id,
                    )
                        ?: truckRepository.findFirstByShopCodeAndStoreIdAndDeletedFalseOrderByIdAsc(
                            normalizedShopCode,
                            store_id,
                        )
                if (existingTruck != null) {

                    val truckRequest = SaveTruckRequest(
                        id = existingTruck.id,
                        store_id = store_id,
                        truckLanceCode = truckLanceCode ?: existingTruck.truckLanceCode ?: "",
                        departureTime = departureTime,
                        shopId = shop.id!!,
                        shopName = shopName ?: "",
                        shopCode = normalizedShopCode,
                        loadingSequence = loadingSequence,
                        remark = remark
                    )
                    saveTruck(truckRequest)
                    logger.info("Row ${i + 1}: Updated truck id=${existingTruck.id}, truckLanceCode=$truckLanceCode, shopCode=$normalizedShopCode, store_id=$store_id")
                } else {
                   
                    val truckRequest = SaveTruckRequest(
                        id = null,
                        store_id = store_id,
                        truckLanceCode = truckLanceCode ?: "",
                        departureTime = departureTime,
                        shopId = shop.id!!,
                        shopName = shopName ?: "",
                        shopCode = normalizedShopCode,
                        loadingSequence = loadingSequence,
                        remark = remark
                    )
                    saveTruck(truckRequest)
                    logger.info("Row ${i + 1}: Created truck truckLanceCode=$truckLanceCode, shopCode=$normalizedShopCode, store_id=$store_id")
                }
            } catch (e: Exception) {
                logger.error("Row ${i + 1}: Skipped - exception: ${e.message}", e)
            }
        }
        logger.info("--------- End - Import Warehouse Excel -------")

        return "Import Excel success";
    }

    open fun findAllByShopId(shopId: Long): List<Truck> {
        return truckRepository.findByShopIdAndDeletedFalse(shopId)
    }

    @Transactional
    open fun updateTruckLaneByTruckLanceCode(request: SaveTruckLane): List<Truck> {

        val updateTruckLance = truckRepository.findById(request.id).orElseThrow()
            ?: throw IllegalArgumentException("Truck not found with truckLanceCode: $request.truckLanceCode")
        
        // Use remark from request (user input) - no auto-fill
        updateTruckLance.truckLanceCode = request.truckLanceCode
        updateTruckLance.loadingSequence = request.loadingSequence.toInt()
        updateTruckLance.districtReference = request.districtReference
        updateTruckLance.departureTime = request.departureTime
        updateTruckLance.storeId = request.storeId
        // Only set remark if storeId is "4F", otherwise set to null
        updateTruckLance.remark = if (request.storeId == "4F") request.remark else null
        if (request.updateLogistic) {
            updateTruckLance.logistic = logisticRefOrNull(request.logisticId)
        }

        val savedTruck = truckRepository.save(updateTruckLance)
        return listOf(savedTruck)
    }


    @Transactional
    open fun deleteById(id: Long): String {
        val deleteTruck = truckRepository.findById(id).orElseThrow().apply {
            deleted = true
        }
        truckRepository.save(deleteTruck)
        return "Truck deleted successfully with id: $id"
    }

    @Transactional
    open fun createTruckInShop(request: SaveTruckRequest): Truck {
        val shop = shopRepository.findById(request.shopId).orElse(null)
        if (shop == null) {
            throw IllegalArgumentException("Shop not found with id: ${request.shopId}")
        }

        val laneRows = trucksInSameLaneBucket(
            request.truckLanceCode,
            request.store_id,
            request.remark,
        )
        val placeholders = laneRows.filter { isLanePlaceholderTruck(it) }
            .sortedBy { it.id ?: Long.MAX_VALUE }
        val primaryPlaceholder = placeholders.firstOrNull()
        val truck = primaryPlaceholder ?: Truck()

        truck.apply {
            this.storeId = request.store_id
            this.truckLanceCode = request.truckLanceCode
            this.departureTime = request.departureTime
            this.shop = shop
            val nameCandidate = request.shopName?.takeIf { it.isNotBlank() } ?: shop.name
            this.shopName = normalizeTruckShopDisplayName(nameCandidate)
            this.shopCode = request.shopCode
            this.loadingSequence = request.loadingSequence
            this.districtReference = request.districtReference
            // Only set remark if store_id is "4F", otherwise set to null
            this.remark = if (request.store_id == "4F") request.remark else null
            this.logistic = logisticRefOrNull(request.logisticId)
        }

        val saved = truckRepository.save(truck)
        if (primaryPlaceholder != null) {
            placeholders.drop(1).forEach { softDeleteTruckRow(it) }
        }
        return saved
    }

    open fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List<Truck> {
        return truckRepository.findAllUniqueTruckLanceCodeAndRemarkCombinations()
    }

    open fun findAllFromShopAndTruckByTruckLanceCodeAndDeletedFalse(truckLanceCode: String): List<ShopAndTruck> {
        return truckRepository.findAllFromShopAndTruckByTruckLanceCodeAndDeletedFalse(truckLanceCode)
    }

    open fun findAllByTruckLanceCodeAndDeletedFalse(truckLanceCode: String): List<Truck> {
        return truckRepository.findAllByTruckLanceCodeAndDeletedFalse(truckLanceCode)
    }

    open fun findAllByTruckLanceCodeAndRemarkAndDeletedFalse(truckLanceCode: String, remark: String?): List<Truck> {
        val trimmed = remark?.trim() ?: ""
        val blankRemark = trimmed.isEmpty()
        return truckRepository.findAllByTruckLanceCodeAndRemarkAndDeletedFalse(
            truckLanceCode,
            blankRemark,
            if (blankRemark) null else trimmed,
        )
    }

    open fun findAllForRouteBoard(): List<Truck> {
        return truckRepository.findAllForRouteBoard()
    }

    open fun findAllUniqueShopNamesAndCodesFromTrucks(): List<Map<String, String>> {
        return truckRepository.findAllUniqueShopNamesAndCodesFromTrucks()
    }

    open fun findAllUniqueRemarksFromTrucks(): List<String> {
        return truckRepository.findAllUniqueRemarksFromTrucks()
    }

    open fun findAllUniqueShopCodesFromTrucks(): List<String> {
        return truckRepository.findAllUniqueShopCodesFromTrucks()
    }

    open fun findAllUniqueShopNamesFromTrucks(): List<String> {
        return truckRepository.findAllUniqueShopNamesFromTrucks()
    }

    @Transactional
    open fun updateTruckShopDetails(request: UpdateTruckShopDetailsRequest): Truck {
        val truck = truckRepository.findById(request.id).orElseThrow {
            IllegalArgumentException("Truck not found with id: ${request.id}")
        }
        
        // Update shop relationship if shopId is provided
        if (request.shopId != null) {
            val shop = shopRepository.findById(request.shopId).orElse(null)
            if (shop != null) {
                truck.shop = shop
            } else {
                throw IllegalArgumentException("Shop not found with id: ${request.shopId}")
            }
        }
        
        // Always use shopName and shopCode from request (from truck table), not from shop entity
        // This allows truck table to have different shop names/codes than shop table
        if (request.shopName != null) {
            truck.shopName = normalizeTruckShopDisplayName(request.shopName)
        }
        
        if (request.shopCode != null) {
            truck.shopCode = request.shopCode
        }
        
        // Update loadingSequence
        truck.loadingSequence = request.loadingSequence
        
        // Update remark - only set if storeId is "4F", otherwise set to null
        val storeIdStr = truck.storeId
        if (storeIdStr == "4F") {
            truck.remark = request.remark
        } else {
            truck.remark = null
        }
        
        return truckRepository.save(truck)
    }

    @Transactional
    open fun updateLogisticForEntireLane(request: UpdateLaneLogisticRequest): Int {
        val trimmedCode = request.truckLanceCode.trim()
        val trimmedRemark = request.remark?.trim() ?: ""
        val blankRemark = trimmedRemark.isEmpty()
        val logistic = logisticRefOrNull(request.logisticId)
        val updated = truckRepository.bulkUpdateLogisticForLaneGroup(
            logistic,
            trimmedCode,
            blankRemark,
            if (blankRemark) null else trimmedRemark,
        )
        if (updated == 0) {
            throw IllegalArgumentException("No truck rows for lane: $trimmedCode")
        }
        return updated
    }

    @Transactional
    open fun createTruckWithoutShop(request: CreateTruckWithoutShopRequest): Truck {
        val laneRows = trucksInSameLaneBucket(
            request.truckLanceCode,
            request.store_id,
            request.remark,
        )
        val placeholders = laneRows.filter { isLanePlaceholderTruck(it) }
            .sortedBy { it.id ?: Long.MAX_VALUE }
        val primary = placeholders.firstOrNull()
        val truck = primary ?: Truck()

        truck.apply {
            this.storeId = request.store_id
            this.truckLanceCode = request.truckLanceCode
            this.departureTime = request.departureTime
            this.shop = null
            /** 僅佔位列：不寫假店名（避免 DB / 報表出現 Unassign） */
            this.shopName = null
            this.shopCode = null
            this.loadingSequence = request.loadingSequence
            this.districtReference = request.districtReference
            // Only set remark if store_id is "4F", otherwise set to null
            this.remark = if (request.store_id == "4F") request.remark else null
            this.logistic = logisticRefOrNull(request.logisticId)
        }

        val saved = truckRepository.save(truck)
        if (primary != null) {
            placeholders.drop(1).forEach { softDeleteTruckRow(it) }
        }
        return saved
    }

    private fun formatDepartureForExcel(t: LocalTime?): String {
        if (t == null) return ""
        return DateTimeFormatter.ofPattern("HH:mm:ss").format(t)
    }

    /** 依 loading 順序切出連續同 district 的段（每段日後對應一個「板」標題列）。 */
    private fun splitDistrictSegments(trucks: List<Truck>): List<List<Truck>> {
        if (trucks.isEmpty()) return emptyList()
        val out = mutableListOf<MutableList<Truck>>()
        var cur = mutableListOf<Truck>()
        var prev: String? = null
        for (t in trucks) {
            val d = t.districtReference?.trim().orEmpty()
            if (prev != null && d != prev) {
                out.add(cur)
                cur = mutableListOf()
            }
            cur.add(t)
            prev = d
        }
        out.add(cur)
        return out
    }

    /**
     * 匯出用：每段給定「板」欄顯示文字後，依中文排序段順序；段內維持原 loading 順序。
     * 無區域代碼（未分類）的段固定輸出「未分類」（與前端 RouteBoard 一致）；多段未分類時同字串需靠列順序區分。
     */
    private fun sortDistrictSegmentsForPlateColumnExport(
        trucksInLoadingOrder: List<Truck>,
    ): List<Pair<String, List<Truck>>> {
        val segments = splitDistrictSegments(trucksInLoadingOrder)
        val labeled = segments.map { seg ->
            val d = seg.first().districtReference?.trim().orEmpty()
            val label = if (d.isNotEmpty()) d else "未分類"
            label to seg
        }
        val coll = Collator.getInstance(Locale.forLanguageTag("zh-HK"))
        coll.strength = Collator.TERTIARY
        return labeled.sortedWith { a, b -> coll.compare(a.first, b.first) }
    }

    /**
     * PDF 圖1：一個 workbook 內每個車線一個 sheet（MTMS_ROUTE_V1）。
     */
    open fun exportRouteLanesExcelBytes(laneIds: List<String>): ByteArray {
        val wb = XSSFWorkbook()
        return ByteArrayOutputStream().use { bos ->
            try {
                val distinctIds = laneIds.distinct().filter { it.isNotBlank() }
                if (distinctIds.isEmpty()) {
                    throw IllegalArgumentException("laneIds is empty")
                }
                for (laneId in distinctIds) {
                    val key = RouteLaneExcelSupport.decodeLaneId(laneId)
                        ?: throw IllegalArgumentException("Invalid lane id: $laneId")
                    val (code, remark) = key
                    val trucks = findAllByTruckLanceCodeAndRemarkAndDeletedFalse(code, remark)
                        .sortedWith(compareBy({ it.loadingSequence ?: 0 }, { it.id ?: 0L }))
                    if (trucks.isEmpty()) continue

                    val sheet = wb.createSheet(
                        RouteLaneExcelSupport.uniqueSheetName(wb, code, remark),
                    )

                    var rr = sheet.createRow(RouteLaneExcelSupport.ROW_MARKER)
                    rr.createCell(RouteLaneExcelSupport.COL_META_A)
                        .setCellValue(RouteLaneExcelSupport.FORMAT_MARKER)
                    rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue(code)
                    rr.createCell(RouteLaneExcelSupport.COL_META_C).setCellValue(remark ?: "")

                    rr = sheet.createRow(RouteLaneExcelSupport.ROW_STORE)
                    rr.createCell(RouteLaneExcelSupport.COL_META_A).setCellValue("樓層")
                    rr.createCell(RouteLaneExcelSupport.COL_META_B)
                        .setCellValue(trucks.first().storeId ?: "")

                    rr = sheet.createRow(RouteLaneExcelSupport.ROW_DEPARTURE_DEFAULT)
                    rr.createCell(RouteLaneExcelSupport.COL_META_A).setCellValue("出車時間")
                    rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue(
                        formatDepartureForExcel(trucks.first().departureTime),
                    )

                    rr = sheet.createRow(RouteLaneExcelSupport.ROW_HEADER)
                    rr.createCell(RouteLaneExcelSupport.COL_AREA_PLATE).setCellValue("板")
                    rr.createCell(RouteLaneExcelSupport.COL_SHOP_NAME).setCellValue("店鋪名稱")
                    rr.createCell(RouteLaneExcelSupport.COL_BRAND).setCellValue("品牌")
                    rr.createCell(RouteLaneExcelSupport.COL_SHOP_CODE).setCellValue("店鋪編號")
                    rr.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue("此店車期")
                    rr.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue("出車時間")

                    val segmentsForRows = sortDistrictSegmentsForPlateColumnExport(trucks)
                    var rowNum = RouteLaneExcelSupport.ROW_FIRST_DATA
                    for ((label, seg) in segmentsForRows) {
                        for ((idx, t) in seg.withIndex()) {
                            val colA = if (idx == 0) label else ""
                            val dataRow = sheet.createRow(rowNum++)
                            dataRow.createCell(RouteLaneExcelSupport.COL_AREA_PLATE).setCellValue(colA)
                            dataRow.createCell(RouteLaneExcelSupport.COL_SHOP_NAME).setCellValue(t.shopName ?: "")
                            dataRow.createCell(RouteLaneExcelSupport.COL_BRAND)
                                .setCellValue(deriveBrandFromShopFullName(t.shop?.name))
                            dataRow.createCell(RouteLaneExcelSupport.COL_SHOP_CODE).setCellValue(t.shopCode ?: "")
                            dataRow.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue(t.remark ?: "")
                            dataRow.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue(
                                formatDepartureForExcel(t.departureTime),
                            )
                        }
                    }
                    RouteLaneExcelSupport.applyRouteLaneExportFinishing(
                        sheet,
                        wb,
                        RouteLaneExcelSupport.ROW_FIRST_DATA,
                        rowNum - 1,
                    )
                }
                if (wb.numberOfSheets == 0) {
                    throw IllegalArgumentException("No lane data to export (check lane ids / empty lanes)")
                }
                wb.write(bos)
                bos.toByteArray()
            } finally {
                wb.close()
            }
        }
    }

    fun buildRouteReportFilename(): String {
        val d = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
        return "車線Report_${d}.xlsx"
    }

    private fun normalizeRemarkForLaneGroup(raw: String?): String {
        val s = raw?.trim().orEmpty()
        return s
    }

    private fun laneKey(truckLanceCode: String?, remark: String?): Pair<String, String> {
        val code = truckLanceCode?.trim().orEmpty()
        val rem = normalizeRemarkForLaneGroup(remark)
        return code to rem
    }

    /**
     * 圖2：車線 Report（單一 sheet，每間物流公司一個水平區塊）。
     * laneIds 若不空，會限制只匯出指定 lane group（與前端 encodeLaneId 對應）。
     */
    open fun exportRouteReportExcelBytes(
        laneIds: List<String>,
        preparedBy: String = "—",
    ): ByteArray {
        val wb = XSSFWorkbook()
        return ByteArrayOutputStream().use { bos ->
            try {
                val trucksAll = truckRepository.findAllForRouteBoard()
                val distinctLaneIds = laneIds.distinct().filter { it.isNotBlank() }
                val decoded =
                    distinctLaneIds.mapNotNull { RouteLaneExcelSupport.decodeLaneId(it) }
                if (distinctLaneIds.isNotEmpty() && decoded.isEmpty()) {
                    throw IllegalArgumentException("Invalid laneIds")
                }
                val filterKeys: Set<Pair<String, String>> =
                    decoded
                        .map { (code, remark) ->
                            code.trim() to normalizeRemarkForLaneGroup(remark)
                        }
                        .toSet()

                val trucks =
                    if (filterKeys.isEmpty()) {
                        trucksAll
                    } else {
                        trucksAll.filter { t ->
                            val (c, r) = laneKey(t.truckLanceCode, t.remark)
                            filterKeys.contains(c to r)
                        }
                    }

                val coll = Collator.getInstance(Locale.forLanguageTag("zh-HK")).apply {
                    strength = Collator.TERTIARY
                }
                data class CompanyKey(val id: Long?, val name: String)

                val byCompanyId = trucks.groupBy { t ->
                    val id = t.logistic?.id
                    val nameRaw = t.logistic?.logisticName?.trim().orEmpty()
                    val name = if (nameRaw.isNotEmpty()) nameRaw else "未命名物流"
                    CompanyKey(id, name)
                }

                val companies = byCompanyId.entries
                    .filter { it.value.isNotEmpty() }
                    .sortedWith { a, b ->
                        val c = coll.compare(a.key.name, b.key.name)
                        if (c != 0) c else (a.key.id ?: Long.MIN_VALUE).compareTo(b.key.id ?: Long.MIN_VALUE)
                    }
                if (companies.isEmpty()) {
                    throw IllegalArgumentException("No lane data to export")
                }

                val sheet = wb.createSheet(RouteReportExcelSupport.SHEET_NAME)
                val st = RouteReportExcelSupport.buildStyles(wb)

                val today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
                RouteReportExcelSupport.writeTitle(
                    sheet,
                    st,
                    "新車綫($today)更改車線",
                    "製表: $preparedBy",
                    companies.size,
                )

                val timeFmt = DateTimeFormatter.ofPattern("HH:mm")

                var blockIndex = 0
                for ((ck, list) in companies) {
                    val sorted = list.sortedWith(
                        compareBy<Truck>(
                            { it.truckLanceCode?.trim().orEmpty() },
                            { it.districtReference?.trim().orEmpty() },
                            { it.loadingSequence ?: 0 },
                            { it.id ?: 0L },
                        ),
                    )

                    val metaTruck = sorted.first()
                    val logistic = metaTruck.logistic
                    val plate = logistic?.carPlate?.trim().orEmpty()
                    val driverName = logistic?.driverName?.trim().orEmpty()
                    val driverNumber = logistic?.driverNumber?.toString().orEmpty()

                    // 先以 lane group（truckLanceCode + normalized remark）聚合，避免同 lane 因為某些 row 的 departureTime 為 null/不一致而被拆散
                    data class LaneKey(val code: String, val remark: String)
                    data class LaneBucket(
                        val key: LaneKey,
                        val time: LocalTime,
                        val trucks: List<Truck>,
                    )

                    val laneBuckets = sorted
                        .groupBy { t ->
                            val (c, r) = laneKey(t.truckLanceCode, t.remark)
                            LaneKey(c, r)
                        }
                        .entries
                        .map { (k, laneTrucks) ->
                            val laneTime = laneTrucks.firstNotNullOfOrNull { it.departureTime } ?: LocalTime.MIDNIGHT
                            LaneBucket(k, laneTime, laneTrucks)
                        }

                    val timeGroups = laneBuckets
                        .groupBy { it.time }
                        .toSortedMap()
                        .map { (time, bucketsAtTime) ->
                            val lanes = bucketsAtTime
                                .sortedWith { a, b ->
                                    val c1 = coll.compare(a.key.code, b.key.code)
                                    if (c1 != 0) c1 else coll.compare(a.key.remark, b.key.remark)
                                }
                                .map { bucket ->
                                    val laneLabel =
                                        if (bucket.key.remark.isNotEmpty()) {
                                            "${bucket.key.code}-${bucket.key.remark}"
                                        } else {
                                            bucket.key.code
                                        }.ifEmpty { "—" }

                                    val districts = bucket.trucks
                                        .groupBy { it.districtReference?.trim().orEmpty() }
                                        .mapKeys { (k) -> if (k.isNotEmpty()) k else "未分類" }
                                        .toList()
                                        .sortedWith { a, b ->
                                            if (a.first == "未分類") -1
                                            else if (b.first == "未分類") 1
                                            else coll.compare(a.first, b.first)
                                        }
                                        .map { (district, dTrucks) ->
                                            val shops = dTrucks
                                                .sortedWith(compareBy({ it.loadingSequence ?: 0 }, { it.id ?: 0L }))
                                                .map { t ->
                                                    val name = t.shopName?.trim().takeUnless { it.isNullOrBlank() }
                                                        ?: t.shopCode?.trim().takeUnless { it.isNullOrBlank() }
                                                        ?: "—"
                                                    val brand = deriveBrandFromShopFullName(t.shop?.name).trim()
                                                    val code = t.shopCode?.trim().orEmpty()
                                                    val lines = ArrayList<String>(3)
                                                    lines.add(name)
                                                    if (brand.isNotEmpty()) lines.add(brand)
                                                    if (code.isNotEmpty() && code != name) lines.add(code)
                                                    lines.joinToString("\n")
                                                }
                                            RouteReportExcelSupport.DistrictGroup(district, shops)
                                        }
                                    RouteReportExcelSupport.LaneGroup(laneLabel, districts)
                                }
                            RouteReportExcelSupport.TimeGroup(time.format(timeFmt), lanes)
                        }

                    val distinctShopCount = sorted
                        .map {
                            val code = it.shopCode?.trim().orEmpty()
                            if (code.isNotEmpty()) "code:$code" else "id:${it.id}"
                        }
                        .toSet()
                        .size

                    RouteReportExcelSupport.writeCompanyBlock(
                        sheet,
                        st,
                        blockIndex,
                        1,
                        RouteReportExcelSupport.BlockMeta(
                            companyName = ck.name,
                            plate = plate,
                            driverName = driverName,
                            driverNumber = driverNumber,
                        ),
                        timeGroups,
                        distinctShopCount,
                    )
                    blockIndex++
                }

                wb.write(bos)
                bos.toByteArray()
            } finally {
                wb.close()
            }
        }
    }

    data class ExportTruckLaneVersionReportInput(
        val fromVersionId: Long,
        val toVersionId: Long,
        val preparedBy: String,
    )

    /**
     * 匯出「版本 Log 車線報告」：
     * - Sheet1：版本異動報告（高亮 + 文字說明）
     * - 其餘 sheets：每車線一個 worksheet（MTMS_ROUTE_V1）— 內容來自 toVersion 快照 lines
     */
    open fun exportTruckLaneVersionReportExcelBytes(input: ExportTruckLaneVersionReportInput): ByteArray {
        val wb = XSSFWorkbook()
        return ByteArrayOutputStream().use { bos ->
            try {
                if (input.fromVersionId == input.toVersionId) {
                    throw IllegalArgumentException("fromVersionId and toVersionId must be different")
                }
                val fromV = truckLaneVersionRepository.findByIdAndDeletedFalse(input.fromVersionId)
                    ?: throw IllegalArgumentException("Version not found: ${input.fromVersionId}")
                val toV = truckLaneVersionRepository.findByIdAndDeletedFalse(input.toVersionId)
                    ?: throw IllegalArgumentException("Version not found: ${input.toVersionId}")

                // 避免 from/to 顛倒導致新增/刪除/移動語意反轉
                if (fromV.created != null && toV.created != null && fromV.created!!.isAfter(toV.created)) {
                    throw IllegalArgumentException("fromVersionId must be older than toVersionId")
                }

                val fromLines = truckLaneVersionLineRepository
                    .findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(input.fromVersionId)
                val toLines = truckLaneVersionLineRepository
                    .findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(input.toVersionId)

                // 大檔保護：避免 OOM / timeout
                val maxLines = 80_000
                val maxLaneSheets = 300
                if (fromLines.size + toLines.size > maxLines) {
                    throw IllegalArgumentException("Snapshot too large to export (lines>${maxLines})")
                }

                val logisticIds = (fromLines.mapNotNull { it.logisticId } + toLines.mapNotNull { it.logisticId }).toSet()
                val logisticNameById =
                    if (logisticIds.isEmpty()) {
                        emptyMap()
                    } else {
                        logisticRepository.findAllById(logisticIds).associate { (it.id ?: -1L) to (it.logisticName ?: "") }
                    }

                val diffLines = buildVersionDiffLines(fromLines, toLines)
                val summaryRows = buildSummaryRows(fromLines, toLines, diffLines, logisticNameById)

                val createdDate = (toV.created?.toString() ?: "").take(10)
                val title = "車線報告(${createdDate.ifBlank { "—" }})更改車線"
                val editor = (toV.modifiedBy ?: input.preparedBy).trim().ifBlank { input.preparedBy }
                val created = toV.created?.toString() ?: "—"

                val stats = summarizeSummaryRows(summaryRows)
                val statsText =
                    "新增 ${stats.added} · 移動 ${stats.moved} · 刪除 ${stats.deleted} · 欄位變更 ${stats.fieldChanged}"

                TruckLaneVersionReportExcelSupport.writeSummarySheet(
                    wb,
                    TruckLaneVersionReportExcelSupport.SummaryMeta(
                        title = title,
                        editor = editor,
                        created = created,
                        fromVersionId = input.fromVersionId,
                        toVersionId = input.toVersionId,
                        note = toV.note,
                        statsText = statsText,
                    ),
                    summaryRows,
                )

                // Sheet2：同正常車線報告版面，但把異動 row 高亮（資料來自 toVersion 快照）
                val logisticsByIdForVersionReport = if (logisticIds.isEmpty()) emptyMap() else
                    logisticRepository.findAllById(logisticIds).associateBy { it.id ?: -1L }
                appendVersionRouteReportSheet(
                    wb,
                    createdDate.ifBlank { "—" },
                    editor,
                    toLines,
                    diffLines,
                    logisticNameById,
                    logisticsByIdForVersionReport,
                )

                // lane sheets (MTMS_ROUTE_V1) based on toLines snapshot
                appendLaneSheetsFromSnapshotLines(wb, toLines, maxLaneSheets)

                wb.write(bos)
                bos.toByteArray()
            } finally {
                wb.close()
            }
        }
    }

    private data class SummaryStats(
        val added: Int,
        val moved: Int,
        val deleted: Int,
        val fieldChanged: Int,
    )

    private fun summarizeSummaryRows(rows: List<TruckLaneVersionReportExcelSupport.SummaryRow>): SummaryStats {
        var added = 0
        var moved = 0
        var deleted = 0
        var fieldChanged = 0
        for (r in rows) {
            when (r.type) {
                TruckLaneVersionReportExcelSupport.RowType.ADDED -> added++
                TruckLaneVersionReportExcelSupport.RowType.MOVED -> moved++
                TruckLaneVersionReportExcelSupport.RowType.DELETED -> deleted++
                TruckLaneVersionReportExcelSupport.RowType.EDITED -> {}
            }
            if (r.changedFields.isNotEmpty()) fieldChanged++
        }
        return SummaryStats(added, moved, deleted, fieldChanged)
    }

    private fun laneLabel(code: String?, remark: String?): String {
        val c = code?.trim().orEmpty()
        val r = remark?.trim().orEmpty()
        if (c.isEmpty() && r.isEmpty()) return "—"
        if (r.isEmpty()) return c
        return "$c-$r"
    }

    private fun buildVersionDiffLines(
        fromLines: List<com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLine>,
        toLines: List<com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLine>,
    ): List<TruckLaneVersionDiffLine> {
        val fromByRow = fromLines.associateBy { it.truckRowId ?: -1 }
        val toByRow = toLines.associateBy { it.truckRowId ?: -1 }
        val allKeys = (fromByRow.keys + toByRow.keys).filter { it > 0 }.sorted()
        val changed = ArrayList<TruckLaneVersionDiffLine>()

        fun s(v: Any?): String? = v?.toString()

        for (key in allKeys) {
            val a = fromByRow[key]
            val b = toByRow[key]
            val changes = ArrayList<DiffFieldChange>()

            if (s(a?.truckLanceCode) != s(b?.truckLanceCode)) changes.add(DiffFieldChange("truckLanceCode", s(a?.truckLanceCode), s(b?.truckLanceCode)))
            if (s(a?.shopCode) != s(b?.shopCode)) changes.add(DiffFieldChange("shopCode", s(a?.shopCode), s(b?.shopCode)))
            if (s(a?.branchName) != s(b?.branchName)) changes.add(DiffFieldChange("branchName", s(a?.branchName), s(b?.branchName)))
            if (s(a?.districtReference) != s(b?.districtReference)) changes.add(DiffFieldChange("districtReference", s(a?.districtReference), s(b?.districtReference)))
            if (s(a?.loadingSequence) != s(b?.loadingSequence)) changes.add(DiffFieldChange("loadingSequence", s(a?.loadingSequence), s(b?.loadingSequence)))
            if (s(a?.departureTime) != s(b?.departureTime)) changes.add(DiffFieldChange("departureTime", s(a?.departureTime), s(b?.departureTime)))
            if (s(a?.storeId) != s(b?.storeId)) changes.add(DiffFieldChange("storeId", s(a?.storeId), s(b?.storeId)))
            if (s(a?.remark) != s(b?.remark)) changes.add(DiffFieldChange("remark", s(a?.remark), s(b?.remark)))
            if (s(a?.logisticId) != s(b?.logisticId)) changes.add(DiffFieldChange("logisticId", s(a?.logisticId), s(b?.logisticId)))

            if (changes.isNotEmpty()) {
                changed.add(
                    TruckLaneVersionDiffLine(
                        truckRowId = key,
                        shopCode = b?.shopCode ?: a?.shopCode,
                        changes = changes,
                        truckLanceCode = b?.truckLanceCode ?: a?.truckLanceCode,
                        remark = b?.remark ?: a?.remark,
                    ),
                )
            }
        }
        return changed
    }

    private val FIELD_LABEL = mapOf(
        "departureTime" to "發車時段",
        "loadingSequence" to "裝載順序",
        "branchName" to "分店名稱",
        "districtReference" to "區域",
        "shopCode" to "店鋪代碼",
        "storeId" to "樓層／店別",
        "remark" to "備註",
        "truckLanceCode" to "車線代碼",
        "logisticId" to "物流公司",
    )

    private fun buildSummaryRows(
        fromLines: List<com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLine>,
        toLines: List<com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLine>,
        diffs: List<TruckLaneVersionDiffLine>,
        logisticNameById: Map<Long, String>,
    ): List<TruckLaneVersionReportExcelSupport.SummaryRow> {
        val fromByRow = fromLines.associateBy { it.truckRowId ?: -1 }
        val toByRow = toLines.associateBy { it.truckRowId ?: -1 }

        fun shopName(line: com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLine?): String {
            val n = line?.branchName?.trim().orEmpty()
            return if (n.isNotEmpty()) n else (line?.shopCode?.trim().orEmpty().ifEmpty { "—" })
        }

        val out = ArrayList<TruckLaneVersionReportExcelSupport.SummaryRow>()
        for (d in diffs) {
            val a = fromByRow[d.truckRowId]
            val b = toByRow[d.truckRowId]
            val fromLane = laneLabel(a?.truckLanceCode, a?.remark)
            val toLane = laneLabel(b?.truckLanceCode, b?.remark)
            val fromEmpty = fromLane == "—"
            val toEmpty = toLane == "—"
            val type =
                if (fromEmpty && !toEmpty) TruckLaneVersionReportExcelSupport.RowType.ADDED
                else if (!fromEmpty && toEmpty) TruckLaneVersionReportExcelSupport.RowType.DELETED
                else if (fromLane != toLane) TruckLaneVersionReportExcelSupport.RowType.MOVED
                else TruckLaneVersionReportExcelSupport.RowType.EDITED

            val changedFields = d.changes
                .map { it.field }
                .filterNot { it == "truckLanceCode" || it == "remark" }
                .toSet()
            val textBits = ArrayList<String>()
            if (type == TruckLaneVersionReportExcelSupport.RowType.ADDED) {
                textBits.add("新增到 $toLane")
            } else if (type == TruckLaneVersionReportExcelSupport.RowType.DELETED) {
                textBits.add("自 $fromLane 移除")
            } else if (type == TruckLaneVersionReportExcelSupport.RowType.MOVED) {
                textBits.add("由 $fromLane → $toLane")
            }
            for (c in d.changes) {
                if (c.field == "truckLanceCode" || c.field == "remark") continue
                val label = FIELD_LABEL[c.field] ?: c.field
                val from =
                    if (c.field == "logisticId") {
                        val id = c.from?.trim()?.toLongOrNull()
                        val name = id?.let { logisticNameById[it] }?.trim().orEmpty()
                        name.ifEmpty { c.from?.trim().takeUnless { it.isNullOrBlank() } ?: "—" }
                    } else {
                        c.from?.trim().takeUnless { it.isNullOrBlank() } ?: "—"
                    }
                val to =
                    if (c.field == "logisticId") {
                        val id = c.to?.trim()?.toLongOrNull()
                        val name = id?.let { logisticNameById[it] }?.trim().orEmpty()
                        name.ifEmpty { c.to?.trim().takeUnless { it.isNullOrBlank() } ?: "—" }
                    } else {
                        c.to?.trim().takeUnless { it.isNullOrBlank() } ?: "—"
                    }
                if (from != to) textBits.add("$label：$from → $to")
            }

            out.add(
                TruckLaneVersionReportExcelSupport.SummaryRow(
                    type = type,
                    shopName = shopName(b ?: a),
                    shopCode = (b?.shopCode ?: a?.shopCode ?: "").trim(),
                    fromLane = fromLane,
                    toLane = toLane,
                    changeText = textBits.joinToString("；").ifEmpty { "欄位變更" },
                    changedFields = changedFields,
                ),
            )
        }
        return out
    }

    private fun appendLaneSheetsFromSnapshotLines(
        wb: XSSFWorkbook,
        toLines: List<com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLine>,
        maxLaneSheets: Int,
    ) {
        data class LaneKey(val code: String, val remark: String)
        fun keyOf(l: com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLine): LaneKey {
            val c = l.truckLanceCode?.trim().orEmpty()
            val r = normalizeRemarkForLaneGroup(l.remark)
            return LaneKey(c, r)
        }

        val codes = toLines.mapNotNull { it.shopCode?.trim()?.takeIf { s -> s.isNotEmpty() } }.toSet()
        val shopNameByCode = if (codes.isEmpty()) emptyMap() else
            shopRepository.findAllByCodeInAndDeletedIsFalse(codes).associateBy({ it.code ?: "" }, { it.name ?: "" })

        fun deriveBrandByShopCode(code: String?): String {
            val c = code?.trim().orEmpty()
            if (c.isEmpty()) return ""
            val full = shopNameByCode[c].orEmpty()
            return deriveBrandFromShopFullName(full)
        }

        fun parseLocalTimeOrNull(raw: String?): LocalTime? = parseDepartureTime(raw)

        // group by lane
        val byLane = toLines
            .filter { !it.truckLanceCode.isNullOrBlank() }
            .groupBy(::keyOf)
            .entries
            .sortedWith(compareBy({ it.key.code }, { it.key.remark }))

        if (byLane.size > maxLaneSheets) {
            throw IllegalArgumentException("Too many lane sheets to export (>${maxLaneSheets})")
        }

        for ((k, linesRaw) in byLane) {
            val lines = linesRaw.sortedWith(compareBy({ it.loadingSequence ?: 0 }, { it.truckRowId ?: 0L }, { it.id ?: 0L }))
            if (lines.isEmpty()) continue
            val sheetName = RouteLaneExcelSupport.uniqueSheetName(wb, k.code, k.remark.ifEmpty { null })
            val sheet = wb.createSheet(sheetName)

            // marker/meta
            var rr = sheet.createRow(RouteLaneExcelSupport.ROW_MARKER)
            rr.createCell(RouteLaneExcelSupport.COL_META_A).setCellValue(RouteLaneExcelSupport.FORMAT_MARKER)
            rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue(k.code)
            rr.createCell(RouteLaneExcelSupport.COL_META_C).setCellValue(k.remark)

            rr = sheet.createRow(RouteLaneExcelSupport.ROW_STORE)
            rr.createCell(RouteLaneExcelSupport.COL_META_A).setCellValue("樓層")
            rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue(lines.first().storeId ?: "")

            rr = sheet.createRow(RouteLaneExcelSupport.ROW_DEPARTURE_DEFAULT)
            rr.createCell(RouteLaneExcelSupport.COL_META_A).setCellValue("出車時間")
            val deptDefault = parseLocalTimeOrNull(lines.firstNotNullOfOrNull { it.departureTime })
            rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue(formatDepartureForExcel(deptDefault))

            rr = sheet.createRow(RouteLaneExcelSupport.ROW_HEADER)
            rr.createCell(RouteLaneExcelSupport.COL_AREA_PLATE).setCellValue("板")
            rr.createCell(RouteLaneExcelSupport.COL_SHOP_NAME).setCellValue("店鋪名稱")
            rr.createCell(RouteLaneExcelSupport.COL_BRAND).setCellValue("品牌")
            rr.createCell(RouteLaneExcelSupport.COL_SHOP_CODE).setCellValue("店鋪編號")
            rr.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue("此店車期")
            rr.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue("出車時間")

            // segments by district changes in loading order
            val segments = ArrayList<List<com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLine>>()
            var cur = ArrayList<com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLine>()
            var prev = ""
            for (l in lines) {
                val d = l.districtReference?.trim().orEmpty()
                if (cur.isNotEmpty() && d != prev) {
                    segments.add(cur)
                    cur = ArrayList()
                }
                cur.add(l)
                prev = d
            }
            if (cur.isNotEmpty()) segments.add(cur)

            val coll = Collator.getInstance(Locale.forLanguageTag("zh-HK")).apply { strength = Collator.TERTIARY }
            val labeled = segments.map { seg ->
                val d = seg.first().districtReference?.trim().orEmpty()
                val label = if (d.isNotEmpty()) d else "未分類"
                label to seg
            }.sortedWith { a, b -> coll.compare(a.first, b.first) }

            var rowNum = RouteLaneExcelSupport.ROW_FIRST_DATA
            for ((label, seg) in labeled) {
                for ((idx, l) in seg.withIndex()) {
                    val colA = if (idx == 0) label else ""
                    val dataRow = sheet.createRow(rowNum++)
                    dataRow.createCell(RouteLaneExcelSupport.COL_AREA_PLATE).setCellValue(colA)
                    dataRow.createCell(RouteLaneExcelSupport.COL_SHOP_NAME).setCellValue(l.branchName ?: "")
                    dataRow.createCell(RouteLaneExcelSupport.COL_BRAND).setCellValue(deriveBrandByShopCode(l.shopCode))
                    dataRow.createCell(RouteLaneExcelSupport.COL_SHOP_CODE).setCellValue(l.shopCode ?: "")
                    dataRow.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue(l.remark ?: "")
                    val dept = parseLocalTimeOrNull(l.departureTime) ?: deptDefault
                    dataRow.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue(formatDepartureForExcel(dept))
                }
            }

            RouteLaneExcelSupport.applyRouteLaneExportFinishing(
                sheet,
                wb,
                RouteLaneExcelSupport.ROW_FIRST_DATA,
                rowNum - 1,
            )
        }
    }

    private fun appendVersionRouteReportSheet(
        wb: XSSFWorkbook,
        createdDate: String,
        editor: String,
        toLines: List<com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLine>,
        diffLines: List<TruckLaneVersionDiffLine>,
        logisticNameById: Map<Long, String>,
        logisticsById: Map<Long, Logistic>,
    ) {
        val diffByRowId = diffLines.associateBy { it.truckRowId }
        val logisticChangeByRowId = diffLines.mapNotNull { d ->
            d.changes.firstOrNull { it.field == "logisticId" }?.let { d.truckRowId to it }
        }.toMap()

        val changedFieldsByRowId: Map<Long, Set<String>> = diffLines.associate { d ->
            d.truckRowId to d.changes.map { it.field }.toSet()
        }
        val changedRowIds = changedFieldsByRowId.keys.toSet()

        val shopCodes = toLines.mapNotNull { it.shopCode?.trim()?.takeIf { s -> s.isNotEmpty() } }.toSet()
        val shopNameByCode = if (shopCodes.isEmpty()) emptyMap() else
            shopRepository.findAllByCodeInAndDeletedIsFalse(shopCodes).associateBy({ it.code ?: "" }, { it.name ?: "" })

        fun brandByShopCode(code: String?): String {
            val c = code?.trim().orEmpty()
            if (c.isEmpty()) return ""
            return deriveBrandFromShopFullName(shopNameByCode[c].orEmpty()).trim()
        }

        fun laneLabel(code: String?, remark: String?): String {
            val c = code?.trim().orEmpty()
            val r = remark?.trim().orEmpty()
            if (c.isEmpty() && r.isEmpty()) return "—"
            return if (r.isEmpty()) c else "$c-$r"
        }

        fun timeLabel(raw: String?): String {
            val t = parseDepartureTime(raw) ?: return "00:00"
            return DateTimeFormatter.ofPattern("HH:mm").format(t)
        }

        // group by logisticId
        val linesWithLane = toLines.filter { !it.truckLanceCode.isNullOrBlank() }
        data class CompanyKey(val id: Long?, val name: String)
        val byCompany = linesWithLane.groupBy { l ->
            val id = l.logisticId
            val logi = if (id != null) logisticsById[id] else null
            val name = logi?.logisticName?.trim().orEmpty().ifEmpty { "未命名物流" }
            CompanyKey(id, name)
        }.entries.sortedBy { it.key.name }

        val st = TruckLaneVersionRouteReportExcelSupport.buildStyles(wb)
        val sheet = wb.createSheet(TruckLaneVersionRouteReportExcelSupport.SHEET_NAME)
        TruckLaneVersionRouteReportExcelSupport.writeTitle(
            sheet,
            st,
            "車線報告($createdDate)更改車線",
            "製表: $editor",
            byCompany.size.coerceAtLeast(1),
        )

        var blockIndex = 0
        for ((ck, list) in byCompany) {
            val metaLogi = ck.id?.let { logisticsById[it] }
            val plate = metaLogi?.carPlate?.trim().orEmpty()
            val driverName = metaLogi?.driverName?.trim().orEmpty()
            val driverNumber = metaLogi?.driverNumber?.toString().orEmpty()

            // lane buckets by (code+remark) to avoid split by inconsistent times
            data class LaneKey(val code: String, val remark: String)
            data class LaneBucket(val key: LaneKey, val time: String, val lines: List<com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLine>)
            val laneBuckets = list.groupBy { l ->
                LaneKey(l.truckLanceCode?.trim().orEmpty(), normalizeRemarkForLaneGroup(l.remark))
            }.map { (k, ls) ->
                val t = ls.firstNotNullOfOrNull { it.departureTime } ?: ""
                LaneBucket(k, timeLabel(t), ls)
            }

            val timeGroups = laneBuckets.groupBy { it.time }.toSortedMap().map { (time, bucketsAtTime) ->
                val lanes = bucketsAtTime
                    .sortedWith(compareBy({ it.key.code }, { it.key.remark }))
                    .map { bucket ->
                        val lane = laneLabel(bucket.key.code, bucket.key.remark)
                        val districts = bucket.lines
                            .groupBy { it.districtReference?.trim().orEmpty().ifEmpty { "未分類" } }
                            .toSortedMap { a, b ->
                                if (a == "未分類") -1
                                else if (b == "未分類") 1
                                else a.compareTo(b)
                            }
                            .map { (district, dLines) ->
                                val shops = dLines
                                    .sortedWith(compareBy({ it.loadingSequence ?: 0 }, { it.truckRowId ?: 0L }, { it.id ?: 0L }))
                                    .map { l ->
                                        val id = l.truckRowId ?: -1L
                                        val changed = changedRowIds.contains(id)
                                        val name = l.branchName?.trim().orEmpty().ifEmpty { l.shopCode?.trim().orEmpty().ifEmpty { "—" } }
                                        val brand = brandByShopCode(l.shopCode)
                                        val code = l.shopCode?.trim().orEmpty()
                                        val extra = changedFieldsByRowId[id]?.let { fields ->
                                            val shown = fields
                                                .filterNot { it == "truckLanceCode" || it == "remark" || it == "logisticId" }
                                                .map { FIELD_LABEL[it] ?: it }
                                            if (shown.isNotEmpty()) "變更: ${shown.joinToString(",")}" else null
                                        }
                                        val logisticChange = logisticChangeByRowId[id]?.let { ch ->
                                            val fromName = ch.from?.trim()?.toLongOrNull()
                                                ?.let { logisticNameById[it] }
                                                ?.trim()
                                                .orEmpty()
                                                .ifEmpty { ch.from?.trim().orEmpty() }
                                                .ifEmpty { "—" }
                                            val toName = ch.to?.trim()?.toLongOrNull()
                                                ?.let { logisticNameById[it] }
                                                ?.trim()
                                                .orEmpty()
                                                .ifEmpty { ch.to?.trim().orEmpty() }
                                                .ifEmpty { "—" }
                                            if (fromName != toName) "物流公司：$fromName → $toName" else null
                                        }
                                        val linesText = ArrayList<String>(4)
                                        linesText.add(name)
                                        if (brand.isNotEmpty()) linesText.add(brand)
                                        if (code.isNotEmpty() && code != name) linesText.add(code)
                                        if (!extra.isNullOrBlank()) linesText.add(extra)
                                        if (!logisticChange.isNullOrBlank()) linesText.add(logisticChange)
                                        TruckLaneVersionRouteReportExcelSupport.ShopRow(
                                            truckRowId = id,
                                            text = linesText.joinToString("\n"),
                                            changed = changed,
                                        )
                                    }
                                TruckLaneVersionRouteReportExcelSupport.DistrictGroup(district, shops)
                            }
                        TruckLaneVersionRouteReportExcelSupport.LaneGroup(lane, districts)
                    }
                TruckLaneVersionRouteReportExcelSupport.TimeGroup(time, lanes)
            }

            val distinctShopCount = list.mapNotNull { it.shopCode?.trim()?.takeIf { s -> s.isNotEmpty() } }.toSet().size

            TruckLaneVersionRouteReportExcelSupport.writeCompanyBlock(
                sheet,
                st,
                blockIndex,
                1,
                TruckLaneVersionRouteReportExcelSupport.BlockMeta(
                    companyName = ck.name,
                    plate = plate,
                    driverName = driverName,
                    driverNumber = driverNumber,
                ),
                timeGroups,
                distinctShopCount,
            )
            blockIndex++
        }
    }

    /** Parse MTMS_ROUTE_V1 workbook without writing to DB (for staged import preview). */
    open fun parseRouteLanesExcel(workbook: Workbook?): ParseRouteLanesExcelResponse {
        if (workbook == null) {
            return ParseRouteLanesExcelResponse(0, 0, emptyList())
        }
        var sheetsProcessed = 0
        val previewRows = mutableListOf<RouteLaneImportPreviewRow>()
        for (si in 0 until workbook.numberOfSheets) {
            val sheet = workbook.getSheetAt(si)
            val row0 = sheet.getRow(RouteLaneExcelSupport.ROW_MARKER) ?: continue
            val marker = ExcelUtils.getStringValue(row0.getCell(RouteLaneExcelSupport.COL_META_A)).trim()
            if (marker != RouteLaneExcelSupport.FORMAT_MARKER) {
                logger.warn("Skip sheet ${sheet.sheetName}: not ${RouteLaneExcelSupport.FORMAT_MARKER}")
                continue
            }
            val truckLanceCode =
                ExcelUtils.getStringValue(row0.getCell(RouteLaneExcelSupport.COL_META_B)).trim()
            if (truckLanceCode.isEmpty()) {
                logger.warn("Skip sheet ${sheet.sheetName}: empty truckLanceCode")
                continue
            }
            val remarkCell =
                ExcelUtils.getStringValue(row0.getCell(RouteLaneExcelSupport.COL_META_C)).trim()
            val laneRemark = remarkCell.ifEmpty { null }

            val rowStore = sheet.getRow(RouteLaneExcelSupport.ROW_STORE)
            val storeId =
                ExcelUtils.getStringValue(rowStore?.getCell(RouteLaneExcelSupport.COL_META_B)).trim()
            if (storeId.isEmpty()) {
                logger.warn("Skip sheet ${sheet.sheetName}: empty store id")
                continue
            }

            val rowDeptRow = sheet.getRow(RouteLaneExcelSupport.ROW_DEPARTURE_DEFAULT)
            val defaultDeptStr =
                ExcelUtils.getStringValue(rowDeptRow?.getCell(RouteLaneExcelSupport.COL_META_B)).trim()
            val defaultDept = parseDepartureTime(defaultDeptStr)

            var currentDistrict = ""
            var seq = 1
            for (i in RouteLaneExcelSupport.ROW_FIRST_DATA..sheet.lastRowNum) {
                val row = sheet.getRow(i) ?: continue
                val c0 =
                    ExcelUtils.getStringValue(row.getCell(RouteLaneExcelSupport.COL_AREA_PLATE)).trim()
                if (c0.isNotEmpty()) {
                    currentDistrict = c0
                }
                val shopName =
                    ExcelUtils.getStringValue(row.getCell(RouteLaneExcelSupport.COL_SHOP_NAME)).trim()
                val shopCodeRaw =
                    ExcelUtils.getStringValue(row.getCell(RouteLaneExcelSupport.COL_SHOP_CODE)).trim()
                if (shopCodeRaw.isEmpty()) {
                    continue
                }

                val scheduleRemark =
                    ExcelUtils.getStringValue(row.getCell(RouteLaneExcelSupport.COL_SCHEDULE)).trim()
                val rowDeptStr =
                    ExcelUtils.getStringValue(row.getCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW)).trim()
                val departure = parseDepartureTime(rowDeptStr) ?: defaultDept
                if (departure == null) {
                    logger.warn("Sheet ${sheet.sheetName} row ${i + 1}: skipped — invalid departure")
                    continue
                }

                val normalizedShopCode = normalizeShopCode(shopCodeRaw)
                val allShops = shopRepository.findAllByDeletedIsFalse()
                val shop = allShops.firstOrNull { it.code == shopCodeRaw }
                    ?: allShops.firstOrNull { it.code == normalizedShopCode }
                if (shop == null) {
                    logger.warn(
                        "Sheet ${sheet.sheetName} row ${i + 1}: no shop for code '$shopCodeRaw' (normalized '$normalizedShopCode')",
                    )
                    continue
                }

                val effectiveRemark =
                    if (storeId == "4F") {
                        when {
                            scheduleRemark.isNotEmpty() -> scheduleRemark
                            laneRemark != null && laneRemark.isNotEmpty() -> laneRemark
                            else -> null
                        }
                    } else {
                        null
                    }

                val existingTruck =
                    resolveExistingTruckForRouteLaneImport(
                        truckLanceCode.trim(),
                        storeId,
                        laneRemark,
                        shop.id!!,
                        shopCodeRaw,
                        normalizedShopCode,
                    )
                val logisticId = existingTruck?.logistic?.id

                previewRows.add(
                    RouteLaneImportPreviewRow(
                        truckRowId = existingTruck?.id,
                        truckLanceCode = truckLanceCode,
                        remark = effectiveRemark,
                        storeId = storeId,
                        departureTime = departure.toString(),
                        shopId = shop.id!!,
                        shopName = shopName.ifEmpty { shop.name ?: "" },
                        shopCode = normalizedShopCode,
                        loadingSequence = seq,
                        districtReference = normalizeDistrictReferenceForRouteLaneImport(currentDistrict),
                        logisticId = logisticId,
                    ),
                )
                seq++
            }
            sheetsProcessed++
        }
        return ParseRouteLanesExcelResponse(
            sheetCount = sheetsProcessed,
            rowCount = previewRows.size,
            rows = previewRows,
        )
    }

    private fun saveTruckFromImportPreview(row: RouteLaneImportPreviewRow) {
        val departure = parseDepartureTime(row.departureTime)
            ?: throw IllegalArgumentException("Invalid departureTime: ${row.departureTime}")
        saveTruck(
            SaveTruckRequest(
                id = row.truckRowId,
                store_id = row.storeId,
                truckLanceCode = row.truckLanceCode,
                departureTime = departure,
                shopId = row.shopId,
                shopName = row.shopName,
                shopCode = row.shopCode,
                loadingSequence = row.loadingSequence,
                remark = row.remark,
                districtReference = row.districtReference,
                logisticId = row.logisticId,
            ),
        )
    }

    /** 不做單一大 transaction：逐列 [saveTruck] 各自提交，部分列失敗時前面仍保留 */
    open fun importRouteLanesExcel(workbook: Workbook?): String {
        if (workbook == null) {
            return "Import Excel failure"
        }
        val parsed = parseRouteLanesExcel(workbook)
        for (row in parsed.rows) {
            saveTruckFromImportPreview(row)
        }
        return "Import Excel success: ${parsed.sheetCount} sheet(s), ${parsed.rowCount} row(s)"
    }

}