import re
from typing import Dict, List, Union

from upf.config import UPFConfig
from upf.detectors.deterministic_ids import is_valid_credit_card

EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
EMAIL_OCR_RE = re.compile(
    r"(?<![A-Za-z0-9._%+\-])"
    r"[A-Za-z0-9._%+\-]+(?:[ \t]*[._%+\-][ \t]*[A-Za-z0-9._%+\-]+)*[ \t]*@[ \t]*"
    r"[A-Za-z0-9.\-]+(?:[ \t]*\.[ \t]*[A-Za-z]{2,})+"
    r"(?![A-Za-z0-9._%+\-])"
)
# Catches OCR artifacts where a space was inserted inside the local part,
# e.g. "th schmitt @email.de" (misread from "th.schmitt@email.de").
# Validated against EMAIL_RE after removing the OCR-inserted space.
EMAIL_OCR_SPACED_RE = re.compile(
    r"(?<![A-Za-z0-9])"
    r"[A-Za-z0-9][A-Za-z0-9._%-]+[ \t][A-Za-z0-9._%-]+[ \t]*@[ \t]*"
    r"[A-Za-z0-9.\-]+(?:[ \t]*\.[ \t]*[A-Za-z]{2,})+"
    r"(?![A-Za-z0-9_%+\-])"
)
# Common English/German prepositions/articles that are false-positive triggers
# when followed by "word @domain.com" — excluded during post-match filtering.
_EMAIL_OCR_SPACED_STOPWORDS = frozenset({
    "a", "an", "and", "at", "by", "for", "from", "in", "of", "on",
    "or", "the", "to", "with",
    # DE
    "an", "auf", "aus", "bei", "bis", "durch", "für", "gegen", "in",
    "mit", "nach", "oder", "und", "von", "vor", "zu",
})
# Guard with non-alphanumeric boundaries to avoid matching inside identifiers like IBANs.
PHONE_RE = re.compile(r"(?<![A-Za-z0-9])\+?\d(?:[\d().\ \t\-]{7,}\d)(?![A-Za-z0-9])")


def detect_context_entities(text: str, config: UPFConfig) -> List[Dict[str, Union[int, str]]]:
    findings: List[Dict[str, Union[int, str]]] = []

    if config.redact_emails:
        findings.extend(_match_emails(text))

    if config.redact_phones:
        findings.extend(_match_phones(text))

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


def _match_phones(text: str) -> List[Dict[str, Union[int, str]]]:
    findings: List[Dict[str, Union[int, str]]] = []
    for match in PHONE_RE.finditer(text):
        raw = text[match.start(): match.end()]
        digits = "".join(c for c in raw if c.isdigit())
        compact = "".join(ch for ch in raw if ch not in " \t")

        # Date-like values should not be classified as phones.
        if re.fullmatch(r"\d{1,2}[./-]\d{1,2}[./-]\d{2,4}", compact):
            continue

        # Do not classify credit cards as phones.
        if is_valid_credit_card(raw):
            continue

        # Long grouped numbers (13+ digits) are typically cards/IDs, not phones.
        if len(digits) >= 13 and (" " in raw or "-" in raw):
            continue

        findings.append({
            "type": "PHONE",
            "start": match.start(),
            "end": match.end(),
            "text": raw,
        })
    return findings


def _match_emails(text: str) -> List[Dict[str, Union[int, str]]]:
    findings: List[Dict[str, Union[int, str]]] = []
    seen = set()

    for match in EMAIL_RE.finditer(text):
        key = (match.start(), match.end())
        seen.add(key)
        findings.append({
            "type": "EMAIL",
            "start": match.start(),
            "end": match.end(),
            "text": text[match.start(): match.end()],
        })

    for match in EMAIL_OCR_RE.finditer(text):
        key = (match.start(), match.end())
        if key in seen:
            continue
        raw = text[match.start(): match.end()]
        normalized = re.sub(r"\s+", "", raw)
        if not EMAIL_RE.fullmatch(normalized):
            continue
        seen.add(key)
        findings.append({
            "type": "EMAIL",
            "start": match.start(),
            "end": match.end(),
            "text": raw,
        })

    for match in EMAIL_OCR_SPACED_RE.finditer(text):
        key = (match.start(), match.end())
        if key in seen:
            continue
        raw = text[match.start(): match.end()]
        # Skip if the first token before the space is a stopword (e.g. "to alice@...")
        first_token = raw.split()[0].lower()
        if first_token in _EMAIL_OCR_SPACED_STOPWORDS:
            continue
        # Remove the OCR-inserted space in the local part and validate
        normalized = re.sub(r"[ \t]+", "", raw)
        if not EMAIL_RE.fullmatch(normalized):
            continue
        seen.add(key)
        findings.append({
            "type": "EMAIL",
            "start": match.start(),
            "end": match.end(),
            "text": raw,
        })

    return findings


def _match_to_findings(text: str, pattern: re.Pattern[str], entity_type: str) -> List[Dict[str, Union[int, str]]]:
    return [
        {
            "type": entity_type,
            "start": match.start(),
            "end": match.end(),
            "text": text[match.start() : match.end()],
        }
        for match in pattern.finditer(text)
    ]
