"""Tests para infer_fk_containment_duckdb."""

import duckdb
import pytest

from .infer_fk_containment_duckdb import infer_fk_containment_duckdb


@pytest.fixture
def db(tmp_path):
    """DuckDB temporal: customers (id PK) + orders (customer_id FK -> customers.id).

    Ademas una columna `total` (DOUBLE) en orders y `region` (VARCHAR) en customers
    que NO estan relacionadas, para comprobar que la funcion no inventa FKs entre
    columnas sin containment ni entre tipos incompatibles.
    """
    path = str(tmp_path / "fk_test.duckdb")
    con = duckdb.connect(path)
    con.execute(
        "CREATE TABLE customers ("
        "  id INTEGER,"        # PK: 1..4, unica
        "  region VARCHAR"     # categorica, no relacionada con nada de orders
        ")"
    )
    con.execute(
        "INSERT INTO customers VALUES "
        "(1, 'norte'), (2, 'sur'), (3, 'este'), (4, 'oeste')"
    )
    con.execute(
        "CREATE TABLE orders ("
        "  order_id INTEGER,"   # PK de orders, unica
        "  customer_id INTEGER," # FK -> customers.id (todos en 1..4)
        "  total DOUBLE"         # numerica float, no relacionada
        ")"
    )
    con.execute(
        "INSERT INTO orders VALUES "
        "(10, 1, 99.5), "
        "(11, 2, 12.0), "
        "(12, 1, 45.25), "   # customer_id se repite -> N:1
        "(13, 3, 7.75), "
        "(14, 4, 60.0)"
    )
    con.close()
    return path


def _find(candidates, from_table, from_col, to_table, to_col):
    """Devuelve la primera FK candidata que casa con la firma dada, o None."""
    for c in candidates:
        if (
            c["from_table"] == from_table
            and c["from_col"] == from_col
            and c["to_table"] == to_table
            and c["to_col"] == to_col
        ):
            return c
    return None


def test_detecta_fk_orders_customer_id(db):
    """orders.customer_id subseteq customers.id con inclusion 1.0 y cardinalidad N:1."""
    res = infer_fk_containment_duckdb(db)
    assert res["status"] == "ok"

    fk = _find(res["fk_candidates"], "orders", "customer_id", "customers", "id")
    assert fk is not None, "no detecto orders.customer_id -> customers.id"
    # Los 4 valores distintos de customer_id (1,2,3,4) estan todos en customers.id.
    assert fk["inclusion"] == pytest.approx(1.0)
    # customers.id es key (4 distintos / 4 filas = 1.0 >= 0.95).
    assert fk["to_is_key"] is True
    # customer_id NO es unica en orders (1 se repite) -> N:1.
    assert fk["cardinality"] == "N:1"


def test_shape_resultado(db):
    """Estructura del resultado y de cada FK candidata."""
    res = infer_fk_containment_duckdb(db)
    assert res["status"] == "ok"
    for key in ("status", "fk_candidates", "tables", "skipped"):
        assert key in res
    assert set(res["tables"]) == {"customers", "orders"}
    for fk in res["fk_candidates"]:
        for key in (
            "from_table", "from_col", "to_table", "to_col",
            "inclusion", "cardinality", "to_is_key",
        ):
            assert key in fk, f"falta clave {key} en fk_candidate"
        assert 0.0 <= fk["inclusion"] <= 1.0
        assert fk["cardinality"] in ("1:1", "N:1")


def test_no_inventa_fk_columnas_no_relacionadas(db):
    """No emite FK entre columnas sin containment real.

    - orders.total (DOUBLE) no debe relacionarse con nada (es float aislado).
    - customers.region (VARCHAR) no tiene contraparte text con la que casar.
    - order_id (PK de orders) no esta contenido en ninguna key de customers.
    """
    res = infer_fk_containment_duckdb(db)
    assert res["status"] == "ok"
    candidates = res["fk_candidates"]

    # total nunca aparece como origen de una FK.
    assert _find(candidates, "orders", "total", "customers", "id") is None
    assert not any(c["from_col"] == "total" for c in candidates)

    # region (varchar de customers) no casa con ninguna columna text de orders.
    assert not any(c["from_col"] == "region" for c in candidates)

    # order_id (10..14) NO esta contenido en customers.id (1..4): inclusion baja.
    assert _find(candidates, "orders", "order_id", "customers", "id") is None


def test_no_fk_entre_tipos_incompatibles(db):
    """customer_id (INTEGER) jamas se empareja con total (DOUBLE): poda por tipo."""
    res = infer_fk_containment_duckdb(db)
    assert res["status"] == "ok"
    # No debe existir ninguna candidata cuyo destino sea orders.total.
    assert not any(c["to_col"] == "total" for c in res["fk_candidates"])


def test_min_inclusion_alto_filtra(db):
    """Subir min_inclusion por encima de 1.0 deja la lista vacia."""
    res = infer_fk_containment_duckdb(db, min_inclusion=1.01)
    assert res["status"] == "ok"
    assert res["fk_candidates"] == []


def test_subset_explicito_de_tablas(db):
    """Pasar tables=[...] limita las tablas consideradas."""
    res = infer_fk_containment_duckdb(db, tables=["customers", "orders"])
    assert res["status"] == "ok"
    assert set(res["tables"]) == {"customers", "orders"}


def test_db_inexistente_devuelve_error(tmp_path):
    """Una base que no existe devuelve {status:'error'} sin lanzar."""
    res = infer_fk_containment_duckdb(str(tmp_path / "no_existe.duckdb"))
    assert res["status"] == "error"
    assert isinstance(res["error"], str)


def test_tabla_invalida_devuelve_error(db):
    """Un nombre de tabla no interpolable devuelve error sin tocar la base."""
    res = infer_fk_containment_duckdb(db, tables=["orders; DROP TABLE orders"])
    assert res["status"] == "error"


# --- Señal de nombre: precisión de FK (issues H3 + H10) -----------------------


def test_name_signal_helpers():
    """Unit tests del nucleo de señal de nombre (puro, sin DB).

    Cubre el patron canonico `<X>Id -> X.<X>Id`, el snake_case `<x>_id -> x`, el
    nombre compuesto que contiene la tabla (`manager_staff_id -> staff`), y los
    falsos que la contencion pura dejaba pasar.
    """
    from .infer_fk_containment_duckdb import _col_is_own_pk, _name_signal

    # Golden — FK reales con nombre que casa.
    assert _name_signal("AlbumId", "Album", "AlbumId") is True
    assert _name_signal("customer_id", "customers", "id") is True
    assert _name_signal("ArtistId", "Artist", "ArtistId") is True
    # Nombre compuesto que CONTIENE la tabla destino.
    assert _name_signal("manager_staff_id", "staff", "staff_id") is True

    # Falsos que mata el fix.
    assert _name_signal("Quantity", "Album", "AlbumId") is False   # no es id-ref
    assert _name_signal("ArtistId", "Invoice", "InvoiceId") is False  # no nombra Invoice
    assert _name_signal("GenreId", "Track", "TrackId") is False     # no nombra Track

    # PK de su propia tabla: nunca es ORIGEN de FK.
    assert _col_is_own_pk("GenreId", "Genre") is True
    assert _col_is_own_pk("film_id", "film") is True
    assert _col_is_own_pk("id", "customers") is True
    assert _col_is_own_pk("AlbumId", "Track") is False   # FK, no PK propia


@pytest.fixture
def db_relational(tmp_path):
    """Esquema mini estilo chinook: FK con nombre + medida + PK pequeña.

    artist(ArtistId PK 1..3), album(AlbumId PK 1..5, ArtistId FK->artist),
    track(TrackId PK 1..10, AlbumId FK->album, Quantity 1..3 medida).

    La contencion PURA inventaria:
      - artist.ArtistId (1..3) ⊆ album.AlbumId (1..5) → falso (ArtistId es PK).
      - track.Quantity (1..3) ⊆ artist.ArtistId (1..3) → falso (Quantity es medida).
    El fix por nombre debe eliminar ambos y conservar solo las 2 FK reales.
    """
    path = str(tmp_path / "rel_test.duckdb")
    con = duckdb.connect(path)
    con.execute("CREATE TABLE artist (ArtistId INTEGER, Name VARCHAR)")
    con.execute("INSERT INTO artist VALUES (1,'a'),(2,'b'),(3,'c')")
    con.execute("CREATE TABLE album (AlbumId INTEGER, ArtistId INTEGER, Title VARCHAR)")
    con.execute(
        "INSERT INTO album VALUES "
        "(1,1,'x'),(2,1,'y'),(3,2,'z'),(4,3,'w'),(5,3,'v')"
    )
    con.execute("CREATE TABLE track (TrackId INTEGER, AlbumId INTEGER, Quantity INTEGER)")
    con.execute(
        "INSERT INTO track VALUES "
        "(1,1,1),(2,1,2),(3,2,1),(4,2,3),(5,3,2),"
        "(6,3,1),(7,4,2),(8,4,3),(9,5,1),(10,5,2)"
    )
    con.close()
    return path


def test_conserva_fk_reales_con_nombre(db_relational):
    """Golden: las 2 FK con nombre que casa se conservan."""
    res = infer_fk_containment_duckdb(db_relational)
    assert res["status"] == "ok"
    assert res["name_signal_enforced"] is True
    c = res["fk_candidates"]
    assert _find(c, "album", "ArtistId", "artist", "ArtistId") is not None
    assert _find(c, "track", "AlbumId", "album", "AlbumId") is not None
    # Cada candidata trae el flag name_match True (enforce activo).
    assert all(fk["name_match"] is True for fk in c)


def test_excluye_medida_y_pk_como_origen(db_relational):
    """Golden anti-falsos: Quantity (medida) y PKs propias no son origen de FK."""
    res = infer_fk_containment_duckdb(db_relational)
    c = res["fk_candidates"]
    # Quantity es una cantidad, jamas una FK.
    assert not any(fk["from_col"] == "Quantity" for fk in c)
    # artist.ArtistId es PK de artist: nunca origen (no album falso).
    assert not any(
        fk["from_table"] == "artist" and fk["from_col"] == "ArtistId" for fk in c
    )
    # album.AlbumId es PK de album: nunca origen.
    assert not any(
        fk["from_table"] == "album" and fk["from_col"] == "AlbumId" for fk in c
    )
    # Resultado total acotado: solo las 2 FK reales (cero ruido).
    assert len(c) == 2


def test_degrada_a_contencion_sin_pistas_de_nombre(tmp_path):
    """Edge retrocompatible: esquema sin convencion de nombres degrada a contencion.

    Columnas cripticas (a, b, c, d) sin sufijo id → no se exige señal de nombre y
    se emite por contencion pura, como el comportamiento historico.
    """
    path = str(tmp_path / "cryptic.duckdb")
    con = duckdb.connect(path)
    con.execute("CREATE TABLE t1 (a INTEGER, b VARCHAR)")
    con.execute("INSERT INTO t1 VALUES (1,'p'),(1,'q'),(2,'r'),(3,'s')")  # a no unica
    con.execute("CREATE TABLE t2 (c INTEGER, d VARCHAR)")
    con.execute("INSERT INTO t2 VALUES (1,'p'),(2,'q'),(3,'r')")          # c key 1..3
    con.close()

    res = infer_fk_containment_duckdb(path)
    assert res["status"] == "ok"
    # Sin pistas de nombre → no se exige señal (degrada).
    assert res["name_signal_enforced"] is False
    # t1.a (1..3) ⊆ t2.c (1..3): se emite por contencion pura.
    fk = _find(res["fk_candidates"], "t1", "a", "t2", "c")
    assert fk is not None
    assert fk["name_match"] is False   # no hay señal, pero se emite por contencion


def test_flujo_materializado_create_table_as_no_vacia(tmp_path):
    """Regresion del flujo real: tablas materializadas con CREATE TABLE AS (sin
    PRIMARY KEY declarada, como hace profile_database al ATTACH sqlite) NO deben
    vaciar el resultado. La señal de nombre se basa en el PATRON de nombres +
    unicidad/cardinalidad como proxy de clave, nunca en la PK fisica declarada.

    Confirma el caso critico `Track.AlbumId -> Album.AlbumId`: AlbumId es unica-ish
    dentro de Track pero NO es la PK de Track (stem 'album' != tabla 'track'), asi
    que NO se excluye como origen.
    """
    path = str(tmp_path / "materialized.duckdb")
    con = duckdb.connect(path)
    # CREATE TABLE AS SELECT: sin PK, exactamente como la materializacion sqlite.
    con.execute(
        "CREATE TABLE Artist AS SELECT * FROM "
        "(VALUES (1,'a'),(2,'b'),(3,'c')) t(ArtistId, Name)"
    )
    con.execute(
        "CREATE TABLE Album AS SELECT * FROM "
        "(VALUES (1,1,'x'),(2,1,'y'),(3,2,'z'),(4,3,'w'),(5,3,'v')) "
        "t(AlbumId, ArtistId, Title)"
    )
    con.execute(
        "CREATE TABLE Track AS SELECT * FROM "
        "(VALUES (1,1),(2,1),(3,2),(4,2),(5,3),(6,4),(7,5)) t(TrackId, AlbumId)"
    )
    con.close()

    res = infer_fk_containment_duckdb(path)
    assert res["status"] == "ok"
    c = res["fk_candidates"]
    # NO debe vaciar: sin PK declarada el patron de nombres sigue detectando FK.
    assert len(c) > 0, "flujo materializado sin PK no debe dar 0 candidatas"
    # FK reales conservadas pese a no haber PRIMARY KEY fisica.
    assert _find(c, "Track", "AlbumId", "Album", "AlbumId") is not None
    assert _find(c, "Album", "ArtistId", "Artist", "ArtistId") is not None


def test_require_name_signal_false_es_historico(db_relational):
    """Apagar require_name_signal vuelve al comportamiento de contencion pura.

    Sin el filtro de nombre reaparecen los falsos (mas candidatas que las 2 reales).
    """
    res = infer_fk_containment_duckdb(db_relational, require_name_signal=False)
    assert res["status"] == "ok"
    assert res["name_signal_enforced"] is False
    # La contencion pura genera mas que las 2 FK reales (incluye PKs/medidas).
    assert len(res["fk_candidates"]) > 2
