"""zcbot 命令入口: web 服务 + db migration + 模型能力探测。

REPL 形态(原 `chat / tasks / export`)已撤(2026-05-18)—— 浏览器 dev SPA
(`/static/dev.html`)+ web `/v1/*` 路由全覆盖,本地命令行 REPL 维护双套
task 创建 / resume / 切换语义已经是冗余。所有交互走 `python main.py web`
起服务后浏览器操作。

装配 lib 在 `core/agent_builder.py`(原 main.py 内容,改名归位);
本文件仅做入口 + click 命令组。
"""
from __future__ import annotations

import sys

import click

from core.agent_builder import load_config
from core.paths import ROOT
from core.ui import make_console


@click.group()
def cli() -> None:
    """zcbot - 个人任务 agent"""


# ─────────────── DB migration(alembic 包装) ───────────────

@cli.group()
def db() -> None:
    """数据库管理 (alembic upgrade/downgrade/current)。需先 export ZCBOT_DB_URL。"""


def _alembic_cfg():
    from alembic.config import Config
    return Config(str(ROOT / "alembic.ini"))


def _run_alembic(fn, *args) -> None:
    """统一包一层友好出错(ZCBOT_DB_URL 未设置 / 连不上 → 简洁报错,不打 traceback)。"""
    try:
        fn(_alembic_cfg(), *args)
    except RuntimeError as e:
        click.echo(f"[err] {e}", err=True)
        sys.exit(2)
    except Exception as e:
        click.echo(f"[err] {type(e).__name__}: {e}", err=True)
        sys.exit(3)


@db.command("upgrade")
@click.argument("revision", default="head")
def db_upgrade(revision: str) -> None:
    """alembic upgrade <revision> (default head)."""
    from alembic import command
    _run_alembic(command.upgrade, revision)


@db.command("downgrade")
@click.argument("revision")
def db_downgrade(revision: str) -> None:
    """alembic downgrade <revision> (use -1 for one step, base for all)."""
    from alembic import command
    _run_alembic(command.downgrade, revision)


@db.command("current")
def db_current() -> None:
    """alembic current -- show currently applied revision."""
    from alembic import command
    _run_alembic(command.current)


# ─────────────── Capability probing ───────────────

@cli.command()
@click.option("--model", default=None, help="模型档案,如 deepseek_v4.flash 或 deepseek_v4.pro")
@click.option("--long-context", is_flag=True, help="加跑 needle-in-haystack(费 token,默认关)")
def probe(model: str, long_context: bool) -> None:
    """实测对账模型 yaml 声称的能力。会调用 LLM,有 API 开销。"""
    from rich.table import Table

    from core.capabilities import ModelCapabilities
    from core.llm import LLM
    from core.probe import probe_capabilities

    cfg = load_config()
    name = model or cfg["default_model"]

    console = make_console()
    try:
        caps = ModelCapabilities.load(name, ROOT / cfg["models_dir"])
    except Exception as e:
        console.print(f"[err]档案加载失败:[/err] {type(e).__name__}: {e}")
        sys.exit(1)

    console.print(
        f"[bold]probing[/bold] [accent]{caps.model_id}[/accent] (profile: {name}) "
        f"[muted]long-context={long_context}[/muted]\n"
    )

    try:
        llm = LLM(caps)
    except Exception as e:
        console.print(f"[err]LLM 构造失败:[/err] {type(e).__name__}: {e}")
        sys.exit(1)

    with console.status("[muted]running probes...[/muted]", spinner="dots"):
        report = probe_capabilities(caps, llm, include_long_context=long_context)

    tbl = Table(show_lines=False)
    tbl.add_column("capability", style="bold")
    tbl.add_column("declared")
    tbl.add_column("observed")
    tbl.add_column("status")
    tbl.add_column("detail")
    color = {"ok": "ok", "mismatch": "warn", "error": "err", "skip": "muted"}
    for r in report.results:
        c = color.get(r.status, "info")
        tbl.add_row(
            r.name,
            str(r.declared),
            str(r.observed),
            f"[{c}]{r.status}[/{c}]",
            r.detail,
        )
    console.print(tbl)

    if report.has_mismatch:
        console.print(
            "\n[warn]存在能力对账差异 —— 看 detail,必要时改 "
            f"config/models/{caps.family}.yaml[/warn]"
        )
        sys.exit(2)
    if any(r.status == "error" for r in report.results):
        console.print("\n[err]部分探测出错(见 detail)[/err]")
        sys.exit(3)
    console.print("\n[ok]全部能力声明与实测一致。[/ok]")


# ─────────────── User 管理(dev SPA 邮箱密码登录后端 bootstrap) ───────────────

@cli.group()
def user() -> None:
    """用户管理:dev SPA 邮箱密码登录 bootstrap。需先 export ZCBOT_DB_URL。"""


@user.command("add")
@click.option("--email", required=True, help="登录邮箱(UNIQUE),登录页填这个")
@click.option("--password", required=True, help="明文密码,后台 bcrypt 哈希落盘")
@click.option("--user-id", default=None,
              help="可选指定 UUID(默认随机);用于把已有 user_id 接到邮箱密码登录路径")
@click.option("--role", "role", default="user",
              type=click.Choice(["user", "admin"]), show_default=True,
              help="admin 可访问 /static/admin.html 管理后台;之后也可 user role 改")
def user_add(email: str, password: str, user_id: str, role: str) -> None:
    """新建用户:bcrypt(password) → INSERT users(email,password_hash,role[,user_id])。

    email 撞 UNIQUE → 报错退出 2;user_id 撞 PK 也是。撤销直接
    `DELETE FROM users WHERE email='...'`(先清该 user 的 tasks,否则 FK 拦)。
    """
    from uuid import UUID as _UUID

    from web.auth import UserCreateError, create_user

    uid_arg = None
    if user_id:
        try:
            uid_arg = _UUID(user_id)
        except ValueError:
            click.echo(f"[err] user-id 不是合法 UUID: {user_id!r}", err=True)
            sys.exit(2)

    try:
        uid, e = create_user(email=email, password=password, user_id=uid_arg, role=role)
    except UserCreateError as ex:
        click.echo(f"[err] {ex.message}", err=True)
        sys.exit(2)
    click.echo(f"[ok] user added  email={e}  role={role}  user_id={uid}")


@user.command("role")
@click.option("--email", required=True, help="目标用户登录邮箱")
@click.option("--role", "role", required=True,
              type=click.Choice(["user", "admin"]),
              help="user(普通)/ admin(可访问 /static/admin.html 管理后台)")
def user_role(email: str, role: str) -> None:
    """改用户角色:UPDATE users SET role=... WHERE email=...。

    admin 才能访问 /v1/admin/* 与 /static/admin.html。改完下次请求立即生效
    (role 走 DB 查,不进 JWT,老 token 无需重签)。email 查无此人 → 退出 2。
    """
    from web.auth import UserCreateError, set_user_role

    try:
        uid, e = set_user_role(email=email, role=role)
    except UserCreateError as ex:
        click.echo(f"[err] {ex.message}", err=True)
        sys.exit(2)
    click.echo(f"[ok] role set  email={e}  role={role}  user_id={uid}")


# ─────────────── Web 服务 ───────────────

@cli.command()
@click.option("--host", default="127.0.0.1", show_default=True,
              help="监听地址。本地形态默认 127.0.0.1,不对外暴露")
@click.option("--port", default=8765, show_default=True, type=int,
              help="监听端口")
@click.option("--reload/--no-reload", default=False,
              help="dev:文件改动自动重启(uvicorn 工厂模式)")
@click.option("--ssl-certfile", default=None,
              help="TLS 证书链(fullchain.pem);与 --ssl-keyfile 同时给即在本端口跑 HTTPS")
@click.option("--ssl-keyfile", default=None,
              help="TLS 私钥(privkey.pem)")
def web(host: str, port: int, reload: bool,
        ssl_certfile: str | None, ssl_keyfile: str | None) -> None:
    """启动 Web 服务(JSON API + dev SPA)。Auth 需 PLATFORM_KEY / JWT_SECRET env。

    HTTPS:`--ssl-certfile <fullchain.pem> --ssl-keyfile <privkey.pem>`(uvicorn 原生 TLS,
    无需 nginx)。两者都不给 = 明文 HTTP(默认,向后兼容)。
    """
    import uvicorn

    # 两者都给才算启用 TLS;只给其一报错提醒(避免半配置悄悄退回 http)
    if bool(ssl_certfile) ^ bool(ssl_keyfile):
        raise click.UsageError("--ssl-certfile 与 --ssl-keyfile 必须同时提供")
    tls = {"ssl_certfile": ssl_certfile, "ssl_keyfile": ssl_keyfile} if ssl_certfile else {}

    # timeout_graceful_shutdown=5:SIGTERM 后 uvicorn 至多等 5s 关掉在连的 HTTP 请求
    # (主要是长连 SSE GET,断开后客户端会重连,run 不受影响),再进 lifespan shutdown
    # 跑真正的 run drain(见 web/app.py finally + config/agent.yaml `shutdown` 段)。
    # 不设的话 uvicorn 会为长连 SSE 无限等,挡在 drain 前面。
    if reload:
        # reload 模式需要 import string + factory,uvicorn 才能监听文件
        uvicorn.run("web.app:create_app", host=host, port=port,
                    reload=True, factory=True, log_level="info",
                    timeout_graceful_shutdown=5, **tls)
    else:
        from web.app import create_app
        uvicorn.run(create_app(), host=host, port=port, log_level="info",
                    timeout_graceful_shutdown=5, **tls)


# ─────────────── Sandbox(Stage C 部署前置对账) ───────────────

@cli.group()
def sandbox() -> None:
    """Sandbox 容器部署对账(`ZCBOT_SANDBOX_BACKEND=docker` 启用前跑一遍)。"""


@sandbox.command("check")
def sandbox_check() -> None:
    """对账 docker backend 启动前置(daemon / 镜像 / network / HOST_UID / fs quota)。

    非阻塞 ─ 每项独立打印 `[ok]` / `[warn]` / `[err]`,最后汇总。`err` 一项 → 退出 1,
    全 ok / 仅 warn → 退出 0。warn 项不阻塞 web 启动,但**外部用户开放前必须清零**
    (详 DESIGN §7.5 落地清单)。
    """
    from core.sandbox.check import run_sandbox_check
    rc = run_sandbox_check()
    sys.exit(rc)


if __name__ == "__main__":
    cli()
