#!/usr/bin/env python3
"""遥测表 `llm_calls` 的保留期清理脚本(issue #12;独立运维工具,库本体不 import 它)。

**为什么是脚本而不是库能力**: 库对下游数据库只做 SELECT/INSERT 加可选建表,一切
改结构与删数据的操作交给下游(ARCHITECTURE D15)。库若持有 DELETE 权限,就与生产
部署模板推荐的 `REVOKE UPDATE, DELETE ON llm_calls FROM app` 直接冲突。

**默认 dry-run**: 本脚本会永久删除审计数据,故不带 `--apply` 时只统计不删,并把
行数、`created_at` 窗口、`tenant_id` 分布三样一并打出——运维据此判断"删掉的是不是
我想删的",判断不了就不该按下 `--apply`。

**失败方向与库相反**: 这是运维工具,缺依赖/连不上/表不存在一律明确报错退出,绝不
静默降级成"删了 0 行"——静默的 0 行会被当成"已清理干净"。

用法见 `--help`。
"""

from __future__ import annotations

import argparse
import asyncio
import sqlite3
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING, Any, NoReturn

if TYPE_CHECKING:
    from collections.abc import Sequence

TABLE = "llm_calls"

# 退出码是本脚本对调度器(cron/systemd)的公共契约,改动即破坏下游告警规则
EXIT_OK = 0
EXIT_USAGE = 1
EXIT_BACKEND = 2
EXIT_PARTITIONED = 3

# 库写 SQLite 的 created_at 是 UTC 文本(DEFAULT (datetime('now'))),故截止时刻
# 也必须是同格式文本——该格式定长且高位在前,字符串比较与时间序等价。
# PG 的 created_at 是 TIMESTAMPTZ,直接传 aware datetime,两端口径不可互换。
_SQLITE_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"

_EPILOG = """\
退出码:
  0  正常完成(含 dry-run)
  1  参数错误
  2  连接/权限/目标表不可用(含缺少 asyncpg)
  3  目标是 PostgreSQL 分区表 —— 请改用 DETACH/DROP PARTITION,脚本不会 DELETE

权限: 请用**维护角色**(表属主)跑本脚本,不要用应用账号 —— 生产部署模板已对应用
      账号 REVOKE UPDATE, DELETE ON llm_calls(遥测表按不可变审计表对待)。

推荐路径(本脚本是存量兜底,不是首选):
  PostgreSQL  把 llm_calls 建成按 created_at 的 RANGE 分区表,过期靠
              ALTER TABLE ... DETACH PARTITION + DROP TABLE 做 O(1) 清理。
  SQLite      按天/按实验轮转库文件(如 runs/<date>.db),到期直接删文件。

时间口径: 截止时刻 = 当前 UTC 时刻 - N 天,删除 created_at < 截止时刻 的行;
          --older-than-days 0 即"删除此刻之前的全部行"。

示例:
  python tools/telemetry_retention.py --backend sqlite --path runs/telemetry.db \\
      --older-than-days 90                      # dry-run,只看会删什么
  python tools/telemetry_retention.py --backend postgres --dsn "$DSN" \\
      --older-than-days 90 --apply --batch-size 1000
"""


class _Parser(argparse.ArgumentParser):
    """把 argparse 的参数错误退出码从 2 改成 1。

    2 在本脚本的契约里留给"连接/权限失败",两者混用会让调度器分不清"我写错了参数"
    与"数据库连不上"——后者要告警重试,前者不该重试。
    """

    def error(self, message: str) -> NoReturn:
        self.print_usage(sys.stderr)
        print(f"{self.prog}: 参数错误: {message}", file=sys.stderr)
        raise SystemExit(EXIT_USAGE)


def _build_parser() -> _Parser:
    """构造 CLI 解析器(参数契约见设计 §6.2)。"""
    parser = _Parser(
        prog="telemetry_retention.py",
        description="按 created_at 清理 PolyGateway 遥测表 llm_calls 的过期行(默认 dry-run)。",
        epilog=_EPILOG,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument("--backend", required=True, choices=("sqlite", "postgres"))
    parser.add_argument("--path", help="SQLite 库文件路径(--backend sqlite 必填)")
    parser.add_argument("--dsn", help="PostgreSQL DSN(--backend postgres 必填)")
    parser.add_argument(
        "--older-than-days",
        type=int,
        required=True,
        metavar="N",
        help="删除 created_at 早于 N 天前的行;N >= 0",
    )
    parser.add_argument(
        "--apply",
        action="store_true",
        help="真正执行删除;不给则只统计不删(默认)",
    )
    parser.add_argument(
        "--batch-size",
        type=int,
        metavar="N",
        help="仅 postgres: 每批删除的行数,每批一个事务(默认 1000)",
    )
    parser.add_argument(
        "--vacuum",
        action="store_true",
        help="仅 sqlite: 删除后执行 VACUUM 回收文件空间;须与 --apply 同时给",
    )
    return parser


def _validate(parser: _Parser, args: argparse.Namespace) -> None:
    """校验参数组合;任何不合法组合以退出码 1 结束(P5: 不给默认值掩盖错误)。

    **校验链的顺序就是错误消息的优先级**: 先两端通用,再按 backend 分支——同时给出
    多个错误参数时,报出的是链上最先命中的那条。
    """
    _validate_shared(parser, args)
    if args.backend == "sqlite":
        _validate_sqlite(parser, args)
        return
    _validate_postgres(parser, args)


def _validate_shared(parser: _Parser, args: argparse.Namespace) -> None:
    """两端通用的校验。

    `--vacuum` 与 `--apply` 的联动归在这里(而不是 SQLite 分支): 它是"别在只想看看的
    时候重写整个库"这条安全约束,先于"这个参数属于哪个 backend"成立。
    """
    if args.older_than_days < 0:
        parser.error("--older-than-days 必须 >= 0")
    if args.vacuum and not args.apply:
        parser.error("--vacuum 会重写整个库文件,必须与 --apply 同时给")


def _validate_sqlite(parser: _Parser, args: argparse.Namespace) -> None:
    """SQLite 分支: 必须有 --path,且拒绝一切 postgres 专属参数(不静默忽略)。"""
    if args.path is None:
        parser.error("--backend sqlite 需要 --path")
    if args.dsn is not None:
        parser.error("--backend sqlite 不接受 --dsn")
    if args.batch_size is not None:
        parser.error("--batch-size 仅用于 --backend postgres")


def _validate_postgres(parser: _Parser, args: argparse.Namespace) -> None:
    """Postgres 分支: 必须有 --dsn,拒绝 sqlite 专属参数,并在此落 --batch-size 缺省值。"""
    if args.dsn is None:
        parser.error("--backend postgres 需要 --dsn")
    if args.path is not None:
        parser.error("--backend postgres 不接受 --path")
    if args.vacuum:
        parser.error("--vacuum 仅用于 --backend sqlite")
    if args.batch_size is None:
        args.batch_size = 1000
    elif args.batch_size < 1:
        parser.error("--batch-size 必须 >= 1")


def _print_stats(total: int, low: object, high: object, tenants: Sequence[tuple[str, int]]) -> None:
    """打印将删除行数、created_at 窗口与按 tenant_id 的分布。

    tenant_id 用 repr 打: 空串是"未归属"的哨兵(不是 NULL),裸打会与缺失混淆。
    """
    print(f"将删除行数: {total}")
    print(f"created_at 范围: {low} ~ {high}" if total else "created_at 范围: (无匹配行)")
    print("按 tenant_id 分布:")
    if not tenants:
        print("  (无匹配行)")
    for tenant, count in tenants:
        print(f"  {tenant!r}: {count}")


# --------------------------------------------------------------------------- SQLite


def _run_sqlite(path: str, cutoff: str, apply_: bool, vacuum: bool) -> int:
    """SQLite 分支: 单条 DELETE(本地文件无长事务与锁膨胀问题),VACUUM 须显式要。"""
    file = Path(path)
    if not file.is_file():
        print(f"SQLite 库文件不存在: {file}", file=sys.stderr)
        return EXIT_BACKEND
    try:
        conn = sqlite3.connect(f"file:{file}?mode=rw", uri=True)
    except sqlite3.Error as exc:
        print(f"打开 SQLite 库失败: {file}: {exc}", file=sys.stderr)
        return EXIT_BACKEND
    try:
        exists = conn.execute(
            "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", (TABLE,)
        ).fetchone()
        if exists is None:
            print(f"目标库里没有表 {TABLE}: {file}", file=sys.stderr)
            return EXIT_BACKEND
        print(f"目标表: {file}::{TABLE}")
        total, low, high = conn.execute(
            f"SELECT COUNT(*), MIN(created_at), MAX(created_at) FROM {TABLE} WHERE created_at < ?",
            (cutoff,),
        ).fetchone()
        tenants = conn.execute(
            f"SELECT tenant_id, COUNT(*) FROM {TABLE} WHERE created_at < ? "
            "GROUP BY tenant_id ORDER BY COUNT(*) DESC, tenant_id",
            (cutoff,),
        ).fetchall()
        _print_stats(total, low, high, tenants)
        if not apply_:
            print("模式 dry-run: 未删除任何行。确认无误后加 --apply 才会真正删除。")
            return EXIT_OK
        cursor = conn.execute(f"DELETE FROM {TABLE} WHERE created_at < ?", (cutoff,))
        conn.commit()
        print(f"已删除 {cursor.rowcount} 行。")
        if vacuum:
            print("执行 VACUUM(重写整个库文件,需要与库等量的空闲磁盘)…")
            conn.execute("VACUUM")
            conn.commit()
            print("VACUUM 完成。")
    except sqlite3.Error as exc:
        print(f"SQLite 操作失败: {exc}", file=sys.stderr)
        return EXIT_BACKEND
    finally:
        conn.close()
    return EXIT_OK


# --------------------------------------------------------------------------- PostgreSQL


def _quote(identifier: str) -> str:
    """把 catalog 取回的 schema/表名包成合法标识符(库名含大写或特殊字符时必需)。"""
    escaped = identifier.replace('"', '""')
    return f'"{escaped}"'


async def _run_postgres(dsn: str, cutoff: datetime, apply_: bool, batch_size: int) -> int:
    """PostgreSQL 分支: 分区表让路,普通表分批 DELETE(每批一个事务)。"""
    try:
        import asyncpg
    except ImportError as exc:
        print(
            f"--backend postgres 需要 asyncpg,当前不可用({exc});"
            "请 pip install 'polygateway[postgres]' 或 pip install asyncpg 后重试。",
            file=sys.stderr,
        )
        return EXIT_BACKEND
    try:
        conn = await asyncpg.connect(dsn, timeout=10)
    except (OSError, asyncpg.PostgresError) as exc:
        print(f"连接 PostgreSQL 失败: {exc}", file=sys.stderr)
        return EXIT_BACKEND
    try:
        return await _purge_postgres(conn, cutoff, apply_, batch_size)
    except asyncpg.PostgresError as exc:
        print(f"PostgreSQL 操作失败: {exc}", file=sys.stderr)
        return EXIT_BACKEND
    finally:
        await conn.close()


async def _purge_postgres(conn: Any, cutoff: datetime, apply_: bool, batch_size: int) -> int:
    """已连上后的清理主体(conn 是 asyncpg.Connection,不 import 类型以免脚本硬依赖)。"""
    # 先解析目标: to_regclass 走连接自己的 search_path,故必须把解析结果打出来——
    # "我删的到底是哪张表"是这个脚本唯一不能猜的事(共享库里另有同名表的场景常见)。
    target = await conn.fetchrow(
        "SELECT n.nspname AS schema, c.relname AS name, "
        "EXISTS (SELECT 1 FROM pg_partitioned_table p WHERE p.partrelid = c.oid) AS partitioned "
        "FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
        "WHERE c.oid = to_regclass($1)",
        TABLE,
    )
    if target is None:
        print(f"目标库的 search_path 下找不到表 {TABLE}", file=sys.stderr)
        return EXIT_BACKEND
    schema, name = target["schema"], target["name"]
    qualified = f"{_quote(schema)}.{_quote(name)}"
    print(f"目标表: {schema}.{name}")
    if target["partitioned"]:
        print(
            f"{schema}.{name} 是分区表: 本脚本拒绝对分区表执行 DELETE。\n"
            "请改用 DETACH/DROP PARTITION —— ALTER TABLE ... DETACH PARTITION <子表> 后 "
            "DROP TABLE <子表>(或交给 pg_partman 的 retention)。\n"
            "那是 O(1) 的,而 DELETE 会全表扫描并留下等量膨胀。"
        )
        return EXIT_PARTITIONED

    stats = await conn.fetchrow(
        f"SELECT COUNT(*) AS total, MIN(created_at) AS low, MAX(created_at) AS high "
        f"FROM {qualified} WHERE created_at < $1",
        cutoff,
    )
    tenants = await conn.fetch(
        f"SELECT tenant_id, COUNT(*) AS total FROM {qualified} WHERE created_at < $1 "
        "GROUP BY tenant_id ORDER BY COUNT(*) DESC, tenant_id",
        cutoff,
    )
    _print_stats(
        stats["total"], stats["low"], stats["high"], [(r["tenant_id"], r["total"]) for r in tenants]
    )
    if not apply_:
        print("模式 dry-run: 未删除任何行。确认无误后加 --apply 才会真正删除。")
        return EXIT_OK

    # 分批: 一条大 DELETE 会撑出长事务(阻塞 autovacuum、堆积 WAL、锁膨胀),
    # 中断后还得整批回滚重来。每批独立提交,中断只影响未删批次。
    deleted = 0
    batches = 0
    statement = (
        f"DELETE FROM {qualified} WHERE ctid IN "
        f"(SELECT ctid FROM {qualified} WHERE created_at < $1 ORDER BY created_at LIMIT $2)"
    )
    while True:
        async with conn.transaction():
            status = await conn.execute(statement, cutoff, batch_size)
        count = int(status.rsplit(" ", 1)[-1])
        if count == 0:
            break
        deleted += count
        batches += 1
        print(f"  批次 {batches}: 删除 {count} 行(已提交)")
    print(f"已删除 {deleted} 行,共 {batches} 批。")
    return EXIT_OK


# --------------------------------------------------------------------------- 入口


def main(argv: Sequence[str] | None = None) -> int:
    """解析参数并分派到对应后端;返回值即进程退出码。"""
    parser = _build_parser()
    args = parser.parse_args(argv)
    _validate(parser, args)

    cutoff = datetime.now(UTC) - timedelta(days=args.older_than_days)
    print(f"后端: {args.backend}")
    print(
        f"截止时间(UTC): {cutoff.strftime(_SQLITE_TIME_FORMAT)}"
        f"(--older-than-days {args.older_than_days};删除 created_at 早于该时刻的行)"
    )
    print(f"模式: {'apply(将真正删除)' if args.apply else 'dry-run(只统计,不删除)'}")
    if args.backend == "sqlite":
        return _run_sqlite(args.path, cutoff.strftime(_SQLITE_TIME_FORMAT), args.apply, args.vacuum)
    return asyncio.run(_run_postgres(args.dsn, cutoff, args.apply, args.batch_size))


if __name__ == "__main__":
    sys.exit(main())
