"""infer_fk_containment_duckdb — infiere FOREIGN KEYs candidatas por containment.

Funcion impura: lee de disco a traves de DuckDB (via las primitivas read-only del
grupo `duckdb`: duckdb_list_tables, duckdb_table_schema, duckdb_query_readonly).
Pertenece al grupo de capacidad `eda` (relaciones inter-tabla): descubre que
columnas de una tabla son una clave foranea probable hacia la clave de otra,
SIN que la base la haya declarado.

Idea: para un par (columna A de T1, columna B de T2), la inclusion (o containment)
de A en B es:

    inclusion(A subseteq B) = |distinct(A) interseccion distinct(B)| / |distinct(A)|

Si inclusion >= min_inclusion y B "parece clave" (alta unicidad en T2, distinct(B)
/ count(T2) >= 0.95), entonces A -> B es una FK candidata. Todo se calcula con
push-down en el motor de DuckDB (COUNT DISTINCT / INTERSECT); nunca se traen filas
a RAM.

PODA por tipo: solo se evaluan pares cuyas columnas comparten tipo base (ambos
enteros, ambos varchar, ambos fecha, ...). Esto evita el O(n^2) de calcular
containment para todos los pares de columnas, y descarta pares incompatibles que
nunca podrian ser una FK real.

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

import re

from infra import (
    duckdb_list_tables,
    duckdb_query_readonly,
    duckdb_table_schema,
)

# Identificador SQL valido. Los nombres de tabla/columna se interpolan citados en
# el SQL (COUNT DISTINCT / INTERSECT no admiten parametros posicionales para
# identificadores), asi que se validan antes de tocar la base.
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")

# Clases de tipo base. Dos columnas solo se comparan si caen en la misma clase.
# Agrupar por clase (no por tipo exacto) permite emparejar INTEGER con BIGINT,
# DECIMAL con DOUBLE, etc. — combinaciones legitimas de FK numerica.
_INTEGER_TYPES = {
    "TINYINT", "SMALLINT", "INTEGER", "BIGINT", "HUGEINT",
    "UTINYINT", "USMALLINT", "UINTEGER", "UBIGINT", "UHUGEINT",
}
_FLOAT_TYPES = {"FLOAT", "REAL", "DOUBLE", "DECIMAL", "NUMERIC"}
_TEXT_TYPES = {"VARCHAR", "TEXT", "STRING", "CHAR", "BPCHAR", "UUID"}
_DATETIME_TYPES = {
    "DATE", "TIME", "TIMESTAMP", "DATETIME",
    "TIMESTAMP_S", "TIMESTAMP_MS", "TIMESTAMP_NS", "TIMESTAMP_US",
    "TIMESTAMP WITH TIME ZONE", "TIMESTAMPTZ", "TIMETZ",
}
_BOOL_TYPES = {"BOOLEAN", "BOOL"}


def _base_physical_type(column_type: str) -> str:
    """Normaliza un tipo fisico DuckDB a su forma base en mayusculas.

    Quita parametros (DECIMAL(10,2) -> DECIMAL) y modificadores de array
    (INTEGER[] -> INTEGER) para poder mapearlo a una clase de tipo.
    """
    t = (column_type or "").strip().upper()
    t = re.sub(r"\[.*\]$", "", t).strip()   # INTEGER[] -> INTEGER
    t = re.sub(r"\(.*\)$", "", t).strip()   # VARCHAR(50) -> VARCHAR
    return t


def _type_class(column_type: str) -> str:
    """Mapea un tipo fisico DuckDB a una clase comparable.

    Devuelve 'integer' | 'float' | 'text' | 'datetime' | 'boolean' | 'other'.
    Dos columnas solo se consideran emparejables para FK si comparten clase y la
    clase no es 'other'. Entero y float NO se mezclan: una FK entera contra una
    columna float es semanticamente sospechosa y casi nunca una FK real.
    """
    base = _base_physical_type(column_type)
    if base in _INTEGER_TYPES:
        return "integer"
    if base in _FLOAT_TYPES:
        return "float"
    if base in _TEXT_TYPES:
        return "text"
    if base in _DATETIME_TYPES:
        return "datetime"
    if base in _BOOL_TYPES:
        return "boolean"
    return "other"


def _valid_idents(*names) -> bool:
    """True si todos los identificadores casan con ^[A-Za-z_][A-Za-z0-9_]*$."""
    return all(isinstance(n, str) and _IDENT_RE.match(n) for n in names)


# --- Señal de nombre (precisión de FK, issues H3 + H10) -----------------------
# La contención de valores por si sola produce 10-20x falsos positivos: cualquier
# clave entera pequeña (1..N) esta contenida en cualquier clave mas grande, y las
# columnas de medida (cantidades, importes) caen dentro del rango de los ids. La
# señal mas fuerte de una FK real es el NOMBRE de la columna: el patron canonico
# `<X>Id -> <X>.<X>Id` (PascalCase de chinook) o `<x>_id -> <x>.<x>_id` (snake_case
# de sakila). Filtrar candidatos por nombre ANTES del INTERSECT corrige precision
# y rendimiento a la vez (menos pares que evaluar).


def _norm(s) -> str:
    """Normaliza un identificador: minusculas, solo [a-z0-9] (quita `_`, espacios)."""
    return re.sub(r"[^a-z0-9]", "", str(s).lower())


def _singular(s: str) -> str:
    """Singular ingles aproximado (KISS): customers->customer, cities->city.

    Heuristica suficiente para casar columna `<x>_id` con tabla `<x>s`/`<x>`.
    """
    if len(s) > 4 and s.endswith("ies"):
        return s[:-3] + "y"
    if len(s) > 3 and s.endswith("s") and not s.endswith("ss"):
        return s[:-1]
    return s


def _ends_id(norm: str) -> bool:
    """True si el nombre normalizado termina en `id` (incluye el propio `id`)."""
    return norm.endswith("id") and norm != ""


def _id_stem(norm: str) -> str:
    """Quita el sufijo `id` de un nombre ya normalizado: albumid->album, id->''."""
    return norm[:-2] if norm.endswith("id") else norm


def _name_eq(a, b) -> bool:
    """Igualdad de nombres tolerante a singular/plural (normaliza ambos lados)."""
    na, nb = _norm(a), _norm(b)
    if not na or not nb:
        return False
    return _singular(na) == _singular(nb)


def _col_is_own_pk(col: str, table: str) -> bool:
    """True si `col` parece la PRIMARY KEY de su propia `table` por nombre.

    Una PK no es origen de FK en un esquema normal: `Genre.GenreId` referencia a
    su propia tabla, no a otra. Casos: `GenreId` en Genre, `film_id` en film, o el
    generico `id`. Esto impide que las PKs pequeñas (1..N), contenidas por
    construccion en cualquier clave mayor, se emitan como FK absurdas (origen).
    """
    nc = _norm(col)
    if nc == "id":
        return True
    if _ends_id(nc) and _name_eq(_id_stem(nc), table):
        return True
    return False


def _name_signal(from_col: str, to_table: str, to_col: str) -> bool:
    """True si (from_col -> to_table.to_col) tiene señal de nombre de FK real.

    Dos condiciones, AMBAS necesarias:

    1. El DESTINO es la PK nombrada de su tabla: `to_col` nombra `to_table`
       (`store_id` en store, `AlbumId` en Album) o es el generico `id`. Esto ancla
       el destino a una clave real, no a una columna cualquiera de la tabla.

    2. El ORIGEN apunta a esa tabla por su nombre: el stem de `from_col` casa con
       `to_table` (`<X>Id -> X`, `<x>_id -> x`) o lo CONTIENE como subcadena
       (`manager_staff_id -> staff`). El origen debe terminar en `id`.

    Mata el grueso de falsos de la contencion pura: `ArtistId -> Invoice.InvoiceId`
    falla porque "artist" no nombra ni contiene "invoice"; `Quantity -> AlbumId`
    falla porque "quantity" no termina en id. Conserva las FK reales con nombre que
    casa (`Track.AlbumId -> Album.AlbumId`). Limite conocido: FK con nombre
    divergente que no contiene la tabla destino (`Customer.SupportRepId ->
    Employee.EmployeeId`) no son alcanzables por nombre.
    """
    nfc = _norm(from_col)
    if not _ends_id(nfc):
        return False
    ntc = _norm(to_col)
    # (1) destino = PK nombrada del to_table, o `id` generico.
    to_col_ok = (_ends_id(ntc) and _name_eq(_id_stem(ntc), to_table)) or ntc == "id"
    if not to_col_ok:
        return False
    # (2) origen nombra (o contiene) la tabla destino.
    fstem = _id_stem(nfc)
    if _name_eq(fstem, to_table):
        return True
    sing_t = _singular(_norm(to_table))
    if sing_t and (sing_t in fstem or _norm(to_table) in fstem):
        return True
    return False


def _schema_has_name_hints(cols_by_table: dict) -> bool:
    """True si el esquema usa convencion de nombres de clave (alguna columna `...id`).

    Permite degradar a contencion pura (retrocompatible) en bases con columnas
    cripticas (`c1`, `c2`) que no siguen ninguna convencion: ahi la señal de
    nombre no aplica y se vuelve al comportamiento historico.
    """
    for cols in cols_by_table.values():
        for c in cols:
            if _ends_id(_norm(c["name"])):
                return True
    return False


def _scalar(res: dict):
    """Extrae el unico valor escalar de un resultado duckdb_query_readonly.

    Devuelve None si el resultado no es ok o no trae filas.
    """
    if res["status"] != "ok" or not res["rows"]:
        return None
    row = res["rows"][0]
    # La query siempre alias-a la unica columna; devolvemos su valor.
    return next(iter(row.values()))


def infer_fk_containment_duckdb(
    db_path: str,
    tables: list = None,
    min_inclusion: float = 0.9,
    max_card: int = 200000,
    require_name_signal: bool = True,
) -> dict:
    """Infiere FOREIGN KEYs candidatas entre tablas DuckDB por containment de valores.

    Args:
        db_path: ruta al archivo DuckDB. Debe existir (lectura read-only via las
            primitivas del grupo duckdb; no se crea).
        tables: lista de nombres de tabla a considerar. None (default) usa todas
            las del esquema main (duckdb_list_tables).
        min_inclusion: umbral minimo de inclusion (0-1) para emitir una FK
            candidata. inclusion(A subseteq B) = |distinct(A) interseccion
            distinct(B)| / |distinct(A)|. Default 0.9.
        max_card: tope de filas en la tabla destino (lado B, el caro del INTERSECT).
            Si count(T2) > max_card, el par se salta para no disparar un INTERSECT
            gigante; se acumula una nota en skipped[]. Default 200000.
        require_name_signal: si True (default) exige SEÑAL DE NOMBRE ademas de
            contencion: la columna origen debe nombrar la tabla destino (patron
            `<X>Id -> X.<X>Id` / `<x>_id -> x.<x>_id`) o referenciar su PK
            nombrada, y NO puede ser la PK de su propia tabla. Esto elimina el
            10-20x de falsos positivos de la contencion pura (issues H3+H10) y, al
            filtrar pares ANTES del INTERSECT, acelera. Degrada automaticamente a
            contencion pura si el esquema no usa convencion de nombres de clave
            (ninguna columna `...id`), por retrocompatibilidad. Con False nunca se
            exige señal (comportamiento historico).

    Returns:
        dict dict-no-throw. En exito:
            {status:'ok',
             fk_candidates:[{from_table, from_col, to_table, to_col, inclusion,
                             cardinality, to_is_key, name_match}, ...],
                            # ordenado por inclusion desc
             tables:[str], skipped:[str], name_signal_enforced:bool}
        En error (sin lanzar): {status:'error', error:str}.
    """
    try:
        # 1) Lista de tablas a considerar.
        if tables is None:
            list_res = duckdb_list_tables(db_path)
            if list_res["status"] != "ok":
                return {"status": "error", "error": list_res["error"]}
            tables = list_res["tables"]

        if not isinstance(tables, list):
            return {"status": "error", "error": "tables debe ser una lista o None"}

        tables = [t for t in tables if isinstance(t, str)]
        if not _valid_idents(*tables):
            return {
                "status": "error",
                "error": "algun nombre de tabla no casa con ^[A-Za-z_][A-Za-z0-9_]*$",
            }

        skipped = []

        # 2) Schema + count + cache de columnas por tabla.
        # cols_by_table[t] = [{name, type, type_class}, ...]
        cols_by_table = {}
        count_by_table = {}
        for t in tables:
            sch = duckdb_table_schema(db_path, t)
            if sch["status"] != "ok":
                return {"status": "error", "error": sch["error"]}
            cols = []
            for c in sch["columns"]:
                if not _valid_idents(c["name"]):
                    # Columna con nombre no interpolable: la ignoramos sin abortar.
                    continue
                cols.append(
                    {
                        "name": c["name"],
                        "type": c["type"],
                        "type_class": _type_class(c["type"]),
                    }
                )
            cols_by_table[t] = cols

            cnt = _scalar(
                duckdb_query_readonly(db_path, f'SELECT count(*) AS n FROM "{t}"')
            )
            count_by_table[t] = int(cnt) if cnt is not None else 0

        # 3) Cache de distinct(col) por (tabla, columna) para no recomputarlo.
        distinct_cache = {}

        def distinct_count(table: str, col: str):
            key = (table, col)
            if key in distinct_cache:
                return distinct_cache[key]
            val = _scalar(
                duckdb_query_readonly(
                    db_path, f'SELECT count(DISTINCT "{col}") AS d FROM "{table}"'
                )
            )
            val = int(val) if val is not None else 0
            distinct_cache[key] = val
            return val

        # 4) Cache de "B es key-ish" por (tabla destino, columna). distinct/count
        #    >= 0.95. Solo se evalua para columnas que aparecen como lado B.
        key_cache = {}

        def to_is_key(table: str, col: str):
            cache_key = (table, col)
            if cache_key in key_cache:
                return key_cache[cache_key]
            n = count_by_table[table]
            if n <= 0:
                key_cache[cache_key] = (False, 0.0)
                return key_cache[cache_key]
            d = distinct_count(table, col)
            ratio = d / n
            key_cache[cache_key] = (ratio >= 0.95, ratio)
            return key_cache[cache_key]

        # 4b) ¿Exigir señal de nombre? Se exige si el caller lo pide Y el esquema
        #     usa convencion de nombres de clave. Si no hay convencion (columnas
        #     cripticas), se degrada a contencion pura (retrocompatible).
        enforce_name = require_name_signal and _schema_has_name_hints(cols_by_table)

        candidates = []

        # 5) Pares (A en T1, B en T2) con T1 != T2 y misma clase de tipo (PODA).
        for t1 in tables:
            for t2 in tables:
                if t1 == t2:
                    continue
                # Lado caro: el INTERSECT lee distinct de T2. Si T2 es enorme,
                # saltamos todos los pares hacia el (B en T2) y dejamos nota.
                if count_by_table[t2] > max_card:
                    note = (
                        f"skip pares -> '{t2}': count {count_by_table[t2]} "
                        f"> max_card {max_card}"
                    )
                    if note not in skipped:
                        skipped.append(note)
                    continue

                for a in cols_by_table[t1]:
                    if a["type_class"] == "other":
                        continue
                    # PODA por nombre: una PK nunca es ORIGEN de FK. Excluir aqui
                    # (antes del bucle interno) mata pares absurdos como
                    # `Genre.GenreId -> Track.TrackId` de raiz.
                    if enforce_name and _col_is_own_pk(a["name"], t1):
                        continue
                    for b in cols_by_table[t2]:
                        # PODA: solo pares con la misma clase de tipo base.
                        if a["type_class"] != b["type_class"]:
                            continue

                        # PODA POR NOMBRE (issues H3+H10): exigir señal de nombre
                        # ANTES del INTERSECT. Recorta el grueso de pares falsos y
                        # evita el coste del containment sobre ellos.
                        if enforce_name and not _name_signal(
                            a["name"], t2, b["name"]
                        ):
                            continue

                        # distinct(A); si es 0, no hay containment que medir.
                        d_a = distinct_count(t1, a["name"])
                        if d_a == 0:
                            continue

                        # B debe parecer key (alta unicidad en T2).
                        b_is_key, _b_ratio = to_is_key(t2, b["name"])
                        if not b_is_key:
                            continue

                        # interseccion de distintos via INTERSECT (push-down).
                        inter_sql = (
                            "SELECT count(*) AS c FROM ("
                            f'SELECT DISTINCT "{a["name"]}" FROM "{t1}" '
                            "INTERSECT "
                            f'SELECT DISTINCT "{b["name"]}" FROM "{t2}"'
                            ")"
                        )
                        inter = _scalar(duckdb_query_readonly(db_path, inter_sql))
                        if inter is None:
                            continue
                        inter = int(inter)

                        inclusion = inter / d_a
                        if inclusion < min_inclusion:
                            continue

                        # Cardinalidad: si A es (casi) unica en T1 -> 1:1; si no N:1.
                        n_t1 = count_by_table[t1]
                        a_unique = n_t1 > 0 and (d_a / n_t1) >= 0.95
                        cardinality = "1:1" if a_unique else "N:1"

                        candidates.append(
                            {
                                "from_table": t1,
                                "from_col": a["name"],
                                "to_table": t2,
                                "to_col": b["name"],
                                "inclusion": inclusion,
                                "cardinality": cardinality,
                                "to_is_key": True,
                                "name_match": _name_signal(
                                    a["name"], t2, b["name"]
                                ),
                            }
                        )

        # Orden: primero las que tienen señal de nombre (FK mas fiables), luego por
        # inclusion descendente. En modo degradado (sin señal) todas son False y el
        # orden cae a inclusion, como antes.
        candidates.sort(
            key=lambda c: (c["name_match"], c["inclusion"]), reverse=True
        )

        return {
            "status": "ok",
            "fk_candidates": candidates,
            "tables": tables,
            "skipped": skipped,
            "name_signal_enforced": enforce_name,
        }
    except Exception as e:  # noqa: BLE001
        return {"status": "error", "error": str(e)}
