from __future__ import annotations

import logging
import re
import time
import unicodedata
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple

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

logger = logging.getLogger("agentid")
DEFAULT_SCAN_DEADLINE_SEC = 0.1
MAX_CANDIDATES_PER_RULE = 256
ANCHOR_WINDOW_CHARS = 20
ADDRESS_WINDOW_CHARS = 40


PHONE_CONTEXT_KEYWORDS = [
    # --- đźŚŤ GLOBAL / SYMBOLS / TECH ---
    "tel",
    "phone",
    "mobile",
    "mobil",
    "cell",
    "call",
    "fax",
    "sms",
    "whatsapp",
    "signal",
    "telegram",
    "viber",
    "skype",
    "dial",
    "contact",
    "number",
    "hotline",
    "helpdesk",
    "support",
    "infoline",
    "customer service",
    "client service",
    "reach me",
    "text me",
    # --- đź‡¨đź‡ż CS (Czech) / đź‡¸đź‡° SK (Slovak) ---
    "volat",
    "volaĹĄ",
    "telefon",
    "telefĂłn",
    "ÄŤĂ­slo",
    "cislo",
    "kontakt",
    "mobilnĂ­",
    "mobilnĂ˝",
    "ozvi se",
    "ozvi sa",
    "napiĹˇ",
    "napĂ­Ĺˇ",
    "zavolej",
    "zavolaj",
    "pevnĂˇ linka",
    "klapka",
    "spojovatelka",
    "zĂˇkaznickĂˇ linka",
    "podpora",
    # --- đź‡©đź‡Ş DE (German) ---
    "telefonnummer",
    "handy",
    "anruf",
    "klingeln",
    "rufnummer",
    "faxnummer",
    "kontaktieren",
    "erreichen",
    "kundenservice",
    "support",
    "mobilfunk",
    "festnetz",
    "durchwahl",
    # --- đź‡µđź‡± PL (Polish) ---
    "telefon",
    "telefonu",
    "komĂłrka",
    "komorkowy",
    "zadzwoĹ„",
    "numer",
    "kontakt",
    "infolinia",
    "wsparcie",
    "fax",
    "smsowaÄ‡",
    "wybierz",
    # --- đź‡­đź‡ş HU (Hungarian) ---
    "hĂ­vĂˇs",
    "telefonszĂˇm",
    "szĂˇm",
    "mobiltelefon",
    "mobil",
    "vezetĂ©kes",
    "elĂ©rhetĹ‘sĂ©g",
    "ĂĽgyfĂ©lszolgĂˇlat",
    "fax",
    "tĂˇrcsĂˇzza",
    "csĂ¶rĂ¶gjĂ¶n",
    # --- đź‡şđź‡¦ UA (Ukrainian) ---
    "Ń‚ĐµĐ»ĐµŃ„ĐľĐ˝",
    "ĐĽĐľĐ±Ń–Đ»ŃŚĐ˝Đ¸Đą",
    "Đ´Đ·Đ˛ĐľĐ˝Đ¸Ń‚Đ¸",
    "Đ˝ĐľĐĽĐµŃ€",
    "ĐşĐľĐ˝Ń‚Đ°ĐşŃ‚",
    "Đ·Đ˛'ŃŹĐ·ĐľĐş",
    "ĐłĐ°Ń€ŃŹŃ‡Đ° Đ»Ń–Đ˝Ń–ŃŹ",
    "ĐżŃ–Đ´Ń‚Ń€Đ¸ĐĽĐşĐ°",
    "ŃĐĽŃ",
    "Ń„Đ°ĐşŃ",
    "ĐżĐľĐ·Đ˛ĐľĐ˝Đ¸Ń‚ŃŚ",
    "Đ˝Đ°Đ±Ń€Đ°Ń‚Đ¸",
]

TAX_ANCHORS = {
    "vat",
    "vat number",
    "tax",
    "tax id",
    "tin",
    "dic",
    "nip",
    "nino",
    "steuernummer",
    "steuer id",
    "steuer-id",
    "ust-idnr",
    "insee",
    "nir",
    "dni",
    "nie",
    "nif",
    "cif",
    "codice fiscale",
    "fiscal code",
    "bsn",
    "burgerservicenummer",
    "svnr",
    "sozialversicherung",
    "sozialversicherungsnummer",
    "fiscal",
}

NL_BSN_ANCHORS = {"bsn", "burgerservicenummer"}
AT_SVNR_ANCHORS = {"svnr", "sozialversicherung", "sozialversicherungsnummer", "ssn"}

DRIVER_ANCHORS = {
    "driver licence",
    "driving licence",
    "drivers license",
    "driving license",
    "ridicsky prukaz",
    "vodicsky preukaz",
    "prawo jazdy",
    "fuhrerschein",
    "fuehrerschein",
    "permis",
    "driving permit",
}

ADDRESS_ANCHORS = {
    "street",
    "st.",
    "strasse",
    "str.",
    "ulice",
    "ul.",
    "ulica",
    "road",
    "rd.",
    "avenue",
    "ave.",
    "postcode",
    "postal code",
    "zip",
    "psc",
    "plz",
}

MRZ_ANCHORS = {"mrz", "machine readable zone", "passport", "id card", "travel document"}
ALL_ANCHORS = sorted(TAX_ANCHORS | DRIVER_ANCHORS | ADDRESS_ANCHORS | MRZ_ANCHORS)

PERSON_NAME_STOPWORDS = {
    "write",
    "code",
    "script",
    "query",
    "curl",
    "http",
    "https",
    "import",
    "python",
    "javascript",
    "typescript",
    "node",
    "bash",
    "powershell",
    "shell",
    "command",
    "example",
    "demo",
    "developer",
    "mode",
    "system",
    "prompt",
    "policy",
    "security",
    "instructions",
    "instruction",
    "rules",
    "rule",
    "json",
    "output",
    "input",
    "database",
    "sql",
    "mongo",
    "openai",
    "agentid",
    "risk",
    "score",
    "summary",
}

TECHNICAL_PERSON_CONTEXT_WORD_RE = re.compile(
    r"\b(?:curl|http|https|import|python|javascript|typescript|sql|nosql|mongo|database|query|script|code|os\.system|eval|exec|node|npm|api|endpoint|regex|json|xml|yaml|bash|powershell)\b",
    re.IGNORECASE,
)
TECHNICAL_PERSON_CONTEXT_SYMBOL_RE = re.compile(r":\/\/|`|\{|\}|\[|\]|\(|\)|;|\$|=>|::|\/\/")


@dataclass(frozen=True)
class _Detection:
    start: int
    end: int
    entity_type: str
    text: str


def _count_digits(value: str) -> int:
    return sum(1 for ch in value if ch.isdigit())


def _fold_for_matching(value: str) -> str:
    normalized = unicodedata.normalize("NFD", value or "")
    return "".join(ch for ch in normalized.lower() if unicodedata.category(ch) != "Mn")


def _normalize_person_word(value: str) -> str:
    normalized = unicodedata.normalize("NFD", value or "")
    return "".join(ch for ch in normalized.lower() if unicodedata.category(ch) != "Mn")


def _build_context_window(source: str, index: int, length: int) -> str:
    start = max(0, index - 28)
    end = min(len(source), index + length + 28)
    return source[start:end]


def _is_technical_person_context(context_window: str) -> bool:
    return bool(
        TECHNICAL_PERSON_CONTEXT_WORD_RE.search(context_window)
        or TECHNICAL_PERSON_CONTEXT_SYMBOL_RE.search(context_window)
    )


def _is_likely_person_name_candidate(candidate: str, context_window: str) -> bool:
    words = [part for part in re.split(r"\s+", candidate.strip()) if part]
    if len(words) != 2:
        return False

    if not (words[0].istitle() and words[1].istitle()):
        return False

    if _is_technical_person_context(context_window):
        return False

    for raw_word in words:
        normalized = _normalize_person_word(raw_word)
        if len(normalized) < 2:
            return False
        if normalized in PERSON_NAME_STOPWORDS:
            return False
        if not re.fullmatch(r"[^\W\d_][^\W\d_'-]*", raw_word, flags=re.UNICODE):
            return False

    return True


def _normalize_compact_identifier(value: str) -> str:
    return re.sub(r"[^A-Z0-9]", "", _fold_for_matching(value).upper())


def _mod97_from_digits(value: str) -> int:
    remainder = 0
    for ch in value:
        if not ch.isdigit():
            return -1
        remainder = (remainder * 10 + int(ch)) % 97
    return remainder


def _luhn_check(value: str) -> bool:
    digits = re.sub(r"\D", "", value or "")
    if len(digits) < 12 or len(digits) > 19:
        return False

    total = 0
    double_next = False
    for ch in reversed(digits):
        digit = int(ch)
        if double_next:
            digit *= 2
            if digit > 9:
                digit -= 9
        total += digit
        double_next = not double_next
    return total % 10 == 0


def _days_in_month(year: int, month: int) -> int:
    if month == 2:
        leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
        return 29 if leap else 28
    if month in {1, 3, 5, 7, 8, 10, 12}:
        return 31
    return 30


def _is_valid_date(year: int, month: int, day: int) -> bool:
    if year < 1800 or year > 2299:
        return False
    if month < 1 or month > 12:
        return False
    if day < 1:
        return False
    return day <= _days_in_month(year, month)


def _resolve_czech_month(raw_month: int) -> Optional[int]:
    if 1 <= raw_month <= 12:
        return raw_month
    if 21 <= raw_month <= 32:
        return raw_month - 20
    if 51 <= raw_month <= 62:
        return raw_month - 50
    if 71 <= raw_month <= 82:
        return raw_month - 70
    return None


def _resolve_cnp_year(century_code: int, yy: int) -> Optional[int]:
    if century_code in (1, 2):
        return 1900 + yy
    if century_code in (3, 4):
        return 1800 + yy
    if century_code in (5, 6, 7, 8):
        return 2000 + yy
    if century_code == 9:
        return 1900 + yy
    return None


def is_valid_pesel(value: str) -> bool:
    digits = re.sub(r"\D", "", value or "")
    if not re.fullmatch(r"\d{11}", digits):
        return False

    yy = int(digits[0:2])
    mm_raw = int(digits[2:4])
    dd = int(digits[4:6])
    century_offset = (mm_raw - 1) // 20
    month = ((mm_raw - 1) % 20) + 1
    century_map = {0: 1900, 1: 2000, 2: 2100, 3: 2200, 4: 1800}
    century = century_map.get(century_offset)
    if century is None:
        return False
    if not _is_valid_date(century + yy, month, dd):
        return False

    weights = [1, 3, 7, 9, 1, 3, 7, 9, 1, 3]
    checksum = sum(int(digits[i]) * weights[i] for i in range(10))
    check_digit = (10 - (checksum % 10)) % 10
    return check_digit == int(digits[10])


def is_valid_romanian_cnp(value: str) -> bool:
    digits = re.sub(r"\D", "", value or "")
    if not re.fullmatch(r"\d{13}", digits):
        return False

    century_code = int(digits[0])
    yy = int(digits[1:3])
    mm = int(digits[3:5])
    dd = int(digits[5:7])
    year = _resolve_cnp_year(century_code, yy)
    if year is None or not _is_valid_date(year, mm, dd):
        return False

    weights = "279146358279"
    checksum = sum(int(digits[i]) * int(weights[i]) for i in range(12))
    check_digit = checksum % 11
    if check_digit == 10:
        check_digit = 1
    return check_digit == int(digits[12])


def is_valid_czech_birth_number(value: str) -> bool:
    digits = re.sub(r"\D", "", value or "")
    if not re.fullmatch(r"\d{9,10}", digits):
        return False

    yy = int(digits[0:2])
    mm_raw = int(digits[2:4])
    dd = int(digits[4:6])
    month = _resolve_czech_month(mm_raw)
    if month is None:
        return False

    year = 1900 + yy if len(digits) == 9 else (1900 + yy if yy >= 54 else 2000 + yy)
    if not _is_valid_date(year, month, dd):
        return False

    if len(digits) == 10:
        body = int(digits[:9])
        expected = 0 if body % 11 == 10 else body % 11
        return expected == int(digits[9])

    return True


def is_valid_german_tax_id(value: str) -> bool:
    digits = re.sub(r"\D", "", value or "")
    if not re.fullmatch(r"\d{11}", digits):
        return False
    if re.fullmatch(r"(\d)\1{10}", digits):
        return False

    product = 10
    for i in range(10):
        summed = (int(digits[i]) + product) % 10
        if summed == 0:
            summed = 10
        product = (2 * summed) % 11
    check = 11 - product
    if check in (10, 11):
        check = 0
    return check == int(digits[10])


def is_valid_hungarian_taj(value: str) -> bool:
    digits = re.sub(r"\D", "", value or "")
    if not re.fullmatch(r"\d{9}", digits):
        return False
    checksum = 0
    for i in range(8):
        weight = 3 if i % 2 == 0 else 7
        checksum += int(digits[i]) * weight
    return checksum % 10 == int(digits[8])


def is_valid_polish_nip(value: str) -> bool:
    digits = re.sub(r"\D", "", value or "")
    if not re.fullmatch(r"\d{10}", digits):
        return False
    weights = [6, 5, 7, 2, 3, 4, 5, 6, 7]
    checksum = sum(int(digits[i]) * weights[i] for i in range(9)) % 11
    if checksum == 10:
        return False
    return checksum == int(digits[9])


DNI_NIE_CONTROL_LETTERS = "TRWAGMYFPDXBNJZSQVHLCKE"

ITALIAN_ODD_WEIGHTS: Dict[str, int] = {
    "0": 1,
    "1": 0,
    "2": 5,
    "3": 7,
    "4": 9,
    "5": 13,
    "6": 15,
    "7": 17,
    "8": 19,
    "9": 21,
    "A": 1,
    "B": 0,
    "C": 5,
    "D": 7,
    "E": 9,
    "F": 13,
    "G": 15,
    "H": 17,
    "I": 19,
    "J": 21,
    "K": 2,
    "L": 4,
    "M": 18,
    "N": 20,
    "O": 11,
    "P": 3,
    "Q": 6,
    "R": 8,
    "S": 12,
    "T": 14,
    "U": 16,
    "V": 10,
    "W": 22,
    "X": 25,
    "Y": 24,
    "Z": 23,
}

ITALIAN_EVEN_WEIGHTS: Dict[str, int] = {
    "0": 0,
    "1": 1,
    "2": 2,
    "3": 3,
    "4": 4,
    "5": 5,
    "6": 6,
    "7": 7,
    "8": 8,
    "9": 9,
    "A": 0,
    "B": 1,
    "C": 2,
    "D": 3,
    "E": 4,
    "F": 5,
    "G": 6,
    "H": 7,
    "I": 8,
    "J": 9,
    "K": 10,
    "L": 11,
    "M": 12,
    "N": 13,
    "O": 14,
    "P": 15,
    "Q": 16,
    "R": 17,
    "S": 18,
    "T": 19,
    "U": 20,
    "V": 21,
    "W": 22,
    "X": 23,
    "Y": 24,
    "Z": 25,
}

AT_SVNR_WEIGHTS = [3, 7, 9, 5, 8, 4, 2, 1, 6]


def _is_valid_day_month_with_two_digit_year(day: int, month: int, yy: int) -> bool:
    return _is_valid_date(1900 + yy, month, day) or _is_valid_date(2000 + yy, month, day)


def is_valid_french_insee(value: str) -> bool:
    normalized = _normalize_compact_identifier(value)
    if not re.fullmatch(
        r"[12]\d{2}(0[1-9]|1[0-2]|[23]\d|4[0-2]|[5-9]\d)(2A|2B|\d{2})\d{3}\d{3}\d{2}",
        normalized,
    ):
        return False

    body = normalized[:13]
    check = normalized[13:]
    body_for_checksum = body.replace("2A", "19").replace("2B", "18")
    if not re.fullmatch(r"\d{13}", body_for_checksum):
        return False

    remainder = _mod97_from_digits(body_for_checksum)
    if remainder < 0:
        return False
    expected = 97 - remainder
    return expected == int(check)


def is_valid_spanish_dni_nie(value: str) -> bool:
    normalized = _normalize_compact_identifier(value)

    number_portion = ""
    check_letter = ""
    if re.fullmatch(r"\d{8}[A-Z]", normalized):
        number_portion = normalized[:8]
        check_letter = normalized[8]
    elif re.fullmatch(r"[XYZ]\d{7}[A-Z]", normalized):
        prefix_map = {"X": "0", "Y": "1", "Z": "2"}
        prefix = prefix_map.get(normalized[0], "")
        number_portion = f"{prefix}{normalized[1:8]}"
        check_letter = normalized[8]
    else:
        return False

    expected = DNI_NIE_CONTROL_LETTERS[int(number_portion) % 23]
    return check_letter == expected


def is_valid_italian_codice_fiscale(value: str) -> bool:
    normalized = _normalize_compact_identifier(value)
    if not re.fullmatch(r"[A-Z]{6}\d{2}[A-Z]\d{2}[A-Z]\d{3}[A-Z]", normalized):
        return False

    checksum = 0
    for i in range(15):
        ch = normalized[i]
        if i % 2 == 0:
            weight = ITALIAN_ODD_WEIGHTS.get(ch)
        else:
            weight = ITALIAN_EVEN_WEIGHTS.get(ch)
        if weight is None:
            return False
        checksum += weight

    expected = chr(ord("A") + (checksum % 26))
    return normalized[15] == expected


def is_valid_dutch_bsn(value: str) -> bool:
    digits = re.sub(r"\D", "", value or "")
    if not re.fullmatch(r"\d{8,9}", digits):
        return False

    normalized = digits.zfill(9)
    if normalized == "000000000":
        return False

    checksum = sum(int(normalized[i]) * (9 - i) for i in range(8)) - int(normalized[8])
    return checksum % 11 == 0


def is_valid_austrian_svnr(value: str) -> bool:
    digits = re.sub(r"\D", "", value or "")
    if not re.fullmatch(r"\d{10}", digits):
        return False

    day = int(digits[4:6])
    month = int(digits[6:8])
    yy = int(digits[8:10])
    if not _is_valid_day_month_with_two_digit_year(day, month, yy):
        return False

    indexes = [0, 1, 2, 4, 5, 6, 7, 8, 9]
    checksum = sum(int(digits[indexes[i]]) * AT_SVNR_WEIGHTS[i] for i in range(9))
    expected = checksum % 11
    if expected == 10:
        return False
    return expected == int(digits[3])


def is_likely_uk_nino(value: str) -> bool:
    normalized = re.sub(r"\s+", "", value or "").upper()
    if not re.fullmatch(r"[A-CEGHJ-PR-TW-Z]{2}\d{6}[A-D]", normalized):
        return False
    prefix = normalized[:2]
    if prefix in {"BG", "GB", "KN", "NK", "NT", "TN", "ZZ"}:
        return False
    if normalized[0] in {"D", "F", "I", "Q", "U", "V"}:
        return False
    if normalized[1] in {"D", "F", "I", "O", "Q", "U", "V"}:
        return False
    return True


def _mrz_char_value(ch: str) -> int:
    if ch == "<":
        return 0
    if ch.isdigit():
        return int(ch)
    if "A" <= ch <= "Z":
        return ord(ch) - 55
    return -1


def _compute_mrz_check_digit(payload: str) -> int:
    weights = [7, 3, 1]
    total = 0
    for i, ch in enumerate(payload):
        value = _mrz_char_value(ch)
        if value < 0:
            return -1
        total += value * weights[i % len(weights)]
    return total % 10


def _is_valid_mrz_check(payload: str, check_char: str) -> bool:
    if not re.fullmatch(r"\d", check_char or ""):
        return False
    expected = _compute_mrz_check_digit(payload)
    return expected >= 0 and expected == int(check_char)


def _is_valid_optional_mrz_check(payload: str, check_char: str) -> bool:
    if check_char == "<":
        return bool(re.fullmatch(r"<+", payload))
    return _is_valid_mrz_check(payload, check_char)


def _validate_mrz_td3(lines: List[str]) -> bool:
    if len(lines) != 2 or len(lines[0]) != 44 or len(lines[1]) != 44:
        return False
    line2 = lines[1]
    if not _is_valid_mrz_check(line2[0:9], line2[9]):
        return False
    if not _is_valid_mrz_check(line2[13:19], line2[19]):
        return False
    if not _is_valid_mrz_check(line2[21:27], line2[27]):
        return False
    if not _is_valid_optional_mrz_check(line2[28:42], line2[42]):
        return False
    composite = f"{line2[0:10]}{line2[13:20]}{line2[21:43]}"
    return _is_valid_mrz_check(composite, line2[43])


def _validate_mrz_td2(lines: List[str]) -> bool:
    if len(lines) != 2 or len(lines[0]) != 36 or len(lines[1]) != 36:
        return False
    line2 = lines[1]
    if not _is_valid_mrz_check(line2[0:9], line2[9]):
        return False
    if not _is_valid_mrz_check(line2[13:19], line2[19]):
        return False
    if not _is_valid_mrz_check(line2[21:27], line2[27]):
        return False
    return _is_valid_optional_mrz_check(line2[28:35], line2[35])


def _validate_mrz_td1(lines: List[str]) -> bool:
    if len(lines) != 3 or any(len(line) != 30 for line in lines):
        return False
    line1, line2, _line3 = lines
    if not _is_valid_mrz_check(line1[5:14], line1[14]):
        return False
    if not _is_valid_mrz_check(line2[0:6], line2[6]):
        return False
    if not _is_valid_mrz_check(line2[8:14], line2[14]):
        return False
    composite = f"{line1[5:30]}{line2[0:7]}{line2[8:15]}{line2[18:29]}"
    return _is_valid_mrz_check(composite, line2[29])


def is_valid_mrz_block(block: str) -> bool:
    lines = [re.sub(r"\s+", "", line).upper() for line in re.split(r"\r?\n", block.strip()) if line.strip()]
    if not lines:
        return False

    merged = "".join(lines)
    if not (len(lines) in (2, 3) and all(len(line) in (30, 36, 44) for line in lines)):
        if len(merged) == 88:
            lines = [merged[:44], merged[44:]]
        elif len(merged) == 72:
            lines = [merged[:36], merged[36:]]
        elif len(merged) == 90:
            lines = [merged[:30], merged[30:60], merged[60:]]

    if len(lines) == 2 and len(lines[0]) == 44 and len(lines[1]) == 44:
        return _validate_mrz_td3(lines)
    if len(lines) == 2 and len(lines[0]) == 36 and len(lines[1]) == 36:
        return _validate_mrz_td2(lines)
    if len(lines) == 3 and all(len(line) == 30 for line in lines):
        return _validate_mrz_td1(lines)
    return False


def _normalize_detections(text: str, detections: List[_Detection]) -> List[_Detection]:
    sorted_detections = sorted(
        (d for d in detections if 0 <= d.start < d.end <= len(text)),
        key=lambda d: (d.start, -(d.end - d.start)),
    )
    kept: List[_Detection] = []
    cursor = 0
    for d in sorted_detections:
        if d.start < cursor:
            continue
        kept.append(d)
        cursor = d.end
    return kept


_PHONE_CONTEXT_RE = re.compile(
    r"(?:^|[^\w])(?:%s)(?=$|[^\w])"
    % "|".join(re.escape(k) for k in PHONE_CONTEXT_KEYWORDS)
)


class RegexAnalyzer:
    """
    Smart-regex PII analyzer used as a fallback when Presidio/SpaCy is unavailable.

    Contract: uses <TYPE_INDEX> placeholders (<PHONE_1>, <PERSON_2>, ...).
    """

    _WINDOW_SIZE = 50

    _EMAIL_RE = re.compile(
        r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b",
        flags=re.IGNORECASE,
    )
    _IBAN_RE = re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b", flags=re.IGNORECASE)
    _CC_RE = re.compile(r"(?:\b\d[\d -]{10,22}\d\b)")
    _PHONE_RE = re.compile(r"(?<!\d)(?:\+?\d[\d\s().-]{7,}\d)(?!\d)")
    _CZ_BIRTH_RE = re.compile(
        r"\b\d{2}(?:0[1-9]|1[0-2]|2[1-9]|3[0-2]|5[1-9]|6[0-2]|7[1-9]|8[0-2])"
        r"(?:0[1-9]|[12]\d|3[01])/?\d{3,4}\b"
    )
    _PESEL_RE = re.compile(r"\b\d{11}\b")
    _CNP_RE = re.compile(r"\b[1-9]\d{12}\b")
    _DE_TAX_RE = re.compile(r"\b\d{11}\b")
    _HU_TAJ_RE = re.compile(r"\b\d{9}\b")
    _PL_NIP_RE = re.compile(r"\b\d{10}\b")
    _UK_NINO_RE = re.compile(r"\b[A-CEGHJ-PR-TW-Z]{2}\d{6}[A-D]\b", flags=re.IGNORECASE)
    _FR_INSEE_RE = re.compile(
        r"\b[12]\s?\d{2}\s?(0[1-9]|1[0-2]|[23]\d|4[0-2]|[5-9]\d)\s?(2[AB]|\d{2})\s?\d{3}\s?\d{3}\s?\d{2}\b",
        flags=re.IGNORECASE,
    )
    _ES_DNI_RE = re.compile(r"\b\d{8}[A-Z]\b", flags=re.IGNORECASE)
    _ES_NIE_RE = re.compile(r"\b[XYZ]\d{7}[A-Z]\b", flags=re.IGNORECASE)
    _IT_CODICE_FISCALE_RE = re.compile(
        r"\b[A-Z]{6}\d{2}[A-Z]\d{2}[A-Z]\d{3}[A-Z]\b", flags=re.IGNORECASE
    )
    _NL_BSN_RE = re.compile(r"\b\d{8,9}\b")
    _AT_SVNR_RE = re.compile(r"\b\d{4}\s?\d{6}\b")
    _EU_VAT_RE = re.compile(r"\b[A-Z]{2}[A-Z0-9]{8,12}\b")
    _UK_DRIVER_RE = re.compile(r"\b[A-Z9]{5}\d{6}[A-Z9]{2}\d[A-Z]{2}\b")
    _GENERIC_DRIVER_RE = re.compile(r"\b[A-Z0-9]{8,18}\b")
    _POSTAL_RE = re.compile(r"\b(?:\d{3}\s?\d{2}|\d{4}|\d{5})\b")
    _MRZ_BLOCK_RE = re.compile(
        r"(?:^|\r?\n)((?:[A-Z0-9< ]{10,44}\r?\n){1,5}[A-Z0-9< ]{10,44})(?=\r?\n|$)",
        flags=re.MULTILINE,
    )
    _NATIONAL_HINT_RE = re.compile(
        r"\b(?:rodn(?:e|é)\s*(?:cislo|číslo)|r\.?\s*[cč]\.?|pesel|cnp|steuer(?:-?id|identifikationsnummer)|taj|szemelyi|személyi|national\s+id|personal\s+number|id\s*card|vat|tax|tin|nip|nino|insee|nir|dni|nie|codice\s+fiscale|bsn|burgerservicenummer|svnr|sozialversicherung(?:snummer)?|mrz|passport|driving\s+licen[cs]e)\b",
        flags=re.IGNORECASE,
    )

    # Two capitalized words heuristic (Unicode-safe)
    # - [^\W\d_] matches Unicode letters only (no digits/underscore)
    _PERSON_RE = re.compile(r"(?<![^\W\d_])[^\W\d_]{3,}\s+[^\W\d_]{3,}(?![^\W\d_])")

    def __init__(self) -> None:
        self._anchor_words = [_fold_for_matching(word).strip() for word in ALL_ANCHORS if word]
        self._anchor_automaton = self._build_anchor_automaton(self._anchor_words)

    @staticmethod
    def _build_anchor_automaton(words: List[str]) -> Optional[Any]:
        if ahocorasick is None:
            return None
        automaton = ahocorasick.Automaton()
        for word in words:
            if word:
                automaton.add_word(word, word)
        automaton.make_automaton()
        return automaton

    def _get_anchor_hits(self, text: str) -> List[Tuple[str, int, int]]:
        normalized = _fold_for_matching(text)
        hits: List[Tuple[str, int, int]] = []

        def has_boundaries(start_index: int, end_index: int) -> bool:
            before = normalized[start_index - 1] if start_index > 0 else " "
            after = normalized[end_index] if end_index < len(normalized) else " "
            return not before.isalnum() and not after.isalnum()

        if self._anchor_automaton is not None:
            for end_index, word in self._anchor_automaton.iter(normalized):
                start_index = end_index - len(word) + 1
                if not has_boundaries(start_index, end_index + 1):
                    continue
                hits.append((word, start_index, end_index + 1))
            return hits

        for word in self._anchor_words:
            if not word:
                continue
            start = 0
            while True:
                idx = normalized.find(word, start)
                if idx < 0:
                    break
                if not has_boundaries(idx, idx + len(word)):
                    start = idx + len(word)
                    continue
                hits.append((word, idx, idx + len(word)))
                start = idx + len(word)
        return hits

    @staticmethod
    def _has_anchor_near(
        hits: List[Tuple[str, int, int]],
        start: int,
        end: int,
        anchors: set[str],
        window: int = ANCHOR_WINDOW_CHARS,
    ) -> bool:
        left = max(0, start - window)
        right = end + window
        for word, hit_start, hit_end in hits:
            if word not in anchors:
                continue
            if hit_end < left:
                continue
            if hit_start > right:
                continue
            return True
        return False

    @staticmethod
    def _contains_anchor(hits: List[Tuple[str, int, int]], anchors: set[str]) -> bool:
        for word, _start, _end in hits:
            if word in anchors:
                return True
        return False

    @staticmethod
    def _is_likely_eu_vat(value: str) -> bool:
        normalized = re.sub(r"\s+", "", value or "").upper()
        if not re.fullmatch(r"[A-Z]{2}[A-Z0-9]{8,12}", normalized):
            return False
        return normalized[:2] != "XX"

    @staticmethod
    def _is_likely_generic_driver_id(value: str) -> bool:
        normalized = re.sub(r"\s+", "", value or "").upper()
        if not re.fullmatch(r"[A-Z0-9]{8,18}", normalized):
            return False
        letters = sum(1 for ch in normalized if "A" <= ch <= "Z")
        digits = sum(1 for ch in normalized if ch.isdigit())
        return letters >= 2 and digits >= 4

    def _detect_mrz_documents(
        self, text: str, started_at: float, deadline_sec: float
    ) -> List[_Detection]:
        detections: List[_Detection] = []
        scanned = 0
        for match in self._MRZ_BLOCK_RE.finditer(text):
            if self._deadline_exceeded(started_at, deadline_sec):
                return detections
            scanned += 1
            if scanned > MAX_CANDIDATES_PER_RULE:
                return detections
            block = match.group(1)
            if not block:
                continue
            if not is_valid_mrz_block(block):
                continue
            detections.append(_Detection(match.start(1), match.end(1), "NATIONAL_ID", block))
        return detections

    def _has_phone_context(self, text: str, match_start: int) -> bool:
        start = max(0, match_start - self._WINDOW_SIZE)
        window = text[start:match_start].lower()
        return bool(_PHONE_CONTEXT_RE.search(window))

    @staticmethod
    def _likely_german_context(text: str) -> bool:
        return bool(
            re.search(
                r"\b(?:steuer|identifikationsnummer|steuer-id|steuerid|deutschland|de)\b",
                text,
                flags=re.IGNORECASE,
            )
        )

    @staticmethod
    def _likely_hungarian_context(text: str) -> bool:
        return bool(
            re.search(
                r"\b(?:taj|szemelyi|szemĂ©lyi|magyar|hungary|hu)\b",
                text,
                flags=re.IGNORECASE,
            )
        )

    @staticmethod
    def _deadline_exceeded(started_at: float, deadline_sec: float) -> bool:
        return (time.perf_counter() - started_at) > deadline_sec

    def _detect_national_identifiers(
        self, text: str, started_at: float, deadline_sec: float
    ) -> List[_Detection]:
        anchor_hits = self._get_anchor_hits(text)
        has_tax_anchor = self._contains_anchor(anchor_hits, TAX_ANCHORS)
        has_nl_bsn_anchor = self._contains_anchor(anchor_hits, NL_BSN_ANCHORS)
        has_at_svnr_anchor = self._contains_anchor(anchor_hits, AT_SVNR_ANCHORS)
        has_driver_anchor = self._contains_anchor(anchor_hits, DRIVER_ANCHORS)
        has_address_anchor = self._contains_anchor(anchor_hits, ADDRESS_ANCHORS)
        has_mrz_anchor = self._contains_anchor(anchor_hits, MRZ_ANCHORS)

        if (
            not self._NATIONAL_HINT_RE.search(text)
            and not re.search(r"\d{8,}", text)
            and "<" not in text
        ):
            return []

        detections: List[_Detection] = []

        if "<" in text or has_mrz_anchor:
            detections.extend(self._detect_mrz_documents(text, started_at, deadline_sec))

        scanned = 0
        for m in self._CZ_BIRTH_RE.finditer(text):
            if self._deadline_exceeded(started_at, deadline_sec):
                return detections
            scanned += 1
            if scanned > MAX_CANDIDATES_PER_RULE:
                break
            candidate = m.group(0)
            if is_valid_czech_birth_number(candidate):
                detections.append(_Detection(m.start(), m.end(), "NATIONAL_ID", candidate))

        scanned = 0
        for m in self._PESEL_RE.finditer(text):
            if self._deadline_exceeded(started_at, deadline_sec):
                return detections
            scanned += 1
            if scanned > MAX_CANDIDATES_PER_RULE:
                break
            candidate = m.group(0)
            if is_valid_pesel(candidate):
                detections.append(_Detection(m.start(), m.end(), "NATIONAL_ID", candidate))

        scanned = 0
        for m in self._CNP_RE.finditer(text):
            if self._deadline_exceeded(started_at, deadline_sec):
                return detections
            scanned += 1
            if scanned > MAX_CANDIDATES_PER_RULE:
                break
            candidate = m.group(0)
            if is_valid_romanian_cnp(candidate):
                detections.append(_Detection(m.start(), m.end(), "NATIONAL_ID", candidate))

        scanned = 0
        for m in self._FR_INSEE_RE.finditer(text):
            if self._deadline_exceeded(started_at, deadline_sec):
                return detections
            scanned += 1
            if scanned > MAX_CANDIDATES_PER_RULE:
                break
            candidate = m.group(0)
            if is_valid_french_insee(candidate):
                detections.append(_Detection(m.start(), m.end(), "NATIONAL_ID", candidate))

        scanned = 0
        for m in self._ES_DNI_RE.finditer(text):
            if self._deadline_exceeded(started_at, deadline_sec):
                return detections
            scanned += 1
            if scanned > MAX_CANDIDATES_PER_RULE:
                break
            candidate = m.group(0)
            if is_valid_spanish_dni_nie(candidate):
                detections.append(_Detection(m.start(), m.end(), "NATIONAL_ID", candidate))

        scanned = 0
        for m in self._ES_NIE_RE.finditer(text):
            if self._deadline_exceeded(started_at, deadline_sec):
                return detections
            scanned += 1
            if scanned > MAX_CANDIDATES_PER_RULE:
                break
            candidate = m.group(0)
            if is_valid_spanish_dni_nie(candidate):
                detections.append(_Detection(m.start(), m.end(), "NATIONAL_ID", candidate))

        scanned = 0
        for m in self._IT_CODICE_FISCALE_RE.finditer(text):
            if self._deadline_exceeded(started_at, deadline_sec):
                return detections
            scanned += 1
            if scanned > MAX_CANDIDATES_PER_RULE:
                break
            candidate = m.group(0)
            if is_valid_italian_codice_fiscale(candidate):
                detections.append(_Detection(m.start(), m.end(), "NATIONAL_ID", candidate))

        if self._likely_german_context(text) or has_tax_anchor:
            scanned = 0
            for m in self._DE_TAX_RE.finditer(text):
                if self._deadline_exceeded(started_at, deadline_sec):
                    return detections
                scanned += 1
                if scanned > MAX_CANDIDATES_PER_RULE:
                    break
                candidate = m.group(0)
                if not self._has_anchor_near(anchor_hits, m.start(), m.end(), TAX_ANCHORS):
                    continue
                if is_valid_german_tax_id(candidate):
                    detections.append(_Detection(m.start(), m.end(), "NATIONAL_ID", candidate))

        if self._likely_hungarian_context(text):
            scanned = 0
            for m in self._HU_TAJ_RE.finditer(text):
                if self._deadline_exceeded(started_at, deadline_sec):
                    return detections
                scanned += 1
                if scanned > MAX_CANDIDATES_PER_RULE:
                    break
                candidate = m.group(0)
                if is_valid_hungarian_taj(candidate):
                    detections.append(_Detection(m.start(), m.end(), "NATIONAL_ID", candidate))

        if has_tax_anchor:
            scanned = 0
            for m in self._PL_NIP_RE.finditer(text):
                if self._deadline_exceeded(started_at, deadline_sec):
                    return detections
                scanned += 1
                if scanned > MAX_CANDIDATES_PER_RULE:
                    break
                if not self._has_anchor_near(anchor_hits, m.start(), m.end(), TAX_ANCHORS):
                    continue
                candidate = m.group(0)
                if is_valid_polish_nip(candidate):
                    detections.append(_Detection(m.start(), m.end(), "NATIONAL_ID", candidate))

            scanned = 0
            for m in self._UK_NINO_RE.finditer(text):
                if self._deadline_exceeded(started_at, deadline_sec):
                    return detections
                scanned += 1
                if scanned > MAX_CANDIDATES_PER_RULE:
                    break
                if not self._has_anchor_near(anchor_hits, m.start(), m.end(), TAX_ANCHORS):
                    continue
                candidate = m.group(0)
                if is_likely_uk_nino(candidate):
                    detections.append(_Detection(m.start(), m.end(), "NATIONAL_ID", candidate))

            scanned = 0
            for m in self._EU_VAT_RE.finditer(text):
                if self._deadline_exceeded(started_at, deadline_sec):
                    return detections
                scanned += 1
                if scanned > MAX_CANDIDATES_PER_RULE:
                    break
                if not self._has_anchor_near(anchor_hits, m.start(), m.end(), TAX_ANCHORS):
                    continue
                candidate = m.group(0)
                if self._is_likely_eu_vat(candidate):
                    detections.append(_Detection(m.start(), m.end(), "NATIONAL_ID", candidate))

        if has_nl_bsn_anchor:
            scanned = 0
            for m in self._NL_BSN_RE.finditer(text):
                if self._deadline_exceeded(started_at, deadline_sec):
                    return detections
                scanned += 1
                if scanned > MAX_CANDIDATES_PER_RULE:
                    break
                if not self._has_anchor_near(anchor_hits, m.start(), m.end(), NL_BSN_ANCHORS):
                    continue
                candidate = m.group(0)
                if is_valid_dutch_bsn(candidate):
                    detections.append(_Detection(m.start(), m.end(), "NATIONAL_ID", candidate))

        if has_at_svnr_anchor:
            scanned = 0
            for m in self._AT_SVNR_RE.finditer(text):
                if self._deadline_exceeded(started_at, deadline_sec):
                    return detections
                scanned += 1
                if scanned > MAX_CANDIDATES_PER_RULE:
                    break
                if not self._has_anchor_near(anchor_hits, m.start(), m.end(), AT_SVNR_ANCHORS):
                    continue
                candidate = m.group(0)
                if is_valid_austrian_svnr(candidate):
                    detections.append(_Detection(m.start(), m.end(), "NATIONAL_ID", candidate))

        if has_driver_anchor:
            scanned = 0
            for m in self._UK_DRIVER_RE.finditer(text):
                if self._deadline_exceeded(started_at, deadline_sec):
                    return detections
                scanned += 1
                if scanned > MAX_CANDIDATES_PER_RULE:
                    break
                if not self._has_anchor_near(anchor_hits, m.start(), m.end(), DRIVER_ANCHORS):
                    continue
                detections.append(_Detection(m.start(), m.end(), "NATIONAL_ID", m.group(0)))

            scanned = 0
            for m in self._GENERIC_DRIVER_RE.finditer(text):
                if self._deadline_exceeded(started_at, deadline_sec):
                    return detections
                scanned += 1
                if scanned > MAX_CANDIDATES_PER_RULE:
                    break
                if not self._has_anchor_near(anchor_hits, m.start(), m.end(), DRIVER_ANCHORS):
                    continue
                candidate = m.group(0)
                if self._is_likely_generic_driver_id(candidate):
                    detections.append(_Detection(m.start(), m.end(), "NATIONAL_ID", candidate))

        if has_address_anchor:
            scanned = 0
            for m in self._POSTAL_RE.finditer(text):
                if self._deadline_exceeded(started_at, deadline_sec):
                    return detections
                scanned += 1
                if scanned > MAX_CANDIDATES_PER_RULE:
                    break
                if not self._has_anchor_near(
                    anchor_hits, m.start(), m.end(), ADDRESS_ANCHORS, ADDRESS_WINDOW_CHARS
                ):
                    continue
                detections.append(_Detection(m.start(), m.end(), "NATIONAL_ID", m.group(0)))

        return detections

    def anonymize(self, text: str) -> Tuple[str, Dict[str, str]]:
        if not text:
            return text, {}

        started_at = time.perf_counter()
        deadline_sec = DEFAULT_SCAN_DEADLINE_SEC
        detections: List[_Detection] = []

        for m in self._EMAIL_RE.finditer(text):
            if self._deadline_exceeded(started_at, deadline_sec):
                break
            detections.append(_Detection(m.start(), m.end(), "EMAIL", m.group(0)))

        for m in self._IBAN_RE.finditer(text):
            if self._deadline_exceeded(started_at, deadline_sec):
                break
            detections.append(_Detection(m.start(), m.end(), "IBAN", m.group(0)))

        for m in self._CC_RE.finditer(text):
            if self._deadline_exceeded(started_at, deadline_sec):
                break
            candidate = m.group(0)
            digits = _count_digits(candidate)
            if digits < 12 or digits > 19:
                continue
            if not _luhn_check(candidate):
                continue
            detections.append(_Detection(m.start(), m.end(), "CREDIT_CARD", candidate))

        for m in self._PHONE_RE.finditer(text):
            candidate = m.group(0)
            digits = _count_digits(candidate)
            if digits < 9 or digits > 15:
                continue

            is_strong = candidate.startswith("+") or candidate.startswith("00")
            if not is_strong:
                # Weak match (CEE local): require exactly 9 digits and strong context.
                if digits != 9:
                    continue
                if not self._has_phone_context(text, m.start()):
                    continue

            detections.append(_Detection(m.start(), m.end(), "PHONE", candidate))

        for m in self._PERSON_RE.finditer(text):
            if self._deadline_exceeded(started_at, deadline_sec):
                break
            candidate = m.group(0)
            context_window = _build_context_window(text, m.start(), len(candidate))
            if not _is_likely_person_name_candidate(candidate, context_window):
                continue
            detections.append(_Detection(m.start(), m.end(), "PERSON", candidate))

        detections.extend(self._detect_national_identifiers(text, started_at, deadline_sec))

        kept = _normalize_detections(text, detections)
        if not kept:
            return text, {}

        counters: Dict[str, int] = {}
        mapping: Dict[str, str] = {}
        replacements: List[Tuple[int, int, str, str]] = []

        for d in kept:
            counters[d.entity_type] = counters.get(d.entity_type, 0) + 1
            placeholder = f"<{d.entity_type}_{counters[d.entity_type]}>"
            mapping[placeholder] = d.text
            replacements.append((d.start, d.end, placeholder, d.text))

        masked = text
        for start, end, placeholder, _orig in sorted(replacements, key=lambda r: r[0], reverse=True):
            masked = masked[:start] + placeholder + masked[end:]

        return masked, mapping


class PIIManager:
    """
    Local-first, reversible PII masking.

    Hybrid mode:
    - Prefer Presidio + SpaCy if installed and a language model is available.
    - Fall back to a "Smart Regex" engine if AI dependencies/models are missing.

    Placeholders use the <TYPE_INDEX> contract: <PERSON_1>, <EMAIL_2>, ...
    """

    _SUPPORTED = ("PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER", "IBAN", "CREDIT_CARD")
    _TYPE_MAP = {
        "EMAIL_ADDRESS": "EMAIL",
        "PHONE_NUMBER": "PHONE",
        "IBAN": "IBAN",
        "CREDIT_CARD": "CREDIT_CARD",
        "PERSON": "PERSON",
    }

    def __init__(self, language: str = "en"):
        self._language = language
        self._use_ai = False
        self._analyzer = None
        self._regex_engine: Optional[RegexAnalyzer] = None

        try:
            self._load_presidio()
            self._use_ai = True
        except (ImportError, OSError, RuntimeError) as exc:
            self._use_ai = False
            self._regex_engine = RegexAnalyzer()
            logger.warning(
                "AgentID: AI Engine unavailable (%s). Switched to Smart Regex Fallback.",
                exc,
            )

    def _load_presidio(self) -> None:
        import spacy  # type: ignore

        try:
            from presidio_analyzer import AnalyzerEngine  # type: ignore
            from presidio_analyzer.nlp_engine import NlpEngineProvider  # type: ignore
        except Exception as exc:
            raise ImportError(
                "Missing PII dependencies. Install with: pip install agentid-sdk[pii]"
            ) from exc

        # --- CZECH SUPPORT ---
        # Select correct model based on language.
        model_name = "cs_core_news_md" if self._language == "cs" else "en_core_web_lg"

        # Check existence to prevent crash.
        if not spacy.util.is_package(model_name):
            raise OSError(
                f"SpaCy model '{model_name}' not found. "
                f"Please run: python -m spacy download {model_name}"
            )

        provider = NlpEngineProvider(
            nlp_configuration={
                "nlp_engine_name": "spacy",
                "models": [{"lang_code": self._language, "model_name": model_name}],
            }
        )
        nlp_engine = provider.create_engine()
        self._analyzer = AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=[self._language])

    def _detect_presidio(self, text: str) -> List[_Detection]:
        if self._analyzer is None:
            raise RuntimeError("Presidio analyzer is not initialized.")

        results = self._analyzer.analyze(
            text=text,
            language=self._language,
            entities=list(self._SUPPORTED),
            score_threshold=0.35,
        )

        detections: List[_Detection] = []
        for r in results:
            if r.start is None or r.end is None:
                continue
            if r.start < 0 or r.end <= r.start or r.end > len(text):
                continue
            entity = str(r.entity_type)
            if entity not in self._SUPPORTED:
                continue
            detections.append(_Detection(r.start, r.end, entity, text[r.start : r.end]))

        return _normalize_detections(text, detections)

    def _anonymize_presidio(self, text: str) -> Tuple[str, Dict[str, str]]:
        if not text:
            return text, {}

        detections = self._detect_presidio(text)
        if not detections:
            return text, {}

        counters: Dict[str, int] = {}
        replacements: List[Tuple[int, int, str, str]] = []
        mapping: Dict[str, str] = {}

        for d in detections:
            key = self._TYPE_MAP.get(d.entity_type, d.entity_type)
            counters[key] = counters.get(key, 0) + 1
            placeholder = f"<{key}_{counters[key]}>"
            replacements.append((d.start, d.end, placeholder, d.text))
            mapping[placeholder] = d.text

        masked = text
        for start, end, placeholder, _orig in sorted(replacements, key=lambda r: r[0], reverse=True):
            masked = masked[:start] + placeholder + masked[end:]

        return masked, mapping

    def anonymize(self, text: str) -> Tuple[str, Dict[str, str]]:
        if self._use_ai:
            try:
                masked, mapping = self._anonymize_presidio(text)
                # Mandatory fallback strategy: if cloud analyzer returns nothing
                # (unsupported language / timeout-like no-op), run local engine.
                if masked != text or mapping:
                    return masked, mapping
            except Exception as exc:
                self._use_ai = False
                logger.warning(
                    "AgentID: AI Engine failed during anonymize (%s). Falling back to Smart Regex.",
                    exc,
                )

        if self._regex_engine is None:
            self._regex_engine = RegexAnalyzer()
        return self._regex_engine.anonymize(text)

    def deanonymize(self, text: str, mapping: Dict[str, str]) -> str:
        """
        Best-effort replacement of placeholders with originals.

        Fail-safe: returns `text` unchanged if anything goes wrong.
        """
        if not text or not mapping:
            return text

        try:
            items = sorted(mapping.items(), key=lambda kv: len(kv[0]), reverse=True)
            out = text
            for placeholder, original in items:
                out = out.replace(placeholder, original)
            return out
        except Exception:
            logger.warning("AgentID: PII deanonymization failed; returning raw text.")
            return text


_PLACEHOLDER_RE = re.compile(r"<[A-Z_]+_\d+>")


def contains_placeholders(text: str) -> bool:
    return bool(_PLACEHOLDER_RE.search(text or ""))

