"""Tests for consolidated auth module (auth.py).

Verifies that auth.py acts as a thin re-export layer over config.py and oauth.py.
"""

import json
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from autodebug import auth
from api.auth import JWKSCache


class TestAuthReExports:
    """Verify auth module re-exports from config."""

    def test_clear_credentials_is_from_config(self):
        """clear_credentials should delegate to config.clear_credentials."""
        from autodebug.config import clear_credentials as config_clear
        assert auth.clear_credentials is config_clear

    def test_get_api_base_is_from_config(self):
        """get_api_base should delegate to config.get_api_base."""
        from autodebug.config import get_api_base as config_get_api_base
        assert auth.get_api_base is config_get_api_base

    def test_get_valid_token_is_from_config(self):
        """get_valid_token should delegate to config.get_valid_token."""
        from autodebug.config import get_valid_token as config_get_valid_token
        assert auth.get_valid_token is config_get_valid_token

    def test_is_authenticated_is_from_config(self):
        """is_authenticated should delegate to config.is_authenticated."""
        from autodebug.config import is_authenticated as config_is_authenticated
        assert auth.is_authenticated is config_is_authenticated

    def test_load_credentials_is_from_config(self):
        """load_credentials should delegate to config.load_credentials."""
        from autodebug.config import load_credentials as config_load_credentials
        assert auth.load_credentials is config_load_credentials


class TestAuthSaveCredentials:
    """Test save_credentials merges with existing."""

    def test_save_credentials_merges(self, tmp_path, monkeypatch):
        """save_credentials should merge with existing credentials."""
        creds_file = tmp_path / "credentials.json"
        creds_file.write_text(json.dumps({"existing_key": "existing_value"}))

        monkeypatch.setattr("autodebug.config.CREDENTIALS_PATH", creds_file)
        monkeypatch.setattr("autodebug.config.CONFIG_DIR", tmp_path)

        auth.save_credentials({"token": "new-token"})

        saved = json.loads(creds_file.read_text())
        assert saved["token"] == "new-token"
        assert saved["existing_key"] == "existing_value"

    def test_save_token_saves_token_key(self, tmp_path, monkeypatch):
        """save_token should save under the 'token' key."""
        creds_file = tmp_path / "credentials.json"
        creds_file.write_text("{}")

        monkeypatch.setattr("autodebug.config.CREDENTIALS_PATH", creds_file)
        monkeypatch.setattr("autodebug.config.CONFIG_DIR", tmp_path)

        auth.save_token("my-test-token")

        saved = json.loads(creds_file.read_text())
        assert saved["token"] == "my-test-token"


class TestAuthGetCredentials:
    """Test get_credentials wrapper."""

    def test_returns_none_when_empty(self, tmp_path, monkeypatch):
        """get_credentials returns None when no credentials exist."""
        creds_file = tmp_path / "credentials.json"
        # Don't create the file - simulates no credentials
        monkeypatch.setattr("autodebug.config.CREDENTIALS_PATH", creds_file)

        result = auth.get_credentials()
        assert result is None

    def test_returns_dict_when_populated(self, tmp_path, monkeypatch):
        """get_credentials returns dict when credentials exist."""
        creds_file = tmp_path / "credentials.json"
        creds_file.write_text(json.dumps({"token": "abc"}))
        monkeypatch.setattr("autodebug.config.CREDENTIALS_PATH", creds_file)

        result = auth.get_credentials()
        assert result is not None
        assert result["token"] == "abc"


class TestAuthLoadConfig:
    """Test load_config merging."""

    def test_load_config_includes_api_base(self, monkeypatch):
        """load_config should always include api_base."""
        monkeypatch.setattr("autodebug.config.CREDENTIALS_PATH", Path("/nonexistent"))
        monkeypatch.setattr("autodebug.auth.CONFIG_FILE", Path("/nonexistent"))
        monkeypatch.delenv("HIKAFLOW_ENDPOINT", raising=False)

        config = auth.load_config()
        assert "api_base" in config

    def test_load_config_merges_config_file(self, tmp_path, monkeypatch):
        """load_config should merge config file settings."""
        config_file = tmp_path / "config.json"
        config_file.write_text(json.dumps({"custom_key": "custom_value"}))
        creds_file = tmp_path / "credentials.json"
        creds_file.write_text("{}")

        monkeypatch.setattr("autodebug.auth.CONFIG_FILE", config_file)
        monkeypatch.setattr("autodebug.config.CREDENTIALS_PATH", creds_file)

        config = auth.load_config()
        assert config.get("custom_key") == "custom_value"

    def test_load_config_merges_credentials(self, tmp_path, monkeypatch):
        """load_config should merge credential values."""
        config_file = tmp_path / "config.json"
        # Config file doesn't exist
        creds_file = tmp_path / "credentials.json"
        creds_file.write_text(json.dumps({"token": "xyz", "email": "user@test.com"}))

        monkeypatch.setattr("autodebug.auth.CONFIG_FILE", config_file)
        monkeypatch.setattr("autodebug.config.CREDENTIALS_PATH", creds_file)

        config = auth.load_config()
        assert config.get("token") == "xyz"
        assert config.get("email") == "user@test.com"


class TestAuthPathConstants:
    """Test backward compatibility path constants."""

    def test_hikaflow_dir_exists(self):
        assert auth.HIKAFLOW_DIR == Path.home() / ".hikaflow"

    def test_credentials_file_path(self):
        assert auth.CREDENTIALS_FILE == Path.home() / ".hikaflow" / "credentials.json"

    def test_config_file_path(self):
        assert auth.CONFIG_FILE == Path.home() / ".hikaflow" / "config.json"


class TestAuthAsyncFlows:
    """Test async auth flow wrappers."""

    @pytest.mark.asyncio
    async def test_browser_auth_flow_delegates(self, monkeypatch):
        """browser_auth_flow should delegate to oauth.pkce_browser_flow."""
        mock_flow = MagicMock(return_value=(True, ""))
        monkeypatch.setattr("autodebug.oauth.pkce_browser_flow", mock_flow)

        result = await auth.browser_auth_flow()
        mock_flow.assert_called_once()

    @pytest.mark.asyncio
    async def test_device_auth_flow_delegates(self, monkeypatch):
        """device_auth_flow should delegate to oauth.device_auth_flow."""
        mock_flow = MagicMock(return_value=(True, ""))
        monkeypatch.setattr("autodebug.oauth.device_auth_flow", mock_flow)

        result = await auth.device_auth_flow()
        mock_flow.assert_called_once()

    @pytest.mark.asyncio
    async def test_refresh_token_delegates(self, monkeypatch):
        """refresh_token should use config.get_valid_token."""
        monkeypatch.setattr("autodebug.auth.get_valid_token", lambda: "valid-token")

        result = await auth.refresh_token()
        assert result is True

    @pytest.mark.asyncio
    async def test_refresh_token_returns_false_on_none(self, monkeypatch):
        """refresh_token returns False when token refresh fails."""
        monkeypatch.setattr("autodebug.auth.get_valid_token", lambda: None)

        result = await auth.refresh_token()
        assert result is False


class TestJWKSCacheUsesSession:
    """Verify JWKS fetching uses requests.Session for proper resource cleanup."""

    def test_get_jwks_uses_session_context_manager(self):
        """requests.Session should be used as a context manager for connection cleanup."""
        cache = JWKSCache()
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {"keys": [{"kid": "k1"}]}
        mock_resp.raise_for_status = MagicMock()

        mock_session = MagicMock()
        mock_session.get.return_value = mock_resp
        mock_session.__enter__ = MagicMock(return_value=mock_session)
        mock_session.__exit__ = MagicMock(return_value=False)

        with patch("api.auth.requests.Session", return_value=mock_session):
            result = cache.get_jwks("https://clerk.example.com")

        mock_session.__enter__.assert_called_once()
        mock_session.__exit__.assert_called_once()
        mock_session.get.assert_called_once_with(
            "https://clerk.example.com/.well-known/jwks.json", timeout=10
        )
        assert result == {"keys": [{"kid": "k1"}]}

    def test_get_jwks_falls_back_to_cache_on_failure(self):
        """On fetch failure, cached JWKS should be returned."""
        cache = JWKSCache()
        # Pre-populate cache
        cache._cache["https://clerk.example.com"] = {"keys": [{"kid": "cached"}]}
        cache._timestamps["https://clerk.example.com"] = 0  # expired

        mock_session = MagicMock()
        mock_session.get.side_effect = ConnectionError("network down")
        mock_session.__enter__ = MagicMock(return_value=mock_session)
        mock_session.__exit__ = MagicMock(return_value=False)

        with patch("api.auth.requests.Session", return_value=mock_session):
            result = cache.get_jwks("https://clerk.example.com")

        assert result == {"keys": [{"kid": "cached"}]}


class TestExtractPermissionsScpString:
    """Fix 2: scp claim as space-separated string should be parsed."""

    def test_scp_as_list(self):
        from api.auth import _extract_permissions
        user = {"scp": ["read", "write"]}
        assert _extract_permissions(user) == {"read", "write"}

    def test_scp_as_string(self):
        from api.auth import _extract_permissions
        user = {"scp": "read write admin"}
        assert _extract_permissions(user) == {"read", "write", "admin"}

    def test_scp_string_with_extra_spaces(self):
        from api.auth import _extract_permissions
        user = {"scp": "  read   write  "}
        assert _extract_permissions(user) == {"read", "write"}

    def test_scp_empty_string(self):
        from api.auth import _extract_permissions
        user = {"scp": ""}
        assert _extract_permissions(user) == set()


class TestEmptyBearerToken:
    """Fix 3: 'Bearer ' with no token should raise clear error."""

    def test_empty_token_raises_value_error(self):
        from api.auth import verify_authorization
        with pytest.raises(ValueError, match="Empty bearer token"):
            verify_authorization("Bearer ", None)

    def test_whitespace_only_after_bearer_raises(self):
        from api.auth import verify_authorization
        # "Bearer  " splits to " " which is truthy but not a valid token
        # This will fail in JWT decode, which is acceptable
        # The key fix is "Bearer " -> empty string case
        with pytest.raises(ValueError, match="Empty bearer token"):
            verify_authorization("Bearer ", None)


class TestMalformedTokenHandling:
    """Fix 4: Garbage tokens should raise ValueError, not DecodeError."""

    def test_garbage_token_raises_value_error(self):
        from api.auth import verify_clerk_jwt
        with pytest.raises(ValueError, match="Malformed token"):
            verify_clerk_jwt("not-a-jwt-at-all")

    def test_empty_string_raises_value_error(self):
        from api.auth import verify_clerk_jwt
        with pytest.raises(ValueError, match="Malformed token"):
            verify_clerk_jwt("")


class TestJWKSSingleflight:
    """Fix 7: Concurrent requests for the same issuer coalesce into one fetch."""

    def test_concurrent_fetches_coalesce(self):
        import threading

        cache = JWKSCache()
        fetch_count = 0

        mock_resp = MagicMock()
        mock_resp.json.return_value = {"keys": [{"kid": "k1"}]}
        mock_resp.raise_for_status = MagicMock()

        def slow_get(url, timeout=10):
            nonlocal fetch_count
            fetch_count += 1
            import time
            time.sleep(0.05)
            return mock_resp

        mock_session = MagicMock()
        mock_session.get = slow_get
        mock_session.__enter__ = MagicMock(return_value=mock_session)
        mock_session.__exit__ = MagicMock(return_value=False)

        results = [None, None, None]

        def fetch(idx):
            results[idx] = cache.get_jwks("https://clerk.example.com")

        # Patch once outside threads to avoid concurrent patch/unpatch corruption
        with patch("api.auth.requests.Session", return_value=mock_session):
            threads = [threading.Thread(target=fetch, args=(i,)) for i in range(3)]
            for t in threads:
                t.start()
            for t in threads:
                t.join(timeout=5)

        # All should get valid results
        for r in results:
            assert r == {"keys": [{"kid": "k1"}]}
        # At most 2 fetches (first thread fetches, others may coalesce or retry once)
        assert fetch_count <= 2


class TestJWKSCacheBounded:
    """Fix 8: Cache evicts oldest entry when max_issuers is reached."""

    def test_evicts_oldest_when_full(self):
        cache = JWKSCache(max_issuers=2)

        mock_resp = MagicMock()
        mock_resp.raise_for_status = MagicMock()

        def make_session(kid):
            resp = MagicMock()
            resp.json.return_value = {"keys": [{"kid": kid}]}
            resp.raise_for_status = MagicMock()
            s = MagicMock()
            s.get.return_value = resp
            s.__enter__ = MagicMock(return_value=s)
            s.__exit__ = MagicMock(return_value=False)
            return s

        # Fill cache with 2 issuers
        with patch("api.auth.requests.Session", return_value=make_session("k1")):
            cache.get_jwks("https://issuer1.com")
        with patch("api.auth.requests.Session", return_value=make_session("k2")):
            cache.get_jwks("https://issuer2.com")

        assert len(cache._cache) == 2

        # Add a third — should evict the oldest
        with patch("api.auth.requests.Session", return_value=make_session("k3")):
            cache.get_jwks("https://issuer3.com")

        assert len(cache._cache) == 2
        assert "https://issuer3.com" in cache._cache
        # issuer1 was oldest, should be evicted
        assert "https://issuer1.com" not in cache._cache


class TestVerifyTokenRunsInThread:
    """verify_token should offload blocking JWT verification to a thread."""

    @pytest.mark.asyncio
    async def test_verify_token_uses_to_thread(self):
        """verify_token should call verify_authorization via asyncio.to_thread."""
        fake_payload = {"sub": "user_123", "org_id": "org_abc"}

        with patch("api.auth.asyncio.to_thread", return_value=fake_payload) as mock_to_thread:
            from api.auth import verify_token

            result = await verify_token(authorization="Bearer fake-token", request=None)

        mock_to_thread.assert_called_once()
        args = mock_to_thread.call_args
        assert args[0][0].__name__ == "verify_authorization"
        assert args[0][1] == "Bearer fake-token"
        assert result == fake_payload

    @pytest.mark.asyncio
    async def test_verify_token_propagates_errors(self):
        """Errors from verify_authorization should become 401 HTTPException."""
        from fastapi import HTTPException

        with patch("api.auth.asyncio.to_thread", side_effect=ValueError("bad token")):
            from api.auth import verify_token

            with pytest.raises(HTTPException) as exc_info:
                await verify_token(authorization="Bearer bad", request=None)
            assert exc_info.value.status_code == 401
