from __future__ import annotations

import json
import queue
import threading
import time
from urllib.parse import parse_qs, urlencode, urlsplit

import httpx
import pytest

import nanobot.providers.xai_oauth as xai_oauth
from nanobot.providers.xai_oauth import (
    XAI_CLIENT_ID,
    XAI_OAUTH_SCOPES,
    XAIOAuthError,
    XAIToken,
    _build_authorize_url,
    _CallbackResult,
    _Discovery,
    _generate_pkce,
    _make_callback_server,
    _validate_xai_endpoint,
    complete_xai_oauth_login,
    get_xai_oauth_login_status,
    get_xai_oauth_storage_path,
    get_xai_oauth_token,
    login_xai_oauth,
    logout_xai_oauth,
    start_xai_oauth_login,
)


def _use_temp_credentials(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
    monkeypatch.setattr(xai_oauth, "get_data_dir", lambda: tmp_path)


def test_authorize_url_uses_pkce_and_frozen_xai_scope_contract() -> None:
    verifier, challenge = _generate_pkce()

    url = _build_authorize_url(
        "https://auth.x.ai/oauth2/authorize",
        redirect_uri="http://127.0.0.1:54321/callback",
        challenge=challenge,
        state="state-value",
        nonce="nonce-value",
    )

    params = parse_qs(urlsplit(url).query)
    assert len(verifier) >= 43
    assert params == {
        "response_type": ["code"],
        "client_id": [XAI_CLIENT_ID],
        "redirect_uri": ["http://127.0.0.1:54321/callback"],
        "scope": [" ".join(XAI_OAUTH_SCOPES)],
        "code_challenge": [challenge],
        "code_challenge_method": ["S256"],
        "state": ["state-value"],
        "nonce": ["nonce-value"],
        "referrer": ["nanobot"],
    }


@pytest.mark.parametrize(
    "endpoint",
    [
        "http://auth.x.ai/oauth2/token",
        "https://evil.example/oauth2/token",
        "https://auth.x.ai:444/oauth2/token",
        "https://user@auth.x.ai/oauth2/token",
        "https://auth.x.ai:invalid/oauth2/token",
    ],
)
def test_discovery_rejects_unsafe_endpoints(endpoint: str) -> None:
    with pytest.raises(XAIOAuthError, match="unsafe token endpoint"):
        _validate_xai_endpoint(endpoint, "token")


def test_callback_server_accepts_only_matching_state_and_allows_accounts_origin() -> None:
    results: queue.Queue[_CallbackResult] = queue.Queue(maxsize=1)
    server = _make_callback_server("expected-state", results)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        response = httpx.get(
            f"http://127.0.0.1:{server.server_port}/callback",
            params={"code": "one-time-code", "state": "expected-state"},
            headers={"Origin": "https://accounts.x.ai"},
        )
        result = results.get(timeout=1)
    finally:
        server.shutdown()
        server.server_close()
        thread.join(timeout=2)

    assert response.status_code == 200
    assert response.headers["access-control-allow-origin"] == "https://accounts.x.ai"
    assert response.headers["access-control-allow-private-network"] == "true"
    assert result == _CallbackResult(code="one-time-code", state="expected-state")
    assert "one-time-code" not in response.text


def test_login_uses_random_loopback_callback_and_saves_separate_credentials(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path,
) -> None:
    _use_temp_credentials(monkeypatch, tmp_path)
    discovery = _Discovery(
        authorization_endpoint="https://auth.x.ai/oauth2/authorize",
        token_endpoint="https://auth.x.ai/oauth2/token",
        userinfo_endpoint="https://auth.x.ai/oauth2/userinfo",
    )
    monkeypatch.setattr(xai_oauth, "_discover", lambda _proxy: discovery)
    exchanged: dict[str, str] = {}

    def fake_exchange(endpoint: str, **kwargs):
        exchanged.update(endpoint=endpoint, **kwargs)
        return {
            "access_token": "access-secret",
            "refresh_token": "refresh-secret",
            "expires_in": 3600,
        }

    monkeypatch.setattr(xai_oauth, "_exchange_code", fake_exchange)
    monkeypatch.setattr(
        xai_oauth,
        "_fetch_account",
        lambda endpoint, access, proxy: "user@example.com",
    )
    opened_urls: list[str] = []

    def complete_in_browser(authorize_url: str) -> bool:
        opened_urls.append(authorize_url)
        params = parse_qs(urlsplit(authorize_url).query)
        callback_url = params["redirect_uri"][0]
        callback_query = urlencode({"code": "auth-code", "state": params["state"][0]})
        response = httpx.get(f"{callback_url}?{callback_query}")
        assert response.status_code == 200
        return True

    token = login_xai_oauth(
        print_fn=lambda _message: None,
        browser_opener=complete_in_browser,
        callback_timeout_s=1,
    )

    assert token.account_id == "user@example.com"
    assert exchanged["code"] == "auth-code"
    assert exchanged["redirect_uri"].startswith("http://127.0.0.1:")
    assert exchanged["verifier"]
    assert opened_urls
    assert get_xai_oauth_storage_path() == tmp_path / "auth" / "xai.json"
    saved = json.loads(get_xai_oauth_storage_path().read_text(encoding="utf-8"))
    assert saved["access"] == "access-secret"
    assert saved["refresh"] == "refresh-secret"
    assert get_xai_oauth_login_status() == token


def test_pending_login_accepts_authorization_code_from_remote_browser(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path,
) -> None:
    _use_temp_credentials(monkeypatch, tmp_path)
    discovery = _Discovery(
        authorization_endpoint="https://auth.x.ai/oauth2/authorize",
        token_endpoint="https://auth.x.ai/oauth2/token",
        userinfo_endpoint=None,
    )
    monkeypatch.setattr(xai_oauth, "_discover", lambda _proxy: discovery)
    exchanged: dict[str, str] = {}

    def fake_exchange(endpoint: str, **kwargs):
        exchanged.update(endpoint=endpoint, **kwargs)
        return {"access_token": "remote-access", "expires_in": 3600}

    monkeypatch.setattr(xai_oauth, "_exchange_code", fake_exchange)

    flow = start_xai_oauth_login(timeout_s=5)
    try:
        params = parse_qs(urlsplit(flow.authorization_url).query)
        callback_url = params["redirect_uri"][0]

        token = complete_xai_oauth_login(flow, "remote-code")
    finally:
        flow.cancel()

    assert token is not None
    assert token.access == "remote-access"
    assert exchanged["code"] == "remote-code"
    assert exchanged["redirect_uri"] == callback_url
    assert get_xai_oauth_login_status() == token


def test_expired_token_refreshes_once_and_persists_rotated_refresh_token(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path,
) -> None:
    _use_temp_credentials(monkeypatch, tmp_path)
    expired = XAIToken(
        access="old-access",
        refresh="old-refresh",
        expires=int(time.time() * 1000) - 1,
        account_id="account",
    )
    xai_oauth._write_token(expired)
    calls: list[tuple[XAIToken, str | None]] = []

    def fake_refresh(token: XAIToken, proxy: str | None) -> XAIToken:
        calls.append((token, proxy))
        return XAIToken(
            access="new-access",
            refresh="new-refresh",
            expires=int(time.time() * 1000) + 3_600_000,
            account_id=token.account_id,
        )

    monkeypatch.setattr(xai_oauth, "_refresh_token", fake_refresh)

    refreshed = get_xai_oauth_token(proxy="http://127.0.0.1:7890")

    assert refreshed.access == "new-access"
    assert refreshed.refresh == "new-refresh"
    assert calls == [(expired, "http://127.0.0.1:7890")]
    assert get_xai_oauth_login_status() == refreshed


def test_missing_credentials_returns_actionable_login_command(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path,
) -> None:
    _use_temp_credentials(monkeypatch, tmp_path)

    with pytest.raises(XAIOAuthError, match="nanobot provider login xai-grok"):
        get_xai_oauth_token()


def test_logout_waits_for_inflight_refresh_and_removes_rotated_token(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path,
) -> None:
    _use_temp_credentials(monkeypatch, tmp_path)
    expired = XAIToken(
        access="old-access",
        refresh="old-refresh",
        expires=int(time.time() * 1000) - 1,
    )
    xai_oauth._write_token(expired)
    refresh_started = threading.Event()
    finish_refresh = threading.Event()
    logout_started = threading.Event()
    logout_finished = threading.Event()
    refresh_errors: list[Exception] = []
    logout_results: list[bool] = []

    def fake_refresh(token: XAIToken, _proxy: str | None) -> XAIToken:
        refresh_started.set()
        assert finish_refresh.wait(timeout=2)
        return XAIToken(
            access="new-access",
            refresh=token.refresh,
            expires=int(time.time() * 1000) + 3_600_000,
        )

    def refresh() -> None:
        try:
            get_xai_oauth_token()
        except Exception as exc:  # pragma: no cover - asserted below
            refresh_errors.append(exc)

    def logout() -> None:
        logout_started.set()
        logout_results.append(logout_xai_oauth())
        logout_finished.set()

    monkeypatch.setattr(xai_oauth, "_refresh_token", fake_refresh)
    refresh_thread = threading.Thread(target=refresh)
    refresh_thread.start()
    assert refresh_started.wait(timeout=2)

    logout_thread = threading.Thread(target=logout)
    logout_thread.start()
    assert logout_started.wait(timeout=2)
    assert not logout_finished.wait(timeout=0.05)
    finish_refresh.set()
    refresh_thread.join(timeout=2)
    logout_thread.join(timeout=2)

    assert not refresh_thread.is_alive()
    assert not logout_thread.is_alive()
    assert refresh_errors == []
    assert logout_results == [True]
    assert not get_xai_oauth_storage_path().exists()


def test_refresh_does_not_restore_credentials_removed_after_its_initial_read(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path,
) -> None:
    _use_temp_credentials(monkeypatch, tmp_path)
    expired = XAIToken(
        access="old-access",
        refresh="old-refresh",
        expires=int(time.time() * 1000) - 1,
    )
    xai_oauth._write_token(expired)
    real_load_token = xai_oauth._load_token
    initial_read_finished = threading.Event()
    continue_refresh = threading.Event()
    refresh_errors: list[Exception] = []
    first_load = True

    def gated_load_token() -> XAIToken | None:
        nonlocal first_load
        token = real_load_token()
        if first_load:
            first_load = False
            initial_read_finished.set()
            assert continue_refresh.wait(timeout=2)
        return token

    def refresh() -> None:
        try:
            get_xai_oauth_token()
        except Exception as exc:
            refresh_errors.append(exc)

    monkeypatch.setattr(xai_oauth, "_load_token", gated_load_token)
    refresh_thread = threading.Thread(target=refresh)
    refresh_thread.start()
    assert initial_read_finished.wait(timeout=2)

    assert logout_xai_oauth() is True
    continue_refresh.set()
    refresh_thread.join(timeout=2)

    assert not refresh_thread.is_alive()
    assert len(refresh_errors) == 1
    assert isinstance(refresh_errors[0], XAIOAuthError)
    assert "not signed in" in str(refresh_errors[0])
    assert not get_xai_oauth_storage_path().exists()
