"""`xq` CLI — 客户端。

0.1 命令：
    xq health
    xq register <phone>
    xq verify <code> --request-id <ULID>
    xq logout
    xq me
"""

from __future__ import annotations

import json
import os
from pathlib import Path
from typing import Any

import click
import httpx

from . import __version__, client_session, version_check

DEFAULT_ENDPOINT = os.environ.get("XIANGQIN_ENDPOINT", "http://112.124.27.213")


@click.group()
@click.version_option(__version__, prog_name="xq")
@click.option(
    "--endpoint",
    default=None,
    envvar="XIANGQIN_ENDPOINT",
    help="xiangqin 服务端 URL（默认读本地 session 或 http://112.124.27.213）",
)
@click.pass_context
def cli(ctx: click.Context, endpoint: str | None) -> None:
    """xiangqin 客户端 — 相亲 + 私信。"""
    ctx.ensure_object(dict)
    version_check.print_update_notice_if_needed()
    sess = client_session.load()
    if endpoint:
        ctx.obj["endpoint"] = endpoint.rstrip("/")
    elif sess and sess.get("endpoint"):
        ctx.obj["endpoint"] = sess["endpoint"]
    else:
        ctx.obj["endpoint"] = DEFAULT_ENDPOINT
    ctx.obj["session"] = sess


def _client(endpoint: str, token: str | None = None) -> httpx.Client:
    headers = {}
    if token:
        headers["Authorization"] = f"Bearer {token}"
    return httpx.Client(base_url=endpoint, timeout=10.0, headers=headers, trust_env=False)


def _require_session(ctx: click.Context) -> dict[str, str]:
    sess = ctx.obj.get("session")
    if not sess or not sess.get("session_token"):
        click.echo("未登录，先 `xq register <phone>`", err=True)
        raise SystemExit(1)
    return sess


# ========== 基础 ==========


@cli.command()
@click.pass_context
def health(ctx: click.Context) -> None:
    """健康检查。"""
    with _client(ctx.obj["endpoint"]) as c:
        try:
            r = c.get("/health")
            r.raise_for_status()
            click.echo(r.text)
        except httpx.HTTPError as e:
            click.echo(f"health check failed: {e}", err=True)
            raise SystemExit(1) from e


# ========== auth ==========


@cli.command()
@click.argument("phone")
@click.option(
    "--agree-legal",
    is_flag=True,
    default=False,
    help="跳过合规条款交互确认（CI / 自动化场景）。未加时会交互显示条款链接 + 要求按回车同意",
)
@click.pass_context
def register(ctx: click.Context, phone: str, agree_legal: bool) -> None:
    """发起注册 / 登录（返回 request_id，0.1 期 mock code = 123456）。

    首次注册前会显示《用户协议》《隐私政策》《数据生命周期》链接，
    用户按回车 = 同意（合规要求明示同意）。"""
    if not agree_legal:
        click.echo("")
        click.echo("=" * 60)
        click.echo("  首次使用 xiangqin —— 继续前请阅读以下条款：")
        click.echo("")
        click.echo("  · 用户协议：       https://agentaily-xiangqin.pages.dev/legal/terms/")
        click.echo("  · 隐私政策：       https://agentaily-xiangqin.pages.dev/legal/privacy/")
        click.echo(
            "  · 数据生命周期：   https://agentaily-xiangqin.pages.dev/legal/data-lifecycle/"
        )
        click.echo("")
        click.echo("  服务提供方：杭州阿空智能科技有限公司（浙ICP备2026022274号-1）")
        click.echo("=" * 60)
        click.confirm("继续注册即代表您同意以上全部条款，继续？", abort=True, default=True)

    with _client(ctx.obj["endpoint"]) as c:
        r = c.post("/auth/register", json={"phone": phone})
    if r.status_code != 200:
        click.echo(f"register failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    data = r.json()
    # 记住 masked_phone 给下一步 verify 做上下文
    ctx.obj["__last_phone_masked"] = data["masked_phone"]
    click.echo(f"验证码已发 {data['masked_phone']}，{data['expires_in_s']}s 内有效")
    click.echo(f"request_id: {data['request_id']}")
    click.echo(f"提示：{data.get('mock_code_hint', '')}")
    # 临时把 register 产生的 masked_phone 暂存（给 verify 显示）
    os.environ["_XIANGQIN_LAST_MASKED"] = data["masked_phone"]


@cli.command()
@click.argument("code")
@click.option("--request-id", required=True, help="register 命令返回的 ULID")
@click.pass_context
def verify(ctx: click.Context, code: str, request_id: str) -> None:
    """用 request_id + 验证码换 session。"""
    with _client(ctx.obj["endpoint"]) as c:
        r = c.post("/auth/verify", json={"request_id": request_id, "code": code})
    if r.status_code != 200:
        click.echo(f"verify failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    data = r.json()
    masked = os.environ.get("_XIANGQIN_LAST_MASKED", "***")
    client_session.save(
        session_token=data["session_token"],
        user_id=data["user_id"],
        endpoint=ctx.obj["endpoint"],
        masked_phone=masked,
    )
    tag = "新用户" if data["is_new_user"] else "老用户"
    click.echo(f"登录成功（{tag}），user_id={data['user_id']}")
    click.echo("session 已写本机 ~/.xiangqin/session.json")


@cli.command()
@click.pass_context
def logout(ctx: click.Context) -> None:
    """登出（删本机 session + 服务端 session 行）。"""
    sess = ctx.obj.get("session")
    if sess and sess.get("session_token"):
        try:
            with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
                c.post("/auth/logout")
        except httpx.HTTPError:
            pass  # 本地清理无论如何都走
    client_session.clear()
    click.echo("已登出")


@cli.command()
@click.pass_context
def me(ctx: click.Context) -> None:
    """查当前登录态。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.get("/auth/me")
    if r.status_code != 200:
        click.echo(f"me failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    data = r.json()
    click.echo(f"user_id: {data['user_id']}")
    click.echo(f"phone:   {sess.get('masked_phone', '***')}")
    click.echo(f"endpoint: {ctx.obj['endpoint']}")


@cli.command()
@click.option("--confirm", is_flag=True, default=False, help="跳过交互确认（脚本用）")
@click.pass_context
def unregister(ctx: click.Context, confirm: bool) -> None:
    """注销账号（软删，30 天冷静期内可邮件申请恢复）。

    \b
    注销后：
    - 资料立即下线（其他用户不再搜到你）
    - 收件箱变不可读；发件方仍看到"已送达"
    - 已购未用信封 **不退款**（视为放弃）
    - 30 天内邮件 yarnb@qq.com 可申请撤回；过期不可恢复
    """
    sess = _require_session(ctx)
    if not confirm:
        click.echo("=" * 60)
        click.echo("  注销后果：")
        click.echo("  · 资料立即下线，其他用户不再搜到你")
        click.echo("  · 收件箱变不可读；发件方仍看到'已送达'")
        click.echo("  · 已购未用信封 **不退款**（视为放弃）")
        click.echo("  · 30 天内可邮件 yarnb@qq.com 申请撤回；过期不可恢复")
        click.echo("=" * 60)
        click.confirm("确认注销？", abort=True, default=False)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.post("/account/unregister")
    if r.status_code != 200:
        click.echo(f"注销失败: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    d = r.json()
    if d.get("already_unregistered"):
        click.echo(f"已注销（deleted_at={d['deleted_at']}），冷静期至 {d['grace_until']}")
    else:
        click.echo(f"✓ 账号已注销。deleted_at={d['deleted_at']}")
        click.echo(f"  冷静期至 {d['grace_until']}（unix ts）")
        click.echo("  30 天内如要撤回，请邮件 yarnb@qq.com 提供注册手机号")
    # 本地清 session
    client_session.clear()


@cli.command("verify-id")
@click.argument("legal_name")
@click.argument("cert_id")
@click.pass_context
def verify_id(ctx: click.Context, legal_name: str, cert_id: str) -> None:
    """实名二要素核验（姓名 + 身份证号）。FEAT-030 PRD-001。

    \b
    xq verify-id 张三 110101199001011237
    """
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.post("/auth/verify-id", json={"legal_name": legal_name, "cert_id": cert_id})
    if r.status_code != 200:
        detail = (
            r.json().get("detail", {})
            if r.headers.get("content-type", "").startswith("application/json")
            else {}
        )
        click.echo(
            f"实名核验失败: {detail.get('code', r.status_code)} {detail.get('message', r.text)}",
            err=True,
        )
        raise SystemExit(1)
    data = r.json()
    status = data["status"]
    if data["verified"]:
        click.echo(f"✓ {data['message']}")
    else:
        click.echo(f"✗ 核验未通过（{status}）：{data['message']}", err=True)
        raise SystemExit(1)


# ========== profile ==========


@cli.group()
def profile() -> None:
    """查 / 改个人资料。"""


@profile.command("show")
@click.pass_context
def profile_show(ctx: click.Context) -> None:
    """查看当前用户资料。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.get("/profile/me")
    if r.status_code != 200:
        click.echo(f"profile show failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    data = r.json()
    click.echo(f"user_id: {data['user_id']}")
    click.echo(f"gender:  {data.get('gender') or '（未填）'}")
    click.echo(f"age:     {data.get('age') or '（未填）'}")
    click.echo(f"city:    {data.get('city') or '（未填）'}")
    tags = data.get("tags") or []
    click.echo(f"tags:    {', '.join(tags) if tags else '（未填）'}")
    bio = data.get("bio")
    click.echo(f"bio:     {bio or '（未填）'}")


@profile.command("set")
@click.argument("field")
@click.argument("value")
@click.pass_context
def profile_set(ctx: click.Context, field: str, value: str) -> None:
    """改单个字段。

    字段：gender/age/city/tags/bio/height/education/agent_gateway_url/agent_hooks_token/notify_on_new_mail

    示例：
        xq profile set tags 程序,登山,做饭
        xq profile set height 175
        xq profile set education bachelor
        xq profile set agent_gateway_url https://b-xxx.example.com
        xq profile set notify_on_new_mail on
    """
    sess = _require_session(ctx)
    payload: dict[str, object] = {}
    if field == "age":
        try:
            payload["age"] = int(value)
        except ValueError as e:
            click.echo(f"age 必须是整数，收到 {value!r}", err=True)
            raise SystemExit(1) from e
    elif field == "height":
        try:
            payload["height"] = int(value)
        except ValueError as e:
            click.echo("height 必须是整数（cm）", err=True)
            raise SystemExit(1) from e
    elif field == "tags":
        payload["tags"] = [t.strip() for t in value.split(",") if t.strip()]
    elif field == "notify_on_new_mail":
        if value.lower() in {"on", "true", "1", "yes"}:
            payload["notify_on_new_mail"] = True
        elif value.lower() in {"off", "false", "0", "no"}:
            payload["notify_on_new_mail"] = False
        else:
            click.echo(f"notify_on_new_mail 请传 on/off，收到 {value!r}", err=True)
            raise SystemExit(1)
    elif field in {"gender", "city", "bio", "education", "agent_gateway_url", "agent_hooks_token"}:
        payload[field] = value
    else:
        click.echo(
            f"未知字段：{field}（允许 gender/age/city/tags/bio/height/education/"
            "agent_gateway_url/agent_hooks_token/notify_on_new_mail）",
            err=True,
        )
        raise SystemExit(1)

    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.put("/profile/me", json=payload)
    if r.status_code != 200:
        click.echo(f"profile set failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    click.echo(f"ok: {field} 已更新")


@profile.command("clear")
@click.argument("field", type=click.Choice(["tags", "bio"]))
@click.pass_context
def profile_clear(ctx: click.Context, field: str) -> None:
    """清空可空字段（只支持 tags / bio；gender/age/city 必填不让清）。"""
    sess = _require_session(ctx)
    payload: dict[str, object] = {field: [] if field == "tags" else None}
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.put("/profile/me", json=payload)
    if r.status_code != 200:
        click.echo(f"profile clear failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    click.echo(f"ok: {field} 已清空")


# ========== profile avatar / album（FEAT-008 / FEAT-009）==========


def _guess_content_type(path: Path) -> str:
    ext = path.suffix.lower().lstrip(".")
    return {
        "jpg": "image/jpeg",
        "jpeg": "image/jpeg",
        "png": "image/png",
        "webp": "image/webp",
    }.get(ext, "application/octet-stream")


@profile.group()
def avatar() -> None:
    """头像管理（upload / show / remove）。"""


@avatar.command("upload")
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.pass_context
def avatar_upload(ctx: click.Context, file: Path) -> None:
    """上传头像（替换旧的）。支持 jpeg / png / webp，≤ 5MB。"""
    sess = _require_session(ctx)
    content_type = _guess_content_type(file)
    with file.open("rb") as f:
        files = {"file": (file.name, f, content_type)}
        with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
            r = c.post("/photo/avatar", files=files)
    if r.status_code != 200:
        click.echo(f"avatar upload failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    data = r.json()
    click.echo(f"ok: avatar {data['id']} ({data['size_bytes']} bytes, {data['moderation_status']})")


@avatar.command("show")
@click.pass_context
def avatar_show(ctx: click.Context) -> None:
    """查自己当前头像的签名 URL（1h 有效）。"""
    sess = _require_session(ctx)
    # 用 /photo/avatar/<self-user-id> — 但 cli 没 user_id，通过 /auth/me 先拿
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        me = c.get("/auth/me")
        if me.status_code != 200:
            click.echo(f"auth failed: {me.text}", err=True)
            raise SystemExit(1)
        my_id = me.json()["user_id"]
        r = c.get(f"/photo/avatar/{my_id}")
    if r.status_code != 200:
        click.echo(f"avatar show failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    url = r.json().get("url")
    if not url:
        click.echo("（未设置头像）")
    else:
        click.echo(url)


@profile.group()
def album() -> None:
    """相册管理（add / list / remove）。"""


@album.command("add")
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.pass_context
def album_add(ctx: click.Context, file: Path) -> None:
    """加一张相册照片。支持 jpeg / png / webp，≤ 5MB。"""
    sess = _require_session(ctx)
    content_type = _guess_content_type(file)
    with file.open("rb") as f:
        files = {"file": (file.name, f, content_type)}
        with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
            r = c.post("/photo/album", files=files)
    if r.status_code != 200:
        click.echo(f"album add failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    data = r.json()
    click.echo(f"ok: album {data['id']} ({data['size_bytes']} bytes, {data['moderation_status']})")


@album.command("list")
@click.pass_context
def album_list(ctx: click.Context) -> None:
    """列我的相册。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.get("/photo/album")
    if r.status_code != 200:
        click.echo(f"album list failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    photos = r.json()
    if not photos:
        click.echo("（相册为空）")
        return
    for p in photos:
        click.echo(
            f"{p['id']}  {p['content_type']}  {p['size_bytes']}B  uploaded={p['uploaded_at']}"
        )
        if p.get("url"):
            click.echo(f"  {p['url']}")


@album.command("remove")
@click.argument("photo_id")
@click.pass_context
def album_remove(ctx: click.Context, photo_id: str) -> None:
    """删一张相册照片。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.delete(f"/photo/{photo_id}")
    if r.status_code != 200:
        click.echo(f"album remove failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    click.echo(f"ok: {photo_id} 已删")


@profile.command("export")
@click.pass_context
def profile_export(ctx: click.Context) -> None:
    """导出本人全部数据 JSON（PIPL §45 可携带权）。用法：xq profile export > my-data.json"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.get("/account/export")
    if r.status_code != 200:
        click.echo(f"export failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    click.echo(json.dumps(r.json(), ensure_ascii=False, indent=2))


# ========== query ==========


@cli.command()
@click.argument("where")
@click.option("--limit", default=50, show_default=True, type=int, help="最多返回几条（1-100）")
@click.option("--json", "as_json", is_flag=True, help="原始 JSON 输出")
@click.pass_context
def query(ctx: click.Context, where: str, limit: int, as_json: bool) -> None:
    """查匹配。受限 DSL，例：xq query 'gender=f AND city=hangzhou AND age>=25'

    结果按 updated_at 倒序（服务端零推荐算法）。每条带 avatar_url（签名 URL 1h 有效，无头像为 null）。
    """
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.post("/query", json={"where": where, "limit": limit})
    if r.status_code != 200:
        click.echo(f"query failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    data = r.json()
    if as_json:
        import json as _json

        click.echo(_json.dumps(data, ensure_ascii=False, indent=2))
        return

    hits = data["results"]
    if not hits:
        click.echo("0 条命中")
        return

    click.echo(f"{data['total_hits']} 条命中")
    click.echo("-" * 60)
    for h in hits:
        tags_s = ", ".join(h.get("tags") or [])
        fields = [
            h.get("gender") or "?",
            str(h.get("age") or "?"),
            h.get("city") or "?",
        ]
        if h.get("height"):
            fields.append(f"{h['height']}cm")
        if h.get("education"):
            fields.append(h["education"])
        click.echo(f"{h['user_id']}  {'  '.join(fields)}")
        if tags_s:
            click.echo(f"   tags: {tags_s}")
        if h.get("bio"):
            click.echo(f"   bio:  {h['bio']}")


# ========== mail / 私信（M4）==========


def _fmt_time(ts: int | None) -> str:
    if ts is None:
        return "-"
    import datetime as _dt

    return _dt.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")


def _fmt_status(m: dict) -> str:
    """压缩版状态展示：投递 + 通知 + 阅读 + 回应 + 处置。"""
    parts = []
    ds = m.get("delivery_status")
    if ds != "delivered":
        parts.append(f"投递={ds}")
        return " ".join(parts)
    ns = m.get("notify_status") or "-"
    parts.append(f"通知={ns}")
    parts.append(f"阅读={'已读' if m.get('read_at') else '未读'}")
    if m.get("replied_at"):
        parts.append("已回复")
    if m.get("deleted_by_recipient_at"):
        parts.append("对方已删")
    return " ".join(parts)


@cli.group()
def dm() -> None:
    """私信（发 / 查发件 / 查详情）。"""


@dm.command("send")
@click.argument("to_user_id")
@click.argument("body")
@click.pass_context
def dm_send(ctx: click.Context, to_user_id: str, body: str) -> None:
    """给对方发私信（消耗 1 个信封）。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.post("/dm/send", json={"to_user_id": to_user_id, "body": body})
    if r.status_code != 200:
        click.echo(f"发信失败: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    m = r.json()
    src = "免费额度" if m["envelope_source"] == "free_daily" else "付费信封"
    click.echo(f"✓ 已投递（{src}）。msg={m['id']}")
    if m.get("notify_status"):
        click.echo(f"  通知：{m['notify_status']}")


@dm.command("sent")
@click.option("--limit", default=20, type=int, show_default=True)
@click.pass_context
def dm_sent(ctx: click.Context, limit: int) -> None:
    """查我发出去的私信。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.get(f"/dm/sent?limit={limit}")
    if r.status_code != 200:
        click.echo(f"查询失败: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    msgs = r.json()
    if not msgs:
        click.echo("暂无发信记录")
        return
    for m in msgs:
        click.echo(f"{_fmt_time(m['sent_at'])}  → {m['to_user_id']}  {_fmt_status(m)}  {m['id']}")


@dm.command("show")
@click.argument("message_id")
@click.pass_context
def dm_show(ctx: click.Context, message_id: str) -> None:
    """查单条详情（5 维状态）。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.get(f"/dm/message/{message_id}")
    if r.status_code != 200:
        click.echo(f"查询失败: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    m = r.json()
    click.echo(f"ID:        {m['id']}")
    click.echo(f"From:      {m['from_user_id']}")
    click.echo(f"To:        {m['to_user_id']}")
    click.echo(f"Sent:      {_fmt_time(m['sent_at'])}")
    click.echo(f"Delivery:  {m['delivery_status']}")
    if m.get("delivery_failure_reason"):
        click.echo(f"  reason:  {m['delivery_failure_reason']}")
    click.echo(
        f"Notify:    {m.get('notify_status') or '-'} ({_fmt_time(m.get('notify_attempted_at'))})"
    )
    if m.get("notify_error"):
        click.echo(f"  error:   {m['notify_error']}")
    click.echo(f"Read:      {_fmt_time(m.get('read_at'))}")
    click.echo(f"Replied:   {_fmt_time(m.get('replied_at'))}")
    click.echo(f"Envelope:  {m['envelope_source']}")
    click.echo(f"Expires:   {_fmt_time(m['expires_at'])}")
    click.echo("Body:")
    click.echo(f"  {m['body']}")


@cli.group()
def inbox() -> None:
    """收件箱（查收 / 看 / 回复 / 删 / 举报）。"""


@inbox.command("list")
@click.option("--limit", default=20, type=int, show_default=True)
@click.pass_context
def inbox_list_cmd(ctx: click.Context, limit: int) -> None:
    """查收件箱（平铺，按时间倒序）。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.get(f"/inbox?limit={limit}")
    if r.status_code != 200:
        click.echo(f"查询失败: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    msgs = r.json()
    if not msgs:
        click.echo("收件箱空")
        return
    for m in msgs:
        mark = " " if m.get("read_at") else "●"
        preview = (m["body"][:40] + "...") if len(m["body"]) > 40 else m["body"]
        click.echo(f"{mark} {_fmt_time(m['sent_at'])}  ← {m['from_user_id']}  {m['id']}")
        click.echo(f"    {preview}")


@inbox.command("show")
@click.argument("message_id")
@click.pass_context
def inbox_show(ctx: click.Context, message_id: str) -> None:
    """查看单条并标为已读。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.post(f"/inbox/{message_id}/read")
    if r.status_code != 200:
        click.echo(f"查询失败: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    m = r.json()
    click.echo(f"From:    {m['from_user_id']}")
    click.echo(f"Sent:    {_fmt_time(m['sent_at'])}")
    click.echo("Body:")
    click.echo(f"  {m['body']}")
    click.echo("")
    click.echo("下一步：")
    click.echo(f'  xq inbox reply {message_id} "..."')
    click.echo(f"  xq inbox report {message_id} --reason '骚扰'")
    click.echo(f"  xq block {m['from_user_id']}")


@inbox.command("reply")
@click.argument("message_id")
@click.argument("body")
@click.pass_context
def inbox_reply_cmd(ctx: click.Context, message_id: str, body: str) -> None:
    """回信（消耗 1 个信封）。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.post(f"/inbox/{message_id}/reply", json={"body": body})
    if r.status_code != 200:
        click.echo(f"回复失败: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    m = r.json()
    click.echo(f"✓ 已回复。msg={m['id']}")


@inbox.command("delete")
@click.argument("message_id")
@click.pass_context
def inbox_delete_cmd(ctx: click.Context, message_id: str) -> None:
    """软删（服务端仍保留 30 天）。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.delete(f"/inbox/{message_id}")
    if r.status_code != 200:
        click.echo(f"删除失败: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    click.echo("已删除")


@inbox.command("report")
@click.argument("message_id")
@click.option("--reason", default=None, help="举报原因")
@click.pass_context
def inbox_report_cmd(ctx: click.Context, message_id: str, reason: str | None) -> None:
    """举报一条私信（留档取证）。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.post(f"/inbox/{message_id}/report", json={"reason": reason})
    if r.status_code != 200:
        click.echo(f"举报失败: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    d = r.json()
    click.echo(f"✓ 已举报 (report_id={d['id']})。举报人 + 被举报人已留档。")


@cli.group()
def envelope() -> None:
    """信封（查额度 / 购买）。"""


@envelope.command("state")
@click.pass_context
def envelope_state_cmd(ctx: click.Context) -> None:
    """查今日信封余额。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.get("/envelope")
    if r.status_code != 200:
        click.echo(f"查询失败: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    s = r.json()
    click.echo(f"今日免费剩余: {s['free_remaining_today']} / {s['free_per_day']}")
    click.echo(f"付费信封余量: {s['paid_remaining']}")
    click.echo(f"历史累计购买: {s['total_purchased']}")
    click.echo(f"信封单价:    ¥{s['price_cents'] / 100:.2f}")


@envelope.command("buy")
@click.option("--count", required=True, type=int, help="买几个信封（1-10000）")
@click.option("--mock", is_flag=True, help="mock 到账（测试用，不真扣钱）")
@click.pass_context
def envelope_buy_cmd(ctx: click.Context, count: int, mock: bool) -> None:
    """购买信封。默认真付（支付宝扫码），--mock 直接到账不扣钱。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.post("/envelope/buy", json={"count": count, "mock": mock})
    if r.status_code != 200:
        click.echo(f"购买失败: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    d = r.json()
    yuan = d["amount_cents"] / 100

    if d.get("mock"):
        click.echo(f"✓ mock 到账 {d['count']} 封  ¥{yuan:.2f}  order={d['id']}")
        return

    # 真付：打印二维码 + 提示轮询
    click.echo(f"订单 {d['id']}  {d['count']} 封  ¥{yuan:.2f}")
    click.echo("5 分钟内用支付宝扫描下方二维码支付：\n")
    try:
        import qrcode

        q = qrcode.QRCode(version=None, border=1)
        q.add_data(d["qr_code"])
        q.print_ascii(invert=True)
    except ImportError:
        click.echo(f"（qrcode 未装，直接打原始 URL 给支付宝扫）: {d['qr_code']}")
    click.echo(f"\n查支付状态: xq envelope order {d['id']}")


@envelope.command("order")
@click.argument("order_id")
@click.pass_context
def envelope_order_cmd(ctx: click.Context, order_id: str) -> None:
    """查订单状态（轮询用）。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.get(f"/envelope/order/{order_id}")
    if r.status_code == 404:
        click.echo(f"订单不存在: {order_id}", err=True)
        raise SystemExit(1)
    if r.status_code != 200:
        click.echo(f"查询失败: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    o = r.json()
    yuan = o["amount_cents"] / 100
    click.echo(
        f"order={o['id']}  {o['count']} 封  ¥{yuan:.2f}  "
        f"provider={o['provider']}  status={o['status']}  paid={o['paid']}"
    )


# ========== admin（FEAT-025/028/029，仅白名单可用）==========


@cli.group()
def admin() -> None:
    """管理员命令（封号 / 举报审核 / 业务指标）。需 XIANGQIN_ADMIN_USER_IDS 白名单。"""


@admin.group("user")
def admin_user() -> None:
    """admin user info/ban/unban/list"""


@admin_user.command("info")
@click.argument("target_user_id")
@click.pass_context
def admin_user_info(ctx: click.Context, target_user_id: str) -> None:
    """查用户封号状态。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.get(f"/admin/user/{target_user_id}")
    if r.status_code != 200:
        click.echo(f"admin user info failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    d = r.json()
    click.echo(f"user_id:    {d['user_id']}")
    click.echo(f"status:     {d['status']}")
    click.echo(f"reason:     {d.get('reason') or '-'}")
    click.echo(f"updated_at: {d.get('updated_at') or '-'}")


@admin_user.command("ban")
@click.argument("target_user_id")
@click.option("--reason", default=None, help="封号原因（审计留档）")
@click.option("--freeze", is_flag=True, help="软封（冻结，冷静期；默认硬封 banned）")
@click.pass_context
def admin_user_ban(
    ctx: click.Context, target_user_id: str, reason: str | None, freeze: bool
) -> None:
    """封号（默认 banned 硬封；--freeze 软封）。"""
    sess = _require_session(ctx)
    mode = "freeze" if freeze else "ban"
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.post(
            f"/admin/user/{target_user_id}/ban",
            json={"reason": reason, "mode": mode},
        )
    if r.status_code != 200:
        click.echo(f"admin ban failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    d = r.json()
    click.echo(f"ok: user {d['user_id']} → {d['status']}  reason={d.get('reason') or '-'}")


@admin_user.command("unban")
@click.argument("target_user_id")
@click.pass_context
def admin_user_unban(ctx: click.Context, target_user_id: str) -> None:
    """解封（banned / frozen → active）。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.post(f"/admin/user/{target_user_id}/unban")
    if r.status_code != 200:
        click.echo(f"admin unban failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    d = r.json()
    click.echo(f"ok: user {d['user_id']} → {d['status']}")


@admin_user.command("list")
@click.option(
    "--status",
    type=click.Choice(["active", "frozen", "banned"]),
    default="banned",
    help="按状态筛（默认 banned）",
)
@click.option("--limit", default=100, type=int)
@click.pass_context
def admin_user_list(ctx: click.Context, status: str, limit: int) -> None:
    """按状态列用户。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.get(f"/admin/users?status={status}&limit={limit}")
    if r.status_code != 200:
        click.echo(f"admin user list failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    rows = r.json()
    if not rows:
        click.echo(f"（no users with status={status}）")
        return
    for u in rows:
        click.echo(f"{u['user_id']}  status={u['status']}  reason={u.get('reason') or '-'}")


@admin_user.command("reactivate")
@click.argument("target_user_id")
@click.pass_context
def admin_user_reactivate(ctx: click.Context, target_user_id: str) -> None:
    """撤销注销（冷静期内；用户邮件申请后 admin 跑）。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.post(f"/admin/user/{target_user_id}/reactivate")
    if r.status_code != 200:
        click.echo(f"reactivate failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    click.echo(f"ok: user {target_user_id} 已恢复")


@admin_user.command("hard-delete")
@click.argument("target_user_id")
@click.option("--confirm", is_flag=True, default=False, help="跳过交互确认")
@click.pass_context
def admin_user_hard_delete(ctx: click.Context, target_user_id: str, confirm: bool) -> None:
    """立即硬删（GDPR §17 被遗忘权，不走 30 天冷静期）。"""
    if not confirm:
        click.echo(f"硬删 {target_user_id}：")
        click.echo("  · 立即物理删 profiles / messages / photos / blocks / reports / sessions")
        click.echo("  · users 行保留但 phone_hash scrub 成 'DELETED:<id>'（防同号复注）")
        click.echo("  · envelope_orders 保留（5 年会计法审计）")
        click.echo("  · 不可撤销")
        click.confirm("确认？", abort=True, default=False)
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.post(f"/admin/user/{target_user_id}/hard-delete")
    if r.status_code != 200:
        click.echo(f"hard-delete failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    d = r.json()
    click.echo(f"✓ user {d['user_id']} 已硬删  hard_deleted_at={d['hard_deleted_at']}")


@admin.group("account")
def admin_account() -> None:
    """账号生命周期维护（purge-expired 等）。FEAT-010。"""


@admin_account.command("purge-expired")
@click.option("--older-than-days", default=30, type=int, help="冷静期天数（默认 30）")
@click.pass_context
def admin_account_purge_expired(ctx: click.Context, older_than_days: int) -> None:
    """扫冷静期已过的注销用户 → 硬删。每天 cron 跑。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.post("/admin/account/purge-expired", json={"older_than_days": older_than_days})
    if r.status_code != 200:
        click.echo(f"purge-expired failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    d = r.json()
    click.echo(f"✓ 清理 {d['purged_count']} 个过期账号  cutoff_ts={d['cutoff_ts']}")
    if d.get("purged_ids"):
        for uid in d["purged_ids"]:
            click.echo(f"  - {uid}")


@admin.group("review")
def admin_review() -> None:
    """admin review pending/pass/reject/confirm（举报审核）"""


@admin_review.command("pending")
@click.option("--limit", default=50, type=int)
@click.pass_context
def admin_review_pending(ctx: click.Context, limit: int) -> None:
    """列待审核举报（按时间升序，先报先审）。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.get(f"/admin/reports/pending?limit={limit}")
    if r.status_code != 200:
        click.echo(f"admin review pending failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    rows = r.json()
    if not rows:
        click.echo("（没有待审举报）")
        return
    for rep in rows:
        click.echo(
            f"{rep['id']}  reporter={rep['reporter_id']}  → reported={rep['reported_id']}  "
            f"reason={rep.get('reason') or '-'}  msg={rep['message_id']}"
        )


def _do_review(ctx: click.Context, report_id: str, action: str, **kwargs: Any) -> None:
    sess = _require_session(ctx)
    payload: dict[str, Any] = {"action": action, **kwargs}
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.post(f"/admin/reports/{report_id}/review", json=payload)
    if r.status_code != 200:
        click.echo(f"admin review {action} failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    d = r.json()
    click.echo(f"ok: report {d['id']} → {d['review_status']}  by={d['reviewed_by']}")


@admin_review.command("pass")
@click.argument("report_id")
@click.pass_context
def admin_review_pass(ctx: click.Context, report_id: str) -> None:
    """通过审核（无后续动作）。"""
    _do_review(ctx, report_id, "pass")


@admin_review.command("reject")
@click.argument("report_id")
@click.pass_context
def admin_review_reject(ctx: click.Context, report_id: str) -> None:
    """驳回（标 spam 举报）。"""
    _do_review(ctx, report_id, "reject")


@admin_review.command("confirm")
@click.argument("report_id")
@click.option("--ban-target", is_flag=True, help="确认后顺手封号被举报者")
@click.option("--reason", default=None)
@click.pass_context
def admin_review_confirm(
    ctx: click.Context, report_id: str, ban_target: bool, reason: str | None
) -> None:
    """确认违规（可选 --ban-target 顺手封号）。"""
    _do_review(ctx, report_id, "confirm", ban_target=ban_target, ban_reason=reason)


@admin.command("stats")
@click.pass_context
def admin_stats(ctx: click.Context) -> None:
    """业务指标（用户数 / 付款量 / 举报数）。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.get("/admin/stats")
    if r.status_code != 200:
        click.echo(f"admin stats failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    d = r.json()
    u = d["users"]
    o = d["orders"]
    click.echo(f"users:    total={u['total']}  active={u['active']}  banned={u['banned']}")
    click.echo(f"messages: delivered={d['messages']['total_delivered']}")
    click.echo(
        f"orders:   total={o['total']}  paid_real={o['paid_real']}  "
        f"paid_amount=¥{o['paid_amount_yuan']}"
    )
    click.echo(f"reports:  pending={d['reports']['pending']}")


@admin.group("notify")
def admin_notify() -> None:
    """agent_gateway 通知管理（重试失败 / 查状态）。FEAT-018。"""


@admin_notify.command("retry")
@click.option("--limit", default=50, help="最多重试的消息数（默认 50）")
@click.option("--since-hours", default=24, help="只重试多少小时内 push_failed 的消息（默认 24）")
@click.pass_context
def admin_notify_retry(ctx: click.Context, limit: int, since_hours: int) -> None:
    """扫 messages 表 notify_status=push_failed 的，逐条重发通知。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.post("/admin/notify/retry", json={"limit": limit, "since_hours": since_hours})
    if r.status_code != 200:
        click.echo(f"notify retry failed: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    d = r.json()
    click.echo(
        f"扫描 {d['scanned']} 条 push_failed → "
        f"重发成功 {d['pushed']}，再次失败 {d['still_failed']}，"
        f"被 12h 去重跳过 {d['suppressed']}"
    )
    if d.get("details"):
        click.echo("\n明细：")
        for x in d["details"]:
            click.echo(f"  {x['message_id']}  {x['status']}  {x.get('error', '')[:80]}")


# ========== block / 拉黑 ==========


@cli.command("block")
@click.argument("user_id")
@click.pass_context
def block_cmd(ctx: click.Context, user_id: str) -> None:
    """拉黑某用户（对方后续发给我的信会被拦截）。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.post("/block", json={"user_id": user_id})
    if r.status_code != 200:
        click.echo(f"拉黑失败: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    click.echo(f"✓ 已拉黑 {user_id}")


@cli.command("unblock")
@click.argument("user_id")
@click.pass_context
def unblock_cmd(ctx: click.Context, user_id: str) -> None:
    """解除拉黑。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.delete(f"/block/{user_id}")
    if r.status_code != 200:
        click.echo(f"解除失败: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    click.echo(f"✓ 已解除拉黑 {user_id}")


@cli.command("blocklist")
@click.pass_context
def blocklist_cmd(ctx: click.Context) -> None:
    """查我的拉黑名单。"""
    sess = _require_session(ctx)
    with _client(ctx.obj["endpoint"], sess["session_token"]) as c:
        r = c.get("/blocklist")
    if r.status_code != 200:
        click.echo(f"查询失败: {r.status_code} {r.text}", err=True)
        raise SystemExit(1)
    items = r.json()
    if not items:
        click.echo("拉黑名单空")
        return
    for it in items:
        click.echo(f"{_fmt_time(it['created_at'])}  {it['blocked_id']}")
