"""profile_table — orquestador one-shot del grupo de capacidad `eda`.

Pipeline impuro: perfila UNA tabla DuckDB end-to-end componiendo las funciones
puras e impuras del grupo `eda` y, opcionalmente, escribe un report markdown +
JSON sidecar a disco. Es la composicion canonica para "hazme un EDA de esta
tabla": una sola llamada en vez de orquestar 7 funciones a mano.

Funciones del registry compuestas (NO se reimplementa su logica):
  - summarize_table_duckdb : perfil base por columna (push-down SQL, sin RAM).
  - duckdb_query_readonly  : muestra read-only de valores no nulos por columna.
  - infer_semantic_type    : clasifica VARCHAR (email, integer, currency, ...).
  - describe_numeric       : estadistica fina sobre la muestra numerica.
  - summarize_categorical  : top-k, moda, entropia sobre la muestra categorica.
  - column_quality_score   : score 0-100 de calidad por columna.
  - render_eda_markdown    : report legible del TableProfile.

Aporta una capa propia de PROMOCION DE TIPO: muchas tablas guardan numeros y
fechas como VARCHAR. Tras el perfil base, se muestrea cada columna textual, se
infiere su semantic_type y, si encaja, se promociona inferred_type a "numeric"
o "datetime" antes de enriquecer. Asi una columna '10','20' (VARCHAR) recibe su
bloque numeric en vez de quedarse como categorica.

Estilo dict-no-throw del grupo: nunca lanza; captura cualquier error y devuelve
{status:'error', error:str}.
"""

import json
import os
from datetime import date, datetime, timezone

from datascience import (
    acf_pacf,
    adf_kpss_stationarity,
    association_matrix,
    build_eda_render_ctx,
    column_quality_score,
    describe_numeric,
    eda_llm_insights,
    exploratory_caveats,
    infer_semantic_type,
    render_automatic_eda_pdf,
    render_automatic_eda_pptx,
    render_eda_markdown,
    render_eda_pdf,
    run_eda_models,
    stl_decompose,
    suggest_reexpression,
    summarize_categorical,
    summarize_table_duckdb,
    summarize_table_pg,
    to_returns,
)
from infra import duckdb_query_readonly, pg_query

# semantic_types que justifican promocionar inferred_type -> "numeric".
_NUMERIC_SEMANTIC = ("integer", "decimal", "currency")
# semantic_types que justifican promocionar inferred_type -> "datetime".
_DATETIME_SEMANTIC = ("datetime_iso", "date_eu")
# Fraccion minima de la muestra que debe parsear a float para confirmar la
# promocion a numeric (evita promocionar columnas mayormente no parseables).
_PROMOTE_MIN_PARSE = 0.8

# Cardinalidad maxima (distinct_count) por debajo de la cual una columna numerica
# se trata como NO continua (binaria / ordinal de pocos niveles) y, por tanto, no
# es candidata a re-expresion de Tukey (la escalera de potencias no aplica a una
# variable con pocos niveles discretos).
_REEXPR_MIN_DISTINCT = 12

# Tokens en el nombre (o semantic_type currency) que sugieren que una serie de
# niveles es FINANCIERA (precios/volumen): en ese caso la transformacion adecuada
# son los retornos. Para magnitudes fisicas (temperatura, caudal) la transformacion
# correcta son las diferencias, no los retornos.
_FINANCIAL_TOKENS = (
    "price", "close", "open", "high", "low", "volume", "adj", "vwap",
    "bid", "ask", "return", "precio", "cierre", "apertura", "cotiz", "retorno",
)


def _is_continuous_for_reexpr(col: dict, vals_float: list) -> bool:
    """True si la columna numerica es continua y justifica sugerir re-expresion.

    Se saltan (devuelve False):
      - binarias / ordinales de baja cardinalidad (``distinct_count`` <= umbral):
        la escalera de potencias de Tukey no tiene sentido sobre pocos niveles
        discretos (p.ej. ``Survived`` 0/1, ``Pclass`` 1/2/3).
      - identificadores enteros (flag ``possible_id`` y todos los valores enteros):
        re-expresar un id (p.ej. ``PassengerId`` 1..n) no aporta nada.
    Los floats continuos de alta cardinalidad (precios, medidas) NO se saltan
    aunque lleven ``possible_id``, porque tienen parte decimal (no son enteros).
    """
    dc = col.get("distinct_count")
    if isinstance(dc, int) and not isinstance(dc, bool) and dc <= _REEXPR_MIN_DISTINCT:
        return False
    flags = col.get("flags") or []
    if "possible_id" in flags and vals_float and all(
        float(f).is_integer() for f in vals_float
    ):
        return False
    return True


def _looks_financial(col: dict) -> bool:
    """True si la columna parece una serie financiera (precio/volumen/divisa).

    Heuristica por nombre (tokens OHLCV típicos) o ``semantic_type == currency``.
    Decide si una serie de niveles se debe transformar a retornos (financiera) o a
    diferencias (no financiera, p.ej. temperatura).
    """
    name = (col.get("name") or "").lower()
    if any(tok in name for tok in _FINANCIAL_TOKENS):
        return True
    return (col.get("semantic_type") or "").lower() == "currency"


def _to_ordinal_days(value) -> float | None:
    """Convierte un valor fecha/datetime/ISO-string a dias ordinales (float), o None.

    Soporta los tipos que devuelve DuckDB para columnas DATE/TIMESTAMP
    (``datetime.date`` / ``datetime.datetime``) y strings ISO. Devuelve None para
    cualquier cosa que no parsee a una fecha (numeros sueltos, basura).
    """
    if value is None or isinstance(value, bool):
        return None
    if isinstance(value, datetime):
        return value.toordinal() + value.hour / 24.0 + value.minute / 1440.0
    if isinstance(value, date):
        return float(value.toordinal())
    if isinstance(value, (int, float)):
        return None  # entero/float suelto: no es una fecha fiable
    s = str(value).strip()
    if not s:
        return None
    try:
        return float(datetime.fromisoformat(s).toordinal())
    except (TypeError, ValueError):
        pass
    try:
        return float(date.fromisoformat(s[:10]).toordinal())
    except (TypeError, ValueError):
        return None


def _infer_period_from_dates(dates: list, n_series: int) -> int | None:
    """Deriva el periodo estacional de la FRECUENCIA del indice datetime.

    En vez de adivinar el periodo por autocorrelacion cruda (que en series con
    tendencia degenera a 2 y produce un falso negativo de estacionalidad), se mide
    el delta mediano en dias entre observaciones consecutivas ordenadas y se mapea
    a su periodo estacional dominante:

      - diario  (delta ~= 1 dia)  -> 365 si hay >= 2 anios de datos; si no, 7.
      - semanal (delta ~= 7 dias) -> 52.
      - mensual (delta ~= 30 dias)-> 12.
      - trimestral (delta ~= 91)  -> 4.

    Devuelve None si no hay fechas suficientes, si la frecuencia no encaja en un
    patron conocido, o si la serie es demasiado corta para dos ciclos del periodo.
    """
    ords = [d for d in (_to_ordinal_days(v) for v in dates) if d is not None]
    if len(ords) < 3:
        return None
    ords.sort()
    deltas = sorted(b - a for a, b in zip(ords[:-1], ords[1:]) if b - a > 0)
    if not deltas:
        return None
    med = deltas[len(deltas) // 2]  # delta mediano en dias
    if med <= 2.0:  # diario
        if n_series >= 730:  # >= 2 anios: estacionalidad anual
            return 365
        return 7 if n_series >= 14 else None
    if 5.0 <= med <= 10.0:  # semanal
        return 52 if n_series >= 104 else None
    if 25.0 <= med <= 35.0:  # mensual
        return 12 if n_series >= 24 else None
    if 85.0 <= med <= 100.0:  # trimestral
        return 4 if n_series >= 8 else None
    return None


def _is_sequential_id(col: dict) -> bool:
    """True si la columna numerica es un id ENTERO secuencial (indice de fila).

    Distingue ``PassengerId`` (1..n, enteros densos, monotono) de un float continuo
    de alta cardinalidad (precios): el id no debe entrar en correlacion ni en
    PCA/KMeans (es ruido que infla pares espurios y distorsiona componentes); el
    precio si. Criterio: flag ``possible_id`` + min/max enteros + rango denso (casi
    todos los enteros del intervalo presentes). Un precio tiene parte decimal en
    min/max, asi que NUNCA lo marca.
    """
    if col.get("inferred_type") != "numeric":
        return False
    if "possible_id" not in (col.get("flags") or []):
        return False
    nb = col.get("numeric") or {}
    mn, mx = nb.get("min"), nb.get("max")
    if not (
        isinstance(mn, (int, float))
        and not isinstance(mn, bool)
        and isinstance(mx, (int, float))
        and not isinstance(mx, bool)
    ):
        return False
    if not (float(mn).is_integer() and float(mx).is_integer()):
        return False  # float continuo (precios): mantener
    dc = col.get("distinct_count")
    if isinstance(dc, int) and not isinstance(dc, bool) and dc > 1:
        span = float(mx) - float(mn) + 1.0
        if span > 0 and (dc / span) >= 0.95:
            return True
    return False


def _to_float(value):
    """Parsea un valor a float limpiando simbolos de moneda y separadores.

    Quita simbolos de divisa (EUR/USD/GBP/€/$/£), espacios y separadores de
    miles, y normaliza la coma decimal. Devuelve None si no parsea.
    """
    if value is None:
        return None
    if isinstance(value, bool):
        return None
    if isinstance(value, (int, float)):
        return float(value)
    s = str(value).strip()
    if not s:
        return None
    # Limpia simbolos de moneda y unidades textuales.
    for tok in ("€", "$", "£", "EUR", "USD", "GBP", "eur", "usd", "gbp"):
        s = s.replace(tok, "")
    s = s.strip()
    # Normaliza separadores: si hay coma y punto, asume punto=miles, coma=decimal.
    if "," in s and "." in s:
        s = s.replace(".", "").replace(",", ".")
    elif "," in s:
        # Solo coma: tratar como separador decimal.
        s = s.replace(",", ".")
    s = s.replace(" ", "")
    try:
        return float(s)
    except (TypeError, ValueError):
        return None


def _sample_values(query_fn, table: str, name: str, sample: int) -> list:
    """Trae hasta `sample` valores no nulos de una columna (read-only).

    query_fn(sql) -> dict es el lector read-only del backend activo
    (duckdb_query_readonly o pg_query), inyectado por profile_table.
    """
    q = query_fn(
        f'SELECT "{name}" AS v FROM "{table}" WHERE "{name}" IS NOT NULL '
        f"LIMIT {int(sample)}",
    )
    if q.get("status") != "ok":
        return []
    return [row.get("v") for row in q.get("rows", [])]


def _sample_rows(query_fn, table: str, names: list, sample: int) -> list:
    """Trae hasta `sample` filas completas con las columnas alineadas por fila.

    A diferencia de _sample_values (una columna, solo no nulos), esto preserva la
    alineacion por fila entre columnas, requisito de la matriz de asociacion
    (los pares (a_i, b_i) deben venir de la misma fila). query_fn es el lector
    read-only del backend activo, inyectado por profile_table.
    """
    if not names:
        return []
    cols_sql = ", ".join(f'"{n}"' for n in names)
    q = query_fn(f'SELECT {cols_sql} FROM "{table}" LIMIT {int(sample)}')
    if q.get("status") != "ok":
        return []
    return q.get("rows", [])


def _sample_series(query_fn, table: str, value_col: str, order_col, sample: int) -> list:
    """Trae hasta `sample` valores no nulos de una columna en orden de serie temporal.

    A diferencia de _sample_values, cuando hay una columna de orden temporal
    (`order_col`, normalmente la primera columna datetime de la tabla) se ordena
    ascendentemente por ella para que la secuencia recuperada respete el orden
    cronologico, requisito de los contrastes de serie temporal (ADF/KPSS, ACF/PACF,
    STL). Si `order_col` es None se cae al orden fisico de inserciones (columna
    numerica secuencial). query_fn es el lector read-only del backend activo.
    """
    base = (
        f'SELECT "{value_col}" AS v FROM "{table}" '
        f'WHERE "{value_col}" IS NOT NULL'
    )
    if order_col:
        base += f' ORDER BY "{order_col}"'
    base += f" LIMIT {int(sample)}"
    q = query_fn(base)
    if q.get("status") != "ok":
        return []
    return [row.get("v") for row in q.get("rows", [])]


def _build_series_block(query_fn, table: str, col: dict, order_col, sample: int) -> dict:
    """Construye el bloque `series` de una columna numerica (estilo dict-no-throw).

    Compone los contrastes de serie temporal del grupo `eda` sobre la secuencia
    ordenada de la columna: estacionariedad (ADF+KPSS), autocorrelacion (ACF/PACF +
    Ljung-Box) y descomposicion STL (tendencia/estacional/resto). Cuando la columna
    parece de NIVELES (precios: estrictamente positiva y no claramente estacionaria)
    anade ademas la conversion a retornos (`to_returns`) como sugerencia, ya que
    correlacionar/modelar niveles no estacionarios produce relaciones espurias
    (Granger-Newbold).

    Devuelve None si no hay suficientes puntos validos (<8) para ningun contraste.
    """
    name = col.get("name")
    raw = _sample_series(query_fn, table, name, order_col, sample)
    series_vals = [f for f in (_to_float(v) for v in raw) if f is not None]
    if len(series_vals) < 8:
        return None

    # Periodo estacional derivado de la FRECUENCIA del indice datetime (mensual->12,
    # diario->365/7), que es fiable, en vez de dejar que stl_decompose lo adivine por
    # autocorrelacion (que en series con tendencia degenera a period=2 -> falso
    # negativo de estacionalidad). Si no hay order_col datetime o la frecuencia no
    # encaja, period queda None y stl_decompose lo infiere (ya con detrend) o avisa.
    period = None
    period_source = "autocorr"
    if order_col:
        raw_dates = _sample_series(query_fn, table, order_col, order_col, sample)
        period = _infer_period_from_dates(raw_dates, len(series_vals))
        if period is not None:
            period_source = "datetime_freq"

    block: dict = {
        "order_col": order_col,
        "ordered": bool(order_col),
        "n": len(series_vals),
        "stationarity": adf_kpss_stationarity(series_vals),
        "acf_pacf": acf_pacf(series_vals),
        # Periodo de la frecuencia del indice si se pudo derivar; si no, stl_decompose
        # lo infiere por autocorrelacion del residuo detrended o devuelve una nota
        # ("periodo no determinado") sin reportar seasonal_strength=0 como conclusion.
        "period_source": period_source,
        "stl": stl_decompose(series_vals, period=period),
    }

    # Sugerencia de transformacion solo si la columna parece de niveles:
    # estrictamente positiva y con veredicto de estacionariedad NO confirmado.
    # La transformacion adecuada depende de la SEMANTICA: retornos para series
    # financieras (precios/volumen), diferencias para magnitudes fisicas
    # (temperatura, caudal). Aplicar "retornos" a una temperatura no tiene sentido
    # fisico; la primera diferencia si la estaciona.
    nb = col.get("numeric") or {}
    minimum = nb.get("min")
    verdict = (block["stationarity"] or {}).get("verdict")
    if (
        isinstance(minimum, (int, float))
        and not isinstance(minimum, bool)
        and minimum > 0
        and verdict in ("non_stationary", "inconclusive")
    ):
        block["levels_suggested"] = True
        if _looks_financial(col):
            block["levels_kind"] = "returns"
            block["to_returns"] = to_returns(series_vals, method="log")
            block["levels_reason"] = (
                "columna financiera estrictamente positiva y no claramente "
                "estacionaria (serie de niveles/precios): trabajar sobre retornos "
                "evita correlacion espuria (Granger-Newbold)."
            )
        else:
            block["levels_kind"] = "differences"
            block["levels_reason"] = (
                "serie de niveles no financiera y no claramente estacionaria: la "
                "primera diferencia la estaciona; los retornos no tienen sentido en "
                "magnitudes fisicas (p.ej. temperatura)."
            )
    else:
        block["levels_suggested"] = False

    return block


def profile_table(
    db_path: str,
    table: str,
    backend: str = "duckdb",
    sample: int = 5000,
    run_models: bool = False,
    run_llm: bool = False,
    run_series: bool = False,
    emit_pdf: bool = False,
    emit_automatic: bool = False,
    report_dir: str = "reports",
    write_report: bool = True,
) -> dict:
    """Perfila una tabla (DuckDB o PostgreSQL) end-to-end y emite el TableProfile.

    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". Selecciona el motor de
            perfilado base (summarize) y de muestreo read-only.
        sample: maximo de valores no nulos muestreados por columna para el
            enriquecimiento (describe_numeric / summarize_categorical /
            infer_semantic_type). Default 5000.
        run_models: si True (default False) corre los modelos baratos
            (PCA/KMeans/IsolationForest/normalidad) sobre las numericas y guarda
            el bloque en prof["models"].
        run_llm: si True (default False) hace 1 llamada LLM sobre el perfil
            agregado y guarda el resultado en prof["llm"].
        run_series: si True (default False) calcula, para cada columna numerica,
            un bloque de serie temporal (estacionariedad ADF+KPSS, ACF/PACF,
            descomposicion STL y, si parece de niveles, conversion a retornos).
            Si hay una columna datetime se usa como orden cronologico; si no, se
            usa el orden fisico de filas (columna numerica secuencial). Los bloques
            se guardan por columna en col["series"] y agregados en prof["series"].
        emit_pdf: si True (default False) renderiza un PDF multipagina vertical
            (legible en movil) del perfil junto al report markdown y devuelve su
            ruta en pdf_path.
        emit_automatic: si True (default False) emite ademas el informe
            AutomaticEDA COMPLETO en sus dos formatos (PDF A5 movil + PPTX 16:9)
            con los 11 capitulos del motor por capitulos. Construye el contexto
            de datos crudos con build_eda_render_ctx (raw_numeric para modelos/
            geo, timeseries_raw para series, geo_points para el mapa, db_path/
            table para la agregacion push-down) para que los capitulos modelos/
            timeseries/geospatial/agregacion salgan poblados, no degradados. Es
            ADITIVO: no sustituye a emit_pdf (render_eda_pdf). Sus rutas vuelven
            en aeda_pdf_path / aeda_pptx_path / aeda_manifest_path.
        report_dir: directorio donde escribir los reports si write_report.
            Default "reports". Se crea si no existe.
        write_report: si True (default), escribe un report markdown + un JSON
            sidecar timestamped en report_dir. Si False, no toca disco y los
            paths del retorno son None.

    Returns:
        dict. En exito: {status:'ok', profile: <TableProfile>,
        report_md_path: str|None, report_json_path: str|None, pdf_path: str|None}.
        En error (sin lanzar): {status:'error', error:str}.
    """
    try:
        # 1) Perfil base por columna (push-down SQL) + lector read-only del
        # backend activo, inyectado en el muestreo (_sample_values/_sample_rows).
        if backend == "postgres":
            r = summarize_table_pg(db_path, table)

            def _q(sql):
                return pg_query(db_path, sql)

        elif backend == "duckdb":
            r = summarize_table_duckdb(db_path, table)

            def _q(sql):
                return duckdb_query_readonly(db_path, sql)

        else:
            return {"status": "error", "error": f"backend desconocido: {backend}"}
        if r.get("status") != "ok":
            return {"status": "error", "error": r.get("error", "summarize failed")}
        prof = r["profile"]
        cols = prof.get("columns", [])

        for col in cols:
            name = col.get("name")
            inferred = col.get("inferred_type")

            # 2) Muestra de valores no nulos.
            vals = _sample_values(_q, table, name, sample)

            # 3) Promocion de tipo sobre columnas textuales.
            if inferred in ("categorical", "text"):
                sem = infer_semantic_type(vals)
                semantic = sem.get("semantic_type", "")
                col["semantic_type"] = semantic

                if semantic in _NUMERIC_SEMANTIC:
                    parsed = [_to_float(v) for v in vals]
                    ok = [f for f in parsed if f is not None]
                    if vals and (len(ok) / len(vals)) >= _PROMOTE_MIN_PARSE:
                        col["inferred_type"] = "numeric"
                        inferred = "numeric"
                        # Tasa de parseo real de la muestra: alimenta la
                        # dimension validity de column_quality_score (fraccion
                        # de valores conformes al tipo numerico promovido).
                        col["validity_rate"] = len(ok) / len(vals)
                elif semantic in _DATETIME_SEMANTIC:
                    col["inferred_type"] = "datetime"
                    inferred = "datetime"
                    # Tasa de parseo de la muestra a fecha (mismo papel que el
                    # parse rate numerico) para la dimension validity.
                    parsed_dt = [_to_ordinal_days(v) for v in vals]
                    ok_dt = [d for d in parsed_dt if d is not None]
                    col["validity_rate"] = (len(ok_dt) / len(vals)) if vals else None

            # 4) Enriquecer segun el inferred_type final.
            if inferred == "numeric":
                vals_float = [f for f in (_to_float(v) for v in vals) if f is not None]
                col["numeric"] = describe_numeric(vals_float)
                # Re-expresion sugerida (escalera de Tukey): que transformacion
                # simetriza mejor la columna a partir de su skew/dominio. Solo para
                # columnas CONTINUAS: no aplica a binarias/ordinales de baja
                # cardinalidad ni a identificadores enteros (la fila seria ruido).
                if _is_continuous_for_reexpr(col, vals_float):
                    col["reexpression"] = suggest_reexpression(col["numeric"])
            elif inferred in ("categorical", "text"):
                col["categorical"] = summarize_categorical(vals)
                # Para columnas no promovidas que ya eran categorical/text y no
                # habian pasado por infer arriba, asegurar semantic_type seteado.
                if not col.get("semantic_type"):
                    col["semantic_type"] = infer_semantic_type(vals).get(
                        "semantic_type", ""
                    )
            elif inferred == "datetime":
                # profile_datetime llega en otra fase; conserva semantic_type.
                col["datetime"] = None

            # 5) Score de calidad por columna.
            col["quality_score"] = column_quality_score(col).get("score")

        # 6) Score agregado de la tabla (report 2046): NO media simple.
        #   cell_quality   = media de los scores de columna, en [0,1].
        #   row_uniqueness = 1 - duplicate_pct (unicidad de registro).
        #   score = 100 * (0.85*cell_quality + 0.15*row_uniqueness).
        # Renormaliza a solo cell_quality si duplicate_pct no se pudo calcular.
        scores = [
            c["quality_score"] for c in cols if c.get("quality_score") is not None
        ]
        if scores:
            cell_quality = (sum(scores) / len(scores)) / 100.0
            dup_pct = prof.get("duplicate_pct")
            if dup_pct is not None:
                try:
                    d = float(dup_pct)
                except (TypeError, ValueError):
                    d = None
            else:
                d = None
            if d is not None:
                # Tolerar escala 0-100 por si algun backend la entrega asi.
                if d > 1.0:
                    d = d / 100.0
                row_uniqueness = max(0.0, min(1.0, 1.0 - d))
                prof["quality_score"] = round(
                    100.0 * (0.85 * cell_quality + 0.15 * row_uniqueness), 1
                )
            else:
                prof["quality_score"] = round(100.0 * cell_quality, 1)
        else:
            prof["quality_score"] = None

        # 7) Candidatos a clave.
        key_candidates = []
        for c in cols:
            flags = c.get("flags") or []
            unique_pct = c.get("unique_pct") or 0.0
            null_pct = c.get("null_pct") or 0.0
            if "possible_id" in flags or (unique_pct >= 0.99 and null_pct == 0):
                key_candidates.append(c["name"])
        prof["key_candidates"] = key_candidates

        # 8) Recalcular type_breakdown tras la promocion.
        type_breakdown = {
            "numeric": 0,
            "categorical": 0,
            "datetime": 0,
            "text": 0,
            "boolean": 0,
        }
        for c in cols:
            it = c.get("inferred_type")
            if it in type_breakdown:
                type_breakdown[it] += 1
        prof["type_breakdown"] = type_breakdown

        # 8.1) Primeras filas crudas (df.head) para el capitulo OVERVIEW del motor
        # AutomaticEDA: una muestra SELECT col1,col2,... LIMIT 10 alineada por fila.
        # Se reusa _sample_rows (mismo lector read-only). Estilo dict-no-throw: si
        # falla, head_rows queda None y el capitulo degrada a su nota honesta. El
        # capitulo lo recoge via profile["head_rows"]; build_eda_render_ctx ademas
        # lo replica en ctx["head_rows"] cuando se construye el contexto de render.
        try:
            head_names = [c.get("name") for c in cols if c.get("name")]
            head_rows = _sample_rows(_q, table, head_names, 10)
            prof["head_rows"] = [
                dict(r) for r in head_rows if isinstance(r, dict)
            ] or None
        except Exception:  # noqa: BLE001
            prof["head_rows"] = None

        # 8.5) Matriz de correlacion/asociacion sobre una muestra de filas
        # alineadas. Elige la metrica por par de tipos (Pearson/Spearman,
        # Cramer's V/Theil's U, correlation ratio, MI) via association_matrix.
        # Se salta el text de alta cardinalidad (ids/urls): solo mete ruido.
        try:
            corr_sample = min(int(sample), 5000)
            # Excluye columnas id-like (possible_id / high_cardinality) de tipo
            # categorical/text: su cardinalidad ~ n filas infla Cramer's V y MI
            # con asociaciones espurias (cada valor unico empareja perfecto).
            # Las numericas de alta cardinalidad SI se conservan (p.ej. precios).
            def _skip_for_assoc(c):
                it = c.get("inferred_type")
                flags = c.get("flags") or []
                # Categoricas/text id-like por cardinalidad ~ n.
                if it in ("categorical", "text") and (
                    "possible_id" in flags or "high_cardinality" in flags
                ):
                    return True
                # Id ENTERO secuencial numerico (PassengerId 1..n): indice de fila,
                # genera pares espurios (correlation_ratio ~0.9 sig=si) y entra como
                # feature ruidosa en PCA/KMeans. Los floats continuos (precios) NO
                # se saltan aunque lleven possible_id.
                return _is_sequential_id(c)

            assoc_cols = [c for c in cols if not _skip_for_assoc(c)]
            rows = _sample_rows(
                _q, table, [c["name"] for c in assoc_cols], corr_sample
            )
            assoc_input = {}
            for c in assoc_cols:
                name = c["name"]
                it = c.get("inferred_type") or "categorical"
                raw = [row.get(name) for row in rows]
                if it == "numeric":
                    assoc_input[name] = {
                        "values": [_to_float(v) for v in raw],
                        "type": "numeric",
                    }
                else:
                    assoc_input[name] = {"values": raw, "type": it}
            prof["correlations"] = (
                association_matrix(assoc_input) if len(assoc_input) >= 2 else None
            )
        except Exception:  # noqa: BLE001
            prof["correlations"] = None
            assoc_input = {}

        # Modelos baratos opt-in en su PROPIO try: un fallo de los modelos NUNCA
        # debe tumbar las correlaciones ya calculadas (bug detectado en EDAs PG
        # reales: un try/except compartido ponia ambos campos a None).
        if run_models:
            try:
                # Subconjunto de features para PCA/KMeans: solo numericas CONTINUAS.
                # Se excluyen binarias/ordinales/target de baja cardinalidad (Survived
                # 0/1, Pclass 1/2/3): como dimensiones del PCA/clustering anaden ruido
                # y distorsionan componentes y centroides. Los id secuenciales ya
                # quedaron fuera de assoc_input via _skip_for_assoc.
                def _is_model_feature(cname):
                    c = next(
                        (x for x in assoc_cols if x.get("name") == cname), None
                    )
                    if c is None or c.get("inferred_type") != "numeric":
                        return False
                    dc = c.get("distinct_count")
                    if (
                        isinstance(dc, int)
                        and not isinstance(dc, bool)
                        and dc <= _REEXPR_MIN_DISTINCT
                    ):
                        return False
                    return True

                models_input = {
                    n: v for n, v in assoc_input.items() if _is_model_feature(n)
                }
                prof["models"] = run_eda_models(models_input)
            except Exception:  # noqa: BLE001
                prof["models"] = None

        # 8.6) Capa LLM opcional: interpreta el perfil ya calculado en UNA
        # llamada (data dictionary, resumen, granularidad de fila, PII, limpieza,
        # analisis sugeridos). Solo envia el perfil agregado, nunca filas crudas.
        if run_llm:
            try:
                res = eda_llm_insights(prof)
                prof["llm"] = res.get("llm") if res.get("status") == "ok" else None
            except Exception:  # noqa: BLE001
                prof["llm"] = None

        # 8.7) Analisis de serie temporal opt-in. Para cada columna numerica se
        # calcula estacionariedad (ADF+KPSS), autocorrelacion (ACF/PACF) y
        # descomposicion STL sobre la secuencia ordenada; si parece de niveles se
        # anade la conversion a retornos. Si hay una columna datetime se usa como
        # orden cronologico; si no, el orden fisico (columna numerica secuencial).
        if run_series:
            try:
                order_col = next(
                    (
                        c.get("name")
                        for c in cols
                        if c.get("inferred_type") == "datetime"
                    ),
                    None,
                )
                series_map: dict = {}
                for col in cols:
                    if col.get("inferred_type") != "numeric":
                        continue
                    try:
                        sblock = _build_series_block(
                            _q, table, col, order_col, sample
                        )
                    except Exception:  # noqa: BLE001
                        sblock = None
                    if sblock is not None:
                        col["series"] = sblock
                        series_map[col["name"]] = sblock
                prof["series"] = series_map or None
            except Exception:  # noqa: BLE001
                prof["series"] = None

        # 8.75) Marcar las correlaciones num-num calculadas sobre NIVELES de series
        # no estacionarias (autocorreladas) como posible espuria (Granger-Newbold):
        # Close-Open=0.998 es un artefacto de la raiz unitaria, no una relacion util.
        # Para uso financiero lo correcto es correlacionar retornos (col["series"]
        # ["to_returns"]). Si corrio run_series se usa el veredicto ADF/KPSS por
        # columna; si no, una heuristica financiera por nombre + dominio positivo.
        try:
            corr = prof.get("correlations")
            if isinstance(corr, dict):
                levels_cols: set = set()
                series_map = prof.get("series") or {}
                if series_map:
                    for cname, sb in series_map.items():
                        if not isinstance(sb, dict):
                            continue
                        verdict = (sb.get("stationarity") or {}).get("verdict")
                        if sb.get("levels_suggested") or verdict in (
                            "non_stationary",
                            "inconclusive",
                        ):
                            levels_cols.add(cname)
                else:
                    for c in cols:
                        if c.get("inferred_type") == "numeric" and _looks_financial(c):
                            mn = (c.get("numeric") or {}).get("min")
                            if (
                                isinstance(mn, (int, float))
                                and not isinstance(mn, bool)
                                and mn > 0
                            ):
                                levels_cols.add(c.get("name"))
                n_marked = 0
                for pair in corr.get("pairs", []):
                    if (
                        pair.get("method") == "pearson/spearman"
                        and pair.get("a") in levels_cols
                        and pair.get("b") in levels_cols
                    ):
                        pair["levels_possible_spurious"] = True
                        n_marked += 1
                if n_marked:
                    corr["levels_caveat"] = (
                        f"{n_marked} par(es) de correlacion se calculan sobre NIVELES "
                        "de series no estacionarias (autocorreladas): la correlacion "
                        "puede ser espuria (Granger-Newbold). Para uso financiero, "
                        "correlacionar sobre retornos/diferencias (ver el bloque "
                        "series de cada columna)."
                    )
        except Exception:  # noqa: BLE001
            pass

        # 8.8) Avisos exploratorios: recuerdan que el EDA genera hipotesis, no
        # conclusiones. Se calculan sobre el perfil ya completo (correlaciones,
        # modelos, outliers, faltantes determinan que advertencias aplican).
        try:
            prof["caveats"] = exploratory_caveats(prof)
        except Exception:  # noqa: BLE001
            prof["caveats"] = None

        # 9) Reports opcionales (markdown + JSON sidecar + PDF movil).
        report_md_path = None
        report_json_path = None
        pdf_path = None
        ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
        if write_report:
            os.makedirs(report_dir, exist_ok=True)
            report_json_path = os.path.join(report_dir, f"eda_{table}_{ts}.json")
            report_md_path = os.path.join(report_dir, f"eda_{table}_{ts}.md")
            with open(report_json_path, "w", encoding="utf-8") as fh:
                fh.write(json.dumps(prof, ensure_ascii=False, indent=1, default=str))
            with open(report_md_path, "w", encoding="utf-8") as fh:
                fh.write(render_eda_markdown(prof))

        # PDF multipagina vertical (legible en movil), junto al report markdown.
        if emit_pdf:
            try:
                os.makedirs(report_dir, exist_ok=True)
                pdf_target = os.path.join(report_dir, f"eda_{table}_{ts}.pdf")
                pres = render_eda_pdf(prof, pdf_target)
                pdf_path = pres.get("pdf_path")
            except Exception:  # noqa: BLE001
                pdf_path = None

        # Informe AutomaticEDA completo (PDF + PPTX por capitulos). Aditivo:
        # convive con emit_pdf (render_eda_pdf) sin sustituirlo. Construye el ctx
        # con los datos crudos para que modelos/timeseries/geospatial/agregacion
        # salgan poblados; degrada por clave si build_eda_render_ctx falla.
        aeda_pdf_path = None
        aeda_pptx_path = None
        aeda_manifest_path = None
        if emit_automatic:
            try:
                os.makedirs(report_dir, exist_ok=True)
                base_ctx = {
                    "dataset_name": table,
                    "source_origin": db_path,
                    "storage": "DuckDB" if backend == "duckdb" else (
                        "PostgreSQL" if backend == "postgres" else backend),
                }
                if run_llm:
                    base_ctx.update({"run_cluster_llm": True,
                                     "run_geo_llm": True, "run_agg_llm": True})
                ctx = build_eda_render_ctx(
                    db_path, table, prof, backend=backend, sample=sample,
                    base_ctx=base_ctx)
                meta = {"title": f"EDA — {table}", "ctx": ctx}
                aeda_pdf_target = os.path.join(report_dir,
                                               f"aeda_{table}_{ts}.pdf")
                aeda_pptx_target = os.path.join(report_dir,
                                                f"aeda_{table}_{ts}.pptx")
                rpdf = render_automatic_eda_pdf(prof, aeda_pdf_target, meta) or {}
                rpptx = render_automatic_eda_pptx(
                    prof, aeda_pptx_target, meta) or {}
                aeda_pdf_path = rpdf.get("path")
                aeda_pptx_path = rpptx.get("path")
                aeda_manifest_path = rpdf.get("manifest_path")
            except Exception:  # noqa: BLE001
                pass

        return {
            "status": "ok",
            "profile": prof,
            "report_md_path": report_md_path,
            "report_json_path": report_json_path,
            "pdf_path": pdf_path,
            "aeda_pdf_path": aeda_pdf_path,
            "aeda_pptx_path": aeda_pptx_path,
            "aeda_manifest_path": aeda_manifest_path,
        }
    except Exception as e:  # noqa: BLE001
        return {"status": "error", "error": str(e)}
