"""Tests for OpenAI OAuth token management."""
import base64
import hashlib
import json
import time
import urllib.error
from pathlib import Path
from unittest.mock import patch, MagicMock

import pytest

from second_opinion_mcp.openai_auth import (
    read_codex_auth,
    _decode_jwt_exp,
    _is_token_expired,
    _generate_pkce,
    _exchange_code_for_tokens,
    _safe_error_from_body,
    _manual_url_login,
    _poll_device_auth,
    _CallbackHandler,
    refresh_access_token,
    get_openai_token,
    get_own_oauth_token,
    _device_code_login,
    _is_headless,
    oauth_login,
    CODEX_AUTH_FILE,
    CLIENT_ID,
    TOKEN_URL,
)


def _make_jwt(payload: dict) -> str:
    """Build a minimal JWT with the given payload (no signature verification)."""
    header = base64.urlsafe_b64encode(json.dumps({"alg": "none"}).encode()).rstrip(b"=").decode()
    body = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=").decode()
    return f"{header}.{body}.fake-signature"


def _make_auth_data(access_token: str = "", refresh_token: str = "", exp: float | None = None) -> dict:
    """Build a Codex CLI auth.json structure."""
    token = access_token or (_make_jwt({"exp": exp}) if exp else "")
    return {
        "OPENAI_API_KEY": None,
        "tokens": {
            "id_token": "fake-id-token",
            "access_token": token,
            "refresh_token": refresh_token,
        },
        "last_refresh": "2026-01-16T21:24:05Z",
    }


class TestReadCodexAuth:
    """Tests for reading ~/.codex/auth.json."""

    def test_returns_none_when_file_missing(self, tmp_path):
        """Should return None when auth file doesn't exist."""
        with patch("second_opinion_mcp.openai_auth.CODEX_AUTH_FILE", tmp_path / "nonexistent.json"):
            result = read_codex_auth()
            assert result is None

    def test_reads_valid_auth_file(self, tmp_path):
        """Should parse valid auth.json with nested tokens."""
        auth_file = tmp_path / "auth.json"
        auth_data = _make_auth_data(
            refresh_token="v1.refresh-123",
            exp=time.time() + 3600,
        )
        auth_file.write_text(json.dumps(auth_data))

        with patch("second_opinion_mcp.openai_auth.CODEX_AUTH_FILE", auth_file):
            result = read_codex_auth()
            assert result is not None
            assert "tokens" in result
            assert result["tokens"]["refresh_token"] == "v1.refresh-123"

    def test_returns_none_for_invalid_json(self, tmp_path):
        """Should return None for malformed JSON."""
        auth_file = tmp_path / "auth.json"
        auth_file.write_text("not valid json {{{")

        with patch("second_opinion_mcp.openai_auth.CODEX_AUTH_FILE", auth_file):
            result = read_codex_auth()
            assert result is None

    def test_returns_none_for_non_dict(self, tmp_path):
        """Should return None if auth.json contains a non-dict value."""
        auth_file = tmp_path / "auth.json"
        auth_file.write_text(json.dumps(["not", "a", "dict"]))

        with patch("second_opinion_mcp.openai_auth.CODEX_AUTH_FILE", auth_file):
            result = read_codex_auth()
            assert result is None


class TestDecodeJwtExp:
    """Tests for JWT expiration extraction."""

    def test_decodes_valid_jwt(self):
        """Should extract exp from a valid JWT."""
        exp = time.time() + 3600
        token = _make_jwt({"exp": exp, "sub": "user-123"})
        assert _decode_jwt_exp(token) == exp

    def test_returns_none_for_invalid_token(self):
        """Should return None for non-JWT strings."""
        assert _decode_jwt_exp("not-a-jwt") is None

    def test_returns_none_for_missing_exp(self):
        """Should return None when JWT has no exp claim."""
        token = _make_jwt({"sub": "user-123"})
        assert _decode_jwt_exp(token) is None


class TestIsTokenExpired:
    """Tests for token expiry detection (string-based)."""

    def test_expired_token(self):
        """Token with past exp should be expired."""
        token = _make_jwt({"exp": time.time() - 100})
        assert _is_token_expired(token) is True

    def test_valid_token(self):
        """Token with future exp should be valid."""
        token = _make_jwt({"exp": time.time() + 3600})
        assert _is_token_expired(token) is False

    def test_empty_string(self):
        """Empty string should be considered expired."""
        assert _is_token_expired("") is True

    def test_token_within_buffer(self):
        """Token expiring within 60-second buffer should be expired."""
        token = _make_jwt({"exp": time.time() + 30})
        assert _is_token_expired(token) is True


class TestGeneratePKCE:
    """Tests for PKCE verifier/challenge generation."""

    def test_verifier_length(self):
        """Verifier should be 43 chars (32 bytes base64url, no padding)."""
        verifier, challenge = _generate_pkce()
        assert len(verifier) == 43

    def test_challenge_is_sha256_of_verifier(self):
        """Challenge should be SHA-256 of verifier, base64url-encoded."""
        verifier, challenge = _generate_pkce()
        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_unique_per_call(self):
        """Each call should produce different verifier/challenge."""
        v1, c1 = _generate_pkce()
        v2, c2 = _generate_pkce()
        assert v1 != v2
        assert c1 != c2


class TestRefreshAccessToken:
    """Tests for OAuth token refresh."""

    def test_successful_refresh(self):
        """Should return new token data on successful refresh."""
        mock_response = MagicMock()
        mock_response.read.return_value = json.dumps({
            "access_token": "new-access-token",
            "refresh_token": "new-refresh-token",
            "id_token": "new-id-token",
        }).encode("utf-8")
        mock_response.__enter__ = lambda s: s
        mock_response.__exit__ = MagicMock(return_value=False)

        with patch("urllib.request.urlopen", return_value=mock_response):
            result = refresh_access_token("old-refresh-token")

        assert result is not None
        assert result["access_token"] == "new-access-token"
        assert result["refresh_token"] == "new-refresh-token"

    def test_failed_refresh_returns_none(self):
        """Should return None when refresh fails."""
        with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("Network error")):
            result = refresh_access_token("bad-refresh-token")
            assert result is None

    def test_sends_form_encoded_body(self):
        """Refresh should send application/x-www-form-urlencoded, not JSON."""
        mock_response = MagicMock()
        mock_response.read.return_value = json.dumps({
            "access_token": "token",
            "refresh_token": "refresh",
        }).encode("utf-8")
        mock_response.__enter__ = lambda s: s
        mock_response.__exit__ = MagicMock(return_value=False)

        with patch("urllib.request.urlopen", return_value=mock_response) as mock_urlopen:
            refresh_access_token("test-refresh")
            request_obj = mock_urlopen.call_args[0][0]
            assert request_obj.get_header("Content-type") == "application/x-www-form-urlencoded"
            # Body should be URL-encoded, not JSON
            body = request_obj.data.decode("utf-8")
            assert "grant_type=refresh_token" in body
            assert "{" not in body  # Not JSON


class TestGetOwnOauthToken:
    """Tests for own OAuth token retrieval."""

    def test_returns_valid_token(self, tmp_path):
        """Should return access token when it's valid."""
        token = _make_jwt({"exp": time.time() + 3600})
        oauth_data = {
            "access_token": token,
            "refresh_token": "refresh-123",
            "id_token": "id-123",
            "obtained_at": "2026-02-16T12:00:00+00:00",
        }

        mock_manager = MagicMock()
        mock_manager.get_config_section.return_value = oauth_data

        with patch("second_opinion_mcp.credentials.CredentialManager", return_value=mock_manager):
            result = get_own_oauth_token()
            assert result == token

    def test_refreshes_expired_token(self, tmp_path):
        """Should refresh expired token and update config."""
        expired_token = _make_jwt({"exp": time.time() - 100})
        oauth_data = {
            "access_token": expired_token,
            "refresh_token": "refresh-123",
            "id_token": "id-123",
            "obtained_at": "2026-02-16T12:00:00+00:00",
        }

        mock_manager = MagicMock()
        mock_manager.get_config_section.return_value = oauth_data

        with patch("second_opinion_mcp.credentials.CredentialManager", return_value=mock_manager):
            with patch("second_opinion_mcp.openai_auth.refresh_access_token") as mock_refresh:
                mock_refresh.return_value = {
                    "access_token": "new-token",
                    "refresh_token": "new-refresh",
                    "id_token": "new-id",
                }
                result = get_own_oauth_token()
                assert result == "new-token"
                mock_refresh.assert_called_once_with("refresh-123")
                mock_manager.set_config_section.assert_called_once()

    def test_returns_none_when_no_data(self):
        """Should return None when no OAuth data stored."""
        mock_manager = MagicMock()
        mock_manager.get_config_section.return_value = None

        with patch("second_opinion_mcp.credentials.CredentialManager", return_value=mock_manager):
            result = get_own_oauth_token()
            assert result is None


class TestGetOpenaiToken:
    """Tests for the main orchestrator function."""

    def test_prefers_own_oauth_over_codex(self):
        """Should use own OAuth token when available, not Codex."""
        own_token = "own-oauth-token"
        with patch("second_opinion_mcp.openai_auth.get_own_oauth_token", return_value=own_token):
            with patch("second_opinion_mcp.openai_auth._get_codex_token") as mock_codex:
                token = get_openai_token()
                assert token == own_token
                mock_codex.assert_not_called()

    def test_falls_back_to_codex(self):
        """Should use Codex token when own OAuth unavailable."""
        codex_token = "codex-token"
        with patch("second_opinion_mcp.openai_auth.get_own_oauth_token", return_value=None):
            with patch("second_opinion_mcp.openai_auth._get_codex_token", return_value=codex_token):
                token = get_openai_token()
                assert token == codex_token

    def test_returns_none_when_both_fail(self):
        """Should return None when neither OAuth nor Codex available."""
        with patch("second_opinion_mcp.openai_auth.get_own_oauth_token", return_value=None):
            with patch("second_opinion_mcp.openai_auth._get_codex_token", return_value=None):
                token = get_openai_token()
                assert token is None

    def test_returns_valid_codex_token(self, tmp_path):
        """Should return Codex access token when it's valid and no own OAuth."""
        auth_file = tmp_path / "auth.json"
        exp = time.time() + 3600
        auth_data = _make_auth_data(refresh_token="refresh-123", exp=exp)
        auth_file.write_text(json.dumps(auth_data))

        with patch("second_opinion_mcp.openai_auth.get_own_oauth_token", return_value=None):
            with patch("second_opinion_mcp.openai_auth.CODEX_AUTH_FILE", auth_file):
                token = get_openai_token()
                assert token is not None
                decoded_exp = _decode_jwt_exp(token)
                assert decoded_exp == exp

    def test_returns_none_when_no_auth_file(self, tmp_path):
        """Should return None when no auth sources exist."""
        with patch("second_opinion_mcp.openai_auth.get_own_oauth_token", return_value=None):
            with patch("second_opinion_mcp.openai_auth.CODEX_AUTH_FILE", tmp_path / "nonexistent.json"):
                token = get_openai_token()
                assert token is None


class TestDeviceCodeLogin:
    """Tests for the device code OAuth flow."""

    def test_returns_none_on_device_code_failure(self):
        """Should return None when device code request fails."""
        with patch("second_opinion_mcp.openai_auth._request_device_code", return_value=None):
            result = _device_code_login()
            assert result is None

    def test_returns_none_on_missing_fields(self):
        """Should return None when device code response lacks required fields."""
        with patch("second_opinion_mcp.openai_auth._request_device_code", return_value={"interval": 5}):
            result = _device_code_login()
            assert result is None


class TestIsHeadless:
    """Tests for environment detection."""

    def test_windows_never_headless(self):
        """Windows should always return False."""
        with patch("second_opinion_mcp.openai_auth.sys") as mock_sys:
            mock_sys.platform = "win32"
            assert _is_headless() is False

    def test_ssh_session_is_headless(self):
        """SSH session should be headless."""
        with patch("second_opinion_mcp.openai_auth.sys") as mock_sys:
            mock_sys.platform = "linux"
            with patch.dict("os.environ", {"SSH_CONNECTION": "1.2.3.4 5678 5.6.7.8 22"}, clear=False):
                assert _is_headless() is True


class TestManualUrlValidation:
    """Tests for _manual_url_login() redirect URI validation (Finding 1)."""

    def test_rejects_non_callback_url(self):
        """Should reject a URL that isn't the OAuth callback."""
        with patch("builtins.input", return_value="https://evil.com/steal?code=abc&state=123"):
            with patch("second_opinion_mcp.openai_auth.secrets.token_hex", return_value="abc123"):
                result = _manual_url_login()
        assert result is None

    def test_rejects_wrong_host(self):
        """Should reject URL with correct path but wrong host."""
        with patch("builtins.input", return_value="http://attacker.com:1455/auth/callback?code=abc&state=abc123"):
            with patch("second_opinion_mcp.openai_auth.secrets.token_hex", return_value="abc123"):
                result = _manual_url_login()
        assert result is None

    def test_accepts_valid_callback_url(self):
        """Should accept valid callback URL and exchange code."""
        url = "http://localhost:1455/auth/callback?code=testcode&state=abc123"
        mock_tokens = {"access_token": "tok", "refresh_token": "ref"}
        with patch("builtins.input", return_value=url):
            with patch("second_opinion_mcp.openai_auth.secrets.token_hex", return_value="abc123"):
                with patch("second_opinion_mcp.openai_auth._exchange_code_for_tokens", return_value=mock_tokens) as mock_exchange:
                    result = _manual_url_login()
        assert result == mock_tokens
        mock_exchange.assert_called_once()

    def test_detects_oauth_error_in_redirect(self):
        """Should detect error param in redirect URL."""
        url = "http://localhost:1455/auth/callback?error=access_denied&error_description=User+denied"
        with patch("builtins.input", return_value=url):
            with patch("second_opinion_mcp.openai_auth.secrets.token_hex", return_value="abc123"):
                result = _manual_url_login()
        assert result is None

    def test_accepts_127_0_0_1_callback(self):
        """Should accept 127.0.0.1 as well as localhost."""
        url = "http://127.0.0.1:1455/auth/callback?code=testcode&state=abc123"
        mock_tokens = {"access_token": "tok", "refresh_token": "ref"}
        with patch("builtins.input", return_value=url):
            with patch("second_opinion_mcp.openai_auth.secrets.token_hex", return_value="abc123"):
                with patch("second_opinion_mcp.openai_auth._exchange_code_for_tokens", return_value=mock_tokens):
                    result = _manual_url_login()
        assert result == mock_tokens


class TestCallbackErrorHandling:
    """Tests for _CallbackHandler.do_GET() error params (Finding 2)."""

    def test_error_param_sets_code_to_none(self):
        """Error param should set authorization_code to None."""
        handler = MagicMock(spec=_CallbackHandler)
        handler.path = "/auth/callback?error=access_denied&error_description=User+denied"
        handler.send_response = MagicMock()
        handler.send_header = MagicMock()
        handler.end_headers = MagicMock()
        handler.wfile = MagicMock()

        _CallbackHandler.authorization_code = "should_be_cleared"
        _CallbackHandler.received_state = None
        _CallbackHandler.do_GET(handler)

        assert _CallbackHandler.authorization_code is None

    def test_success_with_code(self):
        """Normal flow should set authorization_code."""
        handler = MagicMock(spec=_CallbackHandler)
        handler.path = "/auth/callback?code=testcode&state=teststate"
        handler.send_response = MagicMock()
        handler.send_header = MagicMock()
        handler.end_headers = MagicMock()
        handler.wfile = MagicMock()

        _CallbackHandler.do_GET(handler)

        assert _CallbackHandler.authorization_code == "testcode"
        assert _CallbackHandler.received_state == "teststate"


class TestDevicePollingSemantics:
    """Tests for _poll_device_auth() OAuth error handling (Finding 4)."""

    def _make_http_error(self, code, body_json):
        """Helper to create an HTTPError with JSON body."""
        import io
        body_bytes = json.dumps(body_json).encode("utf-8")
        err = urllib.error.HTTPError(
            url="https://example.com",
            code=code,
            msg="Error",
            hdrs={},
            fp=io.BytesIO(body_bytes),
        )
        return err

    def test_slow_down_increases_interval(self):
        """slow_down error should increase polling interval."""
        errors = [
            self._make_http_error(429, {"error": "slow_down"}),
            self._make_http_error(429, {"error": "slow_down"}),
        ]
        call_count = 0

        def side_effect(*args, **kwargs):
            nonlocal call_count
            if call_count < len(errors):
                err = errors[call_count]
                call_count += 1
                raise err
            # Return success on third call
            resp = MagicMock()
            resp.read.return_value = json.dumps({
                "authorization_code": "code123",
                "code_verifier": "verifier123",
            }).encode("utf-8")
            resp.__enter__ = lambda s: s
            resp.__exit__ = MagicMock(return_value=False)
            return resp

        with patch("urllib.request.urlopen", side_effect=side_effect):
            with patch("time.sleep") as mock_sleep:
                with patch("time.monotonic", side_effect=[0, 1, 2, 3, 4]):
                    result = _poll_device_auth("dev_id", "user_code", interval=5, timeout=100)

        assert result is not None
        # First slow_down: interval 5+5=10, second: 10+5=15
        assert mock_sleep.call_args_list[0][0][0] == 10
        assert mock_sleep.call_args_list[1][0][0] == 15

    def test_access_denied_returns_none(self):
        """access_denied error should return None immediately."""
        err = self._make_http_error(403, {"error": "access_denied"})

        with patch("urllib.request.urlopen", side_effect=err):
            with patch("time.monotonic", side_effect=[0, 1]):
                result = _poll_device_auth("dev_id", "user_code", interval=5, timeout=100)

        assert result is None

    def test_expired_token_returns_none(self):
        """expired_token error should return None immediately."""
        err = self._make_http_error(403, {"error": "expired_token"})

        with patch("urllib.request.urlopen", side_effect=err):
            with patch("time.monotonic", side_effect=[0, 1]):
                result = _poll_device_auth("dev_id", "user_code", interval=5, timeout=100)

        assert result is None


class TestLogRedaction:
    """Tests for _safe_error_from_body() (Finding 3)."""

    def test_extracts_error_fields(self):
        """Should extract error and error_description from JSON."""
        body = json.dumps({"error": "invalid_grant", "error_description": "Token expired"})
        assert _safe_error_from_body(body) == "invalid_grant: Token expired"

    def test_extracts_error_only(self):
        """Should handle JSON with error but no description."""
        body = json.dumps({"error": "server_error"})
        assert _safe_error_from_body(body) == "server_error"

    def test_handles_non_json(self):
        """Should return safe message for non-JSON responses."""
        assert _safe_error_from_body("<html>Error page</html>") == "non-JSON response"

    def test_handles_empty_string(self):
        """Should handle empty body."""
        assert _safe_error_from_body("") == "non-JSON response"

    def test_no_sensitive_data_leaked(self):
        """Should not leak tokens or other sensitive data."""
        body = json.dumps({
            "error": "invalid_token",
            "error_description": "Bad token",
            "access_token": "SECRET_TOKEN_VALUE",
            "refresh_token": "SECRET_REFRESH",
        })
        result = _safe_error_from_body(body)
        assert "SECRET_TOKEN_VALUE" not in result
        assert "SECRET_REFRESH" not in result
        assert result == "invalid_token: Bad token"
