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.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.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 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
        private val m18Tz: ZoneId = ZoneId.of("Asia/Hong_Kong")

        private fun formatBomShopHeaderCode(itemCode: String, version: Int): String =
            "BOM${itemCode}V${version.toString().padStart(3, '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 V000 vs V001+.
     */
    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}Vnnn`.
     */
    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 lines = bom.bomMaterials
            .filter { it.deleted != true }
            .sortedBy { it.id ?: 0L }
            .mapIndexedNotNull { idx, mat -> toProductLine(mat, idx + 1) }

        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) ?: "000"
            return Triple(codeForUpdate, forcedRev, forcedId)
        }

        val latestLog = m18BomShopSyncLogRepository.findFirstByBomIdOrderByIdDesc(bomId)
        val prevFp = resolveLogFingerprint(latestLog)
        val prevCodeTrimmed = latestLog?.m18HeaderCode?.trim().orEmpty()
        val samePayload = latestLog != null && prevFp != null && prevFp == fp && prevCodeTrimmed.isNotEmpty()

        if (samePayload) {
            val revReuse = parseTrailingVersion(prevCodeTrimmed) ?: "000"
            return Triple(prevCodeTrimmed, revReuse, bomM18Id)
        }

        val maxV = maxVersionFromLogs(bomId, itemCode)
        val nextV = maxV + 1
        val newCode = formatBomShopHeaderCode(itemCode, nextV)
        val rev = nextV.toString().padStart(3, '0')
        return Triple(newCode, rev, null)
    }

    private fun resolveLogFingerprint(log: M18BomShopSyncLog?): String? {
        if (log == null) return null
        log.requestFingerprint?.takeIf { it.isNotBlank() }?.let { return it }
        val json = log.requestJson ?: return null
        return runCatching {
            val prev = objectMapper.readValue(json, M18BomForShopSaveRequest::class.java)
            contentFingerprint(prev)
        }.getOrNull()
    }

    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 Vnnn.
        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)?.padStart(3, '0')

    /**
     * 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): 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 latestPoLine = itemId?.let { id ->
            purchaseOrderLineRepository.findLatestLinesForBomM18ByItemId(id, PageRequest.of(0, 1)).firstOrNull()
        }
        val itemCode = mat.item?.code?.trim()?.takeIf { it.isNotEmpty() }
        val supplierM18Id = itemCode?.let { code ->
            purchaseOrderLineRepository.findLatestPoSupplierM18IdByItemCodeNative(code)
                .firstOrNull()
                ?.takeIf { it > 0L }
        }
        /**
         * M18 line price unit id ([M18PurchaseOrderPot.unitId]): prefer [PurchaseOrderLine.uomM18] from M18 PO sync,
         * else [PurchaseOrderLine.uom] when uomM18 is missing.
         */
        val purchaseUnitM18Id =
            latestPoLine?.uomM18?.m18Id?.takeIf { it > 0L }
                ?: latestPoLine?.uom?.m18Id?.takeIf { it > 0L }
        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,
        )
    }

    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
    }

    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)
        }
    }
}
