"""
Tests for oauth2_microsoft.py

Tests cover:
- Tenant discovery from email domain
- Token acquisition using MSAL device code flow
- MSAL app caching and silent token refresh
"""

import json
import os
import sys
from unittest.mock import MagicMock, patch

import pytest

sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src")))

import oauth2_microsoft
from conftest import temp_env
from mock_oauth_server import MOCK_TENANT_ID


@pytest.fixture(autouse=True)
def clear_caches():
    """Clear module-level caches between tests."""
    oauth2_microsoft._msal_app_cache.clear()
    oauth2_microsoft._tenant_cache.clear()
    yield
    oauth2_microsoft._msal_app_cache.clear()
    oauth2_microsoft._tenant_cache.clear()


class TestDiscoverTenant:
    """Tests for discover_tenant function."""

    def test_successful_discovery(self):
        """Test successful tenant ID extraction from OpenID config."""
        tenant_id = "12345678-abcd-ef01-2345-67890abcdef0"
        data = {
            "issuer": f"https://sts.windows.net/{tenant_id}/",
            "authorization_endpoint": f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize",
        }

        with patch.object(oauth2_microsoft, "_fetch_json_https", return_value=data):
            result = oauth2_microsoft.discover_tenant("user@contoso.com")

        assert result == tenant_id

    def test_domain_extraction(self):
        """Test that domain is correctly extracted from email."""
        data = {"issuer": "https://sts.windows.net/abcdef01-2345-6789-abcd-ef0123456789/"}

        with patch.object(oauth2_microsoft, "_fetch_json_https", return_value=data) as mock_fetch:
            oauth2_microsoft.discover_tenant("user@example.org")
            host, path = mock_fetch.call_args[0][0], mock_fetch.call_args[0][1]
            assert host == "login.microsoftonline.com"
            assert "example.org" in path

    def test_network_error(self, capsys):
        """Test returns None on network error."""
        with patch.object(oauth2_microsoft, "_fetch_json_https", side_effect=OSError("Connection refused")):
            result = oauth2_microsoft.discover_tenant("user@invalid.example")

        assert result is None
        captured = capsys.readouterr()
        assert "Could not discover" in captured.out

    def test_invalid_json(self, capsys):
        """Test returns None on invalid JSON response."""
        with patch.object(
            oauth2_microsoft,
            "_fetch_json_https",
            side_effect=json.JSONDecodeError("Expecting value", "not json", 0),
        ):
            result = oauth2_microsoft.discover_tenant("user@test.com")

        assert result is None

    def test_no_tenant_in_issuer(self, capsys):
        """Test returns None when issuer has no tenant GUID."""
        data = {"issuer": "https://sts.windows.net/not-a-guid/"}

        with patch.object(oauth2_microsoft, "_fetch_json_https", return_value=data):
            result = oauth2_microsoft.discover_tenant("user@test.com")

        assert result is None
        captured = capsys.readouterr()
        assert "Could not extract tenant ID" in captured.out

    def test_tenant_caching(self):
        """Test that discovered tenant IDs are cached."""
        tenant_id = "12345678-abcd-ef01-2345-67890abcdef0"
        data = {"issuer": f"https://sts.windows.net/{tenant_id}/"}

        with patch.object(oauth2_microsoft, "_fetch_json_https", return_value=data) as mock_fetch:
            # First call
            result1 = oauth2_microsoft.discover_tenant("user@example.com")
            # Second call with same domain
            result2 = oauth2_microsoft.discover_tenant("another@example.com")

        assert result1 == tenant_id
        assert result2 == tenant_id
        # Should only fetch once due to caching
        assert mock_fetch.call_count == 1

    def test_discovery_env_override(self, mock_oauth_server):
        """Test discovery uses OAUTH2_MICROSOFT_DISCOVERY_URL when set."""
        with temp_env({"OAUTH2_MICROSOFT_DISCOVERY_URL": mock_oauth_server}):
            result = oauth2_microsoft.discover_tenant("user@example.org")

        assert result == MOCK_TENANT_ID


class TestAcquireToken:
    """Tests for acquire_token function."""

    def test_successful_token(self):
        """Test successful token acquisition with auto-discovery."""
        with patch.object(oauth2_microsoft, "discover_tenant", return_value="tenant-123"):
            mock_msal = MagicMock()
            mock_app = MagicMock()
            mock_app.get_accounts.return_value = []
            mock_app.initiate_device_flow.return_value = {"user_code": "ABC123", "message": "Go to..."}
            mock_app.acquire_token_by_device_flow.return_value = {"access_token": "test_token"}
            mock_msal.PublicClientApplication.return_value = mock_app

            with patch.dict("sys.modules", {"msal": mock_msal}):
                result = oauth2_microsoft.acquire_token("client-id", "user@test.com")

            assert result == "test_token"

    def test_custom_authority_url(self):
        """Test custom authority URL from environment variable."""
        custom_base = "https://custom.login.example.com"
        tenant_id = "tenant-123"
        expected_authority = f"{custom_base}/{tenant_id}"

        with patch.object(oauth2_microsoft, "discover_tenant", return_value=tenant_id):
            mock_msal = MagicMock()
            mock_app = MagicMock()
            mock_app.get_accounts.return_value = []
            mock_app.initiate_device_flow.return_value = {"user_code": "ABC", "message": "msg"}
            mock_app.acquire_token_by_device_flow.return_value = {"access_token": "token"}
            mock_msal.PublicClientApplication.return_value = mock_app

            with patch.dict("sys.modules", {"msal": mock_msal}):
                with temp_env({"OAUTH2_MICROSOFT_AUTHORITY_BASE_URL": custom_base}):
                    oauth2_microsoft.acquire_token("client-id", "user@test.com")

            mock_msal.PublicClientApplication.assert_called_with("client-id", authority=expected_authority)

    def test_tenant_discovery_failure(self, capsys):
        """Test returns None when tenant discovery fails."""
        with patch.object(oauth2_microsoft, "discover_tenant", return_value=None):
            result = oauth2_microsoft.acquire_token("client-id", "user@test.com")

        assert result is None

    def test_cached_token(self):
        """Test returns cached token when available."""
        with patch.object(oauth2_microsoft, "discover_tenant", return_value="tenant-123"):
            mock_msal = MagicMock()
            mock_app = MagicMock()
            mock_account = {"username": "user@test.com"}
            mock_app.get_accounts.return_value = [mock_account]
            mock_app.acquire_token_silent.return_value = {"access_token": "cached_token"}
            mock_msal.PublicClientApplication.return_value = mock_app

            with patch.dict("sys.modules", {"msal": mock_msal}):
                result = oauth2_microsoft.acquire_token("client-id", "user@test.com")

            assert result == "cached_token"

    def test_msal_app_cached_on_first_call(self):
        """Test MSAL app is cached after first call."""
        with patch.object(oauth2_microsoft, "discover_tenant", return_value="tenant-123"):
            mock_msal = MagicMock()
            mock_app = MagicMock()
            mock_app.get_accounts.return_value = []
            mock_app.initiate_device_flow.return_value = {"user_code": "ABC", "message": "Go to..."}
            mock_app.acquire_token_by_device_flow.return_value = {"access_token": "token1"}
            mock_msal.PublicClientApplication.return_value = mock_app

            with patch.dict("sys.modules", {"msal": mock_msal}):
                oauth2_microsoft.acquire_token("client-id", "user@test.com")

            assert ("client-id", "tenant-123") in oauth2_microsoft._msal_app_cache

    def test_cached_app_reused_on_second_call(self):
        """Test second call reuses cached MSAL app instead of creating new one."""
        with patch.object(oauth2_microsoft, "discover_tenant", return_value="tenant-123"):
            mock_msal = MagicMock()
            mock_app = MagicMock()
            mock_app.get_accounts.return_value = []
            mock_app.initiate_device_flow.return_value = {"user_code": "ABC", "message": "Go to..."}
            mock_app.acquire_token_by_device_flow.return_value = {"access_token": "token1"}
            mock_msal.PublicClientApplication.return_value = mock_app

            with patch.dict("sys.modules", {"msal": mock_msal}):
                oauth2_microsoft.acquire_token("client-id", "user@test.com")

                # Second call — simulate cached token available (refresh token worked)
                mock_account = {"username": "user@test.com"}
                mock_app.get_accounts.return_value = [mock_account]
                mock_app.acquire_token_silent.return_value = {"access_token": "refreshed_token"}

                result = oauth2_microsoft.acquire_token("client-id", "user@test.com")

            assert result == "refreshed_token"
            # PublicClientApplication should only have been called once (first call)
            assert mock_msal.PublicClientApplication.call_count == 1

    def test_missing_msal_library(self):
        """Test exits when msal package is not installed."""
        with patch.object(oauth2_microsoft, "discover_tenant", return_value="tenant-123"):
            with patch.dict("sys.modules", {"msal": None}):
                with pytest.raises(SystemExit):
                    oauth2_microsoft.acquire_token("client-id", "user@test.com")

    def test_no_token_in_response(self):
        """Test returns None when MSAL response has no access_token."""
        with patch.object(oauth2_microsoft, "discover_tenant", return_value="tenant-123"):
            mock_msal = MagicMock()
            mock_app = MagicMock()
            mock_app.get_accounts.return_value = []
            mock_app.initiate_device_flow.return_value = {"user_code": "ABC", "message": "Go to..."}
            mock_app.acquire_token_by_device_flow.return_value = {}  # No access_token
            mock_msal.PublicClientApplication.return_value = mock_app

            with patch.dict("sys.modules", {"msal": mock_msal}):
                result = oauth2_microsoft.acquire_token("client-id", "user@test.com")

            assert result is None


class TestFetchJsonHttps:
    """Tests for internal _fetch_json_https function."""

    def test_invalid_host_raises_value_error(self):
        """Test invalid host raises ValueError."""
        # Test empty host
        with pytest.raises(ValueError, match="Invalid host"):
            oauth2_microsoft._fetch_json_https("", "/path")

        # Test None host
        with pytest.raises(ValueError, match="Invalid host"):
            oauth2_microsoft._fetch_json_https(None, "/path")

        # Test host with newline
        with pytest.raises(ValueError, match="Invalid host"):
            oauth2_microsoft._fetch_json_https("api.example.com\n", "/path")

    def test_path_normalization(self):
        """Test path missing leading slash is corrected."""
        mock_response = MagicMock()
        mock_response.status = 200
        mock_response.read.return_value = b'{"key": "value"}'

        mock_conn = MagicMock()
        mock_conn.getresponse.return_value = mock_response

        with patch("http.client.HTTPSConnection", return_value=mock_conn):
            # Pass path without '/'
            result = oauth2_microsoft._fetch_json_https("api.example.com", "my/resource")

            # Check request called with normalized path
            mock_conn.request.assert_called_with("GET", "/my/resource", headers={"Accept": "application/json"})
            assert result == {"key": "value"}

    def test_https_ssl_context(self):
        """Test SSL context is created and passed to HTTPSConnection."""
        mock_response = MagicMock()
        mock_response.status = 200
        mock_response.read.return_value = b"{}"

        mock_conn = MagicMock()
        mock_conn.getresponse.return_value = mock_response

        mock_context = MagicMock()

        with patch("ssl.create_default_context", return_value=mock_context) as mock_create_context:
            with patch("http.client.HTTPSConnection", return_value=mock_conn) as mock_https:
                oauth2_microsoft._fetch_json_https("example.com", "/")

                # Verify SSL context creation
                mock_create_context.assert_called_once()

                # Verify context passed to connection
                mock_https.assert_called_with("example.com", timeout=10, context=mock_context)
