#!/usr/bin/env python3
"""Send a DM by friend user_id: resolve DM room via /friend/dm, then send. JWT: /home/node/.openclaw/jwt.json."""

import argparse
import base64
import json
import mimetypes
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from typing import Dict, List, Optional, Tuple


DEFAULT_TIMEOUT = 30
DEFAULT_BASE_URL = "https://yagent.sophnet.com/api"
JWT_PATH = "/home/node/.openclaw/jwt.json"


class ApiError(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 read_bearer_token(jwt_path: str) -> str:
    try:
        with open(jwt_path, "r", encoding="utf-8") as fp:
            data = json.load(fp)
    except OSError as exc:
        raise ApiError(f"Cannot read JWT file {jwt_path}: {exc}") from exc
    except json.JSONDecodeError as exc:
        raise ApiError(f"Invalid JSON in JWT file {jwt_path}: {exc}") from exc
    if not isinstance(data, dict):
        raise ApiError("JWT file root must be a JSON object.")
    token = data.get("web_jwt")
    if not token or not isinstance(token, str):
        raise ApiError("JWT file has no valid `web_jwt` field.")
    token = token.strip()
    if token.lower().startswith("bearer "):
        token = token[7:].strip()
    if not token:
        raise ApiError("`web_jwt` is empty.")
    return token


def jwt_exp_unix(token: str) -> Optional[int]:
    """Return JWT `exp` (seconds since epoch) if present; None if unreadable."""
    try:
        parts = token.split(".")
        if len(parts) != 3:
            return None
        payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
        raw = base64.urlsafe_b64decode(payload_b64.encode("ascii"))
        payload = json.loads(raw)
        exp = payload.get("exp")
        if isinstance(exp, (int, float)):
            return int(exp)
    except (ValueError, UnicodeDecodeError, json.JSONDecodeError, IndexError):
        pass
    return None


def ensure_jwt_valid_for_use(token: str, jwt_path: str, clock_skew_sec: int = 60) -> None:
    """Fail fast if JWT is already expired (common cause of HTTP 401 / 请先登录)."""
    exp = jwt_exp_unix(token)
    if exp is None:
        return
    now = int(time.time())
    if now >= exp + clock_skew_sec:
        raise ApiError(
            f"JWT in {jwt_path} has expired (exp={exp}, now={now}). "
            "Log in again in the app or refresh the token, then update the file."
        )


def normalize_base_url(url: str) -> str:
    url = url.strip()
    if not url:
        raise ApiError("Base URL cannot be empty.")
    return url.rstrip("/")


def resolve_files(raw: Optional[List[str]]) -> List[str]:
    if not raw:
        return []
    cwd = os.getcwd()
    out: List[str] = []
    for item in raw:
        if not isinstance(item, str) or not item.strip():
            raise ApiError("Each --file path must be non-empty.")
        p = item if os.path.isabs(item) else os.path.join(cwd, item)
        out.append(os.path.realpath(p))
    return out


def make_headers(token: str, content_type: Optional[str] = None) -> Dict[str, str]:
    h = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
    if content_type:
        h["Content-Type"] = content_type
    return h


def read_http_body(exc: urllib.error.HTTPError) -> str:
    try:
        return exc.read().decode("utf-8", errors="replace")
    except Exception:
        return str(exc)


def http_api_error_hint(http_code: int, body: str) -> str:
    """Append short hints for known API error bodies (JWT, RBAC)."""
    try:
        err = json.loads(body)
    except json.JSONDecodeError:
        if http_code == 401:
            return f" (check web_jwt in {JWT_PATH}.)"
        return ""
    api_st = err.get("status")
    msg = str(err.get("message", ""))
    if http_code == 401 and (api_st == 10025 or "登录" in msg):
        return f" (renew web_jwt in {JWT_PATH}.)"
    if api_st == 20002 or "权限不足" in msg:
        return (
            " (insufficient permission: this account cannot send in this room/channel; "
            "ask an org admin to grant access or send from an allowed account.)"
        )
    return ""


def request_json(
    method: str,
    url: str,
    token: str,
    timeout: int,
    payload: Optional[dict] = None,
) -> dict:
    body = None
    headers = make_headers(token)
    if payload is not None:
        body = json.dumps(payload).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")
    except urllib.error.HTTPError as exc:
        body = read_http_body(exc)
        raise ApiError(f"HTTP {exc.code} {url}: {body}{http_api_error_hint(exc.code, body)}")
    except urllib.error.URLError as exc:
        raise ApiError(f"Request failed for {url}: {exc}")
    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        raise ApiError(f"Invalid JSON from {url}: {exc}\n{raw}")
    if data.get("status") != 0:
        raise ApiError(
            f"API error for {url}: status={data.get('status')} message={data.get('message')!r}"
        )
    return data


def multipart_file(file_name: str, file_bytes: bytes, content_type: str) -> Tuple[bytes, str]:
    boundary = f"----oc{uuid.uuid4().hex}"
    b = boundary.encode("ascii")
    parts: List[bytes] = [
        b"--" + b + b"\r\n",
        (
            f'Content-Disposition: form-data; name="file"; filename="{file_name}"\r\n'
        ).encode("utf-8"),
        f"Content-Type: {content_type}\r\n\r\n".encode("utf-8"),
        file_bytes,
        b"\r\n",
        b"--" + b + b"--\r\n",
    ]
    return b"".join(parts), f"multipart/form-data; boundary={boundary}"


def upload_asset(base_url: str, token: str, rid: str, file_path: str, timeout: int) -> dict:
    if not os.path.isfile(file_path):
        raise ApiError(f"Attachment not found: {file_path}")
    base_name = os.path.basename(file_path)
    mime = mimetypes.guess_type(base_name)[0] or "application/octet-stream"
    with open(file_path, "rb") as fp:
        blob = fp.read()
    body, ctype = multipart_file(base_name, blob, mime)
    url = f"{base_url}/sys/openclaw/im/chat.uploadAsset?{urllib.parse.urlencode({'rid': rid})}"
    req = urllib.request.Request(
        url=url,
        data=body,
        headers=make_headers(token, content_type=ctype),
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            raw = resp.read().decode("utf-8")
    except urllib.error.HTTPError as exc:
        b = read_http_body(exc)
        raise ApiError(
            f"HTTP {exc.code} while uploading {file_path}: {b}{http_api_error_hint(exc.code, b)}"
        )
    except urllib.error.URLError as exc:
        raise ApiError(f"Upload failed for {file_path}: {exc}")
    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        raise ApiError(f"Invalid upload response for {file_path}: {exc}\n{raw}")
    if data.get("status") != 0:
        raise ApiError(
            f"Upload API error: status={data.get('status')} message={data.get('message')!r}"
        )
    asset = (data.get("result") or {}).get("asset") or {}
    url_path = asset.get("urlPath")
    if not url_path:
        raise ApiError(f"Upload OK but missing result.asset.urlPath for {file_path}.")
    return {
        "name": base_name,
        "type": mime,
        "size": os.path.getsize(file_path),
        "url": url_path,
    }


def get_or_create_dm(base_url: str, token: str, friend_id: int, timeout: int) -> str:
    url = f"{base_url}/sys/openclaw/friend/dm"
    data = request_json("POST", url, token, timeout, payload={"friendId": friend_id})
    result = data.get("result") or {}
    channel = result.get("channel") or {}
    rid = channel.get("_id")
    if not rid:
        raise ApiError("DM creation succeeded, but `result.channel._id` is missing.")
    return str(rid)


def send_message(
    base_url: str,
    token: str,
    rid: str,
    text: str,
    attachments: List[dict],
    timeout: int,
) -> dict:
    url = f"{base_url}/sys/openclaw/im/chat.sendMessage"
    payload: Dict[str, object] = {"rid": rid}
    if text:
        payload["msg"] = text
    if attachments:
        payload["attachments"] = attachments
    if "msg" not in payload and "attachments" not in payload:
        raise ApiError("Message text and attachments cannot both be empty.")
    data = request_json("POST", url, token, timeout, payload=payload)
    return (data.get("result") or {}).get("message") or {}


def main() -> int:
    p = argparse.ArgumentParser(
        description="Send DM to a user by user_id (resolves DM via /friend/dm). JWT: %s." % JWT_PATH
    )
    p.add_argument(
        "--user-id",
        type=int,
        required=True,
        metavar="UID",
        help="Target friend userId; script calls /sys/openclaw/friend/dm to get room id.",
    )
    p.add_argument("-m", "--message", default="", help="Text (use --file if no text)")
    p.add_argument("--file", action="append", dest="files", metavar="PATH", help="Attachment; repeat")
    p.add_argument("--base-url", default=DEFAULT_BASE_URL, help="API base URL")
    p.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="HTTP timeout (seconds)")
    p.add_argument(
        "--allow-expired-jwt",
        action="store_true",
        help="Skip local JWT exp check (server will still reject invalid tokens).",
    )
    args = p.parse_args()
    configure_stdio()

    try:
        if args.user_id <= 0:
            raise ApiError("`--user-id` must be a positive integer.")
        token = read_bearer_token(JWT_PATH)
        if args.allow_expired_jwt:
            exp = jwt_exp_unix(token)
            if exp is not None and int(time.time()) >= exp + 60:
                print(
                    "WARNING: JWT is expired; request will likely fail with 401. "
                    "Update web_jwt in %s after re-login." % JWT_PATH,
                    file=sys.stderr,
                )
        else:
            ensure_jwt_valid_for_use(token, JWT_PATH)
        base_url = normalize_base_url(args.base_url)
        text = args.message or ""
        files = resolve_files(args.files)
        if not text.strip() and not files:
            raise ApiError("Provide -m/--message and/or --file.")
        if args.timeout <= 0:
            raise ApiError("`--timeout` must be a positive integer.")

        rid = get_or_create_dm(base_url, token, args.user_id, args.timeout)
        attachments = [upload_asset(base_url, token, rid, fp, args.timeout) for fp in files]
        msg = send_message(base_url, token, rid, text, attachments, args.timeout)
        print(
            json.dumps(
                {
                    "targetUserId": args.user_id,
                    "rid": rid,
                    "baseUrl": base_url,
                    "attachmentCount": len(attachments),
                    "messageId": msg.get("_id"),
                    "message": msg,
                },
                ensure_ascii=False,
                indent=2,
            )
        )
        return 0
    except ApiError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1


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