"""Multilingual keyword context detector.

Detects PII-introducing keywords across languages and flags the token
that immediately follows them. This is a lightweight stepping stone
before full semantic embeddings — no ML models required.
"""
import re
from typing import Dict, List, Tuple, Union

from upf.config import UPFConfig
from upf.detectors.deterministic_ids import (
    is_valid_brazilian_cpf,
    is_valid_credit_card,
    is_valid_portuguese_nif,
)

# ---------------------------------------------------------------------------
# Keyword tables: (keyword_pattern, entity_type_to_flag_for_next_token)
# Each entry is a compiled pattern that matches the keyword, and the type
# to assign to the following token.
# ---------------------------------------------------------------------------

# (regex_source, entity_type)
_KEYWORD_TABLE: List[Tuple[str, str]] = [
    # --- PERSON / NAME ---
    # EN
    (r"\bname\b", "NAME"),
    (r"\bfull\s+name\b", "NAME"),
    (r"\bfirst\s+name\b", "NAME"),
    (r"\blast\s+name\b", "NAME"),
    (r"\bpatient\s+name\b", "NAME"),
    (r"\bcard\s*holder\s+name\b", "NAME"),
    (r"\bcardholder\s+name\b", "NAME"),
    (r"\baccount\s+holder\b", "NAME"),
    (r"\bclient\b", "NAME"),
    # DE
    (r"\bname\b", "NAME"),           # same word
    (r"\bvorname\b", "NAME"),
    (r"\bnachname\b", "NAME"),
    (r"\bpatienten?name\b", "NAME"),
    # FR
    (r"\bpr[eé]nom\b", "NAME"),
    (r"\bnom\b", "NAME"),
    # ES
    (r"\bnombre\b", "NAME"),
    (r"\bapellido\b", "NAME"),
    # PL
    (r"\bimi[eę]\b", "NAME"),
    (r"\bnazwisko\b", "NAME"),
    # PT
    (r"\bnome\b", "NAME"),
    (r"\bsobrenome\b", "NAME"),
    # IT
    (r"\bnome\b", "NAME"),
    (r"\bcognome\b", "NAME"),
    # NL
    (r"\bnaam\b", "NAME"),
    (r"\bvoornaam\b", "NAME"),
    (r"\bachternaam\b", "NAME"),

    # --- EMAIL ---
    (r"\be-?mail(?:\s+address)?\b", "EMAIL"),
    (r"\b[eé]lectronische?\s+post\b", "EMAIL"),   # NL/FR
    (r"\bkorres?pondens?adres\b", "EMAIL"),        # NL
    (r"\bcorr[ée]o\s+electr[oó]nico\b", "EMAIL"), # ES
    (r"\be-mail-adresse\b", "EMAIL"),              # DE

    # --- PHONE ---
    (r"\bphone(?:\s+number)?\b", "PHONE"),
    (r"\btelephone\b", "PHONE"),
    (r"\bmobile(?:\s+number)?\b", "PHONE"),
    (r"\bcell(?:ular)?\b", "PHONE"),
    # DE
    (r"\btelefon(?:nummer)?\b", "PHONE"),
    (r"\bhandy(?:nummer)?\b", "PHONE"),
    # FR
    (r"\bt[eé]l[eé]phone\b", "PHONE"),
    (r"\bnum[eé]ro\s+de\s+t[eé]l[eé]phone\b", "PHONE"),
    # ES
    (r"\btel[eé]fono\b", "PHONE"),
    # PL
    (r"\btelefon\b", "PHONE"),
    (r"\bnumer\s+telefonu\b", "PHONE"),
    # PT
    (r"\btelefone\b", "PHONE"),
    # IT
    (r"\btelefonno\b", "PHONE"),
    (r"\bcellulare\b", "PHONE"),
    # NL
    (r"\btelefoonnummer\b", "PHONE"),

    # --- CREDIT CARD / FINANCIAL ---
    (r"\bcredit\s+card(?:\s+number)?\b", "CREDIT_CARD"),
    (r"\bcard\s+number\b", "CREDIT_CARD"),
    (r"\bcc\s+number\b", "CREDIT_CARD"),
    # DE
    (r"\bkreditkarte(?:nnummer)?\b", "CREDIT_CARD"),
    (r"\bkartennummer\b", "CREDIT_CARD"),
    # FR
    (r"\bnuméro\s+de\s+carte\b", "CREDIT_CARD"),
    # ES
    (r"\bnum[eé]ro\s+de\s+tarjeta\b", "CREDIT_CARD"),
    # PL
    (r"\bnumer\s+karty\b", "CREDIT_CARD"),
    # PT
    (r"\bnúmero\s+do\s+cart[aã]o\b", "CREDIT_CARD"),

    # --- INVOICE / REFERENCE NUMBER ---
    (r"\binvoice\s+(?:no|number|#)\b", "LONG_NUMERIC_ID"),
    (r"\breference\s+(?:no|number|#)\b", "LONG_NUMERIC_ID"),
    (r"\brechnungsnummer\b", "LONG_NUMERIC_ID"),
    (r"\bnúmero\s+de\s+factura\b", "LONG_NUMERIC_ID"),

    # --- IBAN / BANK ACCOUNT ---
    (r"\biban\b", "IBAN"),
    (r"\baccount\s+number\b", "IBAN"),
    (r"\bbank\s+account\b", "IBAN"),
    # DE
    (r"\bkonto(?:nummer)?\b", "IBAN"),
    # FR
    (r"\bcompte\s+bancaire\b", "IBAN"),
    (r"\bnum[eé]ro\s+de\s+compte\b", "IBAN"),
    # ES
    (r"\bcuenta\s+bancaria\b", "IBAN"),
    (r"\bnum[eé]ro\s+de\s+cuenta\b", "IBAN"),
    # PL
    (r"\bnumer\s+konta\b", "IBAN"),
    # PT
    (r"\bconta\s+banc[aá]ria\b", "IBAN"),
    # IT
    (r"\bconto\s+corrente\b", "IBAN"),
    # NL
    (r"\brekeningnummer\b", "IBAN"),

    # --- ADDRESS ---
    (r"\baddress\b", "ADDRESS"),
    (r"\bstreet\s+address\b", "ADDRESS"),
    (r"\bhome\s+address\b", "ADDRESS"),
    (r"\bzip\s*(?:code)?\b", "ADDRESS"),
    (r"\bpostal\s+code\b", "ADDRESS"),
    (r"\bpostcode\b", "ADDRESS"),
    # DE
    (r"\badresse\b", "ADDRESS"),
    (r"\bstra[sß]e\b", "ADDRESS"),
    (r"\bplz\b", "ADDRESS"),
    # FR
    (r"\badresse\b", "ADDRESS"),
    (r"\bcode\s+postal\b", "ADDRESS"),
    (r"\brue\b", "ADDRESS"),
    # ES
    (r"\bdirecci[oó]n\b", "ADDRESS"),
    (r"\bc[oó]digo\s+postal\b", "ADDRESS"),
    (r"\bcalle\b", "ADDRESS"),
    # PL
    (r"\badres\b", "ADDRESS"),
    (r"\bkod\s+pocztowy\b", "ADDRESS"),
    (r"\bulica\b", "ADDRESS"),
    # PT
    (r"\bmorada\b", "ADDRESS"),
    (r"\bendereco\b", "ADDRESS"),
    # IT
    (r"\bindirizzo\b", "ADDRESS"),
    (r"\bcodice\s+postale\b", "ADDRESS"),
    # NL
    (r"\badres\b", "ADDRESS"),
    (r"\bpostcode\b", "ADDRESS"),

    # --- DATE OF BIRTH ---
    (r"\bdate\s+of\s+birth\b", "DATE_OF_BIRTH"),
    (r"\bbirth\s+date\b", "DATE_OF_BIRTH"),
    (r"\bdob\b", "DATE_OF_BIRTH"),

    # --- NATIONAL ID / SSN ---
    (r"\bssn\b", "PESEL"),
    (r"\bsocial\s+security(?:\s+number)?\b", "PESEL"),
    (r"\bnational\s+id(?:\s+number)?\b", "PESEL"),
    (r"\bpassport(?:\s+number)?\b", "PESEL"),
    (r"\bid\s+number\b", "PESEL"),
    # DE
    (r"\bsteueridentifikationsnummer\b", "PESEL"),
    (r"\bsteuernummer\b", "PESEL"),
    (r"\bpersonalausweis\b", "PESEL"),
    # FR
    (r"\bnum[eé]ro\s+de\s+s[eé]curit[eé]\s+sociale\b", "PESEL"),
    (r"\bcarte\s+d.identit[eé]\b", "PESEL"),
    # ES
    (r"\bdni\b", "PESEL"),
    (r"\bnie\b", "PESEL"),
    (r"\bn[uú]mero\s+de\s+seguro\s+social\b", "PESEL"),
    # PL
    (r"\bpesel\b", "PESEL"),
    (r"\bnumer\s+dowodu\b", "PESEL"),
    # PT
    (r"\bnif\b", "PT_NIF"),
    (r"\bcpf\b", "BR_CPF"),
    (r"\bniss\b", "PT_NISS"),
    (r"\bnum[eé]ro\s+de\s+contribuinte\b", "PT_NIF"),
    # IT
    (r"\bcodice\s+fiscale\b", "PESEL"),
    # NL
    (r"\bbsn\b", "PESEL"),
    (r"\bburgerservicenummer\b", "PESEL"),
]

# Compile all patterns once at module load, paired with their entity type.
_COMPILED: List[Tuple[re.Pattern, str]] = [
    (re.compile(pattern, re.IGNORECASE | re.UNICODE), entity_type)
    for pattern, entity_type in _KEYWORD_TABLE
]

# After a keyword we expect an optional separator then the value token(s).
# OCR frequently places values on the next line, so allow at most one newline.
_VALUE_RE = re.compile(r"[ \t]*[:\-=]?[ \t]*(?:\n[ \t]*)?(\S+(?:[ \t]+\S+){0,9})")

# Entity types that need config checks
_CONFIG_GATES: Dict[str, str] = {
    "NAME": "redact_names",
    "EMAIL": "redact_emails",
    "PHONE": "redact_phones",
    "ADDRESS": "redact_addresses",
    "DATE_OF_BIRTH": "redact_national_ids",
    "PT_NIF": "redact_national_ids",
    "BR_CPF": "redact_national_ids",
    "PT_NISS": "redact_national_ids",
}

_EMAIL_VALUE_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
_EMAIL_OCR_VALUE_RE = re.compile(
    r"[A-Za-z0-9._%+\-]+(?:[ \t]*[._%+\-][ \t]*[A-Za-z0-9._%+\-]+)*[ \t]*@[ \t]*"
    r"[A-Za-z0-9.\-]+(?:[ \t]*\.[ \t]*[A-Za-z]{2,})+"
)
_PHONE_VALUE_RE = re.compile(r"\+?\d(?:[\d().\-\s]{6,}\d)")
_CARD_VALUE_RE = re.compile(r"(?:\d[ -]?){13,19}\d")
_IBAN_VALUE_RE = re.compile(r"[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}", re.IGNORECASE)
_CPF_VALUE_RE = re.compile(r"\d{3}\.?\d{3}\.?\d{3}-?\d{2}")
_NIF_VALUE_RE = re.compile(r"\d{9}\b")
_NISS_VALUE_RE = re.compile(r"\d{11}\b")
_PESEL_VALUE_RE = re.compile(r"\d{11}\b")
_SSN_VALUE_RE = re.compile(r"\d{3}-\d{2}-\d{4}\b")
_PESEL_GENERIC_RE = re.compile(r"[A-Z0-9.\-]{8,20}", re.IGNORECASE)
_DOB_VALUE_RE = re.compile(
    r"\b(?:\d{1,2}\s*[./-]\s*\d{1,2}\s*[./-]\s*\d{2,4}"
    r"|\d{4}\s*[./-]\s*\d{1,2}\s*[./-]\s*\d{1,2})\b"
)
_ADDRESS_VALUE_RE = re.compile(
    r"\d{1,5}[A-Za-z]?(?:[ \t]+[A-Za-zÀ-ÿ0-9.'-]+){1,8}(?:,\s*[A-Za-zÀ-ÿ][A-Za-zÀ-ÿ .'-]{1,32})?"
)
_NAME_TITLE_CASE_RE = re.compile(
    r"[A-ZÀ-Ý][a-zà-ÿ'-]+(?:[ \t]+[A-Z]\.)?(?:[ \t]+[A-ZÀ-Ý][a-zà-ÿ'-]+){1,2}"
)
_NAME_UPPER_RE = re.compile(
    r"[A-ZÀ-Ý][A-ZÀ-Ý'-]+(?:[ \t]+[A-ZÀ-Ý][A-ZÀ-Ý'-]+){1,3}"
)
_NAME_SINGLE_RE = re.compile(r"[A-ZÀ-Ý][a-zà-ÿ'-]{1,30}")
_NAME_SINGLE_BLOCKLIST = frozenset({
    "name", "cardholder", "holder", "email", "phone", "address",
    "city", "country", "date", "birth", "number", "id", "account",
    "invoice", "bank", "statement", "application", "form",
})


def _extract_value_for_type(entity_type: str, value_text: str) -> str:
    """Return normalized value text suitable for the target entity type."""
    text = value_text.strip()
    if not text:
        return ""

    if entity_type == "EMAIL":
        m = _EMAIL_VALUE_RE.search(text)
        if m:
            return m.group(0)
        m = _EMAIL_OCR_VALUE_RE.search(text)
        if not m:
            return ""
        candidate = m.group(0).strip()
        normalized = re.sub(r"\s+", "", candidate)
        return candidate if _EMAIL_VALUE_RE.fullmatch(normalized) else ""

    if entity_type == "PHONE":
        # OCR commonly replaces digits with similar-looking letters (S→5, O→0, etc.).
        # Always try the normalized version first so we get the widest match, then
        # return the original-text span at the same offsets.
        _OCR_DIGIT_MAP = str.maketrans("SOIBsob", "5015508")
        normalized = text.translate(_OCR_DIGIT_MAP)
        m = _PHONE_VALUE_RE.search(normalized)
        if not m:
            return ""
        v = text[m.start():m.end()].strip()
        digit_like = sum(1 for c in v if c.isdigit() or c in "SOIsoi")
        return v if digit_like >= 6 else ""

    if entity_type == "CREDIT_CARD":
        m = _CARD_VALUE_RE.search(text)
        if not m:
            return ""
        v = m.group(0).strip()
        return v if is_valid_credit_card(v) else ""

    if entity_type == "IBAN":
        m = _IBAN_VALUE_RE.search(text)
        if not m:
            return ""
        v = m.group(0).strip()
        # Accept if structurally valid (correct length/charset) even if checksum
        # fails — OCR errors commonly corrupt digits but the context keyword
        # (e.g. "IBAN:") makes the intent unambiguous.
        cleaned = "".join(c for c in v.upper() if c.isalnum())
        if 15 <= len(cleaned) <= 34:
            return v
        return ""

    if entity_type == "BR_CPF":
        m = _CPF_VALUE_RE.search(text)
        if not m:
            return ""
        v = m.group(0).strip()
        return v if is_valid_brazilian_cpf(v) else ""

    if entity_type == "PT_NIF":
        m = _NIF_VALUE_RE.search(text)
        if not m:
            return ""
        v = m.group(0).strip()
        return v if is_valid_portuguese_nif(v) else ""

    if entity_type == "PT_NISS":
        m = _NISS_VALUE_RE.search(text)
        return m.group(0).strip() if m else ""

    if entity_type == "PESEL":
        m = _PESEL_VALUE_RE.search(text)
        if m:
            return m.group(0).strip()
        m = _SSN_VALUE_RE.search(text)
        if m:
            return m.group(0).strip()
        # Fallback for DNI/BSN/Codice Fiscale-like tokens.
        m = _PESEL_GENERIC_RE.search(text)
        if not m:
            return ""
        candidate = m.group(0).strip(".,;:()[]{}\"'")
        digits = sum(c.isdigit() for c in candidate)
        if digits < 3:
            return ""
        return candidate

    if entity_type == "NAME":
        m = _NAME_TITLE_CASE_RE.search(text)
        if m:
            return m.group(0).strip()
        m = _NAME_UPPER_RE.search(text)
        if m:
            return m.group(0).strip()
        m = _NAME_SINGLE_RE.search(text)
        if not m:
            return ""
        token = m.group(0).strip()
        return "" if token.lower() in _NAME_SINGLE_BLOCKLIST else token

    if entity_type == "ADDRESS":
        m = _ADDRESS_VALUE_RE.search(text)
        if m:
            return m.group(0).strip(".,;: ")
        cleaned = text.strip(".,;: ").strip()
        if "@" in cleaned:
            return ""
        # Avoid treating field labels from the next OCR line as an address value.
        if cleaned.lower() in {"email", "address", "name", "phone", "cardholder"}:
            return ""
        return cleaned

    if entity_type == "DATE_OF_BIRTH":
        m = _DOB_VALUE_RE.search(text)
        return m.group(0).strip() if m else ""

    if entity_type == "LONG_NUMERIC_ID":
        # Match reference codes like INV-2024-001, REF-123, 2024-002, etc.
        # Must contain at least one digit.
        m = re.search(r"[A-Z0-9][A-Z0-9\-]{2,30}", text.strip(), re.IGNORECASE)
        if not m:
            return ""
        candidate = m.group(0).strip("-")
        if not any(c.isdigit() for c in candidate):
            return ""
        return candidate

    return text


def detect_multilingual_entities(
    text: str, config: UPFConfig
) -> List[Dict[str, Union[int, str]]]:
    """Detect PII following multilingual context keywords."""
    findings: List[Dict[str, Union[int, str]]] = []
    seen: set = set()

    for pattern, entity_type in _COMPILED:
        # Check config gate
        gate_attr = _CONFIG_GATES.get(entity_type)
        if gate_attr and not getattr(config, gate_attr, True):
            continue
        if entity_type in ("CREDIT_CARD", "IBAN", "PESEL") and not config.redact_phones:
            pass  # no specific gate — always on

        for kw_match in pattern.finditer(text):
            # The value starts right after the keyword match.
            rest = text[kw_match.end():]
            val_match = _VALUE_RE.match(rest)
            if not val_match:
                continue

            val_text = val_match.group(1).rstrip(".,;:\"')")
            # Truncate at first newline — OCR text may have embedded line breaks
            nl_pos = val_text.find("\n")
            if nl_pos != -1:
                val_text = val_text[:nl_pos].rstrip()
            if not val_text:
                continue
            # Strip leading punctuation artifacts (e.g. ". 123" → "123")
            val_text = val_text.lstrip(".,;: \t")
            if not val_text:
                continue
            val_text = _extract_value_for_type(entity_type, val_text)
            if not val_text:
                continue
            val_start = kw_match.end() + val_match.start(1)
            # Keep offset aligned with extracted value (which may start later).
            inner = val_match.group(1)
            rel = inner.find(val_text)
            if rel != -1:
                val_start += rel
            val_end = val_start + len(val_text)

            key = (val_start, val_end)
            if key not in seen:
                seen.add(key)
                findings.append({
                    "type": entity_type,
                    "start": val_start,
                    "end": val_end,
                    "text": val_text,
                })

    findings.sort(key=lambda f: (int(f["start"]), int(f["end"])))
    return findings
