"""`conftest.py` 沙箱工厂自身的行为测试(issue #18 Task 1)。

工厂是本次一切隔离的地基: 它若在 setup 中途失败时漏掉清理、或让角色名与
schema 名撞上,受害的不是这一个文件,而是此后每一条 PG 用例。故它必须先被测。

**这里的断言全部只看自建对象与 PG catalog**,不读任何共享数据。
"""

from __future__ import annotations

import pytest

from tests.integration.conftest import _as_role

_DDL = "CREATE TABLE llm_calls (call_id TEXT PRIMARY KEY, created_at TIMESTAMPTZ DEFAULT now())"


@pytest.fixture
async def assert_no_leftovers(pg_catalog_probe):
    """收集沙箱名,在 `pg_sandbox` 清理之后回查它们是否真的没了。

    必须比 `pg_sandbox` **先** setup: pytest 的 finalizer 是后进先出,先 setup
    的后 teardown——本 fixture 的检查因此发生在沙箱清理之后,而不是之前。
    """
    seen: list[tuple[str, str | None]] = []
    yield seen
    for schema, role in seen:
        left = await pg_catalog_probe("SELECT nspname FROM pg_namespace WHERE nspname = $1", schema)
        assert left == [], f"沙箱 schema 未清理: {schema}"
        if role is not None:
            left = await pg_catalog_probe("SELECT rolname FROM pg_roles WHERE rolname = $1", role)
            assert left == [], f"沙箱角色未清理: {role}"


async def _oid_of_llm_calls(dsn: str) -> int | None:
    import asyncpg

    conn = await asyncpg.connect(dsn, timeout=10)
    try:
        return await conn.fetchval("SELECT to_regclass('llm_calls')::oid")
    finally:
        await conn.close()


class TestRoleDsnConstruction:
    """凭据替换失败必须**当场报错**,不许退回管理身份(合并前审查的 P1)。

    这条防线的失效形态特别隐蔽: 替换不上时 `re.sub` 原样返回管理连接串,沙箱
    "看起来"建好了、用例照常绿,而 `bare_dsn` 其实是超级用户——最坏情况用例
    会拿它跑真实 `--apply`,把共享表删空之后才在 `assert returncode == 2` 上红。
    行已经没了。设计 §5.1 要的是"越界做不到",不是"越界会被发现"。
    """

    def test_inline_credentials_are_replaced(self):
        swapped = _as_role("postgresql://app:secret@h:5432/polygateway", "pgw_r_x")

        assert swapped.startswith("postgresql://pgw_r_x:")
        assert "app:secret" not in swapped

    @pytest.mark.parametrize(
        "dsn",
        [
            "postgresql://h:5432/polygateway",  # 口令走 PGPASSWORD / .pgpass / trust
            "postgresql:///polygateway?host=/var/run/postgresql",  # unix socket
        ],
    )
    def test_a_dsn_without_inline_credentials_is_refused(self, dsn):
        """这两种都是合法 DSN,今天的 .env 恰好不是它们——恰好而已。"""
        with pytest.raises(RuntimeError, match="沙箱角色"):
            _as_role(dsn, "pgw_r_x")


class TestCatalogProbeIsReadOnly:
    """探针拿的是管理连接,故它只许查——否则"用例够不到管理能力"就是句空话。"""

    async def test_non_select_statements_are_refused(self, pg_catalog_probe):
        with pytest.raises(RuntimeError, match="只接受 SELECT"):
            await pg_catalog_probe("DELETE FROM llm_calls WHERE call_id = 'nope'")


class TestSchemaOnlySandbox:
    async def test_table_lands_in_the_sandbox_schema_and_bare_dsn_is_absent(self, pg_sandbox):
        """`role="none"`: 表落在自建 schema 下;不发角色,故没有裸 DSN 可给。"""
        sandbox = await pg_sandbox(ddl=_DDL)

        import asyncpg

        conn = await asyncpg.connect(sandbox.dsn, timeout=10)
        try:
            where = await conn.fetchval(
                "SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
                "WHERE c.oid = to_regclass('llm_calls')"
            )
        finally:
            await conn.close()

        assert where == sandbox.schema
        assert sandbox.role is None
        assert sandbox.bare_dsn is None


class TestOwnerRoleSandbox:
    async def test_the_role_owns_its_own_table(self, pg_sandbox):
        """`role="owner"`: 表由角色自己建,故属主是它——与"用维护角色跑"的现场一致。"""
        sandbox = await pg_sandbox(ddl=_DDL, role="owner")

        import asyncpg

        conn = await asyncpg.connect(sandbox.dsn, timeout=10)
        try:
            owner = await conn.fetchval(
                "SELECT pg_get_userbyid(relowner) FROM pg_class WHERE oid = to_regclass('llm_calls')"
            )
        finally:
            await conn.close()

        assert owner == sandbox.role
        # 名字必须错开: 同名会让 "$user" 命中自有 schema 并遮蔽真表,
        # 最坏情况用例就再也走不到那条真实路径上(设计 §5.1 实测)
        assert sandbox.role != sandbox.schema
        assert not sandbox.role.startswith("pgw_s_")
        assert not sandbox.schema.startswith("pgw_r_")

    async def test_bare_dsn_falls_through_to_the_default_search_path(self, pg_sandbox):
        """裸 DSN 必须真的回落到 `"$user", public`——最坏情况用例全靠它构造现场。"""
        sandbox = await pg_sandbox(ddl=_DDL, role="owner")

        import asyncpg

        conn = await asyncpg.connect(sandbox.bare_dsn, timeout=10)
        try:
            path = await conn.fetchval("SHOW search_path")
        finally:
            await conn.close()

        assert path == '"$user", public'
        # 裸 DSN 解析到的绝不能是沙箱里那张表(否则"落到共享表"的现场是假的)
        assert await _oid_of_llm_calls(sandbox.bare_dsn) != await _oid_of_llm_calls(sandbox.dsn)


class TestGranteeRoleSandbox:
    async def test_grantee_can_write_but_cannot_create(self, pg_sandbox):
        """`role="grantee"`: 表属主是 admin,角色只拿表级权限——最小权限部署的现场。"""
        import asyncpg

        sandbox = await pg_sandbox(ddl=_DDL, role="grantee")

        conn = await asyncpg.connect(sandbox.dsn, timeout=10)
        try:
            await conn.execute("INSERT INTO llm_calls (call_id) VALUES ('g1')")
            assert await conn.fetchval("SELECT count(*) FROM llm_calls") == 1
            with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
                await conn.execute("CREATE TABLE another (x TEXT)")
        finally:
            await conn.close()


class TestCleanup:
    async def test_setup_failure_leaves_nothing_behind(self, pg_sandbox, pg_catalog_probe):
        """建到一半失败时也必须删净——角色是**全局**对象,残留不随库消失。"""
        before_schemas = await pg_catalog_probe(
            "SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw!_%' ESCAPE '!'"
        )
        before_roles = await pg_catalog_probe(
            "SELECT rolname FROM pg_roles WHERE rolname LIKE 'pgw!_%' ESCAPE '!'"
        )

        with pytest.raises(Exception):  # noqa: B017 — 工厂原样抛出 PG 的 DDL 错误
            await pg_sandbox(ddl="CREATE TABLE llm_calls (bad NOT_A_REAL_TYPE)", role="owner")

        assert (
            await pg_catalog_probe(
                "SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw!_%' ESCAPE '!'"
            )
            == before_schemas
        )
        assert (
            await pg_catalog_probe(
                "SELECT rolname FROM pg_roles WHERE rolname LIKE 'pgw!_%' ESCAPE '!'"
            )
            == before_roles
        )

    async def test_a_failure_does_not_roll_back_earlier_sandboxes(self, pg_sandbox):
        """一次失败只回滚它自己建的东西——同一条用例里先建成的沙箱必须毫发无损。

        "A 的角色去动 B 的表"这类用例一条要两个沙箱;若失败回滚把整栈清空,受害的
        是那些**已经通过**的断言所依赖的对象,而症状会以"表不见了"的形态出现在
        与真因无关的地方。
        """
        good = await pg_sandbox(ddl=_DDL, role="owner")

        with pytest.raises(Exception):  # noqa: B017 — 工厂原样抛出 PG 的 DDL 错误
            await pg_sandbox(ddl="CREATE TABLE llm_calls (bad NOT_A_REAL_TYPE)", role="owner")

        assert await _oid_of_llm_calls(good.dsn) is not None, "先前建成的沙箱被误清理"

    async def test_teardown_removes_schema_and_role(self, assert_no_leftovers, pg_sandbox):
        """正常路径的清理: 断言发生在 `pg_sandbox` teardown **之后**(见 fixture 说明)。"""
        sandbox = await pg_sandbox(ddl=_DDL, role="owner")
        assert_no_leftovers.append((sandbox.schema, sandbox.role))
