"""chapter_deps — mapa central de dependencias de cómputo por capítulo del EDA.

Fuente de verdad ÚNICA de qué necesita cada capítulo de ``CHAPTER_ORDER`` para
computarse COMPLETO (sin caer en su rama degradada "datos insuficientes"). Lo
consume el pipeline ``render_automatic_eda`` cuando se le pide renderizar un
SUBCONJUNTO de capítulos (kwarg ``only_chapters``): antes de perfilar, resuelve
los requisitos de los capítulos pedidos y activa SOLO el cómputo que esos
capítulos necesitan, de modo que un capítulo suelto siempre llegue poblado y a la
vez no se malgaste CPU/LLM en piezas que ningún capítulo pedido usa.

Diseño: el mapa es CENTRAL (este módulo), NO una constante por capítulo. Así se
evita tocar los ``chapters/<id>.py`` (cada agente es dueño de su capítulo) y se
elimina el riesgo de colisión entre ramas. Si un capítulo cambia lo que lee del
``profile``/``ctx``, se actualiza ESTE mapa — es donde el motor mira.

Dos clases de dependencia, derivadas inspeccionando qué lee cada capítulo:

  - ``profile_flags``: flags de coste de ``profile_table`` que hay que ACTIVAR
    para que el ``profile`` traiga el bloque que el capítulo lee. Son los caros:
      * ``run_models``  -> ``profile['models']`` (KMeans/IsolationForest/PCA).
        Lo leen ``outliers`` (fallback del multivariante) y ``modelos``.
      * ``run_series``  -> ``profile['series']`` (análisis de serie temporal).
        Lo lee ``timeseries``.
      * ``run_llm``     -> ``profile['llm']`` (interpretación del modelo).
        Lo lee ``analisis_llm``.

  - ``ctx``: etiquetas de las piezas de DATOS CRUDOS que construye
    ``build_eda_render_ctx`` y que el capítulo lee del ``ctx``. Si la lista está
    vacía, el capítulo no necesita datos crudos y el pipeline puede saltarse
    ``build_eda_render_ctx`` por completo cuando ningún capítulo pedido los pide.
    Etiquetas y claves reales que mapean (ver ``CTX_LABEL_TO_KEYS``):
      * ``head_rows``      -> ``ctx['head_rows']``      (overview: df.head real).
      * ``raw_numeric``    -> ``ctx['raw_numeric']``    (outliers/modelos/
        correlacion/missingness/geospatial: muestra numérica alineada por fila).
      * ``timeseries_raw`` -> ``ctx['timeseries_raw']`` (timeseries: serie cruda).
      * ``geo_points``     -> ``ctx['geo_points']`` (+ ``raw_numeric``)
        (geospatial: lat/lon).
      * ``db_path_table``  -> ``ctx['db_path']`` + ``ctx['table']`` (agregacion/
        text_distr/missingness/relaciones: push-down de queries propias).

``portada`` y ``glosario`` NO son opcionales: el pipeline los incluye SIEMPRE
(la portada resume el documento y el glosario es el destino de los términos
clicables), así que aquí se declaran sin requisitos de cómputo.

Todas las funciones de este módulo son PURAS (no I/O, deterministas): se prestan
a test unitario directo.
"""

from __future__ import annotations

# Mapa central. Una entrada por id de CHAPTER_ORDER. ``profile_flags`` lista los
# flags de coste a activar; ``ctx`` las etiquetas de datos crudos que lee. Las
# claves vacías significan "no necesita ese tipo de dependencia".
CHAPTER_DEPS = {
    # Portada y glosario: SIEMPRE presentes, sin cómputo propio (la portada lee
    # el document_summary que arma build_document; el glosario lee los términos
    # que el resto registró). Se declaran para que el mapa cubra CHAPTER_ORDER
    # entero y la validación los reconozca.
    "portada":      {"profile_flags": [], "ctx": []},
    "overview":     {"profile_flags": [], "ctx": ["head_rows"]},
    "analisis_llm": {"profile_flags": ["run_llm"], "ctx": []},
    "num_distr":    {"profile_flags": [], "ctx": []},
    "cat_distr":    {"profile_flags": [], "ctx": []},
    # text_distr empuja su propia query de texto (no usa raw_numeric); necesita
    # db_path/table en el ctx para hacerlo.
    "text_distr":   {"profile_flags": [], "ctx": ["db_path_table"]},
    "calidad":      {"profile_flags": [], "ctx": []},
    # missingness lee la muestra numérica cruda (co-ocurrencia de ausencias) y
    # puede empujar una query de patrón de nulos con db_path/table.
    "missingness":  {"profile_flags": [], "ctx": ["raw_numeric", "db_path_table"]},
    # outliers corre IsolationForest EN VIVO sobre ctx['raw_numeric']; run_models
    # asegura además el fallback profile['models']['outliers'] si el ctx faltara.
    "outliers":     {"profile_flags": ["run_models"], "ctx": ["raw_numeric"]},
    "correlacion":  {"profile_flags": [], "ctx": ["raw_numeric"]},
    "relaciones":   {"profile_flags": [], "ctx": ["db_path_table"]},
    "modelos":      {"profile_flags": ["run_models"], "ctx": ["raw_numeric"]},
    "timeseries":   {"profile_flags": ["run_series"], "ctx": ["timeseries_raw"]},
    "geospatial":   {"profile_flags": [], "ctx": ["geo_points", "raw_numeric"]},
    "agregacion":   {"profile_flags": [], "ctx": ["db_path_table"]},
    "glosario":     {"profile_flags": [], "ctx": []},
}

# Capítulos que el documento incluye SIEMPRE, independientemente de only_chapters.
ALWAYS_PRESENT = ("portada", "glosario")

# Flags de coste reconocidos (el orden no importa; se devuelven como set).
KNOWN_PROFILE_FLAGS = ("run_models", "run_series", "run_llm")

# Mapeo de cada etiqueta de ctx a las claves REALES que produce
# build_eda_render_ctx. ``db_path_table`` es especial: db_path/table siempre se
# ponen para un backend válido y son inofensivos, por eso no se podan nunca (no
# aparecen en DATA_CTX_KEYS). El resto (head_rows/raw_numeric/timeseries_raw/
# geo_points) son las piezas de datos podables.
CTX_LABEL_TO_KEYS = {
    "head_rows":      {"head_rows"},
    "raw_numeric":    {"raw_numeric"},
    "timeseries_raw": {"timeseries_raw"},
    "geo_points":     {"geo_points", "raw_numeric"},
    "db_path_table":  set(),  # db_path/table siempre presentes; nunca se podan.
}

# Claves de datos crudos del ctx que se pueden podar cuando ningún capítulo
# pedido las necesita (las que cuestan muestreo). db_path/table NO entran aquí.
DATA_CTX_KEYS = ("head_rows", "raw_numeric", "timeseries_raw", "geo_points")


def _as_id_list(chapter_ids):
    """Normaliza la entrada a una lista de ids string, defensiva. None -> []."""
    if chapter_ids is None:
        return []
    if isinstance(chapter_ids, str):
        return [chapter_ids]
    return [c for c in chapter_ids if isinstance(c, str)]


def validate_chapter_ids(chapter_ids, order):
    """Separa los ids pedidos en válidos y desconocidos respecto a ``order``.

    Args:
        chapter_ids: lista (o str) de ids de capítulo pedidos.
        order: lista canónica de ids válidos (CHAPTER_ORDER).

    Returns:
        dict ``{"valid": [...], "unknown": [...]}`` preservando el orden de
        aparición de la entrada. Función pura.
    """
    valid_set = set(order or [])
    valid, unknown = [], []
    for cid in _as_id_list(chapter_ids):
        (valid if cid in valid_set else unknown).append(cid)
    return {"valid": valid, "unknown": unknown}


def resolve_requirements(chapter_ids):
    """Une los requisitos de cómputo de los capítulos pedidos.

    Es el corazón de la resolución de dependencias: dado el subconjunto de
    capítulos a renderizar, devuelve TODO lo que hay que activar/construir para
    que esos capítulos lleguen COMPLETOS, y solo eso.

    Los capítulos ``ALWAYS_PRESENT`` (portada/glosario) se añaden implícitamente
    porque el pipeline siempre los incluye; como no tienen requisitos, no alteran
    el resultado, pero se contemplan para que el conjunto sea coherente.

    Args:
        chapter_ids: lista (o str) de ids de capítulo. Ids desconocidos se
            ignoran silenciosamente (la validación estricta es de quien llama).
            None o lista vacía -> requisitos vacíos.

    Returns:
        dict ``{"profile_flags": set[str], "ctx_keys": set[str]}`` donde
        ``ctx_keys`` son las ETIQUETAS de ctx (no las claves reales). Función
        pura.
    """
    ids = set(_as_id_list(chapter_ids)) | set(ALWAYS_PRESENT)
    profile_flags = set()
    ctx_keys = set()
    for cid in ids:
        dep = CHAPTER_DEPS.get(cid)
        if not isinstance(dep, dict):
            continue
        for f in dep.get("profile_flags", []) or []:
            if f in KNOWN_PROFILE_FLAGS:
                profile_flags.add(f)
        for k in dep.get("ctx", []) or []:
            ctx_keys.add(k)
    return {"profile_flags": profile_flags, "ctx_keys": ctx_keys}


def resolve_profile_flags(chapter_ids):
    """Atajo: solo el set de profile_flags a activar para los capítulos pedidos.

    Función pura. Devuelve un set ⊆ KNOWN_PROFILE_FLAGS.
    """
    return resolve_requirements(chapter_ids)["profile_flags"]


def needs_render_ctx(chapter_ids):
    """True si algún capítulo pedido necesita datos crudos del ctx.

    Cuando es False, el pipeline puede saltarse ``build_eda_render_ctx`` entero
    (ahorro real de CPU/I/O): los capítulos pedidos no leen ninguna pieza de
    datos crudos. Función pura.
    """
    return bool(resolve_requirements(chapter_ids)["ctx_keys"])


def resolve_ctx_data_keys(chapter_ids):
    """Claves REALES de datos del ctx a CONSERVAR para los capítulos pedidos.

    Traduce las etiquetas de ctx a las claves concretas que produce
    ``build_eda_render_ctx`` (head_rows/raw_numeric/timeseries_raw/geo_points).
    El pipeline poda del ctx las claves de datos que NO estén en este set, para
    que un capítulo suelto no arrastre piezas de datos que no usa. db_path/table
    nunca se podan (no aparecen aquí). Función pura.

    Returns:
        set[str] subconjunto de DATA_CTX_KEYS.
    """
    req = resolve_requirements(chapter_ids)
    keep = set()
    for label in req["ctx_keys"]:
        keep |= CTX_LABEL_TO_KEYS.get(label, set())
    # Solo claves de datos podables (db_path/table se gestionan aparte).
    return {k for k in keep if k in DATA_CTX_KEYS}
