"""
Tests for runtime.auth — OAuth 2.0 management system.

Covers: PKCE generation, state CSRF validation, token storage roundtrip
(encrypted), token expiry detection + auto-refresh, callback server,
provider config validation, and OAuthManager integration.
"""

import asyncio
import base64
import hashlib
import json
import sys
import time
import urllib.error
import urllib.request
from io import BytesIO
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

# Ensure the repo root is importable
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from runtime.auth.callback_server import (
    CallbackResult,
    OAuthCallbackError,
    OAuthCallbackServer,
    OAuthCallbackTimeout,
)
from runtime.auth.oauth_manager import (
    Connection,
    OAuthManager,
    OAuthTokenError,
    generate_pkce_pair,
    generate_state,
)
from runtime.auth.providers import (
    PROVIDERS,
    ProviderConfig,
    get_provider,
    list_provider_names,
)
from runtime.auth.token_store import TokenSet, TokenStore


# ── PKCE Tests ──────────────────────────────────────────────────────────


class TestPKCE:
    def test_pkce_pair_format(self) -> None:
        """Code verifier and challenge are valid base64url strings."""
        verifier, challenge = generate_pkce_pair()

        # RFC 7636: verifier must be 43-128 chars, unreserved characters only
        assert 43 <= len(verifier) <= 128
        assert all(c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" for c in verifier)

        # Challenge is base64url-encoded SHA-256 of verifier (no padding)
        assert "=" not in challenge
        assert len(challenge) == 43  # SHA-256 -> 32 bytes -> 43 base64url chars

    def test_pkce_challenge_matches_verifier(self) -> None:
        """The challenge is the SHA-256 of the verifier, base64url-encoded."""
        verifier, challenge = generate_pkce_pair()

        expected_digest = hashlib.sha256(verifier.encode("ascii")).digest()
        expected_challenge = base64.urlsafe_b64encode(expected_digest).rstrip(b"=").decode("ascii")

        assert challenge == expected_challenge

    def test_pkce_pairs_are_unique(self) -> None:
        """Each call produces a different verifier/challenge pair."""
        pairs = [generate_pkce_pair() for _ in range(10)]
        verifiers = [p[0] for p in pairs]
        challenges = [p[1] for p in pairs]

        assert len(set(verifiers)) == 10
        assert len(set(challenges)) == 10


# ── State Parameter Tests ───────────────────────────────────────────────


class TestState:
    def test_state_is_hex_string(self) -> None:
        """State parameter is a hex string."""
        state = generate_state()
        assert len(state) == 64  # 32 bytes -> 64 hex chars
        int(state, 16)  # Must be valid hex (raises ValueError if not)

    def test_state_is_unique(self) -> None:
        """Each call produces a unique state."""
        states = {generate_state() for _ in range(20)}
        assert len(states) == 20

    @pytest.mark.asyncio
    async def test_state_csrf_validation(self) -> None:
        """OAuthManager rejects callbacks with unknown state values."""
        store = TokenStore(tokens_path=Path("/dev/null"))  # won't be used
        manager = OAuthManager(token_store=store)

        with pytest.raises(ValueError, match="OAuth state mismatch"):
            await manager.handle_callback("anthropic", "some-code", "unknown-state")


# ── Token Store Tests ───────────────────────────────────────────────────


class TestTokenStore:
    @pytest.fixture()
    def store(self, tmp_path: Path) -> TokenStore:
        return TokenStore(tokens_path=tmp_path / "tokens.json")

    @pytest.fixture()
    def store_path(self, tmp_path: Path) -> Path:
        return tmp_path / "tokens.json"

    def test_roundtrip(self, store: TokenStore) -> None:
        """Save and retrieve tokens -- values must match exactly."""
        tokens = TokenSet(
            access_token="at-secret-123",
            refresh_token="rt-secret-456",
            expires_at=time.time() + 3600,
            scopes=["user:read", "messages:write"],
            user_email="user@example.com",
        )
        store.save_tokens("anthropic", tokens)

        retrieved = store.get_tokens("anthropic")
        assert retrieved is not None
        assert retrieved.access_token == "at-secret-123"
        assert retrieved.refresh_token == "rt-secret-456"
        assert retrieved.scopes == ["user:read", "messages:write"]
        assert retrieved.user_email == "user@example.com"

    def test_multiple_providers(self, store: TokenStore) -> None:
        """Tokens for different providers are stored independently."""
        store.save_tokens("anthropic", TokenSet(access_token="at-ant"))
        store.save_tokens("google", TokenSet(access_token="at-goog"))
        store.save_tokens("openai", TokenSet(access_token="at-oai"))

        assert store.get_tokens("anthropic").access_token == "at-ant"
        assert store.get_tokens("google").access_token == "at-goog"
        assert store.get_tokens("openai").access_token == "at-oai"

    def test_overwrite(self, store: TokenStore) -> None:
        """Saving new tokens for the same provider replaces old ones."""
        store.save_tokens("anthropic", TokenSet(access_token="old"))
        store.save_tokens("anthropic", TokenSet(access_token="new"))
        assert store.get_tokens("anthropic").access_token == "new"

    def test_get_nonexistent_returns_none(self, store: TokenStore) -> None:
        """Getting tokens for an unconnected provider returns None."""
        assert store.get_tokens("nonexistent") is None

    def test_delete(self, store: TokenStore) -> None:
        """Deleting tokens removes them from storage."""
        store.save_tokens("anthropic", TokenSet(access_token="at-123"))
        assert store.delete_tokens("anthropic") is True
        assert store.get_tokens("anthropic") is None

    def test_delete_nonexistent(self, store: TokenStore) -> None:
        """Deleting tokens for a non-stored provider returns False."""
        assert store.delete_tokens("nonexistent") is False

    def test_list_providers(self, store: TokenStore) -> None:
        """list_providers returns sorted list of stored providers."""
        store.save_tokens("openai", TokenSet(access_token="at-oai"))
        store.save_tokens("anthropic", TokenSet(access_token="at-ant"))
        assert store.list_providers() == ["anthropic", "openai"]

    def test_has_tokens(self, store: TokenStore) -> None:
        """has_tokens correctly reports presence."""
        store.save_tokens("anthropic", TokenSet(access_token="at"))
        assert store.has_tokens("anthropic") is True
        assert store.has_tokens("google") is False

    def test_empty_provider_raises(self, store: TokenStore) -> None:
        """Empty provider name raises ValueError."""
        with pytest.raises(ValueError, match="cannot be empty"):
            store.save_tokens("", TokenSet(access_token="at"))

    def test_case_insensitive(self, store: TokenStore) -> None:
        """Provider names are normalized to lowercase."""
        store.save_tokens("Anthropic", TokenSet(access_token="at"))
        assert store.get_tokens("anthropic") is not None
        assert store.get_tokens("ANTHROPIC") is not None


# ── Token Encryption at Rest ────────────────────────────────────────────


class TestTokenEncryption:
    def test_raw_token_not_in_file(self, tmp_path: Path) -> None:
        """Raw access/refresh tokens must never appear in the file."""
        path = tmp_path / "tokens.json"
        store = TokenStore(tokens_path=path)
        store.save_tokens(
            "anthropic",
            TokenSet(
                access_token="SUPER_SECRET_ACCESS_TOKEN_12345",
                refresh_token="SUPER_SECRET_REFRESH_TOKEN_67890",
            ),
        )

        contents = path.read_text("utf-8")
        assert "SUPER_SECRET_ACCESS_TOKEN_12345" not in contents
        assert "SUPER_SECRET_REFRESH_TOKEN_67890" not in contents

    def test_different_machine_cannot_decrypt(self, tmp_path: Path) -> None:
        """Tokens encrypted on one machine are unreadable on another."""
        path = tmp_path / "tokens.json"
        store = TokenStore(tokens_path=path)
        store.save_tokens("anthropic", TokenSet(access_token="secret"))

        fake_id = b"\x00" * 32
        with patch(
            "runtime.auth.token_store._get_machine_id", return_value=fake_id
        ):
            store2 = TokenStore(tokens_path=path)
            assert store2.get_tokens("anthropic") is None

    def test_corrupted_file_handled_gracefully(self, tmp_path: Path) -> None:
        """A corrupted tokens file results in empty store, not a crash."""
        path = tmp_path / "tokens.json"
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text("not valid json {{{", encoding="utf-8")

        store = TokenStore(tokens_path=path)
        assert store.get_tokens("anthropic") is None
        # Should be able to save new tokens after corruption
        store.save_tokens("anthropic", TokenSet(access_token="fresh"))
        assert store.get_tokens("anthropic").access_token == "fresh"


# ── Token Expiry Detection ──────────────────────────────────────────────


class TestTokenExpiry:
    def test_unexpired_token(self) -> None:
        """Token with future expiry is not expired."""
        ts = TokenSet(
            access_token="at",
            expires_at=time.time() + 3600,
        )
        assert ts.is_expired is False

    def test_expired_token(self) -> None:
        """Token with past expiry is expired."""
        ts = TokenSet(
            access_token="at",
            expires_at=time.time() - 100,
        )
        assert ts.is_expired is True

    def test_about_to_expire_token(self) -> None:
        """Token expiring within 60s buffer is considered expired."""
        ts = TokenSet(
            access_token="at",
            expires_at=time.time() + 30,  # 30s left, but buffer is 60s
        )
        assert ts.is_expired is True

    def test_no_expiry_assumed_valid(self) -> None:
        """Token with no expires_at is assumed valid."""
        ts = TokenSet(access_token="at", expires_at=None)
        assert ts.is_expired is False


# ── Token Auto-Refresh ──────────────────────────────────────────────────


class TestTokenAutoRefresh:
    @pytest.fixture()
    def manager(self, tmp_path: Path) -> OAuthManager:
        store = TokenStore(tokens_path=tmp_path / "tokens.json")
        return OAuthManager(token_store=store)

    @pytest.mark.asyncio
    async def test_get_token_returns_valid_token(self, manager: OAuthManager, tmp_path: Path) -> None:
        """get_token returns the access token when it's still valid."""
        store = manager._store
        store.save_tokens(
            "anthropic",
            TokenSet(
                access_token="valid-at",
                expires_at=time.time() + 3600,
            ),
        )
        token = await manager.get_token("anthropic")
        assert token == "valid-at"

    @pytest.mark.asyncio
    async def test_get_token_auto_refreshes_expired(self, manager: OAuthManager) -> None:
        """get_token auto-refreshes when the token is expired."""
        manager._store.save_tokens(
            "anthropic",
            TokenSet(
                access_token="old-at",
                refresh_token="rt-123",
                expires_at=time.time() - 100,  # expired
            ),
        )

        # Mock the refresh to return a new token
        with patch.object(manager, "refresh_token", return_value="new-at") as mock_refresh:
            token = await manager.get_token("anthropic")
            assert token == "new-at"
            mock_refresh.assert_called_once_with("anthropic")

    @pytest.mark.asyncio
    async def test_get_token_returns_none_when_expired_no_refresh(
        self, manager: OAuthManager
    ) -> None:
        """get_token returns None when expired with no refresh token."""
        manager._store.save_tokens(
            "anthropic",
            TokenSet(
                access_token="old-at",
                refresh_token=None,
                expires_at=time.time() - 100,
            ),
        )
        token = await manager.get_token("anthropic")
        assert token is None

    @pytest.mark.asyncio
    async def test_get_token_returns_none_when_not_connected(
        self, manager: OAuthManager
    ) -> None:
        """get_token returns None for providers with no stored tokens."""
        token = await manager.get_token("anthropic")
        assert token is None


# ── Callback Server Tests ───────────────────────────────────────────────


class TestCallbackServer:
    def test_server_binds_to_random_port(self) -> None:
        """Server should pick a random available port."""
        server = OAuthCallbackServer()
        assert server.port > 0
        assert server.port < 65536

    def test_redirect_uri_format(self) -> None:
        """redirect_uri follows expected format."""
        server = OAuthCallbackServer()
        assert server.redirect_uri.startswith("http://127.0.0.1:")
        assert server.redirect_uri.endswith("/callback")

    @pytest.mark.asyncio
    async def test_server_receives_callback(self) -> None:
        """Server receives a callback with code and state parameters."""
        server = OAuthCallbackServer()

        async def send_callback():
            """Send a fake OAuth callback to the server after a short delay."""
            await asyncio.sleep(0.3)
            url = f"http://127.0.0.1:{server.port}/callback?code=test-code&state=test-state"
            req = urllib.request.Request(url, method="GET")
            try:
                urllib.request.urlopen(req, timeout=5)
            except Exception:
                pass

        # Start the callback sender in the background
        sender = asyncio.create_task(send_callback())

        result = await server.wait_for_callback(timeout=5)
        assert result.code == "test-code"
        assert result.state == "test-state"

        await sender

    @pytest.mark.asyncio
    async def test_server_timeout(self) -> None:
        """Server raises OAuthCallbackTimeout if no callback arrives."""
        server = OAuthCallbackServer()
        with pytest.raises(OAuthCallbackTimeout):
            await server.wait_for_callback(timeout=0.5)


# ── Provider Config Tests ───────────────────────────────────────────────


class TestProviderConfig:
    def test_all_providers_have_required_fields(self) -> None:
        """Every provider config has the mandatory fields."""
        for name, config in PROVIDERS.items():
            assert config.name, f"{name} missing name"
            assert config.auth_url.startswith("https://"), f"{name} auth_url not HTTPS"
            assert config.token_url.startswith("https://"), f"{name} token_url not HTTPS"
            assert config.scopes, f"{name} has no scopes"
            assert config.client_id_env.startswith("BRYNQ_"), f"{name} client_id_env should start with BRYNQ_"

    def test_anthropic_requires_pkce(self) -> None:
        """Anthropic provider must have PKCE enabled."""
        config = get_provider("anthropic")
        assert config.requires_pkce is True

    def test_google_has_offline_access(self) -> None:
        """Google provider should request offline access for refresh tokens."""
        config = get_provider("google")
        assert config.extra_auth_params is not None
        assert config.extra_auth_params.get("access_type") == "offline"

    def test_get_provider_case_insensitive(self) -> None:
        """get_provider normalizes case."""
        config = get_provider("Anthropic")
        assert config.name == "Anthropic (Claude)"

    def test_get_provider_unknown_raises(self) -> None:
        """get_provider raises ValueError for unknown providers."""
        with pytest.raises(ValueError, match="Unknown OAuth provider"):
            get_provider("nonexistent")

    def test_list_provider_names(self) -> None:
        """list_provider_names returns all supported providers sorted."""
        names = list_provider_names()
        assert names == sorted(names)
        assert "anthropic" in names
        assert "google" in names
        assert "openai" in names

    def test_client_id_from_env(self) -> None:
        """get_client_id reads from environment variable."""
        config = get_provider("anthropic")
        with patch.dict("os.environ", {"BRYNQ_ANTHROPIC_CLIENT_ID": "test-id"}):
            assert config.get_client_id() == "test-id"

    def test_client_id_missing_returns_none(self) -> None:
        """get_client_id returns None when env var is not set."""
        config = get_provider("anthropic")
        with patch.dict("os.environ", {}, clear=True):
            # Only clear the specific env var
            import os
            original = os.environ.pop("BRYNQ_ANTHROPIC_CLIENT_ID", None)
            try:
                assert config.get_client_id() is None
            finally:
                if original is not None:
                    os.environ["BRYNQ_ANTHROPIC_CLIENT_ID"] = original


# ── TokenSet Serialization ──────────────────────────────────────────────


class TestTokenSetSerialization:
    def test_to_dict_full(self) -> None:
        """to_dict includes all fields when set."""
        ts = TokenSet(
            access_token="at",
            refresh_token="rt",
            expires_at=1234567890.0,
            scopes=["a", "b"],
            user_email="user@test.com",
        )
        d = ts.to_dict()
        assert d["access_token"] == "at"
        assert d["refresh_token"] == "rt"
        assert d["expires_at"] == 1234567890.0
        assert d["scopes"] == ["a", "b"]
        assert d["user_email"] == "user@test.com"

    def test_to_dict_minimal(self) -> None:
        """to_dict omits None/empty fields."""
        ts = TokenSet(access_token="at")
        d = ts.to_dict()
        assert d == {"access_token": "at"}
        assert "refresh_token" not in d
        assert "expires_at" not in d
        assert "scopes" not in d
        assert "user_email" not in d

    def test_from_dict_roundtrip(self) -> None:
        """from_dict(to_dict()) is identity."""
        ts = TokenSet(
            access_token="at",
            refresh_token="rt",
            expires_at=9999999999.0,
            scopes=["x"],
            user_email="a@b.com",
        )
        reconstructed = TokenSet.from_dict(ts.to_dict())
        assert reconstructed.access_token == ts.access_token
        assert reconstructed.refresh_token == ts.refresh_token
        assert reconstructed.expires_at == ts.expires_at
        assert reconstructed.scopes == ts.scopes
        assert reconstructed.user_email == ts.user_email

    def test_from_dict_minimal(self) -> None:
        """from_dict handles minimal dict (only access_token)."""
        ts = TokenSet.from_dict({"access_token": "at"})
        assert ts.access_token == "at"
        assert ts.refresh_token is None
        assert ts.expires_at is None
        assert ts.scopes == []
        assert ts.user_email is None


# ── OAuthManager Integration Tests ──────────────────────────────────────


class TestOAuthManagerIntegration:
    @pytest.fixture()
    def manager(self, tmp_path: Path) -> OAuthManager:
        store = TokenStore(tokens_path=tmp_path / "tokens.json")
        return OAuthManager(token_store=store)

    @pytest.mark.asyncio
    async def test_start_login_no_client_id_raises(
        self, manager: OAuthManager
    ) -> None:
        """start_login raises ValueError when client ID env var is not set."""
        with patch.dict("os.environ", {}, clear=False):
            import os
            original = os.environ.pop("BRYNQ_ANTHROPIC_CLIENT_ID", None)
            try:
                with pytest.raises(ValueError, match="No client ID"):
                    await manager.start_login("anthropic", open_browser=False)
            finally:
                if original is not None:
                    os.environ["BRYNQ_ANTHROPIC_CLIENT_ID"] = original

    @pytest.mark.asyncio
    async def test_start_login_builds_correct_url(
        self, manager: OAuthManager
    ) -> None:
        """start_login returns a valid authorization URL with required params."""
        with patch.dict(
            "os.environ", {"BRYNQ_ANTHROPIC_CLIENT_ID": "test-client-id"}
        ):
            url = await manager.start_login("anthropic", open_browser=False)

        assert "console.anthropic.com/oauth/authorize" in url
        assert "client_id=test-client-id" in url
        assert "response_type=code" in url
        assert "state=" in url
        # Anthropic requires PKCE
        assert "code_challenge=" in url
        assert "code_challenge_method=S256" in url

    @pytest.mark.asyncio
    async def test_start_login_google_url(self, manager: OAuthManager) -> None:
        """start_login for Google includes offline access params."""
        with patch.dict(
            "os.environ", {"BRYNQ_GOOGLE_CLIENT_ID": "google-client-id"}
        ):
            url = await manager.start_login("google", open_browser=False)

        assert "accounts.google.com" in url
        assert "access_type=offline" in url
        assert "prompt=consent" in url

    @pytest.mark.asyncio
    async def test_handle_callback_csrf_rejects_bad_state(
        self, manager: OAuthManager
    ) -> None:
        """handle_callback rejects unknown state values."""
        with pytest.raises(ValueError, match="OAuth state mismatch"):
            await manager.handle_callback("anthropic", "code", "bad-state")

    @pytest.mark.asyncio
    async def test_handle_callback_csrf_rejects_wrong_provider(
        self, manager: OAuthManager
    ) -> None:
        """handle_callback rejects state for a different provider."""
        with patch.dict(
            "os.environ", {"BRYNQ_ANTHROPIC_CLIENT_ID": "test-id"}
        ):
            url = await manager.start_login("anthropic", open_browser=False)

        # Extract state from URL
        from urllib.parse import parse_qs, urlparse
        parsed = urlparse(url)
        state = parse_qs(parsed.query)["state"][0]

        # Try to use it with a different provider
        with pytest.raises(ValueError, match="State mismatch"):
            await manager.handle_callback("google", "some-code", state)

    @pytest.mark.asyncio
    async def test_list_connections_all_not_connected(
        self, manager: OAuthManager
    ) -> None:
        """list_connections shows all providers as not_connected by default."""
        connections = await manager.list_connections()
        assert len(connections) == len(PROVIDERS)
        for conn in connections:
            assert conn.status == "not_connected"

    @pytest.mark.asyncio
    async def test_list_connections_with_connected(
        self, manager: OAuthManager
    ) -> None:
        """list_connections correctly shows connected providers."""
        manager._store.save_tokens(
            "anthropic",
            TokenSet(
                access_token="at",
                expires_at=time.time() + 3600,
                user_email="user@test.com",
            ),
        )

        connections = await manager.list_connections()
        anthro = next(c for c in connections if c.provider == "anthropic")
        assert anthro.status == "connected"
        assert anthro.user_email == "user@test.com"

    @pytest.mark.asyncio
    async def test_list_connections_with_expired(
        self, manager: OAuthManager
    ) -> None:
        """list_connections correctly shows expired connections."""
        manager._store.save_tokens(
            "anthropic",
            TokenSet(
                access_token="at",
                expires_at=time.time() - 100,
            ),
        )

        connections = await manager.list_connections()
        anthro = next(c for c in connections if c.provider == "anthropic")
        assert anthro.status == "expired"

    @pytest.mark.asyncio
    async def test_logout_removes_tokens(self, manager: OAuthManager) -> None:
        """logout removes tokens from local storage."""
        manager._store.save_tokens(
            "anthropic", TokenSet(access_token="at")
        )
        await manager.logout("anthropic")
        assert manager._store.get_tokens("anthropic") is None

    @pytest.mark.asyncio
    async def test_logout_attempts_revocation(
        self, manager: OAuthManager
    ) -> None:
        """logout attempts to revoke tokens at the provider's revocation endpoint."""
        manager._store.save_tokens(
            "anthropic",
            TokenSet(access_token="at", refresh_token="rt"),
        )

        with patch.object(manager, "_revoke_token") as mock_revoke:
            await manager.logout("anthropic")
            mock_revoke.assert_called_once()

        # Tokens should still be removed even if revocation is attempted
        assert manager._store.get_tokens("anthropic") is None
