"""Tests for the postgres_readonly_query skill."""

from __future__ import annotations

import json
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path

from core.skills import SkillLoader


def _load_handler_module(module_name: str = "postgres_readonly_query_handler"):
    """Import the postgres_readonly_query handler module from file path."""
    handler_path = Path(__file__).parent.parent / ".chatixia" / "skills" / "postgres_readonly_query" / "handler.py"
    spec = spec_from_file_location(module_name, handler_path)
    assert spec is not None and spec.loader is not None
    module = module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


class _FakeCursor:
    def __init__(self) -> None:
        self.executed: list[tuple[str, tuple | None]] = []
        self.description = None
        self._rows: list[tuple] = []
        self.closed = False

    def execute(self, sql: str, params: tuple | None = None) -> None:
        self.executed.append((sql, params))
        normalized_sql = " ".join(sql.lower().split())

        if normalized_sql.startswith("set default_transaction_read_only = on"):
            self.description = None
            self._rows = []
            return

        if "select set_config('statement_timeout'" in normalized_sql:
            self.description = [("set_config",)]
            self._rows = [("ok",)]
            return

        if "from information_schema.tables" in normalized_sql and "table_schema not in" in normalized_sql:
            self.description = [("table_schema",), ("table_name",), ("table_type",)]
            table_types = set(params[0]) if params else {"BASE TABLE", "VIEW"}
            all_rows = [
                ("public", "agreement_prices", "BASE TABLE"),
                ("public", "search_quotes", "BASE TABLE"),
                ("public", "agreement_price_summary", "VIEW"),
            ]
            self._rows = [row for row in all_rows if row[2] in table_types]
            return

        if (
            "from information_schema.tables" in normalized_sql
            and "where table_schema = %s and table_name = %s" in normalized_sql
        ):
            schema, table = params
            exists = 1 if (schema, table) in {("public", "search_quotes")} else 0
            self.description = [("count",)]
            self._rows = [(exists,)]
            return

        if "from information_schema.columns" in normalized_sql:
            self.description = [
                ("column_name",),
                ("ordinal_position",),
                ("data_type",),
                ("udt_name",),
                ("is_nullable",),
                ("column_default",),
            ]
            _, table = params
            if table == "search_quotes":
                self._rows = [
                    ("id", 1, "text", "text", "NO", None),
                    ("company_name", 2, "text", "text", "YES", None),
                ]
            else:
                self._rows = []
            return

        if (
            "from information_schema.table_constraints tc" in normalized_sql
            and "constraint_type in ('primary key', 'unique')" in normalized_sql
        ):
            self.description = [
                ("constraint_type",),
                ("constraint_name",),
                ("column_name",),
                ("ordinal_position",),
            ]
            _, table = params
            if table == "search_quotes":
                self._rows = [("PRIMARY KEY", "search_quotes_pkey", "id", 1)]
            else:
                self._rows = []
            return

        if (
            "from information_schema.table_constraints tc" in normalized_sql
            and "constraint_type = 'foreign key'" in normalized_sql
        ):
            self.description = [
                ("constraint_name",),
                ("column_name",),
                ("table_name",),
                ("column_name",),
            ]
            self._rows = []
            return

        if "from pg_indexes" in normalized_sql:
            self.description = [("indexname",), ("indexdef",)]
            _, table = params
            if table == "search_quotes":
                self._rows = [
                    (
                        "search_quotes_pkey",
                        "CREATE UNIQUE INDEX search_quotes_pkey ON public.search_quotes (id)",
                    )
                ]
            else:
                self._rows = []
            return

        if normalized_sql.startswith('select * from "public"."search_quotes" limit %s'):
            limit = params[0]
            self.description = [("id",), ("company_name",)]
            all_rows = [(1, "A"), (2, "B"), (3, "C")]
            self._rows = all_rows[:limit]
            return

        if normalized_sql.startswith("select id, company_name from search_quotes order by id"):
            self.description = [("id",), ("company_name",)]
            self._rows = [(1, "A Corp"), (2, "B Corp"), (3, "C Corp")]
            return

        if normalized_sql.startswith("select id from search_quotes limit 1"):
            self.description = [("id",)]
            self._rows = [(1,)]
            return

        self.description = None
        self._rows = []

    def fetchmany(self, size: int):
        return self._rows[:size]

    def fetchall(self):
        return self._rows

    def fetchone(self):
        return self._rows[0] if self._rows else None

    def close(self) -> None:
        self.closed = True


class _FakeConnection:
    def __init__(self, cursor: _FakeCursor) -> None:
        self._cursor = cursor
        self.autocommit = False
        self.closed = False

    def cursor(self) -> _FakeCursor:
        return self._cursor

    def close(self) -> None:
        self.closed = True


class TestPostgresReadonlySkill:
    def test_skill_loads_with_handler(self) -> None:
        loader = SkillLoader(Path("skills"))
        skills = loader.load_all()
        assert "postgres_readonly_query" in skills
        skill = skills["postgres_readonly_query"]
        assert skill.has_handler

        param_names = {p.name for p in skill.parameters}
        assert {
            "mode",
            "query",
            "table_name",
            "max_rows",
            "statement_timeout_ms",
            "include_views",
        } <= param_names

    def test_rejects_invalid_mode(self) -> None:
        module = _load_handler_module("postgres_readonly_query_handler_invalid_mode")
        result = json.loads(module.handle(mode="bad_mode"))
        assert result["ok"] is False
        assert "Invalid mode" in result["error"]

    def test_rejects_non_readonly_sql(self) -> None:
        module = _load_handler_module("postgres_readonly_query_handler_reject_write")
        result = json.loads(module.handle(query="DELETE FROM search_quotes"))
        assert result["ok"] is False
        assert "non read-only" in result["error"]

    def test_rejects_missing_query_for_query_mode(self) -> None:
        module = _load_handler_module("postgres_readonly_query_handler_missing_query")
        result = json.loads(module.handle(mode="query", query=None))
        assert result["ok"] is False
        assert "No query provided" in result["error"]

    def test_rejects_multiple_statements(self) -> None:
        module = _load_handler_module("postgres_readonly_query_handler_reject_multi")
        result = json.loads(module.handle(query="SELECT 1; SELECT 2"))
        assert result["ok"] is False
        assert "Multiple statements" in result["error"]

    def test_query_mode_sets_readonly_and_timeout_and_applies_limit(self, monkeypatch) -> None:
        module = _load_handler_module("postgres_readonly_query_handler_exec")
        fake_cursor = _FakeCursor()
        fake_conn = _FakeConnection(fake_cursor)
        monkeypatch.setattr(module, "_get_connection", lambda: fake_conn)

        result = json.loads(
            module.handle(
                mode="query",
                query="SELECT id, company_name FROM search_quotes ORDER BY id",
                max_rows=2,
                statement_timeout_ms=45000,
            )
        )

        assert result["ok"] is True
        assert result["mode"] == "query"
        assert result["row_count"] == 2
        assert result["truncated"] is True
        assert result["query"].endswith("LIMIT 2")
        assert fake_conn.autocommit is True
        assert fake_cursor.closed is True
        # Connection stays open — cached for reuse by _get_cached_connection
        assert fake_conn.closed is False

        assert fake_cursor.executed[0] == ("SET default_transaction_read_only = on", None)
        assert fake_cursor.executed[1][0] == "SELECT set_config('statement_timeout', %s, false)"
        assert fake_cursor.executed[1][1] == ("45000",)
        assert "LIMIT 2" in fake_cursor.executed[2][0]

    def test_query_mode_does_not_append_limit_when_already_present(self, monkeypatch) -> None:
        module = _load_handler_module("postgres_readonly_query_handler_limit")
        fake_cursor = _FakeCursor()
        fake_conn = _FakeConnection(fake_cursor)
        monkeypatch.setattr(module, "_get_connection", lambda: fake_conn)

        result = json.loads(
            module.handle(
                mode="query",
                query="SELECT id FROM search_quotes LIMIT 1",
                max_rows=200,
            )
        )

        assert result["ok"] is True
        assert result["query"] == "SELECT id FROM search_quotes LIMIT 1"
        assert fake_cursor.executed[2][0] == "SELECT id FROM search_quotes LIMIT 1"

    def test_list_tables_mode(self, monkeypatch) -> None:
        module = _load_handler_module("postgres_readonly_query_handler_list_tables")
        fake_cursor = _FakeCursor()
        fake_conn = _FakeConnection(fake_cursor)
        monkeypatch.setattr(module, "_get_connection", lambda: fake_conn)

        result = json.loads(module.handle(mode="list_tables"))
        assert result["ok"] is True
        assert result["mode"] == "list_tables"
        assert result["table_count"] == 3
        names = {t["name"] for t in result["tables"]}
        assert {"agreement_prices", "search_quotes", "agreement_price_summary"} <= names

    def test_describe_table_mode(self, monkeypatch) -> None:
        module = _load_handler_module("postgres_readonly_query_handler_describe")
        fake_cursor = _FakeCursor()
        fake_conn = _FakeConnection(fake_cursor)
        monkeypatch.setattr(module, "_get_connection", lambda: fake_conn)

        result = json.loads(module.handle(mode="describe_table", table_name="search_quotes"))
        assert result["ok"] is True
        assert result["mode"] == "describe_table"
        assert result["table"] == "public.search_quotes"
        assert result["column_count"] == 2
        assert result["primary_key"] == ["id"]
        assert len(result["indexes"]) == 1

    def test_describe_table_rejects_invalid_identifier(self, monkeypatch) -> None:
        module = _load_handler_module("postgres_readonly_query_handler_bad_table")
        fake_cursor = _FakeCursor()
        fake_conn = _FakeConnection(fake_cursor)
        monkeypatch.setattr(module, "_get_connection", lambda: fake_conn)

        result = json.loads(module.handle(mode="describe_table", table_name="public.search_quotes;drop"))
        assert result["ok"] is False
        assert "invalid characters" in result["error"]

    def test_sample_table_mode(self, monkeypatch) -> None:
        module = _load_handler_module("postgres_readonly_query_handler_sample")
        fake_cursor = _FakeCursor()
        fake_conn = _FakeConnection(fake_cursor)
        monkeypatch.setattr(module, "_get_connection", lambda: fake_conn)

        result = json.loads(module.handle(mode="sample_table", table_name="search_quotes", max_rows=2))
        assert result["ok"] is True
        assert result["mode"] == "sample_table"
        assert result["table"] == "public.search_quotes"
        assert result["row_count"] == 2
        assert result["truncated"] is True
        assert result["columns"] == ["id", "company_name"]
