#!/usr/bin/env python3
"""智能客服在线 Bug：组单并通过虾友 DM 发送给研发值班。"""

from __future__ import annotations

import argparse
import base64
import json
import os
import sys
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Optional


DEFAULT_TIMEOUT = 30
DEFAULT_API_BASE_URL = "https://yagent.sophnet.com/api"
DEFAULT_JWT_PATH = Path("/home/node/.openclaw/jwt.json")
DEFAULT_OPENCLAW_BASE_PATH = Path("/home/node/.openclaw/.base.json")
DEFAULT_SOURCE_LABEL = "虾友 DM"

CONFIG_ALIASES: dict[str, tuple[str, ...]] = {
    "dev_friend_id": ("dev_friend_id", "DEV_FRIEND_ID", "friend_id"),
    "dev_friend_name": ("dev_friend_name", "DEV_FRIEND_NAME", "friend_name"),
    "terminal_label": ("terminal_label", "TERMINAL_LABEL"),
    "terminal_url": ("terminal_url", "TERMINAL_URL"),
    "session_url_template": (
        "session_url_template",
        "SESSION_URL_TEMPLATE",
        "bug_session_url_template",
    ),
    "api_base_url": ("api_base_url", "API_BASE_URL", "base_url"),
    "openclaw_base_url": (
        "openclaw_base_url",
        "OPENCLAW_BASE_URL",
        "sophclaw_base_url",
    ),
    "default_model": ("default_model", "DEFAULT_MODEL", "llm", "model"),
}


class AppError(RuntimeError):
    pass


def configure_stdio() -> None:
    for name in ("stdout", "stderr"):
        stream = getattr(sys, name, None)
        if stream is not None and hasattr(stream, "reconfigure"):
            stream.reconfigure(encoding="utf-8", errors="replace")


def print_json(payload: Any) -> None:
    print(json.dumps(payload, ensure_ascii=False, indent=2))


def normalize_http_url(url: str, *, field_name: str = "url") -> str:
    value = (url or "").strip().rstrip("/")
    if not value:
        raise AppError(f"{field_name} 不能为空")
    if not value.startswith(("http://", "https://")):
        raise AppError(f"{field_name} 必须以 http:// 或 https:// 开头")
    return value


def load_json_file(path: Path) -> dict[str, Any]:
    try:
        with path.open("r", encoding="utf-8") as fp:
            data = json.load(fp)
    except OSError as exc:
        raise AppError(f"无法读取文件 {path}: {exc}") from exc
    except json.JSONDecodeError as exc:
        raise AppError(f"文件不是有效 JSON: {path}: {exc}") from exc
    if not isinstance(data, dict):
        raise AppError(f"JSON 根对象必须是对象: {path}")
    return data


def read_openclaw_base_url(path: Path) -> Optional[str]:
    if not path.is_file():
        return None
    try:
        data = load_json_file(path)
    except AppError:
        return None
    raw = data.get("base_url")
    if not isinstance(raw, str) or not raw.strip():
        return None
    return raw.strip().rstrip("/")


def skill_src_dir() -> Path:
    """Skill 可打包目录（与 prepublish 仅复制 src/ 一致）。"""
    return Path(__file__).resolve().parent.parent


def default_cred_file() -> Path:
    return skill_src_dir() / "secrets" / "bug-report.json"


def resolve_cred_path(explicit: Optional[str]) -> Optional[Path]:
    if explicit and str(explicit).strip():
        return Path(explicit).expanduser()
    default = default_cred_file()
    return default if default.is_file() else None


def load_cred_file(path: Optional[str], *, required: bool = False) -> dict[str, Any]:
    file_path = resolve_cred_path(path)
    if file_path is None:
        if required:
            raise AppError(
                f"未找到凭证文件，请使用 --cred-file 或确认存在：{default_cred_file()}"
            )
        return {}
    if not file_path.is_file():
        raise AppError(f"凭证文件不存在: {file_path}")
    return load_json_file(file_path)


def cfg_pick(cred: dict[str, Any], key: str, env_name: Optional[str] = None) -> Optional[str]:
    for alias in CONFIG_ALIASES.get(key, (key,)):
        value = cred.get(alias)
        if isinstance(value, str) and value.strip():
            return value.strip()
        if isinstance(value, (int, float)) and key == "dev_friend_id":
            return str(int(value))
    if env_name:
        env_val = os.environ.get(env_name)
        if isinstance(env_val, str) and env_val.strip():
            return env_val.strip()
    return None


def resolve_session_url(
    session_key: Optional[str],
    session_url: Optional[str],
    template: Optional[str],
    openclaw_base_url: Optional[str],
) -> Optional[str]:
    if isinstance(session_url, str) and session_url.strip():
        return session_url.strip()
    if not session_key or not template or not openclaw_base_url:
        return None
    try:
        return template.format(
            base_url=openclaw_base_url.rstrip("/"),
            session_key=session_key.strip(),
        )
    except (KeyError, ValueError) as exc:
        raise AppError(f"session_url_template 格式无效: {exc}") from exc


@dataclass
class BugReport:
    sender_id: str
    sender_name: str
    user_description: str
    openclaw_base_url: Optional[str] = None
    terminal_label: str = DEFAULT_SOURCE_LABEL
    terminal_url: Optional[str] = None
    session_key: Optional[str] = None
    session_url: Optional[str] = None
    context: Optional[str] = None
    model: Optional[str] = None
    oss_urls: list[str] = field(default_factory=list)
    reported_at: Optional[str] = None

    def validate(self) -> None:
        if not self.sender_id.strip():
            raise AppError("sender-id 不能为空")
        if not self.sender_name.strip():
            raise AppError("sender-name 不能为空")
        if not self.user_description.strip():
            raise AppError("description 不能为空")
        for url in self.oss_urls:
            normalize_http_url(url, field_name="oss-url")


def format_bug_message(report: BugReport) -> str:
    ts = report.reported_at or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    lines = [
        f"【在线 Bug】{ts}",
        "",
        "▎发件人",
        f"- 用户: {report.sender_name.strip()} ({report.sender_id.strip()})",
        f"- 来源: {report.terminal_label.strip() or DEFAULT_SOURCE_LABEL}",
        "",
        "▎用户描述",
        report.user_description.strip(),
        "",
        "▎环境",
    ]
    if report.openclaw_base_url:
        lines.append(f"- OpenClaw Base URL: {report.openclaw_base_url}")
    else:
        lines.append("- OpenClaw Base URL: （未配置）")
    terminal_line = f"- 终端: {report.terminal_label.strip() or DEFAULT_SOURCE_LABEL}"
    if report.terminal_url and report.terminal_url.strip():
        terminal_line += f" ({report.terminal_url.strip()})"
    lines.append(terminal_line)
    if report.model and report.model.strip():
        lines.append(f"- 模型: {report.model.strip()}")
    else:
        lines.append("- 模型: （未知）")

    lines.extend(["", "▎会话"])
    if report.session_key and report.session_key.strip():
        lines.append(f"- sessionKey: {report.session_key.strip()}")
    else:
        lines.append("- sessionKey: （无）")
    if report.session_url and report.session_url.strip():
        lines.append(f"- Session 链接: {report.session_url.strip()}")
    if report.context and report.context.strip():
        lines.extend(["- 近期上下文:", report.context.strip()])

    lines.extend(["", "▎附件"])
    if report.oss_urls:
        lines.extend(report.oss_urls)
    else:
        lines.append("（无）")

    return "\n".join(lines).rstrip() + "\n"


def read_bearer_token(jwt_path: Path) -> str:
    data = load_json_file(jwt_path)
    token = data.get("web_jwt")
    if not isinstance(token, str) or not token.strip():
        raise AppError(f"JWT 文件缺少有效 web_jwt: {jwt_path}")
    token = token.strip()
    if token.lower().startswith("bearer "):
        token = token[7:].strip()
    return token


def jwt_exp_unix(token: str) -> Optional[int]:
    try:
        parts = token.split(".")
        if len(parts) != 3:
            return None
        payload = parts[1] + "=" * (-len(parts[1]) % 4)
        raw = base64.urlsafe_b64decode(payload.encode("ascii"))
        data = json.loads(raw)
        exp = data.get("exp")
        return int(exp) if isinstance(exp, (int, float)) else None
    except Exception:
        return None


def ensure_jwt_valid(token: str, jwt_path: Path, allow_expired: bool) -> None:
    if allow_expired:
        return
    exp = jwt_exp_unix(token)
    if exp is None:
        return
    now = int(time.time())
    if now >= exp + 60:
        raise AppError(f"JWT 已过期，请重新登录后刷新 {jwt_path}")


def request_json(
    method: str,
    url: str,
    token: str,
    timeout: int,
    payload: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
    headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
    body = None
    if payload is not None:
        body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        headers["Content-Type"] = "application/json"
    req = urllib.request.Request(url=url, data=body, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            raw = resp.read().decode("utf-8", errors="replace")
    except urllib.error.HTTPError as exc:
        err_body = exc.read().decode("utf-8", errors="replace")
        hint = ""
        if exc.code == 401:
            hint = f"（请检查 {jwt_path_hint()} 中的 web_jwt）"
        raise AppError(f"HTTP {exc.code} {url}: {err_body}{hint}") from exc
    except urllib.error.URLError as exc:
        raise AppError(f"请求失败 {url}: {exc}") from exc

    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        raise AppError(f"接口返回不是有效 JSON: {url}") from exc
    if isinstance(data, dict) and data.get("status") not in (None, 0):
        raise AppError(
            f"接口错误: status={data.get('status')} message={data.get('message')!r}"
        )
    return data if isinstance(data, dict) else {"result": data}


def jwt_path_hint() -> str:
    return str(DEFAULT_JWT_PATH)


def unwrap_result(data: dict[str, Any]) -> Any:
    return data.get("result") if "result" in data else data


def get_or_create_dm(api_base: str, token: str, friend_id: int, timeout: int) -> str:
    url = f"{api_base}/sys/openclaw/friend/dm"
    data = request_json("POST", url, token, timeout, payload={"friendId": friend_id})
    result = unwrap_result(data) or {}
    channel = result.get("channel") if isinstance(result, dict) else None
    if not isinstance(channel, dict):
        channel = result if isinstance(result, dict) else {}
    rid = channel.get("_id")
    if not rid:
        raise AppError("DM 创建成功但缺少 result.channel._id")
    return str(rid)


def send_im_message(api_base: str, token: str, rid: str, text: str, timeout: int) -> dict[str, Any]:
    url = f"{api_base}/sys/openclaw/im/chat.sendMessage"
    data = request_json("POST", url, token, timeout, payload={"rid": rid, "msg": text})
    result = unwrap_result(data) or {}
    message = result.get("message") if isinstance(result, dict) else None
    return message if isinstance(message, dict) else {}


def load_friends(api_base: str, token: str, timeout: int) -> list[dict[str, Any]]:
    url = f"{api_base}/sys/openclaw/friend/list"
    data = request_json("GET", url, token, timeout)
    result = unwrap_result(data)
    if not isinstance(result, dict):
        raise AppError("虾友列表接口返回格式异常")
    friends = result.get("friends")
    if not isinstance(friends, list):
        raise AppError("虾友列表缺少 friends 数组")
    out: list[dict[str, Any]] = []
    for item in friends:
        if not isinstance(item, dict):
            continue
        fid = item.get("friendId")
        if fid is None:
            continue
        display = str(item.get("remark") or item.get("nickname") or "未命名虾友").strip()
        out.append({"friendId": fid, "displayName": display})
    return out


def find_nearest_agent_config(start_dir: Path | None = None) -> Path | None:
    current = (start_dir or Path.cwd()).resolve()
    for candidate in [current, *current.parents]:
        config_path = candidate / ".config.json"
        if config_path.is_file():
            return config_path
    return None


def read_model_from_agent_config(config_path: Path | None = None) -> Optional[str]:
    path = config_path or find_nearest_agent_config()
    if path is None:
        return None
    try:
        data = load_json_file(path)
    except AppError:
        return None
    llm = data.get("llm")
    if isinstance(llm, str) and llm.strip():
        return llm.strip()
    model = data.get("model")
    if isinstance(model, str) and model.strip():
        return model.strip()
    return None


def read_context_file(path: Optional[str]) -> Optional[str]:
    if not path:
        return None
    file_path = Path(path).expanduser()
    try:
        text = file_path.read_text(encoding="utf-8")
    except OSError as exc:
        raise AppError(f"无法读取 context 文件 {file_path}: {exc}") from exc
    text = text.strip()
    return text or None


def build_report_from_args(args: argparse.Namespace) -> BugReport:
    cred = load_cred_file(args.cred_file)

    openclaw_base = (
        (args.openclaw_base_url or "").strip()
        or cfg_pick(cred, "openclaw_base_url", "OPENCLAW_BASE_URL")
        or read_openclaw_base_url(Path(args.openclaw_base_path).expanduser())
    )

    terminal_label = (
        (args.terminal_label or "").strip()
        or cfg_pick(cred, "terminal_label", "BUG_TERMINAL_LABEL")
        or DEFAULT_SOURCE_LABEL
    )
    terminal_url = (
        (args.terminal_url or "").strip()
        or cfg_pick(cred, "terminal_url", "BUG_TERMINAL_URL")
        or None
    )

    session_key = (args.session_key or "").strip() or None
    template = (args.session_url_template or "").strip() or cfg_pick(
        cred, "session_url_template", "BUG_SESSION_URL_TEMPLATE"
    )
    explicit_session_url = (args.session_url or "").strip() or None
    session_url = resolve_session_url(
        session_key,
        explicit_session_url,
        template,
        openclaw_base,
    )

    context = (args.context or "").strip() or read_context_file(args.context_file)

    oss_urls = [normalize_http_url(u, field_name="oss-url") for u in (args.oss_url or [])]

    model = (
        (args.model or "").strip()
        or cfg_pick(cred, "default_model", "BUG_MODEL")
        or read_model_from_agent_config()
    ) or None

    return BugReport(
        sender_id=args.sender_id,
        sender_name=args.sender_name,
        user_description=args.description,
        openclaw_base_url=openclaw_base,
        terminal_label=terminal_label,
        terminal_url=terminal_url,
        session_key=session_key,
        session_url=session_url,
        context=context,
        model=model,
        oss_urls=oss_urls,
        reported_at=(args.reported_at or "").strip() or None,
    )


def resolve_dev_friend(args: argparse.Namespace, cred: dict[str, Any]) -> tuple[int, str]:
    if args.friend_id is not None and args.friend_id > 0:
        friend_id = int(args.friend_id)
    else:
        raw = cfg_pick(cred, "dev_friend_id", "BUG_DEV_FRIEND_ID")
        if not raw:
            raise AppError("未配置研发虾友：请使用 --friend-id 或凭证文件 dev_friend_id")
        try:
            friend_id = int(raw)
        except ValueError as exc:
            raise AppError(f"dev_friend_id 不是有效整数: {raw}") from exc
    if friend_id <= 0:
        raise AppError("friend-id 必须为正整数")
    friend_name = (
        (args.friend_name or "").strip()
        or cfg_pick(cred, "dev_friend_name", "BUG_DEV_FRIEND_NAME")
        or "研发值班"
    )
    return friend_id, friend_name


def add_report_args(parser: argparse.ArgumentParser) -> None:
    parser.add_argument(
        "--cred-file",
        "-c",
        help="凭证 JSON 路径（默认 src/secrets/bug-report.json）",
    )
    parser.add_argument("--sender-id", required=True, help="发件人 ID（虾友/Bot senderId）")
    parser.add_argument("--sender-name", required=True, help="发件人展示名")
    parser.add_argument("--description", required=True, help="用户 Bug 描述")
    parser.add_argument("--session-key", default="", help="当前会话 sessionKey")
    parser.add_argument(
        "--session-url",
        default="",
        help="Session 可点开链接（优先于 template）",
    )
    parser.add_argument(
        "--session-url-template",
        default="",
        help="Session URL 模板，占位符 {base_url} {session_key}",
    )
    parser.add_argument(
        "--openclaw-base-url",
        default="",
        help="OpenClaw 实例根 URL（默认读 ~/.openclaw/.base.json）",
    )
    parser.add_argument(
        "--openclaw-base-path",
        default=str(DEFAULT_OPENCLAW_BASE_PATH),
        help="base_url JSON 路径",
    )
    parser.add_argument("--terminal-label", default="", help="终端名称，默认「虾友 DM」")
    parser.add_argument("--terminal-url", default="", help="终端入口 URL（可选）")
    parser.add_argument(
        "--model",
        default="",
        help="用户/客服会话使用的模型（默认读凭证 default_model 或就近 .config.json 的 llm）",
    )
    parser.add_argument("--context", default="", help="近期对话上下文（多行文本）")
    parser.add_argument("--context-file", help="从文件读取近期上下文")
    parser.add_argument(
        "--oss-url",
        action="append",
        default=[],
        help="OSS 附件链接，可重复传入",
    )
    parser.add_argument("--reported-at", default="", help="工单时间，默认当前时间")


def add_http_args(parser: argparse.ArgumentParser) -> None:
    parser.add_argument(
        "--api-base-url",
        default="",
        help=f"虾友 API 根地址（默认 {DEFAULT_API_BASE_URL}）",
    )
    parser.add_argument("--jwt-path", default=str(DEFAULT_JWT_PATH))
    parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
    parser.add_argument("--allow-expired-jwt", action="store_true")
    parser.add_argument("--friend-id", type=int, help="研发值班虾友 friendId")
    parser.add_argument("--friend-name", default="", help="研发虾友展示名（日志用）")


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="在线 Bug：组单并发送给研发虾友")
    sub = parser.add_subparsers(dest="command", required=True)

    p_preview = sub.add_parser("preview", help="预览工单正文（不发送）")
    add_report_args(p_preview)
    p_preview.add_argument("--json", action="store_true", help="JSON 输出")

    p_send = sub.add_parser("send", help="发送工单到研发虾友")
    add_report_args(p_send)
    add_http_args(p_send)
    p_send.add_argument("--json", action="store_true", help="JSON 输出")

    p_friends = sub.add_parser("list-friends", help="列出虾友（配置 dev_friend_id 时用）")
    p_friends.add_argument(
        "--cred-file",
        "-c",
        help="凭证 JSON 路径（默认 src/secrets/bug-report.json）",
    )
    add_http_args(p_friends)

    return parser


def main(argv: Optional[list[str]] = None) -> int:
    configure_stdio()
    args = build_parser().parse_args(argv)
    try:
        if getattr(args, "timeout", DEFAULT_TIMEOUT) <= 0:
            raise AppError("timeout 必须为正整数")

        if args.command == "list-friends":
            cred = load_cred_file(getattr(args, "cred_file", None))
            api_base = normalize_http_url(
                (args.api_base_url or "").strip()
                or cfg_pick(cred, "api_base_url", "BUG_API_BASE_URL")
                or DEFAULT_API_BASE_URL,
                field_name="api-base-url",
            )
            token = read_bearer_token(Path(args.jwt_path).expanduser())
            ensure_jwt_valid(token, Path(args.jwt_path).expanduser(), args.allow_expired_jwt)
            friends = load_friends(api_base, token, args.timeout)
            print_json({"friends": friends})
            return 0

        report = build_report_from_args(args)
        report.validate()
        message = format_bug_message(report)

        if args.command == "preview":
            payload = {
                "message": message,
                "report": {
                    "senderId": report.sender_id,
                    "senderName": report.sender_name,
                    "model": report.model,
                    "sessionKey": report.session_key,
                    "sessionUrl": report.session_url,
                    "ossUrls": report.oss_urls,
                },
            }
            if args.json:
                print_json(payload)
            else:
                sys.stdout.write(message)
            return 0

        cred = load_cred_file(args.cred_file, required=True)
        friend_id, friend_name = resolve_dev_friend(args, cred)
        api_base = normalize_http_url(
            (args.api_base_url or "").strip()
            or cfg_pick(cred, "api_base_url", "BUG_API_BASE_URL")
            or DEFAULT_API_BASE_URL,
            field_name="api-base-url",
        )
        jwt_path = Path(args.jwt_path).expanduser()
        token = read_bearer_token(jwt_path)
        ensure_jwt_valid(token, jwt_path, args.allow_expired_jwt)
        rid = get_or_create_dm(api_base, token, friend_id, args.timeout)
        sent = send_im_message(api_base, token, rid, message, args.timeout)
        result = {
            "success": True,
            "friend": {"friendId": friend_id, "displayName": friend_name},
            "rid": rid,
            "messageId": sent.get("_id"),
            "message": message,
        }
        if args.json:
            print_json(result)
        else:
            print("✅ Bug 工单已发送给研发虾友。")
            print(f"收件人: {friend_name} (friendId={friend_id})")
            if sent.get("_id"):
                print(f"messageId: {sent.get('_id')}")
        return 0
    except AppError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
