package com.ffii.fpsms.m18.service

import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.ffii.fpsms.api.service.ApiCallerService
import com.ffii.fpsms.m18.M18Config
import com.ffii.fpsms.m18.entity.M18BomShopSyncLog
import com.ffii.fpsms.m18.entity.M18BomShopSyncLogRepository
import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse
import com.ffii.fpsms.m18.model.M18BomForShopSaveAttemptResult
import com.ffii.fpsms.m18.model.M18BomForShopSaveRequest
import com.ffii.fpsms.m18.model.M18MainUdfBomForShopValue
import com.ffii.fpsms.m18.model.M18MainUdfBomForShopWrapper
import com.ffii.fpsms.m18.model.M18UdfProductSaveValue
import com.ffii.fpsms.m18.model.M18UdfProductWrapper
import com.ffii.fpsms.modules.master.entity.Bom
import com.ffii.fpsms.modules.master.entity.BomMaterial
import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.master.service.ShopService
import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine
import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLineRepository
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.util.LinkedMultiValueMap
import reactor.core.publisher.Mono
import java.math.BigDecimal
import java.math.RoundingMode
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.time.ZoneId

/**
 * Push FPSMS BOM + materials to M18 udfBomForShop (similar to GRN save/an).
 * PUT /root/api/save/udfbomforshop?menuCode=udfbomforshop
 */
@Service
open class M18BomForShopService(
    private val m18Config: M18Config,
    private val apiCallerService: ApiCallerService,
    private val itemUomService: ItemUomService,
    private val purchaseOrderLineRepository: PurchaseOrderLineRepository,
    private val m18BomShopSyncLogRepository: M18BomShopSyncLogRepository,
    private val shopService: ShopService,
    private val m18VendorLookupService: M18VendorLookupService,
    private val m18BomHeaderLookupService: M18BomHeaderLookupService,
) {
    private val logger: Logger = LoggerFactory.getLogger(M18BomForShopService::class.java)

    private val savePath = "/root/api/save/udfbomforshop"
    private val menuCode = "udfbomforshop"

    /** M18 business entity id for udfBomForShop header (`udfbomforshop.values[0].beId`). */
    private val bomShopMainBeId: Int = 29

    /**
     * Stock UOM `code` on the **BOM header item** (e.g. PACK2LB = prefix + pack multiple + unit suffix).
     * [udfHarvest] = [Bom.outputQty] × middle number; [udfHarvestUnit] = trailing unit (e.g. LB).
     */
    private val bomItemStockUomPackCodeRegex = Regex("^([A-Za-z]+)(\\d+)([A-Za-z]+)$")

    companion object {
        private const val HARVEST_CALC_SCALE = 10
        internal const val BOM_SHOP_HEADER_VERSION_DIGITS = 4
        private val m18Tz: ZoneId = ZoneId.of("Asia/Hong_Kong")

        private fun formatBomShopHeaderCode(itemCode: String, version: Int): String =
            "BOM${itemCode}V${version.toString().padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0')}"

        internal fun normalizedHeaderRevision(versionDigits: String): String =
            versionDigits.padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0')
    }

    @Suppress("DEPRECATION")
    private val objectMapper: ObjectMapper = jacksonObjectMapper().apply {
        disable(JsonGenerator.Feature.ESCAPE_NON_ASCII)
    }

    /**
     * Stable hash of payload **excluding** M18 header `id`, `code`, and `rev` (so version bumps do not affect equality).
     * Used with [M18BomShopSyncLog] to decide V0000 vs V0001+.
     */
    open fun contentFingerprint(request: M18BomForShopSaveRequest): String {
        val json = objectMapper.writeValueAsString(normalizedForFingerprint(request))
        return sha256Hex(json)
    }

    private fun normalizedForFingerprint(request: M18BomForShopSaveRequest): M18BomForShopSaveRequest {
        val v = request.udfbomforshop.values.firstOrNull()
            ?: return request
        val headerNorm = v.copy(id = null, code = null, rev = null)
        val linesSorted = request.udfproduct.values.sortedWith(
            compareBy({ it.itemNo }, { it.udfProduct }, { it.udfIngredients }),
        )
        return M18BomForShopSaveRequest(
            udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(headerNorm)),
            udfproduct = M18UdfProductWrapper(values = linesSorted),
        )
    }

    private fun sha256Hex(text: String): String {
        val md = MessageDigest.getInstance("SHA-256")
        val bytes = md.digest(text.toByteArray(StandardCharsets.UTF_8))
        return bytes.joinToString("") { "%02x".format(it) }
    }

    /**
     * Builds M18 save body from a persisted BOM (materials loaded).
     * [headerM18IdOverride] optional M18 header record id when forcing update; skips version/fingerprint logic for **id** only,
     * reuses latest logged [M18BomShopSyncLog.m18HeaderCode] when possible.
     * Otherwise uses [Bom.m18Id] when the normalized payload matches the latest log; on content change, bumps `BOM{item}Vnnnn`.
     */
    open fun buildSaveRequest(bom: Bom, headerM18IdOverride: Long? = null): M18BomForShopSaveRequest? {
        val bomId = bom.id ?: return null
        val routingCode = bom.code ?: return null
        val itemCode = bom.item?.code?.trim().orEmpty().ifEmpty {
            logger.warn("[M18 BOM] bom.item.code missing; cannot build M18 BOM shop payload. bomId=$bomId")
            return null
        }

        val flowTypeId = resolveFlowTypeId(routingCode)
        val udfUnit = bom.uom?.m18Id?.takeIf { it > 0 } ?: return null
        val outputQty = bom.outputQty ?: BigDecimal.ZERO
        val (udfHarvest, udfHarvestUnit) = resolveUdfHarvestFields(bom, outputQty)
        val udfEffectiveDate = bom.created?.atZone(m18Tz)?.toInstant()?.toEpochMilli()

        val targetBeId = resolveTargetBeId(flowTypeId)
        val supplierCache = mutableMapOf<String, Long?>()
        val lines = bom.bomMaterials
            .filter { it.deleted != true }
            .sortedBy { it.id ?: 0L }
            .mapIndexedNotNull { idx, mat ->
                toProductLine(mat, idx + 1, flowTypeId, targetBeId, supplierCache)
            }

        if (lines.isEmpty()) {
            logger.warn("[M18 BOM] BOM id=$bomId code=$routingCode has no materials; skipping M18 save")
            return null
        }

        val (headerCode, rev, headerM18IdForRequest) = resolveHeaderCodeAndM18Id(
            bomId = bomId,
            itemCode = itemCode,
            lines = lines,
            udfUnit = udfUnit,
            udfHarvest = udfHarvest,
            udfHarvestUnit = udfHarvestUnit,
            udfEffectiveDate = udfEffectiveDate,
            bomYield = bom.yield,
            bomName = bom.name,
            bomDescription = bom.description,
            flowTypeId = flowTypeId,
            headerM18IdOverride = headerM18IdOverride,
            bomM18Id = bom.m18Id?.takeIf { it > 0 },
        )

        val header = M18MainUdfBomForShopValue(
            id = headerM18IdForRequest?.toString(),
            code = headerCode,
            beId = bomShopMainBeId,
            desc = bom.name ?: bom.description,
            descEn = bom.name ?: bom.description,
            udfBomCode = itemCode,
            rev = rev,
            udfUnit = udfUnit,
            udfHarvest = udfHarvest,
            udfHarvestUnit = udfHarvestUnit,
            udfEffectiveDate = udfEffectiveDate,
            udfYieldratePP = bom.yield,
            udftypeoffood = "半成品",
            udfconfirmed = true,
            staffId = 232,
            flowTypeId = flowTypeId,
            virDeptId = 117,
            status = "Y",
        )

        logger.info(
            "[M18 BOM] buildSaveRequest fpsmsBomId=$bomId routingCode=$routingCode itemCode=$itemCode headerCode=$headerCode " +
                "mainM18Id=$headerM18IdForRequest (override=$headerM18IdOverride, bom.m18Id=${bom.m18Id})",
        )

        return M18BomForShopSaveRequest(
            udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(header)),
            udfproduct = M18UdfProductWrapper(values = lines),
        )
    }

    @Suppress("LongParameterList")
    private fun resolveHeaderCodeAndM18Id(
        bomId: Long,
        itemCode: String,
        lines: List<M18UdfProductSaveValue>,
        udfUnit: Long,
        udfHarvest: String,
        udfHarvestUnit: String?,
        udfEffectiveDate: Long?,
        bomYield: BigDecimal?,
        bomName: String?,
        bomDescription: String?,
        flowTypeId: Int,
        headerM18IdOverride: Long?,
        bomM18Id: Long?,
    ): Triple<String, String?, Long?> {
        val draftHeader = M18MainUdfBomForShopValue(
            id = null,
            code = null,
            beId = bomShopMainBeId,
            desc = bomName ?: bomDescription,
            descEn = bomName ?: bomDescription,
            udfBomCode = itemCode,
            rev = null,
            udfUnit = udfUnit,
            udfHarvest = udfHarvest,
            udfHarvestUnit = udfHarvestUnit,
            udfEffectiveDate = udfEffectiveDate,
            udfYieldratePP = bomYield,
            udftypeoffood = "半成品",
            udfconfirmed = true,
            staffId = 232,
            flowTypeId = flowTypeId,
            virDeptId = 117,
            status = "Y",
        )
        val draftRequest = M18BomForShopSaveRequest(
            udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(draftHeader)),
            udfproduct = M18UdfProductWrapper(values = lines),
        )
        val fp = contentFingerprint(draftRequest)

        val forcedId = headerM18IdOverride?.takeIf { it > 0 }
        if (forcedId != null) {
            val latest = m18BomShopSyncLogRepository.findFirstByBomIdOrderByIdDesc(bomId)
            val codeForUpdate =
                latest?.m18HeaderCode?.takeIf { it.isNotBlank() }
                    ?: formatBomShopHeaderCode(itemCode, 0)
            val forcedRev = parseTrailingVersion(codeForUpdate)
                ?: "0".repeat(BOM_SHOP_HEADER_VERSION_DIGITS)
            return Triple(codeForUpdate, forcedRev, forcedId)
        }

        // Identical BOM details already synced — reuse header code + M18 id (update), never allocate a new version.
        findSuccessfulSyncByFingerprint(bomId, fp)?.let { match ->
            val reuseCode = match.m18HeaderCode?.trim().orEmpty()
            if (reuseCode.isNotEmpty()) {
                val reuseId = match.m18RecordId?.takeIf { it > 0L } ?: bomM18Id
                val revReuse = parseTrailingVersion(reuseCode)
                    ?: "0".repeat(BOM_SHOP_HEADER_VERSION_DIGITS)
                return Triple(reuseCode, revReuse, reuseId)
            }
        }

        // Content changed — next version number (new M18 header code).
        val maxV = maxVersionFromLogs(bomId, itemCode)
        val nextV = maxV + 1
        val newCode = formatBomShopHeaderCode(itemCode, nextV)
        val rev = nextV.toString().padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0')
        return Triple(newCode, rev, null)
    }

    private fun findSuccessfulSyncByFingerprint(bomId: Long, fingerprint: String): M18BomShopSyncLog? =
        m18BomShopSyncLogRepository.findFirstByBomIdAndSyncedIsTrueAndRequestFingerprintOrderByIdDesc(
            bomId,
            fingerprint,
        )

    private fun maxVersionFromLogs(bomId: Long, itemCode: String): Int {
        val versionPat = Regex("^BOM${Regex.escape(itemCode)}V(\\d+)$")
        // Only successful syncs advance the numeric tail; failed attempts log a code but must not consume Vnnnn.
        return m18BomShopSyncLogRepository.findTop100ByBomIdAndSyncedIsTrueOrderByIdDesc(bomId)
            .mapNotNull { row ->
                val c = row.m18HeaderCode?.trim().orEmpty().ifEmpty {
                    extractHeaderCodeFromJson(row.requestJson).orEmpty()
                }
                versionPat.find(c)?.groupValues?.get(1)?.toIntOrNull()
            }
            .maxOrNull() ?: -1
    }

    private fun extractHeaderCodeFromJson(json: String?): String? {
        if (json.isNullOrBlank()) return null
        return runCatching {
            val node = objectMapper.readTree(json)
            val text = node.path("udfbomforshop").path("values").path(0).path("code").asText()
            text.trim().takeIf { it.isNotEmpty() }
        }.getOrNull()
    }

    private fun parseTrailingVersion(headerCode: String): String? =
        Regex("V(\\d+)$").find(headerCode.trim())?.groupValues?.get(1)?.let { normalizedHeaderRevision(it) }

    /**
     * From the **finished-good** [Bom.item] stock unit [com.ffii.fpsms.modules.master.entity.UomConversion.code]
     * (pattern `LETTER_PREFIX` + `DIGITS` + `UNIT_SUFFIX`, e.g. PACK2LB): harvest qty = outputQty × digits, unit = suffix.
     * Falls back to plain [outputQty] and null unit when item/stock UOM/code is missing or does not match.
     */
    private fun resolveUdfHarvestFields(bom: Bom, outputQty: BigDecimal): Pair<String, String?> {
        val itemId = bom.item?.id
        if (itemId == null) {
            logger.warn("[M18 BOM] bom.item id missing; udfHarvest=outputQty only. bomId=${bom.id}")
            return outputQty.stripTrailingZeros().toPlainString() to null
        }
        val stockCode = itemUomService.findStockUnitByItemId(itemId)?.uom?.code?.trim().orEmpty()
        if (stockCode.isEmpty()) {
            logger.warn("[M18 BOM] stock UOM code missing for bom itemId=$itemId; udfHarvest=outputQty only. bomId=${bom.id}")
            return outputQty.stripTrailingZeros().toPlainString() to null
        }
        val match = bomItemStockUomPackCodeRegex.matchEntire(stockCode)
        if (match == null) {
            logger.warn(
                "[M18 BOM] stock UOM code '$stockCode' does not match PREFIX+NUMBER+SUFFIX; " +
                    "udfHarvest=outputQty only. bomId=${bom.id} itemId=$itemId",
            )
            return outputQty.stripTrailingZeros().toPlainString() to null
        }
        val mult = match.groupValues[2].toBigDecimalOrNull()
        if (mult == null || mult.compareTo(BigDecimal.ZERO) <= 0) {
            logger.warn(
                "[M18 BOM] invalid pack multiple in stock UOM code '$stockCode'; udfHarvest=outputQty only. bomId=${bom.id}",
            )
            return outputQty.stripTrailingZeros().toPlainString() to null
        }
        val unitSuffix = match.groupValues[3]
        val harvestQty = outputQty.multiply(mult).setScale(HARVEST_CALC_SCALE, RoundingMode.HALF_UP).stripTrailingZeros()
        return harvestQty.toPlainString() to unitSuffix
    }

    private fun toProductLine(
        mat: BomMaterial,
        lineNo: Int,
        flowTypeId: Int,
        targetBeId: Long?,
        supplierCache: MutableMap<String, Long?>,
    ): M18UdfProductSaveValue? {
        val proId = mat.item?.m18Id?.takeIf { it > 0 } ?: run {
            logger.warn("[M18 BOM] material item m18Id missing bomMaterialId=${mat.id} itemId=${mat.item?.id}")
            return null
        }
        val udfBaseUnit = mat.uom?.code?.trim()?.takeIf { it.isNotEmpty() } ?: run {
            logger.warn("[M18 BOM] material UOM code missing bomMaterialId=${mat.id}")
            return null
        }
        val itemId = mat.item?.id
        val poLines = itemId?.let { id ->
            purchaseOrderLineRepository.findLatestLinesForBomM18ByItemId(id, PageRequest.of(0, 20))
        } ?: emptyList()
        val supplierAndUnit = resolvePoSupplierAndPurchaseUnit(poLines, flowTypeId, targetBeId, supplierCache)
        val supplierM18Id = supplierAndUnit.supplierM18Id
        val purchaseUnitM18Id = supplierAndUnit.purchaseUnitM18Id
        val udfqty = (mat.qty ?: BigDecimal.ZERO).setScale(8, RoundingMode.HALF_UP).toDouble()
        return M18UdfProductSaveValue(
            id = mat.m18Id?.takeIf { it > 0 },
            udfqty = udfqty,
            udfProduct = proId,
            udfIngredients = mat.itemName ?: mat.item?.name,
            udfBaseUnit = udfBaseUnit,
            udfSupplier = supplierM18Id,
            udfpurchaseUnit = purchaseUnitM18Id,
            itemNo = String.format("%6d", lineNo),
            udfoptions = "",
            udfoption = 0.0,
            udfYieldRate = 0.0,
        )
    }

    /** Prefer a PO line whose header [com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder.m18BeId] matches the BOM BE. */
    private fun pickPreferredPoLine(lines: List<PurchaseOrderLine>, preferredBeId: Long?): PurchaseOrderLine? {
        if (lines.isEmpty()) return null
        if (preferredBeId == null) return lines.first()
        return lines.firstOrNull { it.purchaseOrder?.m18BeId == preferredBeId } ?: lines.first()
    }

    private fun pickPoLineMatchingBeOnly(lines: List<PurchaseOrderLine>, beId: Long?): PurchaseOrderLine? {
        if (lines.isEmpty() || beId == null) return null
        return lines.firstOrNull { it.purchaseOrder?.m18BeId == beId }
    }

    /** M18 line price unit ([M18PurchaseOrderPot.unitId]): [PurchaseOrderLine.uomM18] then [PurchaseOrderLine.uom]. */
    private fun purchaseUnitM18IdFromPoLine(poLine: PurchaseOrderLine?): Long? =
        poLine?.uomM18?.m18Id?.takeIf { it > 0L }
            ?: poLine?.uom?.m18Id?.takeIf { it > 0L }

    private data class PoSupplierUnitContext(
        val supplierM18Id: Long?,
        val purchaseUnitM18Id: Long?,
    )

    /**
     * Supplier + purchase unit for BOM material lines.
     * PF/PP BOMs: [udfpurchaseUnit] must come from a PO line on the **same business entity** (PP unit with PP supplier).
     * When PP supplier is resolved via supplier code from a PF/other PO, look for a PP PO line for the same item + supplier code for the unit.
     */
    private fun resolvePoSupplierAndPurchaseUnit(
        poLines: List<PurchaseOrderLine>,
        flowTypeId: Int,
        targetBeId: Long?,
        supplierCache: MutableMap<String, Long?>,
    ): PoSupplierUnitContext {
        if (poLines.isEmpty()) {
            return PoSupplierUnitContext(null, null)
        }

        val beMatchedLine = pickPoLineMatchingBeOnly(poLines, targetBeId)
        if (beMatchedLine != null) {
            return PoSupplierUnitContext(
                supplierM18Id = resolveSupplierM18Id(beMatchedLine, flowTypeId, supplierCache),
                purchaseUnitM18Id = purchaseUnitM18IdFromPoLine(beMatchedLine),
            )
        }

        if (flowTypeId != 2 && flowTypeId != 3) {
            val line = pickPreferredPoLine(poLines, targetBeId)
            return PoSupplierUnitContext(
                supplierM18Id = resolveSupplierM18Id(line, flowTypeId, supplierCache),
                purchaseUnitM18Id = purchaseUnitM18IdFromPoLine(line),
            )
        }

        val supplierSourceLine = pickPreferredPoLine(poLines, targetBeId)
        val supplierCode = supplierSourceLine?.purchaseOrder?.supplier?.code?.trim()?.takeIf { it.isNotEmpty() }
        val supplierM18Id = resolveSupplierM18Id(supplierSourceLine, flowTypeId, supplierCache)

        val unitLine =
            if (targetBeId != null && !supplierCode.isNullOrEmpty()) {
                poLines.firstOrNull { pol ->
                    pol.purchaseOrder?.m18BeId == targetBeId &&
                        pol.purchaseOrder?.supplier?.code?.trim().equals(supplierCode, ignoreCase = true)
                }
            } else {
                null
            }

        val purchaseUnitM18Id = purchaseUnitM18IdFromPoLine(unitLine)
        if (supplierM18Id != null && purchaseUnitM18Id == null && supplierSourceLine != null) {
            val beLabel = if (flowTypeId == 2) "PF" else "PP"
            logger.warn(
                "[M18 BOM] $beLabel supplier resolved from PO code={} supplierCode={} but no $beLabel PO line " +
                    "for same item+supplier — omitting udfpurchaseUnit (PF/other BE unit is invalid with $beLabel supplier)",
                supplierSourceLine.purchaseOrder?.code,
                supplierCode,
            )
        }

        return PoSupplierUnitContext(supplierM18Id, purchaseUnitM18Id)
    }

    private fun resolveTargetBeId(flowTypeId: Int): Long? = when (flowTypeId) {
        2 -> m18Config.BEID_PF.toLongOrNull()
        3 -> m18Config.BEID_PP.toLongOrNull()
        else -> null
    }

    /**
     * Resolves M18 vendor id for BOM material line supplier:
     * - PF BOMs: M18 search by supplier code + [M18Config.BEID_PF]
     * - PP BOMs: M18 search by supplier code + [M18Config.BEID_PP] (never local [Shop.m18Id] first — duplicate codes may be PF ids)
     */
    private fun resolveSupplierM18Id(
        latestPoLine: PurchaseOrderLine?,
        flowTypeId: Int,
        cache: MutableMap<String, Long?>,
    ): Long? {
        val po = latestPoLine?.purchaseOrder
        val supplier = po?.supplier
        val directM18Id = supplier?.m18Id?.takeIf { it > 0L }
        val supplierCode = supplier?.code?.trim()?.takeIf { it.isNotEmpty() }
        val targetBeId = resolveTargetBeId(flowTypeId)
        val poBeId = po?.m18BeId

        if (supplierCode == null) {
            return directM18Id
        }

        if (flowTypeId == 2 || flowTypeId == 3) {
            val cacheKey = "$supplierCode|$flowTypeId"
            cache[cacheKey]?.let { return it }
            val beId = if (flowTypeId == 2) m18Config.BEID_PF else m18Config.BEID_PP
            val beLabel = if (flowTypeId == 2) "PF" else "PP"
            val resolved =
                m18VendorLookupService.findVendorM18IdByCode(supplierCode, beId)
                    ?: directM18Id.takeIf { poBeId != null && poBeId == targetBeId }
            if (resolved == null) {
                logger.warn("[M18 BOM] $beLabel vendor M18 id not found for supplierCode=$supplierCode")
            }
            cache[cacheKey] = resolved
            return resolved
        }

        return shopService.findVendorByCode(supplierCode)?.m18Id?.takeIf { it > 0L }
            ?: directM18Id
    }

    private fun resolveFlowTypeId(code: String): Int = when {
        code.startsWith("TOA") -> 1
        code.startsWith("BOMPP") || code.startsWith("PP") -> 3
        code.startsWith("BOMPF") || code.startsWith("PF") || code.startsWith("PFP") -> 2
        else -> 1
    }

    /** M18 rejects duplicate header [M18MainUdfBomForShopValue.code] on create (core_101903). */
    open fun isSameCodeFoundError(resp: GoodsReceiptNoteResponse?): Boolean {
        if (resp == null || resp.status) return false
        return resp.messages.any { msg ->
            msg.msgCode == "core_101903" ||
                msg.msgDetail?.contains("Same Code found", ignoreCase = true) == true
        }
    }

    private fun withHeaderM18Id(
        request: M18BomForShopSaveRequest,
        headerCode: String,
        m18Id: Long,
    ): M18BomForShopSaveRequest {
        val header = request.udfbomforshop.values.firstOrNull()
            ?: return request
        val rev = parseTrailingVersion(headerCode) ?: header.rev
        val newHeader = header.copy(
            id = m18Id.toString(),
            code = headerCode,
            rev = rev,
        )
        return request.copy(
            udfbomforshop = request.udfbomforshop.copy(values = listOf(newHeader)),
        )
    }

    /**
     * Increments `BOM{item}Vnnnn` tail, clears header `id` (new M18 row), updates `rev`.
     * Returns null when [udfBomCode] is missing.
     */
    open fun bumpHeaderVersionForRetry(request: M18BomForShopSaveRequest): M18BomForShopSaveRequest? {
        val header = request.udfbomforshop.values.firstOrNull() ?: return null
        val itemCode = header.udfBomCode?.trim().orEmpty().ifEmpty { return null }
        val currentCode = header.code?.trim().orEmpty()
        val currentV =
            parseTrailingVersion(currentCode)?.toIntOrNull()
                ?: Regex("V(\\d+)$").find(currentCode)?.groupValues?.get(1)?.toIntOrNull()
                ?: -1
        val nextV = currentV + 1
        val newCode = formatBomShopHeaderCode(itemCode, nextV)
        val newRev = nextV.toString().padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0')
        val newHeader = header.copy(id = null, code = newCode, rev = newRev)
        return request.copy(
            udfbomforshop = request.udfbomforshop.copy(values = listOf(newHeader)),
        )
    }

    /**
     * Saves to M18. On duplicate code: update existing row when details match a prior sync or M18 lookup;
     * bump version only when BOM content (fingerprint) is new.
     */
    open fun saveBomForShopWithVersionRetry(
        request: M18BomForShopSaveRequest,
        bomId: Long,
        maxSameCodeRetries: Int = 20,
    ): M18BomForShopSaveAttemptResult {
        val fp = contentFingerprint(request)
        findSuccessfulSyncByFingerprint(bomId, fp)?.let { match ->
            val reuseCode = match.m18HeaderCode?.trim().orEmpty()
            val reuseId = match.m18RecordId?.takeIf { it > 0L }
            if (reuseCode.isNotEmpty() && reuseId != null) {
                logger.info(
                    "[M18 BOM] Unchanged BOM details; skip new code (reuse headerCode={} m18Id={})",
                    reuseCode,
                    reuseId,
                )
                return M18BomForShopSaveAttemptResult(
                    request = withHeaderM18Id(request, reuseCode, reuseId),
                    response = GoodsReceiptNoteResponse(recordId = reuseId, status = true),
                    callError = null,
                    versionBumps = 0,
                    skippedUnchanged = true,
                )
            }
        }

        var current = request
        var bumps = 0
        var lastResp: GoodsReceiptNoteResponse? = null
        var lastError: Throwable? = null
        val attachIdAttemptedForCode = mutableSetOf<String>()
        while (true) {
            lastError = null
            try {
                lastResp = saveBomForShop(current)
            } catch (e: Exception) {
                lastError = e
                break
            }
            if (
                lastResp == null ||
                !isSameCodeFoundError(lastResp) ||
                bumps >= maxSameCodeRetries
            ) {
                break
            }

            val header = current.udfbomforshop.values.firstOrNull() ?: break
            val code = header.code?.trim().orEmpty()

            // Same details already synced — update existing row, do not create a new version code.
            val reuseFromSync = resolveReuseFromSuccessfulSync(bomId, fp)
            if (reuseFromSync != null) {
                val (reuseCode, reuseId) = reuseFromSync
                current = withHeaderM18Id(current, reuseCode, reuseId)
                logger.info(
                    "[M18 BOM] Same Code found; same details as prior sync — update headerCode={} m18Id={}",
                    reuseCode,
                    reuseId,
                )
                continue
            }

            // Code exists in M18 without header id — attach id and update (same code, not a new version).
            if (header.id.isNullOrBlank() && code.isNotEmpty() && code !in attachIdAttemptedForCode) {
                attachIdAttemptedForCode.add(code)
                val m18Id = m18BomHeaderLookupService.findM18IdByHeaderCode(code)
                if (m18Id != null) {
                    current = withHeaderM18Id(current, code, m18Id)
                    logger.info("[M18 BOM] Same Code found; attach M18 id={} for headerCode={}", m18Id, code)
                    continue
                }
            }

            // Same code already holds this exact content in our sync log — update that row.
            if (code.isNotEmpty()) {
                val logAtCode =
                    m18BomShopSyncLogRepository.findFirstByBomIdAndSyncedIsTrueAndM18HeaderCodeOrderByIdDesc(
                        bomId,
                        code,
                    )
                if (logAtCode?.requestFingerprint == fp && logAtCode.m18RecordId != null) {
                    current = withHeaderM18Id(current, code, logAtCode.m18RecordId!!)
                    logger.info(
                        "[M18 BOM] Same Code found; headerCode={} already has matching content — update m18Id={}",
                        code,
                        logAtCode.m18RecordId,
                    )
                    continue
                }
            }

            // Content differs from existing code — allocate next version (new code for new details).
            val bumped = bumpHeaderVersionForRetry(current) ?: break
            val bumpedCode = bumped.udfbomforshop.values.firstOrNull()?.code?.trim().orEmpty()
            if (bumpedCode.isNotEmpty()) {
                val logAtBumped =
                    m18BomShopSyncLogRepository.findFirstByBomIdAndSyncedIsTrueAndM18HeaderCodeOrderByIdDesc(
                        bomId,
                        bumpedCode,
                    )
                if (logAtBumped?.requestFingerprint == fp && logAtBumped.m18RecordId != null) {
                    current = withHeaderM18Id(bumped, bumpedCode, logAtBumped.m18RecordId!!)
                    logger.info(
                        "[M18 BOM] Version {} already synced with same details — update m18Id={}",
                        bumpedCode,
                        logAtBumped.m18RecordId,
                    )
                    continue
                }
            }
            current = bumped
            bumps++
            logger.info(
                "[M18 BOM] Same Code found; content changed — bump version (#$bumps) headerCode={}",
                bumpedCode,
            )
        }
        return M18BomForShopSaveAttemptResult(
            request = current,
            response = lastResp,
            callError = lastError,
            versionBumps = bumps,
        )
    }

    private fun resolveReuseFromSuccessfulSync(bomId: Long, fingerprint: String): Pair<String, Long>? {
        val match = findSuccessfulSyncByFingerprint(bomId, fingerprint) ?: return null
        val reuseCode = match.m18HeaderCode?.trim().orEmpty().ifEmpty { return null }
        val reuseId = match.m18RecordId?.takeIf { it > 0L } ?: return null
        return reuseCode to reuseId
    }

    open fun toJson(request: M18BomForShopSaveRequest): String =
        objectMapper.writeValueAsString(request)

    open fun toJson(response: GoodsReceiptNoteResponse): String =
        objectMapper.writeValueAsString(response)

    open fun saveBomForShop(request: M18BomForShopSaveRequest): GoodsReceiptNoteResponse? =
        saveBomForShopMono(request).block()

    open fun saveBomForShopMono(request: M18BomForShopSaveRequest): Mono<GoodsReceiptNoteResponse> {
        val queryParams = LinkedMultiValueMap<String, String>().apply {
            add("menuCode", menuCode)
        }
        val qs = queryParams.entries.flatMap { (k, v) -> v.map { "$k=$it" } }.joinToString("&")
        val fullUrl = "${m18Config.BASE_URL}$savePath?$qs"
        val bodyJson = objectMapper.writeValueAsString(request)
        logger.info("[M18 BOM udfBomForShop] PUT url=$fullUrl bodyUtf8Bytes=${bodyJson.toByteArray(StandardCharsets.UTF_8).size}")
        logger.debug("[M18 BOM udfBomForShop] PUT body=$bodyJson")
        return apiCallerService.putWithJsonString<GoodsReceiptNoteResponse>(
            urlPath = savePath,
            queryParams = queryParams,
            bodyJson = bodyJson,
        ).doOnSuccess { r ->
            logger.info("[M18 BOM udfBomForShop] response status=${r.status} recordId=${r.recordId} messages=${r.messages}")
        }.doOnError { e ->
            logger.error("[M18 BOM udfBomForShop] failed: ${e.message}", e)
        }
    }
}
