# normalized_invoice_extractor.py
from __future__ import annotations

import re
import hashlib
import unicodedata
from dataclasses import dataclass, asdict, field
from typing import List, Optional, Dict, Any, Union
from pathlib import Path

import pdfplumber
from PIL import Image

try:
    import pytesseract
    OCR_AVAILABLE = True
except Exception:
    OCR_AVAILABLE = False

try:
    from pyzbar.pyzbar import decode as qr_decode
    QR_AVAILABLE = True
except Exception:
    QR_AVAILABLE = False

try:
    import cv2
    import numpy as np
    CV2_AVAILABLE = True
except ImportError:
    CV2_AVAILABLE = False


# ---------------------------
# Normalisation / utilitaires
# ---------------------------

_DASH_CHARS = {
    "\u00AD": "-",  # soft hyphen (souvent dans FC/.../1­1/2026)
    "\u2010": "-",  # hyphen
    "\u2011": "-",  # non-breaking hyphen
    "\u2012": "-",  # figure dash
    "\u2013": "-",  # en dash
    "\u2014": "-",  # em dash
    "\u2212": "-",  # minus sign
}

_RE_SPACED_LETTERS = re.compile(r"(?<=\b[0-9A-Za-zÀ-ÿ])\s+(?=[0-9A-Za-zÀ-ÿ]\b)", re.UNICODE)

def _collapse_spaced_letters(s: str) -> str:
    """
    Coller les lettres séparées par des espaces :
    ex: "T o t a l T T C : C D F 491.480,00" -> "Total TTC : CDF 491.480,00"
    """
    if not s:
        return s
    prev = None
    cur = s
    # boucle jusqu’à stabilisation
    for _ in range(10):
        prev = cur
        cur = _RE_SPACED_LETTERS.sub("", cur)
        if cur == prev:
            break
    return cur

def _normalize_text(s: str) -> str:
    """Normalise accents + tirets + colle lettres espacées."""
    if not s:
        return s
    s = unicodedata.normalize("NFC", s)
    s = s.replace("\xa0", " ").replace("\r", "\n")
    for ch, repl in _DASH_CHARS.items():
        s = s.replace(ch, repl)
    s = _collapse_spaced_letters(s)
    return s

def _clean_spaces(s: str) -> str:
    return re.sub(r"[ \t\xa0]+", " ", (s or "")).strip()

def _to_number(s: str) -> Optional[float]:
    if not s:
        return None
    s = str(s).strip().replace(" ", "")
    # format type 491.480,00
    if re.search(r"\d+\.\d{3},\d{2}", s):
        s = s.replace(".", "").replace(",", ".")
    else:
        if s.count(",") == 1 and s.count(".") == 0:
            s = s.replace(",", ".")
        elif s.count(",") >= 1 and s.count(".") >= 1:
            s = s.replace(".", "").replace(",", ".")
    try:
        return float(s)
    except ValueError:
        return None

def _sha1_file(path: Union[str, Path]) -> str:
    h = hashlib.sha1()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()


# ---------------------------
# Data structures
# ---------------------------

@dataclass
class InvoiceLine:
    designation: str
    unit_price_ttc: Optional[float]
    quantity: Optional[float]
    amount_ttc: Optional[float]

@dataclass
class NormalizedInvoiceData:
    invoice_type: Optional[str] = None
    invoice_number: Optional[str] = None

    code_def_dgi: Optional[str] = None
    def_nid: Optional[str] = None
    def_compteurs: Optional[str] = None
    def_datetime: Optional[str] = None

    nb_articles: Optional[int] = None
    ht_taxable_16: Optional[float] = None
    tva_16: Optional[float] = None
    total_ttc_cdf: Optional[float] = None
    payment_mode: Optional[str] = None
    total_ttc_usd: Optional[float] = None
    exchange_rate: Optional[float] = None
    exchange_rate_date: Optional[str] = None

    isf: Optional[str] = None
    qr_raw: Optional[str] = None

    lines: List[InvoiceLine] = field(default_factory=list)

    source_sha1: Optional[str] = None
    source_filename: Optional[str] = None


# ---------------------------
# Extraction texte
# ---------------------------

def extract_text_from_pdf(pdf_path: Union[str, Path]) -> str:
    text_parts = []
    with pdfplumber.open(str(pdf_path)) as pdf:
        for page in pdf.pages:
            t = page.extract_text() or ""
            t = _normalize_text(t)
            if t.strip():
                text_parts.append(t)
    return "\n".join(text_parts)

def extract_text_from_image(image_path: Union[str, Path]) -> str:
    if not OCR_AVAILABLE:
        raise RuntimeError("OCR non disponible. Installez pytesseract, pillow et tesseract-ocr.")
    img = Image.open(str(image_path)).convert("RGB")
    try:
        text = pytesseract.image_to_string(img, lang="fra+eng")
    except Exception:
        text = pytesseract.image_to_string(img)
    return _normalize_text(text)


# ---------------------------
# QR decoding
# ---------------------------

def _decode_qr_from_pil_image(img: "Image.Image") -> Optional[str]:
    # 1. Try PyZbar
    if QR_AVAILABLE:
        try:
            codes = qr_decode(img)
            if codes:
                return codes[0].data.decode("utf-8", errors="replace")
        except Exception:
            pass

    # 2. Try OpenCV
    if CV2_AVAILABLE:
        try:
            open_cv_image = np.array(img)
            # Convert RGB to BGR
            if len(open_cv_image.shape) == 3 and open_cv_image.shape[2] == 3:
                open_cv_image = open_cv_image[:, :, ::-1].copy()
            
            detector = cv2.QRCodeDetector()
            data, bbox, _ = detector.detectAndDecode(open_cv_image)
            if data:
                return data
        except Exception:
            pass

    return None

def extract_qr_from_image(image_path: Union[str, Path]) -> Optional[str]:
    if not (QR_AVAILABLE or CV2_AVAILABLE):
        return None
    img = Image.open(str(image_path)).convert("RGB")
    qr = _decode_qr_from_pil_image(img)
    if qr:
        return qr
    w, h = img.size
    crop = img.crop((int(w * 0.55), int(h * 0.55), w, h))
    return _decode_qr_from_pil_image(crop)

def extract_qr_from_pdf(pdf_path: Union[str, Path]) -> Optional[str]:
    if not (QR_AVAILABLE or CV2_AVAILABLE):
        return None
    try:
        import fitz  # PyMuPDF
    except Exception:
        return None

    path = Path(pdf_path)
    doc = fitz.open(path)
    try:
        if len(doc) == 0:
            return None
        max_pages = min(len(doc), 3)
        for i in range(max_pages):
            page = doc[i]
            pix = page.get_pixmap(matrix=fitz.Matrix(3, 3), alpha=False)
            img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)

            qr = _decode_qr_from_pil_image(img)
            if qr:
                return qr

            w, h = img.size
            crop = img.crop((int(w * 0.55), int(h * 0.55), w, h))
            qr = _decode_qr_from_pil_image(crop)
            if qr:
                return qr
        return None
    finally:
        doc.close()


# ---------------------------
# Parsing (regex robustes)
# ---------------------------

def parse_invoice_text(text: str) -> NormalizedInvoiceData:
    text = _normalize_text(text)

    raw_lines = text.split("\n")
    lines = [_clean_spaces(l) for l in raw_lines if _clean_spaces(l)]
    joined = "\n".join(lines)

    data = NormalizedInvoiceData(lines=[])

    m = re.search(r"(FACTURE\s+DE\s+VENTE)", joined, re.IGNORECASE | re.UNICODE)
    if m:
        data.invoice_type = m.group(1).upper()

    # IMPORTANT : capturer tout le numéro (slash + tirets), ex FC/CD02002767/1-1/2026
    m = re.search(
        r"Facture\s*n[°o]\s*[:\-]?\s*([A-Za-z0-9\/\-_]+)",
        joined,
        re.IGNORECASE | re.UNICODE
    )
    if m:
        data.invoice_number = _clean_spaces(m.group(1))

    m = re.search(r"Code\s*DEF\/DGI\s*[:\-]?\s*([A-Za-z0-9\-]{6,})", joined, re.IGNORECASE | re.UNICODE)
    if m:
        data.code_def_dgi = m.group(1).strip()

    m = re.search(r"DEF\s*NID\s*[:\-]?\s*([A-Za-z0-9\-]+)", joined, re.IGNORECASE | re.UNICODE)
    if m:
        data.def_nid = m.group(1).strip()

    m = re.search(r"DEF\s*Compteurs\s*[:\-]?\s*([0-9\/]+\s*[A-Za-zÀ-ÿ]{1,4})", joined, re.IGNORECASE | re.UNICODE)
    if m:
        data.def_compteurs = _clean_spaces(m.group(1))

    m = re.search(
        r"DEF\s*Heure\s*[:\-]?\s*([0-9]{2}\/[0-9]{2}\/[0-9]{4}\s+[0-9]{2}:[0-9]{2}:[0-9]{2})",
        joined,
        re.IGNORECASE | re.UNICODE
    )
    if m:
        data.def_datetime = m.group(1).strip()

    m = re.search(r"Nombre\s*d['´`’]\s*articles\s*[:\-]?\s*(\d+)", joined, re.IGNORECASE | re.UNICODE)
    if m:
        data.nb_articles = int(m.group(1))

    m = re.search(r"H\.?T\.?\s*\[[A-Z]\]\s*Taxable\s*16[,\.]00%\s*([0-9\.\s]+,[0-9]{2}|[0-9\.\s]+)", joined, re.IGNORECASE | re.UNICODE)
    if m:
        data.ht_taxable_16 = _to_number(m.group(1))

    m = re.search(r"TVA\s*\[[A-Z]\]\s*Taxable\s*16[,\.]00%\s*([0-9\.\s]+,[0-9]{2}|[0-9\.\s]+)", joined, re.IGNORECASE | re.UNICODE)
    if m:
        data.tva_16 = _to_number(m.group(1))

    # ICI : maintenant que "T o t a l T T C : C D F" est recollé → regex simple OK
    m = re.search(
        r"Total\s*TTC\s*[:\-]?\s*(?:CDF|FC)?\s*([0-9\.\s]+,[0-9]{2}|[0-9\.\s]+)",
        joined,
        re.IGNORECASE | re.UNICODE
    )
    if m:
        data.total_ttc_cdf = _to_number(m.group(1))

    m = re.search(
        r"Mode\s*de\s*paiement\s*[:\-]?\s*([A-Za-zÀ-ÿ ]+?)(?:\s+Total\s*TTC|\s*$)",
        joined,
        re.IGNORECASE | re.UNICODE
    )
    if m:
        data.payment_mode = _clean_spaces(m.group(1)).upper()

    m = re.search(r"Montant\s*TTC\s*\(USD\)\s*[:\-]?\s*([0-9\.,]+)", joined, re.IGNORECASE | re.UNICODE)
    if m:
        data.total_ttc_usd = _to_number(m.group(1))

    m = re.search(
        r"Cours\s*de\s*change\s*[:\-]?\s*([0-9\.,]+)\s*(?:\(du\s*|du\s*)([0-9]{2}\/[0-9]{2}\/[0-9]{4})",
        joined,
        re.IGNORECASE | re.UNICODE
    )
    if m:
        data.exchange_rate = _to_number(m.group(1))
        data.exchange_rate_date = m.group(2).strip()

    m = re.search(r"\bISF\s*[:\-]?\s*([A-Za-z0-9\-]+)", joined, re.IGNORECASE | re.UNICODE)
    if m:
        data.isf = m.group(1).strip()

    # lignes articles
    line_regex = re.compile(
        r"^\s*\d+\s+(.+?)\s+([0-9\.\s]+,[0-9]{2}|[0-9\.\s]+)\s+(\d+(?:[.,]\d+)?)\s+([0-9\.\s]+,[0-9]{2}|[0-9\.\s]+)\s*$",
        re.UNICODE,
    )
    for l in lines:
        mm = line_regex.match(l)
        if mm:
            data.lines.append(InvoiceLine(
                _clean_spaces(mm.group(1)),
                _to_number(mm.group(2)),
                _to_number(mm.group(3)),
                _to_number(mm.group(4)),
            ))

    return data


# ---------------------------
# Main entry
# ---------------------------

def extract_normalized_invoice(path: Union[str, Path]) -> Dict[str, Any]:
    path = Path(path)
    out = NormalizedInvoiceData(lines=[])
    out.source_filename = path.name
    out.source_sha1 = _sha1_file(path)

    if path.suffix.lower() == ".pdf":
        text = extract_text_from_pdf(path)
        parsed = parse_invoice_text(text) if text.strip() else NormalizedInvoiceData(lines=[])

        # QR depuis PDF (rendue en image) même si PDF "texte bizarre"
        qr = extract_qr_from_pdf(path)
        if qr:
            parsed.qr_raw = qr

        parsed.source_filename = out.source_filename
        parsed.source_sha1 = out.source_sha1
        return asdict(parsed)

    # Image : OCR + QR
    text = extract_text_from_image(path)
    parsed = parse_invoice_text(text)
    parsed.source_filename = out.source_filename
    parsed.source_sha1 = out.source_sha1
    parsed.qr_raw = extract_qr_from_image(path)
    return asdict(parsed)
