"""Sincroniza un catalogo de data quality checks desde un .xlsx a BigQuery.

El Excel es la fuente de verdad: cada corrida reemplaza la tabla destino entera
(WRITE_TRUNCATE). Antes de cargar, valida cada fila y (opcionalmente) hace un
dry-run del assert_sql contra BigQuery para cazar SQL invalido en el momento.
"""

import csv
import os
import re
import tempfile
from datetime import datetime, timezone

# Columnas del schema destino, en orden. El CSV temporal y el load respetan
# exactamente este orden.
SCHEMA_COLUMNS = [
    "check_id",
    "tipo",
    "subcategoria",
    "pregunta",
    "assert_sql",
    "threshold",
    "severity",
    "enabled",
    "owner",
    "created_at",
    "updated_at",
]

# Cabeceras que se leen del Excel (sin created_at/updated_at, que se generan).
INPUT_HEADERS = [
    "check_id",
    "tipo",
    "subcategoria",
    "pregunta",
    "assert_sql",
    "threshold",
    "severity",
    "enabled",
    "owner",
]

VALID_TIPOS = {"fiabilidad", "negocio"}
VALID_SEVERITIES = {"info", "warning", "critical"}
TRUE_TOKENS = {"true", "1", "si", "sí", "x", "yes", "verdadero"}
FALSE_TOKENS = {"false", "0", "no", "", "falso"}


def _slug(text: str) -> str:
    """Pasa un texto a slug: minusculas, no-alfanumericos -> _, colapsa y recorta."""
    s = (text or "").strip().lower()
    s = re.sub(r"[^a-z0-9]+", "_", s)
    s = re.sub(r"_+", "_", s)
    return s.strip("_")


def _norm(value) -> str:
    """Normaliza una celda a string strip-eado. None -> ''."""
    if value is None:
        return ""
    return str(value).strip()


def _parse_bool(value) -> bool:
    """Interpreta TRUE/1/si/sí/x como True; FALSE/0/vacio como False. Default True."""
    token = _norm(value).lower()
    if token in TRUE_TOKENS:
        return True
    if token in FALSE_TOKENS:
        return False
    # Valor desconocido no vacio: tratar como True (la celda trae algo afirmativo).
    return True


def _parse_threshold(value):
    """Devuelve (int>=0, None) si valido, o (None, motivo) si no.

    Vacio/None -> 0. Acepta enteros y floats que sean enteros.
    """
    token = _norm(value)
    if token == "":
        return 0, None
    try:
        num = float(token.replace(",", "."))
    except (ValueError, TypeError):
        return None, f"threshold no es numerico: {token!r}"
    if num != int(num):
        return None, f"threshold debe ser entero, recibido: {token!r}"
    num = int(num)
    if num < 0:
        return None, f"threshold debe ser >= 0, recibido: {num}"
    return num, None


def _parse_catalog_rows(rows: list[dict]) -> tuple[list[dict], list[dict]]:
    """Parsea, valida y normaliza filas crudas del catalogo. Funcion pura.

    Args:
        rows: lista de dicts cabecera->valor crudo, una por fila del Excel.

    Returns:
        (valid, skipped) donde:
          - valid: lista de dicts con las 11 columnas del schema destino,
            check_id unico garantizado, created_at/updated_at en ISO 8601 UTC.
          - skipped: lista de dicts {"row": dict_original, "reason": str}.
    """
    now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")

    valid: list[dict] = []
    skipped: list[dict] = []
    seen_ids: set[str] = set()

    for raw in rows:
        # Ignora filas totalmente vacias.
        if all(_norm(raw.get(h)) == "" for h in INPUT_HEADERS):
            continue

        tipo = _norm(raw.get("tipo")).lower()
        subcategoria = _norm(raw.get("subcategoria"))
        pregunta = _norm(raw.get("pregunta"))
        assert_sql = _norm(raw.get("assert_sql"))
        severity = _norm(raw.get("severity")).lower()
        owner = _norm(raw.get("owner"))
        check_id = _norm(raw.get("check_id"))

        # Validaciones.
        if tipo not in VALID_TIPOS:
            skipped.append({"row": raw, "reason": f"tipo invalido: {tipo!r} (esperado fiabilidad|negocio)"})
            continue
        if pregunta == "":
            skipped.append({"row": raw, "reason": "pregunta vacia"})
            continue
        if assert_sql == "":
            skipped.append({"row": raw, "reason": "assert_sql vacia"})
            continue

        if severity == "":
            severity = "warning"
        elif severity not in VALID_SEVERITIES:
            skipped.append({"row": raw, "reason": f"severity invalida: {severity!r} (esperado info|warning|critical)"})
            continue

        threshold, threshold_err = _parse_threshold(raw.get("threshold"))
        if threshold_err is not None:
            skipped.append({"row": raw, "reason": threshold_err})
            continue

        enabled = _parse_bool(raw.get("enabled"))

        # Auto-deriva check_id si viene vacio.
        if check_id == "":
            check_id = f"{tipo}__{_slug(subcategoria)}_{_slug(pregunta)[:40]}"

        # Garantiza unicidad sufijando _2, _3, ...
        base_id = check_id
        suffix = 2
        while check_id in seen_ids:
            check_id = f"{base_id}_{suffix}"
            suffix += 1
        seen_ids.add(check_id)

        valid.append({
            "check_id": check_id,
            "tipo": tipo,
            "subcategoria": subcategoria,
            "pregunta": pregunta,
            "assert_sql": assert_sql,
            "threshold": threshold,
            "severity": severity,
            "enabled": enabled,
            "owner": owner,
            "created_at": now_iso,
            "updated_at": now_iso,
        })

    return valid, skipped


def _read_xlsx_rows(xlsx_path: str, sheet_name: str) -> list[dict]:
    """Lee la hoja indicada del .xlsx y devuelve filas como dicts cabecera->valor.

    La fila 1 son las cabeceras. Localiza las columnas por nombre de cabecera,
    no por posicion. Devuelve solo las cabeceras conocidas (INPUT_HEADERS).
    """
    from openpyxl import load_workbook

    wb = load_workbook(filename=xlsx_path, read_only=True, data_only=True)
    if sheet_name not in wb.sheetnames:
        wb.close()
        raise ValueError(
            f"hoja {sheet_name!r} no encontrada en {xlsx_path}. Hojas: {wb.sheetnames}"
        )
    ws = wb[sheet_name]

    rows_iter = ws.iter_rows(values_only=True)
    try:
        header_row = next(rows_iter)
    except StopIteration:
        wb.close()
        return []

    # Mapa cabecera-normalizada -> indice de columna.
    header_index: dict[str, int] = {}
    for idx, cell in enumerate(header_row):
        name = _norm(cell).lower()
        if name in INPUT_HEADERS:
            header_index[name] = idx

    rows: list[dict] = []
    for excel_row in rows_iter:
        row_dict = {}
        for header in INPUT_HEADERS:
            idx = header_index.get(header)
            if idx is not None and idx < len(excel_row):
                row_dict[header] = excel_row[idx]
            else:
                row_dict[header] = None
        rows.append(row_dict)

    wb.close()
    return rows


def sync_xlsx_to_dq_checks(
    xlsx_path: str,
    project: str = "autingo-159109",
    dataset: str = "data_quality",
    table: str = "dq_checks",
    sheet_name: str = "catalogo",
    validate_sql: bool = True,
) -> dict:
    """Sincroniza un catalogo de data quality checks desde un .xlsx a BigQuery.

    Lee la hoja del Excel, valida/normaliza cada fila, (opcionalmente) hace un
    dry-run de cada assert_sql contra BigQuery, y carga las filas validas a
    project.dataset.table con WRITE_TRUNCATE (reemplazo total: el xlsx manda).

    Args:
        xlsx_path: ruta al archivo .xlsx del catalogo.
        project: proyecto GCP destino. Default "autingo-159109".
        dataset: dataset BigQuery destino. Default "data_quality".
        table: tabla BigQuery destino. Default "dq_checks".
        sheet_name: nombre de la hoja a leer. Default "catalogo".
        validate_sql: si True, dry-run de cada assert_sql contra BQ antes de cargar.

    Returns:
        dict con {"loaded": int, "skipped": int, "sql_errors": [...], "rows": [...]}
        donde rows es la lista de filas cargadas (con sus campos normalizados).

    Raises:
        FileNotFoundError: si xlsx_path no existe.
        ValueError: si la hoja no existe en el libro.
        google.api_core.exceptions.GoogleAPICallError: si la carga a BQ falla.
    """
    from bigquery import bq_auth, bq_query, bq_load_from_file

    if not os.path.exists(xlsx_path):
        raise FileNotFoundError(f"no existe el archivo xlsx: {xlsx_path}")

    raw_rows = _read_xlsx_rows(xlsx_path, sheet_name)
    valid, skipped = _parse_catalog_rows(raw_rows)

    client = bq_auth(project_id=project)

    sql_errors: list[dict] = []
    loadable: list[dict] = []

    if validate_sql:
        for row in valid:
            try:
                bq_query(client, row["assert_sql"], dry_run=True)
                loadable.append(row)
            except Exception as exc:  # noqa: BLE001 — capturamos cualquier fallo de BQ
                sql_errors.append({"check_id": row["check_id"], "error": str(exc)})
    else:
        loadable = valid

    # Escribe las filas cargables a un CSV temporal con QUOTE_ALL: el assert_sql
    # puede contener comas, comillas y saltos de linea.
    tmp_fd, tmp_path = tempfile.mkstemp(suffix=".csv", prefix="dq_checks_")
    os.close(tmp_fd)
    try:
        with open(tmp_path, "w", newline="", encoding="utf-8") as f:
            writer = csv.writer(f, quoting=csv.QUOTE_ALL)
            writer.writerow(SCHEMA_COLUMNS)
            for row in loadable:
                writer.writerow([
                    row["check_id"],
                    row["tipo"],
                    row["subcategoria"],
                    row["pregunta"],
                    row["assert_sql"],
                    row["threshold"],
                    row["severity"],
                    # BigQuery CSV BOOL acepta true/false en minusculas.
                    "true" if row["enabled"] else "false",
                    row["owner"],
                    row["created_at"],
                    row["updated_at"],
                ])

        bq_load_from_file(
            client,
            tmp_path,
            dataset,
            table,
            source_format="CSV",
            write_disposition="WRITE_TRUNCATE",
            skip_leading_rows=1,
            autodetect=False,
        )
    finally:
        if os.path.exists(tmp_path):
            os.remove(tmp_path)

    return {
        "loaded": len(loadable),
        "skipped": len(skipped),
        "sql_errors": sql_errors,
        "rows": loadable,
    }


if __name__ == "__main__":
    import json
    import sys

    path = sys.argv[1] if len(sys.argv) > 1 else "/mnt/c/Users/egutierrez/Documents/dq_catalogo_asserts.xlsx"
    result = sync_xlsx_to_dq_checks(path)
    print(json.dumps({k: v for k, v in result.items() if k != "rows"}, ensure_ascii=False, indent=2))
    print(f"loaded={result['loaded']} skipped={result['skipped']} sql_errors={len(result['sql_errors'])}")
