"""Tests for app/core/duckdb_analytics.py (T13 - RMIV5).

Per RMIV5 §T13: DuckDB is an in-process analytics engine for queries
too small for ClickHouse but still needing columnar speed. These tests
verify:
  - Basic SELECT returns list[dict]
  - Parameterized queries (? placeholders)
  - Parquet round-trip (export + query)
  - DataFrame registration
  - Context manager cleanup
  - Postgres attach (mocked)
  - Error handling on bad SQL
"""

from __future__ import annotations

import os
import tempfile

import pytest

from app.core.duckdb_analytics import DuckDBAnalytics, get_default_analytics


@pytest.fixture
def db():
    """Fresh in-memory DuckDB per test."""
    d = DuckDBAnalytics()
    yield d
    d.close()


class TestBasicQuery:
    """Core SELECT queries."""

    def test_simple_select(self, db) -> None:
        r = db.query("SELECT 1 AS n, 'hello' AS msg")
        assert r == [{"n": 1, "msg": "hello"}]

    def test_empty_result(self, db) -> None:
        r = db.query("SELECT 1 AS n WHERE 1 = 0")
        assert r == []

    def test_multiple_rows(self, db) -> None:
        r = db.query("SELECT i FROM range(0, 5) t(i) ORDER BY i")
        assert r == [{"i": 0}, {"i": 1}, {"i": 2}, {"i": 3}, {"i": 4}]

    def test_null_values(self, db) -> None:
        r = db.query("SELECT NULL AS nothing, 1 AS one")
        assert r == [{"nothing": None, "one": 1}]


class TestParameterizedQuery:
    """? placeholder binding."""

    def test_string_param(self, db) -> None:
        r = db.query("SELECT ? AS name", ["alice"])
        assert r == [{"name": "alice"}]

    def test_int_param(self, db) -> None:
        r = db.query("SELECT ? + 10 AS answer", [32])
        assert r == [{"answer": 42}]

    def test_multiple_params(self, db) -> None:
        r = db.query(
            "SELECT ? AS a, ? AS b, ? AS c",
            ["x", 1, True],
        )
        assert r == [{"a": "x", "b": 1, "c": True}]

    def test_params_in_where_clause(self, db) -> None:
        r = db.query(
            "SELECT i FROM range(0, 10) t(i) WHERE i > ? AND i < ?",
            [3, 7],
        )
        assert [row["i"] for row in r] == [4, 5, 6]

    def test_no_params_works(self, db) -> None:
        r = db.query("SELECT 1 AS n")
        assert r == [{"n": 1}]


class TestParquetRoundTrip:
    """Export to Parquet + query back."""

    def test_export_and_query(self, db) -> None:
        with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as f:
            path = f.name
        try:
            n = db.export_to_parquet(
                "SELECT i, i*2 AS doubled, i*i AS squared FROM range(0, 50) t(i)",
                path,
            )
            assert n == 50

            # Query back
            r = db.query(f"SELECT count(*) AS n FROM '{path}'")
            assert r == [{"n": 50}]

            # Aggregation
            r = db.query(
                f"SELECT count(*) AS n, sum(doubled) AS total FROM '{path}'"
            )
            assert r[0]["n"] == 50
            assert r[0]["total"] == 2 * sum(range(50))
        finally:
            os.unlink(path)

    def test_query_with_explicit_sql(self, db) -> None:
        with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as f:
            path = f.name
        try:
            db.export_to_parquet(
                "SELECT chain, count(*) AS n FROM (VALUES ('eth'), ('eth'), ('sol')) t(chain) GROUP BY chain",
                path,
            )
            # Use query_parquet with explicit SQL
            r = db.query_parquet(
                path, "SELECT chain, n FROM parquet ORDER BY chain"
            )
            assert r == [{"chain": "eth", "n": 2}, {"chain": "sol", "n": 1}]
        finally:
            os.unlink(path)

    def test_export_creates_parent_dirs(self, db) -> None:
        with tempfile.TemporaryDirectory() as tmpdir:
            nested = os.path.join(tmpdir, "a", "b", "c", "out.parquet")
            n = db.export_to_parquet("SELECT 1 AS n", nested)
            assert n == 1
            assert os.path.exists(nested)


class TestDataFrameRegistration:
    """Register pandas DataFrames as queryable tables."""

    def test_register_and_query(self, db) -> None:
        import pandas as pd

        df = pd.DataFrame({"name": ["alice", "bob"], "val": [10, 20]})
        db.register_dataframe("users", df)
        r = db.query("SELECT name, val FROM users ORDER BY val DESC")
        assert r == [{"name": "bob", "val": 20}, {"name": "alice", "val": 10}]

    def test_register_with_aggregation(self, db) -> None:
        import pandas as pd

        df = pd.DataFrame({"chain": ["eth", "eth", "sol"], "amount": [100, 200, 50]})
        db.register_dataframe("txs", df)
        r = db.query("SELECT chain, sum(amount) AS total FROM txs GROUP BY chain ORDER BY chain")
        assert r == [{"chain": "eth", "total": 300}, {"chain": "sol", "total": 50}]


class TestContextManager:
    """with-statement lifecycle."""

    def test_context_manager(self) -> None:
        with DuckDBAnalytics() as d:
            r = d.query("SELECT 1 AS x")
            assert r == [{"x": 1}]
        # After exit, connection should be closed (subsequent ops fail)
        # We just verify the with-statement works cleanly.

    def test_explicit_close(self) -> None:
        d = DuckDBAnalytics()
        d.query("SELECT 1")
        d.close()
        # Subsequent ops should fail (connection closed)
        with pytest.raises(Exception):  # noqa: B017
            d.query("SELECT 1")


class TestPersistence:
    """File-backed DB mode."""

    def test_persistent_db(self) -> None:
        with tempfile.TemporaryDirectory() as tmpdir:
            path = os.path.join(tmpdir, "test.db")

            # First connection: create data
            d1 = DuckDBAnalytics(persist_path=path)
            d1.query("CREATE TABLE foo (n INTEGER)")
            d1.query("INSERT INTO foo VALUES (1), (2), (3)")
            d1.close()

            # Second connection: verify data persists
            d2 = DuckDBAnalytics(persist_path=path)
            r = d2.query("SELECT count(*) AS n FROM foo")
            assert r == [{"n": 3}]
            d2.close()


class TestTableInfo:
    """table_exists + list_tables."""

    def test_table_exists_true(self, db) -> None:
        db.query("CREATE TABLE foo (n INTEGER)")
        assert db.table_exists("foo") is True

    def test_table_exists_false(self, db) -> None:
        assert db.table_exists("nonexistent") is False

    def test_list_tables(self, db) -> None:
        db.query("CREATE TABLE a (x INTEGER)")
        db.query("CREATE TABLE b (y INTEGER)")
        db.query("CREATE TABLE c (z INTEGER)")
        tables = db.list_tables()
        assert set(tables) >= {"a", "b", "c"}


class TestErrorHandling:
    """Bad SQL should raise, not silently return empty."""

    def test_bad_sql_raises(self, db) -> None:
        with pytest.raises(Exception):  # noqa: B017
            db.query("SELECT * FROM nonexistent_table")

    def test_syntax_error_raises(self, db) -> None:
        with pytest.raises(Exception):  # noqa: B017
            db.query("THIS IS NOT VALID SQL")


class TestDefaultAnalytics:
    """Process-wide singleton."""

    def test_get_default_returns_instance(self) -> None:
        d = get_default_analytics()
        assert isinstance(d, DuckDBAnalytics)

    def test_default_works(self) -> None:
        d = get_default_analytics()
        r = d.query("SELECT 42 AS answer")
        assert r == [{"answer": 42}]


class TestPostgresAttach:
    """Postgres attach (tested with non-existent URL to verify graceful failure)."""

    def test_attach_missing_pg_url_fails(self, db, monkeypatch) -> None:
        """If PG_URL points nowhere, ATTACH should raise (not silently swallow)."""
        monkeypatch.setenv("PG_URL", "postgres://nobody:nope@localhost:1/none")
        with pytest.raises(Exception):  # noqa: B017
            db.query_postgres("SELECT 1")
