from __future__ import annotations

import hashlib
import json
import re
import unicodedata
from collections.abc import Iterable
from datetime import UTC, datetime
from typing import Any

from app.case_epicrisis.domain.curation import (
    CURATION_SCHEMA_VERSION,
    CandidateOrigin,
    CaseCuration,
    CatalogReference,
    CodeSystem,
    CodingConflict,
    CurationDecision,
    CurationDecisionType,
    CurationStatus,
    EvidenceReference,
    ObjectiveData,
    ObjectiveDiagnosis,
    ObjectiveProcedure,
    ProcedureState,
    dump_curation_model,
)
from app.case_epicrisis.domain.ports import CodingCatalogRegistry
from app.services.deterministic_signals import DeterministicSignalSnapshot


_TECHNICAL_CODE_VALUES = {"", "PENDIENTE", "SIN CODIGO", "SIN CÓDIGO", "ERROR", "NO DISPONIBLE"}
_PRIMARY_TERMS = ("FRACTURA", "INFECCION", "INFECCIÓN", "NEOPLASIA", "INFARTO")
_LOCATION_GROUPS = (
    ({"PROXIMAL", "EPIFISIS", "EPÍFISIS", "SUPERIOR"}, {"DIAFISIS", "DIÁFISIS"}),
    ({"HOMBRO"}, {"CODO", "PIE", "RODILLA", "TOBILLO"}),
)


def _ascii_upper(value: Any) -> str:
    normalized = unicodedata.normalize("NFKD", str(value or ""))
    return "".join(char for char in normalized if not unicodedata.combining(char)).upper()


def _clean_text(value: Any) -> str:
    return re.sub(r"\s+", " ", str(value or "")).strip()


def _normalized_key(value: Any) -> str:
    return re.sub(r"[^A-Z0-9]+", "-", _ascii_upper(value)).strip("-").lower()


def _stable_id(prefix: str, *values: Any) -> str:
    raw = "|".join(_clean_text(value) for value in values)
    return f"{prefix}:{hashlib.sha256(raw.encode()).hexdigest()[:20]}"


def _real_code(value: Any) -> str:
    code = _clean_text(value)
    return "" if _ascii_upper(code) in _TECHNICAL_CODE_VALUES else code


def _context_type(value: Any) -> str:
    text = _ascii_upper(value)
    if re.search(r"\b(POP|POST|POSTOPERATORIO|POSOPERATORIO)\b", text):
        return "post"
    if re.search(r"\b(PRE|PREQUIRURGICO|PREOPERATORIO)\b", text):
        return "pre"
    return "no_clasificado"


def _is_primary_description(value: str) -> bool:
    upper = _ascii_upper(value)
    return any(term in upper for term in _PRIMARY_TERMS)


def _evidence_for_document(
    document: dict[str, Any],
    *,
    section: str,
    excerpt: str,
    page: int | None = None,
) -> EvidenceReference:
    return EvidenceReference(
        document_id=str(document.get("_id") or document.get("id") or ""),
        document_type=str(document.get("tipo_documento") or document.get("document_type") or "desconocido"),
        filename=str(document.get("nombre_archivo") or ""),
        page=page if page and page > 0 else None,
        section=section,
        excerpt=_clean_text(excerpt)[:280],
        source_hash=str(document.get("source_file_hash") or ""),
    )


def _inline_evidence(
    signals: DeterministicSignalSnapshot | None,
    *,
    system: CodeSystem,
    code: str,
) -> dict[str, Any] | None:
    if signals is None:
        return None
    entries = signals.inline_cie10 if system == CodeSystem.CIE10 else signals.inline_cups
    code_key = re.sub(r"[^A-Z0-9]", "", _ascii_upper(code))
    for item in entries:
        item_code = item.get("codigo") if system == CodeSystem.CIE10 else item.get("codigo_cups")
        if re.sub(r"[^A-Z0-9]", "", _ascii_upper(item_code)) == code_key:
            return item
    return None


def _candidate_status(
    *,
    catalog_registry: CodingCatalogRegistry,
    system: CodeSystem,
    code: str,
    description: str,
    explicit: bool,
) -> tuple[CurationStatus, list[str], CatalogReference]:
    reference = catalog_registry.reference(system)
    warnings: list[str] = []
    if not explicit:
        warnings.append("codigo_inferido_requiere_revision")
    if not reference.available:
        warnings.append("catalogo_no_disponible")
    elif not catalog_registry.contains(system, code):
        warnings.append("codigo_no_encontrado_en_catalogo")
    elif not catalog_registry.description_matches(system, code, description):
        warnings.append("codigo_descripcion_incompatibles")
    if explicit and not warnings:
        return CurationStatus.AUTO_VALIDATED, warnings, reference
    return CurationStatus.PENDING_REVIEW, warnings, reference


def build_document_objective_data(
    document: dict[str, Any],
    *,
    catalog_registry: CodingCatalogRegistry,
    deterministic_signals: DeterministicSignalSnapshot | None = None,
) -> ObjectiveData:
    document_type = str(document.get("tipo_documento") or document.get("document_type") or "")
    diagnoses: list[ObjectiveDiagnosis] = []
    procedures: list[ObjectiveProcedure] = []

    for item in document.get("codigos_cie10") or []:
        if not isinstance(item, dict):
            continue
        code = _real_code(item.get("codigo"))
        description = _clean_text(item.get("descripcion") or item.get("diagnostico"))
        original = _clean_text(item.get("diagnostico") or description)
        if not description:
            continue
        inline = _inline_evidence(deterministic_signals, system=CodeSystem.CIE10, code=code)
        explicit = bool(code and inline)
        status = CurationStatus.PENDING_REVIEW
        warnings = ["diagnostico_sin_codigo"] if not code else []
        catalog = None
        if code:
            status, code_warnings, catalog = _candidate_status(
                catalog_registry=catalog_registry,
                system=CodeSystem.CIE10,
                code=code,
                description=description,
                explicit=explicit,
            )
            warnings.extend(code_warnings)
        evidence = _evidence_for_document(
            document,
            section="diagnosticos",
            excerpt=(inline or {}).get("excerpt") or original,
            page=int((inline or {}).get("page") or document.get("source_page_start") or 0) or None,
        )
        diagnoses.append(
            ObjectiveDiagnosis(
                item_id=_stable_id("dx", document.get("_id"), code, original),
                code=code,
                description=description,
                original_text=original,
                origin=CandidateOrigin.EXPLICIT if explicit else CandidateOrigin.RAG,
                status=status,
                evidence=[evidence],
                catalog=catalog,
                context_type=_context_type(original),
                is_primary=_is_primary_description(original),
                warnings=list(dict.fromkeys(warnings)),
            )
        )

    procedure_state = {
        "quirurgico": ProcedureState.PERFORMED,
        "factura": ProcedureState.BILLED,
    }.get(document_type, ProcedureState.DOCUMENTED)
    for item in document.get("codigos_cups") or []:
        if not isinstance(item, dict):
            continue
        code = _real_code(item.get("codigo_cups") or item.get("codigo"))
        description = _clean_text(item.get("procedimiento") or item.get("descripcion"))
        if not description:
            continue
        inline = _inline_evidence(deterministic_signals, system=CodeSystem.CUPS, code=code)
        explicit = bool(code and inline)
        status = CurationStatus.PENDING_REVIEW
        warnings = ["procedimiento_sin_cups"] if not code else []
        catalog = None
        if code:
            status, code_warnings, catalog = _candidate_status(
                catalog_registry=catalog_registry,
                system=CodeSystem.CUPS,
                code=code,
                description=description,
                explicit=explicit,
            )
            warnings.extend(code_warnings)
        procedures.append(
            ObjectiveProcedure(
                item_id=_stable_id("px", document.get("_id"), code, description, procedure_state),
                description=description,
                original_text=description,
                codigo_cups=code,
                origin=CandidateOrigin.EXPLICIT if explicit else CandidateOrigin.RAG,
                status=status,
                states=[procedure_state],
                evidence=[
                    _evidence_for_document(
                        document,
                        section="procedimientos",
                        excerpt=(inline or {}).get("excerpt") or description,
                        page=int((inline or {}).get("page") or document.get("source_page_start") or 0)
                        or None,
                    )
                ],
                catalog=catalog,
                is_primary=procedure_state == ProcedureState.PERFORMED,
                warnings=list(dict.fromkeys(warnings)),
            )
        )

    if document_type == "factura":
        procedures.extend(_build_invoice_procedures(document, catalog_registry))
    else:
        procedures.extend(_build_uncoded_procedures(document, procedure_state))

    retryable_errors = []
    if document.get("error_cups"):
        retryable_errors.append("cups_provider_unavailable")
        for procedure in procedures:
            if not procedure.codigo_cups:
                procedure.status = CurationStatus.EXTERNAL_SERVICE_PENDING
                procedure.warnings = list(dict.fromkeys([*procedure.warnings, "codificacion_cups_reintentable"]))

    return ObjectiveData(
        diagnoses=_dedupe_diagnoses(diagnoses),
        procedures=_dedupe_procedures(procedures),
        catalogs=catalog_registry.references(),
        processing_status="partial" if retryable_errors else "completed",
        retryable_errors=retryable_errors,
    )


def _build_invoice_procedures(
    document: dict[str, Any],
    catalog_registry: CodingCatalogRegistry,
) -> list[ObjectiveProcedure]:
    factura_json = document.get("factura_json") or {}
    services = factura_json.get("servicios_procedimientos") or {}
    results: list[ObjectiveProcedure] = []
    for item in services.get("procedimientos_quirurgicos") or []:
        if not isinstance(item, dict):
            continue
        description = _clean_text(item.get("descripcion") or item.get("concepto"))
        cups = _real_code(item.get("codigo_cups") or item.get("codigo_referencia"))
        billing = _real_code(item.get("codigo_facturacion") or item.get("concepto"))
        if not description:
            continue
        status = CurationStatus.PENDING_REVIEW
        warnings = []
        catalog = None
        if cups:
            status, warnings, catalog = _candidate_status(
                catalog_registry=catalog_registry,
                system=CodeSystem.CUPS,
                code=cups,
                description=description,
                explicit=True,
            )
        else:
            warnings.append("factura_sin_codigo_cups")
        results.append(
            ObjectiveProcedure(
                item_id=_stable_id("px", document.get("_id"), cups, billing, description, "facturado"),
                description=description,
                original_text=description,
                codigo_cups=cups,
                codigo_facturacion=billing,
                origin=CandidateOrigin.EXPLICIT,
                status=status,
                states=[ProcedureState.BILLED],
                evidence=[
                    _evidence_for_document(
                        document,
                        section="servicios_facturados",
                        excerpt=description,
                        page=int(document.get("source_page_start") or 0) or None,
                    )
                ],
                catalog=catalog,
                warnings=warnings,
            )
        )
    return results


def _build_uncoded_procedures(
    document: dict[str, Any],
    state: ProcedureState,
) -> list[ObjectiveProcedure]:
    coded_descriptions = {
        _normalized_key(
            item.get("procedimiento") or item.get("descripcion")
        )
        for item in document.get("codigos_cups") or []
        if isinstance(item, dict)
    }
    results: list[ObjectiveProcedure] = []
    for raw in document.get("procedimientos_extraidos") or []:
        description = _clean_text(raw)
        if not description or _normalized_key(description) in coded_descriptions:
            continue
        results.append(
            ObjectiveProcedure(
                item_id=_stable_id("px", document.get("_id"), description, state),
                description=description,
                original_text=description,
                origin=CandidateOrigin.LLM,
                status=CurationStatus.PENDING_REVIEW,
                states=[state],
                evidence=[
                    _evidence_for_document(
                        document,
                        section="procedimientos",
                        excerpt=description,
                        page=int(document.get("source_page_start") or 0) or None,
                    )
                ],
                warnings=["procedimiento_sin_cups"],
            )
        )
    return results


def _dedupe_diagnoses(items: Iterable[ObjectiveDiagnosis]) -> list[ObjectiveDiagnosis]:
    merged: dict[str, ObjectiveDiagnosis] = {}
    for item in items:
        key = f"{item.code}|{_normalized_key(item.description)}"
        if key not in merged:
            merged[key] = item
            continue
        current = merged[key]
        current.evidence.extend(e for e in item.evidence if e not in current.evidence)
        current.warnings = list(dict.fromkeys([*current.warnings, *item.warnings]))
    return list(merged.values())


def _dedupe_procedures(items: Iterable[ObjectiveProcedure]) -> list[ObjectiveProcedure]:
    merged: dict[str, ObjectiveProcedure] = {}
    for item in items:
        key = item.codigo_cups or f"{item.codigo_facturacion}|{_normalized_key(item.description)}"
        if key not in merged:
            merged[key] = item
            continue
        current = merged[key]
        current.states = list(dict.fromkeys([*current.states, *item.states]))
        current.evidence.extend(e for e in item.evidence if e not in current.evidence)
        current.warnings = list(dict.fromkeys([*current.warnings, *item.warnings]))
        current.codigo_facturacion = current.codigo_facturacion or item.codigo_facturacion
    return list(merged.values())


def _objective_from_document(document: dict[str, Any]) -> ObjectiveData | None:
    payload = document.get("datos_objetivos")
    if not isinstance(payload, dict):
        return None
    try:
        return ObjectiveData.model_validate(payload)
    except ValueError:
        return None


def reconcile_case_objective_data(
    documents: Iterable[dict[str, Any] | None],
    *,
    preserved_context: dict[str, Any] | None = None,
) -> CaseCuration | None:
    objective_documents = [
        objective
        for document in documents
        if isinstance(document, dict)
        if (objective := _objective_from_document(document)) is not None
    ]
    if not objective_documents:
        return None

    diagnoses = _dedupe_diagnoses(
        diagnosis for objective in objective_documents for diagnosis in objective.diagnoses
    )
    procedures = _dedupe_procedures(
        procedure for objective in objective_documents for procedure in objective.procedures
    )
    catalogs = _dedupe_catalogs(
        reference for objective in objective_documents for reference in objective.catalogs
    )
    _restore_decisions(diagnoses, procedures, preserved_context or {})
    conflicts = _detect_diagnosis_conflicts(diagnoses)
    conflict_ids = {item_id for conflict in conflicts for item_id in conflict.item_ids}
    for item in [*diagnoses, *procedures]:
        if item.item_id in conflict_ids and item.status not in {
            CurationStatus.APPROVED,
            CurationStatus.CORRECTED,
            CurationStatus.REJECTED,
        }:
            item.status = CurationStatus.CONFLICT

    version = _decision_aware_version(
        _case_version(objective_documents, catalogs),
        diagnoses,
        procedures,
    )
    pending = [
        item.item_id
        for item in [*diagnoses, *procedures]
        if item.status
        in {
            CurationStatus.PENDING_REVIEW,
            CurationStatus.EXTERNAL_SERVICE_PENDING,
            CurationStatus.CONFLICT,
        }
    ]
    blocking_conflict = next((conflict for conflict in conflicts if conflict.blocking), None)
    return CaseCuration(
        version=version,
        diagnoses=diagnoses,
        procedures=procedures,
        conflicts=conflicts,
        pending_item_ids=pending,
        catalogs=catalogs,
        generation_blocked=blocking_conflict is not None,
        blocking_reason=blocking_conflict.message if blocking_conflict else "",
    )


def _dedupe_catalogs(items: Iterable[CatalogReference]) -> list[CatalogReference]:
    result: dict[tuple[CodeSystem, str], CatalogReference] = {}
    for item in items:
        result[(item.system, item.fingerprint)] = item
    return list(result.values())


def _case_version(
    objectives: list[ObjectiveData],
    catalogs: list[CatalogReference],
) -> str:
    payload = {
        "schema": CURATION_SCHEMA_VERSION,
        "objectives": [dump_curation_model(item) for item in objectives],
        "catalogs": [dump_curation_model(item) for item in catalogs],
    }
    encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
    return hashlib.sha256(encoded.encode()).hexdigest()


def _decision_aware_version(
    base_version: str,
    diagnoses: list[ObjectiveDiagnosis],
    procedures: list[ObjectiveProcedure],
) -> str:
    decisions = [
        dump_curation_model(decision)
        for item in [*diagnoses, *procedures]
        for decision in item.decisions
    ]
    encoded = json.dumps(
        {"base": base_version, "decisions": decisions},
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=True,
    )
    return hashlib.sha256(encoded.encode()).hexdigest()


def _detect_diagnosis_conflicts(diagnoses: list[ObjectiveDiagnosis]) -> list[CodingConflict]:
    conflicts: list[CodingConflict] = []
    for index, left in enumerate(diagnoses):
        for right in diagnoses[index + 1 :]:
            left_text = _ascii_upper(f"{left.original_text} {left.description}")
            right_text = _ascii_upper(f"{right.original_text} {right.description}")
            if not left.code or not right.code or left.code == right.code:
                continue
            if "HUMERO" not in left_text or "HUMERO" not in right_text:
                continue
            if not any(
                bool(left_terms & set(left_text.split())) and bool(right_terms & set(right_text.split()))
                or bool(left_terms & set(right_text.split())) and bool(right_terms & set(left_text.split()))
                for left_terms, right_terms in _LOCATION_GROUPS
            ):
                continue
            left.is_primary = True
            right.is_primary = True
            conflicts.append(
                CodingConflict(
                    conflict_id=_stable_id("conflict", left.item_id, right.item_id),
                    kind="anatomia_incompatible",
                    severity="error",
                    message=(
                        f"Conflicto anatómico entre {left.code} ({left.description}) y "
                        f"{right.code} ({right.description})."
                    ),
                    item_ids=[left.item_id, right.item_id],
                    blocking=True,
                )
            )
    return conflicts


def _restore_decisions(
    diagnoses: list[ObjectiveDiagnosis],
    procedures: list[ObjectiveProcedure],
    context: dict[str, Any],
) -> None:
    previous = {}
    for raw in [
        *(context.get("diagnosticos_curados") or []),
        *(context.get("procedimientos_curados") or []),
    ]:
        if isinstance(raw, dict) and raw.get("item_id"):
            previous[str(raw["item_id"])] = raw
    for item in [*diagnoses, *procedures]:
        raw = previous.get(item.item_id)
        if not raw:
            continue
        item.decisions = [
            CurationDecision.model_validate(decision)
            for decision in raw.get("decisions") or []
            if isinstance(decision, dict)
        ]
        if item.decisions:
            item.status = CurationStatus(str(raw.get("status") or item.status))


def apply_curation_decision(
    curation: CaseCuration,
    *,
    item_id: str,
    decision: CurationDecisionType,
    actor: str,
    expected_version: str,
    corrected_code: str = "",
    corrected_description: str = "",
    reason: str = "",
) -> CaseCuration:
    if expected_version != curation.version:
        raise ValueError("La curación cambió; recarga el caso antes de guardar la decisión.")
    item = next(
        (candidate for candidate in [*curation.diagnoses, *curation.procedures] if candidate.item_id == item_id),
        None,
    )
    if item is None:
        raise LookupError("Elemento de curación no encontrado.")
    if decision == CurationDecisionType.CORRECT and not (corrected_code or corrected_description):
        raise ValueError("Una corrección requiere código o descripción corregida.")

    original_code = item.code if isinstance(item, ObjectiveDiagnosis) else item.codigo_cups
    record = CurationDecision(
        decision=decision,
        actor=actor,
        decided_at=datetime.now(UTC),
        original_code=original_code,
        original_description=item.description,
        corrected_code=corrected_code,
        corrected_description=corrected_description,
        reason=reason,
        expected_version=expected_version,
    )
    item.decisions.append(record)
    if decision == CurationDecisionType.APPROVE:
        item.status = CurationStatus.APPROVED
    elif decision == CurationDecisionType.REJECT:
        item.status = CurationStatus.REJECTED
    else:
        item.status = CurationStatus.CORRECTED
        if corrected_description:
            item.description = _clean_text(corrected_description)
        if corrected_code:
            if isinstance(item, ObjectiveDiagnosis):
                item.code = _clean_text(corrected_code)
            else:
                item.codigo_cups = _clean_text(corrected_code)

    curation.pending_item_ids = [
        candidate.item_id
        for candidate in [*curation.diagnoses, *curation.procedures]
        if candidate.status
        in {
            CurationStatus.PENDING_REVIEW,
            CurationStatus.EXTERNAL_SERVICE_PENDING,
            CurationStatus.CONFLICT,
        }
    ]
    unresolved_blocking = any(
        conflict.blocking
        and any(
            candidate.item_id in conflict.item_ids
            and candidate.status not in {CurationStatus.APPROVED, CurationStatus.CORRECTED, CurationStatus.REJECTED}
            for candidate in [*curation.diagnoses, *curation.procedures]
        )
        for conflict in curation.conflicts
    )
    curation.generation_blocked = unresolved_blocking
    curation.blocking_reason = (
        next((conflict.message for conflict in curation.conflicts if conflict.blocking), "")
        if unresolved_blocking
        else ""
    )
    curation.version = _decision_aware_version(
        expected_version,
        curation.diagnoses,
        curation.procedures,
    )
    return curation


def curation_context_payload(curation: CaseCuration) -> dict[str, Any]:
    publishable = {CurationStatus.AUTO_VALIDATED, CurationStatus.APPROVED, CurationStatus.CORRECTED}
    return {
        "curation_schema_version": curation.schema_version,
        "curation_version": curation.version,
        "diagnosticos_curados": [dump_curation_model(item) for item in curation.diagnoses],
        "procedimientos_curados": [dump_curation_model(item) for item in curation.procedures],
        "conflictos_curacion": [dump_curation_model(item) for item in curation.conflicts],
        "pendientes_curacion": list(curation.pending_item_ids),
        "catalogos_utilizados": [dump_curation_model(item) for item in curation.catalogs],
        "curation_generation_blocked": curation.generation_blocked,
        "curation_blocking_reason": curation.blocking_reason,
        "pdf_primary_diagnosticos": [
            {
                "key": item.item_id,
                "codigo": item.code,
                "descripcion": item.description,
                "diagnostico": item.original_text or item.description,
                "estado_codificacion": item.status,
                "fuentes": [evidence.document_type for evidence in item.evidence],
                "tipo_contexto": item.context_type,
                "texto_presentacion": f"{item.code} - {item.description}" if item.code else item.description,
            }
            for item in curation.diagnoses
            if item.status in publishable
        ],
        "pdf_primary_procedimientos": [
            " - ".join(part for part in (item.codigo_cups, item.description) if part)
            for item in curation.procedures
            if item.status in publishable and ProcedureState.PERFORMED in item.states
        ],
    }


def curation_from_context(context: dict[str, Any]) -> CaseCuration | None:
    if not context.get("curation_schema_version") or not context.get("curation_version"):
        return None
    try:
        return CaseCuration.model_validate(
            {
                "schema_version": context.get("curation_schema_version"),
                "version": context.get("curation_version"),
                "diagnoses": context.get("diagnosticos_curados") or [],
                "procedures": context.get("procedimientos_curados") or [],
                "conflicts": context.get("conflictos_curacion") or [],
                "pending_item_ids": context.get("pendientes_curacion") or [],
                "catalogs": context.get("catalogos_utilizados") or [],
                "generation_blocked": bool(context.get("curation_generation_blocked")),
                "blocking_reason": context.get("curation_blocking_reason") or "",
            }
        )
    except ValueError:
        return None
