"""render_automatic_eda — EDA completo one-shot: perfil → ctx → PDF + PPTX + MD.

Pipeline impuro del grupo de capacidad `eda`. Dada UNA tabla DuckDB (o
PostgreSQL), produce el informe AutomaticEDA COMPLETO en sus tres formatos a la
vez (PDF móvil A5 + PPTX 16:9 + Markdown autocontenido para pegar a un LLM) con
los capítulos POBLADOS, en una sola llamada. Compone, sin reimplementar su
lógica, varias funciones del registry:

  - profile_table          : perfila la tabla end-to-end (TableProfile agregado),
                             opcionalmente con modelos baratos y análisis de serie.
  - build_eda_render_ctx   : construye el `ctx` con los DATOS CRUDOS que el
                             TableProfile agregado no incluye (raw_numeric para
                             modelos/geo, timeseries_raw para series, geo_points
                             para el mapa, db_path/table para la agregación
                             push-down). Sin él, esos capítulos degradan.
  - render_automatic_eda_pdf      : renderiza el documento por capítulos a PDF.
  - render_automatic_eda_pptx     : renderiza el mismo documento a PPTX.
  - render_automatic_eda_markdown : serializa el mismo documento a Markdown
                                    autocontenido (texto + tablas markdown, sin
                                    binarios) para incorporar a un LLM.

El TableProfile agregado basta para portada/overview/distribuciones/calidad/
correlación, pero los capítulos `modelos`, `timeseries`, `geospatial` y
`agregacion` necesitan datos crudos (los clusters proyectados sobre el PCA, la
serie valor-vs-tiempo, los puntos lat/lon, las filas para el groupby/pivot).
`build_eda_render_ctx` los muestrea (LIMIT + push-down, sin traer la tabla
entera a RAM) y los entrega en `ctx`; este pipeline los pasa como `meta['ctx']`
a ambos renderers para que el informe salga completo.

Estilo dict-no-throw del grupo `eda`: nunca lanza; captura cualquier error y
degrada a `{"status": "error", "error": str}`.
"""

import os
from datetime import datetime, timezone

from datascience import (
    build_eda_render_ctx,
    render_automatic_eda_markdown,
    render_automatic_eda_pdf,
    render_automatic_eda_pptx,
    run_eda_models,
)
from pipelines.profile_table import profile_table

# Tokens de almacenamiento por backend (para la portada del informe).
_STORAGE = {"duckdb": "DuckDB", "postgres": "PostgreSQL"}

# Presets de consumo CPU/LLM: cada profile_level fija SOLO los DEFAULTS de los
# flags que controlan el coste (un flag explícito del caller siempre prima sobre
# el preset). model_opts != None marca el camino "modelos baratos" (lite): los
# modelos NO los corre profile_table (que ejecutaría KMeans + IsolationForest),
# sino run_eda_models con esa granularidad, de modo que el coste CPU de los
# multivariantes nunca se paga. model_opts None => modelos completos como hasta
# ahora (profile_table los corre con todos los algoritmos).
_PROFILE_PRESETS = {
    # Bajo consumo: sin LLM, sin serie, sample reducido y modelos limitados a
    # PCA + normalidad (sin KMeans ni IsolationForest, lo caro en CPU). Vistazo
    # rápido y barato de una tabla.
    "lite": {
        "run_models": True,
        "run_series": False,
        "run_llm": False,
        "sample": 2000,
        "model_opts": {"run_kmeans": False, "run_isolation": False},
    },
    # Default: idéntico al comportamiento histórico del pipeline (modelos
    # completos, serie temporal, sin LLM, sample 5000).
    "standard": {
        "run_models": True,
        "run_series": True,
        "run_llm": False,
        "sample": 5000,
        "model_opts": None,
    },
    # Máximo: standard + narrativa LLM (interpretación del perfil y de los
    # capítulos modelos/geospatial/agregacion). Es la única parte que gasta
    # tokens del modelo.
    "full": {
        "run_models": True,
        "run_series": True,
        "run_llm": True,
        "sample": 5000,
        "model_opts": None,
    },
}


def render_automatic_eda(
    db_path: str,
    table: str,
    backend: str = "duckdb",
    sample: int = None,
    run_models: bool = None,
    run_series: bool = None,
    run_llm: bool = None,
    profile_level: str = "standard",
    out_dir: str = "reports",
    basename: str = None,
    ctx_extra: dict = None,
    emit_md: bool = True,
    only_chapters: list = None,
) -> dict:
    """Perfila una tabla y emite el informe AutomaticEDA completo (PDF + PPTX).

    Args:
        db_path: ruta al archivo DuckDB, o DSN PostgreSQL si backend="postgres".
        table: nombre de la tabla a perfilar.
        backend: "duckdb" (default) o "postgres".
        sample: máximo de filas/valores muestreados por columna para el perfil
            y para los datos crudos del ctx (LIMIT). Default None => lo fija el
            preset de profile_level (lite=2000, standard/full=5000).
        run_models: corre los modelos baratos
            (PCA/KMeans/IsolationForest/normalidad). Necesario para que el
            capítulo `modelos` pinte los clusters sobre el plano PCA. Default
            None => lo fija el preset (True en los tres niveles); en `lite` los
            modelos se limitan a PCA + normalidad (ver profile_level).
        run_series: calcula el análisis de serie temporal por
            columna numérica. Necesario para el análisis del capítulo
            `timeseries` (la gráfica de evolución sale de los datos crudos del
            ctx aunque run_series sea False). Default None => lo fija el preset
            (standard/full=True, lite=False).
        run_llm: hace la interpretación LLM del perfil y
            ACTIVA además la narrativa LLM de los capítulos modelos/geospatial/
            agregacion (títulos de segmento, descripción de la zona, selección de
            agregaciones). Con False esos capítulos usan su derivación
            cuantitativa (siguen completos, sin llamadas de red). Default None =>
            lo fija el preset (full=True, lite/standard=False).
        profile_level: preset de consumo CPU/LLM. Mapea a defaults de los flags
            anteriores; un flag explícito SIEMPRE prima sobre el preset (el
            preset solo fija el default cuando el flag se deja en None):

            - "lite"     bajo consumo: run_llm=False, run_series=False,
              sample=2000 y modelos limitados a **PCA + normalidad** (SIN KMeans
              ni IsolationForest, que es lo caro en CPU). Pensado para un vistazo
              rápido y barato. El capítulo `modelos` sale con PCA + normalidad,
              sin el scatter de clusters.
            - "standard" (default): comportamiento histórico — modelos completos
              (PCA/KMeans/IsolationForest/normalidad), serie temporal, sin LLM.
            - "full"     standard + narrativa LLM (run_llm=True).

            Ejemplo de precedencia: profile_level="lite" con run_llm=True
            explícito => el LLM SÍ se ejecuta (el flag explícito gana al preset).
        out_dir: directorio de salida (se crea si no existe). Default "reports".
        basename: nombre base de los archivos sin extensión. Default
            "aeda_<table>_<timestamp>".
        ctx_extra: dict opcional con claves de presentación/contexto extra que se
            mezclan en el ctx (p.ej. dataset_name, description, source_origin).
            No pisan las claves de datos calculadas por build_eda_render_ctx.
        emit_md: además del PDF y el PPTX, emite un Markdown autocontenido del
            MISMO documento por capítulos (texto plano + tablas markdown, sin
            binarios), pensado para pegar a un LLM. Default True. La ruta sale en
            la clave de retorno ``aeda_md_path``. No altera las demás salidas.
        only_chapters: lista opcional de ids de capítulo a renderizar (un
            SUBCONJUNTO de CHAPTER_ORDER) para iterar/testear un capítulo concreto
            sin generar el documento entero. Default None => documento COMPLETO,
            idéntico al de hoy (retrocompatible). Cuando se pasa una lista:

            - Se VALIDA contra CHAPTER_ORDER; un id desconocido devuelve un error
              claro listando los válidos (dict-no-throw, no lanza). Lista vacía
              ``[]`` también devuelve error (pasa al menos un capítulo o None).
            - Se RESUELVEN las dependencias de cómputo de esos capítulos
              (``automatic_eda.chapter_deps``): se activan los flags de coste que
              necesiten (run_models / run_series / run_llm) AUNQUE el caller no
              los pidiera, y se construyen SOLO las piezas de ``ctx`` que esos
              capítulos leen. Así un capítulo suelto SIEMPRE llega poblado —
              p.ej. ``only_chapters=['outliers']`` activa run_models y conserva
              ``ctx['raw_numeric']`` para que el bloque IsolationForest salga
              completo— y a la vez no se malgasta CPU/LLM en lo que ningún
              capítulo pedido usa (pedir solo ``geospatial`` no corre modelos).
            - El documento (PDF/PPTX/MD) y su manifest contienen SOLO esos
              capítulos, MÁS la portada (primera) y el glosario (última), que se
              incluyen siempre para que el documento sea válido y los términos
              clicables tengan destino.
            - Un flag explícito del caller (run_models/run_series/run_llm != None)
              SIEMPRE prima sobre lo que resuelvan las dependencias.

    Returns:
        dict (nunca lanza). En éxito::

            {"status": "ok", "pdf_path": str, "pptx_path": str,
             "aeda_md_path": str|None, "manifest_path": str|None,
             "n_pages": int, "n_slides": int, "md_chars": int|None,
             "pdf_note": str, "pptx_note": str, "md_note": str|None,
             "profile": <TableProfile>}

        En error: {"status": "error", "error": str}.
    """
    try:
        # 0) Resolución del preset: el profile_level fija los DEFAULTS de los
        # flags de coste; cualquier flag que el caller haya pasado explícito
        # (!= None) prima sobre el preset. Un profile_level desconocido cae a
        # "standard" (comportamiento histórico), sin lanzar.
        preset = _PROFILE_PRESETS.get(profile_level, _PROFILE_PRESETS["standard"])
        sample = preset["sample"] if sample is None else sample
        model_opts = preset["model_opts"]

        # 0.bis) Modo "capítulos sueltos": valida la selección y RESUELVE sus
        # dependencias de cómputo. Es lo que garantiza que un capítulo pedido
        # llegue completo (activa lo que necesita) sin malgastar en lo que no.
        # Cuando only_chapters es None se conserva el camino histórico (preset).
        if only_chapters is not None:
            from datascience.automatic_eda import CHAPTER_ORDER
            from datascience.automatic_eda.chapter_deps import (
                needs_render_ctx,
                resolve_ctx_data_keys,
                resolve_requirements,
                validate_chapter_ids,
            )

            if not isinstance(only_chapters, (list, tuple)):
                return {"status": "error",
                        "error": "only_chapters debe ser una lista de ids de "
                                 "capítulo o None (documento completo)."}
            only_chapters = [c for c in only_chapters]
            if not only_chapters:
                return {"status": "error",
                        "error": "only_chapters=[] está vacío. Pasa al menos un "
                                 "capítulo, o None para el documento completo. "
                                 "Capítulos válidos: " + ", ".join(CHAPTER_ORDER)}
            checked = validate_chapter_ids(only_chapters, CHAPTER_ORDER)
            if checked["unknown"]:
                return {"status": "error",
                        "error": "only_chapters con ids desconocidos: "
                                 + ", ".join(checked["unknown"])
                                 + ". Capítulos válidos: "
                                 + ", ".join(CHAPTER_ORDER)}
            only_chapters = checked["valid"]

            # Las dependencias fijan el DEFAULT de cada flag de coste (eficiencia:
            # lo que ningún capítulo pedido necesita queda en False); un flag
            # explícito del caller (!= None) sigue primando.
            dep_flags = resolve_requirements(only_chapters)["profile_flags"]
            run_models = ("run_models" in dep_flags) if run_models is None else run_models
            run_series = ("run_series" in dep_flags) if run_series is None else run_series
            run_llm = ("run_llm" in dep_flags) if run_llm is None else run_llm
            # En capítulos sueltos no se usa el camino "modelos baratos" (lite),
            # que poda ctx['raw_numeric']: un capítulo como outliers lo necesita
            # para su multivariante en vivo. El preset solo gobierna `sample`.
            model_opts = None
        else:
            run_models = preset["run_models"] if run_models is None else run_models
            run_series = preset["run_series"] if run_series is None else run_series
            run_llm = preset["run_llm"] if run_llm is None else run_llm

        # En el camino "modelos baratos" (lite) profile_table NO corre los
        # modelos: los ejecuta este pipeline con run_eda_models y la granularidad
        # del preset, evitando pagar el coste CPU de KMeans + IsolationForest.
        # En standard/full profile_table los corre completos como siempre.
        lite_models = bool(run_models) and model_opts is not None
        pt_run_models = bool(run_models) and not lite_models

        # 1) Perfil base + modelos/serie opcionales. No escribe report propio
        # (write_report=False): este pipeline emite su propio par PDF/PPTX.
        pres = profile_table(
            db_path,
            table,
            backend=backend,
            sample=sample,
            run_models=pt_run_models,
            run_llm=run_llm,
            run_series=run_series,
            emit_pdf=False,
            write_report=False,
        )
        if pres.get("status") != "ok":
            return {"status": "error",
                    "error": f"profile_table falló: {pres.get('error')}"}
        prof = pres.get("profile") or {}

        # 2) Contexto de presentación + datos crudos para los 4 capítulos que los
        # necesitan. Las claves de presentación van en base_ctx; build_eda_render_ctx
        # añade raw_numeric / timeseries_raw / geo_points / db_path / table.
        base_ctx = {
            "dataset_name": table,
            "source_origin": db_path,
            "storage": _STORAGE.get(backend, backend),
        }
        if run_llm:
            # Activa la narrativa LLM de los capítulos que la soportan.
            base_ctx.update({
                "run_cluster_llm": True,
                "run_geo_llm": True,
                "run_agg_llm": True,
            })
        if ctx_extra:
            base_ctx.update(ctx_extra)

        # En modo capítulos sueltos, si NINGÚN capítulo pedido necesita datos
        # crudos del ctx, se salta build_eda_render_ctx por completo (ahorro real
        # de I/O): solo se conservan presentación + db_path/table. Si sí los
        # necesita, se construye el ctx y luego se PODAN las piezas de datos que
        # ningún capítulo pedido usa (db_path/table nunca se podan).
        if only_chapters is not None and not needs_render_ctx(only_chapters):
            ctx = dict(base_ctx)
            ctx["db_path"] = db_path
            ctx["table"] = table
        else:
            ctx = build_eda_render_ctx(
                db_path, table, prof, backend=backend, sample=sample,
                base_ctx=base_ctx,
            )
            if only_chapters is not None and isinstance(ctx, dict):
                keep = resolve_ctx_data_keys(only_chapters)
                for k in ("head_rows", "raw_numeric", "timeseries_raw", "geo_points"):
                    if k not in keep:
                        ctx.pop(k, None)

        # 2.5) Camino lite — modelos baratos (PCA + normalidad, sin KMeans ni
        # IsolationForest). profile_table no corrió los modelos; aquí se corren
        # con run_eda_models reusando la muestra numérica alineada por fila que
        # build_eda_render_ctx ya trajo en ctx['raw_numeric'] (no se reimplementa
        # la lógica de los modelos: se delega en run_eda_models con la
        # granularidad del preset).
        if lite_models:
            raw_numeric = ctx.get("raw_numeric") if isinstance(ctx, dict) else None
            if isinstance(raw_numeric, dict) and raw_numeric:
                model_input = {
                    col: {"values": vals, "type": "numeric"}
                    for col, vals in raw_numeric.items()
                }
                prof["models"] = run_eda_models(model_input, **model_opts)
            # Quita raw_numeric del ctx para que el capítulo `modelos` NO
            # reproyecte clusters KMeans en vivo (project_clusters_2d ejecuta
            # KMeans): en lite ese coste se evita. geo_points ya quedó derivado
            # en ctx por build_eda_render_ctx, así que el capítulo geospatial no
            # se ve afectado.
            if isinstance(ctx, dict):
                ctx.pop("raw_numeric", None)

        # 3) Render a ambos formatos desde el MISMO documento por capítulos.
        # En modo capítulos sueltos, la selección viaja a build_document por una
        # clave reservada del ctx (los renderers llaman build_document sin pasar
        # `only`): build_document filtra el cuerpo a esos capítulos y siempre
        # añade portada (primera) + glosario (última). build_document la consume
        # y la quita, así que no llega a los capítulos.
        if only_chapters is not None and isinstance(ctx, dict):
            ctx["_only_chapters"] = list(only_chapters)
        os.makedirs(out_dir, exist_ok=True)
        ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
        base = basename or f"aeda_{table}_{ts}"
        pdf_path = os.path.join(out_dir, base + ".pdf")
        pptx_path = os.path.join(out_dir, base + ".pptx")
        meta = {"title": f"EDA — {table}", "ctx": ctx}

        rpdf = render_automatic_eda_pdf(prof, pdf_path, meta) or {}
        rpptx = render_automatic_eda_pptx(prof, pptx_path, meta) or {}

        # Salida Markdown autocontenida (mismo documento por capítulos) para
        # pegar a un LLM. Aditiva: no afecta a PDF/PPTX/manifest. dict-no-throw.
        rmd = {}
        md_path = None
        if emit_md:
            md_path = os.path.join(out_dir, base + ".md")
            # El Markdown es la salida MÁS completa: además del documento por
            # capítulos (compartido con PDF/PPTX) volca un apéndice con TODOS los
            # datos numéricos del perfil (matriz de asociación completa, describe
            # con skew/kurtosis/percentiles, re-expresiones, scores_by_k de
            # KMeans, estadísticos de normalidad). Se le pasa el `prof` vía
            # meta['profile']; un meta propio evita alterar el de PDF/PPTX.
            md_meta = dict(meta)
            md_meta["profile"] = prof
            rmd = render_automatic_eda_markdown(prof, md_path, md_meta) or {}

        return {
            "status": "ok",
            "pdf_path": rpdf.get("path"),
            "pptx_path": rpptx.get("path"),
            "aeda_md_path": rmd.get("path"),
            "manifest_path": rpdf.get("manifest_path"),
            "n_pages": rpdf.get("n_pages"),
            "n_slides": rpptx.get("n_slides"),
            "md_chars": rmd.get("n_chars"),
            "pdf_note": rpdf.get("note"),
            "pptx_note": rpptx.get("note"),
            "md_note": rmd.get("note"),
            "only_chapters": only_chapters,
            "profile": prof,
        }
    except Exception as e:  # noqa: BLE001 — dict-no-throw: degradar, nunca lanzar.
        return {"status": "error", "error": str(e)}
