"""
plate_utils.py — เครื่องมือประมวลผลภาพ + ตัวกรอง/จัดรูปแบบข้อความป้ายทะเบียน

แยกออกมาจาก pipeline เพื่อให้ทดสอบ (unit test) ได้โดยไม่ต้องโหลดโมเดล AI
"""
from __future__ import annotations

import base64
import binascii
import re
import unicodedata
from typing import List, Optional, Tuple

import cv2
import numpy as np

from .config import settings

# =====================================================================
#  ส่วนที่ 1 : แปลง Base64 -> ภาพ OpenCV (BGR)
# =====================================================================

def decode_base64_image(b64: str) -> np.ndarray:
    """
    รับ Base64 จากหน้าเว็บ (รองรับทั้งแบบมีและไม่มี prefix 'data:image/jpeg;base64,')
    คืนภาพ OpenCV BGR

    โยน ValueError พร้อมข้อความภาษาไทยเมื่อข้อมูลเสีย เพื่อให้ชั้น API
    จับแล้วตอบกลับได้อย่างนุ่มนวล (ไม่ทำให้ Backend ล่ม)
    """
    if not b64 or not isinstance(b64, str):
        raise ValueError("ไม่พบข้อมูลภาพ")

    # ตัด data URL prefix ออกถ้ามี
    if "," in b64[:64] and b64.lstrip().startswith("data:"):
        b64 = b64.split(",", 1)[1]

    b64 = "".join(b64.split())          # ลบ \n \r ช่องว่างที่อาจติดมา
    b64 += "=" * (-len(b64) % 4)        # เติม padding ให้ครบ 4

    try:
        raw = base64.b64decode(b64, validate=False)
    except (binascii.Error, ValueError) as exc:
        raise ValueError("ถอดรหัส Base64 ไม่สำเร็จ") from exc

    if not raw:
        raise ValueError("ข้อมูลภาพว่างเปล่า")
    if len(raw) > settings.MAX_IMAGE_BYTES:
        raise ValueError("ไฟล์ภาพใหญ่เกินกำหนด")

    img = cv2.imdecode(np.frombuffer(raw, dtype=np.uint8), cv2.IMREAD_COLOR)
    if img is None or img.size == 0:
        raise ValueError("ไฟล์ไม่ใช่รูปภาพที่อ่านได้")
    return img


# =====================================================================
#  ส่วนที่ 2 : ปรับภาพให้ทน "แดดจัด + แสงสะท้อน"
# =====================================================================

def reduce_glare(bgr: np.ndarray) -> np.ndarray:
    """
    ลดแสงสะท้อนจ้าบนแผ่นป้าย (specular highlight)

    วิธี: หา pixel ที่สว่างจนเกือบขาว (>=250) แล้ว inpaint เติมสีจากรอบข้าง
    ช่วยกรณีแดดตกกระทบป้ายเป็นดวงจนตัวเลขหาย
    """
    gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
    _, mask = cv2.threshold(gray, 250, 255, cv2.THRESH_BINARY)
    # ถ้าจุดสว่างจัดน้อยกว่า 0.2% ของภาพ ถือว่าไม่มีปัญหา ไม่ต้องเสียเวลา inpaint
    if cv2.countNonZero(mask) < 0.002 * mask.size:
        return bgr
    mask = cv2.dilate(mask, np.ones((5, 5), np.uint8), iterations=1)
    return cv2.inpaint(bgr, mask, 3, cv2.INPAINT_TELEA)


def upscale_plate(crop: np.ndarray, target_h: int = 320, max_scale: float = 6.0) -> np.ndarray:
    """
    ขยายภาพป้ายให้สูงราว 320px ก่อนส่งเข้า OCR

    ตัวเลข 320 มาจากการทดสอบจริงกับภาพป้ายรถบรรทุกที่บีบอัดแล้ว:
    ป้ายที่ crop ได้จากภาพ 1280px มักสูงเพียง 60–120px ซึ่ง EasyOCR อ่านพลาดบ่อย
    การขยายด้วย INTER_CUBIC ก่อน ช่วยให้อ่านถูกขึ้นชัดเจน
    (เดิมตั้งไว้แค่ 96px ซึ่งน้อยเกินไป)
    """
    if crop is None or crop.size == 0:
        return crop
    h, w = crop.shape[:2]
    scale = min(max(target_h / max(h, 1), 1.0), max_scale)
    if scale > 1.0:
        crop = cv2.resize(crop, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_CUBIC)
    return crop


def apply_clahe(bgr: np.ndarray, clip: float = 2.0) -> np.ndarray:
    """
    CLAHE บนช่อง L ของ LAB — ดึงคอนทราสต์เฉพาะจุด
    แก้ป้ายที่มีเงาพาดครึ่งได้ดีกว่า histogram equalization ทั้งภาพ
    """
    l, a, b = cv2.split(cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB))
    l = cv2.createCLAHE(clipLimit=clip, tileGridSize=(8, 8)).apply(l)
    return cv2.cvtColor(cv2.merge((l, a, b)), cv2.COLOR_LAB2BGR)


def black_hat(bgr: np.ndarray) -> np.ndarray:
    """
    Black-hat morphology — เน้น "ร่องมืด" ของตัวอักษรที่แคบกว่าขนาด kernel

    ใช้แก้ป้ายเก่าที่สีลอกจนตัวเลขนูนเป็นสีเดียวกับแผ่นป้าย (คอนทราสต์เกือบศูนย์)
    ซึ่ง CLAHE / Otsu / threshold ทั่วไปทำอะไรไม่ได้เลย เพราะไม่มีความต่างของ "ความสว่าง"
    ให้ดึง — แต่ยังมีเงาในร่องตัวอักษรอยู่ ซึ่ง black-hat จับได้

    ที่มา: ทดสอบกับภาพป้าย 80-6378 จากหน้างานจริงที่สีลอกทั้งแผ่น
    วิธีอื่นอ่านผิดหมด (ได้ 80-5378 / 80-3378 / 00-6378) มีเพียง black-hat ที่อ่านถูก
    """
    gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
    # ขนาด kernel ต้องใหญ่กว่าความหนาของเส้นตัวอักษร และเป็นเลขคี่
    k = max(9, (gray.shape[0] // 12) | 1)
    bh = cv2.morphologyEx(gray, cv2.MORPH_BLACKHAT,
                          cv2.getStructuringElement(cv2.MORPH_RECT, (k, k)))
    bh = cv2.normalize(bh, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
    return cv2.cvtColor(255 - bh, cv2.COLOR_GRAY2BGR)   # กลับเป็นตัวดำพื้นขาว


def enhance_plate(crop: np.ndarray) -> np.ndarray:
    """ภาพป้ายเวอร์ชันมาตรฐาน: ขยาย + CLAHE (คงชื่อฟังก์ชันเดิมไว้)"""
    return apply_clahe(upscale_plate(crop))


def variant_images(crop: np.ndarray) -> List[np.ndarray]:
    """
    สร้างภาพหลายเวอร์ชันของป้ายเดียวกัน ส่งให้ OCR อ่านทุกแบบแล้วโหวตกัน

    ลำดับนี้มาจากการวัดผลจริงกับภาพป้ายรถบรรทุกไทยที่ผ่านการบีบอัดหน้างาน
    (เรียงจากที่ได้ผลบ่อยที่สุดไปน้อยที่สุด — ตัวแรกมักพอ):
      v0 = ขยายอย่างเดียว      ← แม่นที่สุดในภาพสภาพปกติ
      v1 = ขยาย + CLAHE        ← ช่วยเมื่อป้ายมีเงาพาดหรือสีซีด
      v2 = ขยาย + black-hat    ← ช่วยเมื่อป้ายเก่าสีลอกจนตัวเลขกลืนกับแผ่น
      v3 = ขยาย + Otsu ขาวดำ   ← ช่วยเมื่อป้ายเหลืองฟุ้ง/คอนทราสต์สูง

    บันทึกจากการทดสอบกับภาพจริง (อย่าใส่กลับเข้ามาโดยไม่วัดผลก่อน):
      * bilateral filter + adaptive threshold — ทำให้อ่านแย่ลงกับป้ายที่ crop ได้เล็ก
      * background division — เคยใส่ไว้ แต่กับป้าย 80-6378 มันให้คำตอบ "80-5378"
        ที่ผิดแต่ผ่าน Regex ทุกด่าน (อันตรายกว่าอ่านไม่ออก) และไม่เคยช่วยกรณีไหน
        ที่ variant อื่นทำไม่ได้ จึงถอดออกและใส่ black-hat แทน
    """
    if crop is None or crop.size == 0:
        return []

    base = upscale_plate(crop)
    gray = cv2.cvtColor(base, cv2.COLOR_BGR2GRAY)
    _, otsu = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

    return [
        base,
        apply_clahe(base, 3.0),
        black_hat(base),
        cv2.cvtColor(otsu, cv2.COLOR_GRAY2BGR),
    ]


def expand_box(x1: int, y1: int, x2: int, y2: int,
               shape: Tuple[int, int], pad_ratio: float = 0.06) -> Tuple[int, int, int, int]:
    """ขยายกรอบออกเล็กน้อยกันตัดขอบตัวอักษรขาด แล้ว clamp ไม่ให้ออกนอกภาพ"""
    h, w = shape[:2]
    pw = int((x2 - x1) * pad_ratio)
    ph = int((y2 - y1) * pad_ratio)
    return (max(0, x1 - pw), max(0, y1 - ph), min(w, x2 + pw), min(h, y2 + ph))


# =====================================================================
#  ส่วนที่ 3 : ล้างข้อความ + Regex คัดกรองเลขทะเบียนรถบรรทุก
# =====================================================================

# แม็ปตัวอักษรที่ OCR มักสับสนกับตัวเลข (ใช้เฉพาะตอนตีความส่วนที่ควรเป็นตัวเลข)
_DIGIT_LOOKALIKE = str.maketrans({
    "O": "0", "o": "0", "Q": "0", "D": "0", "๐": "0",
    "I": "1", "l": "1", "|": "1", "i": "1", "๑": "1",
    "Z": "2", "z": "2", "๒": "2",
    "E": "3", "๓": "3",
    "A": "4", "๔": "4",
    "S": "5", "s": "5", "๕": "5",
    "G": "6", "b": "6", "๖": "6",
    "T": "7", "๗": "7",
    "B": "8", "๘": "8",
    "g": "9", "q": "9", "๙": "9",
})

# อักขระที่อนุญาตให้เหลืออยู่: ตัวเลข, อักษรไทย (สำหรับชื่อจังหวัด/หมวดอักษร), ช่องว่าง, ขีด
_ALLOWED = re.compile(r"[^0-9฀-๿ \-]")

def _build_prefix_pattern(lo: int, hi: int) -> str:
    """
    สร้างส่วน regex ของหมวดเลข 2 หลัก ตามช่วงที่ตั้งไว้ใน .env (PLATE_PREFIX_MIN/MAX)

    เดิมโค้ดเขียนช่วง 70–85 ตายตัวไว้ในตัว regex เอง (7[0-9]|8[0-5]) ทำให้ต่อให้ไปแก้
    PLATE_PREFIX_MAX ใน .env เป็นเท่าไหร่ก็ไม่มีผล เพราะ regex ไม่มีทางจับเลขนอกช่วงนี้ได้ตั้งแต่แรก
    เช่นป้ายจริงที่ขึ้นต้นด้วย 89 จะหลุดไปเป็น "รูปแบบไม่ตรง" เสมอ ทั้งที่ AI อ่านตัวเลขถูกทุกตัว
    จึงเปลี่ยนมาสร้าง regex จากค่าตั้งค่าโดยตรง แก้แค่ .env ก็มีผลจริงโดยไม่ต้องแก้โค้ด
    """
    lo = max(0, min(99, lo))
    hi = max(0, min(99, hi))
    if lo > hi:
        lo, hi = hi, lo
    return "(?:" + "|".join(f"{n:02d}" for n in range(lo, hi + 1)) + ")"


_PREFIX_PATTERN = _build_prefix_pattern(settings.PLATE_PREFIX_MIN, settings.PLATE_PREFIX_MAX)

# ป้ายรถบรรทุก: ตัวเลข 2 หลักตามช่วงที่ตั้งค่าใน .env + คั่น + ตัวเลข 4 หลัก
#   60-69, 70-79 = รถบรรทุกรับจ้าง (ป้ายเหลือง) · 50-59, 80-99 = รถบรรทุกส่วนบุคคล (ป้ายขาว)
# ตัวคั่นยอมรับได้ทั้ง ไม่มี / '-' / ช่องว่าง / จุด (OCR มักอ่านขีดเป็นจุดหรือหาย)
TRUCK_PLATE_RE = re.compile(rf"({_PREFIX_PATTERN})\s*[-–—.·]?\s*([0-9]{{4}})")

# ป้ายขาวรถยนต์ทั่วไป (กก 1234) — ใช้เป็น fallback แจ้งเตือนว่า "นี่ไม่ใช่รถบรรทุก"
CAR_PLATE_RE = re.compile(r"([ก-ฮ]{1,2})\s*([0-9]{1,4})")

# รายชื่อจังหวัด (บางส่วนที่พบบ่อยหน้างานขนส่ง) ใช้ตัดคำที่ OCR อ่านติดมาจากบรรทัดล่างของป้าย
_PROVINCE_HINT = (
    "กรุงเทพมหานคร", "กรุงเทพ", "สมุทรปราการ", "สมุทรสาคร", "สมุทรสงคราม", "นนทบุรี",
    "ปทุมธานี", "พระนครศรีอยุธยา", "อยุธยา", "สระบุรี", "ชลบุรี", "ระยอง", "ฉะเชิงเทรา",
    "ปราจีนบุรี", "นครปฐม", "ราชบุรี", "กาญจนบุรี", "เพชรบุรี", "นครราชสีมา", "ขอนแก่น",
    "อุดรธานี", "อุบลราชธานี", "เชียงใหม่", "เชียงราย", "ลำปาง", "พิษณุโลก", "นครสวรรค์",
    "สุราษฎร์ธานี", "สงขลา", "ภูเก็ต", "นครศรีธรรมราช", "ชุมพร", "ตาก", "สระแก้ว",
)


def clean_text(text: str) -> str:
    """
    ล้างอักขระพิเศษ/สัญลักษณ์แปลกๆ ให้เหลือเฉพาะ ตัวเลข อักษรไทย ช่องว่าง และขีด
    """
    if not text:
        return ""
    text = unicodedata.normalize("NFKC", text)
    text = _ALLOWED.sub(" ", text)
    text = re.sub(r"\s+", " ", text).strip()
    return text


def normalize_digits(text: str) -> str:
    """แปลงตัวอักษรที่หน้าตาเหมือนตัวเลข ให้กลายเป็นตัวเลข (ใช้ก่อนเข้า regex)"""
    return (text or "").translate(_DIGIT_LOOKALIKE)


def extract_province(texts: List[str]) -> Optional[str]:
    """ดึงชื่อจังหวัดจากข้อความที่ OCR อ่านได้ (ถ้ามี) — ใช้ประกอบการยืนยันตัวรถ"""
    joined = " ".join(texts)
    for p in _PROVINCE_HINT:
        if p in joined:
            return p
    return None


def collect_truck_plates(texts: List[str]) -> List[str]:
    """
    คืน "ผู้เข้าชิง" ทุกตัวที่ตรงรูปแบบป้ายรถบรรทุก (ยังไม่ตัดสิน)
    ใช้โดยระบบโหวตใน pipeline เพื่อกันการอ่านผิดแบบเงียบๆ
    """
    out: List[str] = []
    for c in _raw_candidates(texts):
        prefix = int(c[:2])
        if settings.PLATE_PREFIX_MIN <= prefix <= settings.PLATE_PREFIX_MAX:
            out.append(c)
    return out


def find_truck_plate(texts: List[str]) -> Optional[str]:
    """
    ค้นหาเลขทะเบียนรถบรรทุกจากทุกข้อความที่ AI อ่านได้

    ทำ 2 รอบเพื่อกันการอ่านสติกเกอร์/เบอร์โทร/ตัวหนังสือข้างรถผิดพลาด:
      รอบ 1 — ค้นหาในข้อความแต่ละก้อนแยกกัน (แม่นกว่า)
      รอบ 2 — ค้นหาในข้อความที่ต่อกันทั้งหมด (เผื่อ OCR ตัดบรรทัด '70' / '1234' แยกกัน)

    คืนค่าในรูปแบบมาตรฐาน 'NN-NNNN' หรือ None ถ้าไม่พบ
    """
    cands = collect_truck_plates(texts)
    if not cands:
        return None
    # ถ้าเจอหลายตัว เลือกตัวที่ปรากฏบ่อยที่สุด (โอกาสเป็นป้ายจริงสูงกว่าสติกเกอร์)
    return max(set(cands), key=cands.count)


def _raw_candidates(texts: List[str]) -> List[str]:
    """หา string ที่ตรงแพทเทิร์นทั้งหมด (ยังไม่กรองช่วงหมวด)"""
    candidates: List[str] = []

    # ---- รอบ 1: ทีละก้อนข้อความ ----
    # ลำดับสำคัญ: normalize_digits ต้องทำ "ก่อน" clean_text
    # เพราะ clean_text จะลบตัวอักษรละติน (O, l, S…) ทิ้ง ทำให้แปลงเป็นตัวเลขไม่ทัน
    for t in texts:
        norm = clean_text(normalize_digits(t))
        for m in TRUCK_PLATE_RE.finditer(norm):
            candidates.append(f"{m.group(1)}-{m.group(2)}")

    # ---- รอบ 2: ต่อทุกก้อนแล้วลบช่องว่าง ----
    if not candidates:
        joined = clean_text(normalize_digits(" ".join(texts)))
        squashed = re.sub(r"[\s\-–—.·]", "", joined)
        for m in re.finditer(rf"({_PREFIX_PATTERN})([0-9]{{4}})", squashed):
            candidates.append(f"{m.group(1)}-{m.group(2)}")

    return candidates


def find_car_plate(texts: List[str]) -> Optional[str]:
    """หาป้ายขาวรถยนต์ทั่วไป — ใช้บอกพนักงานว่า 'คันนี้ไม่ใช่ป้ายรถบรรทุก'"""
    for t in texts:
        c = clean_text(t)
        m = CAR_PLATE_RE.search(c)
        if m and len(m.group(2)) >= 3:
            return f"{m.group(1)} {m.group(2)}"
    return None


def normalize_manual_plate(raw: str) -> Optional[str]:
    """
    ทำความสะอาดเลขทะเบียนที่พนักงานพิมพ์มือ
    รับได้ทั้ง '701234', '70-1234', '70 1234', ' 70—1234 '
    คืน 'NN-NNNN' หรือ None ถ้ารูปแบบไม่ถูกต้อง
    """
    if not raw:
        return None
    digits = re.sub(r"\D", "", normalize_digits(unicodedata.normalize("NFKC", raw)))
    if len(digits) != 6:
        return None
    prefix = int(digits[:2])
    if not (settings.PLATE_PREFIX_MIN <= prefix <= settings.PLATE_PREFIX_MAX):
        return None
    return f"{digits[:2]}-{digits[2:]}"
