"""K1.3 端点测试：Kimi Code OAuth start/disconnect/status（内存替身，无 DB/无网络）。

覆盖：
- POST .../start → 202 + user_code/verification_uri + 创建 job；后台轮询 poll_token 成功
  → 存加密 oauth_enc + job done；**token 不进 job 结果/响应**；
- 后台轮询 authorization_pending → 继续轮询直到成功；
- POST .../disconnect → 清除凭据行；
- GET .../status → connected 真值（无 token 本体）。

后台 work 用 `run_job` 自建独立 session（请求 session 已关闭）——测试经依赖 override
注 fake http + fake store（共享实例，断后台落库）+ FakeSessionFactory；monkeypatch
`kimi_oauth.asyncio.sleep` 为 no-op（避免真等 interval 秒）。
"""

from __future__ import annotations

import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any

import pytest
from cryptography.fernet import Fernet
from fakes_providers import FakeCredentialStore
from fastapi.testclient import TestClient
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.kimi_oauth import decrypt_oauth_bundle
from ww_db import get_session


class _FakeResponse:
    def __init__(self, status_code: int, body: dict[str, Any]) -> None:
        self.status_code = status_code
        self._body = body

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


class _ScriptedHttp:
    """按调用序号返回预设响应（device auth → poll attempts）。aclose 无操作。"""

    def __init__(self, responses: list[_FakeResponse]) -> None:
        self._responses = responses
        self.calls: list[tuple[str, dict[str, str]]] = []

    async def post(self, url: str, *, data: dict[str, str]) -> _FakeResponse:
        idx = len(self.calls)
        self.calls.append((url, data))
        return self._responses[idx]

    async def aclose(self) -> None:
        return None


class _TrackingSessionFactory:
    """记录同一时刻在持会话数（open_count）+ 累计开启次数（total_opened）的 session 工厂替身。

    CR-H1 断言用：轮询期间 open_count 应恒 0（无会话被长时间占用），持久化时 total_opened≥1。
    """

    def __init__(self) -> None:
        self.open_count = 0
        self.total_opened = 0

    def __call__(self) -> Any:
        from fakes_projects import FakeSession

        @asynccontextmanager
        async def _cm() -> AsyncIterator[Any]:
            self.open_count += 1
            self.total_opened += 1
            try:
                yield FakeSession()
            finally:
                self.open_count -= 1

        return _cm()


class _ObservingHttp:
    """观测型 scripted http：每次 POST 到 TOKEN_URL（轮询）时记录当下在持会话数。aclose 无操作。"""

    def __init__(
        self,
        responses: list[_FakeResponse],
        *,
        factory: _TrackingSessionFactory,
        token_url: str,
    ) -> None:
        self._responses = responses
        self._factory = factory
        self._token_url = token_url
        self.calls: list[tuple[str, dict[str, str]]] = []
        self.open_during_poll: list[int] = []

    async def post(self, url: str, *, data: dict[str, str]) -> _FakeResponse:
        idx = len(self.calls)
        self.calls.append((url, data))
        if url == self._token_url:  # 轮询 POST（区别于 device_authorization POST）。
            self.open_during_poll.append(self._factory.open_count)
        return self._responses[idx]

    async def aclose(self) -> None:
        return None


def _make_client(
    *,
    store: FakeCredentialStore,
    http: Any,
    enc_key: str,
    monkeypatch: pytest.MonkeyPatch,
    session_factory: Any = None,
) -> TestClient:
    os.environ["CREDENTIAL_ENC_KEY"] = enc_key
    from ww_config import get_settings

    get_settings.cache_clear()

    from fakes_projects import FakeJobRepo, FakeSession, FakeSessionFactory
    from ww_api import routers
    from ww_api.main import create_app
    from ww_api.services import job_runner
    from ww_api.services.project_deps import (
        get_job_repo,
        get_session_factory,
    )
    from ww_api.services.provider_deps import get_credential_store

    # 后台轮询不真等 interval 秒。
    async def _no_sleep(_seconds: float) -> None:
        return None

    monkeypatch.setattr("ww_api.routers.kimi_oauth.asyncio.sleep", _no_sleep)
    # 后台 work 自建 SqlCredentialStore(session)——指到共享 fake store（断后台落库）。
    monkeypatch.setattr(routers.kimi_oauth, "SqlCredentialStore", lambda _session: store)
    # 后台 work 内调模块级 `_default_http_client()` 自建 http（非 dep）——monkeypatch 它指到
    # scripted fake，否则后台轮询会真联网（见 device-flow 注入纪律）。
    monkeypatch.setattr(routers.kimi_oauth, "_default_http_client", lambda: http)
    # `run_job` 默认 repo_factory 构 `SqlJobRepo(session)`——FakeSession 无 .execute，
    # 故 monkeypatch 模块级 `job_runner.SqlJobRepo` 指到共享 fake（见 memory/gotchas）。
    job_repo = FakeJobRepo()
    monkeypatch.setattr(job_runner, "SqlJobRepo", lambda _session: job_repo)

    sf = session_factory if session_factory is not None else FakeSessionFactory()

    app = create_app()
    app.dependency_overrides[get_credential_store] = lambda: store
    app.dependency_overrides[get_job_repo] = lambda: job_repo
    app.dependency_overrides[get_session] = lambda: FakeSession()
    app.dependency_overrides[get_session_factory] = lambda: sf
    # 请求级 http 现为生成器依赖 `_http_client`（CR-H2）——override 它注 fake（否则真生成器联网）。
    app.dependency_overrides[routers.kimi_oauth._http_client] = lambda: http
    return TestClient(app)


def _device_auth_response() -> _FakeResponse:
    return _FakeResponse(
        200,
        {
            "device_code": "dev-1",
            "user_code": "WXYZ-9999",
            "verification_uri": "https://kimi.com/device",
            "verification_uri_complete": "https://kimi.com/device?code=WXYZ-9999",
            "expires_in": 600,
            "interval": 5,
        },
    )


def _token_response() -> _FakeResponse:
    return _FakeResponse(
        200, {"access_token": "secret-acc", "refresh_token": "secret-ref", "expires_in": 900}
    )


def test_start_returns_202_and_polls_to_success(monkeypatch: pytest.MonkeyPatch) -> None:
    enc_key = Fernet.generate_key().decode()
    store = FakeCredentialStore()
    # device auth → poll #1 success（TestClient 同步跑完 background task）。
    http = _ScriptedHttp([_device_auth_response(), _token_response()])
    client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)

    resp = client.post("/settings/providers/kimi-code/oauth/start")

    assert resp.status_code == 202
    body = resp.json()
    assert body["user_code"] == "WXYZ-9999"
    assert body["verification_uri"] == "https://kimi.com/device"
    assert body["interval"] == 5
    assert "job_id" in body
    # 响应绝不含 token。
    assert "secret-acc" not in resp.text
    assert "secret-ref" not in resp.text

    # 后台轮询成功 → 加密 oauth 凭据落库（共享 store）。
    cred = store.oauth.get((STUB_OWNER_ID, "kimi-code"))
    assert cred is not None
    token = decrypt_oauth_bundle(cred, key=enc_key)
    assert token.access_token == "secret-acc"
    assert token.refresh_token == "secret-ref"


def test_start_polls_through_authorization_pending(monkeypatch: pytest.MonkeyPatch) -> None:
    enc_key = Fernet.generate_key().decode()
    store = FakeCredentialStore()
    http = _ScriptedHttp(
        [
            _device_auth_response(),
            _FakeResponse(400, {"error": "authorization_pending"}),
            _token_response(),
        ]
    )
    client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)

    resp = client.post("/settings/providers/kimi-code/oauth/start")

    assert resp.status_code == 202
    # pending 后继续轮询 → 第二次成功落库。
    assert (STUB_OWNER_ID, "kimi-code") in store.oauth


def test_poll_loop_does_not_hold_db_session(monkeypatch: pytest.MonkeyPatch) -> None:
    """CR-H1：设备授权轮询期间**不得**占用 DB 会话（否则连接池被长时间空占→饥饿）。

    轮询循环只做 HTTP（无 DB）；仅最终持久化才开一个**短**会话。断言：轮询 POST 期间
    session_factory 未开会话（open_count==0），而持久化确开过短会话；凭据已落库、响应不泄 token。
    """
    from ww_api.services.kimi_oauth import TOKEN_URL

    enc_key = Fernet.generate_key().decode()
    store = FakeCredentialStore()
    factory = _TrackingSessionFactory()
    # device auth → poll #1 pending → poll #2 success（≥2 次轮询 POST）。
    http = _ObservingHttp(
        [
            _device_auth_response(),
            _FakeResponse(400, {"error": "authorization_pending"}),
            _token_response(),
        ],
        factory=factory,
        token_url=TOKEN_URL,
    )
    client = _make_client(
        store=store,
        http=http,
        enc_key=enc_key,
        monkeypatch=monkeypatch,
        session_factory=factory,
    )

    resp = client.post("/settings/providers/kimi-code/oauth/start")

    assert resp.status_code == 202
    # 核心断言：轮询 POST 期间无 session_factory 会话在持。
    assert http.open_during_poll  # 确有轮询 POST 发生
    assert all(c == 0 for c in http.open_during_poll)
    # 持久化时确开过一个短会话。
    assert factory.total_opened >= 1
    # 凭据已落库；响应绝不含 token。
    assert (STUB_OWNER_ID, "kimi-code") in store.oauth
    assert "secret-acc" not in resp.text
    assert "secret-ref" not in resp.text


async def test_http_client_dependency_is_generator_and_closes_client(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """CR-H2：请求级 `_http_client` 是**生成器依赖**，请求结束时 aclose 客户端（不泄漏连接）。

    驱动生成器一轮（enter→yield→exit），断言：是 async 生成器；yield 时未关闭；耗尽时恰好
    关闭一次。RED：修复前 `_http_client` 不存在（AttributeError）。
    """
    import inspect

    from ww_api.routers import kimi_oauth

    closes = {"count": 0}

    class _SpyClient:
        def __init__(self, *args: Any, **kwargs: Any) -> None:
            pass

        async def __aenter__(self) -> _SpyClient:
            return self

        async def __aexit__(self, *exc: Any) -> None:
            closes["count"] += 1

        async def aclose(self) -> None:
            closes["count"] += 1

    monkeypatch.setattr("ww_api.routers.kimi_oauth.httpx.AsyncClient", _SpyClient)

    # 是 async 生成器依赖（有 teardown），非普通函数（普通函数依赖无 teardown → 泄漏）。
    assert inspect.isasyncgenfunction(kimi_oauth._http_client)

    gen = kimi_oauth._http_client()
    client = await gen.__anext__()
    assert client is not None
    assert closes["count"] == 0  # yield 时尚未关闭
    with pytest.raises(StopAsyncIteration):
        await gen.__anext__()
    assert closes["count"] == 1  # 请求结束恰好关闭一次


def test_disconnect_clears_credential(monkeypatch: pytest.MonkeyPatch) -> None:
    enc_key = Fernet.generate_key().decode()
    store = FakeCredentialStore()
    store.oauth[(STUB_OWNER_ID, "kimi-code")] = b"blob"
    http = _ScriptedHttp([])
    client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)

    resp = client.post("/settings/providers/kimi-code/oauth/disconnect")

    assert resp.status_code == 200
    assert resp.json()["disconnected"] is True
    assert (STUB_OWNER_ID, "kimi-code") not in store.oauth


def test_status_reflects_connection(monkeypatch: pytest.MonkeyPatch) -> None:
    from datetime import UTC, datetime, timedelta

    from ww_api.services.kimi_oauth import TokenSet, encrypt_oauth_bundle

    enc_key = Fernet.generate_key().decode()
    store = FakeCredentialStore()
    expires = datetime.now(UTC) + timedelta(seconds=900)
    token = TokenSet(access_token="a", refresh_token="r", expires_at=expires)
    store.oauth[(STUB_OWNER_ID, "kimi-code")] = encrypt_oauth_bundle(token, key=enc_key)
    http = _ScriptedHttp([])
    client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)

    resp = client.get("/settings/providers/kimi-code/oauth/status")

    assert resp.status_code == 200
    body = resp.json()
    assert body["connected"] is True
    assert body["expires_at"] is not None
    # 状态绝不回 token 本体。
    assert "access_token" not in body
    assert "refresh_token" not in body


def test_status_not_connected_when_no_credential(monkeypatch: pytest.MonkeyPatch) -> None:
    enc_key = Fernet.generate_key().decode()
    store = FakeCredentialStore()
    http = _ScriptedHttp([])
    client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)

    resp = client.get("/settings/providers/kimi-code/oauth/status")

    assert resp.status_code == 200
    assert resp.json()["connected"] is False
