"""
Safir Otopark open-source plate reading module.

Engine:
- OpenCV for image preparation and plate candidate detection
- Tesseract OCR via pytesseract for text recognition

The module is intentionally isolated from the commercial UI so it can be
audited, replaced, or reused under the MIT license in this project.
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
import re
import shutil
from typing import Iterable, Optional

try:
    import cv2  # type: ignore
except Exception:  # pragma: no cover - optional dependency guard
    cv2 = None

try:
    import numpy as np  # type: ignore
except Exception:  # pragma: no cover - optional dependency guard
    np = None

try:
    import pytesseract  # type: ignore
except Exception:  # pragma: no cover - optional dependency guard
    pytesseract = None


TR_PLATE_RE = re.compile(r"^(0[1-9]|[1-7][0-9]|8[01])\s?[A-Z]{1,3}\s?[0-9]{2,4}$")
ALNUM_RE = re.compile(r"[^A-Z0-9]")
OCR_CONFIGS = (
    "--oem 3 --psm 7 -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
    "--oem 3 --psm 8 -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
    "--oem 3 --psm 6 -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
)


@dataclass(frozen=True)
class PlateReadResult:
    plate: str
    raw_text: str
    confidence: float
    is_valid: bool
    engine_available: bool
    message: str = ""


def normalize_plate(text: str) -> str:
    """Normalize OCR output to a Turkey plate-like value."""
    if not text:
        return ""

    replacements = str.maketrans(
        {
            "I": "I",
            "İ": "I",
            "ı": "I",
            "O": "O",
            "Ö": "O",
            "Ü": "U",
            "Ş": "S",
            "Ğ": "G",
            "Ç": "C",
        }
    )
    value = text.upper().translate(replacements)
    value = ALNUM_RE.sub("", value)

    # OCR often confuses letters and digits around the city code.
    value = value.replace("O", "0", 1) if value[:1] == "O" else value

    compact = value
    match = re.match(r"^([0-9]{2})([A-Z]{1,3})([0-9]{2,4})$", compact)
    if match:
        return f"{match.group(1)} {match.group(2)} {match.group(3)}"
    return compact


def compact_plate(plate: str) -> str:
    return ALNUM_RE.sub("", plate.upper())


def is_turkey_plate(plate: str) -> bool:
    normalized = normalize_plate(plate)
    return bool(TR_PLATE_RE.match(normalized))


def find_tesseract_cmd() -> Optional[str]:
    found = shutil.which("tesseract")
    if found:
        return found

    candidates = [
        Path(r"C:\Program Files\Tesseract-OCR\tesseract.exe"),
        Path(r"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe"),
        Path.home() / r"AppData\Local\Programs\Tesseract-OCR\tesseract.exe",
    ]
    for candidate in candidates:
        if candidate.exists():
            return str(candidate)
    return None


def engine_status() -> tuple[bool, str]:
    if cv2 is None:
        return False, "OpenCV kurulu degil."
    if np is None:
        return False, "NumPy kurulu degil."
    if pytesseract is None:
        return False, "pytesseract kurulu degil."

    tesseract_cmd = find_tesseract_cmd()
    if not tesseract_cmd:
        return False, "Tesseract OCR motoru bulunamadi."

    pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
    try:
        pytesseract.get_tesseract_version()
    except Exception as exc:
        return False, f"Tesseract calismiyor: {exc}"
    return True, "OpenCV + Tesseract hazir."


def _score_plate(text: str, confidence: float) -> tuple[int, float]:
    plate = normalize_plate(text)
    compact = compact_plate(plate)
    valid_bonus = 100 if is_turkey_plate(plate) else 0
    length_bonus = 20 if 6 <= len(compact) <= 9 else 0
    alpha_bonus = 10 if re.search(r"[A-Z]", compact) else 0
    digit_bonus = 10 if re.search(r"[0-9]", compact) else 0
    return valid_bonus + length_bonus + alpha_bonus + digit_bonus, confidence


def _ocr_image(image) -> Iterable[tuple[str, float]]:
    if pytesseract is None:
        return []

    results: list[tuple[str, float]] = []
    for config in OCR_CONFIGS:
        try:
            data = pytesseract.image_to_data(
                image,
                config=config,
                output_type=pytesseract.Output.DICT,
            )
            texts = data.get("text", [])
            confs = data.get("conf", [])
            joined_parts: list[str] = []
            confidence_values: list[float] = []
            for text, conf in zip(texts, confs):
                if not text:
                    continue
                try:
                    conf_value = float(conf)
                except Exception:
                    conf_value = -1.0
                if conf_value >= 0:
                    confidence_values.append(conf_value)
                joined_parts.append(str(text))
            if joined_parts:
                confidence = sum(confidence_values) / len(confidence_values) if confidence_values else 0.0
                results.append((" ".join(joined_parts), confidence))
        except Exception:
            continue
    return results


def _prepare_variants(gray):
    if cv2 is None:
        return []
    variants = [gray]
    try:
        variants.append(cv2.equalizeHist(gray))
        variants.append(cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[1])
        variants.append(cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 11))
    except Exception:
        pass
    return variants


def _plate_candidates(frame, max_candidates: int = 8):
    if cv2 is None:
        return []

    height, width = frame.shape[:2]
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    blurred = cv2.bilateralFilter(gray, 11, 17, 17)
    edged = cv2.Canny(blurred, 40, 180)
    contours, _ = cv2.findContours(edged, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

    boxes = []
    image_area = float(width * height)
    for contour in contours:
        x, y, w, h = cv2.boundingRect(contour)
        if h <= 0:
            continue
        aspect = w / float(h)
        area = float(w * h)
        if not (2.0 <= aspect <= 6.5):
            continue
        if not (image_area * 0.002 <= area <= image_area * 0.25):
            continue
        boxes.append((x, y, w, h, area))

    boxes.sort(key=lambda item: item[4], reverse=True)
    crops = []
    for x, y, w, h, _ in boxes[:max_candidates]:
        pad_x = int(w * 0.08)
        pad_y = int(h * 0.20)
        x0 = max(0, x - pad_x)
        y0 = max(0, y - pad_y)
        x1 = min(width, x + w + pad_x)
        y1 = min(height, y + h + pad_y)
        crop = frame[y0:y1, x0:x1]
        if crop.size:
            crops.append(crop)

    return crops


def read_plate_from_frame(frame) -> PlateReadResult:
    ready, message = engine_status()
    if not ready:
        return PlateReadResult("", "", 0.0, False, False, message)

    if cv2 is None:
        return PlateReadResult("", "", 0.0, False, False, "OpenCV kurulu degil.")

    best_text = ""
    best_conf = 0.0
    best_score = (-1, -1.0)
    images = [frame] + _plate_candidates(frame)

    for image in images:
        gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if len(image.shape) == 3 else image
        for variant in _prepare_variants(gray):
            for raw_text, confidence in _ocr_image(variant):
                score = _score_plate(raw_text, confidence)
                if score > best_score:
                    best_score = score
                    best_text = raw_text
                    best_conf = confidence

    plate = normalize_plate(best_text)
    valid = is_turkey_plate(plate)
    if not plate:
        return PlateReadResult("", "", 0.0, False, True, "Plaka okunamadi; manuel giris gerekli.")

    if not valid:
        return PlateReadResult(plate, best_text, best_conf, False, True, "Plaka formati dogrulanamadi; kontrol edin.")

    return PlateReadResult(plate, best_text, best_conf, True, True, "Plaka okundu.")


def read_plate_from_image(path: str | Path) -> PlateReadResult:
    ready, message = engine_status()
    if not ready:
        return PlateReadResult("", "", 0.0, False, False, message)

    image_path = Path(path)
    if not image_path.exists():
        return PlateReadResult("", "", 0.0, False, True, "Gorsel dosyasi bulunamadi.")

    if cv2 is None:
        return PlateReadResult("", "", 0.0, False, False, "OpenCV kurulu degil.")

    frame = cv2.imread(str(image_path))
    if frame is None:
        return PlateReadResult("", "", 0.0, False, True, "Gorsel acilamadi.")

    return read_plate_from_frame(frame)


def read_plate_from_camera(source: int | str = 0) -> PlateReadResult:
    ready, message = engine_status()
    if not ready:
        return PlateReadResult("", "", 0.0, False, False, message)
    if cv2 is None:
        return PlateReadResult("", "", 0.0, False, False, "OpenCV kurulu degil.")

    cap = cv2.VideoCapture(source)
    try:
        if not cap.isOpened():
            return PlateReadResult("", "", 0.0, False, True, "Kamera acilamadi.")
        ok, frame = cap.read()
        if not ok or frame is None:
            return PlateReadResult("", "", 0.0, False, True, "Kamera kare yakalayamadi.")
        return read_plate_from_frame(frame)
    finally:
        cap.release()
