"""`tools/telemetry_retention.py` 的 PostgreSQL 分支测试(issue #12 Task 3,真实 PG)。

隔离纪律(issue #18): 本文件跑的是一个**会删数据的脚本**,而实例上的共享表 `llm_calls`
与真实批跑共用。故**凡启动脚本的用例一律用 `pg_sandbox(role="owner")` 的临时角色跑**:
该角色对共享表一无所有,越界不是"会被发现",而是数据库层面做不到。

这条纪律取代了此前那条"跑完对比共享表行数"的安全网——行数快照守的是安全属性,却把它
编码成对全局可变量的观测: 外部进程一写就假红,外部插入与脚本误删互相抵消则假阴。
权限边界两个方向都没有。
"""

from __future__ import annotations

import os
import re
import subprocess
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path

import pytest

from polygateway.telemetry.schema import PG_DDL

_ROOT = Path(__file__).resolve().parents[2]
_SCRIPT = _ROOT / "tools" / "telemetry_retention.py"

_INSERT = (
    "INSERT INTO llm_calls (call_id, model, provider, source_name, messages, response, "
    "prompt_tokens, completion_tokens, usage_source, latency_ms, tenant_id, created_at) "
    "VALUES ($1, 'm', 'p', 's1', '[]', 'ok', 1, 2, 'measured', 10, $2, $3)"
)


def _partitioned_ddl() -> str:
    """由库的真实 `PG_DDL` 派生一份 RANGE 分区版建表语句。

    不另抄一份 DDL: 抄的那份与库的 schema 必然漂移,而漂移后本测试验的就不再是
    "库建的表被做成分区后脚本认不认得"。两处改动都是分区表的**硬性要求**——
    分区表上的唯一约束必须包含分区键,故 `call_id` 单列主键不再合法。
    """
    body, count = re.subn(
        r"call_id(\s+)TEXT PRIMARY KEY", r"call_id\1TEXT NOT NULL", PG_DDL, count=1
    )
    if count != 1:
        raise AssertionError("PG_DDL 的 call_id 主键声明形态已变,分区版 DDL 需同步")
    body = body.strip().rstrip(";").strip()
    if not body.endswith(")"):
        raise AssertionError("PG_DDL 结尾形态已变,分区版 DDL 需同步")
    return (
        f"{body[:-1].rstrip()},\n"
        "    PRIMARY KEY (call_id, created_at)\n"
        ") PARTITION BY RANGE (created_at)"
    )


def _stamp(delta: timedelta) -> datetime:
    return datetime.now(UTC) + delta


def _run(*args: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
    return subprocess.run(
        [sys.executable, str(_SCRIPT), *args],
        capture_output=True,
        text=True,
        cwd=_ROOT,
        env=env,
        timeout=120,
    )


async def _seed(schema_dsn: str, rows: list[tuple[str, str, datetime]]) -> None:
    import asyncpg

    conn = await asyncpg.connect(schema_dsn, timeout=10)
    try:
        await conn.executemany(_INSERT, rows)
    finally:
        await conn.close()


async def _call_ids(schema_dsn: str) -> list[str]:
    import asyncpg

    conn = await asyncpg.connect(schema_dsn, timeout=10)
    try:
        rows = await conn.fetch("SELECT call_id FROM llm_calls ORDER BY call_id")
    finally:
        await conn.close()
    return [r["call_id"] for r in rows]


@pytest.fixture
async def partitioned_sandbox(pg_sandbox):
    """临时 schema 内的**分区表**: 脚本必须认出它并让路给 DROP PARTITION。"""
    return await pg_sandbox(
        ddl=_partitioned_ddl(),
        extra=(
            "CREATE TABLE llm_calls_all PARTITION OF llm_calls "
            "FOR VALUES FROM ('2000-01-01') TO ('2100-01-01')",
        ),
        role="owner",
    )


@pytest.fixture
async def plain_sandbox(pg_sandbox):
    """临时 schema 内的普通表: 存量场景,脚本的分批 DELETE 兜底路径。"""
    return await pg_sandbox(ddl=PG_DDL, role="owner")


class TestPartitionedTarget:
    async def test_partitioned_table_exits_three_without_deleting_anything(
        self, partitioned_sandbox
    ):
        await _seed(
            partitioned_sandbox.dsn,
            [
                ("part-old-1", "", _stamp(timedelta(days=-30))),
                ("part-old-2", "acme", _stamp(timedelta(days=-20))),
            ],
        )

        # 带 --apply 跑: 危险的那条路径必须在真正删之前就被分区探测拦住
        result = _run(
            "--backend",
            "postgres",
            "--dsn",
            partitioned_sandbox.dsn,
            "--older-than-days",
            "7",
            "--apply",
        )

        assert result.returncode == 3, (result.stdout, result.stderr)
        combined = result.stdout + result.stderr
        assert "DROP PARTITION" in combined
        assert "DETACH" in combined
        assert await _call_ids(partitioned_sandbox.dsn) == ["part-old-1", "part-old-2"]
        # 脚本必须报出它解析到的**限定表名**: 这是"我删的到底是哪张表"的唯一凭据
        assert f"{partitioned_sandbox.schema}.llm_calls" in result.stdout


class TestPlainTableBatches:
    async def test_apply_deletes_only_expired_rows_in_batches(self, plain_sandbox):
        await _seed(
            plain_sandbox.dsn,
            [
                ("old-1", "", _stamp(timedelta(days=-40))),
                ("old-2", "acme", _stamp(timedelta(days=-30))),
                ("old-3", "acme", _stamp(timedelta(days=-20))),
                ("old-4", "acme", _stamp(timedelta(days=-15))),
                ("old-5", "", _stamp(timedelta(days=-10))),
                ("fresh-1", "acme", _stamp(timedelta(days=-1))),
                ("fresh-2", "", _stamp(timedelta(hours=-1))),
            ],
        )

        result = _run(
            "--backend",
            "postgres",
            "--dsn",
            plain_sandbox.dsn,
            "--older-than-days",
            "7",
            "--apply",
            "--batch-size",
            "2",
        )

        assert result.returncode == 0, (result.stdout, result.stderr)
        assert await _call_ids(plain_sandbox.dsn) == ["fresh-1", "fresh-2"]
        assert f"{plain_sandbox.schema}.llm_calls" in result.stdout
        assert "将删除行数: 5" in result.stdout
        assert "'acme': 3" in result.stdout
        # 5 行 / 每批 2 行 = 3 批,每批各自提交;批次行必须真的出现三条
        assert "批次 1" in result.stdout
        assert "批次 3" in result.stdout
        assert "批次 4" not in result.stdout
        assert "已删除 5 行" in result.stdout

    async def test_dry_run_on_a_plain_table_deletes_nothing(self, plain_sandbox):
        await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])

        result = _run("--backend", "postgres", "--dsn", plain_sandbox.dsn, "--older-than-days", "7")

        assert result.returncode == 0, (result.stdout, result.stderr)
        assert "将删除行数: 1" in result.stdout
        assert "dry-run" in result.stdout
        assert await _call_ids(plain_sandbox.dsn) == ["old-1"]


class TestExplicitTable:
    """`--table SCHEMA.NAME` 的真实解析行为(issue #18 设计 §4.1;判据 1b)。

    单测只能验参数分类,验不了 `to_regclass` 的语义——schema 不存在返 NULL 而非抛错、
    引号限定名区分大小写、无权限落在 `COUNT` 而非解析,这三条都必须真连库才成立。
    """

    async def test_explicit_table_deletes_exactly_like_the_implicit_path(self, plain_sandbox):
        await _seed(
            plain_sandbox.dsn,
            [
                ("old-1", "", _stamp(timedelta(days=-40))),
                ("old-2", "acme", _stamp(timedelta(days=-30))),
                ("old-3", "acme", _stamp(timedelta(days=-20))),
                ("old-4", "acme", _stamp(timedelta(days=-15))),
                ("old-5", "", _stamp(timedelta(days=-10))),
                ("fresh-1", "acme", _stamp(timedelta(days=-1))),
                ("fresh-2", "", _stamp(timedelta(hours=-1))),
            ],
        )

        result = _run(
            "--backend",
            "postgres",
            "--dsn",
            plain_sandbox.dsn,
            "--older-than-days",
            "7",
            "--apply",
            "--batch-size",
            "2",
            "--table",
            f"{plain_sandbox.schema}.llm_calls",
        )

        # 与不给 --table 的那条用例逐条同款: 显式声明只改"怎么找到表",不改任何行为
        assert result.returncode == 0, (result.stdout, result.stderr)
        assert await _call_ids(plain_sandbox.dsn) == ["fresh-1", "fresh-2"]
        assert f"{plain_sandbox.schema}.llm_calls" in result.stdout
        assert "将删除行数: 5" in result.stdout
        assert "'acme': 3" in result.stdout
        assert "批次 1" in result.stdout
        assert "批次 3" in result.stdout
        assert "批次 4" not in result.stdout
        assert "已删除 5 行" in result.stdout

    async def test_table_in_a_nonexistent_schema_exits_two(self, plain_sandbox):
        """schema 不存在时 `to_regclass` 返 NULL(不抛错),故落进既有的"目标不可用"。"""
        await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
        missing = "pgw_s_nosuchxxxxxxxx"

        result = _run(
            "--backend",
            "postgres",
            "--dsn",
            plain_sandbox.dsn,
            "--older-than-days",
            "7",
            "--apply",
            "--table",
            f"{missing}.llm_calls",
        )

        assert result.returncode == 2, (result.stdout, result.stderr)
        assert missing in result.stderr
        assert "llm_calls" in result.stderr
        assert await _call_ids(plain_sandbox.dsn) == ["old-1"]

    async def test_table_owned_by_another_role_exits_two(self, pg_sandbox):
        """拿 A 的连接指 B 的表: 权限拒绝,两张表都不能少一行。"""
        sandbox_a = await pg_sandbox(ddl=PG_DDL, role="owner")
        sandbox_b = await pg_sandbox(ddl=PG_DDL, role="owner")
        await _seed(sandbox_a.dsn, [("a-old", "acme", _stamp(timedelta(days=-30)))])
        await _seed(sandbox_b.dsn, [("b-old", "acme", _stamp(timedelta(days=-30)))])

        result = _run(
            "--backend",
            "postgres",
            "--dsn",
            sandbox_a.dsn,
            "--older-than-days",
            "7",
            "--apply",
            "--table",
            f"{sandbox_b.schema}.llm_calls",
        )

        assert result.returncode == 2, (result.stdout, result.stderr)
        assert await _call_ids(sandbox_a.dsn) == ["a-old"]
        assert await _call_ids(sandbox_b.dsn) == ["b-old"]

    async def test_explicit_partitioned_table_still_exits_three(self, partitioned_sandbox):
        await _seed(partitioned_sandbox.dsn, [("part-old-1", "acme", _stamp(timedelta(days=-30)))])

        result = _run(
            "--backend",
            "postgres",
            "--dsn",
            partitioned_sandbox.dsn,
            "--older-than-days",
            "7",
            "--apply",
            "--table",
            f"{partitioned_sandbox.schema}.llm_calls",
        )

        assert result.returncode == 3, (result.stdout, result.stderr)
        combined = result.stdout + result.stderr
        assert "DROP PARTITION" in combined
        assert "DETACH" in combined
        assert await _call_ids(partitioned_sandbox.dsn) == ["part-old-1"]


class TestInferredTargetHint:
    async def test_apply_without_table_warns_that_the_target_was_inferred(self, plain_sandbox):
        """未钉死目标时必须当场说清"这张表是猜出来的"(设计 §4.4;判据 2)。

        该提示行只在 PG 分支打印,不连库的单测触发不到它,故验收落在集成层。
        """
        await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])

        result = _run(
            "--backend",
            "postgres",
            "--dsn",
            plain_sandbox.dsn,
            "--older-than-days",
            "7",
            "--apply",
        )

        assert result.returncode == 0, (result.stdout, result.stderr)
        assert "search_path" in result.stdout
        assert "--table" in result.stdout

    async def test_dry_run_does_not_print_the_hint(self, plain_sandbox):
        """dry-run 不可逆性为零,它本就以"看清楚再决定"为用途,多一行提示是噪音。"""
        await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])

        result = _run("--backend", "postgres", "--dsn", plain_sandbox.dsn, "--older-than-days", "7")

        assert result.returncode == 0, (result.stdout, result.stderr)
        assert "--table" not in result.stdout


class TestSearchPathFallsThrough:
    async def test_bare_search_path_cannot_touch_the_shared_table(self, plain_sandbox):
        """最坏情况: `search_path` 没生效,脚本落到共享表 `llm_calls` 上(设计 §5.3)。

        用沙箱角色的**裸** DSN 跑(search_path 回落 `"$user", public`,而角色名与 schema
        名有意错开,故 `"$user"` 命不中沙箱),不给 `--table`,带 `--apply`。角色对共享表
        无任何权限,于是两条可能的路都收敛到退出码 2: 库里有那张表则 `COUNT` 被权限拒绝,
        没有则解析不到。**不断言 PG 的英文原文**——服务端 `lc_messages` 不由测试掌握。
        """
        await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])

        result = _run(
            "--backend",
            "postgres",
            "--dsn",
            plain_sandbox.bare_dsn,
            "--older-than-days",
            "7",
            "--apply",
        )

        assert result.returncode == 2, (result.stdout, result.stderr)
        assert result.stderr.strip()
        assert "llm_calls" in result.stderr
        # 沙箱表一行不少: 脚本既没删共享表,也没绕回来删自己这张
        assert await _call_ids(plain_sandbox.dsn) == ["old-1"]


class TestMissingAsyncpg:
    async def test_missing_asyncpg_exits_two_without_touching_rows(self, plain_sandbox, tmp_path):
        """缺 asyncpg 必须明确报错退出(码 2),不静默降级——这是运维工具不是库路径。

        用一个只 `raise ImportError` 的临时 `asyncpg.py` 挂进子进程的 PYTHONPATH 构造该
        场景: 脚本跑在子进程里,monkeypatch 对它无效。DSN 用**真实可连**的临时 schema,
        这样"没有导入守卫"的实现会走通并退出 0,而不是碰巧也退出 2 而假绿。
        """
        await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
        stub = tmp_path / "stub"
        stub.mkdir()
        (stub / "asyncpg.py").write_text(
            'raise ImportError("asyncpg 未安装(测试构造)")\n', encoding="utf-8"
        )
        env = {
            **os.environ,
            "PYTHONPATH": os.pathsep.join(
                [str(stub), *([p] if (p := os.environ.get("PYTHONPATH")) else [])]
            ),
        }

        result = _run(
            "--backend",
            "postgres",
            "--dsn",
            plain_sandbox.dsn,
            "--older-than-days",
            "7",
            "--apply",
            env=env,
        )

        assert result.returncode == 2, (result.stdout, result.stderr)
        assert "asyncpg" in result.stderr
        assert "pip install" in result.stderr
        assert await _call_ids(plain_sandbox.dsn) == ["old-1"]
