import re
import unicodedata
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Union

from upf.config import UPFConfig, compute_risk_level, compute_risk_score
from upf.detectors.address import detect_address_entities
from upf.detectors.context import detect_context_entities
from upf.detectors.deterministic_ids import detect_deterministic_id_entities
from upf.detectors.entropy import detect_entropy_entities
from upf.detectors.ml_ner import detect_ml_entities
from upf.detectors.multilingual import detect_multilingual_entities
from upf.detectors.name_dict import detect_name_dict_entities, _is_in_lexicon
from upf.detectors.ner import detect_named_entities
from upf.tokenizer import CAPITALIZED_PHRASE, EMAIL_CANDIDATE, PHONE_CANDIDATE, tokenize
from upf.detectors.niss import detect_niss_entities
from upf.detectors.numeric_id import detect_numeric_id_entities
from upf.detectors.relationship import detect_relationship_entities
from upf.detectors.url import detect_url_entities
from upf.redaction.pseudonym import PseudonymSession, apply_pseudonym_redaction
from upf.redaction.skeleton import skeletonize
from upf.redaction.text import RedactionMode, apply_text_redaction

_LABEL_LIKE_NAME_TOKENS = frozenset({
    "account", "address", "amount", "application", "balance", "bank",
    "birth", "card", "cardholder", "city", "client", "contact", "contacto",
    "date", "description", "descripcion", "direccao", "direccion", "direct",
    "dob", "document", "email", "electrenico", "electronic", "electronico",
    "eletronico", "emergencia", "emergency", "expiry", "fecha", "field",
    "firma", "form", "holder", "id", "identification", "identity", "imposto",
    "information", "inscripcion", "invoice", "issued", "issue", "label",
    "line", "mobile", "name", "nacimiento", "number", "pagamento",
    "payment", "phone", "postal", "position", "price", "quantity", "reference",
    "signature", "solicitante", "statement", "street", "subject", "tax",
    "telefono", "telefone", "total", "transaction", "type", "unit",
    "valid", "valido", "nalido", "desde", "inicio", "disponible",
    "administrativo", "asstente", "assistente", "permiso", "conduccion",
    "cadastro", "antrag", "kredit", "formular", "unterschrift",
    "unteeschritt", "datum", "zeitraum", "beschreibung", "calle",
})


def _normalize_token(word: str) -> str:
    nfkd = unicodedata.normalize("NFKD", word.lower())
    return "".join(c for c in nfkd if not unicodedata.combining(c))


def _is_label_like_name(text: str) -> bool:
    tokens = re.findall(r"[A-Za-zÀ-ÿ']+", text)
    if not tokens:
        return False

    normalized = [_normalize_token(t.strip("'")) for t in tokens if t.strip("'")]
    if not normalized:
        return False

    label_hits = [t for t in normalized if t in _LABEL_LIKE_NAME_TOKENS]
    if not label_hits:
        return False

    # Keep true personal names even if one token collides with a label word.
    if any(_is_in_lexicon(t) for t in tokens):
        return False

    # Single-word labels ("Address", "Number"), or multi-token form labels.
    if len(normalized) == 1:
        return True
    if len(label_hits) >= 1 and len(normalized) <= 4:
        return True
    return len(label_hits) >= len(normalized) - 1


def _is_name_label_before_colon(
    finding: Dict[str, Union[int, str]],
    source_text: Optional[str],
) -> bool:
    if source_text is None:
        return False
    start = int(finding.get("start", 0))
    end = int(finding.get("end", 0))
    if end <= start or end > len(source_text):
        return False

    name_text = str(finding.get("text", source_text[start:end]))
    tokens = re.findall(r"[A-Za-zÀ-ÿ']+", name_text)
    if any(_is_in_lexicon(t) for t in tokens):
        return False

    # Field-label pattern: "<label phrase>:"
    tail = source_text[end:end + 4]
    return bool(re.match(r"[ \t]{0,2}:", tail))


class HighRiskBlockedError(Exception):
    """Raised when risk_mode='threshold', block_high_risk=True, and risk_level='high'."""

    def __init__(self, risk_score: int, risk_level: str, entities: List[dict]) -> None:
        self.risk_score = risk_score
        self.risk_level = risk_level
        self.entities = entities
        super().__init__(
            f"Request blocked: risk_level={risk_level}, risk_score={risk_score}"
        )


@dataclass
class TextSanitizeResult:
    sanitized_text: str
    entities: List[Dict[str, Any]] = field(default_factory=list)
    risk_score: int = 0
    risk_level: str = "low"    # 'low' | 'medium' | 'high'
    decision: str = "redact"   # 'redact' | 'allow' | 'block'
    session: Optional[PseudonymSession] = None

    def __str__(self) -> str:
        return self.sanitized_text

    def restore(self, response_text: str) -> str:
        """Re-insert original values into *response_text* using the session mapping."""
        if self.session is None:
            return response_text
        return self.session.restore(response_text)


class TextPipeline:
    def __init__(self, config: Optional[UPFConfig] = None) -> None:
        self.config = config or UPFConfig()

    def _prepare_text(self, text: str) -> str:
        # Keep original text for downstream multilayer detection. Rust acceleration,
        # when available, is merged as findings (detect_entities), not via pre-redaction.
        return text

    def detect(self, text: str) -> List[Dict[str, Union[int, str]]]:
        working_text = self._prepare_text(text)
        return self._detect_in_prepared_text(working_text)

    def _detect_in_prepared_text(self, working_text: str) -> List[Dict[str, Union[int, str]]]:
        findings: List[Dict[str, Union[int, str]]] = []

        # Pre-layer: privacy tokenizer produces candidate spans for the entire text.
        # This single pass gives all downstream detectors a shared set of hints,
        # and feeds the ML NER window selection below.
        candidates = tokenize(working_text)
        # Convert capitalized-phrase candidates to finding-compatible dicts for ML window hints
        ml_hints = [
            {"start": c.start, "end": c.end, "type": "NAME_CANDIDATE", "text": c.text}
            for c in candidates
            if c.candidate_type in (CAPITALIZED_PHRASE, EMAIL_CANDIDATE, PHONE_CANDIDATE)
        ]

        # Deterministic IDs are always on.
        findings.extend(detect_deterministic_id_entities(working_text, self.config))

        # Layer 1: structural + regex detectors
        findings.extend(detect_context_entities(working_text, self.config))
        findings.extend(detect_named_entities(working_text, self.config))
        findings.extend(detect_name_dict_entities(working_text, self.config))
        if self.config.redact_secrets:
            findings.extend(detect_entropy_entities(working_text, self.config))
        if self.config.redact_addresses:
            findings.extend(detect_address_entities(working_text, self.config))
        if self.config.redact_urls:
            findings.extend(detect_url_entities(working_text, self.config))
        if self.config.redact_numeric_ids:
            findings.extend(detect_numeric_id_entities(working_text, self.config))
        if self.config.redact_national_ids:
            findings.extend(detect_niss_entities(working_text, self.config))

        # Layer 2: multilingual keyword context (no ML required)
        if self.config.use_multilingual:
            findings.extend(detect_multilingual_entities(working_text, self.config))

        # Layer 2b: relationship-pattern detector
        if self.config.use_relationship_detector:
            findings.extend(detect_relationship_entities(working_text, self.config))

        # Layer 3: ML NER — windows driven by tokenizer hints + existing findings
        if self.config.use_ml_ner:
            ml_window_spans = findings + ml_hints
            findings.extend(detect_ml_entities(working_text, self.config, suspicious_spans=ml_window_spans))

        findings = self._apply_suppression_rules(findings, source_text=working_text)

        # Deduplicate exact (start, end, type) triples across detectors
        seen_keys: set = set()
        deduped = []
        for f in findings:
            key = (int(f["start"]), int(f["end"]), str(f["type"]))
            if key not in seen_keys:
                seen_keys.add(key)
                deduped.append(f)
        return deduped

    def _apply_suppression_rules(
        self, findings: List[Dict[str, Union[int, str]]], source_text: Optional[str] = None
    ) -> List[Dict[str, Union[int, str]]]:
        def _overlaps(a: Dict[str, Union[int, str]], b: Dict[str, Union[int, str]]) -> bool:
            return int(a["start"]) < int(b["end"]) and int(b["start"]) < int(a["end"])

        deterministic_types = {"IBAN", "CREDIT_CARD", "PT_NIF", "BR_CPF", "PESEL", "PT_NISS"}
        deterministic = [f for f in findings if str(f.get("type")) in deterministic_types]
        ibans = [f for f in deterministic if str(f["type"]) == "IBAN"]
        cards = [f for f in deterministic if str(f["type"]) == "CREDIT_CARD"]
        pt_nifs = [f for f in deterministic if str(f["type"]) == "PT_NIF"]
        br_cpfs = [f for f in deterministic if str(f["type"]) == "BR_CPF"]
        strong_ids = [f for f in deterministic if str(f["type"]) in {"IBAN", "CREDIT_CARD", "PT_NIF", "BR_CPF", "PESEL", "PT_NISS"}]

        out: List[Dict[str, Union[int, str]]] = []
        for f in findings:
            ftype = str(f.get("type"))
            if ftype == "NAME":
                if _is_label_like_name(str(f.get("text", ""))):
                    continue
                if _is_name_label_before_colon(f, source_text):
                    continue
            if ftype == "LONG_NUMERIC_ID" and any(_overlaps(f, s) for s in strong_ids):
                continue
            if ftype == "PHONE" and any(_overlaps(f, s) for s in cards):
                continue
            if ftype == "PHONE" and any(_overlaps(f, s) for s in ibans):
                continue
            if ftype == "PESEL" and any(_overlaps(f, s) for s in pt_nifs + br_cpfs):
                continue
            out.append(f)
        return out

    def _make_decision(self, risk_level: str, findings: list) -> str:
        if self.config.risk_mode == "legacy":
            return "redact"
        if risk_level == "high" and self.config.block_high_risk:
            return "block"
        if risk_level == "low" and not findings:
            return "allow"
        return "redact"

    def sanitize(self, text: str, session: Optional[PseudonymSession] = None) -> str:
        working_text = self._prepare_text(text)
        findings = self._detect_in_prepared_text(working_text)

        if self.config.risk_mode == "threshold" and self.config.block_high_risk:
            level = compute_risk_level(
                findings,
                self.config.low_threshold,
                self.config.high_threshold,
                self.config.deterministic_floor_types,
            )
            if level == "high":
                raise HighRiskBlockedError(
                    risk_score=compute_risk_score(findings),
                    risk_level=level,
                    entities=list(findings),
                )

        if self.config.redaction_mode == RedactionMode.PSEUDONYM or session is not None:
            s = session or PseudonymSession()
            return apply_pseudonym_redaction(working_text, findings, s)
        if self.config.redaction_mode == RedactionMode.SKELETON:
            return skeletonize(working_text, findings)
        return apply_text_redaction(working_text, findings, mode=self.config.redaction_mode)

    def sanitize_with_details(self, text: str, session: Optional[PseudonymSession] = None) -> TextSanitizeResult:
        working_text = self._prepare_text(text)
        findings = self._detect_in_prepared_text(working_text)

        risk_score = compute_risk_score(findings)
        risk_level = compute_risk_level(
            findings,
            self.config.low_threshold,
            self.config.high_threshold,
            self.config.deterministic_floor_types,
        )
        decision = self._make_decision(risk_level, findings)

        if decision == "block":
            raise HighRiskBlockedError(
                risk_score=risk_score,
                risk_level=risk_level,
                entities=list(findings),
            )

        if self.config.redaction_mode == RedactionMode.PSEUDONYM or session is not None:
            s = session or PseudonymSession()
            sanitized = apply_pseudonym_redaction(working_text, findings, s)
            return TextSanitizeResult(
                sanitized_text=sanitized,
                entities=findings,
                risk_score=risk_score,
                risk_level=risk_level,
                decision=decision,
                session=s,
            )
        if self.config.redaction_mode == RedactionMode.SKELETON:
            sanitized = skeletonize(working_text, findings)
        else:
            sanitized = apply_text_redaction(working_text, findings, mode=self.config.redaction_mode)
        return TextSanitizeResult(
            sanitized_text=sanitized,
            entities=findings,
            risk_score=risk_score,
            risk_level=risk_level,
            decision=decision,
        )
