#!/usr/bin/env python3
"""Field extraction from a supplier-invoice PDF text layer.

Lifted out of email-inspect.sh so it can be REGRESSION-TESTED against the 16
hand-verified invoices in fleet/golden/invoice-extract/ (see test_extract.py).
It was a heredoc, therefore untestable, therefore wrong in ways nobody could
measure: on Darnis F1048 it returned Arcodange's own VAT number as the invoice
reference and no date at all.

Two structural traps, both observed on real documents:

  1. `Nº` in French invoices is U+00BA (MASCULINE ORDINAL INDICATOR), not the
     degree sign `°`. A character class of [°o] silently misses it, the primary
     pattern fails, and the fallback grabs the first ref-shaped token in the
     document — which is very often a VAT number.
  2. Two-column layouts put the label and its value on DIFFERENT lines, with
     unrelated text in between. A pattern requiring `label:\\s*value` never
     matches, so the field comes back empty.

Principle kept from the atom contract: emit nothing rather than emit a guess.
A value that fails its own sanity check is dropped, not repaired.
"""
from __future__ import annotations

import re

# Ordinal marks seen on real French invoices: degree sign, masculine ordinal, 'o'
ORD = "[°ºo]?"

# VAT numbers are ref-shaped and appear near the top of most invoices — the
# single biggest source of false "invoice_ref". Two country prefixes + 8-13
# digits/letters, no separators.
VAT_LIKE = re.compile(r"^(?:FR[0-9A-Z]{11}|BE0?\d{9,10}|DE\d{9}|ES[0-9A-Z]\d{7}[0-9A-Z]|IT\d{11}"
                      r"|NL\d{9}B\d{2}|LU\d{8}|IE\d{7}[A-W]{1,2}|PT\d{9}|AT U\d{8}|PL\d{10})$", re.I)
SIRET_LIKE = re.compile(r"^\d{9,14}$")


def parse_amount(s: str) -> float | None:
    """European or Anglo number → float. None when it is not a number."""
    if not s:
        return None
    t = re.sub(r"[\s  ]", "", s).strip().rstrip(".,")
    if not t:
        return None
    if "," in t and "." in t:                      # 1.234,56 or 1,234.56
        t = t.replace(".", "").replace(",", ".") if t.rfind(",") > t.rfind(".") \
            else t.replace(",", "")
    elif "," in t:
        t = t.replace(",", ".")
    try:
        return float(t)
    except ValueError:
        return None


def extract_ref(text: str) -> str | None:
    """The supplier's own invoice number."""
    # 1. Explicitly labelled, on the same line — the reliable case.
    for pat in (
        rf"(?:facture|invoice|receipt|re[çc]u)\s*(?:n{ORD}|number|#|:)\s*[:\s]*([A-Za-z0-9][\w\d/-]{{2,}})",
        r"(?:num[ée]ro\s+de\s+facture|invoice\s+number)\s*:?\s*([A-Za-z0-9][\w\d/-]{2,})",
    ):
        for m in re.finditer(pat, text, re.IGNORECASE):
            cand = m.group(1).strip(" .:،,")
            if _plausible_ref(cand):
                return cand

    # 2. Two-column layout: the label sits alone, the value lands further down
    #    the page. Look ahead a bounded window and take the first plausible
    #    token — bounded so we do not wander into another block.
    m = re.search(r"(?:num[ée]ro\s+de\s+facture|invoice\s+number|facture\s+n" + ORD + r")\s*:?", text, re.IGNORECASE)
    if m:
        window = text[m.end():m.end() + 400]
        # Rejoin FIRST: if the window opens on a dangling fragment, the tail alone
        # is also plausible, and scanning first would return a ref amputated of
        # its head — a subtly wrong value, the worst kind.
        # A ref split by the column wrap: "06-01-26-" ends a line, its tail
        # ("payment-366753") lands further down in the other column. Rejoin the
        # dangling fragment with the next token that carries a digit.
        frag = re.match(r"\s*([A-Za-z0-9][\w\d/-]*[-/])(?=\s)", window)
        if frag:
            for tail in re.findall(r"\b([A-Za-z0-9][\w\d/-]{2,})\b", window[frag.end():frag.end() + 200]):
                if any(c.isdigit() for c in tail):
                    joined = frag.group(1) + tail
                    if _plausible_ref(joined):
                        return joined
        for cand in re.findall(r"\b([A-Za-z0-9][\w\d/-]{2,})\b", window):
            if _plausible_ref(cand):
                return cand

    # 3. No labelled ref found. Emit nothing rather than the first ref-shaped
    #    token in the document — that is how a VAT number ends up as an invoice
    #    reference, which is exactly the bug this module exists to kill.
    return None


def _plausible_ref(cand: str) -> bool:
    if not cand or not any(c.isdigit() for c in cand):
        return False
    if VAT_LIKE.match(cand) or SIRET_LIKE.match(cand):
        return False
    if re.fullmatch(r"\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4}", cand):   # a date
        return False
    if len(cand) > 40:
        return False
    if cand.endswith(("-", "/", ".")):   # tronquée au saut de colonne
        return False
    return True


MONTHS = {}
for i, names in enumerate([
    ("janvier", "janv", "january", "jan"), ("février", "fevrier", "févr", "fevr", "february", "feb"),
    ("mars", "march", "mar"), ("avril", "avr", "april", "apr"), ("mai", "may"),
    ("juin", "june", "jun"), ("juillet", "juil", "july", "jul"), ("août", "aout", "august", "aug"),
    ("septembre", "sept", "september", "sep"), ("octobre", "oct", "october"),
    ("novembre", "nov", "november"), ("décembre", "decembre", "déc", "dec", "december"),
], start=1):
    for n in names:
        MONTHS[n] = i


def to_iso(raw: str | None) -> str | None:
    """dd/mm/yyyy or '2 avr. 2026' → yyyy-mm-dd. Consumers want one shape, not five."""
    if not raw:
        return None
    raw = raw.strip()
    if re.fullmatch(r"\d{4}-\d{2}-\d{2}", raw):
        return raw
    m = re.fullmatch(r"(\d{1,2})[/.\-](\d{1,2})[/.\-](\d{2,4})", raw)
    if m:
        d, mo, y = m.groups()
        y = "20" + y if len(y) == 2 else y
        return f"{y}-{int(mo):02d}-{int(d):02d}"
    m = re.fullmatch(r"(\d{1,2})\s+([A-Za-zéûîàôçÉ.]+)\.?\s+(\d{4})", raw)
    if m:
        d, mon, y = m.groups()
        idx = MONTHS.get(mon.lower().rstrip("."))
        if idx:
            return f"{y}-{idx:02d}-{int(d):02d}"
    return raw


DATE_LABELS = (r"date\s*d[e']\s*[ée]mission", r"date\s*de\s*facturation", r"invoice\s*date",
               r"date\s*d[e']\s*facture", r"[ée]mise?\s*le", r"\bdate\b")


def extract_date(text: str) -> str | None:
    """Issue date. Prefers a labelled date; falls back to the earliest date seen."""
    # ISO anywhere wins — unambiguous.
    m = re.search(r"\b(\d{4}-\d{2}-\d{2})\b", text)
    if m:
        return m.group(1)

    # Labelled, allowing the two-column gap: up to 300 chars of anything between
    # the label and its value (the value is often a column away and a line down).
    for lab in DATE_LABELS:
        for m in re.finditer(lab, text, re.IGNORECASE):
            window = text[m.end():m.end() + 300]
            d = re.search(r"\b(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4})\b", window)
            if d:
                return d.group(1)
            d = re.search(r"\b(\d{1,2}\s+[A-Za-zéûîàôç.]{3,10}\.?\s+\d{4})\b", window)
            if d:
                return d.group(1)

    # Unlabelled: take the first date-shaped token, but only if the document has
    # one — no invention.
    m = re.search(r"\b(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{4})\b", text)
    return m.group(1) if m else None


def extract_amounts(text: str) -> dict:
    """HT / TVA / TTC. Returns only what the document actually labels."""
    lines = text.splitlines()

    def first(pattern: str) -> str | None:
        for line in lines:
            m = re.search(pattern, line, re.IGNORECASE)
            if m:
                v = parse_amount(m.group(1))
                if v is not None:
                    return f"{v:.2f}"
        return None

    AMT = r"[^\d-]*([\d\u00a0\u202f .,]+)"
    # "Montant total (HT)" is an HT, not a TTC — the parenthetical decides, so it
    # is matched on the HT side and excluded on the TTC side.
    ht = first(r"(?:total\s*ht|montant\s*ht|montant\s*total\s*\(\s*ht\s*\)|net\s*amount|subtotal|sous.?total\s*\(\s*ht\s*\))" + AMT)
    # The rate often sits between the label and the amount — "TVA (20%) : 43,70 €".
    # A currency mark is still required so a VAT *number* is never read as a VAT *amount*.
    tva = first(r"(?:tva|vat)\s*(?:\(\s*\d{1,2}(?:[.,]\d+)?\s*%\s*\))?\s*:?" + AMT + r"\s*(?:€|\beur\b)")
    ttc = first(r"(?:total\s*ttc|amount\s*due|total\s*due|grand\s*total|montant\s*total|amount\s*paid)(?!\s*\(?\s*ht)" + AMT)
    if ttc is None:
        # Some invoices label the TTC simply "Total" (Legalstart, INPI). Accept it
        # only as a fallback, and only on a line that is nothing else.
        ttc = first(r"^\s*total\s+" + AMT.lstrip("[^\\d-]*") if False else r"^\s*total\s+([\d\u00a0\u202f .,]+)\s*€")

    out = {"total_ht": ht, "total_tva": tva, "total_ttc": ttc}
    # Arithmetic sanity: if HT + TVA disagrees with TTC we matched the wrong
    # lines somewhere. Report it rather than pretend the numbers are coherent.
    h, t_, c = (parse_amount(out[k] or "") for k in ("total_ht", "total_tva", "total_ttc"))
    out["arithmetic_ok"] = None if None in (h, t_, c) else abs(h + t_ - c) <= 0.01
    return out


def extract_vat_rate(text: str) -> str | None:
    for line in text.splitlines():
        m = re.search(r"\b(\d{1,2}(?:[.,]\d+)?)\s*%", line)
        if m:
            v = float(m.group(1).replace(",", "."))
            if 0 <= v <= 25:
                return m.group(1)
    return None


def extract(text: str) -> dict:
    out = {
        "invoice_ref": extract_ref(text),
        "invoice_date_raw": to_iso(extract_date(text)),
        "vat_rate_pct": extract_vat_rate(text),
    }
    out.update(extract_amounts(text))
    return out
