from __future__ import annotations

import re
import unicodedata
from collections import Counter
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from difflib import SequenceMatcher
from typing import Any

from app.llm.schemas import AntecedenteCategoria, AntecedenteClinico, AntecedenteEstado


_DATE = r"\d{1,2}[/-]\d{1,2}[/-]\d{2,4}"
_TABLE_ROW_PATTERN = re.compile(
    rf"^\s*(?P<date>{_DATE})\s+(?P<label>[A-ZÁÉÍÓÚÑÜ ]{{3,30}}?)\s{{2,}}(?P<value>.+?)\s*$",
    re.IGNORECASE,
)
_SECTION_START_PATTERN = re.compile(
    r"^\s*(?:ANTECEDENTES(?:\s+(?:PERSONALES|PERONSLES|CL[IÍ]NICOS|M[EÉ]DICOS|"
    r"QUIR[UÚ]RGICOS|PATOL[OÓ]GICOS|FAMILIARES|FARMACOL[OÓ]GICOS|T[OÓ]XICOS))?|"
    r"FECHA\s+TIPO\s+DESCRIPCI[OÓ]N\s+ANTECEDENTES)\s*:?\s*$",
    re.IGNORECASE,
)
_SECTION_END_PATTERN = re.compile(
    r"^\s*(?:EXAMEN\s+F[IÍ]SICO|REVISI[OÓ]N\s+POR\s+SISTEMAS|HALLAZGOS|DIAGN[OÓ]STICOS?|"
    r"AN[AÁ]LISIS(?:\s+Y\s+PLAN)?|PLAN|EVOLUCI[OÓ]N|ORDENES\s+M[EÉ]DICAS|PARACL[IÍ]NICOS|"
    r"MEDICAMENTOS?|TRATAMIENTO|MOTIVO\s+DE\s+CONSULTA|ENFERMEDAD\s+ACTUAL)\s*:?.*$",
    re.IGNORECASE,
)
_CATEGORY_SECTION_HEADER_PATTERN = re.compile(
    r"^\s*ANTECEDENTES\s+(?P<label>PERSONALES|PERONSLES|CL[IÍ]NICOS|M[EÉ]DICOS|"
    r"QUIR[UÚ]RGICOS|PATOL[OÓ]GICOS|FAMILIARES|FARMACOL[OÓ]GICOS|T[OÓ]XICOS)"
    r"\s*:?\s*(?P<value>.*)$",
    re.IGNORECASE,
)
_DENIAL_PATTERN = re.compile(
    r"^(?:NIEGA|NEGATIV[OA]S?|NO\s+(?:CONOCID[OA]S?|REFIERE|APLICA)|SIN\s+ANTECEDENTES?)\.?$",
    re.IGNORECASE,
)
_NOISE_PATTERN = re.compile(r"^[*_.\-–—:/\s]+$")
_HISTORY_CONTEXT_PATTERN = re.compile(
    r"\b(?:antecedentes?|alergias?|al[eé]rgic[oa]|previ[oa]s?|preexistente|cr[oó]nic[oa]|"
    r"habitual(?:es)?|hace\s+\d+|desde\s+hace|historia\s+de|familiares?|madre|padre|"
    r"quir[uú]rgic[oa]s?|patol[oó]gic[oa]s?|farmacol[oó]gic[oa]s?|t[oó]xic[oa]s?)\b",
    re.IGNORECASE,
)
_CURRENT_EPISODE_PATTERN = re.compile(
    r"\b(?:motivo\s+de\s+consulta|enfermedad\s+actual|consulta\s+por|ingresa\s+por|"
    r"se\s+realiza|se\s+administra|durante\s+la\s+hospitalizaci[oó]n|evoluci[oó]n|"
    r"examen\s+f[ií]sico|revisi[oó]n\s+por\s+sistemas|diagn[oó]stico\s+actual|"
    r"plan\s+de\s+manejo|egreso|alta)\b",
    re.IGNORECASE,
)
_ADMINISTRATIVE_PATTERN = re.compile(
    r"\b(?:documento|identificaci[oó]n|n[uú]mero\s+de\s+caso|m[eé]dico\s+tratante|"
    r"prestador|aseguradora|eps|admisi[oó]n|factura)\b",
    re.IGNORECASE,
)
_NARRATIVE_SECTION_PATTERN = re.compile(
    r"\b(?:motivo|examen\s+f[ií]sico|revisi[oó]n\s+por\s+sistemas|diagn[oó]sticos?|"
    r"procedimientos?|tratamiento|evoluci[oó]n|plan|egreso|hallazgos?)\s*:",
    re.IGNORECASE,
)
_MAX_DESCRIPTION_CHARACTERS = 160
_MAX_DESCRIPTION_WORDS = 25
_MAX_PHARMACOLOGICAL_CONTEXT_CHARACTERS = 120
_MAX_PHARMACOLOGICAL_CONTEXT_WORDS = 18
ANTECEDENT_POLICY_VERSION = "v3"
_ALLERGY_PREFIX_PATTERN = re.compile(
    r"^ALERGIAS?\s+A(?:\s+(?:LA|EL))?\s+|^AL[EÉ]RGIC[OA]S?\s+A(?:\s+(?:LA|EL))?\s+",
    re.IGNORECASE,
)
_INCOMPLETE_ALLERGY_PATTERN = re.compile(
    r"^(?:ALERGIAS?|AL[EÉ]RGIC[OA]S?)(?:\s+A(?:\s+(?:LA|EL))?)?$",
    re.IGNORECASE,
)
_CROSS_CATEGORY_STOPWORDS = {"a", "al", "con", "de", "del", "el", "en", "la", "las", "los", "y"}
_LABEL_PATTERN = re.compile(
    r"(?P<label>"
    r"PATOL[OÓ]GICOS?|AL[EÉ]RGICOS?|ALERGIAS?|QUIR[UÚ]RGICOS?|FARMACOL(?:[OÓ])?GICOS?|F[AÁ]RMACOS?|"
    r"T[OÓ]XICOS?|PERSONALES?|FAMILIARES?|SIST[EÉ]MICOS?|GINECO(?:L[OÓ]GICOS?)?|OBST[EÉ]TRICOS?|"
    r"GINECO[- ]?OBST[EÉ]TRICOS?|OTROS?|DIABETES|OBESIDAD|HIPERTENSI[OÓ]N(?:\s+ARTERIA[L]?)?|"
    r"TABAQUISMO|CARDIOPAT[IÍ]A|ASMA|RENAL\s+CR[OÓ]NICO|IVU|ENFERMEDADES\s+INMUNOL[OÓ]GICAS|"
    r"COLAGENESIS|EPOC|ALCOHOL(?:ISMO)?|SPA"
    r")\s*:",
    re.IGNORECASE,
)
_GLOBAL_ALLERGY_PATTERN = re.compile(r"^\s*ALERGIAS?\s*:?(?P<value>.*)$", re.IGNORECASE)
_UNTIMED_ROW_PATTERN = re.compile(
    r"^\s*(?P<label>PATOL[OÓ]GICOS?|AL[EÉ]RGICOS?|QUIR[UÚ]RGICOS?|"
    r"FARMACOL(?:[OÓ])?GICOS?|T[OÓ]XICOS?|PERSONALES?|FAMILIARES?|SIST[EÉ]MICOS?)"
    r"\s*:?\s+(?P<value>\S.+?)\s*$",
    re.IGNORECASE,
)

_CATEGORY_LABELS: tuple[tuple[str, AntecedenteCategoria], ...] = (
    ("alerg", "alergico"),
    ("quirurg", "quirurgico"),
    ("farmac", "farmacologico"),
    ("medicament", "farmacologico"),
    ("toxic", "toxico"),
    ("tabaqu", "toxico"),
    ("alcohol", "toxico"),
    ("spa", "toxico"),
    ("familiar", "familiar"),
    ("personal", "personal"),
    ("sistem", "sistemico"),
    ("gineco", "gineco_obstetrico"),
    ("obstetric", "gineco_obstetrico"),
    ("patolog", "patologico"),
)
_CHECKLIST_PATHOLOGICAL = {
    "diabetes",
    "obesidad",
    "hipertension",
    "hipertension arterial",
    "cardiopatia",
    "asma",
    "renal cronico",
    "ivu",
    "enfermedades inmunologicas",
    "colagenesis",
    "epoc",
}
_GENERIC_DESCRIPTION: dict[AntecedenteCategoria, str] = {
    "alergico": "Alergias",
    "patologico": "Patológicos",
    "quirurgico": "Quirúrgicos",
    "farmacologico": "Farmacológicos",
    "toxico": "Tóxicos",
    "personal": "Personales",
    "familiar": "Familiares",
    "sistemico": "Sistémicos",
    "gineco_obstetrico": "Gineco-obstétricos",
    "otro": "Otros",
}


@dataclass(frozen=True, slots=True)
class HistoriaAntecedentResolution:
    items: tuple[AntecedenteClinico, ...]
    accepted_procedure_keys: frozenset[str]
    reason_codes: tuple[str, ...]
    candidates_total: int
    accepted_explicit: int
    accepted_inferred: int
    rejected: int
    negatives_discarded: int
    pharmacological_contexts_accepted: int = 0
    pharmacological_contexts_omitted: int = 0
    pharmacological_contexts_discarded: int = 0
    rejected_by_reason: tuple[tuple[str, int], ...] = ()
    policy_version: str = ANTECEDENT_POLICY_VERSION

    @property
    def should_retry(self) -> bool:
        return bool(self.reason_codes)

    def to_metrics(self, *, retries: int = 0) -> dict[str, Any]:
        return {
            "candidates_total": self.candidates_total,
            "accepted": len(self.items),
            "accepted_explicit": self.accepted_explicit,
            "accepted_inferred": self.accepted_inferred,
            "rejected": self.rejected,
            "rejected_by_reason": dict(self.rejected_by_reason),
            "negatives_discarded": self.negatives_discarded,
            "pharmacological_contexts_accepted": self.pharmacological_contexts_accepted,
            "pharmacological_contexts_omitted": self.pharmacological_contexts_omitted,
            "pharmacological_contexts_discarded": self.pharmacological_contexts_discarded,
            "quality_retries": max(0, int(retries)),
            "reason_codes": list(self.reason_codes),
            "policy_version": self.policy_version,
        }


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


def _clean(value: Any) -> str:
    text = re.sub(r"[*_]+", " ", str(value or ""))
    return re.sub(r"\s+", " ", text).strip(" ,.;:-/\t\r\n")


def _canonical_description(category: AntecedenteCategoria, value: Any) -> str:
    description = _clean(value)
    if category == "alergico":
        description = _ALLERGY_PREFIX_PATTERN.sub("", description).strip()
    return description


def _category(label: str) -> AntecedenteCategoria:
    normalized = _ascii(label)
    if normalized in _CHECKLIST_PATHOLOGICAL:
        return "patologico"
    for token, category in _CATEGORY_LABELS:
        if token in normalized:
            return category
    return "otro"


def _is_category_label(label: str) -> bool:
    normalized = _ascii(label)
    return normalized not in _CHECKLIST_PATHOLOGICAL and normalized not in {
        "tabaquismo",
        "alcohol",
        "alcoholismo",
        "spa",
    }


def _state(value: str) -> AntecedenteEstado:
    return "negado" if _DENIAL_PATTERN.fullmatch(_clean(value)) else "presente"


def _description_for_denial(label: str, category: AntecedenteCategoria) -> str:
    return _GENERIC_DESCRIPTION[category] if _is_category_label(label) else _clean(label).capitalize()


def _split_positive_values(value: str, category: AntecedenteCategoria) -> list[str]:
    cleaned = _canonical_description(category, value)
    if not cleaned:
        return []
    separator = r"\s*[,;]\s*"
    if category == "alergico":
        separator = r"\s*(?:[,;]|\s+-\s+|\s+Y\s+)\s*"
    if category not in {"alergico", "patologico", "quirurgico", "sistemico", "personal"}:
        return [cleaned]
    return [
        part for part in (_clean(item) for item in re.split(separator, cleaned, flags=re.IGNORECASE)) if part
    ]


def _make_items(
    *,
    label: str,
    value: str,
    date: str | None,
    page: int,
    evidence: str,
) -> list[AntecedenteClinico]:
    category = _category(label)
    state = _state(value)
    if state == "negado":
        descriptions = [_description_for_denial(label, category)]
    else:
        descriptions = _split_positive_values(value, category)
    return [
        AntecedenteClinico(
            categoria=category,
            descripcion=description,
            estado=state,
            fecha=date,
            pagina=page,
            evidencia=_clean(evidence)[:240] or None,
        )
        for description in descriptions
        if description and not _NOISE_PATTERN.fullmatch(description)
    ]


def _parse_labeled_segment(segment: str, *, page: int, date: str | None = None) -> list[AntecedenteClinico]:
    matches = list(_LABEL_PATTERN.finditer(segment))
    items: list[AntecedenteClinico] = []
    for index, match in enumerate(matches):
        value_end = matches[index + 1].start() if index + 1 < len(matches) else len(segment)
        value = _clean(segment[match.end() : value_end])
        if not value:
            continue
        items.extend(
            _make_items(
                label=match.group("label"),
                value=value,
                date=date,
                page=page,
                evidence=segment,
            )
        )
    return items


def _column_segments(line: str) -> list[tuple[int, str]]:
    return [
        (match.start(), match.group().strip())
        for match in re.finditer(r"\S(?:.*?\S)?(?=\s{8,}|$)", line.rstrip())
        if match.group().strip()
    ]


def _column_anchors(lines: Sequence[str]) -> tuple[int, ...]:
    positions: list[int] = []
    for line in lines:
        if _TABLE_ROW_PATTERN.match(line):
            continue
        positions.extend(match.start() for match in _LABEL_PATTERN.finditer(line))
    if not positions:
        return (0,)
    left = min(positions)
    right_positions = sorted(position for position in positions if position - left >= 24)
    if not right_positions:
        return (left,)
    return (left, right_positions[len(right_positions) // 2])


def _column_key(position: int, anchors: Sequence[int]) -> int:
    return min(range(len(anchors)), key=lambda index: abs(position - anchors[index]))


def _append_continuation(item: AntecedenteClinico, continuation: str, evidence: str) -> AntecedenteClinico:
    return item.model_copy(
        update={
            "descripcion": _clean(f"{item.descripcion} {continuation}"),
            "evidencia": _clean(f"{item.evidencia or ''} {evidence}")[:240] or None,
        }
    )


def _parse_section_lines(lines: list[str], *, page: int) -> list[AntecedenteClinico]:
    items: list[AntecedenteClinico] = []
    pending_by_column: dict[int, int] = {}
    anchors = _column_anchors(lines)
    for line in lines:
        table_match = _TABLE_ROW_PATTERN.match(line)
        if table_match:
            created = _make_items(
                label=table_match.group("label"),
                value=table_match.group("value"),
                date=table_match.group("date"),
                page=page,
                evidence=line,
            )
            if created:
                items.extend(created)
                pending_by_column[len(anchors) - 1] = len(items) - 1
            continue

        untimed_match = _UNTIMED_ROW_PATTERN.match(line)
        if untimed_match and len(list(_LABEL_PATTERN.finditer(line))) <= 1:
            created = _make_items(
                label=untimed_match.group("label"),
                value=untimed_match.group("value"),
                date=None,
                page=page,
                evidence=line,
            )
            if created:
                items.extend(created)
                pending_by_column[_column_key(untimed_match.start(), anchors)] = len(items) - 1
            continue

        label_matches = list(_LABEL_PATTERN.finditer(line))
        if label_matches:
            for index, match in enumerate(label_matches):
                value_end = (
                    label_matches[index + 1].start()
                    if index + 1 < len(label_matches)
                    else len(line)
                )
                value = _clean(line[match.end() : value_end])
                if not value:
                    continue
                created = _make_items(
                    label=match.group("label"),
                    value=value,
                    date=None,
                    page=page,
                    evidence=line,
                )
                if not created:
                    continue
                items.extend(created)
                pending_by_column[_column_key(match.start(), anchors)] = len(items) - 1
            continue

        segments = _column_segments(line)
        for position, segment in segments:
            if _SECTION_START_PATTERN.match(segment) or not _clean(segment):
                continue
            created = _parse_labeled_segment(segment, page=page)
            column = _column_key(position, anchors)
            if created:
                items.extend(created)
                pending_by_column[column] = len(items) - 1
                continue
            if column not in pending_by_column or re.search(r"\b(?:FECHA|TIPO|DESCRIPCI[OÓ]N)\b", segment):
                continue
            if ":" in segment:
                continue
            continuation = _clean(segment)
            if not continuation or _NOISE_PATTERN.fullmatch(continuation):
                continue
            item_index = pending_by_column[column]
            if items[item_index].estado == "negado":
                continue
            if re.match(r"^(?:ANESTESIA|EXFUMADOR|FUMADOR|SOBRE?PESO)\b", continuation, re.IGNORECASE):
                previous = items[item_index]
                items.append(
                    previous.model_copy(
                        update={
                            "descripcion": continuation,
                            "evidencia": _clean(line)[:240] or None,
                        }
                    )
                )
                pending_by_column[column] = len(items) - 1
                continue
            items[item_index] = _append_continuation(items[item_index], continuation, line)
    return items


def _section_blocks(page_text: str) -> list[list[str]]:
    blocks: list[list[str]] = []
    current: list[str] | None = None
    for line in page_text.splitlines():
        normalized = line.strip()
        if _SECTION_START_PATTERN.match(normalized):
            if current:
                blocks.append(current)
            current = []
            continue
        if current is None:
            continue
        if _SECTION_END_PATTERN.match(normalized):
            if current:
                blocks.append(current)
            current = None
            continue
        current.append(line)
        if len(current) >= 80:
            blocks.append(current)
            current = None
    if current:
        blocks.append(current)
    return blocks


def _similar(left: AntecedenteClinico, right: AntecedenteClinico) -> bool:
    if left.categoria != right.categoria or left.estado != right.estado:
        return False
    left_key = re.sub(r"\btotal\b", "", _ascii(left.descripcion))
    right_key = re.sub(r"\btotal\b", "", _ascii(right.descripcion))
    for source, target in (("amoxacilina", "amoxicilina"), ("tamslosina", "tamsulosina")):
        left_key = left_key.replace(source, target)
        right_key = right_key.replace(source, target)
    left_key = re.sub(r"[^a-z0-9]+", " ", left_key).strip()
    right_key = re.sub(r"[^a-z0-9]+", " ", right_key).strip()
    if left_key == right_key:
        return True
    if min(len(left_key), len(right_key)) >= 10 and (left_key in right_key or right_key in left_key):
        return True
    return SequenceMatcher(None, left_key, right_key).ratio() >= 0.92


def _prefer_detail(left: AntecedenteClinico, right: AntecedenteClinico) -> AntecedenteClinico:
    if (
        left.categoria == right.categoria == "farmacologico"
        and _search_key(left.descripcion) in _search_key(right.descripcion)
    ):
        preferred = left
    elif (
        left.categoria == right.categoria == "farmacologico"
        and _search_key(right.descripcion) in _search_key(left.descripcion)
    ):
        preferred = right
    elif left.origen == "explicito" and right.origen != "explicito":
        preferred = left
    elif right.origen == "explicito" and left.origen != "explicito":
        preferred = right
    else:
        preferred = right if len(right.descripcion) > len(left.descripcion) else left
    other = left if preferred is right else right
    updates: dict[str, Any] = {}
    if not preferred.fecha and other.fecha:
        updates["fecha"] = other.fecha
    if not preferred.pagina and other.pagina:
        updates["pagina"] = other.pagina
    if not preferred.evidencia and other.evidencia:
        updates["evidencia"] = other.evidencia
    if not preferred.contexto and other.contexto:
        updates["contexto"] = other.contexto
    if not preferred.origen and other.origen:
        updates["origen"] = other.origen
    if not preferred.confianza and other.confianza:
        updates["confianza"] = other.confianza
    if preferred.discordante or other.discordante:
        updates["discordante"] = True
    return preferred.model_copy(update=updates)


def consolidate_antecedentes(
    items: Iterable[AntecedenteClinico | dict[str, Any]],
) -> list[AntecedenteClinico]:
    consolidated: list[AntecedenteClinico] = []
    for raw in items:
        try:
            item = raw if isinstance(raw, AntecedenteClinico) else AntecedenteClinico.model_validate(raw)
        except (TypeError, ValueError):
            continue
        description = _canonical_description(item.categoria, item.descripcion)
        item = item.model_copy(update={"descripcion": description})
        if not item.descripcion:
            continue
        existing_index = next(
            (index for index, candidate in enumerate(consolidated) if _similar(candidate, item)),
            None,
        )
        if existing_index is None:
            consolidated.append(item)
        else:
            consolidated[existing_index] = _prefer_detail(consolidated[existing_index], item)

    for category in {item.categoria for item in consolidated}:
        positives = [
            item for item in consolidated if item.categoria == category and item.estado == "presente"
        ]
        negatives = [item for item in consolidated if item.categoria == category and item.estado == "negado"]
        generic_key = _ascii(_GENERIC_DESCRIPTION[category])
        conflicting_descriptions = (
            {
                _ascii(negative.descripcion)
                for negative in negatives
                if _ascii(negative.descripcion) == generic_key
            }
            if positives
            else set()
        )
        for positive in positives:
            if any(_ascii(negative.descripcion) == _ascii(positive.descripcion) for negative in negatives):
                conflicting_descriptions.add(_ascii(positive.descripcion))
        if not conflicting_descriptions:
            continue
        for index, item in enumerate(consolidated):
            if item.categoria != category:
                continue
            if (
                generic_key in conflicting_descriptions
                or _ascii(item.descripcion) in conflicting_descriptions
            ):
                consolidated[index] = item.model_copy(update={"discordante": True})

    order = {
        "alergico": 0,
        "patologico": 1,
        "quirurgico": 2,
        "farmacologico": 3,
        "toxico": 4,
        "personal": 5,
        "familiar": 6,
        "sistemico": 7,
        "gineco_obstetrico": 8,
        "otro": 9,
    }
    return sorted(
        consolidated,
        key=lambda item: (item.estado == "negado", order[item.categoria], _ascii(item.descripcion)),
    )


def extract_historia_antecedentes_structured(
    raw_text: str,
    *,
    initial: Iterable[AntecedenteClinico | dict[str, Any]] = (),
) -> list[AntecedenteClinico]:
    items: list[AntecedenteClinico | dict[str, Any]] = list(initial)
    for page_number, page_text in enumerate(re.split(r"\f", str(raw_text or "")), start=1):
        for block in _section_blocks(page_text):
            items.extend(_parse_section_lines(block, page=page_number))

        active_category_label = ""
        category_lines = 0
        for line in page_text.splitlines():
            category_header = _CATEGORY_SECTION_HEADER_PATTERN.match(line)
            if category_header:
                active_category_label = category_header.group("label")
                category_lines = 0
                inline_value = _clean(category_header.group("value"))
                if inline_value:
                    items.extend(
                        _make_items(
                            label=active_category_label,
                            value=inline_value,
                            date=None,
                            page=page_number,
                            evidence=line,
                        )
                    )
                continue
            if not active_category_label:
                continue
            if not line.strip() or _SECTION_END_PATTERN.match(line):
                active_category_label = ""
                continue
            if _SECTION_START_PATTERN.match(line) or _CATEGORY_SECTION_HEADER_PATTERN.match(line):
                active_category_label = ""
                continue
            if ":" in line:
                active_category_label = ""
                continue
            items.extend(
                _make_items(
                    label=active_category_label,
                    value=line,
                    date=None,
                    page=page_number,
                    evidence=line,
                )
            )
            category_lines += 1
            if category_lines >= 10:
                active_category_label = ""

        # Las alergias suelen aparecer antes de la tabla formal de antecedentes.
        for line in page_text.splitlines():
            allergy_match = _GLOBAL_ALLERGY_PATTERN.match(line)
            if not allergy_match:
                continue
            value = _clean(allergy_match.group("value"))
            if value:
                items.extend(
                    _make_items(
                        label="ALERGIAS",
                        value=value,
                        date=None,
                        page=page_number,
                        evidence=line,
                    )
                )
    return consolidate_antecedentes(items)


def _search_key(value: Any) -> str:
    return re.sub(r"[^a-z0-9]+", " ", _ascii(value)).strip()


def antecedent_description_key(value: Any) -> str:
    return _search_key(value)


def _source_occurrences(
    raw_text: str,
    description: str,
    evidence: str = "",
) -> list[tuple[int, str, bool, str]]:
    description_key = _search_key(description)
    evidence_key = _search_key(evidence)
    occurrences: list[tuple[int, str, bool, str]] = []
    for page_number, page_text in enumerate(re.split(r"\f", str(raw_text or "")), start=1):
        lines = page_text.splitlines()
        in_section = False
        for index, raw_line in enumerate(lines):
            line = _clean(raw_line)
            if _SECTION_START_PATTERN.match(line):
                in_section = True
                continue
            if in_section and _SECTION_END_PATTERN.match(line):
                in_section = False
            line_key = _search_key(line)
            description_matches = bool(description_key and description_key in line_key)
            evidence_matches = bool(
                evidence_key
                and len(evidence_key) >= 8
                and (evidence_key in line_key or line_key in evidence_key)
                and (
                    description_key in evidence_key
                    or SequenceMatcher(None, description_key, evidence_key).ratio() >= 0.55
                )
            )
            if not description_matches and not evidence_matches:
                continue
            context = _clean(" ".join(lines[max(0, index - 1) : min(len(lines), index + 2)]))
            occurrences.append((page_number, context[:240], in_section, line))
    return occurrences


def _candidate_reason(
    item: AntecedenteClinico,
    *,
    raw_text: str,
    force_explicit: bool,
) -> tuple[AntecedenteClinico | None, str | None]:
    description = _canonical_description(item.categoria, item.descripcion)
    if not description:
        return None, "antecedents_ungrounded"
    if item.categoria == "alergico" and _INCOMPLETE_ALLERGY_PATTERN.fullmatch(description):
        return None, "antecedents_incomplete_value"
    if (
        len(description) > _MAX_DESCRIPTION_CHARACTERS
        or len(description.split()) > _MAX_DESCRIPTION_WORDS
        or _NARRATIVE_SECTION_PATTERN.search(description)
    ):
        return None, "antecedents_contaminated_narrative"
    if _ADMINISTRATIVE_PATTERN.search(description):
        return None, "antecedents_contaminated_narrative"

    occurrences = _source_occurrences(raw_text, description, str(item.evidencia or ""))
    if not occurrences:
        if force_explicit and item.pagina and item.evidencia:
            return (
                item.model_copy(
                    update={
                        "descripcion": description,
                        "estado": "presente",
                        "evidencia": _clean(item.evidencia)[:240],
                        "origen": "explicito",
                        "confianza": "alta",
                    }
                ),
                None,
            )
        return None, "antecedents_ungrounded"

    page, localized_evidence, in_section, local_line = next(
        (
            occurrence
            for occurrence in occurrences
            if occurrence[2] or _HISTORY_CONTEXT_PATTERN.search(occurrence[3])
        ),
        occurrences[0],
    )
    has_history_context = bool(_HISTORY_CONTEXT_PATTERN.search(local_line))
    has_current_context = bool(_CURRENT_EPISODE_PATTERN.search(local_line))
    explicit = force_explicit or in_section or has_history_context
    if has_current_context and not explicit:
        return None, "antecedents_current_episode_content"
    if not explicit and not (item.origen == "inferido" and item.confianza == "alta"):
        return None, "antecedents_ambiguous_temporality"

    origin = "explicito" if explicit else "inferido"
    already_resolved = bool(
        item.pagina
        and item.evidencia
        and item.origen in {"explicito", "inferido"}
        and item.confianza == "alta"
    )
    return (
        item.model_copy(
            update={
                "descripcion": description,
                "estado": "presente",
                "pagina": item.pagina if already_resolved else page,
                "evidencia": _clean(item.evidencia)[:240] if already_resolved else localized_evidence,
                "origen": origin,
                "confianza": "alta",
                "discordante": item.discordante,
            }
        ),
        None,
    )


def _validated_pharmacological_context(
    item: AntecedenteClinico,
    *,
    raw_text: str,
) -> tuple[str | None, bool]:
    context = _clean(item.contexto)
    if item.categoria != "farmacologico" or not context:
        return None, False
    if (
        len(context) > _MAX_PHARMACOLOGICAL_CONTEXT_CHARACTERS
        or len(context.split()) > _MAX_PHARMACOLOGICAL_CONTEXT_WORDS
        or _NARRATIVE_SECTION_PATTERN.search(context)
        or _ADMINISTRATIVE_PATTERN.search(context)
    ):
        return None, True

    context_key = _search_key(context)
    raw_key = _search_key(raw_text)
    evidence_key = _search_key(item.evidencia)
    evidence_is_source = bool(
        evidence_key
        and raw_key
        and (evidence_key in raw_key or raw_key in evidence_key)
    )
    grounded = bool(
        context_key
        and (
            context_key in raw_key
            or (context_key in evidence_key and evidence_is_source)
        )
    )
    return (context if grounded else None), not grounded


def _meaningful_tokens(value: Any) -> set[str]:
    return {
        token
        for token in _search_key(value).split()
        if len(token) >= 3 and token not in _CROSS_CATEGORY_STOPWORDS
    }


def _remove_cross_category_contamination(
    items: Sequence[AntecedenteClinico],
) -> tuple[list[AntecedenteClinico], list[AntecedenteClinico]]:
    removed_indexes: set[int] = set()
    item_tokens = [_meaningful_tokens(item.descripcion) for item in items]
    for index, candidate in enumerate(items):
        candidate_tokens = item_tokens[index]
        if len(candidate_tokens) < 3:
            continue
        same_category_parts = [
            item_tokens[other_index]
            for other_index, other in enumerate(items)
            if other_index != index
            and other.categoria == candidate.categoria
            and item_tokens[other_index] < candidate_tokens
        ]
        cross_category_parts = [
            item_tokens[other_index]
            for other_index, other in enumerate(items)
            if other_index != index
            and other.categoria != candidate.categoria
            and item_tokens[other_index]
            and item_tokens[other_index] <= candidate_tokens
        ]
        if same_category_parts and cross_category_parts:
            removed_indexes.add(index)
    return (
        [item for index, item in enumerate(items) if index not in removed_indexes],
        [item for index, item in enumerate(items) if index in removed_indexes],
    )


def resolve_historia_antecedentes(
    raw_text: str,
    *,
    structured_candidates: Iterable[AntecedenteClinico | dict[str, Any]] = (),
    procedure_candidates: Iterable[Any] = (),
    legacy_flat_candidates: Iterable[Any] = (),
) -> HistoriaAntecedentResolution:
    deterministic = extract_historia_antecedentes_structured(raw_text)
    deterministic_negatives = [item for item in deterministic if item.estado == "negado"]
    prepared: list[tuple[AntecedenteClinico, bool, bool]] = []
    for raw in structured_candidates:
        try:
            item = raw if isinstance(raw, AntecedenteClinico) else AntecedenteClinico.model_validate(raw)
        except (TypeError, ValueError):
            continue
        prepared.append((item, False, False))
    prepared.extend((item, True, False) for item in deterministic)
    for raw in procedure_candidates:
        description = getattr(raw, "descripcion", None)
        if description is None and isinstance(raw, dict):
            description = raw.get("d") or raw.get("descripcion")
        if not str(description or "").strip():
            continue
        prepared.append(
            (
                AntecedenteClinico(
                    categoria="quirurgico",
                    descripcion=str(description),
                    estado="presente",
                    origen="inferido",
                    confianza="alta",
                ),
                False,
                True,
            )
        )

    accepted: list[AntecedenteClinico] = []
    accepted_procedure_candidates: set[str] = set()
    reasons: list[str] = []
    deferred_reasons: list[tuple[str, AntecedenteClinico]] = []
    rejection_counts: Counter[str] = Counter()
    rejected = 0
    negatives_discarded = 0
    pharmacological_contexts_discarded = 0
    for item, force_explicit, is_procedure in prepared:
        if item.estado == "negado":
            negatives_discarded += 1
            continue
        resolved, reason = _candidate_reason(item, raw_text=raw_text, force_explicit=force_explicit)
        if resolved is None:
            rejected += 1
            if reason:
                rejection_counts[reason] += 1
                if reason in {
                    "antecedents_incomplete_value",
                    "antecedents_cross_column_contamination",
                    "antecedents_ungrounded",
                }:
                    deferred_reasons.append((reason, item))
                else:
                    reasons.append(reason)
            continue
        context, context_discarded = _validated_pharmacological_context(
            resolved,
            raw_text=raw_text,
        )
        if context_discarded:
            pharmacological_contexts_discarded += 1
        resolved = resolved.model_copy(update={"contexto": context})
        accepted.append(resolved)
        if is_procedure:
            accepted_procedure_candidates.add(_search_key(resolved.descripcion))

    consolidated_with_negatives = consolidate_antecedentes([*accepted, *deterministic_negatives])
    positive_items = [item for item in consolidated_with_negatives if item.estado == "presente"]
    filtered_items, cross_category_items = _remove_cross_category_contamination(positive_items)
    if cross_category_items:
        rejected += len(cross_category_items)
        rejection_counts["antecedents_cross_column_contamination"] += len(cross_category_items)
        deferred_reasons.extend(
            ("antecedents_cross_column_contamination", item)
            for item in cross_category_items
        )
    consolidated = tuple(filtered_items)
    for reason, rejected_item in deferred_reasons:
        has_category_replacement = any(
            item.categoria == rejected_item.categoria for item in consolidated
        )
        has_equivalent_replacement = any(
            _similar(item, rejected_item) for item in consolidated
        )
        if reason == "antecedents_ungrounded" and has_equivalent_replacement:
            continue
        if reason in {
            "antecedents_incomplete_value",
            "antecedents_cross_column_contamination",
        } and has_category_replacement:
            continue
        reasons.append(reason)

    accepted_keys = {_search_key(item.descripcion) for item in consolidated}
    accepted_procedure_keys = accepted_procedure_candidates & accepted_keys
    legacy_values = [_clean(item) for item in legacy_flat_candidates if _clean(item)]
    mismatched_legacy = [
        value
        for value in legacy_values
        if not any(_legacy_projection_matches_item(value, item) for item in consolidated)
    ]
    if any(
        len(item) > _MAX_DESCRIPTION_CHARACTERS
        or len(item.split()) > _MAX_DESCRIPTION_WORDS
        or _NARRATIVE_SECTION_PATTERN.search(item)
        for item in legacy_values
    ):
        reasons.append("antecedents_contaminated_narrative")
    if mismatched_legacy:
        reasons.append("antecedents_inconsistent_flat_projection")
        rejected += len(mismatched_legacy)

    unique_reasons = tuple(dict.fromkeys(reasons))
    return HistoriaAntecedentResolution(
        items=consolidated,
        accepted_procedure_keys=frozenset(accepted_procedure_keys),
        reason_codes=unique_reasons,
        candidates_total=len(prepared) + len(legacy_values),
        accepted_explicit=sum(item.origen == "explicito" for item in consolidated),
        accepted_inferred=sum(item.origen == "inferido" for item in consolidated),
        rejected=rejected,
        negatives_discarded=negatives_discarded,
        pharmacological_contexts_accepted=sum(
            item.categoria == "farmacologico" and bool(item.contexto) for item in consolidated
        ),
        pharmacological_contexts_omitted=sum(
            item.categoria == "farmacologico" and not item.contexto for item in consolidated
        ),
        pharmacological_contexts_discarded=pharmacological_contexts_discarded,
        rejected_by_reason=tuple(sorted(rejection_counts.items())),
    )


def build_historia_antecedent_context(raw_text: str, *, max_characters: int = 8000) -> str:
    selected: list[str] = []
    seen: set[str] = set()
    for page_number, page_text in enumerate(re.split(r"\f", str(raw_text or "")), start=1):
        lines = page_text.splitlines()
        in_section = False
        for index, raw_line in enumerate(lines):
            line = _clean(raw_line)
            if _SECTION_START_PATTERN.match(line):
                in_section = True
            elif in_section and _SECTION_END_PATTERN.match(line):
                in_section = False
            if not in_section and not _HISTORY_CONTEXT_PATTERN.search(line):
                continue
            context = _clean(" ".join(lines[max(0, index - 1) : min(len(lines), index + 2)]))
            if not context:
                continue
            value = f"[página {page_number}] {context}"
            key = _search_key(value)
            if key in seen:
                continue
            seen.add(key)
            selected.append(value)
            if sum(len(item) + 1 for item in selected) >= max(500, max_characters):
                break
        if sum(len(item) + 1 for item in selected) >= max(500, max_characters):
            break
    return "\n".join(selected)[: max(500, max_characters)]


def _flat_projection(item: AntecedenteClinico) -> str:
    if item.categoria == "alergico":
        return f"ALERGIA: {item.descripcion}"
    if item.categoria == "farmacologico":
        context = _clean(item.contexto)
        suffix = f" — {context}" if context else ""
        return f"FARMACOLÓGICO: {item.descripcion}{suffix}"
    return item.descripcion


def _legacy_projection_matches_item(value: Any, item: AntecedenteClinico) -> bool:
    value_key = _search_key(value)
    if value_key in {_search_key(item.descripcion), _search_key(_flat_projection(item))}:
        return True
    if item.categoria == "alergico":
        unlabeled = re.sub(r"^\s*ALERGIAS?\s*:\s*", "", str(value or ""), flags=re.IGNORECASE)
        return _search_key(unlabeled) == _search_key(item.descripcion)
    if item.categoria == "farmacologico":
        unlabeled = re.sub(
            r"^\s*FARMACOL[OÓ]GICO\s*:\s*",
            "",
            str(value or ""),
            flags=re.IGNORECASE,
        )
        medication = re.split(r"\s+[—–-]\s+", unlabeled, maxsplit=1)[0]
        return _search_key(medication) == _search_key(item.descripcion)
    return False


def antecedentes_to_flat_list(items: Iterable[AntecedenteClinico | dict[str, Any]]) -> list[str]:
    return [_flat_projection(item) for item in consolidate_antecedentes(items) if item.estado == "presente"]


def antecedentes_to_prompt_text(items: Iterable[AntecedenteClinico | dict[str, Any]]) -> str:
    lines: list[str] = []
    for item in consolidate_antecedentes(items):
        date = f" | fecha={item.fecha}" if item.fecha else ""
        context = f" | contexto={item.contexto}" if item.contexto else ""
        lines.append(f"{item.categoria} | {item.estado}{date}{context} | {item.descripcion}")
    return "\n".join(lines)


def group_antecedentes_for_view(items: Iterable[AntecedenteClinico | dict[str, Any]]) -> dict[str, Any]:
    consolidated = consolidate_antecedentes(items)
    labels = {
        "alergico": "Alergias",
        "patologico": "Patológicos",
        "quirurgico": "Quirúrgicos",
        "farmacologico": "Farmacológicos",
        "toxico": "Tóxicos",
        "personal": "Personales",
        "familiar": "Familiares",
        "sistemico": "Sistémicos",
        "gineco_obstetrico": "Gineco-obstétricos",
        "otro": "Otros",
    }
    categories: list[dict[str, Any]] = []
    for category in labels:
        category_items = [
            item.model_dump(mode="json", exclude_none=True)
            for item in consolidated
            if item.categoria == category and item.estado == "presente"
        ]
        if category_items:
            categories.append(
                {
                    "key": category,
                    "label": labels[category],
                    "items": category_items,
                    "discordant": any(item["discordante"] for item in category_items),
                }
            )
    negatives = [
        item.model_dump(mode="json", exclude_none=True) for item in consolidated if item.estado == "negado"
    ]
    return {
        "categories": categories,
        "negatives": negatives,
        "negative_category_count": len({item["categoria"] for item in negatives}),
        "has_discordance": any(item.discordante for item in consolidated),
    }
