"""correlation_matrix_duckdb — matriz de correlacion de Pearson con push-down SQL.

Funcion impura: lee de disco a traves de DuckDB (via las primitivas read-only del
grupo `duckdb`: `duckdb_table_schema` para descubrir las columnas numericas y
`duckdb_query_readonly` para ejecutar la query de correlacion). Pertenece al grupo
de capacidad `eda` (exploratory data analysis).

Calcula la matriz de correlacion de Pearson entre columnas NUMERICAS de una tabla
DuckDB usando la funcion agregada nativa `corr()` del motor. TODO el calculo ocurre
en el motor de DuckDB (push-down): se construye UN solo SELECT con un `corr()` por
cada pareja (i < j) y se traen unicamente los coeficientes, nunca las filas. Esto la
hace apta para tablas grandes donde muestrear en Python (pandas/numpy) seria caro o
imposible.

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

import math
import re

from infra import duckdb_query_readonly, duckdb_table_schema

# Identificador SQL valido. DuckDB no admite parametros posicionales para nombres
# de tabla/columna, asi que hay que validar e interpolar citado con dobles comillas.
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")

# Tipos fisicos DuckDB que mapean a "numeric" y por tanto admiten corr().
_NUMERIC_TYPES = {
    "TINYINT", "SMALLINT", "INTEGER", "BIGINT", "HUGEINT",
    "UTINYINT", "USMALLINT", "UINTEGER", "UBIGINT", "UHUGEINT",
    "FLOAT", "REAL", "DOUBLE", "DECIMAL", "NUMERIC",
}


def _base_type(duckdb_type: str) -> str:
    """Normaliza un tipo DuckDB a su nombre base en mayusculas.

    DuckDB reporta tipos como 'DECIMAL(18,3)' o 'BIGINT'. Nos quedamos con el
    prefijo antes de '(' para mapearlo contra _NUMERIC_TYPES.
    """
    return duckdb_type.split("(", 1)[0].strip().upper()


def _quote(ident: str) -> str:
    """Cita un identificador SQL con dobles comillas (ya validado por el regex)."""
    return '"' + ident.replace('"', '""') + '"'


def correlation_matrix_duckdb(
    db_path: str,
    table: str,
    columns: list = None,
    strong_threshold: float = 0.7,
) -> dict:
    """Matriz de correlacion de Pearson entre columnas numericas, push-down en DuckDB.

    Args:
        db_path: ruta al archivo DuckDB. Debe existir (read_only NO crea la base).
        table: nombre de la tabla. Se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ antes
            de interpolarlo (DuckDB no admite parametros para identificadores).
        columns: lista de columnas numericas a correlacionar. Si es None (default),
            se descubren automaticamente leyendo el schema de la tabla y quedandose
            con las de tipo numerico DuckDB. Cada nombre se valida con el mismo regex.
        strong_threshold: umbral en valor absoluto para marcar una pareja como
            "fuerte" (default 0.7). Las parejas con abs(corr) >= threshold se devuelven
            ademas en `strong`, ordenadas por abs(corr) descendente.

    Returns:
        dict. En exito:
            {status:'ok',
             columns:[...],                          # columnas usadas, en orden
             matrix:{a:{b:corr, ...}, ...},          # matriz simetrica; diagonal=1.0
             pairs:[{a, b, corr}, ...],              # cada pareja i<j una vez
             strong:[{a, b, corr}, ...]}             # pares con abs(corr)>=threshold
        donde corr es float o None (columna constante / <2 valores -> corr() = NULL).
        Los pares con corr None se omiten de `strong`. En error (sin lanzar):
        {status:'error', error:str}.
    """
    # 1. Validar tabla.
    if not isinstance(table, str) or not _IDENT_RE.match(table):
        return {"status": "error", "error": f"invalid table identifier: {table!r}"}

    try:
        # 2. Resolver columnas numericas si no se especificaron.
        if columns is None:
            schema = duckdb_table_schema(db_path, table)
            if schema.get("status") != "ok":
                return {
                    "status": "error",
                    "error": "could not read schema: "
                    + str(schema.get("error", "unknown")),
                }
            columns = [
                col["name"]
                for col in schema.get("columns", [])
                if _base_type(col.get("type", "")) in _NUMERIC_TYPES
            ]

        # Validar cada nombre de columna.
        if not isinstance(columns, list):
            return {"status": "error", "error": "columns must be a list or None"}
        for col in columns:
            if not isinstance(col, str) or not _IDENT_RE.match(col):
                return {
                    "status": "error",
                    "error": f"invalid column identifier: {col!r}",
                }

        if len(columns) < 2:
            return {
                "status": "error",
                "error": "need at least 2 numeric columns to correlate, got "
                + str(len(columns)),
            }

        # 3. Construir UNA query con un corr() por pareja (i < j). El alias usa el
        # indice de cada columna (c0__c1) para evitar colisiones y nombres invalidos
        # cuando los nombres de columna son largos o repiten substrings.
        select_terms = []
        pair_index = []  # (i, j) en el mismo orden que los terminos del SELECT
        for i in range(len(columns)):
            for j in range(i + 1, len(columns)):
                alias = f"c{i}__c{j}"
                select_terms.append(
                    f"corr({_quote(columns[i])}, {_quote(columns[j])}) AS {alias}"
                )
                pair_index.append((i, j))

        sql = f"SELECT {', '.join(select_terms)} FROM {_quote(table)}"
        result = duckdb_query_readonly(db_path, sql, max_rows=1, sandbox=False)
        if result.get("status") != "ok":
            return {
                "status": "error",
                "error": "correlation query failed: "
                + str(result.get("error", "unknown")),
            }

        rows = result.get("rows", [])
        if not rows:
            # Tabla vacia: corr() de cero filas es NULL; devolvemos matriz de None.
            row = {}
        else:
            row = rows[0]

        # 4. Parsear a matriz simetrica.
        matrix = {a: {b: None for b in columns} for a in columns}
        for a in columns:
            matrix[a][a] = 1.0

        pairs = []
        for term_pos, (i, j) in enumerate(pair_index):
            alias = f"c{i}__c{j}"
            value = row.get(alias)
            # corr() devuelve NULL (cero filas) o NaN (varianza cero: columna
            # constante / <2 valores). Ambos casos significan "sin correlacion
            # definida": los normalizamos a None para que `strong` y la matriz
            # nunca contengan NaN.
            if value is None or (isinstance(value, float) and math.isnan(value)):
                corr = None
            else:
                corr = float(value)
            a, b = columns[i], columns[j]
            matrix[a][b] = corr
            matrix[b][a] = corr
            pairs.append({"a": a, "b": b, "corr": corr})

        strong = sorted(
            (p for p in pairs if p["corr"] is not None and abs(p["corr"]) >= strong_threshold),
            key=lambda p: abs(p["corr"]),
            reverse=True,
        )

        return {
            "status": "ok",
            "columns": list(columns),
            "matrix": matrix,
            "pairs": pairs,
            "strong": strong,
        }
    except Exception as e:  # noqa: BLE001
        return {"status": "error", "error": str(e)}
