from __future__ import annotations

import html
import inspect
import logging
import re
import time
from dataclasses import dataclass, field, is_dataclass, replace
from datetime import datetime
from typing import Any

from bson import ObjectId
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

from app.batch_processing.application.utils import build_case_key
from app.batch_processing.infrastructure.heuristic_association import HeuristicCaseAssociationService
from app.batch_processing.infrastructure.mongo_repositories import MongoBatchCaseRepository
from app.case_epicrisis.application.curation import build_document_objective_data
from app.case_epicrisis.application.utils import build_ayudas_diagnosticas
from app.case_epicrisis.domain.curation import dump_curation_model
from app.case_epicrisis.domain.ports import CodingCatalogRegistry
from app.clinical_pipeline.domain.errors import HistoriaSummaryCoverageError
from app.config import config
from app.core.logging import bind_log_context, get_audit_logger
from app.llm import LLMOutputKind, LLMProviderError, LLMStructuredRequest, LLMTask
from app.llm.schemas import (
    FacturaLineaCanonicaItem,
    FacturaStructured,
    HistoriaClinicaStructured,
    PrefacturaStructured,
    dump_clinical_model,
    normalize_factura_date_value,
    normalize_factura_due_date_value,
)
from app.services.clinical_document_projection import (
    render_document_analysis_html,
    serialize_analysis_document,
)
from app.services.clinical_extraction_quality import assess_clinical_extraction
from app.services.clinical_processing import (
    extraer_antecedentes_historia,
    extraer_antecedentes_historia_estructurados,
    extraer_diagnosticos_quirurgicos,
    extraer_factura_json,
    extraer_medicamentos_historia,
    extraer_nombre_paciente,
    extraer_procedimientos_historia,
    extraer_procedimientos_quirurgicos,
    procesar_documento_generico,
)
from app.services.clinical_structured_extraction import ClinicalStructuredExtractionService
from app.services.context_budgeting import deduplicate_texts, limit_items
from app.services.deterministic_signals import (
    DeterministicSignalSnapshot,
    extract_deterministic_signals,
    parse_inline_cie10_entry,
    parse_inline_cups_entry,
)
from app.services.document_identity_extraction import (
    DocumentIdentityExtractionResult,
    RedactedIdentityField,
    extract_document_identity,
    resolve_identity_strategy,
)
from app.services.factura_deterministic import build_factura_structured
from app.services.historia_summary import (
    extract_historia_edad_text,
    extract_historia_fecha_ingreso_text,
    extract_historia_fecha_nacimiento_text,
    extract_historia_institution_text,
    extract_historia_motivo_text,
    extract_historia_prestador_text,
    extract_historia_sexo_text,
    resolve_historia_resumen,
)
from app.services.soat_processing import _parse_cie10_lines


logger = logging.getLogger(__name__)
audit_logger = get_audit_logger()


_CIE10_INLINE_PATTERN = re.compile(
    r"^\s*(?P<codigo>[A-TV-Z]\d{2}[0-9A-Z]?(?:\.[0-9A-Z]{1,2})?)\s*(?:[-:–—]\s*|\s+)(?P<descripcion>.+)$",
    flags=re.IGNORECASE,
)
_EPICRISIS_RELEVANT_DOCUMENT_TYPES = {
    "historia_clinica",
    "quirurgico",
    "factura",
    "prefactura",
    "radiologia",
    "laboratorio",
    "generico",
    "prescripcion",
}
_HISTORIA_CIVIL_STATUS_PATTERN = re.compile(
    r"\b(estado civil|solter[oa]|casad[oa]|uni[oó]n libre|viud[oa]|divorciad[oa])\b",
    re.IGNORECASE,
)
_HISTORIA_SYMPTOM_ONLY_PATTERN = re.compile(
    r"^\s*(dolor|fiebre|tos|mareo|n[aá]usea|vomito|v[oó]mito|cefalea|diarrea|"
    r"malestar|sangrado|edema|disnea|ardor|prurito|inflamaci[oó]n)\b",
    re.IGNORECASE,
)


def _is_low_quality_historia_motivo(value: Any) -> bool:
    text = re.sub(r"\s+", " ", str(value or "")).strip()
    if len(text) < 6:
        return True
    return bool(_HISTORIA_CIVIL_STATUS_PATTERN.search(text))


def _is_historia_symptom_only(value: Any) -> bool:
    text = re.sub(r"\s+", " ", str(value or "")).strip()
    if not text:
        return False
    if re.search(r"\b[A-TV-Z]\d{2}[0-9A-Z]?(?:\.[0-9A-Z]{1,2})?\b", text, re.IGNORECASE):
        return False
    if re.search(r"\b(diagn[oó]stic|impresi[oó]n diagn[oó]stica|dx)\b", text, re.IGNORECASE):
        return False
    return bool(_HISTORIA_SYMPTOM_ONLY_PATTERN.search(text)) and len(text.split()) <= 5


_PREFACTURA_CASE_PATTERN = re.compile(
    r"(?:caso\s*(?:no\.?)?|no\.?\s*de\s*caso|n[°o]\s*caso|caso)\s*[:#-]?\s*(?:cm\s*-\s*)?(\d{4,12})",
    re.IGNORECASE,
)
_PREFACTURA_AMOUNT_PATTERN = re.compile(
    r"(?:"
    r"(?:valor\s*(?:estimado|total)|total)\s*[:$ ]+\s*([$]?\s*[\d.,]+)"
    r"|"
    r"([$]?\s*[\d.,]+)\s+total\s+de\s+la\s+factura"
    r")",
    re.IGNORECASE,
)
_PREFACTURA_TABLE_HEADER_PATTERN = re.compile(
    r"total\s+precio\s+(?:cant(?:s?\.?)?|cantidad).*(?:nombre\s+servicio|servicio\s+fecha)",
    re.IGNORECASE,
)
_PREFACTURA_TOTAL_LINE_PATTERN = re.compile(
    r"total\s+de\s+la\s+factura|total\s+neto\s+factura",
    re.IGNORECASE,
)
_PREFACTURA_SERVICE_ROW_PATTERN = re.compile(
    r"^\s*\d{2}/\d{2}/\d{2}\s+\d{2}:\d{2}\s+"
    r"(?P<descripcion>.+?)\s+"
    r"(?P<cantidad>\d+)\s+"
    r"(?P<precio>[\d.,]+(?:\s+\d{1,3})?)\s+"
    r"(?P<valor>[\d.,]+)\s+\d+\s+"
    r"(?P<codigo>[A-Z0-9-]{3,20})\s*$",
    re.IGNORECASE,
)
_PREFACTURA_COMPANY_MARKERS = (
    "inversiones",
    "medicas",
    "médicas",
    "salud",
    "hospital",
    "clinica",
    "clínica",
    "ips",
)
_PREFACTURA_COMPANY_SUFFIX_MARKERS = ("s.a.s", "s.a", "sas", "ltda", "e.u", "ips")
_PREFACTURA_ADMIN_LINE_PATTERN = re.compile(
    r"(?:caso|paciente|fecha|hora|convenio|servicio|usuario|page\s+\d+|total\s+precio|total\s+de\s+la\s+factura)",
    re.IGNORECASE,
)
_PREFACTURA_DATE_TOKEN_PATTERN = re.compile(r"^\d{2}/\d{2}/\d{2}$")
_PREFACTURA_TIME_TOKEN_PATTERN = re.compile(r"^\d{2}:\d{2}$")
_PREFACTURA_QUANTITY_TOKEN_PATTERN = re.compile(r"^\d+(?:[.,]\d+)?$")
_PREFACTURA_MONEY_TOKEN_PATTERN = re.compile(r"^\d[\d.,]*$")
_HTML_TAG_PATTERN = re.compile(r"<[^>]+>")
_REDACTED_IDENTITY_VALUE_PATTERN = re.compile(r"x(?:[\s.*_-]*x){3,}", re.IGNORECASE)


def _load_historia_clinica_request_class():
    # Delay the legacy import so app/web and Celery bootstrap do not load embeddings on import.
    from modules.processing.resumen_google import HistoriaClinicaRequest

    return HistoriaClinicaRequest


@dataclass
class ClinicalDocumentRequest:
    raw_text: str
    detected_type: str
    username: str
    original_name: str
    case_key: str = ""
    case_number: str = ""
    patient_id: str = ""
    batch_id: str = ""
    batch_file_id: str = ""
    ingestion_source: str = "manual"
    provided_patient_name: str = ""
    provided_patient_id: str = ""
    provided_case_number: str = ""
    case_resolution_status: str = ""
    case_resolution_evidence: list[str] = field(default_factory=list)
    redacted_identity_fields: list[RedactedIdentityField] = field(default_factory=list)
    review_required: bool = False
    review_messages: list[str] = field(default_factory=list)
    selected_document_type: str = ""
    detected_document_type: str = ""
    effective_document_type: str = ""
    source_file_hash: str = ""
    document_title: str = ""
    document_key: str = ""
    document_reference: str = ""
    parent_prefactura_batch_id: str = ""
    source_page_start: int = 0
    source_page_end: int = 0
    category_override_confirmed: bool = False
    override_audit: dict[str, Any] = field(default_factory=dict)
    extraction_metadata: dict[str, Any] = field(default_factory=dict)
    classification_decision: dict[str, Any] = field(default_factory=dict)


@dataclass
class CaseIdentityResolution:
    case_key: str
    case_number: str = ""
    patient_id: str = ""
    patient_name: str = ""
    redacted_identity_fields: list[RedactedIdentityField] = field(default_factory=list)
    case_resolution_status: str = "provisional"
    case_resolution_evidence: list[str] = field(default_factory=list)
    review_required: bool = False
    review_messages: list[str] = field(default_factory=list)


@dataclass
class ClinicalAnalysisResult:
    analysis_structured: dict[str, Any] | None
    analysis_schema: str | None
    analysis_schema_version: str | None
    analysis_render_version: str | None
    rendered_html: str
    error_analisis: str | None = None
    analysis_provider: str | None = None
    analysis_model_name: str | None = None
    analysis_route: dict[str, Any] | None = None
    analysis_quality: dict[str, Any] | None = None

    def __iter__(self):
        # Compatibilidad con tests y consumidores legacy que todavía desempacan
        # el resultado como `(html, error)`.
        yield self.rendered_html
        yield self.error_analisis


class _FacturaRepairProveedor(BaseModel):
    model_config = ConfigDict(populate_by_name=True, extra="ignore")

    nombre_institucion: str | None = Field(default=None, alias="ni")
    nit: str | None = Field(default=None, alias="nt")
    direccion: str | None = Field(default=None, alias="dr")
    ciudad_departamento: str | None = Field(default=None, alias="cd")


class _FacturaRepairDatos(BaseModel):
    model_config = ConfigDict(populate_by_name=True, extra="ignore")

    numero_factura: str | None = Field(default=None, alias="nf")
    prefijo: str | None = Field(default=None, alias="pf")
    fecha_emision: str | None = Field(default=None, alias="fe")
    numero_caso: str | None = Field(default=None, alias="nc")
    fecha_vencimiento: str | None = Field(default=None, alias="fv")

    @model_validator(mode="before")
    @classmethod
    def _normalize_dates(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        normalized = dict(value)
        issue_source = normalized.get("fe", normalized.get("fecha_emision"))
        issue_date = normalize_factura_date_value(issue_source, allow_time=True)
        due_source = normalized.get("fv", normalized.get("fecha_vencimiento"))
        normalized["fe"] = issue_date
        normalized["fv"] = normalize_factura_due_date_value(
            due_source, issue_date_value=issue_date or issue_source
        )
        return normalized


class _FacturaRepairPagador(BaseModel):
    model_config = ConfigDict(populate_by_name=True, extra="ignore")

    aseguradora_eps: str | None = Field(default=None, alias="ae")
    nit_pagador: str | None = Field(default=None, alias="np")
    tipo_convenio: str | None = Field(default=None, alias="tc")
    numero_autorizacion: str | None = Field(default=None, alias="na")


class _FacturaRepairPaciente(BaseModel):
    model_config = ConfigDict(populate_by_name=True, extra="ignore")

    nombre_completo: str | None = Field(default=None, alias="nm")
    numero_identificacion: str | None = Field(default=None, alias="id")
    fecha_ingreso: str | None = Field(default=None, alias="fi")
    fecha_egreso: str | None = Field(default=None, alias="fg")

    @field_validator("fecha_ingreso", "fecha_egreso", mode="before")
    @classmethod
    def _normalize_dates(cls, value: Any) -> str | None:
        return normalize_factura_date_value(value, allow_time=True)


class _FacturaRepairResumen(BaseModel):
    model_config = ConfigDict(populate_by_name=True, extra="ignore")

    total_servicios: str | None = Field(default=None, alias="ts")
    descuentos: str | None = Field(default=None, alias="dc")
    copagos: str | None = Field(default=None, alias="cg")
    valor_total_factura: str | None = Field(default=None, alias="vt")
    valor_en_letras: str | None = Field(default=None, alias="vl")


class _FacturaHeaderRepair(BaseModel):
    model_config = ConfigDict(populate_by_name=True, extra="ignore")

    document_type: str = Field(default="factura", alias="dt")
    patient_name: str | None = Field(default=None, alias="pn")
    proveedor: _FacturaRepairProveedor = Field(default_factory=_FacturaRepairProveedor, alias="pv")
    datos_factura: _FacturaRepairDatos = Field(default_factory=_FacturaRepairDatos, alias="df")
    pagador: _FacturaRepairPagador = Field(default_factory=_FacturaRepairPagador, alias="pg")
    paciente: _FacturaRepairPaciente = Field(default_factory=_FacturaRepairPaciente, alias="pc")

    @model_validator(mode="before")
    @classmethod
    def _force_factura_document_type(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        normalized = dict(value)
        normalized["dt"] = "factura"
        normalized["document_type"] = "factura"
        return normalized


class _FacturaSummaryRepair(BaseModel):
    model_config = ConfigDict(populate_by_name=True, extra="ignore")

    document_type: str = Field(default="factura", alias="dt")
    datos_factura: _FacturaRepairDatos = Field(default_factory=_FacturaRepairDatos, alias="df")
    resumen_financiero: _FacturaRepairResumen = Field(default_factory=_FacturaRepairResumen, alias="rf")

    @model_validator(mode="before")
    @classmethod
    def _force_factura_document_type(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        normalized = dict(value)
        normalized["dt"] = "factura"
        normalized["document_type"] = "factura"
        return normalized


class _FacturaIdentityRepair(BaseModel):
    model_config = ConfigDict(populate_by_name=True, extra="ignore")

    document_type: str = Field(default="factura", alias="dt")
    patient_name: str | None = Field(default=None, alias="pn")
    pagador: _FacturaRepairPagador = Field(default_factory=_FacturaRepairPagador, alias="pg")
    paciente: _FacturaRepairPaciente = Field(default_factory=_FacturaRepairPaciente, alias="pc")

    @model_validator(mode="before")
    @classmethod
    def _force_factura_document_type(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        normalized = dict(value)
        normalized["dt"] = "factura"
        normalized["document_type"] = "factura"
        return normalized


class _FacturaLineItemsRepair(BaseModel):
    model_config = ConfigDict(populate_by_name=True, extra="ignore")

    document_type: str = Field(default="factura", alias="dt")
    lineas_canonicas: list[FacturaLineaCanonicaItem] = Field(default_factory=list, alias="lc")

    @model_validator(mode="before")
    @classmethod
    def _force_factura_document_type(cls, value: Any) -> Any:
        if not isinstance(value, dict):
            return value
        normalized = dict(value)
        normalized["dt"] = "factura"
        normalized["document_type"] = "factura"
        return normalized


class ClinicalDocumentService:
    """Materializa documentos clínicos usando el pipeline existente."""

    def __init__(
        self,
        *,
        mongo_storage,
        mongo_analyses,
        client_groq,
        client_gemini,
        cie10_retriever,
        cups_retriever,
        colombia_tz,
        llm_router=None,
        llm_task_cache_repository=None,
        catalog_registry: CodingCatalogRegistry | None = None,
        sleep_fn=time.sleep,
    ) -> None:
        self.mongo_analyses = mongo_analyses
        self.mongo_storage = mongo_storage
        self.client_groq = client_groq
        self.client_gemini = client_gemini
        self.llm_router = llm_router
        self.cie10_retriever = cie10_retriever
        self.cups_retriever = cups_retriever
        self.colombia_tz = colombia_tz
        self.llm_task_cache_repository = llm_task_cache_repository
        self.catalog_registry = catalog_registry
        self.sleep_fn = sleep_fn
        self.batch_case_repository = MongoBatchCaseRepository()
        self.case_association_service = HeuristicCaseAssociationService()
        self.structured_extraction_service = ClinicalStructuredExtractionService(
            llm_router=llm_router,
            cache_repository=llm_task_cache_repository,
        )

    def process_and_persist(self, request: ClinicalDocumentRequest) -> dict[str, Any]:
        resolution = self.resolve_case_identity(request)
        request = self._apply_case_resolution(request, resolution)
        selected_type, detected_type, effective_type = self._resolve_request_document_types(request)
        bind_log_context(
            username=request.username,
            case_key=request.case_key,
            batch_id=request.batch_id,
            file_id=request.batch_file_id,
            document_type=effective_type,
        )
        existing = self._find_existing_document(request)
        if existing:
            self._ensure_legacy_history(existing)
            audit_logger.business_event(
                event_type="clinical.document_processed",
                action="reuse_existing_document",
                outcome="success",
                service="clinical_document_service",
                resource={
                    "document_type": effective_type,
                    "effective_document_type": existing.get("effective_document_type", effective_type),
                    "selected_document_type": existing.get("selected_document_type", selected_type),
                    "detected_document_type": existing.get("detected_document_type", detected_type),
                    "analysis_document_id": str(existing.get("_id") or ""),
                },
            )
            return self._serialize(existing, reused=True)

        payload = self._build_payload(request)
        safe_payload = jsonable_encoder(payload)
        safe_payload.pop("analisis_html", None)
        inserted = self.mongo_analyses.collection.insert_one(safe_payload)
        payload["_id"] = str(inserted.inserted_id)
        self._ensure_legacy_history(payload)
        audit_logger.business_event(
            event_type="clinical.document_processed",
            action="process_and_persist",
            outcome="warning"
            if payload.get("error_analisis") or payload.get("error_cie10") or payload.get("error_cups")
            else "success",
            service="clinical_document_service",
            resource={
                "document_type": effective_type,
                "effective_document_type": effective_type,
                "selected_document_type": selected_type,
                "detected_document_type": detected_type,
                "analysis_document_id": payload["_id"],
                "reused": False,
            },
        )
        self._invalidate_case_epicrisis_cache(
            username=request.username,
            case_key=str(payload.get("case_key") or ""),
            effective_document_type=effective_type,
            case_number=str(payload.get("case_number") or ""),
            patient_id=str(payload.get("patient_id") or ""),
            patient_name=str(payload.get("nombre_paciente") or ""),
            ingestion_source=str(payload.get("ingestion_source") or request.ingestion_source or "manual"),
            batch_id=str(payload.get("batch_id") or request.batch_id or ""),
        )
        return self._serialize(payload, reused=False)

    def get_user_case_context(self, username: str, case_key: str) -> dict[str, Any]:
        normalized_case_key = str(case_key or "").strip()
        if not normalized_case_key:
            return {}

        case = self.batch_case_repository.get_user_case(username, normalized_case_key) or {}
        document = self._find_latest_case_identity_document(username, normalized_case_key)
        if not case and not document:
            return {}

        lookup_sources = []
        if case:
            lookup_sources.append("batch_case_repository")
        if document:
            lookup_sources.append("historias_analizadas")

        context = {
            "case_key": normalized_case_key,
            "case_number": str(case.get("case_number") or document.get("case_number") or "").strip(),
            "patient_id": str(case.get("patient_id") or document.get("patient_id") or "").strip(),
            "patient_name": str(case.get("patient_name") or document.get("nombre_paciente") or "").strip(),
            "lookup_source": "+".join(lookup_sources),
        }
        if document:
            context["document_id"] = str(document.get("_id") or "").strip()
        return context

    def resolve_case_identity(self, request: ClinicalDocumentRequest) -> CaseIdentityResolution:
        _selected_type, _detected_type, effective_type = self._resolve_request_document_types(request)
        explicit = self._extract_explicit_case_identity(request)
        evidence = list(request.case_resolution_evidence or [])
        review_messages = list(request.review_messages or [])

        existing_resolution = self._resolve_existing_case_key_identity(
            username=request.username,
            explicit=explicit,
            evidence=evidence,
            review_messages=review_messages,
            review_required=bool(request.review_required),
        )
        if existing_resolution is not None:
            return existing_resolution

        signals = self.case_association_service.extract_signals(
            request.original_name,
            request.raw_text,
            effective_type,
        )
        extracted_identity = extract_document_identity(
            request.raw_text,
            strategy=resolve_identity_strategy(effective_type),
        )
        patient_name, patient_id, case_number = self._resolve_identity_fields(
            explicit=explicit,
            signals=signals,
            extracted_identity=extracted_identity,
        )
        evidence.extend(
            self._build_resolution_evidence(
                explicit=explicit,
                signals=signals,
                extracted_identity=extracted_identity,
            )
        )
        case_key, has_anchor = self._resolve_case_key(
            explicit_case_key=explicit["case_key"],
            patient_name=patient_name,
            patient_id=patient_id,
            case_number=case_number,
            original_name=request.original_name,
        )
        status, review_required = self._resolve_case_status(
            has_anchor=has_anchor,
            requested_review=bool(request.review_required),
            review_messages=review_messages,
        )
        if "patient_name" in extracted_identity.redacted_identity_fields:
            review_required = True
            review_messages.append(
                "El nombre del paciente está censurado por políticas de protección de datos."
            )
        elif patient_name == "desconocido":
            review_required = True
            review_messages.append("No fue posible identificar con confianza el nombre del paciente.")

        return self._build_case_identity_resolution(
            case_key=case_key,
            case_number=case_number,
            patient_id=patient_id,
            patient_name=patient_name,
            status=status,
            evidence=evidence,
            review_required=review_required,
            review_messages=review_messages,
            redacted_identity_fields=list(extracted_identity.redacted_identity_fields),
        )

    def _extract_explicit_case_identity(self, request: ClinicalDocumentRequest) -> dict[str, str]:
        return {
            "case_key": str(request.case_key or "").strip(),
            "patient_name": str(request.provided_patient_name or "").strip(),
            "patient_id": str(request.provided_patient_id or request.patient_id or "").strip(),
            "case_number": str(request.provided_case_number or request.case_number or "").strip(),
        }

    def _resolve_existing_case_key_identity(
        self,
        *,
        username: str,
        explicit: dict[str, str],
        evidence: list[str],
        review_messages: list[str],
        review_required: bool,
    ) -> CaseIdentityResolution | None:
        explicit_case_key = explicit["case_key"]
        if not explicit_case_key:
            return None
        existing = self.get_user_case_context(username, explicit_case_key)
        if not existing:
            review_messages.append(
                "No se encontró un caso previo con el case_key indicado. Se creó una asociación provisional."
            )
            return None
        evidence.extend(["provided_case_key", str(existing.get("lookup_source") or "case_lookup")])
        return self._build_case_identity_resolution(
            case_key=explicit_case_key,
            case_number=str(existing.get("case_number") or explicit["case_number"]).strip(),
            patient_id=str(existing.get("patient_id") or explicit["patient_id"]).strip(),
            patient_name=str(existing.get("patient_name") or explicit["patient_name"]).strip()
            or "desconocido",
            status="confirmed",
            evidence=evidence,
            review_required=review_required,
            review_messages=review_messages,
        )

    def _resolve_identity_fields(
        self,
        *,
        explicit: dict[str, str],
        signals: Any,
        extracted_identity: DocumentIdentityExtractionResult,
    ) -> tuple[str, str, str]:
        patient_name = (
            explicit["patient_name"]
            or str(extracted_identity.patient_name or "").strip()
            or str(signals.patient_name or "").strip()
            or "desconocido"
        )
        patient_id = (
            explicit["patient_id"]
            or str(extracted_identity.patient_id or "").strip()
            or str(signals.patient_id or "").strip()
        )
        case_number = (
            explicit["case_number"]
            or str(extracted_identity.case_number or "").strip()
            or str(signals.case_number or "").strip()
        )
        return patient_name, patient_id, case_number

    def _build_resolution_evidence(
        self,
        *,
        explicit: dict[str, str],
        signals: Any,
        extracted_identity: DocumentIdentityExtractionResult,
    ) -> list[str]:
        evidence = list(signals.evidence or [])
        evidence.extend(list(extracted_identity.evidence or ()))
        evidence.extend(
            item
            for present, item in (
                (bool(explicit["patient_name"]), "provided_patient_name"),
                (bool(explicit["patient_id"]), "provided_patient_id"),
                (bool(explicit["case_number"]), "provided_case_number"),
            )
            if present
        )
        return evidence

    def _resolve_case_key(
        self,
        *,
        explicit_case_key: str,
        patient_name: str,
        patient_id: str,
        case_number: str,
        original_name: str,
    ) -> tuple[str, bool]:
        has_anchor = bool(patient_id or case_number or explicit_case_key)
        if explicit_case_key:
            return explicit_case_key, has_anchor
        case_key = build_case_key(
            patient_id=patient_id,
            case_number=case_number,
            patient_name=patient_name,
            fallback_name=original_name,
        )
        if case_key:
            return case_key, has_anchor
        return (
            build_case_key(
                patient_id="",
                case_number="",
                patient_name=patient_name,
                fallback_name=original_name or "documento",
            ),
            has_anchor,
        )

    def _resolve_case_status(
        self,
        *,
        has_anchor: bool,
        requested_review: bool,
        review_messages: list[str],
    ) -> tuple[str, bool]:
        if has_anchor:
            return "confirmed", requested_review
        review_messages.append(
            "No se identificaron señales fuertes del caso. Se creó una case_key provisional para no bloquear la ingesta."
        )
        return "provisional", True

    def _build_case_identity_resolution(
        self,
        *,
        case_key: str,
        case_number: str,
        patient_id: str,
        patient_name: str,
        status: str,
        evidence: list[str],
        review_required: bool,
        review_messages: list[str],
        redacted_identity_fields: list[RedactedIdentityField] | None = None,
    ) -> CaseIdentityResolution:
        return CaseIdentityResolution(
            case_key=case_key,
            case_number=case_number,
            patient_id=patient_id,
            patient_name=patient_name,
            redacted_identity_fields=list(dict.fromkeys(redacted_identity_fields or [])),
            case_resolution_status=status,
            case_resolution_evidence=self._unique_str_list(evidence),
            review_required=review_required,
            review_messages=self._unique_str_list(review_messages),
        )

    def repair_existing_documents_for_user(self, username: str, *, limit: int = 200) -> dict[str, int]:
        counters = {
            "repaired_patient_names": 0,
            "repaired_legacy_histories": 0,
            "repaired_case_metadata": 0,
        }
        for doc in self._iter_repairable_documents(username, limit):
            payload_updates, repaired_patient_name = self._collect_document_repair_updates(username, doc)
            counters["repaired_patient_names"] += int(repaired_patient_name)
            doc = self._persist_document_repair_updates(doc, payload_updates, counters)
            counters["repaired_legacy_histories"] += int(self._legacy_history_repaired(doc))
        return counters

    def _iter_repairable_documents(self, username: str, limit: int):
        return (
            self.mongo_analyses.collection.find(
                {
                    "usuario": username,
                    "tipo_documento": {"$nin": ["epicrisis", "epicrisis_case_cache"]},
                }
            )
            .sort([("fecha_analisis", -1)])
            .limit(max(1, int(limit or 200)))
        )

    def _collect_document_repair_updates(
        self,
        username: str,
        doc: dict[str, Any],
    ) -> tuple[dict[str, Any], bool]:
        resolution = self.resolve_case_identity(self._build_document_repair_request(username, doc))
        payload_updates = self._collect_case_resolution_updates(doc, resolution)
        nombre_paciente = self._resolve_repaired_patient_name(username, doc, resolution.case_key)
        repaired_patient_name = bool(nombre_paciente and nombre_paciente != doc.get("nombre_paciente"))
        if repaired_patient_name:
            payload_updates["nombre_paciente"] = nombre_paciente
        payload_updates.update(self._collect_historia_repair_updates(doc, payload_updates))
        return payload_updates, repaired_patient_name

    def _build_document_repair_request(
        self,
        username: str,
        doc: dict[str, Any],
    ) -> ClinicalDocumentRequest:
        return ClinicalDocumentRequest(
            raw_text=str(doc.get("descripcion") or ""),
            detected_type=str(doc.get("tipo_documento") or "generico"),
            username=username,
            original_name=str(doc.get("nombre_archivo") or ""),
            document_title=str(doc.get("document_title") or ""),
            document_key=str(doc.get("document_key") or ""),
            document_reference=str(doc.get("document_reference") or ""),
            case_key=str(doc.get("case_key") or ""),
            case_number=str(doc.get("case_number") or ""),
            patient_id=str(doc.get("patient_id") or ""),
            provided_patient_name=str(doc.get("nombre_paciente") or ""),
            case_resolution_status=str(doc.get("case_resolution_status") or ""),
            case_resolution_evidence=list(doc.get("case_resolution_evidence") or []),
            redacted_identity_fields=list(doc.get("redacted_identity_fields") or []),
            review_required=bool(doc.get("review_required", False)),
            review_messages=list(doc.get("review_messages") or []),
        )

    def _collect_case_resolution_updates(
        self,
        doc: dict[str, Any],
        resolution: CaseIdentityResolution,
    ) -> dict[str, Any]:
        payload_updates: dict[str, Any] = {}
        current_values = {
            "case_key": str(doc.get("case_key") or "").strip(),
            "case_number": str(doc.get("case_number") or "").strip(),
            "patient_id": str(doc.get("patient_id") or "").strip(),
            "case_resolution_status": str(doc.get("case_resolution_status") or "").strip(),
            "review_required": bool(doc.get("review_required", False)),
            "review_messages": self._unique_str_list(doc.get("review_messages") or []),
            "case_resolution_evidence": self._unique_str_list(doc.get("case_resolution_evidence") or []),
            "redacted_identity_fields": list(doc.get("redacted_identity_fields") or []),
        }
        resolved_values = {
            "case_key": resolution.case_key,
            "case_number": resolution.case_number,
            "patient_id": resolution.patient_id,
            "case_resolution_status": resolution.case_resolution_status,
            "review_required": resolution.review_required,
            "review_messages": list(resolution.review_messages),
            "case_resolution_evidence": list(resolution.case_resolution_evidence),
            "redacted_identity_fields": list(
                dict.fromkeys(
                    [
                        *list(doc.get("redacted_identity_fields") or []),
                        *resolution.redacted_identity_fields,
                    ]
                )
            ),
        }
        for key, resolved_value in resolved_values.items():
            if key == "case_key" and not resolved_value:
                continue
            if key == "case_key" and self._should_preserve_existing_case_key(doc):
                continue
            if resolved_value != current_values[key]:
                payload_updates[key] = resolved_value
        return payload_updates

    def _resolve_repaired_patient_name(
        self,
        username: str,
        doc: dict[str, Any],
        resolved_case_key: str,
    ) -> str:
        return self._resolve_patient_name_from_sources(
            provided_patient_name=doc.get("nombre_paciente", ""),
            case_key=resolved_case_key or doc.get("case_key", ""),
            username=username,
            analisis_html=render_document_analysis_html(doc),
            analysis_structured=doc.get("analysis_structured"),
            raw_text=doc.get("descripcion", ""),
        )

    def _collect_historia_repair_updates(
        self,
        doc: dict[str, Any],
        payload_updates: dict[str, Any],
    ) -> dict[str, Any]:
        candidate_doc = {**doc, **payload_updates}
        if str(candidate_doc.get("tipo_documento") or "").strip() != "historia_clinica":
            return {}
        self._normalize_historia_structured_payload(candidate_doc)
        history_updates: dict[str, Any] = {}
        if candidate_doc.get("analysis_structured") != doc.get("analysis_structured"):
            history_updates["analysis_structured"] = candidate_doc.get("analysis_structured") or {}
        if candidate_doc.get("analisis_html") != doc.get("analisis_html"):
            history_updates["analisis_html"] = candidate_doc.get("analisis_html") or ""
        return history_updates

    def _persist_document_repair_updates(
        self,
        doc: dict[str, Any],
        payload_updates: dict[str, Any],
        counters: dict[str, int],
    ) -> dict[str, Any]:
        if not payload_updates or doc.get("_id") is None:
            return doc
        self.mongo_analyses.collection.update_one(
            {"_id": doc["_id"]},
            {"$set": payload_updates},
        )
        if self._updates_case_metadata(payload_updates):
            counters["repaired_case_metadata"] += 1
        return {**doc, **payload_updates}

    def _updates_case_metadata(self, payload_updates: dict[str, Any]) -> bool:
        return any(
            key in payload_updates
            for key in (
                "case_key",
                "case_number",
                "patient_id",
                "case_resolution_status",
                "review_required",
                "review_messages",
                "case_resolution_evidence",
            )
        )

    def _legacy_history_repaired(self, doc: dict[str, Any]) -> bool:
        legacy_id = self._ensure_legacy_history(doc)
        return bool(legacy_id and legacy_id != doc.get("legacy_historia_id"))

    def _find_existing_document(self, request: ClinicalDocumentRequest) -> dict[str, Any] | None:
        if not request.batch_file_id:
            return None
        return self.mongo_analyses.collection.find_one(
            {
                "usuario": request.username,
                "batch_file_id": request.batch_file_id,
                "ingestion_source": request.ingestion_source,
            }
        )

    def _apply_case_resolution(
        self,
        request: ClinicalDocumentRequest,
        resolution: CaseIdentityResolution,
    ) -> ClinicalDocumentRequest:
        _selected_type, _detected_type, effective_type = self._resolve_request_document_types(request)
        source_redacted_fields = extract_document_identity(
            request.raw_text,
            strategy=resolve_identity_strategy(effective_type),
        ).redacted_identity_fields
        updates = {
            "case_key": resolution.case_key,
            "case_number": resolution.case_number,
            "patient_id": resolution.patient_id,
            "provided_patient_name": resolution.patient_name,
            "case_resolution_status": resolution.case_resolution_status,
            "case_resolution_evidence": list(resolution.case_resolution_evidence),
            "redacted_identity_fields": list(
                dict.fromkeys(
                    [
                        *request.redacted_identity_fields,
                        *resolution.redacted_identity_fields,
                        *source_redacted_fields,
                    ]
                )
            ),
            "review_required": bool(resolution.review_required),
            "review_messages": list(resolution.review_messages),
        }
        if is_dataclass(request):
            return replace(request, **updates)
        for field_name, value in updates.items():
            setattr(request, field_name, value)
        return request

    def _build_payload(self, request: ClinicalDocumentRequest) -> dict[str, Any]:
        selected_type, detected_type, effective_type = self._resolve_request_document_types(request)
        raw_text = str(request.raw_text or "")
        now = datetime.now(self.colombia_tz).isoformat()
        deterministic_signals = extract_deterministic_signals(raw_text, effective_type)
        analysis = self._generate_analysis(raw_text, effective_type, username=request.username)
        nombre_paciente = self._resolve_patient_name_from_sources(
            provided_patient_name=request.provided_patient_name,
            case_key=request.case_key,
            username=request.username,
            analisis_html=analysis.rendered_html,
            analysis_structured=analysis.analysis_structured,
            raw_text=raw_text,
            deterministic_signals=deterministic_signals,
        )

        payload: dict[str, Any] = {
            "mensaje": f"Documento {effective_type} procesado exitosamente",
            "nombre_archivo": request.original_name,
            "usuario": request.username,
            "nombre_paciente": nombre_paciente,
            "tipo_documento": effective_type,
            "document_type": effective_type,
            "selected_document_type": selected_type,
            "detected_document_type": detected_type,
            "effective_document_type": effective_type,
            "fecha_analisis": now,
            "analisis_html": analysis.rendered_html,
            "analysis_structured": analysis.analysis_structured or {},
            "analysis_schema": analysis.analysis_schema,
            "analysis_schema_version": analysis.analysis_schema_version,
            "analysis_render_version": analysis.analysis_render_version,
            "analysis_provider": getattr(analysis, "analysis_provider", None),
            "analysis_model_name": getattr(analysis, "analysis_model_name", None),
            "analysis_route": dict(getattr(analysis, "analysis_route", {}) or {}),
            "analysis_quality": dict(getattr(analysis, "analysis_quality", {}) or {}),
            "descripcion": raw_text,
            "case_key": request.case_key,
            "case_number": request.case_number,
            "patient_id": request.patient_id,
            "batch_id": request.batch_id,
            "batch_file_id": request.batch_file_id,
            "ingestion_source": request.ingestion_source,
            "source_file_hash": str(request.source_file_hash or "").strip(),
            "extraction_metadata": dict(request.extraction_metadata or {}),
            "classification_decision": dict(request.classification_decision or {}),
            "document_title": str(request.document_title or "").strip(),
            "document_key": str(request.document_key or "").strip(),
            "document_reference": str(request.document_reference or "").strip(),
            "parent_prefactura_batch_id": str(request.parent_prefactura_batch_id or "").strip(),
            "source_page_start": int(request.source_page_start or 0),
            "source_page_end": int(request.source_page_end or 0),
            "category_override_confirmed": bool(request.category_override_confirmed),
            "override_audit": dict(request.override_audit or {}),
            "case_resolution_status": str(request.case_resolution_status or "provisional"),
            "case_resolution_evidence": self._unique_str_list(request.case_resolution_evidence or []),
            "redacted_identity_fields": list(dict.fromkeys(request.redacted_identity_fields or [])),
            "review_required": bool(request.review_required),
            "review_messages": self._unique_str_list(request.review_messages or []),
            "error_analisis": analysis.error_analisis,
            "legacy_historia_id": "",
        }
        historia_processing = dict((payload.get("analysis_route") or {}).get("historia_processing") or {})
        if historia_processing:
            payload["historia_processing"] = historia_processing
        if analysis.error_analisis == "texto_no_extraible":
            payload["texto_no_extraible"] = True

        if effective_type == "historia_clinica":
            self._normalize_historia_structured_payload(payload)

        if effective_type == "factura":
            try:
                self._normalize_factura_structured_payload(payload)
                self._enrich_factura(payload)
            except Exception:
                logger.exception("Error normalizando factura estructurada")
                payload["analysis_structured"] = {"dt": "factura"}
                payload["factura_json"] = {}
                payload["error_analisis"] = "No fue posible validar la factura estructurada."
        elif effective_type == "prefactura":
            self._normalize_prefactura_structured_payload(payload)
        elif effective_type == "quirurgico":
            self._enrich_quirurgico(payload, deterministic_signals=deterministic_signals)
        elif effective_type == "historia_clinica":
            self._enrich_historia(payload, deterministic_signals=deterministic_signals)
        elif effective_type in {"radiologia", "laboratorio", "generico"}:
            self._enrich_ayudas_diagnosticas(payload)

        self._apply_pipeline_quality_metadata(payload)
        self._ensure_prefactura_historia_visibility(payload)
        if self.catalog_registry is not None:
            payload["datos_objetivos"] = dump_curation_model(
                build_document_objective_data(
                    payload,
                    catalog_registry=self.catalog_registry,
                    deterministic_signals=deterministic_signals,
                )
            )

        if payload.get("error_analisis") or payload.get("error_cie10") or payload.get("error_cups"):
            payload["mensaje"] = "Procesamiento completado con advertencias"

        return payload

    def _apply_pipeline_quality_metadata(self, payload: dict[str, Any]) -> None:
        extraction = dict(payload.get("extraction_metadata") or {})
        processing = dict(payload.get("historia_processing") or {})
        quality = dict(payload.get("analysis_quality") or {})
        reason_codes = [str(item) for item in quality.get("reason_codes") or [] if str(item)]
        review_messages = list(payload.get("review_messages") or [])

        pages_without_text = list(extraction.get("pages_without_text") or [])
        if pages_without_text:
            reason_codes.append("pdf_pages_without_extractable_text")
            review_messages.append(
                "Una o más páginas del PDF no aportaron texto extraíble y requieren revisión."
            )
        if processing and not bool(processing.get("complete", False)):
            reason_codes.append("historia_incomplete_source_coverage")
            review_messages.append("La historia clínica no alcanzó cobertura completa durante el resumen.")

        unique_reasons = self._unique_str_list(reason_codes)
        payload["review_messages"] = self._unique_str_list(review_messages)
        if not unique_reasons:
            return
        quality.update(
            {
                "status": "degraded",
                "low_confidence": True,
                "reason_codes": unique_reasons,
            }
        )
        payload["analysis_quality"] = quality
        payload["review_required"] = True

    def _normalize_historia_structured_payload(self, payload: dict[str, Any]) -> None:
        structured = payload.get("analysis_structured")
        if not isinstance(structured, dict):
            return

        normalized_structured = dict(structured)
        raw_text = str(payload.get("descripcion") or "")
        source_identity = extract_document_identity(raw_text, strategy="historia_clinica")
        redacted_fields = list(
            dict.fromkeys(
                [
                    *list(payload.get("redacted_identity_fields") or []),
                    *source_identity.redacted_identity_fields,
                ]
            )
        )
        payload["redacted_identity_fields"] = redacted_fields
        case_number = str(payload.get("case_number") or source_identity.case_number or "").strip()
        patient_id = str(payload.get("patient_id") or "").strip()
        patient_name = str(payload.get("nombre_paciente") or "").strip()

        if case_number:
            normalized_structured["nc"] = case_number
        patient_id_matches_case = bool(
            patient_id
            and case_number
            and re.sub(r"\W+", "", patient_id).casefold()
            == re.sub(r"\W+", "", case_number).casefold()
        )
        if (
            patient_id
            and not _REDACTED_IDENTITY_VALUE_PATTERN.search(patient_id)
            and not ("patient_id" in redacted_fields and patient_id_matches_case)
        ):
            normalized_structured["ip"] = patient_id
        elif "patient_id" in redacted_fields:
            normalized_structured.pop("ip", None)
            normalized_structured.pop("identificacion_paciente", None)
        if patient_name and not _REDACTED_IDENTITY_VALUE_PATTERN.search(patient_name):
            normalized_structured["pn"] = patient_name
        elif "patient_name" in redacted_fields:
            normalized_structured.pop("pn", None)
            normalized_structured.pop("patient_name", None)
        raw_institution = extract_historia_institution_text(raw_text)
        raw_prestador = extract_historia_prestador_text(raw_text)
        if raw_institution:
            normalized_structured["ps"] = raw_institution
        elif raw_prestador and not str(normalized_structured.get("ps") or "").strip():
            normalized_structured["ps"] = raw_prestador
        raw_fecha_nacimiento = extract_historia_fecha_nacimiento_text(raw_text)
        if raw_fecha_nacimiento:
            normalized_structured["fn"] = raw_fecha_nacimiento
        raw_fecha_ingreso = extract_historia_fecha_ingreso_text(raw_text)
        if raw_fecha_ingreso:
            normalized_structured["fi"] = raw_fecha_ingreso
        raw_edad = extract_historia_edad_text(raw_text)
        if raw_edad:
            normalized_structured["ed"] = raw_edad
        raw_sexo = extract_historia_sexo_text(raw_text)
        if raw_sexo:
            normalized_structured["sx"] = raw_sexo
        raw_motivo = extract_historia_motivo_text(raw_text)
        if raw_motivo and _is_low_quality_historia_motivo(normalized_structured.get("mc")):
            normalized_structured["mc"] = raw_motivo
        diagnosticos = normalized_structured.get("dx")
        if isinstance(diagnosticos, list):
            normalized_structured["dx"] = [
                item
                for item in diagnosticos
                if not (
                    isinstance(item, dict)
                    and not str(item.get("c") or item.get("codigo") or "").strip()
                    and _is_historia_symptom_only(item.get("d") or item.get("descripcion"))
                )
            ]
        normalized_structured["rs"] = resolve_historia_resumen(
            normalized_structured.get("rs") or normalized_structured.get("resumen_clinico"),
            raw_text=raw_text,
            analisis_html=str(payload.get("analisis_html") or ""),
        )

        validated = HistoriaClinicaStructured.model_validate(normalized_structured)
        dumped = dump_clinical_model(validated)
        extraction_document = {**payload, "analysis_structured": dumped}
        antecedentes_estructurados = extraer_antecedentes_historia_estructurados(extraction_document)
        if antecedentes_estructurados:
            dumped["ae"] = antecedentes_estructurados
        antecedentes = extraer_antecedentes_historia({**payload, "analysis_structured": dumped})
        if antecedentes:
            dumped["an"] = antecedentes
        validated = HistoriaClinicaStructured.model_validate(dumped)
        dumped = dump_clinical_model(validated)
        if "patient_name" in redacted_fields and not patient_name:
            dumped.pop("pn", None)
        if "patient_id" in redacted_fields and not patient_id:
            dumped.pop("ip", None)
        payload["analysis_structured"] = dumped
        payload["analisis_html"] = render_document_analysis_html(payload)
        payload["analysis_quality"] = assess_clinical_extraction(
            document_type="historia_clinica",
            raw_text=str(payload.get("descripcion") or ""),
            analysis_model=validated,
        ).to_payload()

    def _normalize_factura_structured_payload(self, payload: dict[str, Any]) -> None:
        structured = payload.get("analysis_structured")
        if structured is not None and not isinstance(structured, dict):
            return

        normalized_model, factura_json = build_factura_structured(
            raw_text=str(payload.get("descripcion") or ""),
            current_structured=structured,
            patient_name=str(payload.get("nombre_paciente") or ""),
            patient_id=str(payload.get("patient_id") or ""),
            case_number=str(payload.get("case_number") or ""),
        )
        repair_structured = self._repair_factura_low_confidence_sections(
            payload=payload,
            current_structured=structured,
            normalized_model=normalized_model,
            factura_json=factura_json,
        )
        if repair_structured:
            normalized_model, factura_json = build_factura_structured(
                raw_text=str(payload.get("descripcion") or ""),
                current_structured=structured,
                patient_name=str(payload.get("nombre_paciente") or ""),
                patient_id=str(payload.get("patient_id") or ""),
                case_number=str(payload.get("case_number") or ""),
                repair_structured=repair_structured,
            )
        payload["analysis_structured"] = dump_clinical_model(normalized_model)
        payload["analisis_html"] = render_document_analysis_html(payload)
        payload["factura_json"] = factura_json
        payload["analysis_quality"] = self._merge_analysis_quality(
            current_quality=payload.get("analysis_quality"),
            low_confidence_sections=factura_json.get("low_confidence_sections") or [],
            repair_applied=bool(repair_structured),
        )
        self._append_factura_identity_repair_note(
            payload, current_structured=structured, normalized_model=normalized_model
        )

    def _merge_analysis_quality(
        self,
        *,
        current_quality: Any,
        low_confidence_sections: list[str],
        repair_applied: bool,
    ) -> dict[str, Any]:
        merged = dict(current_quality or {})
        reason_codes = [str(item).strip() for item in merged.get("reason_codes") or [] if str(item).strip()]
        for section in low_confidence_sections:
            code = f"factura_{section}_low_confidence"
            if code not in reason_codes:
                reason_codes.append(code)
        if repair_applied:
            reason_codes.append("factura_targeted_repair_applied")
        unique_reasons = self._unique_str_list(reason_codes)
        has_low_confidence = bool(low_confidence_sections)
        merged.update(
            {
                "status": "degraded" if has_low_confidence else merged.get("status", "ok"),
                "low_confidence": has_low_confidence or bool(merged.get("low_confidence")),
                "reason_codes": unique_reasons,
                "repair_applied": bool(repair_applied),
                "low_confidence_sections": list(low_confidence_sections),
            }
        )
        if not has_low_confidence:
            merged["status"] = "ok"
            merged["low_confidence"] = False
        return merged

    def _repair_factura_low_confidence_sections(
        self,
        *,
        payload: dict[str, Any],
        current_structured: dict[str, Any] | None,
        normalized_model: FacturaStructured,
        factura_json: dict[str, Any],
    ) -> dict[str, Any] | None:
        if self.llm_router is None or not hasattr(self.llm_router, "generate_structured"):
            return None

        low_confidence = set(factura_json.get("low_confidence_sections") or [])
        if not low_confidence:
            return None

        repair_payload: dict[str, Any] = {"dt": "factura"}
        repair_contexts = dict(factura_json.get("repair_contexts") or {})
        if "identity" in low_confidence:
            identity_patch = self._request_factura_section_repair(
                section="identity",
                context=repair_contexts.get("identity", ""),
            )
            if identity_patch:
                repair_payload.update(identity_patch)
        if "header" in low_confidence and self._factura_header_needs_repair(normalized_model):
            header_patch = self._request_factura_section_repair(
                section="header",
                context=repair_contexts.get("header", ""),
            )
            if header_patch:
                repair_payload.update(header_patch)
        if "summary" in low_confidence and self._factura_summary_needs_repair(normalized_model):
            summary_patch = self._request_factura_section_repair(
                section="summary",
                context=repair_contexts.get("summary", ""),
            )
            if summary_patch:
                repair_payload.update(summary_patch)
        if "line_items" in low_confidence and self._factura_line_items_need_repair(normalized_model):
            line_items_patch = self._request_factura_section_repair(
                section="line_items",
                context=repair_contexts.get("line_items", ""),
            )
            if line_items_patch:
                repair_payload.update(line_items_patch)

        if repair_payload == {"dt": "factura"}:
            return None
        if isinstance(current_structured, dict):
            merged = dict(current_structured)
            merged.update(repair_payload)
            return merged
        return repair_payload

    def _factura_header_needs_repair(self, model: FacturaStructured) -> bool:
        payer_name = str(model.pagador.aseguradora_eps or "").strip()
        provider_address = str(model.proveedor.direccion or "").strip()
        payer_nit = str(model.pagador.nit_pagador or "").strip()
        normalized_payer = payer_name.lower()
        return not all(
            [
                payer_name,
                payer_nit,
                provider_address,
                "caso no." not in normalized_payer,
                "factura electrónica" not in provider_address.lower(),
            ]
        )

    def _factura_identity_needs_repair(self, model: FacturaStructured) -> bool:
        patient_name = str(model.patient_name or "").strip()
        payer_name = str(model.pagador.aseguradora_eps or "").strip()
        provider_name = str(model.proveedor.nombre_institucion or "").strip()
        patient_id = str(model.paciente.numero_identificacion or "").strip()
        return not all(
            [
                patient_name,
                patient_id,
                patient_name != payer_name,
                patient_name != provider_name,
            ]
        )

    def _factura_summary_needs_repair(self, model: FacturaStructured) -> bool:
        due_date = str(model.datos_factura.fecha_vencimiento or "").strip().lower()
        total = str(model.resumen_financiero.valor_total_factura or "").strip()
        total_services = str(model.resumen_financiero.total_servicios or "").strip()
        return not total or ("proveedor tecnologico" in due_date) or (not total_services)

    def _factura_line_items_need_repair(self, model: FacturaStructured) -> bool:
        if not model.lineas_canonicas:
            return True
        populated_sections = sum(
            1
            for items in (
                model.servicios_procedimientos.procedimientos_quirurgicos,
                model.servicios_procedimientos.examenes_laboratorio,
                model.servicios_procedimientos.imagenologia,
                model.servicios_procedimientos.hospitalizacion,
                model.servicios_procedimientos.honorarios_medicos,
                model.servicios_procedimientos.medicamentos,
                model.servicios_procedimientos.otros_servicios,
            )
            if items
        )
        return populated_sections <= 1

    def _request_factura_section_repair(self, *, section: str, context: str) -> dict[str, Any] | None:
        if not context.strip():
            return None
        router = self.llm_router
        if router is None or not hasattr(router, "generate_structured"):
            return None
        if section == "identity":
            prompt = (
                "Repara solo la identidad del paciente y el pagador de una factura clinica colombiana. "
                "No confundas paciente con aseguradora o prestador. "
                "Si un dato no es claro, omítelo."
            )
            output_model = _FacturaIdentityRepair
        elif section == "header":
            prompt = (
                "Repara solo el encabezado de una factura clinica colombiana. "
                "Separa proveedor, datos de factura, pagador y paciente. "
                "No inventes datos. Si un campo no aparece claro, omítelo."
            )
            output_model = _FacturaHeaderRepair
        elif section == "summary":
            prompt = (
                "Repara solo el bloque financiero de una factura clínica colombiana. "
                "Extrae vencimiento, total de servicios, descuentos, copagos, valor total y valor en letras. "
                "No inventes datos."
            )
            output_model = _FacturaSummaryRepair
        else:
            prompt = (
                "Repara solo lineas facturadas canónicas de una factura clínica colombiana. "
                "Extrae únicamente filas con fecha válida, código facturación, código referencia opcional, "
                "descripción, cantidad, valor unitario y total. "
                "No inventes filas ni repitas encabezados."
            )
            output_model = _FacturaLineItemsRepair
        try:
            response = router.generate_structured(
                LLMStructuredRequest(
                    task=LLMTask.CLINICAL_DOCUMENT_EXTRACT,
                    prompt=f"{prompt}\n\nBloque fuente:\n{context}",
                    output_model=output_model,
                    output_kind=LLMOutputKind.STRUCTURED_OBJECT,
                    system_prompt=(
                        "Eres un extractor estructurado de facturas. "
                        "Responde solo un JSON valido para el esquema solicitado."
                    ),
                    metadata={"document_type": "factura", "repair_section": section},
                )
            )
        except Exception:
            logger.exception("Error reparando seccion %s de factura", section)
            return None
        try:
            validated = output_model.model_validate(response.content)
        except Exception:
            logger.exception("Respuesta invalida reparando seccion %s de factura", section)
            return None
        return validated.model_dump(by_alias=True, exclude_none=True, exclude_defaults=True)

    def _append_factura_identity_repair_note(
        self,
        payload: dict[str, Any],
        *,
        current_structured: dict[str, Any] | None,
        normalized_model: FacturaStructured,
    ) -> None:
        if str(payload.get("case_resolution_status") or "").strip() != "confirmed":
            return
        if not isinstance(current_structured, dict):
            return
        incoming_name = str(current_structured.get("pn") or "").strip()
        normalized_name = str(normalized_model.patient_name or "").strip()
        if not incoming_name or not normalized_name:
            return
        if incoming_name == normalized_name:
            return
        notes = list(payload.get("review_messages") or [])
        note = "Se preservo la identidad confirmada del caso sobre la lectura estructurada de la factura."
        if note not in notes:
            notes.append(note)
        payload["review_messages"] = notes

    def _normalize_prefactura_structured_payload(self, payload: dict[str, Any]) -> None:
        structured = payload.get("analysis_structured")
        normalized: dict[str, Any] = (
            dict(structured) if isinstance(structured, dict) else {"dt": "prefactura"}
        )
        parsed = self._parse_prefactura_text(str(payload.get("descripcion") or ""))

        case_number = str(payload.get("case_number") or parsed.get("numero_caso") or "").strip()
        patient_id = str(payload.get("patient_id") or parsed.get("paciente_identificacion") or "").strip()
        patient_name = str(payload.get("nombre_paciente") or parsed.get("paciente_nombre") or "").strip()
        normalized["dt"] = "prefactura"
        normalized["nc"] = case_number or normalized.get("nc")
        normalized["ps"] = parsed.get("prestador") or normalized.get("ps")
        normalized["pnm"] = patient_name or normalized.get("pnm")
        normalized["pid"] = patient_id or normalized.get("pid")
        normalized["svs"] = parsed.get("servicios") or normalized.get("svs") or []
        normalized["ve"] = parsed.get("valor_estimado") or normalized.get("ve")
        normalized["ob"] = parsed.get("observaciones") or normalized.get("ob")
        if patient_name:
            normalized["pn"] = patient_name

        validated = PrefacturaStructured.model_validate(normalized)
        payload["analysis_structured"] = dump_clinical_model(validated)
        payload["analisis_html"] = render_document_analysis_html(payload)
        payload["prefactura_json"] = {
            "numero_caso": validated.numero_caso or "",
            "prestador": validated.prestador or "",
            "paciente_nombre": validated.paciente_nombre or validated.patient_name,
            "paciente_identificacion": validated.paciente_identificacion or "",
            "servicios": [item.model_dump(exclude_none=True) for item in validated.servicios],
            "valor_estimado": validated.valor_estimado or "",
            "observaciones": validated.observaciones or "",
        }

    def _parse_prefactura_text(self, raw_text: str) -> dict[str, Any]:
        lines = [re.sub(r"\s+", " ", line).strip() for line in raw_text.splitlines()]
        lines = [line for line in lines if line]
        compact = "\n".join(lines)
        case_match = _PREFACTURA_CASE_PATTERN.search(compact)
        amount_match = _PREFACTURA_AMOUNT_PATTERN.search(compact)
        table_start_index = next(
            (index + 1 for index, line in enumerate(lines) if _PREFACTURA_TABLE_HEADER_PATTERN.search(line)),
            -1,
        )
        services = []
        if table_start_index >= 0:
            for line in lines[table_start_index:]:
                if _PREFACTURA_TOTAL_LINE_PATTERN.search(line):
                    break
                parsed_row = self._parse_prefactura_service_row(line)
                if not parsed_row:
                    continue
                descripcion = str(parsed_row["descripcion"] or "").strip()
                if any(
                    token in descripcion.lower() for token in ("factura", "caso", "paciente", "prestador")
                ):
                    continue
                services.append(
                    {
                        "c": str(parsed_row["codigo"] or "").strip(),
                        "d": descripcion,
                        "q": str(parsed_row["cantidad"] or "").strip() or None,
                        "v": str(parsed_row["valor"] or "").strip() or None,
                    }
                )
                if len(services) >= 25:
                    break
        prestador = self._extract_prefactura_provider(lines)
        extracted_identity = extract_document_identity(compact, strategy="prefactura")
        return {
            "numero_caso": case_match.group(1).strip() if case_match else "",
            "prestador": prestador,
            "paciente_nombre": extracted_identity.patient_name,
            "paciente_identificacion": extracted_identity.patient_id,
            "servicios": services,
            "valor_estimado": (
                str(amount_match.group(1) or amount_match.group(2) or "").strip() if amount_match else ""
            ),
            "observaciones": "Prefactura identificada por número de caso sin documento de identidad del paciente.",
        }

    def _parse_prefactura_service_row(self, line: str) -> dict[str, str] | None:
        raw_line = re.sub(r"\s+", " ", str(line or "")).strip()
        if not raw_line:
            return None

        match = _PREFACTURA_SERVICE_ROW_PATTERN.match(raw_line)
        if match:
            return {
                "descripcion": re.sub(r"\s+", " ", str(match.group("descripcion") or "")).strip(),
                "cantidad": re.sub(r"\s+", "", str(match.group("cantidad") or "")).strip(),
                "precio": re.sub(r"\s+", "", str(match.group("precio") or "")).strip(),
                "valor": re.sub(r"\s+", "", str(match.group("valor") or "")).strip(),
                "codigo": str(match.group("codigo") or "").strip(),
            }

        tokens = raw_line.split()
        if len(tokens) < 8:
            return None
        if not _PREFACTURA_DATE_TOKEN_PATTERN.match(tokens[0]) or not _PREFACTURA_TIME_TOKEN_PATTERN.match(
            tokens[1]
        ):
            return None

        code = tokens[-1]
        percent = tokens[-2]
        if not _PREFACTURA_MONEY_TOKEN_PATTERN.match(code) and not _PREFACTURA_QUANTITY_TOKEN_PATTERN.match(
            percent
        ):
            candidate = self._build_prefactura_row_from_tokens(tokens)
            if candidate:
                return candidate
        return self._build_prefactura_row_from_tokens(tokens)

    def _build_prefactura_row_from_tokens(self, tokens: list[str]) -> dict[str, str] | None:
        trailing = tokens[2:]
        if len(trailing) < 6:
            return None

        code = trailing[-1]
        percent = trailing[-2]
        value = trailing[-3]
        if not _PREFACTURA_MONEY_TOKEN_PATTERN.match(value):
            return None
        if not _PREFACTURA_QUANTITY_TOKEN_PATTERN.match(percent):
            return None

        for price_token_count in (2, 1):
            qty_index = len(trailing) - (3 + price_token_count + 1)
            if qty_index < 1:
                continue
            qty_token = trailing[qty_index]
            if not _PREFACTURA_QUANTITY_TOKEN_PATTERN.match(qty_token):
                continue
            price_tokens = trailing[qty_index + 1 : qty_index + 1 + price_token_count]
            if not price_tokens or not all(
                _PREFACTURA_MONEY_TOKEN_PATTERN.match(item) for item in price_tokens
            ):
                continue
            descripcion_tokens = trailing[:qty_index]
            descripcion = " ".join(descripcion_tokens).strip()
            if not descripcion:
                continue
            return {
                "descripcion": descripcion,
                "cantidad": qty_token,
                "precio": "".join(price_tokens),
                "valor": value,
                "codigo": code,
            }
        return None

    def _extract_prefactura_provider(self, lines: list[str]) -> str:
        if not lines:
            return ""
        search_window = lines[:12]
        for index, line in enumerate(search_window):
            normalized = line.lower()
            if not any(marker in normalized for marker in _PREFACTURA_COMPANY_MARKERS):
                continue
            candidate_parts = [line]
            next_index = index + 1
            while next_index < len(search_window):
                next_line = search_window[next_index]
                next_normalized = next_line.lower()
                if _PREFACTURA_ADMIN_LINE_PATTERN.search(next_line):
                    break
                if any(marker in next_normalized for marker in _PREFACTURA_COMPANY_MARKERS) or any(
                    marker in next_normalized for marker in _PREFACTURA_COMPANY_SUFFIX_MARKERS
                ):
                    candidate_parts.append(next_line)
                    next_index += 1
                    continue
                break
            candidate = re.sub(r"\s+", " ", " ".join(candidate_parts)).strip()
            if candidate:
                return candidate
        return ""

    def _ensure_prefactura_historia_visibility(self, payload: dict[str, Any]) -> None:
        if str(payload.get("tipo_documento") or "").strip() != "historia_clinica":
            return
        if str(payload.get("ingestion_source") or "").strip() != "prefactura":
            return
        if self._has_usable_prefactura_historia_html(payload):
            return

        raw_text = str(payload.get("descripcion") or "").strip()
        if not raw_text:
            return

        escaped_text = html.escape(raw_text).replace("\n", "<br>")
        summary = html.escape(str((payload.get("analysis_structured") or {}).get("rs") or "").strip())
        details = [
            f"<p><b>Nombre del paciente</b></p><p>{html.escape(str(payload.get('nombre_paciente') or 'No especificado'))}</p>",
            f"<p><b>Número de caso</b></p><p>{html.escape(str(payload.get('case_number') or 'No especificado'))}</p>",
        ]
        if summary:
            details.append(f"<p><b>Resumen</b></p><p>{summary}</p>")
        details.append(f"<p><b>Texto clínico consolidado</b></p><p>{escaped_text}</p>")
        payload["analisis_html"] = "".join(details)

    def _has_usable_prefactura_historia_html(self, payload: dict[str, Any]) -> bool:
        html_content = str(payload.get("analisis_html") or "").strip()
        if not html_content:
            return False
        plain_text = _HTML_TAG_PATTERN.sub(" ", html_content)
        plain_text = re.sub(r"\s+", " ", plain_text).strip()
        if "Texto clínico consolidado" in plain_text:
            return True
        structured = payload.get("analysis_structured")
        has_structured_content = isinstance(structured, dict) and bool(structured)
        return has_structured_content and len(plain_text) >= 120

    def _generate_analysis(
        self, raw_text: str, detected_type: str, *, username: str = ""
    ) -> ClinicalAnalysisResult:
        if not str(raw_text or "").strip():
            return ClinicalAnalysisResult(
                analysis_structured=None,
                analysis_schema=None,
                analysis_schema_version=None,
                analysis_render_version=None,
                rendered_html="",
                error_analisis="texto_no_extraible",
                analysis_quality={
                    "status": "error",
                    "low_confidence": True,
                    "reason_codes": ["texto_no_extraible"],
                },
            )
        try:
            if detected_type == "historia_clinica":
                primary_error: Exception | None = None
                try:
                    historia_request = _load_historia_clinica_request_class()
                    extraer_historia_estructurada = getattr(
                        historia_request, "extraer_historia_estructurada", None
                    )
                    if callable(extraer_historia_estructurada):
                        extraer_kwargs = {"llm_router": self.llm_router}
                        try:
                            parameters = inspect.signature(extraer_historia_estructurada).parameters
                        except (TypeError, ValueError):
                            parameters = {}
                        if "cache_repository" in parameters:
                            extraer_kwargs["cache_repository"] = self.llm_task_cache_repository
                        if "username" in parameters:
                            extraer_kwargs["username"] = username
                        structured_result = extraer_historia_estructurada(raw_text, **extraer_kwargs)
                        return ClinicalAnalysisResult(
                            analysis_structured=structured_result.analysis_structured,
                            analysis_schema=structured_result.analysis_model.__class__.__name__,
                            analysis_schema_version=structured_result.analysis_model.schema_version,
                            analysis_render_version="v1",
                            rendered_html=structured_result.rendered_html,
                            analysis_provider=getattr(structured_result, "provider", None),
                            analysis_model_name=getattr(structured_result, "model", None),
                            analysis_route=dict(getattr(structured_result, "route_metadata", {}) or {}),
                            analysis_quality=dict(getattr(structured_result, "quality_metadata", {}) or {}),
                        )

                    analizar_historiaclinica = getattr(historia_request, "analizar_historiaclinica", None)
                    if callable(analizar_historiaclinica):
                        return ClinicalAnalysisResult(
                            analysis_structured=None,
                            analysis_schema=None,
                            analysis_schema_version=None,
                            analysis_render_version=None,
                            rendered_html=str(analizar_historiaclinica(raw_text) or ""),
                            analysis_quality={
                                "status": "legacy",
                                "low_confidence": False,
                                "reason_codes": [],
                            },
                        )

                    raise AttributeError(
                        "HistoriaClinicaRequest no expone extraer_historia_estructurada ni analizar_historiaclinica."
                    )
                except HistoriaSummaryCoverageError:
                    raise
                except Exception as exc:
                    primary_error = exc
                    logger.exception("Fallo analisis principal de historia clinica")
                    audit_logger.llm_event(
                        action="historia_clinica.analysis",
                        outcome="warning",
                        service="clinical_document_service",
                        provider="gemini" if self.client_gemini else "groq",
                        model="historia_primary",
                        metrics={"input_chars": len(raw_text or "")},
                        resource={"task_name": "historia_clinica_primary"},
                        error={"class": exc.__class__.__name__, "message": str(exc)},
                    )
                    structured_extraction_service = getattr(self, "structured_extraction_service", None)
                    if structured_extraction_service is not None:
                        try:
                            structured_result = structured_extraction_service.extract(
                                raw_text=raw_text,
                                document_type="historia_clinica",
                            )
                            return ClinicalAnalysisResult(
                                analysis_structured=structured_result.analysis_structured,
                                analysis_schema=structured_result.analysis_model.__class__.__name__,
                                analysis_schema_version=structured_result.analysis_model.schema_version,
                                analysis_render_version="v1",
                                rendered_html=structured_result.rendered_html,
                                error_analisis=None,
                                analysis_provider=getattr(structured_result, "provider", None),
                                analysis_model_name=getattr(structured_result, "model", None),
                                analysis_route=dict(getattr(structured_result, "route_metadata", {}) or {}),
                                analysis_quality=dict(
                                    getattr(structured_result, "quality_metadata", {}) or {}
                                ),
                            )
                        except Exception:
                            logger.exception("Fallo fallback estructurado de historia clinica")

                    legacy_html = procesar_documento_generico(
                        raw_text,
                        "historia_clinica",
                        self.client_groq,
                        self.client_gemini,
                        force_provider="gemini" if self.client_gemini else None,
                    )
                    return ClinicalAnalysisResult(
                        analysis_structured=None,
                        analysis_schema=None,
                        analysis_schema_version=None,
                        analysis_render_version=None,
                        rendered_html=str(legacy_html or ""),
                        error_analisis=self._public_analysis_error_message(primary_error, "historia_clinica"),
                        analysis_quality={
                            "status": "legacy",
                            "low_confidence": True,
                            "reason_codes": ["legacy_fallback"],
                        },
                    )

            structured_result = self.structured_extraction_service.extract(
                raw_text=raw_text,
                document_type=detected_type,
            )
            return ClinicalAnalysisResult(
                analysis_structured=structured_result.analysis_structured,
                analysis_schema=structured_result.analysis_model.__class__.__name__,
                analysis_schema_version=structured_result.analysis_model.schema_version,
                analysis_render_version="v1",
                rendered_html=structured_result.rendered_html,
                analysis_provider=getattr(structured_result, "provider", None),
                analysis_model_name=getattr(structured_result, "model", None),
                analysis_route=dict(getattr(structured_result, "route_metadata", {}) or {}),
                analysis_quality=dict(getattr(structured_result, "quality_metadata", {}) or {}),
            )
        except HistoriaSummaryCoverageError:
            raise
        except Exception as exc:
            logger.exception("Error generando analisis clinico para %s", detected_type)
            audit_logger.business_event(
                event_type="clinical.analysis_failed",
                action="generate_analysis",
                outcome="error",
                service="clinical_document_service",
                resource={"document_type": detected_type},
                error={"class": exc.__class__.__name__, "message": str(exc)},
            )
            public_error = self._public_analysis_error_message(exc, detected_type)
            return ClinicalAnalysisResult(
                analysis_structured=None,
                analysis_schema=None,
                analysis_schema_version=None,
                analysis_render_version=None,
                rendered_html="",
                error_analisis=public_error,
                analysis_quality={
                    "status": "error",
                    "low_confidence": True,
                    "reason_codes": ["analysis_exception"],
                },
            )

    def _public_analysis_error_message(self, error: Exception, document_type: str) -> str:
        if document_type == "factura":
            return "No fue posible validar la factura estructurada."
        if isinstance(error, LLMProviderError):
            return "No fue posible completar el análisis estructurado con Gemini."
        return str(error)

    def _enrich_factura(self, payload: dict[str, Any]) -> None:
        try:
            if isinstance(payload.get("factura_json"), dict) and payload["factura_json"]:
                if isinstance(payload.get("analysis_structured"), dict):
                    payload["analysis_structured"] = dump_clinical_model(
                        FacturaStructured.model_validate(payload["analysis_structured"])
                    )
                return
            factura_json = extraer_factura_json(payload)
            payload["factura_json"] = factura_json
            if isinstance(payload.get("analysis_structured"), dict):
                payload["analysis_structured"] = dump_clinical_model(
                    FacturaStructured.model_validate(payload["analysis_structured"])
                )
        except Exception as exc:
            logger.warning("Error extrayendo factura_json: %s", exc)
            payload["factura_json"] = {}
            payload["error_factura_json"] = str(exc)

    def _enrich_ayudas_diagnosticas(self, payload: dict[str, Any]) -> None:
        tipo = str(payload.get("tipo_documento") or "").strip()
        context = {
            "radiologia": [payload] if tipo == "radiologia" else [],
            "laboratorio": [payload] if tipo == "laboratorio" else [],
            "generico": [payload] if tipo == "generico" else [],
        }
        payload["ayudas_diagnosticas"] = build_ayudas_diagnosticas(context)
        payload["ayudas_diagnosticas_hallazgos"] = [
            alerta for ayuda in payload["ayudas_diagnosticas"] for alerta in (ayuda.get("alertas") or [])
        ]

    def _enrich_quirurgico(
        self,
        payload: dict[str, Any],
        *,
        deterministic_signals: DeterministicSignalSnapshot | None = None,
    ) -> None:
        try:
            procedimientos = extraer_procedimientos_quirurgicos(payload)
            payload["procedimientos_extraidos"] = procedimientos
            payload["total_procedimientos"] = len(procedimientos)
            payload["codigos_cups"], cups_error = self._assign_cups_list(
                procedimientos,
                fallback_inline=deterministic_signals.inline_cups if deterministic_signals else None,
            )
            if cups_error:
                payload["error_cups"] = cups_error
        except Exception as exc:
            logger.warning("Error al asignar codigos CUPS: %s", exc)
            payload["codigos_cups"] = []
            payload["error_cups"] = "No fue posible codificar CUPS automáticamente."

        try:
            diagnosticos = extraer_diagnosticos_quirurgicos(payload)
            payload["diagnosticos_extraidos"] = diagnosticos
            payload["total_diagnosticos"] = len(diagnosticos)
            payload["codigos_cie10"] = self._assign_cie10_list(diagnosticos)
        except Exception as exc:
            logger.warning("Error al asignar codigos CIE-10: %s", exc)
            payload["codigos_cie10"] = []
            payload["error_cie10"] = "No fue posible codificar CIE-10 automaticamente."

    def _enrich_historia(
        self,
        payload: dict[str, Any],
        *,
        deterministic_signals: DeterministicSignalSnapshot | None = None,
    ) -> None:
        procedimientos = extraer_procedimientos_historia(payload)
        payload["procedimientos_extraidos"] = procedimientos
        payload["medicamentos_extraidos"] = extraer_medicamentos_historia(payload)
        error_cie10 = None
        try:
            structured = payload.get("analysis_structured") or {}
            diagnosticos = structured.get("dx") if isinstance(structured, dict) else None
            if diagnosticos:
                payload["codigos_cie10"] = self._assign_cie10_list(
                    [
                        f"{item.get('c')} - {item.get('d')}".strip()
                        if item.get("c")
                        else str(item.get("d") or "")
                        for item in diagnosticos
                        if str(item.get("d") or "").strip()
                        and not (
                            not str(item.get("c") or "").strip() and _is_historia_symptom_only(item.get("d"))
                        )
                    ]
                )
            elif deterministic_signals and deterministic_signals.has_inline_cie10:
                payload["codigos_cie10"] = [
                    {
                        "diagnostico": item["descripcion"],
                        "codigo": item["codigo"],
                        "descripcion": item["descripcion"],
                    }
                    for item in deterministic_signals.inline_cie10
                ]
            else:
                df_cie = self.cie10_retriever.asignar_codigos_batch(payload.get("analisis_html", ""))
                payload["codigos_cie10"] = df_cie.to_dict(orient="records")
        except Exception as exc:
            logger.exception("Error al asignar codigos CIE-10 para historia clinica")
            payload["codigos_cie10"] = []
            error_cie10 = str(exc)

        error_cups = None
        try:
            payload["codigos_cups"], error_cups = self._assign_cups_list(
                procedimientos,
                fallback_inline=deterministic_signals.inline_cups if deterministic_signals else None,
            )
        except Exception as exc:
            logger.exception("Error al asignar codigos CUPS para historia clinica")
            payload["codigos_cups"] = []
            error_cups = str(exc)

        if error_cie10:
            payload["error_cie10"] = error_cie10
        if error_cups:
            payload["error_cups"] = error_cups

    def _assign_cie10_list(self, diagnosticos: list[str]) -> list[dict[str, str]]:
        cie10_list: list[dict[str, str]] = []
        cie10_index_disponible = getattr(self.cie10_retriever, "db", None) is not None
        diagnosticos_dedup, _ = deduplicate_texts(diagnosticos)
        diagnosticos_budgeted, _ = limit_items(diagnosticos_dedup, max_items=config.CIE10_RESOLUTION_K)

        for diagnostico in diagnosticos_budgeted:
            inline = self._extraer_cie10_inline(diagnostico)
            if inline:
                cie10_list.append(
                    {
                        "diagnostico": diagnostico,
                        "codigo": inline["codigo"],
                        "descripcion": inline["descripcion"],
                    }
                )
                continue

            if not cie10_index_disponible:
                cie10_list.append(
                    {
                        "diagnostico": diagnostico,
                        "codigo": "Pendiente",
                        "descripcion": "Codificacion automatica no disponible para este diagnostico.",
                    }
                )
                continue

            try:
                texto_cie10 = self.cie10_retriever.asignar_codigo_cie10(
                    diagnostico,
                    temperature=0.0,
                    k=config.CIE10_RESOLUTION_K,
                )
                codigos_parseados = _parse_cie10_lines(texto_cie10)
                if codigos_parseados:
                    codigo_principal = codigos_parseados[0]
                    cie10_list.append(
                        {
                            "diagnostico": diagnostico,
                            "codigo": codigo_principal["codigo"],
                            "descripcion": codigo_principal["descripcion"],
                        }
                    )
                else:
                    cie10_list.append(
                        {
                            "diagnostico": diagnostico,
                            "codigo": "Sin codigo",
                            "descripcion": "No se encontró codigo CIE-10 correspondiente",
                        }
                    )
            except Exception as exc:
                logger.warning(
                    "Error codificando CIE-10 para '%s...': %s",
                    diagnostico[:50],
                    exc,
                )
                cie10_list.append(
                    {
                        "diagnostico": diagnostico,
                        "codigo": "Pendiente",
                        "descripcion": "No se pudo codificar automáticamente este diagnostico.",
                    }
                )

        return cie10_list

    def _assign_cups_list(
        self,
        procedimientos: list[str],
        *,
        fallback_inline: list[dict[str, str]] | None = None,
    ) -> tuple[list[dict[str, str]], str | None]:
        codigos_cups: list[dict[str, str]] = []
        pendientes: list[str] = []
        procedimientos_dedup, _ = deduplicate_texts(procedimientos)
        procedimientos_budgeted, _ = limit_items(procedimientos_dedup, max_items=config.CIE10_RESOLUTION_K)

        for procedimiento in procedimientos_budgeted:
            inline = parse_inline_cups_entry(procedimiento)
            if inline:
                codigos_cups.append(inline)
            else:
                pendientes.append(procedimiento)

        if not procedimientos and fallback_inline:
            codigos_cups.extend(dict(item) for item in fallback_inline)
            return codigos_cups, None

        if not pendientes:
            return codigos_cups, None

        if self.cups_retriever is None:
            return codigos_cups, "No fue posible codificar CUPS automáticamente."

        df_cups = self._assign_cups_with_retry(pendientes)
        codigos_cups.extend(df_cups.to_dict(orient="records"))
        return codigos_cups, None

    def _assign_cups_with_retry(self, procedures: list[str]):
        delays = (0, 2, 8)
        last_error: Exception | None = None
        for delay in delays:
            if delay:
                self.sleep_fn(delay)
            try:
                return self.cups_retriever.asignar_codigos(procedures)
            except Exception as exc:
                last_error = exc
                message = str(exc).upper()
                if not any(token in message for token in ("503", "UNAVAILABLE", "TIMEOUT", "TEMPORAR")):
                    raise
        if last_error is not None:
            raise last_error
        raise RuntimeError("No fue posible codificar CUPS automáticamente.")

    def _extract_patient_name(self, analisis_html: str) -> str:
        try:
            return extraer_nombre_paciente(analisis_html)
        except Exception:
            logger.exception("Error extrayendo nombre_paciente desde analisis")
            return ""

    def _resolve_patient_name_from_sources(
        self,
        *,
        provided_patient_name: str,
        case_key: str,
        username: str,
        analisis_html: str,
        analysis_structured: dict[str, Any] | None,
        raw_text: str,
        deterministic_signals: DeterministicSignalSnapshot | None = None,
    ) -> str:
        resolved = str(provided_patient_name or "").strip()
        if resolved and resolved.lower() != "desconocido":
            return resolved

        normalized_case_key = str(case_key or "").strip()
        if normalized_case_key:
            try:
                case = self.get_user_case_context(username, normalized_case_key)
            except Exception:
                logger.exception("Error consultando contexto de caso para nombre_paciente")
                case = {}
            resolved = str(case.get("patient_name") or "").strip()
            if resolved and resolved.lower() != "desconocido":
                return resolved

        redaction_identity = extract_document_identity(raw_text, strategy="general")
        if "patient_name" in redaction_identity.redacted_identity_fields:
            return ""

        if isinstance(analysis_structured, dict):
            resolved = str(
                analysis_structured.get("pn") or analysis_structured.get("patient_name") or ""
            ).strip()
            if resolved and resolved.lower() != "desconocido":
                return resolved

        if deterministic_signals:
            resolved = str(deterministic_signals.patient_name or "").strip()
            if resolved and resolved.lower() != "desconocido":
                return resolved

        try:
            strategy = resolve_identity_strategy(
                str((deterministic_signals.document_type if deterministic_signals else "") or "")
            )
            resolved = extract_document_identity(raw_text, strategy=strategy).patient_name
        except Exception:
            logger.exception("Error extrayendo nombre_paciente desde texto crudo")
            resolved = ""
        if resolved and resolved.lower() != "desconocido":
            return resolved

        resolved = self._extract_patient_name(analisis_html)
        if resolved and resolved.lower() != "desconocido":
            return resolved

        return resolved or "desconocido"

    def _ensure_legacy_history(self, payload: dict[str, Any]) -> str:
        if str(payload.get("tipo_documento") or "").strip() != "historia_clinica":
            return ""

        existing_legacy_id = str(payload.get("legacy_historia_id") or "").strip()
        if existing_legacy_id:
            return existing_legacy_id

        analysis_document_id = payload.get("_id")
        existing_doc = None
        if analysis_document_id:
            existing_doc = self.mongo_storage.collection.find_one(
                {"analysis_document_id": str(analysis_document_id)}
            )
        if existing_doc and existing_doc.get("_id") is not None:
            legacy_id = str(existing_doc["_id"])
            self._set_legacy_history_id(payload, legacy_id)
            return legacy_id

        legacy_id = self.mongo_storage.guardar_analisis(
            descripcion=str(payload.get("descripcion") or ""),
            analisis_html=str(payload.get("analisis_html") or render_document_analysis_html(payload) or ""),
            codigos_cie10=list(payload.get("codigos_cie10") or []),
            codigos_cups=list(payload.get("codigos_cups") or []),
            usuario=str(payload.get("usuario") or ""),
            nombre_archivo=str(payload.get("nombre_archivo") or ""),
            nombre_paciente=str(payload.get("nombre_paciente") or "desconocido"),
            analysis_document_id=str(analysis_document_id or ""),
            case_key=str(payload.get("case_key") or ""),
            case_number=str(payload.get("case_number") or ""),
            batch_id=str(payload.get("batch_id") or ""),
            batch_file_id=str(payload.get("batch_file_id") or ""),
            ingestion_source=str(payload.get("ingestion_source") or "manual"),
            analysis_structured=payload.get("analysis_structured") or None,
            analysis_schema=str(payload.get("analysis_schema") or "") or None,
            analysis_schema_version=str(payload.get("analysis_schema_version") or "") or None,
            analysis_render_version=str(payload.get("analysis_render_version") or "") or None,
        )
        self._set_legacy_history_id(payload, legacy_id)
        return legacy_id

    def _set_legacy_history_id(self, payload: dict[str, Any], legacy_id: str) -> None:
        payload["legacy_historia_id"] = legacy_id
        if payload.get("_id") is None:
            return
        analysis_id = payload["_id"]
        if not ObjectId.is_valid(str(analysis_id)):
            return
        self.mongo_analyses.collection.update_one(
            {"_id": ObjectId(str(analysis_id))},
            {"$set": {"legacy_historia_id": legacy_id}},
        )

    def _extraer_cie10_inline(self, diagnostico: str) -> dict[str, str] | None:
        return parse_inline_cie10_entry(diagnostico)

    def _serialize(self, payload: dict[str, Any], *, reused: bool) -> dict[str, Any]:
        serialized = serialize_analysis_document(payload) or {}
        document_id = serialized.get("_id")
        if document_id and not isinstance(document_id, str):
            document_id = str(document_id)
        return {
            "id_documento": document_id,
            "nombre_paciente": serialized.get("nombre_paciente", "desconocido"),
            "tipo_documento": serialized.get("tipo_documento", "generico"),
            "mensaje": serialized.get("mensaje", ""),
            "analisis_html": serialized.get("analisis_html", ""),
            "analysis_structured": serialized.get("analysis_structured", {}),
            "analysis_schema": serialized.get("analysis_schema"),
            "analysis_schema_version": serialized.get("analysis_schema_version"),
            "analysis_render_version": serialized.get("analysis_render_version"),
            "analysis_provider": serialized.get("analysis_provider"),
            "analysis_model_name": serialized.get("analysis_model_name"),
            "analysis_route": serialized.get("analysis_route", {}),
            "analysis_quality": serialized.get("analysis_quality", {}),
            "extraction_metadata": serialized.get("extraction_metadata", {}),
            "classification_decision": serialized.get("classification_decision", {}),
            "historia_processing": serialized.get("historia_processing", {}),
            "codigos_cie10": serialized.get("codigos_cie10", []),
            "codigos_cups": serialized.get("codigos_cups", []),
            "datos_objetivos": serialized.get("datos_objetivos", {}),
            "factura_json": serialized.get("factura_json", {}),
            "reused": reused,
            "case_key": serialized.get("case_key", ""),
            "case_number": serialized.get("case_number", ""),
            "patient_id": serialized.get("patient_id", ""),
            "batch_id": serialized.get("batch_id", ""),
            "batch_file_id": serialized.get("batch_file_id", ""),
            "ingestion_source": serialized.get("ingestion_source", "manual"),
            "selected_document_type": serialized.get(
                "selected_document_type",
                serialized.get("tipo_documento", "generico"),
            ),
            "detected_document_type": serialized.get("detected_document_type", ""),
            "effective_document_type": serialized.get(
                "effective_document_type",
                serialized.get("tipo_documento", "generico"),
            ),
            "source_file_hash": serialized.get("source_file_hash", ""),
            "document_title": serialized.get("document_title", ""),
            "document_key": serialized.get("document_key", ""),
            "document_reference": serialized.get("document_reference", ""),
            "category_override_confirmed": bool(serialized.get("category_override_confirmed", False)),
            "override_audit": serialized.get("override_audit", {}),
            "legacy_historia_id": serialized.get("legacy_historia_id", ""),
            "error_analisis": serialized.get("error_analisis"),
            "error_cie10": serialized.get("error_cie10"),
            "error_cups": serialized.get("error_cups"),
            "fecha_analisis": serialized.get("fecha_analisis"),
            "case_resolution_status": serialized.get("case_resolution_status", "provisional"),
            "case_resolution_evidence": serialized.get("case_resolution_evidence", []),
            "review_required": bool(serialized.get("review_required", False)),
            "review_messages": serialized.get("review_messages", []),
        }

    @staticmethod
    def _normalize_document_type(value: Any) -> str:
        return str(value or "").strip()

    def _resolve_request_document_types(self, request: ClinicalDocumentRequest) -> tuple[str, str, str]:
        selected_type = self._normalize_document_type(request.selected_document_type)
        detected_type = self._normalize_document_type(request.detected_document_type or request.detected_type)
        effective_type = self._normalize_document_type(request.effective_document_type)
        if not selected_type:
            selected_type = detected_type or effective_type or "generico"
        if not detected_type:
            detected_type = effective_type or selected_type or "generico"
        if not effective_type:
            effective_type = detected_type or selected_type or "generico"
        return selected_type, detected_type, effective_type

    def _invalidate_case_epicrisis_cache(
        self,
        *,
        username: str,
        case_key: str,
        effective_document_type: str,
        case_number: str = "",
        patient_id: str = "",
        patient_name: str = "",
        ingestion_source: str = "manual",
        batch_id: str = "",
    ) -> None:
        normalized_case_key = self._normalize_document_type(case_key)
        normalized_type = self._normalize_document_type(effective_document_type)
        if not normalized_case_key or normalized_type not in _EPICRISIS_RELEVANT_DOCUMENT_TYPES:
            return
        delete_many = getattr(self.mongo_analyses.collection, "delete_many", None)
        if callable(delete_many):
            try:
                delete_many(
                    {
                        "usuario": username,
                        "tipo_documento": "epicrisis_case_cache",
                        "case_key": normalized_case_key,
                    }
                )
            except Exception as exc:
                logger.warning(
                    "No fue posible invalidar cache de epicrisis del caso %s: %s", normalized_case_key, exc
                )
        upsert_user_case = getattr(self.batch_case_repository, "upsert_user_case", None)
        if callable(upsert_user_case):
            try:
                normalized_ingestion_source = str(ingestion_source or "").strip().lower()
                normalized_batch_id = str(batch_id or "").strip()
                is_manual_runtime = (
                    normalized_ingestion_source in {"", "manual", "individual"} and not normalized_batch_id
                )
                payload = {
                    "epicrisis_status": "pendiente",
                    "epicrisis_job_id": "",
                    "epicrisis_error": "",
                    "epicrisis_url": f"/epicrisis?case_key={normalized_case_key}",
                    "updated_at": datetime.now(self.colombia_tz).isoformat(),
                }
                if is_manual_runtime:
                    payload.update(
                        {
                            "ready_for_epicrisis": True,
                            "epicrisis_rule_status": "",
                            "epicrisis_rule_findings": [],
                            "epicrisis_blocking_reason": "",
                            "epicrisis_missing_documents": [],
                            "epicrisis_last_rule_evaluation_at": "",
                            "runtime_origin": "manual",
                        }
                    )
                if str(case_number or "").strip():
                    payload["case_number"] = str(case_number).strip()
                if str(patient_id or "").strip():
                    payload["patient_id"] = str(patient_id).strip()
                if str(patient_name or "").strip():
                    payload["patient_name"] = str(patient_name).strip()
                upsert_user_case(
                    username,
                    normalized_case_key,
                    payload,
                )
            except Exception as exc:
                logger.warning(
                    "No fue posible sincronizar estado de epicrisis del caso %s: %s",
                    normalized_case_key,
                    exc,
                )

    def _find_latest_case_identity_document(self, username: str, case_key: str) -> dict[str, Any]:
        query = {
            "usuario": username,
            "case_key": case_key,
            "tipo_documento": {"$nin": ["epicrisis", "epicrisis_case_cache"]},
        }
        return self.mongo_analyses.collection.find_one(query, sort=[("fecha_analisis", -1)]) or {}

    def _should_preserve_existing_case_key(self, doc: dict[str, Any]) -> bool:
        current_case_key = str(doc.get("case_key") or "").strip()
        if not current_case_key:
            return False
        ingestion_source = str(doc.get("ingestion_source") or "").strip().lower()
        has_batch_id = bool(str(doc.get("batch_id") or "").strip())
        return ingestion_source in {"", "manual", "individual"} and not has_batch_id

    @staticmethod
    def _unique_str_list(values: list[Any]) -> list[str]:
        seen: set[str] = set()
        items: list[str] = []
        for raw in values or []:
            value = str(raw or "").strip()
            if not value or value in seen:
                continue
            seen.add(value)
            items.append(value)
        return items
