"""extract_null_mask — extrae la mascara de nulos (1=falta / 0=presente) de una tabla.

Lector read-only inyectado: recibe `query_fn(sql) -> dict` con el mismo contrato
que duckdb_query_readonly / pg_query (y que el `_q` de profile_table):
`{"status": "ok", "rows": [{col: val, ...}, ...]}`. Esta funcion NO abre ninguna
conexion por su cuenta — solo usa `query_fn`. Construye UNA sola query que, por
cada columna pedida, evalua `CASE WHEN "col" IS NULL THEN 1 ELSE 0 END` y devuelve
una muestra de filas con esos bits. El resultado es un dict `mask` con una lista
0/1 por columna, alineada por fila (1 = el valor falta / IS NULL, 0 = presente),
listo para alimentar el capitulo de calidad / patron de nulos de AutomaticEDA sin
que el capitulo toque la base de datos.

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


def _to_bit(value):
    """Coacciona el valor 0/1 del CASE a int de forma defensiva.

    El SQL ya devuelve 0 (presente) o 1 (falta). Por si una celda llega como None
    (el CASE no se aplico o el backend la nulifico), se cuenta como 1 (falta). El
    resto se reduce a int: un entero distinto de 0 cuenta como 1 (falta), 0 como
    presente. Un valor no convertible se trata como presente (0) — nunca lanza.
    """
    if value is None:
        return 1
    try:
        return 1 if int(value) != 0 else 0
    except (TypeError, ValueError):
        return 0


def extract_null_mask(query_fn, table, columns, max_rows=5000):
    """Extrae la mascara de nulos (1=falta / 0=presente) de una muestra de la tabla.

    Args:
        query_fn: callable lector read-only del backend activo. Recibe un string
            SQL y devuelve un dict {"status": "ok", "rows": [{col: val, ...}]}
            (mismo contrato que duckdb_query_readonly / el `_q` de profile_table).
            No se abre ninguna conexion aqui: toda la lectura pasa por query_fn.
        table: nombre de la tabla. Se escapa con comillas dobles en la query.
        columns: lista de nombres de columna a evaluar. Cada una produce una
            entrada en `mask` con una lista 0/1 paralela por fila. Vacia o None ->
            status error.
        max_rows: limite de filas a muestrear (clausula LIMIT). Default 5000.

    Returns:
        dict (nunca lanza):
            {
              "status": "ok" | "error",
              "error": str,                 # solo si status == "error"
              "table": str,
              "columns": [str, ...],        # columnas efectivamente leidas, en orden
              "mask": {col: [int 0/1, ...], ...},  # alineada por fila, 1=falta, 0=presente
              "n": int                      # nº de filas muestreadas
            }
        Todas las listas de `mask` tienen la misma longitud (= n).
    """
    base = {"status": "ok", "table": table, "columns": [], "mask": {}, "n": 0}
    try:
        if query_fn is None:
            return {**base, "status": "error", "error": "query_fn es None"}
        if not table:
            return {**base, "status": "error", "error": "table es obligatorio"}
        if not columns:
            return {**base, "status": "error", "error": "columns vacío"}

        # Identificadores escapados con comillas dobles (como hace profile_table)
        # para tolerar nombres con mayusculas/espacios/palabras reservadas. Cada
        # columna se proyecta como su propio bit IS NULL conservando el alias.
        select_sql = ", ".join(
            f'(CASE WHEN "{c}" IS NULL THEN 1 ELSE 0 END) AS "{c}"' for c in columns
        )
        sql = f'SELECT {select_sql} FROM "{table}" LIMIT {int(max_rows)}'

        q = query_fn(sql)
        if not isinstance(q, dict) or q.get("status") != "ok":
            err = (
                q.get("error", "query_fn fallo")
                if isinstance(q, dict)
                else "query_fn no devolvio un dict"
            )
            return {**base, "status": "error", "error": err}

        rows = q.get("rows", []) or []
        mask = {c: [] for c in columns}
        for row in rows:
            for c in columns:
                # row.get tolera filas que no traigan la columna (None -> falta).
                mask[c].append(_to_bit(row.get(c) if isinstance(row, dict) else None))

        return {
            "status": "ok",
            "table": table,
            "columns": list(columns),
            "mask": mask,
            "n": len(rows),
        }
    except Exception as e:  # noqa: BLE001 - dict-no-throw: degradar, nunca lanzar
        return {**base, "status": "error", "error": str(e)}
