package com.ffii.fpsms.modules.jobOrder.service

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.common.SettingNames
import com.ffii.fpsms.modules.jobOrder.entity.JobOrder
import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository
import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendRequest
import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendResponse
import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2MarkInfoResponse
import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SettingsResponse
import com.ffii.fpsms.modules.jobOrder.web.model.LaserLastReceiveSuccessDto
import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest
import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest
import com.ffii.fpsms.modules.jobOrder.web.model.NgpclPushResponse
import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrJobOrderRequest
import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.settings.service.SettingsService
import com.ffii.fpsms.modules.stock.entity.StockInLineRepository
import com.ffii.fpsms.py.PrintedQtyByChannel
import com.ffii.fpsms.py.PyJobOrderListItem
import com.ffii.fpsms.py.PyJobOrderListMapper
import com.ffii.fpsms.py.PyJobOrderPrintSubmitService
import org.springframework.core.env.Environment
import org.springframework.stereotype.Service
import java.awt.Color
import java.awt.Font
import java.awt.Graphics2D
import java.awt.RenderingHints
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import javax.imageio.ImageIO
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.net.Socket
import java.net.InetSocketAddress
import java.io.PrintWriter
import java.io.DataOutputStream
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets

import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.ConnectException
import java.net.SocketTimeoutException
import org.springframework.core.io.ClassPathResource
import org.slf4j.LoggerFactory
import com.fasterxml.jackson.databind.ObjectMapper
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId

// Data class to store bitmap bytes + width (for XML)
data class BitmapResult(val bytes: ByteArray, val width: Int)

/** One Bag2-style laser TCP attempt (internal to [PlasticBagPrinterService]). */
private data class LaserBag2TcpResult(
    val success: Boolean,
    val message: String,
    val payload: String,
    val printerAck: String?,
    val receiveAcknowledged: Boolean,
)

@Service
class PlasticBagPrinterService(
    val jobOrderRepository: JobOrderRepository,
    private val jdbcDao: JdbcDao,
    private val stockInLineRepository: StockInLineRepository,
    private val itemUomService: ItemUomService,
    private val settingsService: SettingsService,
    private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService,
    private val environment: Environment,
    private val objectMapper: ObjectMapper,
) {
    private val logger = LoggerFactory.getLogger(javaClass)
    private val hongKongZoneId = ZoneId.of("Asia/Hong_Kong")

    companion object {
        private const val DEFAULT_LASER_BAG2_HOST = "192.168.18.77"
        private const val DEFAULT_LASER_BAG2_PORT = 45678
        private const val DEFAULT_LASER_ITEM_CODES = "PP1175"
        private const val PACKAGING_PROCESS_NAME = "包裝"
    }

    fun getLaserBag2Settings(): LaserBag2SettingsResponse {
        val hostRaw = settingsService.findByName(SettingNames.LASER_PRINT_HOST)
            .map { it.value }
            .orElse(null)
            ?.trim()
        val host = if (hostRaw.isNullOrBlank()) DEFAULT_LASER_BAG2_HOST else hostRaw
        val portStr = settingsService.findByName(SettingNames.LASER_PRINT_PORT)
            .map { it.value }
            .orElse(null)
            ?.trim()
            .orEmpty()
        val port = portStr.toIntOrNull() ?: DEFAULT_LASER_BAG2_PORT
        val itemCodes = settingsService.findByName(SettingNames.LASER_PRINT_ITEM_CODES)
            .map { it.value?.trim() ?: "" }
            .orElse(DEFAULT_LASER_ITEM_CODES)
        return LaserBag2SettingsResponse(
            host = host,
            port = port,
            itemCodes = itemCodes,
            lastReceiveSuccess = readLaserLastReceiveSuccessFromSettings(),
        )
    }

    private fun readLaserLastReceiveSuccessFromSettings(): LaserLastReceiveSuccessDto? {
        val raw = settingsService.findByName(SettingNames.LASER_PRINT_LAST_RECEIVE_SUCCESS)
            .map { it.value }
            .orElse(null)
            ?.trim()
            .orEmpty()
        if (raw.isBlank() || raw == "{}") return null
        return try {
            val dto = objectMapper.readValue(raw, LaserLastReceiveSuccessDto::class.java)
            if (dto.sentAt.isNullOrBlank()) null else dto
        } catch (e: Exception) {
            logger.warn("Could not parse LASER_PRINT.lastReceiveSuccess: {}", e.message)
            null
        }
    }

    private fun persistLaserLastReceiveSuccess(request: LaserBag2SendRequest, printerAck: String?) {
        val dto = LaserLastReceiveSuccessDto(
            jobOrderId = request.jobOrderId,
            jobOrderNo = request.jobOrderNo?.trim()?.takeIf { it.isNotEmpty() },
            lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() },
            itemId = request.itemId,
            stockInLineId = request.stockInLineId,
            printerAck = printerAck?.trim()?.takeIf { it.isNotEmpty() },
            sentAt = Instant.now().toString(),
            source = request.source?.trim()?.takeIf { it.isNotEmpty() } ?: "MANUAL",
        )
        val json = objectMapper.writeValueAsString(dto)
        settingsService.update(SettingNames.LASER_PRINT_LAST_RECEIVE_SUCCESS, json)
    }

    /**
     * Same criteria as [com.ffii.fpsms.py.PyController.listJobOrders], filtered by [SettingNames.LASER_PRINT_ITEM_CODES]
     * (comma-separated item codes). Blank / whitespace-only setting value means no filter (all packaging job orders).
     *
     * No Transactional annotation on this class: CGLIB subclassing plus Kotlin breaks constructor-injected deps on the proxy.
     * Rely on default OSIV for lazy BOM access within the HTTP request.
     */
    fun listLaserPrintJobOrders(planStart: LocalDate): List<PyJobOrderListItem> {
        val dayStart = planStart.atStartOfDay()
        val dayEndExclusive = planStart.plusDays(1).atStartOfDay()
        val orders = jobOrderRepository.findByDeletedFalseAndPlanStartBetweenAndBomProcessNameOrderByIdAsc(
            dayStart,
            dayEndExclusive,
            PACKAGING_PROCESS_NAME,
        )
        val opt = settingsService.findByName(SettingNames.LASER_PRINT_ITEM_CODES)
        val raw = if (opt.isPresent) opt.get().value else null
        val allowed = when {
            raw == null -> parseLaserItemCodeFilters(DEFAULT_LASER_ITEM_CODES)
            raw.trim().isEmpty() -> emptySet()
            else -> parseLaserItemCodeFilters(raw.trim())
        }
        val filtered = if (allowed.isEmpty()) {
            orders
        } else {
            orders.filter { jo ->
                val code = (jo.bom?.item?.code ?: jo.bom?.code)?.trim().orEmpty()
                allowed.contains(code)
            }
        }
        val ids = filtered.mapNotNull { it.id }
        val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids)
        return filtered.map { jo ->
            PyJobOrderListMapper.toListItem(jo, printed[jo.id!!], stockInLineRepository, itemUomService)
        }
    }

    private fun parseLaserItemCodeFilters(raw: String?): Set<String> {
        if (raw.isNullOrBlank()) return emptySet()
        return raw.split(',')
            .map { it.trim() }
            .filter { it.isNotEmpty() }
            .toSet()
    }

    /**
     * Bag2.py [send_job_to_laser] / [send_job_to_laser_with_retry]: UTF-8 TCP payload and optional ack read.
     */
    fun sendLaserBag2Job(request: LaserBag2SendRequest): LaserBag2SendResponse {
        val ip = (request.printerIp?.trim()?.takeIf { it.isNotEmpty() }
            ?: resolveLaserBag2Host()).trim()
        val port = request.printerPort ?: resolveLaserBag2Port()
        val first = sendLaserBag2TcpOnce(
            ip = ip,
            port = port,
            itemId = request.itemId,
            stockInLineId = request.stockInLineId,
            itemCode = request.itemCode,
            itemName = request.itemName,
        )
        val response = if (first.success) {
            LaserBag2SendResponse(
                success = true,
                message = first.message,
                payloadSent = first.payload,
                printerAck = first.printerAck,
                receiveAcknowledged = first.receiveAcknowledged,
            )
        } else {
            val second = sendLaserBag2TcpOnce(
                ip = ip,
                port = port,
                itemId = request.itemId,
                stockInLineId = request.stockInLineId,
                itemCode = request.itemCode,
                itemName = request.itemName,
            )
            LaserBag2SendResponse(
                success = second.success,
                message = second.message,
                payloadSent = second.payload,
                printerAck = second.printerAck,
                receiveAcknowledged = second.receiveAcknowledged,
            )
        }
        if (response.success && response.receiveAcknowledged) {
            try {
                persistLaserLastReceiveSuccess(request, response.printerAck)
            } catch (e: Exception) {
                logger.warn("Could not persist laser last receive success: {}", e.message)
            }
        }
        return response
    }

    private fun resolveLaserBag2Host(): String {
        val v = settingsService.findByName(SettingNames.LASER_PRINT_HOST)
            .map { it.value }
            .orElse(null)
            ?.trim()
            .orEmpty()
        return v.ifBlank { DEFAULT_LASER_BAG2_HOST }
    }

    private fun resolveLaserBag2Port(): Int {
        val v = settingsService.findByName(SettingNames.LASER_PRINT_PORT)
            .map { it.value }
            .orElse(null)
            ?.trim()
            .orEmpty()
        return v.toIntOrNull() ?: DEFAULT_LASER_BAG2_PORT
    }

    /**
     * TCP connect probe to configured [LASER_PRINT.host] / [LASER_PRINT.port] (same endpoint as [sendLaserBag2Job] / auto-send).
     * @return Triple(ok, host, port)
     */
    fun probeLaserBag2Tcp(): Triple<Boolean, String, Int> {
        val host = resolveLaserBag2Host()
        val port = resolveLaserBag2Port()
        val (ok, _) = checkTcpPrinter(host, port, "Laser")
        return Triple(ok, host, port)
    }

    /**
     * Returns true when [SettingNames.LASER_PRINT_LAST_RECEIVE_SUCCESS] has a [sentAt] on [date]
     * (interpreted in Asia/Hong_Kong local date).
     */
    fun hasLaserBag2ReceiveOnDate(date: LocalDate): Boolean {
        val last = readLaserLastReceiveSuccessFromSettings() ?: return false
        val sentAtRaw = last.sentAt?.trim().orEmpty()
        if (sentAtRaw.isEmpty()) return false
        return try {
            val sentDateHk = Instant.parse(sentAtRaw).atZone(hongKongZoneId).toLocalDate()
            sentDateHk == date
        } catch (e: Exception) {
            logger.warn("Could not parse LASER_PRINT.lastReceiveSuccess.sentAt='{}': {}", sentAtRaw, e.message)
            false
        }
    }

    private fun sendLaserBag2TcpOnce(
        ip: String,
        port: Int,
        itemId: Long?,
        stockInLineId: Long?,
        itemCode: String?,
        itemName: String?,
    ): LaserBag2TcpResult {
        val codeStr = (itemCode ?: "").trim().replace(";", ",")
        val nameStr = (itemName ?: "").trim().replace(";", ",")
        val payload = if (itemId != null && stockInLineId != null) {
            "{\"itemId\":$itemId,\"stockInLineId\":$stockInLineId};$codeStr;$nameStr;;"
        } else {
            "0;$codeStr;$nameStr;;"
        }
        val bytes = payload.toByteArray(StandardCharsets.UTF_8)
        var socket: Socket? = null
        try {
            socket = Socket()
            socket.soTimeout = 3000
            socket.connect(InetSocketAddress(ip, port), 3000)
            val out = socket.getOutputStream()
            out.write(bytes)
            out.flush()
            // Half-close the write side so the peer sees a clean end-of-send before we read the ack.
            // Abrupt full socket close right after read often makes EZCAD log "Remote TCP client disconnected".
            try {
                socket.shutdownOutput()
            } catch (_: Exception) {
            }
            var ackRaw: String? = null
            var receiveAck = false
            socket.soTimeout = 500
            try {
                val buf = ByteArray(4096)
                val n = socket.getInputStream().read(buf)
                if (n > 0) {
                    ackRaw = String(buf, 0, n, StandardCharsets.UTF_8).trim()
                    val ackLower = ackRaw.lowercase()
                    if (ackLower.contains("receive") && !ackLower.contains("invalid")) {
                        receiveAck = true
                        logger.info(
                            "Laser TCP ack (receive): {}:{} sent={} ack={}",
                            ip,
                            port,
                            payload,
                            ackRaw,
                        )
                    } else if (ackRaw.isNotEmpty()) {
                        logger.info(
                            "Laser TCP response: {}:{} sent={} ack={}",
                            ip,
                            port,
                            payload,
                            ackRaw,
                        )
                    }
                }
            } catch (_: SocketTimeoutException) {
                // Same as Python Bag3: ignore read timeout, payload was still sent
            }
            val msg = if (receiveAck) {
                "已送出激光機：$payload（已確認）"
            } else {
                "已送出激光機：$payload"
            }
            return LaserBag2TcpResult(true, msg, payload, ackRaw, receiveAck)
        } catch (e: ConnectException) {
            return LaserBag2TcpResult(false, "無法連線至 $ip:$port，請確認激光機已開機且 IP 正確。", payload, null, false)
        } catch (e: SocketTimeoutException) {
            return LaserBag2TcpResult(false, "連線逾時 ($ip:$port)，請檢查網路與連接埠。", payload, null, false)
        } catch (e: Exception) {
            return LaserBag2TcpResult(false, "激光機送出失敗：${e.message}", payload, null, false)
        } finally {
            try {
                Thread.sleep(100)
            } catch (_: InterruptedException) {
                Thread.currentThread().interrupt()
            }
            try {
                socket?.close()
            } catch (_: Exception) {
            }
        }
    }

    /**
     * TCP text queries on the same [LASER_PRINT] host/port as Bag2 send (UTF-8, line-terminated commands).
     * Uses one connection per command so the plugin can answer independently.
     */
    fun getLaserBag2MarkInfo(): LaserBag2MarkInfoResponse {
        val host = resolveLaserBag2Host()
        val port = resolveLaserBag2Port()
        val (dataVal, dataErr) = sendLaserBag2QueryCommand(host, port, "GetMarkData")
        val (statusVal, statusErr) = sendLaserBag2QueryCommand(host, port, "GetMarkStatus")
        val (countVal, countErr) = sendLaserBag2QueryCommand(host, port, "GetMarkedCount")
        val statusInt = statusVal?.let { parseFirstIntToken(it) }
        val countInt = countVal?.let { parseFirstIntToken(it) }
        val errs = listOfNotNull(
            dataErr?.let { "GetMarkData：$it" },
            statusErr?.let { "GetMarkStatus：$it" },
            countErr?.let { "GetMarkedCount：$it" },
        )
        return LaserBag2MarkInfoResponse(
            host = host,
            port = port,
            markData = dataVal,
            markStatus = statusInt,
            markStatusLabel = markStatusToLabel(statusInt),
            markedCount = countInt,
            rawMarkStatus = statusVal,
            rawMarkedCount = countVal,
            error = errs.joinToString("；").ifBlank { null },
        )
    }

    /**
     * Sends [command] as UTF-8 with LF, half-closes output, reads one response chunk (same pattern as job send ack read).
     * @return Pair(trimmed response or null, error message or null)
     */
    private fun sendLaserBag2QueryCommand(ip: String, port: Int, command: String): Pair<String?, String?> {
        var socket: Socket? = null
        try {
            socket = Socket()
            socket.soTimeout = 3000
            socket.connect(InetSocketAddress(ip, port), 3000)
            val line = command.trim() + "\n"
            val out = socket.getOutputStream()
            out.write(line.toByteArray(StandardCharsets.UTF_8))
            out.flush()
            try {
                socket.shutdownOutput()
            } catch (_: Exception) {
            }
            socket.soTimeout = 2500
            val buf = ByteArray(8192)
            val n = socket.getInputStream().read(buf)
            val raw = if (n > 0) {
                String(buf, 0, n, StandardCharsets.UTF_8).trim()
            } else {
                ""
            }
            return raw to null
        } catch (e: ConnectException) {
            return null to "無法連線"
        } catch (e: SocketTimeoutException) {
            return null to "讀取逾時"
        } catch (e: Exception) {
            return null to (e.message ?: e.javaClass.simpleName)
        } finally {
            try {
                socket?.close()
            } catch (_: Exception) {
            }
        }
    }

    private fun parseFirstIntToken(text: String): Int? {
        val t = text.trim()
        if (t.isEmpty()) return null
        t.toIntOrNull()?.let { return it }
        return Regex("""-?\d+""").find(t)?.value?.toIntOrNull()
    }

    private fun markStatusToLabel(v: Int?): String? = when (v) {
        0 -> "待機"
        1 -> "打標中"
        2 -> "其他狀態"
        else -> null
    }

    fun generatePrintJobBundle(
        itemCode: String,
        lotNo: String,
        expiryDate: String,
        productName: String
    ): ByteArray {
        val baos = ByteArrayOutputStream()
        ZipOutputStream(baos).use { zos ->
            // Reduced heights by ~80% to hit the 12KB-17KB file size target
            // These heights (100-180) will produce much smaller BMP files
            val productBmp = createMonochromeBitmap(productName, 300)
            val codeBmp = createMonochromeBitmap(itemCode, 200)
            val expBmp = createMonochromeBitmap(expiryDate, 180)
            val qrBmp = createQrCodeBitmap("$itemCode|$lotNo|$expiryDate", 600)
            val lotBmp = createMonochromeBitmap(lotNo, 180)

            // All file names now prefixed with "TT_$itemCode"
            val prefix = "TT_$itemCode"
            val nameFile = "${prefix}_product.bmp"
            val codeFile = "${prefix}_code.bmp"
            val expFile = "${prefix}_expiry.bmp"
            val qrFile  = "${prefix}_qr.bmp"
            val lotFile = "${prefix}_lot.bmp"

            addToZip(zos, nameFile, productBmp.bytes)
            addToZip(zos, codeFile, codeBmp.bytes)
            addToZip(zos, lotFile, lotBmp.bytes)
            addToZip(zos, qrFile,  qrBmp.bytes)

            val imageXml = """
                <Legend type="Image.V1" PaperColor="White" BackgroundColor="DarkGray">
                    <DeviceInfo DeviceURI="NGEDriver[SDX40c.cadb94e3-677c-4bcb-9c80-88bf9526d6fe]/">
                        <DeviceName />
                    </DeviceInfo>
                    <Description />
                    <Mirrored>false</Mirrored>
                    <Orientation>DEG_0</Orientation>
                    <Width>5300</Width>
                    <Height>7100</Height>
                    <FieldList>
                        <Logo type="Logo.V1">
                            <Name>LOGO</Name>
                            <ID>4</ID>
                            <Geometry>
                                <X>250</X>
                                <Y>500</Y>
                                <Rotation>0</Rotation>
                            </Geometry>
                            <FieldColor>BLACK</FieldColor>
                            <FileName>$nameFile</FileName>
                            <Width>4914</Width>
                            <Height>845</Height>
                        </Logo>
                        <Logo type="Logo.V1">
                            <Name>LOGO_2</Name>
                            <ID>5</ID>
                            <Geometry>
                                <X>1000</X>
                                <Y>1250</Y>
                                <Rotation>0</Rotation>
                            </Geometry>
                            <FieldColor>BLACK</FieldColor>
                            <FileName>$codeFile</FileName>
                            <Width>3205</Width>
                            <Height>1173</Height>
                        </Logo>
                        <Logo type="Logo.V1">
                            <Name>LOGO_3</Name>
                            <ID>6</ID>
                            <Geometry>
                                <X>500</X>
                                <Y>2500</Y>
                                <Rotation>0</Rotation>
                            </Geometry>
                            <FieldColor>BLACK</FieldColor>
                            <FileName>$expFile</FileName>
                            <Width>4203</Width>
                            <Height>1173</Height>
                        </Logo>
                        <Logo type="Logo.V1">
                            <Name>LOGO_4</Name>
                            <ID>7</ID>
                            <Geometry>
                                <X>500</X>
                                <Y>3750</Y>
                                <Rotation>0</Rotation>
                            </Geometry>
                            <FieldColor>BLACK</FieldColor>
                            <FileName>$qrFile</FileName>
                            <Width>4353</Width>
                            <Height>3000</Height>
                        </Logo>
                    </FieldList>
                </Legend>""".trimIndent()

            // Image and Job files now also use TT_$itemCode
            addToZip(zos, "$prefix.image", imageXml.toByteArray(Charsets.UTF_8))

            val jobXml = """
                <Job>
                    <ImageFileName>$prefix.image</ImageFileName>
                </Job>""".trimIndent()

            addToZip(zos, "$prefix.job", jobXml.toByteArray(Charsets.UTF_8))
        }
        return baos.toByteArray()
    }

    fun generateOnPackQrZip(jobOrders: List<OnPackQrJobOrderRequest>): ByteArray {
        val normalizedJobOrders = jobOrders
            .map {
                OnPackQrJobOrderRequest(
                    jobOrderId = it.jobOrderId,
                    itemCode = it.itemCode.trim(),
                )
            }
            .filter { it.jobOrderId > 0 && it.itemCode.isNotBlank() }

        require(normalizedJobOrders.isNotEmpty()) { "No job orders provided" }

        // Safety check: export only supports job orders whose BOM contains process "包裝".
        val requestedJobOrderIds = normalizedJobOrders.map { it.jobOrderId }.distinct()
        val allowedRows = jdbcDao.queryForList(
            """
            SELECT DISTINCT jo.id AS jobOrderId
            FROM job_order jo
            JOIN bom b ON b.id = jo.bomId
            JOIN bom_process bp ON bp.bomId = b.id
            JOIN process p ON p.id = bp.processId
            WHERE jo.id IN (:jobOrderIds)
              AND jo.deleted = 0
              AND p.name = :processName
            """.trimIndent(),
            mapOf(
                "jobOrderIds" to requestedJobOrderIds,
                "processName" to "包裝",
            )
        )
        val allowedJobOrderIds = allowedRows
            .mapNotNull { (it["jobOrderId"] as? Number)?.toLong() }
            .toSet()
        val packagingJobOrders = normalizedJobOrders.filter { it.jobOrderId in allowedJobOrderIds }
        require(packagingJobOrders.isNotEmpty()) { "No 包裝 process job orders found for export" }

        val exportItemsRaw = packagingJobOrders
            .groupBy { it.itemCode.trim().lowercase() }
            .mapNotNull { (codeLower, orders) ->
                val order = orders.firstOrNull() ?: return@mapNotNull null
                val stockInLine = stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(order.jobOrderId)
                    ?: return@mapNotNull null
                val itemId = stockInLine.item?.id ?: return@mapNotNull null
                val stockInLineId = stockInLine.id ?: return@mapNotNull null
                Triple(codeLower, itemId, stockInLineId)
            }

        require(exportItemsRaw.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" }

        val codesUpper = exportItemsRaw.map { it.first.uppercase() }.toSet()
        val allowedBmpCodes = codesOnPackMatchingTemplateType(codesUpper, "bmp")
        val exportItems = exportItemsRaw.filter { allowedBmpCodes.contains(it.first.uppercase()) }

        require(exportItems.isNotEmpty()) { "No OnPack QR (bmp) rows in onpack_qr for the selected job orders, or no matching templates" }

        val baos = ByteArrayOutputStream()
        ZipOutputStream(baos).use { zos ->
            val addedEntries = linkedSetOf<String>()
            exportItems.forEach { (codeLower, itemId, stockInLineId) ->
                val imageTemplate = loadOnPackImageTemplateOrNull(codeLower) ?: return@forEach

                val qrContent = """{"itemId": $itemId, "stockInLineId": $stockInLineId}"""
                // Target approximately 470x389 BMP, but with larger visible QR and very little vertical whitespace.
                // Width  = 386 + (42 * 2) = 470
                // Height = 386 + (1  * 2) = 388 (~389)
                val bmp = createQrCodeBitmap(qrContent, contentSize = 386, horizontalPadding = 42, verticalPadding = 1)
                val qrBmpFileName = "${codeLower}qr.bmp"
                val imageFileName = "$codeLower.image"
                val imageContent = withOnPackLogo4Bmp(imageTemplate, qrBmpFileName)

                if (addedEntries.add(qrBmpFileName)) {
                    addToZip(zos, qrBmpFileName, bmp.bytes)
                }
                if (addedEntries.add(imageFileName)) {
                    addToZip(zos, imageFileName, imageContent)
                }
            }

            require(addedEntries.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" }
        }

        return baos.toByteArray()
    }

    /**
     * OnPack2023 檸檬機: templates under classpath `onpack2030_2/{code}.image` with embedded QR (Static text).
     * Only replaces `<Text>...</Text>` under `<Static type="StaticSrc.V1">` with JSON payload. No separate .bmp in zip.
     */
    fun generateOnPackQrTextZip(jobOrders: List<OnPackQrJobOrderRequest>): ByteArray {
        val normalizedJobOrders = jobOrders
            .map {
                OnPackQrJobOrderRequest(
                    jobOrderId = it.jobOrderId,
                    itemCode = it.itemCode.trim(),
                )
            }
            .filter { it.jobOrderId > 0 && it.itemCode.isNotBlank() }

        require(normalizedJobOrders.isNotEmpty()) { "No job orders provided" }

        val requestedJobOrderIds = normalizedJobOrders.map { it.jobOrderId }.distinct()
        val allowedRows = jdbcDao.queryForList(
            """
            SELECT DISTINCT jo.id AS jobOrderId
            FROM job_order jo
            JOIN bom b ON b.id = jo.bomId
            JOIN bom_process bp ON bp.bomId = b.id
            JOIN process p ON p.id = bp.processId
            WHERE jo.id IN (:jobOrderIds)
              AND jo.deleted = 0
              AND p.name = :processName
            """.trimIndent(),
            mapOf(
                "jobOrderIds" to requestedJobOrderIds,
                "processName" to "包裝",
            )
        )
        val allowedJobOrderIds = allowedRows
            .mapNotNull { (it["jobOrderId"] as? Number)?.toLong() }
            .toSet()
        val packagingJobOrders = normalizedJobOrders.filter { it.jobOrderId in allowedJobOrderIds }
        require(packagingJobOrders.isNotEmpty()) { "No 包裝 process job orders found for export" }

        val exportItemsRaw = packagingJobOrders
            .groupBy { it.itemCode.trim().lowercase() }
            .mapNotNull { (codeLower, orders) ->
                val order = orders.firstOrNull() ?: return@mapNotNull null
                val stockInLine = stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(order.jobOrderId)
                    ?: return@mapNotNull null
                val itemId = stockInLine.item?.id ?: return@mapNotNull null
                val stockInLineId = stockInLine.id ?: return@mapNotNull null
                Triple(codeLower, itemId, stockInLineId)
            }

        require(exportItemsRaw.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" }

        val codesUpper = exportItemsRaw.map { it.first.uppercase() }.toSet()
        val allowedTextCodes = codesOnPackMatchingTemplateType(codesUpper, "text")
        val exportItems = exportItemsRaw.filter { allowedTextCodes.contains(it.first.uppercase()) }

        require(exportItems.isNotEmpty()) { "No OnPack QR (text) rows in onpack_qr for the selected job orders, or no matching templates" }

        val baos = ByteArrayOutputStream()
        ZipOutputStream(baos).use { zos ->
            val addedEntries = linkedSetOf<String>()
            exportItems.forEach { (codeLower, itemId, stockInLineId) ->
                val imageTemplate = loadOnPack2030_2ImageTemplateOrNull(codeLower) ?: run {
                    logger.warn("OnPack text ZIP: missing classpath template onpack2030_2/{}.image", codeLower.uppercase())
                    return@forEach
                }
                val imageFileName = "${codeLower.uppercase()}.image"
                val imageContent = withOnPackStaticQrText(codeLower, imageTemplate, itemId, stockInLineId)
                if (addedEntries.add(imageFileName)) {
                    addToZip(zos, imageFileName, imageContent)
                }
                val decodedXmlForAssets = decodeOnPackImageTemplateForTextEdit(imageTemplate).first
                extractLogoBmpFileNamesFromOnPackImageXml(decodedXmlForAssets).forEach { bmpName ->
                    if (!addedEntries.add(bmpName)) return@forEach
                    val bmpBytes = loadOnPack2030_2AssetOrNull(bmpName) ?: run {
                        logger.warn("OnPack text ZIP: missing classpath asset onpack2030_2/{}", bmpName)
                        return@forEach
                    }
                    addToZip(zos, bmpName, bmpBytes)
                }
                val jobFileName = "${codeLower.lowercase()}.job"
                loadOnPack2030_2AssetOrNull(jobFileName)?.let { jobBytes ->
                    if (addedEntries.add(jobFileName)) {
                        addToZip(zos, jobFileName, jobBytes)
                    }
                }
            }
            require(addedEntries.isNotEmpty()) { "No OnPack text template files could be generated for the selected date" }
        }
        return baos.toByteArray()
    }

    /**
     * Builds the same ZIP as [generateOnPackQrTextZip] and POSTs it to [ngpcl.push-url] (application/zip).
     * When the URL is blank, returns [NgpclPushResponse] with pushed=false so callers can fall back to manual download.
     */
    fun pushOnPackQrTextZipToNgpcl(jobOrders: List<OnPackQrJobOrderRequest>): NgpclPushResponse {
        val url = (environment.getProperty("ngpcl.push-url") ?: "").trim()
        if (url.isEmpty()) {
            return NgpclPushResponse(
                pushed = false,
                message = "NGPCL push URL not configured. Set ngpcl.push-url or NGPCL_PUSH_URL, or download the ZIP and transfer loose files manually.",
            )
        }
        val zipBytes = try {
            generateOnPackQrTextZip(jobOrders)
        } catch (e: Exception) {
            logger.warn("OnPack text ZIP generation failed before NGPCL push", e)
            return NgpclPushResponse(
                pushed = false,
                message = e.message ?: "ZIP generation failed",
            )
        }
        return try {
            val client = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(30))
                .build()
            val request = HttpRequest.newBuilder()
                .uri(URI.create(url))
                .timeout(Duration.ofMinutes(2))
                .header("Content-Type", "application/zip")
                .POST(HttpRequest.BodyPublishers.ofByteArray(zipBytes))
                .build()
            val response = client.send(request, HttpResponse.BodyHandlers.ofString())
            val body = response.body()
            if (response.statusCode() in 200..299) {
                NgpclPushResponse(
                    pushed = true,
                    message = "NGPCL accepted (HTTP ${response.statusCode()})${if (body.isNotBlank()) ": ${body.take(300)}" else ""}",
                )
            } else {
                NgpclPushResponse(
                    pushed = false,
                    message = "NGPCL returned HTTP ${response.statusCode()}: ${body.take(500)}",
                )
            }
        } catch (e: Exception) {
            logger.error("NGPCL push failed", e)
            NgpclPushResponse(
                pushed = false,
                message = e.message ?: "Push failed: ${e.javaClass.simpleName}",
            )
        }
    }

    /**
     * Returns uppercase item codes present in `onpack_qr` with the given [templateType] (`bmp` or `text`).
     * Empty or NULL `template_type` is treated as `bmp` for backward compatibility.
     */
    private fun codesOnPackMatchingTemplateType(codesUpper: Set<String>, templateType: String): Set<String> {
        if (codesUpper.isEmpty()) return emptySet()
        val normalizedType = templateType.trim().lowercase()
        val sql = when (normalizedType) {
            "bmp" -> """
                SELECT DISTINCT UPPER(TRIM(code)) AS code
                FROM onpack_qr
                WHERE UPPER(TRIM(code)) IN (:codes)
                  AND COALESCE(NULLIF(TRIM(template_type), ''), 'bmp') = 'bmp'
                """.trimIndent()
            "text" -> """
                SELECT DISTINCT UPPER(TRIM(code)) AS code
                FROM onpack_qr
                WHERE UPPER(TRIM(code)) IN (:codes)
                  AND LOWER(TRIM(template_type)) = 'text'
                """.trimIndent()
            else -> return emptySet()
        }
        val rows = jdbcDao.queryForList(sql, mapOf("codes" to codesUpper.toList()))
        return rows.mapNotNull { it["code"]?.toString()?.trim()?.uppercase() }.toSet()
    }

    private fun loadOnPackImageTemplateOrNull(codeLower: String): ByteArray? {
        val resourcePath = "onpack2030/${codeLower}.image"
        val resource = ClassPathResource(resourcePath)
        if (!resource.exists()) return null
        return resource.inputStream.use { it.readBytes() }
    }

    /** Template files on classpath use uppercase code, e.g. `onpack2030_2/PP1175.image`. */
    private fun loadOnPack2030_2ImageTemplateOrNull(codeLower: String): ByteArray? {
        val resourcePath = "onpack2030_2/${codeLower.uppercase()}.image"
        val resource = ClassPathResource(resourcePath)
        if (!resource.exists()) return null
        return resource.inputStream.use { it.readBytes() }
    }

    private fun loadOnPack2030_2AssetOrNull(fileName: String): ByteArray? {
        val safe = fileName.trim().replace(Regex("""[\\/]+"""), "").ifBlank { return null }
        val resource = ClassPathResource("onpack2030_2/$safe")
        if (!resource.exists()) return null
        return resource.inputStream.use { it.readBytes() }
    }

    /** Collect `<FileName>xxx.bmp</FileName>` inside each `<Logo>...</Logo>` block (decoded template XML). */
    private fun extractLogoBmpFileNamesFromOnPackImageXml(xml: String): Set<String> {
        val out = linkedSetOf<String>()
        Regex("""(?s)<Logo[^>]*>[\s\S]*?</Logo>""").findAll(xml).forEach { block ->
            Regex("""<FileName>([^<]+\.bmp)</FileName>""", RegexOption.IGNORE_CASE).findAll(block.value).forEach { m ->
                out.add(m.groupValues[1].trim())
            }
        }
        return out
    }

    private fun withOnPackLogo4Bmp(imageBytes: ByteArray, qrBmpFileName: String): ByteArray {
        // Use ISO-8859-1 one-byte mapping so all original bytes are preserved,
        // while replacing only ASCII XML fragment for LOGO_4 filename.
        val oneByteText = String(imageBytes, StandardCharsets.ISO_8859_1)
        val replaced = oneByteText.replace(
            Regex("""(<Name>\s*LOGO_4\s*</Name>[\s\S]*?<FileName>)([^<]+)(</FileName>)"""),
            "$1$qrBmpFileName$3",
        )
        return replaced.toByteArray(StandardCharsets.ISO_8859_1)
    }

    /**
     * `.image` templates from Windows tools are often UTF-16 LE with BOM; decoding as ISO-8859-1 breaks
     * substring/regex matching (`hasNameQr=false` while XML is valid).
     */
    private fun decodeOnPackImageTemplateForTextEdit(bytes: ByteArray): Pair<String, (String) -> ByteArray> {
        val bomUtf16Le = byteArrayOf(0xFF.toByte(), 0xFE.toByte())
        val bomUtf16Be = byteArrayOf(0xFE.toByte(), 0xFF.toByte())
        val bomUtf8 = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte())

        when {
            bytes.size >= 2 && bytes[0] == bomUtf16Le[0] && bytes[1] == bomUtf16Le[1] -> {
                val body = bytes.copyOfRange(2, bytes.size)
                val text = String(body, StandardCharsets.UTF_16LE)
                return text to { s -> bomUtf16Le + s.toByteArray(StandardCharsets.UTF_16LE) }
            }
            bytes.size >= 2 && bytes[0] == bomUtf16Be[0] && bytes[1] == bomUtf16Be[1] -> {
                val body = bytes.copyOfRange(2, bytes.size)
                val text = String(body, StandardCharsets.UTF_16BE)
                return text to { s -> bomUtf16Be + s.toByteArray(StandardCharsets.UTF_16BE) }
            }
            bytes.size >= 3 && bytes[0] == bomUtf8[0] && bytes[1] == bomUtf8[1] && bytes[2] == bomUtf8[2] -> {
                val body = bytes.copyOfRange(3, bytes.size)
                val text = String(body, StandardCharsets.UTF_8)
                return text to { s -> bomUtf8 + s.toByteArray(StandardCharsets.UTF_8) }
            }
            // UTF-16 LE without BOM: "<" == 0x3C 0x00
            bytes.size >= 2 && bytes[0] == 0x3C.toByte() && bytes[1] == 0x00.toByte() -> {
                val text = String(bytes, StandardCharsets.UTF_16LE)
                return text to { s -> s.toByteArray(StandardCharsets.UTF_16LE) }
            }
            else -> {
                val utf8 = String(bytes, StandardCharsets.UTF_8)
                return utf8 to { s -> s.toByteArray(StandardCharsets.UTF_8) }
            }
        }
    }

    private fun withOnPackStaticQrText(forCode: String, imageBytes: ByteArray, itemId: Long, stockInLineId: Long): ByteArray {
        val payload = """{"itemId": $itemId, "stockInLineId": $stockInLineId}"""
        val (oneByteText, encodeBack) = decodeOnPackImageTemplateForTextEdit(imageBytes)
        // Must NOT use Static[^>]*StaticSrc — [^>]* already eats type='StaticSrc.V1', so StaticSrc can never match.
        // Match the attribute form: <Static type='StaticSrc.V1'> or type="StaticSrc.V1"
        val staticQrTextOpen =
            """<Static\s+type=(?:'|\u0022)StaticSrc\.V1(?:'|\u0022)[^>]*>\s*<Text>"""
        val regexWithComment = Regex(
            """(?s)(<!--\s*2\.\s*QR\s+Code\s*-\s*Change\s+this\s+part\s+daily\s+for\s+dynamic\s+JSON\s*-->[\s\S]*?<Name>QR</Name>[\s\S]*?)($staticQrTextOpen)([^<]*)(</Text>)""",
        )
        val regexFallbackNameQr = Regex(
            """(?s)(<Name>QR</Name>[\s\S]*?)($staticQrTextOpen)([^<]*)(</Text>)""",
        )
        val afterComment = oneByteText.replace(regexWithComment, "$1$2$payload$4")
        val usedComment = afterComment !== oneByteText
        val replaced = if (usedComment) {
            afterComment
        } else {
            oneByteText.replace(regexFallbackNameQr, "$1$2$payload$4")
        }

        if (replaced === oneByteText) {
            logger.warn(
                "OnPack text QR: NO regex match for code={} itemId={} stockInLineId={} templateBytes={} hasNameQr={} hasStaticSrc={} staticPatternSample={}",
                forCode,
                itemId,
                stockInLineId,
                imageBytes.size,
                oneByteText.contains("<Name>QR</Name>"),
                oneByteText.contains("StaticSrc.V1"),
                previewOneLineForLog(oneByteText, 600),
            )
        }
        val withQrCellWidth = replaceQrV1CellWidth67To50(replaced)
        return encodeBack(withQrCellWidth)
    }

    /** First `<CellWidth>67</CellWidth>` inside `<QR type='QR.V1'>` → 50 (export tuning). */
    private fun replaceQrV1CellWidth67To50(xml: String): String {
        return Regex(
            """(?s)(<QR\s+type=['"]QR\.V1['"][^>]*>[\s\S]*?<CellWidth>)(67)(</CellWidth>)""",
        ).replace(xml) { m ->
            "${m.groupValues[1]}50${m.groupValues[3]}"
        }
    }

    /** Single-line preview for logs (avoids multiline noise). */
    private fun previewOneLineForLog(s: String, maxLen: Int): String {
        val collapsed = s.replace(Regex("\\s+"), " ").trim()
        return if (collapsed.length <= maxLen) collapsed else collapsed.take(maxLen) + "…"
    }

    private fun createMonochromeBitmap(text: String, targetHeight: Int): BitmapResult {
        // Step 1: Measure text width with temporary image
        val tempImg = BufferedImage(1, 1, BufferedImage.TYPE_BYTE_BINARY)
        val g2dTemp = tempImg.createGraphics()
        val font = Font("SimSun", Font.BOLD, (targetHeight * 0.92).toInt())
        g2dTemp.font = font
        val metrics = g2dTemp.getFontMetrics(font)
        val textWidth = metrics.stringWidth(text).coerceAtLeast(1)
        g2dTemp.dispose()

        // Step 2: Create actual image 
        // CHANGE 1: Changed 'val' to 'var' so we can reassign it on line 84
        var img = BufferedImage(textWidth, targetHeight, BufferedImage.TYPE_BYTE_BINARY)
        
        // CHANGE 2: Removed 'val g2d =' because it was unused
        img.createGraphics().apply {
            color = Color.WHITE
            fillRect(0, 0, textWidth, targetHeight)
            color = Color.BLACK
            this.font = font // use 'this.font' to be explicit inside apply
            setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF)
            drawString(text, 0, metrics.ascent)
            dispose()
        }

        // Step 3: Crop excess white space
        // This line (84) will now work because 'img' is a 'var'
        img = cropToContent(img)

        val baos = ByteArrayOutputStream()
        ImageIO.write(img, "bmp", baos)
        return BitmapResult(baos.toByteArray(), img.width)
    }
    
    fun testSmartDateX40Tcp(printerIp: String, port: Int = 3001, timeoutMs: Int = 3000): Pair<Boolean, String> {
        return try {
            Socket().use { socket ->
                socket.connect(InetSocketAddress(printerIp, port), timeoutMs)
                socket.soTimeout = 2000  // read timeout

                val output = PrintWriter(socket.getOutputStream(), true)
                output.println("~VR")  // or any simple command your printer understands
                output.flush()

                val input = BufferedReader(InputStreamReader(socket.getInputStream()))
                val response = try {
                    input.readLine() ?: "No response line"
                } catch (e: java.net.SocketTimeoutException) {
                    "No response within timeout"
                }

                if (response.isNotBlank() && response != "No response within timeout") {
                    true to "Connected successfully. Response: $response"
                } else {
                    true to "Connected (port open), but no meaningful response"
                }
            }
        } catch (e: SocketTimeoutException) {
            false to "Connection timed out (port may be closed or printer unreachable)"
        } catch (e: ConnectException) {
            false to "Connection refused (port closed or wrong IP)"
        } catch (e: Exception) {
            false to "Test failed: ${e.javaClass.simpleName} - ${e.message}"
        }
    }

    fun checkPrinterConnection(
        printerType: String,
        printerIp: String?,
        printerPort: Int?,
        labelCom: String?,
    ): Pair<Boolean, String> {
        return when (printerType.lowercase()) {
            "dataflex" -> checkTcpPrinter(printerIp, printerPort ?: 3008, "DataFlex")
            // - /bagPrint: sends browser localStorage laser IP (may differ from DB — second machine).
            // - /laserPrint: omit printerIp → use DB LASER_PRINT.* (same as sendLaserBag2Job).
            // - Explicit empty string → not configured (do not fall back to DB).
            "laser" -> {
                val trimmed = printerIp?.trim().orEmpty()
                when {
                    trimmed.isNotEmpty() -> checkTcpPrinter(trimmed, printerPort ?: 45678, "Laser")
                    printerIp == null -> checkTcpPrinter(resolveLaserBag2Host(), resolveLaserBag2Port(), "Laser")
                    else -> false to "Laser IP is not configured"
                }
            }
            "label" -> {
                val comPort = labelCom?.trim().orEmpty()
                if (comPort.isBlank()) {
                    false to "Label printer COM port is not configured"
                } else {
                    checkSerialPrinter(comPort)
                }
            }
            else -> false to "Unsupported printer type: $printerType"
        }
    }

    private fun checkTcpPrinter(printerIp: String?, port: Int, printerName: String): Pair<Boolean, String> {
        val ip = printerIp?.trim().orEmpty()
        if (ip.isBlank()) {
            return false to "$printerName IP is not configured"
        }

        return try {
            Socket().use { socket ->
                socket.connect(InetSocketAddress(ip, port), 3000)
                true to "$printerName 已連線（$ip:$port）"
            }
        } catch (e: SocketTimeoutException) {
            false to "$printerName 連線逾時（$ip:$port）"
        } catch (e: ConnectException) {
            false to "$printerName 無法連線（$ip:$port）"
        } catch (e: Exception) {
            false to "$printerName 連線失敗（$ip:$port）：${e.message}"
        }
    }

    private fun checkSerialPrinter(comPort: String): Pair<Boolean, String> {
        val normalizedPort = comPort.trim().uppercase()
        val osName = System.getProperty("os.name")?.lowercase().orEmpty()

        if (!osName.contains("win")) {
            return false to "Label printer COM check is only supported on Windows"
        }

        return try {
            val process = ProcessBuilder("cmd", "/c", "mode", normalizedPort)
                .redirectErrorStream(true)
                .start()

            val output = process.inputStream.bufferedReader().use { it.readText() }
            val exitCode = process.waitFor()

            if (exitCode == 0) {
                true to "Label printer connected on $normalizedPort"
            } else {
                false to (output.trim().ifBlank { "Label printer $normalizedPort not available" })
            }
        } catch (e: Exception) {
            false to "Label printer check failed: ${e.message}"
        }
    }

    private fun createQrCodeBitmap(
        content: String,
        contentSize: Int,
        horizontalPadding: Int = 40,
        verticalPadding: Int = 40,
    ): BitmapResult {
        require(horizontalPadding >= 0) { "horizontalPadding must be >= 0" }
        require(verticalPadding >= 0) { "verticalPadding must be >= 0" }
        val totalWidth = contentSize + (horizontalPadding * 2)
        val totalHeight = contentSize + (verticalPadding * 2)

        val writer = QRCodeWriter()
        val hints = mapOf(EncodeHintType.MARGIN to 0)
        val bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, contentSize, contentSize, hints)

        val image = BufferedImage(totalWidth, totalHeight, BufferedImage.TYPE_BYTE_BINARY)
        val g = image.createGraphics()
        g.color = Color.WHITE
        g.fillRect(0, 0, totalWidth, totalHeight)

        for (x in 0 until contentSize) {
            for (y in 0 until contentSize) {
                if (bitMatrix.get(x, y)) {
                    image.setRGB(x + horizontalPadding, y + verticalPadding, Color.BLACK.rgb)
                }
            }
        }
        g.dispose()

        val baos = ByteArrayOutputStream()
        ImageIO.write(image, "bmp", baos)

        return BitmapResult(baos.toByteArray(), image.width)
    }

    private fun cropToContent(img: BufferedImage): BufferedImage {
        val width = img.width
        val height = img.height
        var minX = width
        var minY = height
        var maxX = 0
        var maxY = 0

        for (x in 0 until width) {
            for (y in 0 until height) {
                if (img.getRGB(x, y) == Color.BLACK.rgb) {
                    minX = minOf(minX, x)
                    minY = minOf(minY, y)
                    maxX = maxOf(maxX, x)
                    maxY = maxOf(maxY, y)
                }
            }
        }

        if (minX > maxX || minY > maxY) return img  // empty image fallback

        val padding = 4
        val cropX = (minX - padding).coerceAtLeast(0)
        val cropY = (minY - padding).coerceAtLeast(0)
        val cropW = (maxX - minX + padding * 2).coerceAtMost(width - cropX)
        val cropH = (maxY - minY + padding * 2).coerceAtMost(height - cropY)

        return img.getSubimage(cropX, cropY, cropW, cropH)
    }

    private fun addToZip(zos: ZipOutputStream, entryName: String, content: ByteArray) {
        val entry = ZipEntry(entryName)
        zos.putNextEntry(entry)
        zos.write(content)
        zos.closeEntry()
    }

    private fun sanitizeFilePart(value: String): String {
        return value.replace(Regex("""[\\/:*?"<>|]"""), "_")
    }

    private fun buildUniqueZipEntryName(filename: String, existingEntries: Set<String>): String {
        val sanitized = sanitizeFilePart(filename)
        if (sanitized.isBlank()) {
            return buildUniqueZipEntryName("qr.bmp", existingEntries)
        }

        if (!existingEntries.contains(sanitized)) {
            return sanitized
        }

        val dotIndex = sanitized.lastIndexOf('.')
        val baseName = if (dotIndex > 0) sanitized.substring(0, dotIndex) else sanitized
        val extension = if (dotIndex > 0) sanitized.substring(dotIndex) else ""

        var counter = 2
        while (true) {
            val candidate = "${baseName}_${counter}${extension}"
            if (!existingEntries.contains(candidate)) {
                return candidate
            }
            counter++
        }
    }

    // ────────────────────────────────────────────────
    // The rest of your printer communication methods remain unchanged
    // ────────────────────────────────────────────────

    fun sendDataFlexJob(request: PrintRequest) {
        Socket().use { socket ->
            try {
                socket.connect(InetSocketAddress(request.printerIp, request.printerPort), 5000)
                val output = socket.getOutputStream()
                val writer = PrintWriter(output, true)

                val command = buildString {
                    append("\u0002")  // <STX>
                    append("<ESC>JI;YOUR_JOB_NAME\r\n")
                    append("<ESC>VB;ItemCode;${request.itemCode}\r\n")
                    append("<ESC>VB;ItemName;${request.itemName}\r\n")
                    append("<ESC>VB;LotNo;${request.lotNo}\r\n")
                    append("<ESC>VB;ExpiryDate;${request.expiryDate}\r\n")
                    append("<ESC>VB;QRContent;lot=${request.lotNo}&item=${request.itemCode}\r\n")
                    append("<ESC>P;0\r\n")
                    append("\u0003")  // <ETX>
                }

                writer.print(command)
                writer.flush()
                println("DataFlex: Print job sent to ${request.printerIp}:${request.printerPort}")
            } catch (e: Exception) {
                throw RuntimeException("DataFlex communication failed: ${e.message}", e)
            }
        }
    }

    fun sendLaserMark(request: LaserRequest) {
        Socket().use { socket ->
            socket.connect(InetSocketAddress(request.printerIp, request.printerPort.toInt()), 3000)
            val writer = PrintWriter(socket.getOutputStream(), true)
            val command = "STRSET|${request.templateId}|LOT=${request.lotNo}|EXP=${request.expiryDate}\n"
            writer.print(command)
            writer.flush()
        }
    }

    fun sendLaserPreview(request: LaserRequest) {
        Socket().use { socket ->
            socket.connect(InetSocketAddress(request.printerIp, request.printerPort), 3000)
            val writer = PrintWriter(socket.getOutputStream(), true)
            val command = """
                JOBLOAD|${request.templateId}
                REDLIGHT|1
            """.trimIndent()
            writer.println(command)
            writer.flush()
        }
    }

    fun sendTscPrintJob(request: PrintRequest) {
        Socket().use { socket ->
            try {
                socket.connect(InetSocketAddress(request.printerIp, request.printerPort), 3000)
                val out = DataOutputStream(socket.getOutputStream())

                val tspl = StringBuilder()
                    .append("SIZE 100 mm, 50 mm\n")
                    .append("GAP 3 mm, 0 mm\n")
                    .append("DIRECTION 1\n")
                    .append("CLS\n")
                    .append("TEXT 50,40,\"ROMAN.TTF\",0,1,1,\"ITEM: ${request.itemCode}\"\n")
                    .append("TEXT 50,80,\"ROMAN.TTF\",0,1,1,\"NAME: ${request.itemName}\"\n")
                    .append("TEXT 50,120,\"ROMAN.TTF\",0,1,1,\"EXP: ${request.expiryDate}\"\n")
                    .append("TEXT 50,160,\"ROMAN.TTF\",0,1,1,\"LOT: ${request.lotNo}\"\n")
                    .append("QRCODE 450,40,M,4,A,0,\"${request.lotNo}\"\n")
                    .append("TEXT 450,180,\"ROMAN.TTF\",0,1,1,\"SCAN LOT\"\n")
                    .append("PRINT 1,1\n")
                    .toString()

                val bytes = tspl.toByteArray(Charset.forName("TIS-620"))
                out.write(bytes)
                out.flush()
                println("TSC: Print job sent to ${request.printerIp}")
            } catch (e: Exception) {
                throw RuntimeException("TSC Printer Error: ${e.message}")
            }
        }
    }

    fun sendLaserTcpUpdateAndMark(
        ip: String = "192.168.76.10",
        port: Int = 45678,
        textChannel3: String,               // required – content for channel 3
        textChannel4: String,               // required – content for channel 4
        text3ObjectName: String = "Text3",  // EZCAD object name for channel 3
        text4ObjectName: String = "Text4"   // EZCAD object name for channel 4
    ): Boolean {
        var socket: Socket? = null
        var writer: PrintWriter? = null
        var reader: BufferedReader? = null

        try {
            println("Han's Laser TCP: 正在連接 $ip:$port ...")
            socket = Socket(ip, port).apply {
                soTimeout = 8000  // 8秒超時，避免卡死
            }

            writer = PrintWriter(socket.getOutputStream(), true)
            reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))

            // Helper function: send command and check response
            fun send(cmd: String): Boolean {
                writer.println("$cmd\r\n")
                writer.flush()
                Thread.sleep(300)  // give device time to process

                val resp = reader.readLine()?.trim() ?: ""
                println("命令: $cmd → 回應: $resp")
                return resp.startsWith("OK")
            }

            // Update channel 3 text
            val success3 = send("""E3_SetTextObject "$text3ObjectName" "$textChannel3"""")

            // Update channel 4 text
            val success4 = send("""E3_SetTextObject "$text4ObjectName" "$textChannel4"""")

            // Trigger marking
            val successMark = send("E3_StartMarking")

            val allSuccess = success3 && success4 && successMark

            if (allSuccess) {
                println("Han's Laser: 更新並打標成功")
            } else {
                println("Han's Laser: 部分命令失敗")
            }

            return allSuccess

        } catch (e: Exception) {
            println("Han's Laser TCP 錯誤: ${e.message}")
            e.printStackTrace()
            return false
        } finally {
            reader?.close()
            writer?.close()
            socket?.close()
            println("Han's Laser TCP 連接已關閉")
        }
    }

    fun sendDataFlex6330Zpl(request: PrintRequest) {
        fun zplEscape(s: String): String {
            // Match python Bag2.py: escape "\" as "\\" and "^" as "\^"
            return s.replace("\\", "\\\\").replace("^", "\\^")
        }

        fun buildZplDataFlex(): String {
            val desc = zplEscape((request.itemName.takeIf { it.isNotBlank() } ?: "—").trim())
            val code = zplEscape((request.itemCode.takeIf { it.isNotBlank() } ?: "—").trim())

            val lot = request.lotNo.trim()
            val batchNo = if (lot.isBlank()) "—" else lot

            val labelLine = (if (lot.isBlank()) null else lot) ?: batchNo
            val labelEsc = zplEscape(labelLine)

            val qrPayload = if (request.itemId != null && request.stockInLineId != null) {
                // Keep same spacing style as python json.dumps (default).
                "{\"itemId\": ${request.itemId}, \"stockInLineId\": ${request.stockInLineId}}"
            } else {
                if (labelLine.isNotBlank()) labelLine else batchNo
            }
            val qrValue = zplEscape(qrPayload)

            // Must match python Bag3.py generate_zpl_dataflex() field layout / fonts.
            val fontRegular = "E:STXihei.ttf"
            val fontBold = "E:STXihei.ttf"

            // Match python Bag3.py DataFlex defaults: narrower ^PW so job preview is not mostly empty on the right (^A@R fields are tall, not wide).
            val labelPw = 400
            val labelLl = 500
            return """
                ^XA
                ^CI28
                ^PW$labelPw
                ^LL$labelLl
                ^PO N
                ^FO10,20
                ^BQN,2,4^FDQA,$qrValue^FS
                ^FO170,20
                ^A@R,72,72,$fontRegular^FD$desc^FS
                ^FO0,200
                ^A@R,72,72,$fontRegular^FD$labelEsc^FS
                ^FO55,200
                ^A@R,88,88,$fontBold^FD$code^FS
                ^XZ
            """.trimIndent()
        }

        val rawQty = request.printQty
        val qty = if (rawQty == -1) 100 else rawQty.coerceAtLeast(1)

        val zpl = buildZplDataFlex()

        repeat(qty) { idx ->
            Socket().use { socket ->
                try {
                    socket.connect(InetSocketAddress(request.printerIp, request.printerPort), 5000)
                    val out = socket.getOutputStream()
                    out.write(zpl.toByteArray(Charsets.UTF_8))
                    out.flush()
                } catch (e: Exception) {
                    throw RuntimeException(
                        "DataFlex ZPL communication failed (sent ${idx} of ${qty}) to ${request.printerIp}:${request.printerPort}: ${e.message}",
                        e
                    )
                }
            }
        }

        println("DataFlex ZPL: sent ${qty} labels to ${request.printerIp}:${request.printerPort}")
    }
}