"""Kimi Code OAuth device-flow 客户端（K1.3 / PROGRESS K1）。

Kimi 订阅 plan 走 OAuth 2.0 **device authorization flow**（RFC 8628）：

1. `start_device_authorization` → `POST .../device_authorization`，拿 `device_code` +
   `user_code` + `verification_uri`（用户在浏览器授权）+ 轮询 `interval`/过期 `expires_in`。
2. `poll_token`（一次尝试，调用方按 `interval` 循环）→ `POST .../token`
   （grant=device_code）；`authorization_pending` → 继续轮询、`slow_down` → 增大间隔、
   `expired_token`/`access_denied` → 停止失败；成功 → access/refresh token。
3. `refresh` → 同 token 端点（grant=refresh_token），换新的 access/refresh token。

**httpx 注入**：所有 HTTP 经注入的 `AsyncHttpClient` Protocol（= `httpx.AsyncClient`
的 `.post` 子集）——测试注 fake，**绝不联网**。

**token 不落明文**：`TokenSet` 序列化为 JSON 串经 Fernet 加密入 `provider_credentials.oauth_enc`
（`encrypt_oauth_bundle`/`decrypt_oauth_bundle`），明文 token 绝不进日志/响应/job 结果。

研究确认（对照 `github.com/ooojustin/opencode-kimi` `constants.ts` + `picassio/pi-kimi-coder`）：
- client_id `17e5f671-d194-4dfb-9706-5516cb48c098`（env `KIMI_CLIENT_ID` 可覆盖）。
- device authorization **带 `scope=kimi-code`**（coding-agent OAuth scope）：opencode-kimi
  `constants.ts` 发送之；kimi-cli v1.41.0 已不发但服务端仍接受。实测省略 scope 拿到的 token
  缺 coding entitlement，调 `api.kimi.com/coding/v1` 回 `401 Invalid Authentication`，故重新带上。
- scope 只放在 device_authorization 请求上；token 交换/刷新不带 scope（OAuth device flow 惯例）。
"""

from __future__ import annotations

import json
import os
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any, Protocol

from ww_shared import AppError, ErrorCode

from ww_api.security.credentials import decrypt_api_key, encrypt_api_key

#: Kimi OAuth 端点（device authorization + token）。
KIMI_AUTH_BASE_URL = "https://auth.kimi.com/api/oauth"
DEVICE_AUTHORIZATION_URL = f"{KIMI_AUTH_BASE_URL}/device_authorization"
TOKEN_URL = f"{KIMI_AUTH_BASE_URL}/token"

#: 默认 client_id（kimi-cli 公开常量；env `KIMI_CLIENT_ID` 可覆盖）。
DEFAULT_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"
ENV_CLIENT_ID = "KIMI_CLIENT_ID"

GRANT_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code"
GRANT_REFRESH_TOKEN = "refresh_token"

#: coding-agent OAuth scope（device authorization 专用；缺它 token 无 coding entitlement）。
KIMI_CODE_SCOPE = "kimi-code"

#: device flow 默认轮询间隔（秒）——服务端未给 `interval` 时的兜底。
DEFAULT_POLL_INTERVAL = 5

#: token 刷新触发缓冲（秒）：剩余寿命低于 max(300, 0.5*expires_in) 即刷新。
MIN_REFRESH_BUFFER_SECONDS = 300


def client_id() -> str:
    """当前 OAuth client_id（env `KIMI_CLIENT_ID` 优先，否则公开默认值）。"""
    return os.environ.get(ENV_CLIENT_ID) or DEFAULT_CLIENT_ID


class AsyncHttpClient(Protocol):
    """`run_job`/服务对 HTTP 客户端的**最小**依赖（= `httpx.AsyncClient.post` 子集）。

    便于测试注 fake（绝不联网）。运行时传 `httpx.AsyncClient`。
    """

    async def post(self, url: str, *, data: dict[str, str]) -> HttpResponse: ...


class HttpResponse(Protocol):
    """HTTP 响应的最小读接口（`httpx.Response` 满足之）。"""

    status_code: int

    def json(self) -> Any: ...


@dataclass(frozen=True)
class DeviceAuth:
    """device authorization 响应（用户面：展示 user_code + 打开 verification_uri）。"""

    device_code: str
    user_code: str
    verification_uri: str
    verification_uri_complete: str | None
    expires_in: int
    interval: int


@dataclass(frozen=True)
class TokenSet:
    """OAuth token 三元组（access 短期 / refresh 长期 / 服务端驱动过期时刻 UTC）。"""

    access_token: str
    refresh_token: str
    expires_at: datetime


class AuthorizationPending(Exception):
    """device flow 轮询：用户尚未授权（继续轮询）。"""


class SlowDown(Exception):
    """device flow 轮询：轮询过快（增大 interval 后继续）。"""


def _now() -> datetime:
    return datetime.now(UTC)


def _expires_at(expires_in: int) -> datetime:
    return _now() + timedelta(seconds=max(0, expires_in))


def needs_refresh(token: TokenSet, *, now: datetime | None = None) -> bool:
    """判定 access token 是否临近过期（剩余寿命 < `MIN_REFRESH_BUFFER_SECONDS`）。

    建网关时按需刷新（§token 刷新启发式）；过期时刻已是服务端驱动的绝对时刻，故只需
    与缓冲比较（无需原始 expires_in：缓冲固定 300s，对 ~15min access 足够）。
    """
    current = now or _now()
    remaining = (token.expires_at - current).total_seconds()
    return remaining < MIN_REFRESH_BUFFER_SECONDS


async def start_device_authorization(http: AsyncHttpClient) -> DeviceAuth:
    """发起 device authorization（带 `scope=kimi-code` 以获取 coding entitlement）。"""
    resp = await http.post(
        DEVICE_AUTHORIZATION_URL,
        data={"client_id": client_id(), "scope": KIMI_CODE_SCOPE},
    )
    if resp.status_code >= 400:
        raise AppError(
            ErrorCode.LLM_UNAVAILABLE,
            "Kimi 设备授权请求失败",
            {"status": resp.status_code},
        )
    body = resp.json()
    return DeviceAuth(
        device_code=str(body["device_code"]),
        user_code=str(body["user_code"]),
        verification_uri=str(body["verification_uri"]),
        verification_uri_complete=(
            str(body["verification_uri_complete"])
            if body.get("verification_uri_complete")
            else None
        ),
        expires_in=int(body.get("expires_in", 0)),
        interval=int(body.get("interval", DEFAULT_POLL_INTERVAL)),
    )


def _token_set_from_body(body: dict[str, Any]) -> TokenSet:
    return TokenSet(
        access_token=str(body["access_token"]),
        refresh_token=str(body["refresh_token"]),
        expires_at=_expires_at(int(body.get("expires_in", 0))),
    )


async def poll_token(http: AsyncHttpClient, device_code: str) -> TokenSet:
    """轮询一次 token 端点（调用方按 interval 循环）。

    `authorization_pending` → 抛 `AuthorizationPending`（继续轮询）；
    `slow_down` → 抛 `SlowDown`（增大 interval）；
    `expired_token`/`access_denied`/其它 → 抛 `AppError`（停止失败）；
    成功 → `TokenSet`。
    """
    resp = await http.post(
        TOKEN_URL,
        data={
            "grant_type": GRANT_DEVICE_CODE,
            "device_code": device_code,
            "client_id": client_id(),
        },
    )
    body = resp.json()
    if resp.status_code >= 400 or body.get("error"):
        error = str(body.get("error", "unknown_error"))
        if error == "authorization_pending":
            raise AuthorizationPending
        if error == "slow_down":
            raise SlowDown
        # expired_token / access_denied / 其它 → 终止失败。
        raise AppError(
            ErrorCode.LLM_UNAVAILABLE,
            f"Kimi 设备授权失败：{error}",
            {"error": error},
        )
    return _token_set_from_body(body)


async def refresh(http: AsyncHttpClient, refresh_token: str) -> TokenSet:
    """用 refresh_token 换新 token 组（access 临近过期时建网关触发）。"""
    resp = await http.post(
        TOKEN_URL,
        data={
            "grant_type": GRANT_REFRESH_TOKEN,
            "refresh_token": refresh_token,
            "client_id": client_id(),
        },
    )
    body = resp.json()
    if resp.status_code >= 400 or body.get("error"):
        error = str(body.get("error", "unknown_error"))
        raise AppError(
            ErrorCode.LLM_UNAVAILABLE,
            f"Kimi token 刷新失败：{error}",
            {"error": error},
        )
    return _token_set_from_body(body)


def encrypt_oauth_bundle(token: TokenSet, *, key: str) -> bytes:
    """把 `TokenSet` 序列化为 JSON 串并 Fernet 加密为 `oauth_enc` 密文。

    JSON 形 `{access_token, refresh_token, expires_at(ISO8601)}`——明文 token 绝不出此函数。
    """
    bundle = json.dumps(
        {
            "access_token": token.access_token,
            "refresh_token": token.refresh_token,
            "expires_at": token.expires_at.isoformat(),
        }
    )
    return encrypt_api_key(bundle, key=key)


def decrypt_oauth_bundle(blob: bytes, *, key: str) -> TokenSet:
    """解密 `oauth_enc` 密文回 `TokenSet`（reuse Fernet helper，工作在 str 上）。"""
    bundle = json.loads(decrypt_api_key(blob, key=key))
    return TokenSet(
        access_token=str(bundle["access_token"]),
        refresh_token=str(bundle["refresh_token"]),
        expires_at=datetime.fromisoformat(str(bundle["expires_at"])),
    )
