#!/usr/bin/env python3
"""
Bag2 – GUI to show FPSMS job orders by plan date.
Uses the public API GET /py/job-orders (no login required).
UI tuned for aged users: larger font, Traditional Chinese labels, prev/next date.

Run: python Bag2.py
"""

import json
import os
import select
import socket
import sys
import tempfile
import threading
import time
import tkinter as tk
from datetime import date, datetime, timedelta
from tkinter import messagebox, ttk
from typing import Callable, Optional

import requests

try:
    import serial
except ImportError:
    serial = None  # type: ignore

try:
    import win32print  # type: ignore[import]
    import win32ui  # type: ignore[import]
    import win32con  # type: ignore[import]
    import win32gui  # type: ignore[import]
except ImportError:
    win32print = None  # type: ignore[assignment]
    win32ui = None  # type: ignore[assignment]
    win32con = None  # type: ignore[assignment]
    win32gui = None  # type: ignore[assignment]

try:
    from PIL import Image, ImageDraw, ImageFont, ImageOps
    try:
        from PIL import ImageWin  # type: ignore
    except Exception:
        ImageWin = None  # type: ignore[assignment]
    import qrcode
    _HAS_PIL_QR = True
except ImportError:
    Image = None  # type: ignore[assignment]
    ImageDraw = None  # type: ignore[assignment]
    ImageFont = None  # type: ignore[assignment]
    ImageOps = None  # type: ignore[assignment]
    ImageWin = None  # type: ignore[assignment]
    qrcode = None  # type: ignore[assignment]
    _HAS_PIL_QR = False

DEFAULT_BASE_URL = os.environ.get("FPSMS_BASE_URL", "http://localhost:8090/api")
# When run as PyInstaller exe, save settings next to the exe; otherwise next to script
if getattr(sys, "frozen", False):
    _SETTINGS_DIR = os.path.dirname(sys.executable)
else:
    _SETTINGS_DIR = os.path.dirname(os.path.abspath(__file__))
# Bag2 has its own settings file so it doesn't share with Bag1.
SETTINGS_FILE = os.path.join(_SETTINGS_DIR, "bag2_settings.json")
LASER_COUNTER_FILE = os.path.join(_SETTINGS_DIR, "last_batch_count.txt")

DEFAULT_SETTINGS = {
    "api_ip": "localhost",
    "api_port": "8090",
    "dabag_ip": "",
    "dabag_port": "3008",
    "laser_ip": "192.168.17.10",
    "laser_port": "45678",
    # For 標簽機 on Windows, this is the Windows printer name, e.g. "TSC TTP-246M Pro"
    "label_com": "TSC TTP-246M Pro",
}


def load_settings() -> dict:
    """Load settings from JSON file; return defaults if missing or invalid."""
    try:
        if os.path.isfile(SETTINGS_FILE):
            with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
                data = json.load(f)
                return {**DEFAULT_SETTINGS, **data}
    except Exception:
        pass
    return dict(DEFAULT_SETTINGS)


def save_settings(settings: dict) -> None:
    """Save settings to JSON file."""
    with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
        json.dump(settings, f, indent=2, ensure_ascii=False)


def build_base_url(api_ip: str, api_port: str) -> str:
    ip = (api_ip or "localhost").strip()
    port = (api_port or "8090").strip()
    return f"http://{ip}:{port}/api"


def try_printer_connection(printer_name: str, sett: dict) -> bool:
    """Try to connect to the selected printer (TCP IP:port or COM). Returns True if OK."""
    if printer_name == "打袋機 DataFlex":
        ip = (sett.get("dabag_ip") or "").strip()
        port_str = (sett.get("dabag_port") or "9100").strip()
        if not ip:
            return False
        try:
            port = int(port_str)
            s = socket.create_connection((ip, port), timeout=PRINTER_SOCKET_TIMEOUT)
            s.close()
            return True
        except (socket.error, ValueError, OSError):
            return False
    if printer_name == "激光機":
        ip = (sett.get("laser_ip") or "").strip()
        port_str = (sett.get("laser_port") or "45678").strip()
        if not ip:
            return False
        try:
            port = int(port_str)
            s = socket.create_connection((ip, port), timeout=PRINTER_SOCKET_TIMEOUT)
            s.close()
            return True
        except (socket.error, ValueError, OSError):
            return False
    if printer_name == "標簽機":
        target = (sett.get("label_com") or "").strip()
        if not target:
            return False
        # On Windows, allow using a Windows printer name (e.g. "TSC TTP-246M Pro")
        # as an alternative to a COM port. If it doesn't look like a COM port,
        # try opening it via the Windows print spooler.
        if os.name == "nt" and not target.upper().startswith("COM"):
            if win32print is None:
                return False
            try:
                handle = win32print.OpenPrinter(target)
                win32print.ClosePrinter(handle)
                return True
            except Exception:
                return False
        # Fallback: treat as serial COM port (original behaviour)
        if serial is None:
            return False
        try:
            ser = serial.Serial(target, timeout=1)
            ser.close()
            return True
        except (serial.SerialException, OSError):
            return False
    return False

# Larger font for aged users (point size)
FONT_SIZE = 16
FONT_SIZE_BUTTONS = 15
FONT_SIZE_QTY = 12  # smaller for 需求數量 under batch no.
FONT_SIZE_META = 11  # single-line 需求／已印 (compact list)
# Less vertical padding so ~30 rows fit more comfortably
LIST_ROW_PADY = 2
LIST_ROW_IPADY = 5
FONT_SIZE_ITEM = 20  # item code and item name (larger for readability)
FONT_FAMILY = "Microsoft JhengHei UI"  # Traditional Chinese; fallback to TkDefaultFont
FONT_SIZE_ITEM_CODE = 20  # item code (larger for readability)
FONT_SIZE_ITEM_NAME = 26  # item name (bigger than item code)
# Column widths: fixed frame widths so 品號/品名 columns line up across rows
LEFT_COL_WIDTH_PX = 300   # 工單 + 需求／已印 block
ITEM_CODE_WRAP = 140      # Label wraplength (px)
# Narrower than wrap+padding so short codes sit closer to 品名 (still aligned across rows)
CODE_COL_WIDTH_PX = ITEM_CODE_WRAP + 6
ITEM_NAME_WRAP = 640      # item name wraps in remaining space

# Light blue theme (softer than pure grey)
BG_TOP = "#E8F4FC"
BG_LIST = "#D4E8F7"
BG_ROOT = "#E1F0FF"
BG_ROW = "#C5E1F5"
BG_ROW_SELECTED = "#6BB5FF"  # highlighted when selected (for printing)
# Connection status bar
BG_STATUS_ERROR = "#FFCCCB"   # red background when disconnected
FG_STATUS_ERROR = "#B22222"   # red text
BG_STATUS_OK = "#90EE90"     # light green when connected
FG_STATUS_OK = "#006400"      # green text
RETRY_MS = 30 * 1000          # 30 seconds reconnect
REFRESH_MS = 60 * 1000        # 60 seconds refresh when connected
PRINTER_CHECK_MS = 60 * 1000  # 1 minute when printer OK
PRINTER_RETRY_MS = 30 * 1000  # 30 seconds when printer failed
PRINTER_SOCKET_TIMEOUT = 3
DATAFLEX_SEND_TIMEOUT = 10  # seconds when sending ZPL to DataFlex


def _zpl_escape(s: str) -> str:
    """Escape text for ZPL ^FD...^FS (backslash and caret)."""
    return s.replace("\\", "\\\\").replace("^", "\\^")


def generate_zpl_dataflex(
    batch_no: str,
    item_code: str,
    item_name: str,
    item_id: Optional[int] = None,
    stock_in_line_id: Optional[int] = None,
    lot_no: Optional[str] = None,
    font_regular: str = "E:STXihei.ttf",
    font_bold: str = "E:STXihei.ttf",
) -> str:
    """
    Row 1 (from zero): QR code, then item name (rotated 90°).
    Row 2: Batch/lot (left), item code (right).
    Label and QR use lotNo from API when present, else batch_no (Bxxxxx).
    """
    desc = _zpl_escape((item_name or "—").strip())
    code = _zpl_escape((item_code or "—").strip())
    label_line = (lot_no or batch_no or "").strip()
    label_esc = _zpl_escape(label_line)
    # QR payload: prefer JSON {"itemId":..., "stockInLineId":...} when both present; else fall back to lot/batch text
    if item_id is not None and stock_in_line_id is not None:
        qr_payload = json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id})
    else:
        qr_payload = label_line if label_line else batch_no.strip()
    qr_value = _zpl_escape(qr_payload)
    return f"""^XA
^CI28
^PW700
^LL500
^PO N
^FO10,20
^BQN,2,4^FDQA,{qr_value}^FS
^FO170,20
^A@R,72,72,{font_regular}^FD{desc}^FS
^FO0,200
^A@R,72,72,{font_regular}^FD{label_esc}^FS
^FO55,200
^A@R,88,88,{font_bold}^FD{code}^FS
^XZ"""


def generate_zpl_label_small(
    batch_no: str,
    item_code: str,
    item_name: str,
    item_id: Optional[int] = None,
    stock_in_line_id: Optional[int] = None,
    lot_no: Optional[str] = None,
    font: str = "MingLiUHKSCS",
) -> str:
    """
    ZPL for 標簽機. Row 1: item name. Row 2: QR left | item code + lot no (or batch) right.
    QR contains {"itemId": xxx, "stockInLineId": xxx} when both present; else batch_no.
    Unicode (^CI28); font set for Big-5 (e.g. MingLiUHKSCS).
    """
    desc = _zpl_escape((item_name or "—").strip())
    code = _zpl_escape((item_code or "—").strip())
    label_line2 = (lot_no or batch_no or "—").strip()
    label_line2_esc = _zpl_escape(label_line2)
    if item_id is not None and stock_in_line_id is not None:
        qr_data = _zpl_escape(json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id}))
    else:
        qr_data = f"QA,{batch_no}"
    return f"""^XA
^CI28
^PW500
^LL500
^FO10,15
^FB480,3,0,L,0
^A@N,38,38,{font}^FD{desc}^FS
^FO10,110
^BQN,2,6^FD{qr_data}^FS
^FO150,110
^A@N,48,48,{font}^FD{code}^FS
^FO150,175
^A@N,40,40,{font}^FD{label_line2_esc}^FS
^XZ"""


# Label image size (pixels) for 標簽機 image printing.
# Enlarged for readability (approx +90% scale).
LABEL_IMAGE_W = 720
LABEL_IMAGE_H = 530
LABEL_PADDING = 23
LABEL_FONT_NAME_SIZE = 42
LABEL_FONT_CODE_SIZE = 49
LABEL_FONT_BATCH_SIZE = 34
LABEL_QR_SIZE = 210


def _get_chinese_font(size: int) -> Optional["ImageFont.FreeTypeFont"]:
    """Return a Chinese-capable font for PIL, or None to use default."""
    if ImageFont is None:
        return None
    # Prefer real font files on Windows (font *names* may fail and silently fallback).
    if os.name == "nt":
        fonts_dir = os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts")
        for rel in (
            "msjh.ttc",   # Microsoft JhengHei
            "msjhl.ttc",  # Microsoft JhengHei Light
            "msjhbd.ttc", # Microsoft JhengHei Bold
            "mingliu.ttc",
            "mingliub.ttc",
            "kaiu.ttf",
            "msyh.ttc",   # Microsoft YaHei
            "msyhbd.ttc",
            "simhei.ttf",
            "simsun.ttc",
        ):
            p = os.path.join(fonts_dir, rel)
            try:
                if os.path.exists(p):
                    return ImageFont.truetype(p, size)
            except (OSError, IOError):
                continue
    # Fallback: try common font names (may still work depending on Pillow build)
    for name in (
        "Microsoft JhengHei UI",
        "Microsoft JhengHei",
        "MingLiU",
        "MingLiU_HKSCS",
        "Microsoft YaHei",
        "SimHei",
        "SimSun",
    ):
        try:
            return ImageFont.truetype(name, size)
        except (OSError, IOError):
            continue
    try:
        return ImageFont.load_default()
    except Exception:
        return None


def render_label_to_image(
    batch_no: str,
    item_code: str,
    item_name: str,
    item_id: Optional[int] = None,
    stock_in_line_id: Optional[int] = None,
    lot_no: Optional[str] = None,
) -> "Image.Image":
    """
    Render 標簽機 label as a PIL Image (white bg, black text + QR).
    Use this image for printing so Chinese displays correctly; words are drawn bigger.
    Requires Pillow and qrcode. Raises RuntimeError if not available.
    """
    if not _HAS_PIL_QR or Image is None or qrcode is None:
        raise RuntimeError("Pillow and qrcode are required for image labels. Run: pip install Pillow qrcode[pil]")
    img = Image.new("RGB", (LABEL_IMAGE_W, LABEL_IMAGE_H), "white")
    draw = ImageDraw.Draw(img)
    # QR payload (same as ZPL)
    if item_id is not None and stock_in_line_id is not None:
        qr_data = json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id})
    else:
        qr_data = f"QA,{batch_no}"
    # Draw QR top-left area
    qr = qrcode.QRCode(box_size=4, border=2)
    qr.add_data(qr_data)
    qr.make(fit=True)
    qr_img = qr.make_image(fill_color="black", back_color="white")
    _resample = getattr(Image, "Resampling", Image).NEAREST
    qr_img = qr_img.resize((LABEL_QR_SIZE, LABEL_QR_SIZE), _resample)
    img.paste(qr_img, (LABEL_PADDING, LABEL_PADDING))
    # Fonts (bigger for readability)
    font_name = _get_chinese_font(LABEL_FONT_NAME_SIZE)
    font_code = _get_chinese_font(LABEL_FONT_CODE_SIZE)
    font_batch = _get_chinese_font(LABEL_FONT_BATCH_SIZE)
    x_right = LABEL_PADDING + LABEL_QR_SIZE + LABEL_PADDING
    y_line = LABEL_PADDING
    # Line 1: item name (wrap within remaining width)
    name_str = (item_name or "—").strip()
    max_name_w = LABEL_IMAGE_W - x_right - LABEL_PADDING
    if font_name:
        # Wrap rule: after 7 "words" (excl. parentheses). ()（） not counted; +=*/. and A–Z/a–z count as 0.5.
        def _wrap_text(text: str, font, max_width: int) -> list:
            ignore = set("()（）")
            half = set("+=*/.abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
            max_count = 6.5
            lines: list[str] = []
            current: list[str] = []
            count = 0.0

            for ch in text:
                if ch == "\n":
                    lines.append("".join(current).strip())
                    current = []
                    count = 0.0
                    continue

                ch_count = 0.0 if ch in ignore else (0.5 if ch in half else 1.0)
                if count + ch_count > max_count and current:
                    lines.append("".join(current).strip())
                    current = []
                    count = 0.0

                current.append(ch)
                count += ch_count

            if current:
                lines.append("".join(current).strip())

            # Max 2 rows for item name. If still long, keep everything in row 2.
            if len(lines) > 2:
                lines = [lines[0], "".join(lines[1:]).strip()]

            # Safety: if any line still exceeds pixel width, wrap by width as well.
            if hasattr(draw, "textbbox"):
                out: list[str] = []
                for ln in lines:
                    buf: list[str] = []
                    for ch in ln:
                        buf.append(ch)
                        bbox = draw.textbbox((0, 0), "".join(buf), font=font)
                        if bbox[2] - bbox[0] > max_width and len(buf) > 1:
                            out.append("".join(buf[:-1]).strip())
                            buf = [buf[-1]]
                    if buf:
                        out.append("".join(buf).strip())
                out = [x for x in out if x]
                if len(out) > 2:
                    out = [out[0], "".join(out[1:]).strip()]
                return out

            lines = [x for x in lines if x]
            if len(lines) > 2:
                lines = [lines[0], "".join(lines[1:]).strip()]
            return lines
        lines = _wrap_text(name_str, font_name, max_name_w)
        for i, ln in enumerate(lines):
            draw.text((x_right, y_line + i * (LABEL_FONT_NAME_SIZE + 4)), ln, font=font_name, fill="black")
        y_line += len(lines) * (LABEL_FONT_NAME_SIZE + 4) + 8
    else:
        draw.text((x_right, y_line), name_str[:30], fill="black")
        y_line += LABEL_FONT_NAME_SIZE + 12
    # Item code (bigger)
    code_str = (item_code or "—").strip()
    if font_code:
        draw.text((x_right, y_line), code_str, font=font_code, fill="black")
    else:
        draw.text((x_right, y_line), code_str, fill="black")
    y_line += LABEL_FONT_CODE_SIZE + 6
    # Batch/lot line
    batch_str = (lot_no or batch_no or "—").strip()
    if font_batch:
        draw.text((x_right, y_line), batch_str, font=font_batch, fill="black")
    else:
        draw.text((x_right, y_line), batch_str, fill="black")
    return img


def _image_to_zpl_gfa(pil_image: "Image.Image") -> str:
    """
    Convert a PIL image into ZPL ^GFA (ASCII hex) so we can print Chinese reliably
    on ZPL printers (USB/Windows printer or COM) without relying on GDI drivers.
    """
    if Image is None or ImageOps is None:
        raise RuntimeError("Pillow is required for image-to-ZPL conversion.")
    # Convert to 1-bit monochrome bitmap. Invert so '1' bits represent black in ZPL.
    img_bw = ImageOps.invert(pil_image.convert("L")).convert("1")
    w, h = img_bw.size
    bytes_per_row = (w + 7) // 8
    raw = img_bw.tobytes()
    total = bytes_per_row * h
    # Ensure length matches expected (Pillow should already pack per row).
    if len(raw) != total:
        raw = raw[:total].ljust(total, b"\x00")
    hex_data = raw.hex().upper()
    return f"""^XA
^PW{w}
^LL{h}
^FO0,0
^GFA,{total},{total},{bytes_per_row},{hex_data}
^FS
^XZ"""


def send_image_to_label_printer(printer_name: str, pil_image: "Image.Image") -> None:
    """
    Send a PIL Image to 標簽機 via Windows GDI (so Chinese and graphics print correctly).
    Only supported when target is a Windows printer name (not COM port). Requires pywin32.
    """
    dest = (printer_name or "").strip()
    if not dest:
        raise ValueError("Label printer destination is empty.")
    if os.name != "nt" or dest.upper().startswith("COM"):
        raise RuntimeError("Image printing is only supported for a Windows printer name (e.g. TSC TTP-246M Pro).")
    if win32print is None or win32ui is None or win32con is None or win32gui is None:
        raise RuntimeError("pywin32 is required. Run: pip install pywin32")
    dc = win32ui.CreateDC()
    dc.CreatePrinterDC(dest)
    dc.StartDoc("FPSMS Label")
    dc.StartPage()
    try:
        bmp_w = pil_image.width
        bmp_h = pil_image.height
        # Scale-to-fit printable area (important for smaller physical labels).
        try:
            page_w = int(dc.GetDeviceCaps(win32con.HORZRES))
            page_h = int(dc.GetDeviceCaps(win32con.VERTRES))
        except Exception:
            page_w, page_h = bmp_w, bmp_h
        if page_w <= 0 or page_h <= 0:
            page_w, page_h = bmp_w, bmp_h
        scale = min(page_w / max(1, bmp_w), page_h / max(1, bmp_h))
        out_w = max(1, int(bmp_w * scale))
        out_h = max(1, int(bmp_h * scale))
        x0 = max(0, (page_w - out_w) // 2)
        y0 = max(0, (page_h - out_h) // 2)

        # Most reliable: render via Pillow ImageWin directly to printer DC.
        if ImageWin is not None:
            dib = ImageWin.Dib(pil_image.convert("RGB"))
            dib.draw(dc.GetHandleOutput(), (x0, y0, x0 + out_w, y0 + out_h))
        else:
            # Fallback: Draw image to printer DC via temp BMP (GDI uses BMP)
            with tempfile.NamedTemporaryFile(suffix=".bmp", delete=False) as f:
                tmp_bmp = f.name
            try:
                pil_image.save(tmp_bmp, "BMP")
                hbm = win32gui.LoadImage(
                    0, tmp_bmp, win32con.IMAGE_BITMAP, 0, 0,
                    win32con.LR_LOADFROMFILE | win32con.LR_CREATEDIBSECTION,
                )
                if hbm == 0:
                    raise RuntimeError("Failed to load label image as bitmap.")
                try:
                    mem_dc = win32ui.CreateDCFromHandle(win32gui.CreateCompatibleDC(dc.GetSafeHdc()))
                    bmp = getattr(win32ui, "CreateBitmapFromHandle", lambda h: win32ui.PyCBitmap.FromHandle(h))(hbm)
                    mem_dc.SelectObject(bmp)
                    dc.StretchBlt((x0, y0), (out_w, out_h), mem_dc, (0, 0), (bmp_w, bmp_h), win32con.SRCCOPY)
                finally:
                    win32gui.DeleteObject(hbm)
            finally:
                try:
                    os.unlink(tmp_bmp)
                except OSError:
                    pass
    finally:
        dc.EndPage()
        dc.EndDoc()


def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None:
    """Send ZPL to DataFlex printer via TCP. Raises on connection/send error."""
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(DATAFLEX_SEND_TIMEOUT)
    try:
        sock.connect((ip, port))
        sock.sendall(zpl.encode("utf-8"))
    finally:
        sock.close()


def send_zpl_to_label_printer(target: str, zpl: str) -> None:
    """
    Send ZPL to 標簽機.

    On Windows, if target is not a COM port (e.g. "TSC TTP-246M Pro"),
    send raw ZPL to the named Windows printer via the spooler.
    Otherwise, treat target as a serial COM port (original behaviour).
    """
    dest = (target or "").strip()
    if not dest:
        raise ValueError("Label printer destination is empty.")

    # Unicode (^CI28); send UTF-8 to 標簽機
    raw_bytes = zpl.encode("utf-8")

    # Windows printer name path (USB printer installed as normal printer)
    if os.name == "nt" and not dest.upper().startswith("COM"):
        if win32print is None:
            raise RuntimeError("pywin32 not installed. Run: pip install pywin32")
        handle = win32print.OpenPrinter(dest)
        try:
            job = win32print.StartDocPrinter(handle, 1, ("FPSMS Label", None, "RAW"))
            win32print.StartPagePrinter(handle)
            win32print.WritePrinter(handle, raw_bytes)
            win32print.EndPagePrinter(handle)
            win32print.EndDocPrinter(handle)
        finally:
            win32print.ClosePrinter(handle)
        return

    # Fallback: serial COM port
    if serial is None:
        raise RuntimeError("pyserial not installed. Run: pip install pyserial")
    ser = serial.Serial(dest, timeout=5)
    try:
        ser.write(raw_bytes)
    finally:
        ser.close()


def load_laser_last_count() -> tuple[int, Optional[str]]:
    """Load last batch count and date from laser counter file. Returns (count, date_str)."""
    if not os.path.exists(LASER_COUNTER_FILE):
        return 0, None
    try:
        with open(LASER_COUNTER_FILE, "r", encoding="utf-8") as f:
            lines = f.read().strip().splitlines()
            if len(lines) >= 2:
                return int(lines[1].strip()), lines[0].strip()
    except Exception:
        pass
    return 0, None


def save_laser_last_count(date_str: str, count: int) -> None:
    """Save laser batch count and date to file."""
    try:
        with open(LASER_COUNTER_FILE, "w", encoding="utf-8") as f:
            f.write(f"{date_str}\n{count}")
    except Exception:
        pass


LASER_PUSH_INTERVAL = 2  # seconds between pushes (like sample script)
# Click row with 激光機 selected: send this many times, delay between sends (not after last).
LASER_ROW_SEND_COUNT = 3
LASER_ROW_SEND_DELAY_SEC = 3


def laser_push_loop(
    ip: str,
    port: int,
    stop_event: threading.Event,
    root: tk.Tk,
    on_error: Callable[[str], None],
) -> None:
    """
    Run in a background thread: persistent connection to EZCAD, push B{yymmdd}{count:03d};;
    every LASER_PUSH_INTERVAL seconds. Resets count each new day. Uses counter file.
    """
    conn = None
    push_count, last_saved_date = load_laser_last_count()
    while not stop_event.is_set():
        try:
            if conn is None:
                conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
                conn.settimeout(0.4)
                conn.connect((ip, port))
            now = datetime.now()
            today_str = now.strftime("%y%m%d")
            if last_saved_date != today_str:
                push_count = 1
                last_saved_date = today_str
            batch = f"B{today_str}{push_count:03d}"
            reply = f"{batch};;"
            conn.sendall(reply.encode("utf-8"))
            save_laser_last_count(today_str, push_count)
            rlist, _, _ = select.select([conn], [], [], 0.4)
            if rlist:
                data = conn.recv(4096)
                if not data:
                    conn.close()
                    conn = None
            push_count += 1
            for _ in range(int(LASER_PUSH_INTERVAL * 2)):
                if stop_event.is_set():
                    break
                time.sleep(0.5)
        except socket.timeout:
            pass
        except Exception as e:
            if conn:
                try:
                    conn.close()
                except Exception:
                    pass
                conn = None
                try:
                    root.after(0, lambda msg=str(e): on_error(msg))
                except Exception:
                    pass
                for _ in range(6):
                    if stop_event.is_set():
                        break
                    time.sleep(0.5)
    if conn:
        try:
            conn.close()
        except Exception:
            pass


def send_job_to_laser(
    conn_ref: list,
    ip: str,
    port: int,
    item_id: Optional[int],
    stock_in_line_id: Optional[int],
    item_code: str,
    item_name: str,
) -> tuple[bool, str]:
    """
    Send to laser using `;` separated 3 params:
    {"itemID": itemId, "stockInLineId": stockInLineId} ; itemCode ; itemName ;;
    conn_ref: [socket or None] - reused across calls; closed only when switching printer.
    When both item_id and stock_in_line_id present, sends JSON first param; else fallback: 0;item_code;item_name;;
    Returns (success, message).
    """
    code_str = (item_code or "").strip().replace(";", ",")
    name_str = (item_name or "").strip().replace(";", ",")

    if item_id is not None and stock_in_line_id is not None:
        # Use compact JSON so device-side parser doesn't get spaces.
        json_part = json.dumps(
            {"itemID": item_id, "stockInLineId": stock_in_line_id},
            separators=(",", ":"),
        )
        reply = f"{json_part};{code_str};{name_str};;"
    else:
        reply = f"0;{code_str};{name_str};;"
    conn = conn_ref[0]
    try:
        if conn is None:
            conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            conn.settimeout(3.0)
            conn.connect((ip, port))
            conn_ref[0] = conn
        conn.settimeout(3.0)
        conn.sendall(reply.encode("utf-8"))
        conn.settimeout(0.5)
        try:
            data = conn.recv(4096)
            if data:
                ack = data.decode("utf-8", errors="ignore").strip().lower()
                if "receive" in ack and "invalid" not in ack:
                    return True, f"已送出激光機：{reply}（已確認）"
        except socket.timeout:
            pass
        return True, f"已送出激光機：{reply}"
    except (ConnectionRefusedError, socket.timeout, OSError) as e:
        if conn_ref[0] is not None:
            try:
                conn_ref[0].close()
            except Exception:
                pass
            conn_ref[0] = None
        if isinstance(e, ConnectionRefusedError):
            return False, f"無法連線至 {ip}:{port}，請確認激光機已開機且 IP 正確。"
        if isinstance(e, socket.timeout):
            return False, f"連線逾時 ({ip}:{port})，請檢查網路與連接埠。"
        return False, f"激光機送出失敗：{e}"


def send_job_to_laser_with_retry(
    conn_ref: list,
    ip: str,
    port: int,
    item_id: Optional[int],
    stock_in_line_id: Optional[int],
    item_code: str,
    item_name: str,
) -> tuple[bool, str]:
    """Send job to laser; on failure, retry once. Returns (success, message)."""
    ok, msg = send_job_to_laser(conn_ref, ip, port, item_id, stock_in_line_id, item_code, item_name)
    if ok:
        return True, msg
    ok2, msg2 = send_job_to_laser(conn_ref, ip, port, item_id, stock_in_line_id, item_code, item_name)
    return ok2, msg2


def run_laser_row_send_thread(
    root: tk.Tk,
    laser_conn_ref: list,
    laser_busy_ref: list,
    ip: str,
    port: int,
    item_id: Optional[int],
    stock_in_line_id: Optional[int],
    item_code: str,
    item_name: str,
    set_status_message: Callable[[str, bool], None],
    base_url: Optional[str] = None,
    job_order_id: Optional[int] = None,
    on_recorded: Optional[Callable[[], None]] = None,
) -> None:
    """
    On row click with 激光機: send LASER_ROW_SEND_COUNT times with LASER_ROW_SEND_DELAY_SEC between sends.
    UI updates on main thread; work runs in background so the window does not freeze.
    After success, POST LASER qty to API when job_order_id and base_url are set.
    """
    if laser_busy_ref[0]:
        return
    laser_busy_ref[0] = True

    def worker() -> None:
        try:
            n = LASER_ROW_SEND_COUNT
            for i in range(n):
                ok, msg = send_job_to_laser_with_retry(
                    laser_conn_ref,
                    ip,
                    port,
                    item_id,
                    stock_in_line_id,
                    item_code,
                    item_name,
                )
                if not ok:
                    root.after(
                        0,
                        lambda m=msg: messagebox.showwarning("激光機", m),
                    )
                    return
                if i < n - 1:
                    time.sleep(LASER_ROW_SEND_DELAY_SEC)
            posted = False
            if base_url and job_order_id is not None:
                try:
                    submit_job_order_print_submit(base_url, int(job_order_id), n, "LASER")
                    posted = True
                except requests.RequestException as ex:
                    root.after(
                        0,
                        lambda err=str(ex): messagebox.showwarning(
                            "激光機",
                            f"已發送，但伺服器記錄失敗：{err}",
                        ),
                    )
            root.after(
                0,
                lambda: set_status_message("已發送", is_error=False),
            )
            if on_recorded is not None and posted:
                root.after(0, on_recorded)
        except Exception as e:
            root.after(
                0,
                lambda err=str(e): messagebox.showwarning("激光機", f"送出失敗：{err}"),
            )
        finally:
            laser_busy_ref[0] = False

    threading.Thread(target=worker, daemon=True).start()


def _printed_qty_int(raw) -> int:
    """Parse API printed qty field (may be float JSON) to int."""
    try:
        return int(float(raw)) if raw is not None else 0
    except (TypeError, ValueError):
        return 0


def _filter_job_orders_by_search(data: list, needle: str) -> list:
    """Substring match on item code, job order code, item name, lot (case-insensitive)."""
    n = needle.strip().lower()
    if not n:
        return data
    out: list = []
    for jo in data:
        parts = [
            str(jo.get("itemCode") or ""),
            str(jo.get("code") or ""),
            str(jo.get("itemName") or ""),
            str(jo.get("lotNo") or ""),
        ]
        if any(n in p.lower() for p in parts):
            out.append(jo)
    return out


def format_qty(val) -> str:
    """Format quantity: integer without .0, with thousand separator."""
    if val is None:
        return "—"
    try:
        n = float(val)
        if n == int(n):
            return f"{int(n):,}"
        return f"{n:,.2f}".rstrip("0").rstrip(".")
    except (TypeError, ValueError):
        return str(val)


def batch_no(year: int, job_order_id: int) -> str:
    """Batch no.: B + 4-digit year + jobOrderId zero-padded to 6 digits."""
    return f"B{year}{job_order_id:06d}"


def get_font(size: int = FONT_SIZE, bold: bool = False) -> tuple:
    try:
        return (FONT_FAMILY, size, "bold" if bold else "normal")
    except Exception:
        return ("TkDefaultFont", size, "bold" if bold else "normal")


def fetch_job_orders(base_url: str, plan_start: date) -> list:
    """Call GET /py/job-orders and return the JSON list."""
    url = f"{base_url.rstrip('/')}/py/job-orders"
    params = {"planStart": plan_start.isoformat()}
    resp = requests.get(url, params=params, timeout=30)
    resp.raise_for_status()
    return resp.json()


def submit_job_order_print_submit(
    base_url: str,
    job_order_id: int,
    qty: int,
    print_channel: str = "LABEL",
) -> None:
    """POST /py/job-order-print-submit — one row per submit for DB wastage/stock tracking."""
    url = f"{base_url.rstrip('/')}/py/job-order-print-submit"
    resp = requests.post(
        url,
        json={"jobOrderId": job_order_id, "qty": qty, "printChannel": print_channel},
        timeout=30,
    )
    resp.raise_for_status()


def set_row_highlight(row_frame: tk.Frame, selected: bool) -> None:
    """Set row and all nested Frame/Label children to selected or normal background."""
    bg = BG_ROW_SELECTED if selected else BG_ROW

    def _paint(w: tk.Misc) -> None:
        if isinstance(w, (tk.Frame, tk.Label)):
            w.configure(bg=bg)
            for c in w.winfo_children():
                _paint(c)

    _paint(row_frame)


def on_job_order_click(jo: dict, batch: str) -> None:
    """Show message and highlight row (keeps printing to selected printer)."""
    item_code = jo.get("itemCode") or "—"
    item_name = jo.get("itemName") or "—"
    messagebox.showinfo(
        "工單",
        f'已點選：批次 {batch}\n品號 {item_code}　{item_name}',
    )


def ask_label_count(parent: tk.Tk) -> Optional[int]:
    """
    When printer is 標簽機, ask how many labels to print:
    optional direct qty in text field, +50/+10/+5/+1, 重置, then 確認送出.
    Returns count (>= 1), or None if cancelled.
    """
    result: list[Optional[int]] = [None]
    qty_var = tk.StringVar(value="0")

    win = tk.Toplevel(parent)
    win.title("標簽印數")
    win.geometry("580x280")
    win.transient(parent)
    win.grab_set()
    win.configure(bg=BG_TOP)
    ttk.Label(win, text="印多少個？", font=get_font(FONT_SIZE)).pack(pady=(12, 4))

    entry_row = tk.Frame(win, bg=BG_TOP)
    entry_row.pack(pady=8)
    tk.Label(entry_row, text="需求數量：", font=get_font(FONT_SIZE), bg=BG_TOP).pack(side=tk.LEFT, padx=(0, 6))
    qty_entry = tk.Entry(
        entry_row,
        textvariable=qty_var,
        width=12,
        font=get_font(FONT_SIZE),
        bg="white",
        justify=tk.RIGHT,
    )
    qty_entry.pack(side=tk.LEFT, padx=4)

    def current_qty() -> int:
        s = (qty_var.get() or "").strip().replace(",", "")
        if not s:
            return 0
        try:
            return max(0, int(s))
        except ValueError:
            return 0

    def reset_qty() -> None:
        qty_var.set("0")

    ttk.Button(entry_row, text="重置", command=reset_qty, width=8).pack(side=tk.LEFT, padx=8)

    def add(n: int) -> None:
        qty_var.set(str(current_qty() + n))

    def confirm() -> None:
        q = current_qty()
        if q < 1:
            messagebox.showwarning("標簽機", "請輸入需求數量或按 +50、+10、+5、+1。", parent=win)
            return
        result[0] = q
        win.destroy()

    btn_row1 = tk.Frame(win, bg=BG_TOP)
    btn_row1.pack(pady=8)
    for label, value in [("+50", 50), ("+10", 10), ("+5", 5), ("+1", 1)]:
        def make_add(v: int):
            return lambda: add(v)
        ttk.Button(btn_row1, text=label, command=make_add(value), width=8).pack(side=tk.LEFT, padx=4)

    ttk.Button(win, text="確認送出", command=confirm, width=14).pack(pady=12)
    qty_entry.bind("<Return>", lambda e: confirm())
    win.protocol("WM_DELETE_WINDOW", win.destroy)
    win.wait_window()
    return result[0]
    
def ask_bag_count(parent: tk.Tk) -> Optional[int]:
    """
    When printer is 打袋機 DataFlex, ask how many bags:
    optional direct qty in text field, +50/+10/+5/+1, 重置, then 確認送出.
    Returns count (>= 1), or None if cancelled.
    """
    result: list[Optional[int]] = [None]
    qty_var = tk.StringVar(value="0")

    win = tk.Toplevel(parent)
    win.title("打袋列印數量")
    win.geometry("580x280")
    win.transient(parent)
    win.grab_set()
    win.configure(bg=BG_TOP)
    ttk.Label(win, text="列印多少個袋？", font=get_font(FONT_SIZE)).pack(pady=(12, 4))

    entry_row = tk.Frame(win, bg=BG_TOP)
    entry_row.pack(pady=8)
    tk.Label(entry_row, text="需求數量：", font=get_font(FONT_SIZE), bg=BG_TOP).pack(side=tk.LEFT, padx=(0, 6))
    qty_entry = tk.Entry(
        entry_row,
        textvariable=qty_var,
        width=12,
        font=get_font(FONT_SIZE),
        bg="white",
        justify=tk.RIGHT,
    )
    qty_entry.pack(side=tk.LEFT, padx=4)

    def current_qty() -> int:
        s = (qty_var.get() or "").strip().replace(",", "")
        if not s:
            return 0
        try:
            return max(0, int(s))
        except ValueError:
            return 0

    def reset_qty() -> None:
        qty_var.set("0")

    ttk.Button(entry_row, text="重置", command=reset_qty, width=8).pack(side=tk.LEFT, padx=8)

    def add(n: int) -> None:
        qty_var.set(str(current_qty() + n))

    def confirm() -> None:
        q = current_qty()
        if q < 1:
            messagebox.showwarning("打袋機", "請輸入需求數量或按 +50、+10、+5、+1。", parent=win)
            return
        result[0] = q
        win.destroy()

    btn_row1 = tk.Frame(win, bg=BG_TOP)
    btn_row1.pack(pady=8)
    for label, value in [("+50", 50), ("+10", 10), ("+5", 5), ("+1", 1)]:
        def make_add(v: int):
            return lambda: add(v)
        ttk.Button(btn_row1, text=label, command=make_add(value), width=8).pack(side=tk.LEFT, padx=4)

    ttk.Button(win, text="確認送出", command=confirm, width=14).pack(pady=12)
    qty_entry.bind("<Return>", lambda e: confirm())
    win.protocol("WM_DELETE_WINDOW", win.destroy)
    win.wait_window()
    return result[0]


def main() -> None:
    settings = load_settings()
    base_url_ref = [build_base_url(settings["api_ip"], settings["api_port"])]

    root = tk.Tk()
    root.title("FP-MTMS Bag v1.1 打袋機")
    root.geometry("1120x960")
    root.minsize(480, 360)
    root.configure(bg=BG_ROOT)

    # Style: larger font for aged users; light blue theme
    style = ttk.Style()
    try:
        style.configure(".", font=get_font(FONT_SIZE), background=BG_TOP)
        style.configure("TButton", font=get_font(FONT_SIZE_BUTTONS), background=BG_TOP)
        style.configure("TLabel", font=get_font(FONT_SIZE), background=BG_TOP)
        style.configure("TEntry", font=get_font(FONT_SIZE))
        style.configure("TFrame", background=BG_TOP)
    except tk.TclError:
        pass

    # Status bar at top: connection state (no popup on error)
    status_frame = tk.Frame(root, bg=BG_STATUS_ERROR, padx=12, pady=6)
    status_frame.pack(fill=tk.X)
    status_lbl = tk.Label(
        status_frame,
        text="連接不到服務器",
        font=get_font(FONT_SIZE_BUTTONS),
        bg=BG_STATUS_ERROR,
        fg=FG_STATUS_ERROR,
        anchor=tk.CENTER,
    )
    status_lbl.pack(fill=tk.X)

    def set_status_ok():
        status_frame.configure(bg=BG_STATUS_OK)
        status_lbl.configure(text="連接正常", bg=BG_STATUS_OK, fg=FG_STATUS_OK)

    def set_status_error():
        status_frame.configure(bg=BG_STATUS_ERROR)
        status_lbl.configure(text="連接不到服務器", bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR)

    def set_status_message(msg: str, is_error: bool = False) -> None:
        """Show a message on the status bar."""
        if is_error:
            status_frame.configure(bg=BG_STATUS_ERROR)
            status_lbl.configure(text=msg, bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR)
        else:
            status_frame.configure(bg=BG_STATUS_OK)
            status_lbl.configure(text=msg, bg=BG_STATUS_OK, fg=FG_STATUS_OK)

    # Laser: keep connection open for repeated sends; close when switching away
    laser_conn_ref: list = [None]
    laser_send_busy_ref: list = [False]

    # Top: left [前一天] [date] [後一天]  |  right [printer dropdown]
    top = tk.Frame(root, padx=12, pady=12, bg=BG_TOP)
    top.pack(fill=tk.X)

    date_var = tk.StringVar(value=date.today().isoformat())
    printer_options = ["打袋機 DataFlex", "標簽機", "激光機"]
    printer_var = tk.StringVar(value=printer_options[0])

    def go_prev_day() -> None:
        try:
            d = date.fromisoformat(date_var.get().strip())
            date_var.set((d - timedelta(days=1)).isoformat())
            load_job_orders(from_user_date_change=True)
        except ValueError:
            date_var.set(date.today().isoformat())
            load_job_orders(from_user_date_change=True)

    def go_next_day() -> None:
        try:
            d = date.fromisoformat(date_var.get().strip())
            date_var.set((d + timedelta(days=1)).isoformat())
            load_job_orders(from_user_date_change=True)
        except ValueError:
            date_var.set(date.today().isoformat())
            load_job_orders(from_user_date_change=True)

    # 前一天 (previous day) with left arrow icon
    btn_prev = ttk.Button(top, text="◀  前一天", command=go_prev_day)
    btn_prev.pack(side=tk.LEFT, padx=(0, 8))

    # Date field (no "日期:" label); shorter width
    date_entry = tk.Entry(
        top,
        textvariable=date_var,
        font=get_font(FONT_SIZE),
        width=10,
        bg="white",
    )
    date_entry.pack(side=tk.LEFT, padx=(0, 8), ipady=4)

    # 後一天 (next day) with right arrow icon
    btn_next = ttk.Button(top, text="後一天  ▶", command=go_next_day)
    btn_next.pack(side=tk.LEFT, padx=(0, 8))

    # Top right: Setup button + printer selection
    right_frame = tk.Frame(top, bg=BG_TOP)
    right_frame.pack(side=tk.RIGHT)
    ttk.Button(right_frame, text="設定", command=lambda: open_setup_window(root, settings, base_url_ref)).pack(
        side=tk.LEFT, padx=(0, 12)
    )
    # 列印機 label: green when printer connected, red when not (checked periodically)
    printer_status_lbl = tk.Label(
        right_frame,
        text="列印機：",
        font=get_font(FONT_SIZE),
        bg=BG_STATUS_ERROR,
        fg="black",
        padx=6,
        pady=2,
    )
    printer_status_lbl.pack(side=tk.LEFT, padx=(0, 4))
    printer_combo = ttk.Combobox(
        right_frame,
        textvariable=printer_var,
        values=printer_options,
        state="readonly",
        width=14,
        font=get_font(FONT_SIZE),
    )
    printer_combo.pack(side=tk.LEFT)

    printer_after_ref = [None]

    def set_printer_status_ok():
        printer_status_lbl.configure(bg=BG_STATUS_OK, fg=FG_STATUS_OK)

    def set_printer_status_error():
        printer_status_lbl.configure(bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR)

    def check_printer() -> None:
        if printer_after_ref[0] is not None:
            root.after_cancel(printer_after_ref[0])
            printer_after_ref[0] = None
        ok = try_printer_connection(printer_var.get(), settings)
        if ok:
            set_printer_status_ok()
            printer_after_ref[0] = root.after(PRINTER_CHECK_MS, check_printer)
        else:
            set_printer_status_error()
            printer_after_ref[0] = root.after(PRINTER_RETRY_MS, check_printer)

    def on_printer_selection_changed(*args) -> None:
        check_printer()
        if printer_var.get() != "激光機":
            if laser_conn_ref[0] is not None:
                try:
                    laser_conn_ref[0].close()
                except Exception:
                    pass
                laser_conn_ref[0] = None

    printer_var.trace_add("write", on_printer_selection_changed)

    def open_setup_window(parent_win: tk.Tk, sett: dict, base_url_ref_list: list) -> None:
        """Modal setup: API IP/port, 打袋機/激光機 IP+port, 標簽機 COM port."""
        d = tk.Toplevel(parent_win)
        d.title("設定")
        d.geometry("440x520")
        d.transient(parent_win)
        d.grab_set()
        d.configure(bg=BG_TOP)
        f = tk.Frame(d, padx=16, pady=16, bg=BG_TOP)
        f.pack(fill=tk.BOTH, expand=True)
        grid_row = [0]  # use list so inner function can update

        def _ensure_dot_in_entry(entry: tk.Entry) -> None:
            """Allow typing dot (.) in Entry when IME or layout blocks it (e.g. 192.168.17.27)."""
            def on_key(event):
                if event.keysym in ("period", "decimal"):
                    pos = entry.index(tk.INSERT)
                    entry.insert(tk.INSERT, ".")
                    return "break"
            entry.bind("<KeyPress>", on_key)

        def add_section(label_text: str, key_ip: str | None, key_port: str | None, key_single: str | None):
            out = []
            ttk.Label(f, text=label_text, font=get_font(FONT_SIZE_BUTTONS)).grid(
                row=grid_row[0], column=0, columnspan=2, sticky=tk.W, pady=(8, 2)
            )
            grid_row[0] += 1
            if key_single:
                ttk.Label(
                    f,
                    text="列印機名稱 (Windows)：",
                ).grid(
                    row=grid_row[0],
                    column=0,
                    sticky=tk.W,
                    pady=2,
                )
                var = tk.StringVar(value=sett.get(key_single, ""))
                e = tk.Entry(f, textvariable=var, width=22, font=get_font(FONT_SIZE), bg="white")
                e.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2)
                _ensure_dot_in_entry(e)
                grid_row[0] += 1
                return [(key_single, var)]
            if key_ip:
                ttk.Label(f, text="IP：").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2)
                var_ip = tk.StringVar(value=sett.get(key_ip, ""))
                e_ip = tk.Entry(f, textvariable=var_ip, width=22, font=get_font(FONT_SIZE), bg="white")
                e_ip.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2)
                _ensure_dot_in_entry(e_ip)
                grid_row[0] += 1
                out.append((key_ip, var_ip))
            if key_port:
                ttk.Label(f, text="Port：").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2)
                var_port = tk.StringVar(value=sett.get(key_port, ""))
                e_port = tk.Entry(f, textvariable=var_port, width=12, font=get_font(FONT_SIZE), bg="white")
                e_port.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2)
                _ensure_dot_in_entry(e_port)
                grid_row[0] += 1
                out.append((key_port, var_port))
            return out

        all_vars = []
        all_vars.extend(add_section("API 伺服器", "api_ip", "api_port", None))
        all_vars.extend(add_section("打袋機 DataFlex", "dabag_ip", "dabag_port", None))
        all_vars.extend(add_section("激光機", "laser_ip", "laser_port", None))
        all_vars.extend(add_section("標簽機 (USB)", None, None, "label_com"))

        def on_save():
            for key, var in all_vars:
                sett[key] = var.get().strip()
            save_settings(sett)
            base_url_ref_list[0] = build_base_url(sett["api_ip"], sett["api_port"])
            d.destroy()

        btn_f = tk.Frame(d, bg=BG_TOP)
        btn_f.pack(pady=12)
        ttk.Button(btn_f, text="儲存", command=on_save).pack(side=tk.LEFT, padx=4)
        ttk.Button(btn_f, text="取消", command=d.destroy).pack(side=tk.LEFT, padx=4)
        d.wait_window()

    job_orders_frame = tk.Frame(root, bg=BG_LIST)
    job_orders_frame.pack(fill=tk.BOTH, expand=True, padx=12, pady=12)

    search_var = tk.StringVar()
    search_frame = tk.Frame(job_orders_frame, bg=BG_LIST)
    search_frame.pack(fill=tk.X, pady=(0, 6))
    tk.Label(
        search_frame,
        text="搜尋品號／工單／批號：",
        font=get_font(FONT_SIZE_QTY),
        bg=BG_LIST,
        fg="black",
    ).pack(side=tk.LEFT, padx=(0, 6))
    search_entry = tk.Entry(
        search_frame,
        textvariable=search_var,
        width=32,
        font=get_font(FONT_SIZE_QTY),
        bg="white",
    )
    search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 8))

    # Scrollable area for buttons
    canvas = tk.Canvas(job_orders_frame, highlightthickness=0, bg=BG_LIST)
    scrollbar = ttk.Scrollbar(job_orders_frame, orient=tk.VERTICAL, command=canvas.yview)
    inner = tk.Frame(canvas, bg=BG_LIST)

    win_id = canvas.create_window((0, 0), window=inner, anchor=tk.NW)
    canvas.configure(yscrollcommand=scrollbar.set)

    def _on_inner_configure(event):
        canvas.configure(scrollregion=canvas.bbox("all"))

    def _on_canvas_configure(event):
        canvas.itemconfig(win_id, width=event.width)

    inner.bind("<Configure>", _on_inner_configure)
    canvas.bind("<Configure>", _on_canvas_configure)

    # Mouse wheel: default Tk scroll speed (one unit per notch)
    def _on_mousewheel(event):
        if getattr(event, "delta", None) is not None:
            canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
        elif event.num == 5:
            canvas.yview_scroll(1, "units")
        elif event.num == 4:
            canvas.yview_scroll(-1, "units")

    canvas.bind("<MouseWheel>", _on_mousewheel)
    inner.bind("<MouseWheel>", _on_mousewheel)
    canvas.bind("<Button-4>", _on_mousewheel)
    canvas.bind("<Button-5>", _on_mousewheel)
    inner.bind("<Button-4>", _on_mousewheel)
    inner.bind("<Button-5>", _on_mousewheel)

    scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
    canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

    # Track which row is highlighted (selected for printing) and which job id
    selected_row_holder = [None]  # [tk.Frame | None]
    selected_jo_id_ref = [None]   # [int | None] job order id for selection preservation
    last_data_ref = [None]        # [list | None] last successful fetch for current date
    last_plan_start_ref = [date.today()]  # plan date for the current list (search filter uses same)
    after_id_ref = [None]         # [str | None] root.after id to cancel retry/refresh

    def _data_equal(a: Optional[list], b: Optional[list]) -> bool:
        if a is None or b is None:
            return a is b
        if len(a) != len(b):
            return False
        for x, y in zip(a, b):
            if x.get("id") != y.get("id"):
                return False
            for k in ("bagPrintedQty", "labelPrintedQty", "laserPrintedQty"):
                if x.get(k) != y.get(k):
                    return False
        return True

    def _build_list_from_data(data: list, plan_start: date, preserve_selection: bool) -> None:
        selected_row_holder[0] = None
        year = plan_start.year
        selected_id = selected_jo_id_ref[0] if preserve_selection else None
        found_row = None
        for jo in data:
            jo_id = jo.get("id")
            raw_batch = batch_no(year, jo_id) if jo_id is not None else "—"
            lot_no_val = jo.get("lotNo")
            batch = (lot_no_val or "—").strip() if lot_no_val else "—"
            jo_no_display = (jo.get("code") or "").strip()
            if not jo_no_display and jo_id is not None:
                jo_no_display = raw_batch
            elif not jo_no_display:
                jo_no_display = "—"
            # Line 1: job order no.; line 2: 需求 + 已印（袋／標／激）on one row for compact scrolling
            head_line = f"工單：{jo_no_display}"
            item_code = jo.get("itemCode") or "—"
            item_name = jo.get("itemName") or "—"
            req_qty = jo.get("reqQty")
            qty_str = format_qty(req_qty)
            bag_pq = _printed_qty_int(jo.get("bagPrintedQty"))
            label_pq = _printed_qty_int(jo.get("labelPrintedQty"))
            laser_pq = _printed_qty_int(jo.get("laserPrintedQty"))
            meta_line = (
                f"需求：{qty_str}　"
                f"已印　袋{bag_pq:,}　標{label_pq:,}　激{laser_pq:,}"
            )
            # Columns: fixed-width left | fixed-width 品號 | 品名 (expand)
            row = tk.Frame(
                inner,
                bg=BG_ROW,
                relief=tk.RAISED,
                bd=2,
                cursor="hand2",
                padx=10,
                pady=LIST_ROW_IPADY,
            )
            row.pack(fill=tk.X, pady=LIST_ROW_PADY)

            left = tk.Frame(row, bg=BG_ROW, width=LEFT_COL_WIDTH_PX)
            left.pack_propagate(False)
            left.pack(side=tk.LEFT, anchor=tk.NW, fill=tk.Y)
            batch_lbl = tk.Label(
                left,
                text=head_line,
                font=get_font(FONT_SIZE_BUTTONS),
                bg=BG_ROW,
                fg="black",
            )
            batch_lbl.pack(anchor=tk.W)
            meta_lbl = tk.Label(
                left,
                text=meta_line,
                font=get_font(FONT_SIZE_META),
                bg=BG_ROW,
                fg="#222222",
                anchor=tk.W,
                justify=tk.LEFT,
                wraplength=LEFT_COL_WIDTH_PX - 8,
            )
            meta_lbl.pack(anchor=tk.W)

            code_col = tk.Frame(row, bg=BG_ROW, width=CODE_COL_WIDTH_PX)
            code_col.pack_propagate(False)
            code_col.pack(side=tk.LEFT, anchor=tk.NW, fill=tk.Y, padx=(6, 2))
            code_lbl = tk.Label(
                code_col,
                text=item_code,
                font=get_font(FONT_SIZE_ITEM_CODE),
                bg=BG_ROW,
                fg="black",
                wraplength=ITEM_CODE_WRAP,
                justify=tk.LEFT,
                anchor=tk.NW,
            )
            code_lbl.pack(anchor=tk.NW)

            name_col = tk.Frame(row, bg=BG_ROW)
            name_col.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.NW)
            name_lbl = tk.Label(
                name_col,
                text=item_name or "—",
                font=get_font(FONT_SIZE_ITEM_NAME),
                bg=BG_ROW,
                fg="black",
                wraplength=ITEM_NAME_WRAP,
                justify=tk.LEFT,
                anchor=tk.NW,
            )
            name_lbl.pack(anchor=tk.NW)

            def _on_click(e, j=jo, b=batch, r=row):
                if selected_row_holder[0] is not None:
                    set_row_highlight(selected_row_holder[0], False)
                set_row_highlight(r, True)
                selected_row_holder[0] = r
                selected_jo_id_ref[0] = j.get("id")
                if printer_var.get() == "打袋機 DataFlex":
                    ip = (settings.get("dabag_ip") or "").strip()
                    port_str = (settings.get("dabag_port") or "3008").strip()
                    try:
                        port = int(port_str)
                    except ValueError:
                        port = 3008
                    if not ip:
                        messagebox.showerror("打袋機", "請在設定中填寫打袋機 DataFlex 的 IP。")
                    else:
                        count = ask_bag_count(root)
                        if count is not None:
                            item_code = j.get("itemCode") or "—"
                            item_name = j.get("itemName") or "—"
                            item_id = j.get("itemId")
                            stock_in_line_id = j.get("stockInLineId")
                            lot_no = j.get("lotNo")
                            zpl = generate_zpl_dataflex(
                                b,
                                item_code,
                                item_name,
                                item_id=item_id,
                                stock_in_line_id=stock_in_line_id,
                                lot_no=lot_no,
                            )
                            label_text = (lot_no or b).strip()
                            n = count
                            try:
                                for i in range(n):
                                    send_zpl_to_dataflex(ip, port, zpl)
                                    if i < n - 1:
                                        time.sleep(2)
                                set_status_message(f"已送出列印：批次 {label_text} x {n} 張", is_error=False)
                                jo_id = j.get("id")
                                if jo_id is not None:
                                    try:
                                        submit_job_order_print_submit(
                                            base_url_ref[0], int(jo_id), n, "DATAFLEX"
                                        )
                                        load_job_orders(from_user_date_change=False)
                                    except requests.RequestException as ex:
                                        messagebox.showwarning(
                                            "打袋機",
                                            f"已送出 {n} 張，但伺服器記錄失敗：{ex}",
                                        )
                            except ConnectionRefusedError:
                                set_status_message(f"無法連線至 {ip}:{port}，請確認印表機已開機且 IP 正確。", is_error=True)
                            except socket.timeout:
                                set_status_message(f"連線逾時 ({ip}:{port})，請檢查網路與連接埠。", is_error=True)
                            except OSError as err:
                                set_status_message(f"列印失敗：{err}", is_error=True)
                elif printer_var.get() == "標簽機":
                    com = (settings.get("label_com") or "").strip()
                    if not com:
                        messagebox.showerror("標簽機", "請在設定中填寫標簽機名稱 (例如：TSC TTP-246M Pro)。")
                    else:
                        count = ask_label_count(root)
                        if count is not None:
                            item_code = j.get("itemCode") or "—"
                            item_name = j.get("itemName") or "—"
                            item_id = j.get("itemId")
                            stock_in_line_id = j.get("stockInLineId")
                            lot_no = j.get("lotNo")
                            n = count
                            try:
                                # Always render to image (Chinese OK), then send as ZPL graphic (^GFA).
                                # This is more reliable than Windows GDI and works for both Windows printer name and COM.
                                if not _HAS_PIL_QR:
                                    raise RuntimeError("請先安裝 Pillow + qrcode（pip install Pillow qrcode[pil]）。")
                                label_img = render_label_to_image(
                                    b, item_code, item_name,
                                    item_id=item_id, stock_in_line_id=stock_in_line_id,
                                    lot_no=lot_no,
                                )
                                zpl_img = _image_to_zpl_gfa(label_img)
                                for i in range(n):
                                    send_zpl_to_label_printer(com, zpl_img)
                                    if i < n - 1:
                                        time.sleep(0.5)
                                jo_id = j.get("id")
                                if jo_id is not None:
                                    try:
                                        submit_job_order_print_submit(
                                            base_url_ref[0], int(jo_id), n, "LABEL"
                                        )
                                        load_job_orders(from_user_date_change=False)
                                        messagebox.showinfo(
                                            "標籤機",
                                            f"已送出列印：{n} 張標籤（已記錄）",
                                        )
                                    except requests.RequestException as ex:
                                        messagebox.showwarning(
                                            "標籤機",
                                            f"標籤已列印 {n} 張，但伺服器記錄失敗：{ex}",
                                        )
                                else:
                                    messagebox.showwarning(
                                        "標籤機",
                                        f"已送出列印：{n} 張標籤（無工單 id，無法寫入伺服器記錄）",
                                    )
                            except Exception as err:
                                messagebox.showerror("標簽機", f"列印失敗：{err}")
                elif printer_var.get() == "激光機":
                    ip = (settings.get("laser_ip") or "").strip()
                    port_str = (settings.get("laser_port") or "45678").strip()
                    try:
                        port = int(port_str)
                    except ValueError:
                        port = 45678
                    if not ip:
                        set_status_message("請在設定中填寫激光機的 IP。", is_error=True)
                    else:
                        item_id = j.get("itemId")
                        stock_in_line_id = j.get("stockInLineId")
                        item_code_val = j.get("itemCode") or ""
                        item_name_val = j.get("itemName") or ""
                        run_laser_row_send_thread(
                            root=root,
                            laser_conn_ref=laser_conn_ref,
                            laser_busy_ref=laser_send_busy_ref,
                            ip=ip,
                            port=port,
                            item_id=item_id,
                            stock_in_line_id=stock_in_line_id,
                            item_code=item_code_val,
                            item_name=item_name_val,
                            set_status_message=set_status_message,
                            base_url=base_url_ref[0],
                            job_order_id=j.get("id"),
                            on_recorded=lambda: load_job_orders(from_user_date_change=False),
                        )

            for w in (
                row,
                left,
                batch_lbl,
                meta_lbl,
                code_col,
                code_lbl,
                name_col,
                name_lbl,
            ):
                w.bind("<Button-1>", _on_click)
                w.bind("<MouseWheel>", _on_mousewheel)
                w.bind("<Button-4>", _on_mousewheel)
                w.bind("<Button-5>", _on_mousewheel)
            if preserve_selection and selected_id is not None and jo.get("id") == selected_id:
                found_row = row
        if found_row is not None:
            set_row_highlight(found_row, True)
            selected_row_holder[0] = found_row

    def refresh_visible_list() -> None:
        """Re-apply search filter to last fetched rows without hitting the API."""
        raw = last_data_ref[0]
        if raw is None:
            return
        ps = last_plan_start_ref[0]
        needle = search_var.get().strip()
        shown = _filter_job_orders_by_search(raw, needle) if needle else raw
        for w in inner.winfo_children():
            w.destroy()
        _build_list_from_data(shown, ps, preserve_selection=True)

    search_entry.bind("<KeyRelease>", lambda e: refresh_visible_list())

    def load_job_orders(from_user_date_change: bool = False) -> None:
        if after_id_ref[0] is not None:
            root.after_cancel(after_id_ref[0])
            after_id_ref[0] = None
        date_str = date_var.get().strip()
        try:
            plan_start = date.fromisoformat(date_str)
        except ValueError:
            messagebox.showerror("日期錯誤", f"請使用 yyyy-MM-dd 格式。目前：{date_str}")
            return
        if from_user_date_change:
            selected_row_holder[0] = None
            selected_jo_id_ref[0] = None
        try:
            data = fetch_job_orders(base_url_ref[0], plan_start)
        except requests.RequestException:
            set_status_error()
            after_id_ref[0] = root.after(RETRY_MS, lambda: load_job_orders(from_user_date_change=False))
            return
        set_status_ok()
        old_data = last_data_ref[0]
        last_data_ref[0] = data
        last_plan_start_ref[0] = plan_start
        data_changed = not _data_equal(old_data, data)
        if data_changed or from_user_date_change:
            # Rebuild list: clear and rebuild from current data (last_data_ref already updated)
            for w in inner.winfo_children():
                w.destroy()
            preserve = not from_user_date_change
            needle = search_var.get().strip()
            shown = _filter_job_orders_by_search(data, needle) if needle else data
            _build_list_from_data(shown, plan_start, preserve_selection=preserve)
        if from_user_date_change:
            canvas.yview_moveto(0)
        after_id_ref[0] = root.after(REFRESH_MS, lambda: load_job_orders(from_user_date_change=False))

    # Load default (today) on start; then start printer connection check
    root.after(100, lambda: load_job_orders(from_user_date_change=True))
    root.after(300, check_printer)

    root.mainloop()


if __name__ == "__main__":
    main()