from __future__ import annotations

import asyncio
from dataclasses import dataclass
from types import SimpleNamespace
from typing import cast

import pandas as pd
import pytest
from fastapi import Request

from app.case_epicrisis.application.curation import (
    apply_curation_decision,
    build_document_objective_data,
    curation_context_payload,
    dump_curation_model,
    recalculate_reference_valuations,
    reconcile_case_objective_data,
)
from app.case_epicrisis.domain.curation import (
    CandidateOrigin,
    CatalogReference,
    CodeSystem,
    CurationDecisionType,
    CurationStatus,
    ObjectiveData,
    ObjectiveDiagnosis,
    ObjectiveProcedure,
    ProcedureReconciliationStatus,
    ProcedureState,
    SoatCurationValuation,
)
from app.core.services import AppServices
from app.models import UserInDB
from app.routes.pages import epicrisis as epicrisis_routes
from app.routes.pages.epicrisis_pdf_cache import guardar_epicrisis_pdf_cache
from app.services.clinical_document_service import ClinicalDocumentService
from app.services.deterministic_signals import DeterministicSignalSnapshot


@dataclass
class FakeCatalogRegistry:
    codes: dict[CodeSystem, set[str]]

    def reference(self, system: CodeSystem) -> CatalogReference:
        return CatalogReference(
            system=system,
            index_name=f"{system.value}_fixture",
            fingerprint=f"fingerprint-{system.value}",
            source_name="fixture",
        )

    def references(self) -> list[CatalogReference]:
        return [
            self.reference(CodeSystem.CIE10),
            self.reference(CodeSystem.CUPS),
            self.reference(CodeSystem.SOAT),
        ]

    def contains(self, system: CodeSystem, code: str) -> bool:
        return code in self.codes.get(system, set())

    def description_matches(self, system: CodeSystem, code: str, description: str) -> bool:
        return self.contains(system, code) and bool(description.strip())

    def classification(self, system: CodeSystem, code: str) -> str | None:
        return None


def _registry() -> FakeCatalogRegistry:
    return FakeCatalogRegistry(
        {
            CodeSystem.CIE10: {"S422", "S423"},
            CodeSystem.CUPS: {
                "793101",
                "808112",
                "804001",
                "817204",
                "793203",
                "836301",
                "765401",
                "818307",
                "890202",
                "890602",
                "890701",
                "931001",
            },
            CodeSystem.SOAT: set(),
        }
    )


def _require_valuation(procedure: ObjectiveProcedure) -> SoatCurationValuation:
    valuation = procedure.selected_soat_valuation
    assert valuation is not None
    return valuation


def _signals(*cups: tuple[str, int]) -> DeterministicSignalSnapshot:
    return DeterministicSignalSnapshot(
        document_type="quirurgico",
        inline_cups=[
            {
                "codigo_cups": code,
                "procedimiento": f"Procedimiento explícito {code}",
                "page": page,
                "excerpt": f"{code} procedimiento quirúrgico",
            }
            for code, page in cups
        ]
    )


def test_julio_preserves_explicit_surgical_cups_and_invoice_differences():
    registry = _registry()
    qx = {
        "_id": "julio-qx",
        "tipo_documento": "quirurgico",
        "nombre_archivo": "cirugia-julio.pdf",
        "codigos_cups": [
            {"codigo_cups": code, "procedimiento": f"Procedimiento explícito {code}"}
            for code in ("793101", "808112", "804001", "817204")
        ],
    }
    qx_objective = build_document_objective_data(
        qx,
        catalog_registry=registry,
        deterministic_signals=_signals(
            ("793101", 5),
            ("808112", 5),
            ("804001", 6),
            ("817204", 6),
        ),
    )

    assert {item.codigo_cups for item in qx_objective.procedures} == {
        "793101",
        "808112",
        "804001",
        "817204",
    }
    assert all(item.status == CurationStatus.PENDING_REVIEW for item in qx_objective.procedures)
    assert {item.evidence[0].page for item in qx_objective.procedures} == {5, 6}

    invoice = {
        "_id": "julio-invoice",
        "tipo_documento": "factura",
        "nombre_archivo": "factura-julio.pdf",
        "factura_json": {
            "servicios_procedimientos": {
                "procedimientos_quirurgicos": [
                    {
                        "codigo_cups": code,
                        "codigo_facturacion": f"INT-{code}",
                        "descripcion": f"Procedimiento explícito {code}",
                    }
                    for code in ("793101", "817204", "765401", "818307")
                ],
                "procedimientos_no_quirurgicos": [
                    {
                        "codigo_cups": code,
                        "codigo_facturacion": f"INT-{code}",
                        "descripcion": f"Procedimiento no quirúrgico {code}",
                    }
                    for code in ("890701", "890202", "931001", "890602")
                ],
            }
        },
    }
    invoice_objective = build_document_objective_data(invoice, catalog_registry=registry)
    case = reconcile_case_objective_data(
        [
            {"datos_objetivos": dump_curation_model(qx_objective)},
            {"datos_objetivos": dump_curation_model(invoice_objective)},
        ]
    )

    assert case is not None
    merged = {item.codigo_cups: item for item in case.procedures}
    assert set(merged) == {
        "793101",
        "808112",
        "804001",
        "817204",
        "765401",
        "818307",
        "890701",
        "890202",
        "931001",
        "890602",
    }
    assert set(merged["793101"].states) == {ProcedureState.PERFORMED, ProcedureState.BILLED}
    assert merged["793101"].codigo_facturacion == "INT-793101"
    assert ProcedureState.BILLED not in merged["808112"].states
    assert merged["793101"].reconciliation_status == ProcedureReconciliationStatus.PERFORMED_AND_BILLED
    assert merged["817204"].reconciliation_status == ProcedureReconciliationStatus.PERFORMED_AND_BILLED
    assert merged["808112"].reconciliation_status == ProcedureReconciliationStatus.PERFORMED_NOT_BILLED
    assert merged["804001"].reconciliation_status == ProcedureReconciliationStatus.PERFORMED_NOT_BILLED
    assert (
        merged["765401"].reconciliation_status
        == ProcedureReconciliationStatus.BILLED_WITHOUT_EXACT_SUPPORT
    )
    assert (
        merged["818307"].reconciliation_status
        == ProcedureReconciliationStatus.BILLED_WITHOUT_EXACT_SUPPORT
    )
    assert merged["793101"].description == "Procedimiento explícito 793101"
    assert all(
        merged[code].reconciliation_status == ProcedureReconciliationStatus.NON_SURGICAL_BILLED
        for code in ("890701", "890202", "931001", "890602")
    )

    before = curation_context_payload(case)["pdf_primary_procedimientos"]
    assert [item["codigo_cups"] for item in before if item["clasificacion"] == "quirurgico"] == [
        "793101",
        "817204",
    ]
    with pytest.raises(ValueError, match="motivo"):
        apply_curation_decision(
            case,
            item_id=merged["818307"].item_id,
            decision=CurationDecisionType.APPROVE,
            actor="auditor",
            expected_version=case.version,
        )
    for code in ("818307", "765401"):
        apply_curation_decision(
            case,
            item_id=merged[code].item_id,
            decision=CurationDecisionType.APPROVE,
            actor="auditor",
            expected_version=case.version,
            reason="Soporte validado por auditoría.",
        )
    after = curation_context_payload(case)["pdf_primary_procedimientos"]
    assert {
        item["codigo_cups"] for item in after if item["clasificacion"] == "quirurgico"
    } == {"793101", "817204", "818307", "765401"}


def test_diagnosis_rejects_cross_system_codes():
    with pytest.raises(ValueError, match="CIE-10"):
        ObjectiveDiagnosis(
            item_id="invalid-system",
            code_system=CodeSystem.CUPS,
            code="793101",
            description="Procedimiento numérico usado como diagnóstico",
            origin=CandidateOrigin.LLM,
            status=CurationStatus.PENDING_REVIEW,
        )


def test_manual_soat_reference_uses_unique_principal_and_does_not_create_billed_state() -> None:
    procedures = [
        ObjectiveProcedure(
            item_id="px-principal",
            description="Procedimiento principal omitido",
            codigo_cups="808112",
            classification="quirurgico",
            reconciliation_status=ProcedureReconciliationStatus.PERFORMED_NOT_BILLED,
            origin=CandidateOrigin.MANUAL,
            status=CurationStatus.APPROVED,
            states=[ProcedureState.PERFORMED],
            selected_soat_valuation=SoatCurationValuation(
                year=2026,
                soat_code="01101",
                surgical_group=11,
                base_tariff=1000,
                components={"cirujano": 600, "anestesia": 400},
                selected_components=["cirujano", "anestesia"],
                catalog_version="test",
                source_reference="fixture",
            ),
        ),
        ObjectiveProcedure(
            item_id="px-secondary",
            description="Procedimiento secundario omitido",
            codigo_cups="804001",
            classification="quirurgico",
            reconciliation_status=ProcedureReconciliationStatus.PERFORMED_NOT_BILLED,
            origin=CandidateOrigin.MANUAL,
            status=CurationStatus.APPROVED,
            states=[ProcedureState.PERFORMED],
            selected_soat_valuation=SoatCurationValuation(
                year=2026,
                surgical_group=7,
                base_tariff=600,
                components={"cirujano": 400, "sala": 200},
                selected_components=["cirujano", "sala"],
                catalog_version="test",
                source_reference="fixture",
            ),
        ),
    ]
    case = reconcile_case_objective_data(
        [{"datos_objetivos": dump_curation_model(ObjectiveData(procedures=procedures))}]
    )
    assert case is not None

    recalculate_reference_valuations(case)

    principal, secondary = case.procedures
    principal_valuation = _require_valuation(principal)
    secondary_valuation = _require_valuation(secondary)
    assert principal_valuation.liquidated_value == 1000
    assert secondary_valuation.liquidated_value == 200
    assert secondary_valuation.liquidated_components == {"cirujano": 200}
    assert ProcedureState.BILLED not in secondary.states


def test_manual_soat_reference_stays_pending_without_unique_principal() -> None:
    def valuation() -> SoatCurationValuation:
        return SoatCurationValuation(
            year=2026,
            surgical_group=7,
            base_tariff=600,
            components={"cirujano": 400},
            selected_components=["cirujano"],
            catalog_version="test",
            source_reference="fixture",
        )
    case = reconcile_case_objective_data(
        [
            {
                "datos_objetivos": dump_curation_model(
                    ObjectiveData(
                        procedures=[
                            ObjectiveProcedure(
                                item_id=f"px-{index}",
                                description=f"Procedimiento {index}",
                                codigo_cups=f"80400{index}",
                                classification="quirurgico",
                                origin=CandidateOrigin.MANUAL,
                                status=CurationStatus.APPROVED,
                                states=[ProcedureState.PERFORMED],
                                selected_soat_valuation=valuation(),
                            )
                            for index in (1, 2)
                        ]
                    )
                )
            }
        ]
    )
    assert case is not None

    recalculate_reference_valuations(case)

    valuations = [_require_valuation(item) for item in case.procedures]
    assert all(valuation.status == "pendiente_revision" for valuation in valuations)
    assert all("principal tarifario único" in valuation.error for valuation in valuations)


def test_monica_anatomy_conflict_blocks_formal_publication():
    registry = _registry()
    objective = ObjectiveData(
        diagnoses=[
            ObjectiveDiagnosis(
                item_id="dx-proximal",
                code="S422",
                description="Fractura de la epífisis superior del húmero",
                original_text="fractura de húmero proximal",
                origin=CandidateOrigin.EXPLICIT,
                status=CurationStatus.AUTO_VALIDATED,
                catalog=registry.reference(CodeSystem.CIE10),
            ),
            ObjectiveDiagnosis(
                item_id="dx-diaphysis",
                code="S423",
                description="Fractura de la diáfisis del húmero",
                original_text="fractura de la diáfisis del húmero",
                origin=CandidateOrigin.EXPLICIT,
                status=CurationStatus.AUTO_VALIDATED,
                catalog=registry.reference(CodeSystem.CIE10),
            ),
        ],
        catalogs=registry.references(),
    )
    case = reconcile_case_objective_data([{"datos_objetivos": dump_curation_model(objective)}])

    assert case is not None
    assert case.generation_blocked is True
    assert case.conflicts[0].blocking is True
    assert {item.status for item in case.diagnoses} == {CurationStatus.CONFLICT}
    assert curation_context_payload(case)["pdf_primary_diagnosticos"] == []


def test_external_cups_failure_preserves_uncoded_procedures_as_retryable():
    objective = build_document_objective_data(
        {
            "_id": "1390913-history",
            "tipo_documento": "historia_clinica",
            "procedimientos_extraidos": ["Reducción de fractura", "Inmovilización"],
            "error_cups": "503 Service Unavailable",
        },
        catalog_registry=_registry(),
    )

    assert objective.processing_status == "partial"
    assert objective.retryable_errors == ["cups_provider_unavailable"]
    assert len(objective.procedures) == 2
    assert all(
        item.status == CurationStatus.EXTERNAL_SERVICE_PENDING for item in objective.procedures
    )


def test_pop_context_marks_current_procedure_and_keeps_traceable_signal() -> None:
    signals = DeterministicSignalSnapshot(
        document_type="historia_clinica",
        postoperative_signals=[
            {
                "term": "POP",
                "meaning": "postoperatorio",
                "page": 4,
                "line_index": 8,
                "excerpt": "POP de Osteosíntesis de tibia",
                "section": "evolucion",
                "block_index": 2,
            }
        ],
        procedure_contexts=[
            {
                "codigo_cups": "793101",
                "description": "Osteosíntesis de tibia",
                "page": 4,
                "line_index": 8,
                "excerpt": "POP de Osteosíntesis de tibia",
                "section": "evolucion",
                "block_index": 2,
                "historical": False,
            }
        ],
    )
    objective = build_document_objective_data(
        {
            "_id": "history-pop",
            "tipo_documento": "historia_clinica",
            "nombre_archivo": "historia-pop.pdf",
            "codigos_cups": [{"codigo_cups": "793101", "procedimiento": "Osteosíntesis de tibia"}],
        },
        catalog_registry=_registry(),
        deterministic_signals=signals,
    )

    procedure = objective.procedures[0]
    assert ProcedureState.PERFORMED in procedure.states
    assert procedure.evidence[-1].page == 4
    assert procedure.evidence[-1].excerpt == "POP de Osteosíntesis de tibia"
    assert objective.postoperative_signals[0].correlation_status == "correlacionado"
    assert objective.postoperative_signals[0].procedure_item_id == procedure.item_id

    case = reconcile_case_objective_data([{"datos_objetivos": dump_curation_model(objective)}])
    assert case is not None
    assert case.procedures[0].reconciliation_status == ProcedureReconciliationStatus.PERFORMED_NOT_BILLED

    invoice = build_document_objective_data(
        {
            "_id": "invoice-pop",
            "tipo_documento": "factura",
            "factura_json": {
                "servicios_procedimientos": {
                    "procedimientos_quirurgicos": [
                        {"codigo_cups": "793101", "descripcion": "Osteosíntesis de tibia"}
                    ]
                }
            },
        },
        catalog_registry=_registry(),
    )
    case_with_invoice = reconcile_case_objective_data(
        [
            {"datos_objetivos": dump_curation_model(objective)},
            {"datos_objetivos": dump_curation_model(invoice)},
        ]
    )
    assert case_with_invoice is not None
    assert case_with_invoice.procedures[0].reconciliation_status == ProcedureReconciliationStatus.PERFORMED_AND_BILLED


def test_pop_without_unique_context_does_not_elevate_procedure() -> None:
    signals = DeterministicSignalSnapshot(
        document_type="historia_clinica",
        postoperative_signals=[
            {
                "term": "POP",
                "meaning": "postoperatorio",
                "page": 1,
                "line_index": 2,
                "excerpt": "POP sin procedimiento explícito",
                "section": "evolucion",
                "block_index": 1,
            }
        ],
    )
    objective = build_document_objective_data(
        {
            "_id": "history-pop-review",
            "tipo_documento": "historia_clinica",
            "codigos_cups": [{"codigo_cups": "793101", "procedimiento": "Osteosíntesis de tibia"}],
        },
        catalog_registry=_registry(),
        deterministic_signals=signals,
    )

    assert objective.procedures[0].states == [ProcedureState.DOCUMENTED]
    assert objective.postoperative_signals[0].correlation_status == "pendiente_revision"


def test_human_decision_is_auditable_and_uses_optimistic_version():
    objective = ObjectiveData(
        diagnoses=[
            ObjectiveDiagnosis(
                item_id="dx-review",
                code="S423",
                description="Fractura de húmero",
                origin=CandidateOrigin.RAG,
                status=CurationStatus.PENDING_REVIEW,
            )
        ]
    )
    case = reconcile_case_objective_data([{"datos_objetivos": dump_curation_model(objective)}])
    assert case is not None

    with pytest.raises(ValueError, match="recarga"):
        apply_curation_decision(
            case,
            item_id="dx-review",
            decision=CurationDecisionType.APPROVE,
            actor="auditor",
            expected_version="stale",
        )

    previous_version = case.version
    updated = apply_curation_decision(
        case,
        item_id="dx-review",
        decision=CurationDecisionType.CORRECT,
        actor="auditor",
        expected_version=previous_version,
        corrected_code="S422",
        corrected_description="Fractura proximal del húmero",
    )
    diagnosis = updated.diagnoses[0]
    assert diagnosis.status == CurationStatus.CORRECTED
    assert diagnosis.code == "S422"
    assert diagnosis.decisions[0].original_code == "S423"
    assert diagnosis.decisions[0].actor == "auditor"
    assert updated.version != previous_version


def test_transient_cups_provider_retries_without_real_sleep():
    class TransientRetriever:
        def __init__(self):
            self.calls = 0

        def asignar_codigos(self, procedures):
            self.calls += 1
            if self.calls < 3:
                raise RuntimeError("503 Service Unavailable")
            return pd.DataFrame([{"codigo_cups": "793101", "procedimiento": procedures[0]}])

    service = object.__new__(ClinicalDocumentService)
    service.cups_retriever = TransientRetriever()
    delays = []
    service.sleep_fn = delays.append

    result = service._assign_cups_with_retry(["Reducción abierta"])

    assert result.iloc[0]["codigo_cups"] == "793101"
    assert service.cups_retriever.calls == 3
    assert delays == [2, 8]


def test_pdf_cache_route_preserves_user_selection_without_consulting_curation():
    inserted_documents = []

    class CacheCollection:
        def insert_one(self, document):
            inserted_documents.append(document)
            return SimpleNamespace(inserted_id="pdf-cache-1")

    class CaseService:
        def get_cached_case_context(self, username, case_key):
            raise AssertionError("La generación del PDF no debe consultar la curación.")

    class JsonRequest:
        async def json(self):
            return {
                "html_pdf": "<html><body>Epicrisis</body></html>",
                "nombre_paciente": "Paciente",
                "case_key": "monica",
                "diagnosticos": ["T870 - Diagnóstico agregado manualmente"],
                "procedimientos": [
                    {"codigo_cups": "", "descripcion": "Procedimiento agregado manualmente"}
                ],
                "recomendaciones_medicas": [
                    {
                        "categoria": "restriccion",
                        "indicacion": "Evitar cargar peso",
                        "duracion": "6 semanas",
                        "fecha": "2026-07-30",
                        "pagina": 7,
                        "evidencia": "Indicación en nota de egreso",
                    }
                ],
            }

    services = SimpleNamespace(
        case_epicrisis_service=CaseService(),
        mongo_analyses=SimpleNamespace(
            collection=SimpleNamespace(
                database={"epicrisis_pdf_cache": CacheCollection()}
            )
        ),
    )

    result = asyncio.run(
        guardar_epicrisis_pdf_cache(
            cast(Request, JsonRequest()),
            cast(UserInDB, SimpleNamespace(username="auditor")),
            cast(AppServices, services),
        )
    )

    assert result["cache_id"] == "pdf-cache-1"
    assert inserted_documents[0]["diagnosticos"] == ["T870 - Diagnóstico agregado manualmente"]
    assert inserted_documents[0]["procedimientos"] == ["Procedimiento agregado manualmente"]
    assert inserted_documents[0]["recomendaciones_medicas"] == [
        {
            "categoria": "restriccion",
            "indicacion": "Evitar cargar peso",
            "duracion": "6 semanas",
            "fecha": "2026-07-30",
            "pagina": 7,
            "evidencia": "Indicación en nota de egreso",
        }
    ]


def test_pdf_cache_route_allows_unresolved_curation_without_publishable_clinical_items():
    inserted_documents = []

    class CacheCollection:
        def insert_one(self, document):
            inserted_documents.append(document)
            return SimpleNamespace(inserted_id="pdf-cache-empty")

    class CaseService:
        def get_cached_case_context(self, username, case_key):
            return {
                "contexto": {
                    "curation_schema_version": "v1",
                    "curation_generation_blocked": True,
                    "pdf_primary_diagnosticos": [],
                    "pdf_primary_procedimientos": [],
                }
            }

    class JsonRequest:
        async def json(self):
            return {
                "html_pdf": "<html><body>Resumen clínico</body></html>",
                "nombre_paciente": "Paciente",
                "case_key": "monica",
                "diagnosticos": [],
                "procedimientos": [],
            }

    services = SimpleNamespace(
        case_epicrisis_service=CaseService(),
        mongo_analyses=SimpleNamespace(
            collection=SimpleNamespace(
                database={"epicrisis_pdf_cache": CacheCollection()}
            )
        ),
    )

    result = asyncio.run(
        guardar_epicrisis_pdf_cache(
            cast(Request, JsonRequest()),
            cast(UserInDB, SimpleNamespace(username="auditor")),
            cast(AppServices, services),
        )
    )

    assert result["cache_id"] == "pdf-cache-empty"
    assert inserted_documents[0]["diagnosticos"] == []
    assert inserted_documents[0]["procedimientos"] == []


def test_curation_routes_forward_versioned_human_decisions(monkeypatch):
    calls = []

    class CaseService:
        def get_case_curation(self, username, case_key):
            return {"curation_version": "v-current", "diagnosticos_curados": []}

        def update_case_curation_item(self, username, case_key, **kwargs):
            calls.append((username, case_key, kwargs))
            return {"version": "v-next"}

    class JsonRequest:
        async def json(self):
            return {
                "decision": "correct",
                "expected_version": "v-current",
                "corrected_code": "S422",
                "corrected_description": "Fractura proximal",
            }

    monkeypatch.setattr(
        epicrisis_routes,
        "_resolve_real_case_key",
        lambda **kwargs: f"real-{kwargs['visible_case_key']}",
    )
    services = SimpleNamespace(case_epicrisis_service=CaseService())
    user = cast(UserInDB, SimpleNamespace(username="auditor"))

    queried = asyncio.run(
        epicrisis_routes.obtener_curacion_epicrisis(
            "visible-case",
            current_user=user,
            services=cast(AppServices, services),
        )
    )
    updated = asyncio.run(
        epicrisis_routes.actualizar_item_curacion_epicrisis(
            "visible-case",
            "dx-1",
            cast(Request, JsonRequest()),
            current_user=user,
            services=cast(AppServices, services),
        )
    )

    assert queried["curation_version"] == "v-current"
    assert updated["curation"]["version"] == "v-next"
    assert calls[0][1] == "real-visible-case"
    assert calls[0][2]["expected_version"] == "v-current"
    assert calls[0][2]["corrected_code"] == "S422"
