"""load_bq_table_to_duckdb — adaptador BigQuery -> DuckDB local para el grupo `eda`.

Trae una tabla o vista de Google BigQuery a un archivo DuckDB local (por defecto
COMPLETA — todas las filas — o una muestra si se pasa `sample_frac`), de modo que
las funciones del grupo de capacidad `eda` (que perfilan DuckDB/PostgreSQL)
puedan analizarla sin un adaptador BigQuery nativo. Materializa una sola tabla
DuckDB desde el resultado de la query.

Modo por defecto = FULL: `sample_frac=None` trae la vista/tabla entera (preferencia
estándar del usuario: los EDA se corren sobre el total salvo que se pida lo
contrario). El muestreo es opt-in explícito: `sample_frac=0.05` trae ~5 %; `max_rows`
es un tope duro opcional (0 = sin tope).

Ingesta streaming Arrow -> DuckDB por batches: cuando `pyarrow` y el iterador
`to_arrow_iterable` están disponibles, el resultado se trae y materializa por
`pyarrow.RecordBatch`, insertando batch a batch en DuckDB. Así la RAM queda
acotada al tamaño de un batch y una tabla de decenas de millones de filas cabe sin
cargarse entera como DataFrame de pandas. Si `pyarrow`/`to_arrow_iterable` no están
disponibles, cae al camino DataFrame completo (que sí carga todo en RAM).

Filtrado en origen: `where_sql` aplica una cláusula WHERE SQL sobre la tabla origen
(y también al COUNT del origen, para contar las filas filtradas). `select_sql`
permite proyectar/castear expresiones concretas en el SELECT (vacío = `*`), útil
para castear tipos problemáticos (p. ej. BIGNUMERIC -> FLOAT64) antes de ingerir.

Seudonimización LOPDGDD/RGPD: las columnas listadas en `pseudonymize_cols` se
transforman con un hash SHA-1 truncado ANTES de escribir a disco, preservando
nulos, cardinalidad y patrón de faltantes pero sin volcar el valor real (DNI,
nombre, email, teléfono, etc.). En el camino streaming se aplica POR BATCH antes de
insertar. El EDA conserva su valor analítico sin incrustar datos personales reales.

Autenticación: ADC (gcloud auth). Aplica creds.with_quota_project(None) para
evitar el 403 USER_PROJECT_DENIED cuando el ADC lleva quota project ajeno. El
cliente BigQuery Storage (usado por el streaming Arrow) se crea con esas MISMAS
credenciales corregidas.

Estilo dict-no-throw del grupo `eda`: nunca lanza; devuelve {status:'error', ...}.
"""

import hashlib
import re

_FQN_RE = re.compile(r"^[A-Za-z0-9_.\-]+$")


def _pseudonymize_series(values):
    """Hash SHA-1 truncado (12 hex) de cada valor no nulo; conserva None/NaN."""
    import pandas as pd
    out = []
    for v in values:
        if v is None or (isinstance(v, float) and pd.isna(v)) or (
            not isinstance(v, (list, dict)) and pd.isna(v) if _safe_isna(v) else False
        ):
            out.append(None)
        else:
            h = hashlib.sha1(str(v).encode("utf-8")).hexdigest()[:12]
            out.append(h)
    return out


def _safe_isna(v):
    import pandas as pd
    try:
        return bool(pd.isna(v))
    except (TypeError, ValueError):
        return False


def _sanitize_dest_table(dest_table: str, table_fqn: str) -> str:
    """Nombre de tabla DuckDB destino saneado (helper puro, testeable sin red).

    Reglas:
      - `dest_table` vacío -> último segmento del FQN.
      - Si el resultado no casa `^[A-Za-z_][A-Za-z0-9_]*$`, cada carácter inválido
        se sustituye por `_`; si quedara vacío se usa `bq_table`.
    """
    dest = dest_table or table_fqn.split(".")[-1]
    if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", dest):
        dest = re.sub(r"[^A-Za-z0-9_]", "_", dest) or "bq_table"
    return dest


def _build_source_sql(
    table_fqn: str,
    select_sql: str = "",
    where_sql: str = "",
    sample_frac: float = None,
    max_rows: int = 0,
) -> str:
    """Compone el SELECT sobre la tabla/vista origen de BigQuery (helper puro).

    Sin efectos: solo construye la cadena SQL, testeable sin red.

    SEGURIDAD: `select_sql` y `where_sql` se interpolan TAL CUAL (no se escapan),
    igual que `table_fqn`, por lo que NO deben construirse a partir de input no
    confiable (riesgo de inyección SQL).

    Reglas:
      - `select_sql` vacío -> `SELECT *`; en otro caso `SELECT <select_sql>`.
      - `where_sql` y el muestreo (`rand() < sample_frac`, para `sample_frac` en
        (0,1)) se combinan con AND. Si hay más de una condición cada una se
        envuelve en paréntesis para respetar la precedencia de operadores.
      - `max_rows` > 0 añade un `LIMIT` como tope duro.
    """
    select_expr = select_sql.strip() if (select_sql and select_sql.strip()) else "*"

    conditions = []
    ws = (where_sql or "").strip()
    if ws:
        conditions.append(ws)
    if sample_frac is not None and 0 < float(sample_frac) < 1:
        conditions.append(f"rand() < {float(sample_frac)}")

    if len(conditions) > 1:
        where = " WHERE " + " AND ".join(f"({c})" for c in conditions)
    elif conditions:
        where = " WHERE " + conditions[0]
    else:
        where = ""

    limit = f" LIMIT {int(max_rows)}" if max_rows and int(max_rows) > 0 else ""
    return f"SELECT {select_expr} FROM `{table_fqn}`{where}{limit}"


def _decimal256_columns(schema) -> list:
    """Nombres de columnas Arrow de tipo decimal256 (BigQuery BIGNUMERIC).

    DuckDB no ingiere decimal256 directamente; se usa para dar un error claro que
    recomiende castear esas columnas a FLOAT64 vía `select_sql`.
    """
    import pyarrow as pa
    return [f.name for f in schema if pa.types.is_decimal256(f.type)]


def _auto_select_exprs(schema_fields) -> tuple:
    """Construye el SELECT auto-casteado desde el schema BigQuery (helper puro).

    Recibe la lista de campos top-level del schema de BigQuery
    (`google.cloud.bigquery.SchemaField` o cualquier objeto con `.name`,
    `.field_type` y `.mode`) y devuelve `(select_sql, auto_casts)`:

      - BIGNUMERIC              -> CAST(col AS FLOAT64)   (Arrow decimal256, DuckDB no lo ingiere)
      - REPEATED / RECORD / JSON -> TO_JSON_STRING(col)   (arrays/structs rompen profile_table:
                                                           "unhashable type: 'list'")
      - GEOGRAPHY               -> ST_ASTEXT(col)         (WKT string)
      - resto                   -> col sin tocar

    Si ninguna columna necesita transformación devuelve ("", {}) para que el
    caller use `SELECT *` (comportamiento previo intacto).
    """
    exprs = []
    auto_casts = {}
    for f in schema_fields:
        name = f.name
        ftype = (f.field_type or "").upper()
        mode = (getattr(f, "mode", "") or "").upper()
        if mode == "REPEATED" or ftype in ("RECORD", "STRUCT", "JSON"):
            exprs.append(f"TO_JSON_STRING(`{name}`) AS `{name}`")
            auto_casts[name] = "TO_JSON_STRING"
        elif ftype == "BIGNUMERIC":
            exprs.append(f"CAST(`{name}` AS FLOAT64) AS `{name}`")
            auto_casts[name] = "CAST_FLOAT64"
        elif ftype == "GEOGRAPHY":
            exprs.append(f"ST_ASTEXT(`{name}`) AS `{name}`")
            auto_casts[name] = "ST_ASTEXT"
        else:
            exprs.append(f"`{name}`")
    if not auto_casts:
        return "", {}
    return ", ".join(exprs), auto_casts


def _pseudonymize_arrow_table(batch, pseudo_set: set, pseudo_applied: list):
    """Envuelve un `pyarrow.RecordBatch` en una `pyarrow.Table`, hasheando las PII.

    Las columnas no listadas en `pseudo_set` conservan su tipo Arrow NATIVO (DATE,
    TIME, TIMESTAMP incluidos), que DuckDB ingiere directamente sin normalización.
    Solo las columnas PII se reescriben a string con el hash SHA-1 truncado.

    Muta `pseudo_applied` in situ (añade el nombre de cada columna seudonimizada la
    primera vez que aparece).
    """
    import pyarrow as pa
    if not pseudo_set:
        return pa.Table.from_batches([batch])
    names = list(batch.schema.names)
    arrays = []
    for i, name in enumerate(names):
        col = batch.column(i)
        if name in pseudo_set:
            hashed = _pseudonymize_series(col.to_pylist())
            arrays.append(pa.array(hashed, type=pa.string()))
            if name not in pseudo_applied:
                pseudo_applied.append(name)
        else:
            arrays.append(col)
    new_batch = pa.RecordBatch.from_arrays(arrays, names=names)
    return pa.Table.from_batches([new_batch])


def load_bq_table_to_duckdb(
    table_fqn: str,
    duckdb_path: str,
    dest_table: str = "",
    sample_frac: float = None,
    max_rows: int = 0,
    project_id: str = "",
    pseudonymize_cols: list = None,
    where_sql: str = "",
    select_sql: str = "",
) -> dict:
    try:
        import duckdb
        import google.auth
        from google.cloud import bigquery

        if not table_fqn or not _FQN_RE.match(table_fqn):
            return {"status": "error", "error": f"table_fqn inválido: {table_fqn!r}"}

        # dest_table: derivar del último segmento del FQN si no se pasa, saneado.
        dest = _sanitize_dest_table(dest_table, table_fqn)

        # Auth ADC con fix de quota project (403 USER_PROJECT_DENIED).
        creds, adc_project = google.auth.default(
            scopes=["https://www.googleapis.com/auth/bigquery"]
        )
        if hasattr(creds, "with_quota_project"):
            creds = creds.with_quota_project(None)
        proj = project_id or table_fqn.split(".")[0] or adc_project
        client = bigquery.Client(project=proj, credentials=creds)

        # Auto-cast de tipos problemáticos: si el caller no proyecta un
        # select_sql propio, se inspecciona el schema del origen y se castean
        # automáticamente BIGNUMERIC -> FLOAT64 (Arrow decimal256 que DuckDB no
        # ingiere), REPEATED/RECORD/JSON -> TO_JSON_STRING (los LIST/STRUCT
        # rompen el perfilado aguas abajo) y GEOGRAPHY -> ST_ASTEXT. Best-effort:
        # si el schema no se puede leer, se sigue con SELECT * como antes.
        auto_casts = {}
        if not (select_sql and select_sql.strip()):
            try:
                src = client.get_table(table_fqn)
                auto_sel, auto_casts = _auto_select_exprs(src.schema)
                if auto_sel:
                    select_sql = auto_sel
            except Exception:  # noqa: BLE001
                auto_casts = {}

        # Conteo de filas del origen FILTRADO: aplica el mismo `where_sql` (cuenta
        # las filas que se van a traer, no la tabla entera). El muestreo NO entra
        # en el conteo (es un submuestreo aparte del origen filtrado).
        count_where = ""
        _ws = (where_sql or "").strip()
        if _ws:
            count_where = f" WHERE {_ws}"
        cnt = client.query(
            f"SELECT COUNT(*) AS n FROM `{table_fqn}`{count_where}"
        ).result()
        n_source = 0
        for row in cnt:
            n_source = int(row["n"])

        # Modo por defecto = FULL (sample_frac=None -> todas las filas). El
        # muestreo es opt-in: sample_frac in (0,1) muestrea esa fracción con
        # `rand() < frac`, combinado con `where_sql` vía AND. max_rows>0 es un tope
        # duro opcional (LIMIT). `select_sql` proyecta expresiones (vacío = `*`).
        sampled = sample_frac is not None and 0 < float(sample_frac) < 1
        sql = _build_source_sql(table_fqn, select_sql, where_sql, sample_frac, max_rows)

        # ¿Está pyarrow disponible? Decide el camino de ingesta ANTES de consumir
        # el resultado (streaming Arrow por batches vs DataFrame completo).
        try:
            import pyarrow  # noqa: F401
            has_pyarrow = True
        except Exception:  # noqa: BLE001
            has_pyarrow = False

        job = client.query(sql)
        result = job.result()
        use_stream = has_pyarrow and hasattr(result, "to_arrow_iterable")

        pseudo_set = set(pseudonymize_cols or [])
        pseudo_applied = []
        n_fetched = 0
        columns = []
        streamed = False

        con = duckdb.connect(duckdb_path)
        try:
            if use_stream:
                # Cliente BigQuery Storage con las MISMAS creds corregidas
                # (quota None). Si la lib no está, to_arrow_iterable cae al
                # transporte REST-Arrow con bqstorage_client=None.
                try:
                    from google.cloud import bigquery_storage
                    bqstorage_client = bigquery_storage.BigQueryReadClient(
                        credentials=creds
                    )
                except Exception:  # noqa: BLE001
                    bqstorage_client = None

                first = True
                for batch in result.to_arrow_iterable(
                    bqstorage_client=bqstorage_client
                ):
                    # Seudonimización PII POR BATCH; no PII conserva tipo Arrow.
                    tbl = _pseudonymize_arrow_table(batch, pseudo_set, pseudo_applied)

                    # Gotcha BIGNUMERIC: decimal256 no lo ingiere DuckDB. Detectar
                    # en el primer batch y devolver un error claro que recomiende
                    # castear a FLOAT64 vía select_sql (no intentar magia de tipos).
                    if first:
                        dcols = _decimal256_columns(tbl.schema)
                        if dcols:
                            return {
                                "status": "error",
                                "error": (
                                    "Ingesta Arrow bloqueada: columnas BIGNUMERIC "
                                    f"(Arrow decimal256) que DuckDB no ingiere: {dcols}. "
                                    "Castéalas a FLOAT64 con select_sql, p. ej. "
                                    "select_sql='..., CAST(col AS FLOAT64) AS col, ...'."
                                ),
                                "stage": "stream_schema",
                            }

                    con.register("_batch_arrow", tbl)
                    try:
                        if first:
                            con.execute(
                                f'CREATE OR REPLACE TABLE "{dest}" '
                                f"AS SELECT * FROM _batch_arrow"
                            )
                            columns = list(tbl.schema.names)
                            first = False
                        else:
                            con.execute(
                                f'INSERT INTO "{dest}" SELECT * FROM _batch_arrow'
                            )
                    except Exception as ie:  # noqa: BLE001
                        msg = str(ie).lower()
                        if "decimal256" in msg or ("decimal" in msg and "256" in msg):
                            return {
                                "status": "error",
                                "error": (
                                    "Ingesta Arrow falló por columna BIGNUMERIC "
                                    "(Arrow decimal256) que DuckDB no ingiere. Castea "
                                    "esas columnas a FLOAT64 con select_sql. Detalle: "
                                    + str(ie)
                                ),
                                "stage": "stream_insert",
                            }
                        raise
                    finally:
                        con.unregister("_batch_arrow")
                    n_fetched += tbl.num_rows

                # Origen vacío: si el iterable no emitió ningún batch, materializa
                # una tabla vacía con el esquema del origen (evita que aguas abajo
                # falle por "tabla inexistente"). job.result() da un iterador fresco.
                if first:
                    empty_df = job.result().to_dataframe(create_bqstorage_client=False)
                    con.register("_empty_df", empty_df)
                    con.execute(
                        f'CREATE OR REPLACE TABLE "{dest}" AS SELECT * FROM _empty_df'
                    )
                    con.unregister("_empty_df")
                    columns = list(empty_df.columns)
                streamed = True
            else:
                # Fallback: camino DataFrame completo (carga TODO el resultado en
                # RAM). Mismo comportamiento que antes del streaming Arrow.
                try:
                    df = result.to_dataframe(create_bqstorage_client=True)
                except Exception:  # noqa: BLE001
                    df = job.result().to_dataframe(create_bqstorage_client=False)
                n_fetched = len(df)

                # Normalizar dtypes de db-dtypes (solo camino pandas): el conversor
                # REST mapea DATE/TIME a las extension dtypes `dbdate`/`dbtime` de
                # db-dtypes, que DuckDB NO reconoce al registrar el DataFrame. Se
                # convierten a tipos estándar: DATE -> datetime64[ns], TIME ->
                # string. En el camino Arrow esto no aplica (tipos Arrow nativos).
                import pandas as pd
                for col in df.columns:
                    dt = str(df[col].dtype)
                    if dt == "dbdate":
                        df[col] = pd.to_datetime(df[col], errors="coerce")
                    elif dt == "dbtime":
                        df[col] = df[col].astype("string").astype(object)

                # Seudonimización de columnas PII antes de escribir a disco.
                for col in (pseudonymize_cols or []):
                    if col in df.columns:
                        df[col] = _pseudonymize_series(df[col].tolist())
                        pseudo_applied.append(col)

                con.register("_src_df", df)
                con.execute(
                    f'CREATE OR REPLACE TABLE "{dest}" AS SELECT * FROM _src_df'
                )
                con.unregister("_src_df")
                columns = list(df.columns)
        finally:
            con.close()

        return {
            "status": "ok",
            "duckdb_path": duckdb_path,
            "table": dest,
            "n_rows_source": n_source,
            "n_rows_fetched": n_fetched,
            "sampled": sampled,
            "sample_frac": float(sample_frac) if sampled else None,
            "columns": columns,
            "pseudonymized": pseudo_applied,
            "streamed": streamed,
            "auto_casts": auto_casts,
        }
    except Exception as e:  # noqa: BLE001
        return {"status": "error", "error": str(e)}
