"""
tests/test_auth.py — Tests for Cortex JWT + API key authentication (#542, #737).

Covers:
    - Valid JWT → correct owner_id extracted
    - Expired JWT → AuthError
    - Tampered JWT (wrong signature) → AuthError
    - Valid API key → success
    - Invalid API key → AuthError
    - Open access mode (no secrets configured) → success with 'default' owner
    - Authorization: Bearer header parsing
    - X-Cortex-API-Key header parsing
    - X-API-Key legacy header parsing
    - Public endpoints skip auth
    - Constant-time comparison used for both JWT sig and API key
    - FastAPI integration: 401 when auth fails and require_auth=True
    - request.state.owner_id is set correctly by verify_api_key dependency
    - flyio-sync lookup resolves sub UUID → cortex_owner_id (#737)
    - In-memory cache with TTL prevents redundant flyio-sync calls (#737)
    - Fallback to sub UUID when flyio-sync returns null or errors (#737)
"""
from __future__ import annotations

import base64
import hashlib
import hmac
import json
import os
import time
from typing import Any, Dict, Optional
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from cortex.api.auth import AuthError, AuthProvider, AuthResult, _b64url_decode, _b64url_encode


# ---------------------------------------------------------------------------
# JWT test helpers
# ---------------------------------------------------------------------------

def _b64url_encode_str(s: str) -> str:
    """Base64url-encode a UTF-8 string."""
    return _b64url_encode(s.encode())


def make_test_jwt(payload: dict, secret: str, algorithm: str = "HS256") -> str:
    """
    Create a minimal HS256 JWT for testing.

    Args:
        payload: Claims dict (must include at least 'sub' and 'exp').
        secret:  HMAC secret key.

    Returns:
        Dot-separated JWT string.
    """
    header = _b64url_encode_str(json.dumps({"alg": algorithm, "typ": "JWT"}))
    body = _b64url_encode_str(json.dumps(payload))
    signing_input = f"{header}.{body}".encode()
    sig_bytes = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()
    sig = _b64url_encode(sig_bytes)
    return f"{header}.{body}.{sig}"


def _future_exp(seconds: int = 3600) -> int:
    return int(time.time()) + seconds


def _past_exp(seconds: int = 3600) -> int:
    return int(time.time()) - seconds


# ---------------------------------------------------------------------------
# AuthResult dataclass
# ---------------------------------------------------------------------------

class TestAuthResult:
    def test_fields(self):
        r = AuthResult(owner_id="user-123", method="jwt", plan="pro")
        assert r.owner_id == "user-123"
        assert r.method == "jwt"
        assert r.plan == "pro"

    def test_plan_defaults_to_none(self):
        r = AuthResult(owner_id="u", method="open")
        assert r.plan is None


# ---------------------------------------------------------------------------
# JWT validation
# ---------------------------------------------------------------------------

class TestJWTValidation:
    SECRET = "test-jwt-secret-supersecret"

    def _provider(self) -> AuthProvider:
        return AuthProvider(jwt_secret=self.SECRET)

    def test_valid_jwt_returns_correct_owner_id(self):
        jwt = make_test_jwt({"sub": "user-uuid-abc", "exp": _future_exp()}, self.SECRET)
        result = self._provider().validate_jwt(jwt)
        assert result.owner_id == "user-uuid-abc"
        assert result.method == "jwt"
        assert result.plan is None

    def test_valid_jwt_with_plan_claim(self):
        jwt = make_test_jwt(
            {"sub": "user-uuid-pro", "exp": _future_exp(), "plan": "pro"},
            self.SECRET,
        )
        result = self._provider().validate_jwt(jwt)
        assert result.owner_id == "user-uuid-pro"
        assert result.plan == "pro"

    def test_valid_jwt_with_user_plan_claim(self):
        """Supabase custom claims may use 'user_plan' instead of 'plan'."""
        jwt = make_test_jwt(
            {"sub": "user-uuid-scale", "exp": _future_exp(), "user_plan": "scale"},
            self.SECRET,
        )
        result = self._provider().validate_jwt(jwt)
        assert result.plan == "scale"

    def test_expired_jwt_raises_auth_error(self):
        jwt = make_test_jwt({"sub": "user-xyz", "exp": _past_exp()}, self.SECRET)
        with pytest.raises(AuthError, match="expired"):
            self._provider().validate_jwt(jwt)

    def test_tampered_jwt_wrong_signature_raises_auth_error(self):
        jwt = make_test_jwt({"sub": "user-xyz", "exp": _future_exp()}, self.SECRET)
        # Tamper: replace last char of signature
        parts = jwt.split(".")
        parts[2] = parts[2][:-1] + ("A" if parts[2][-1] != "A" else "B")
        tampered = ".".join(parts)
        with pytest.raises(AuthError, match="signature"):
            self._provider().validate_jwt(tampered)

    def test_tampered_jwt_wrong_secret_raises_auth_error(self):
        jwt = make_test_jwt({"sub": "user-xyz", "exp": _future_exp()}, self.SECRET)
        provider = AuthProvider(jwt_secret="wrong-secret")
        with pytest.raises(AuthError, match="signature"):
            provider.validate_jwt(jwt)

    def test_invalid_jwt_format_missing_parts(self):
        with pytest.raises(AuthError, match="format"):
            self._provider().validate_jwt("only.two")

    def test_jwt_missing_sub_claim(self):
        jwt = make_test_jwt({"exp": _future_exp()}, self.SECRET)
        with pytest.raises(AuthError, match="sub"):
            self._provider().validate_jwt(jwt)

    def test_jwt_missing_exp_claim(self):
        jwt = make_test_jwt({"sub": "user-xyz"}, self.SECRET)
        with pytest.raises(AuthError, match="exp"):
            self._provider().validate_jwt(jwt)

    def test_jwt_owner_resolved_via_flyio_sync(self):
        """JWT sub UUID is resolved to cortex_owner_id via flyio-sync (#737)."""
        from cortex.api.auth import _owner_id_cache
        _owner_id_cache.clear()  # Ensure clean cache state

        jwt = make_test_jwt({"sub": "uuid-12345", "exp": _future_exp()}, self.SECRET)
        provider = AuthProvider(jwt_secret=self.SECRET, flyio_sync_secret="sync-secret")

        mock_response_data = json.dumps({"cortex_owner_id": "andrew-main"}).encode()
        mock_resp = MagicMock()
        mock_resp.read.return_value = mock_response_data
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)

        with patch("urllib.request.urlopen", return_value=mock_resp), \
             patch.dict(os.environ, {"SUPABASE_URL": "https://rhfojelkgtwcxnrfhtlj.supabase.co"}):
            result = provider.validate_jwt(jwt)

        assert result.owner_id == "andrew-main"
        assert result.method == "jwt"

    def test_jwt_flyio_sync_returns_null_falls_back_to_sub(self):
        """flyio-sync returning null → fall back to sub UUID (#737)."""
        from cortex.api.auth import _owner_id_cache
        _owner_id_cache.clear()

        jwt = make_test_jwt({"sub": "uuid-67890", "exp": _future_exp()}, self.SECRET)
        provider = AuthProvider(jwt_secret=self.SECRET, flyio_sync_secret="sync-secret")

        mock_response_data = json.dumps({"cortex_owner_id": None}).encode()
        mock_resp = MagicMock()
        mock_resp.read.return_value = mock_response_data
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)

        with patch("urllib.request.urlopen", return_value=mock_resp), \
             patch.dict(os.environ, {"SUPABASE_URL": "https://rhfojelkgtwcxnrfhtlj.supabase.co"}):
            result = provider.validate_jwt(jwt)

        assert result.owner_id == "uuid-67890"

    def test_jwt_flyio_sync_error_falls_back_to_sub(self):
        """flyio-sync network error → fall back to sub UUID (#737)."""
        from cortex.api.auth import _owner_id_cache
        _owner_id_cache.clear()

        jwt = make_test_jwt({"sub": "uuid-fallback", "exp": _future_exp()}, self.SECRET)
        provider = AuthProvider(jwt_secret=self.SECRET, flyio_sync_secret="sync-secret")

        with patch("urllib.request.urlopen", side_effect=Exception("timeout")), \
             patch.dict(os.environ, {"SUPABASE_URL": "https://rhfojelkgtwcxnrfhtlj.supabase.co"}):
            result = provider.validate_jwt(jwt)

        assert result.owner_id == "uuid-fallback"

    def test_jwt_no_flyio_sync_secret_falls_back_to_sub(self):
        """No FLYIO_SYNC_SECRET configured → sub UUID used directly, no HTTP call (#737)."""
        from cortex.api.auth import _owner_id_cache
        _owner_id_cache.clear()

        jwt = make_test_jwt({"sub": "uuid-no-sync", "exp": _future_exp()}, self.SECRET)
        provider = AuthProvider(jwt_secret=self.SECRET, flyio_sync_secret=None)

        with patch("urllib.request.urlopen") as mock_urlopen:
            result = provider.validate_jwt(jwt)
            mock_urlopen.assert_not_called()

        assert result.owner_id == "uuid-no-sync"

    def test_jwt_owner_id_cached_second_call_skips_flyio_sync(self):
        """Second JWT validation for same sub uses cache, no HTTP call (#737)."""
        from cortex.api.auth import _owner_id_cache
        _owner_id_cache.clear()

        sub = "uuid-cached-99"
        jwt = make_test_jwt({"sub": sub, "exp": _future_exp()}, self.SECRET)
        provider = AuthProvider(jwt_secret=self.SECRET, flyio_sync_secret="sync-secret")

        mock_response_data = json.dumps({"cortex_owner_id": "cached-user"}).encode()
        mock_resp = MagicMock()
        mock_resp.read.return_value = mock_response_data
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)

        with patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen, \
             patch.dict(os.environ, {"SUPABASE_URL": "https://rhfojelkgtwcxnrfhtlj.supabase.co"}):
            result1 = provider.validate_jwt(jwt)
            result2 = provider.validate_jwt(jwt)
            # Only one HTTP call despite two validate_jwt calls
            assert mock_urlopen.call_count == 1

        assert result1.owner_id == "cached-user"
        assert result2.owner_id == "cached-user"

    def test_jwt_owner_id_with_plan(self):
        """flyio-sync owner resolution and plan claim both work together (#737)."""
        from cortex.api.auth import _owner_id_cache
        _owner_id_cache.clear()

        jwt = make_test_jwt(
            {"sub": "uuid-pro", "exp": _future_exp(), "plan": "pro"},
            self.SECRET,
        )
        provider = AuthProvider(jwt_secret=self.SECRET, flyio_sync_secret="sync-secret")

        mock_response_data = json.dumps({"cortex_owner_id": "pro-user"}).encode()
        mock_resp = MagicMock()
        mock_resp.read.return_value = mock_response_data
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)

        with patch("urllib.request.urlopen", return_value=mock_resp), \
             patch.dict(os.environ, {"SUPABASE_URL": "https://rhfojelkgtwcxnrfhtlj.supabase.co"}):
            result = provider.validate_jwt(jwt)

        assert result.owner_id == "pro-user"
        assert result.plan == "pro"

    def test_jwt_secret_not_configured_raises_auth_error(self):
        provider = AuthProvider(jwt_secret=None)
        with pytest.raises(AuthError, match="not configured"):
            provider.validate_jwt("any.token.value")

    def test_constant_time_comparison_used(self):
        """
        Verify that hmac.compare_digest is used (constant-time).
        The implementation should call hmac.compare_digest, not ==.
        We check this by ensuring a near-match still fails (no short-circuit).
        """
        jwt = make_test_jwt({"sub": "user-xyz", "exp": _future_exp()}, self.SECRET)
        # Construct a token signed with a secret that differs only in the last byte
        # If == were used instead of compare_digest, this would be timing-vulnerable.
        bad_secret = self.SECRET[:-1] + chr(ord(self.SECRET[-1]) ^ 1)
        provider = AuthProvider(jwt_secret=bad_secret)
        with pytest.raises(AuthError):
            provider.validate_jwt(jwt)


# ---------------------------------------------------------------------------
# API key validation
# ---------------------------------------------------------------------------

class TestAPIKeyValidation:
    KEY = "my-api-key-12345"

    def _provider(self, owner: str = "test-owner") -> AuthProvider:
        return AuthProvider(api_key=self.KEY, api_key_owner=owner)

    @pytest.mark.asyncio
    async def test_valid_api_key_returns_auth_result(self):
        result = await self._provider().validate_api_key(self.KEY)
        assert result.method == "api_key"
        assert result.owner_id == "test-owner"

    @pytest.mark.asyncio
    async def test_api_key_owner_defaults_to_default(self):
        """When no api_key_owner is configured, owner_id falls back to 'default'."""
        provider = AuthProvider(api_key=self.KEY)
        result = await provider.validate_api_key(self.KEY)
        assert result.owner_id == "default"

    @pytest.mark.asyncio
    async def test_invalid_api_key_raises_auth_error(self):
        with pytest.raises(AuthError, match="API key verification service unavailable|Invalid API key|not configured"):
            await self._provider().validate_api_key("wrong-key")

    @pytest.mark.asyncio
    async def test_api_key_not_configured_raises_auth_error(self):
        provider = AuthProvider(api_key=None)
        with pytest.raises(AuthError, match="not configured"):
            await provider.validate_api_key(self.KEY)

    @pytest.mark.asyncio
    async def test_constant_time_comparison_for_api_key(self):
        """Verify near-match API key still fails — constant-time compare."""
        bad_key = self.KEY[:-1] + chr(ord(self.KEY[-1]) ^ 1)
        with pytest.raises(AuthError):
            await self._provider().validate_api_key(bad_key)


# ---------------------------------------------------------------------------
# flyio-sync API key owner resolution (#1063)
# ---------------------------------------------------------------------------

class TestFlyioSyncApiKeyOwnerResolution:
    """
    _verify_key_via_flyio_sync should prefer cortex_owner_id / owner_id from
    flyio-sync's verify_api_key response over the raw user_id UUID.

    Covers three scenarios:
    1. flyio-sync returns owner_id directly  → use it as-is
    2. flyio-sync returns only user_id UUID  → resolve via get_cortex_owner_id
    3. flyio-sync returns user_id + owner_id → prefer owner_id
    """

    KEY = "osk_test_key_abc123"

    @staticmethod
    def _make_provider() -> "AuthProvider":
        from cortex.api.auth import AuthProvider
        return AuthProvider(flyio_sync_secret="test-sync-secret")

    @staticmethod
    def _mock_flyio_response(json_body: dict, status: int = 200):
        """Return a mock httpx.Response-like object with .json() and .status_code."""
        mock_resp = MagicMock()
        mock_resp.status_code = status
        mock_resp.json.return_value = json_body
        mock_resp.text = json.dumps(json_body)
        return mock_resp

    @pytest.mark.asyncio
    async def test_flyio_returns_valid_false_raises_auth_error(self):
        """When verify_api_key returns {valid: false}, auth must fail (#1589 audit F-01)."""
        provider = self._make_provider()
        mock_resp = self._mock_flyio_response({"valid": False, "owner_id": None, "plan": None})
        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client.post = AsyncMock(return_value=mock_resp)

        env = {"FLYIO_SYNC_SECRET": "test-sync-secret"}
        with patch("cortex.api.auth._get_flyio_sync_url", return_value="http://localhost/functions/v1/flyio-sync"), \
             patch("cortex.api.auth.httpx.AsyncClient", return_value=mock_client), \
             patch.dict(os.environ, env):
            with pytest.raises(AuthError, match="Invalid API key"):
                await provider._verify_key_via_flyio_sync(self.KEY)

    @pytest.mark.asyncio
    async def test_flyio_valid_false_with_owner_id_still_rejects(self):
        """Even if owner_id is present, valid=false must reject (#1589 audit F-01)."""
        provider = self._make_provider()
        # Simulate a future flyio-sync that includes owner_id for audit logging
        mock_resp = self._mock_flyio_response({"valid": False, "owner_id": "revoked-user", "plan": "pro"})
        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client.post = AsyncMock(return_value=mock_resp)

        env = {"FLYIO_SYNC_SECRET": "test-sync-secret"}
        with patch("cortex.api.auth._get_flyio_sync_url", return_value="http://localhost/functions/v1/flyio-sync"), \
             patch("cortex.api.auth.httpx.AsyncClient", return_value=mock_client), \
             patch.dict(os.environ, env):
            with pytest.raises(AuthError, match="Invalid API key"):
                await provider._verify_key_via_flyio_sync(self.KEY)

    @pytest.mark.asyncio
    async def test_flyio_returns_owner_id_directly(self):
        """When verify_api_key response includes owner_id, use it."""
        provider = self._make_provider()
        mock_resp = self._mock_flyio_response({"valid": True, "user_id": "uuid-abc-123", "owner_id": "andrew-main"})
        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client.post = AsyncMock(return_value=mock_resp)

        env = {"FLYIO_SYNC_SECRET": "test-sync-secret"}
        with patch("cortex.api.auth._get_flyio_sync_url", return_value="http://localhost/functions/v1/flyio-sync"), \
             patch("cortex.api.auth.httpx.AsyncClient", return_value=mock_client), \
             patch.dict(os.environ, env):
            result = await provider._verify_key_via_flyio_sync(self.KEY)

        assert result.owner_id == "andrew-main"
        assert result.method == "api_key"

    @pytest.mark.asyncio
    async def test_flyio_returns_uuid_resolves_to_cortex_owner(self):
        """When verify_api_key returns only user_id UUID, resolve via _lookup_cortex_owner_id."""
        from cortex.api import auth as _auth
        provider = self._make_provider()

        verify_resp = self._mock_flyio_response({"valid": True, "user_id": "550e8400-e29b-41d4-a716-446655440000"})
        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client.post = AsyncMock(return_value=verify_resp)

        _auth._owner_id_cache.clear()
        env = {"FLYIO_SYNC_SECRET": "test-sync-secret"}
        with patch("cortex.api.auth._get_flyio_sync_url", return_value="http://localhost/functions/v1/flyio-sync"), \
             patch("cortex.api.auth.httpx.AsyncClient", return_value=mock_client), \
             patch("cortex.api.auth._lookup_cortex_owner_id", return_value="alice") as mock_lookup, \
             patch.dict(os.environ, env):
            result = await provider._verify_key_via_flyio_sync(self.KEY)

        assert result.owner_id == "alice"
        assert result.method == "api_key"
        mock_lookup.assert_called_once_with("550e8400-e29b-41d4-a716-446655440000", "test-sync-secret")

    @pytest.mark.asyncio
    async def test_flyio_prefers_owner_id_over_user_id(self):
        """When both user_id and owner_id are present, owner_id wins (no extra lookup)."""
        from cortex.api import auth as _auth
        provider = self._make_provider()

        mock_resp = self._mock_flyio_response({
            "valid": True,
            "user_id": "550e8400-e29b-41d4-a716-446655440001",
            "owner_id": "bob",
        })
        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client.post = AsyncMock(return_value=mock_resp)

        env = {"FLYIO_SYNC_SECRET": "test-sync-secret"}
        with patch("cortex.api.auth._get_flyio_sync_url", return_value="http://localhost/functions/v1/flyio-sync"), \
             patch("cortex.api.auth.httpx.AsyncClient", return_value=mock_client), \
             patch("cortex.api.auth._lookup_cortex_owner_id") as mock_lookup, \
             patch.dict(os.environ, env):
            result = await provider._verify_key_via_flyio_sync(self.KEY)

        assert result.owner_id == "bob"
        mock_lookup.assert_not_called()


# ---------------------------------------------------------------------------
# authenticate() — header dispatch
# ---------------------------------------------------------------------------

class TestAuthenticate:
    JWT_SECRET = "jwt-secret-xyz"
    API_KEY = "api-key-abc"

    def _provider(
        self,
        jwt_secret: Optional[str] = None,
        api_key: Optional[str] = None,
    ) -> AuthProvider:
        return AuthProvider(jwt_secret=jwt_secret, api_key=api_key)

    # ---- JWT path ----------------------------------------------------------

    @pytest.mark.asyncio
    async def test_authorization_bearer_uses_jwt(self):
        jwt = make_test_jwt({"sub": "jwt-user", "exp": _future_exp()}, self.JWT_SECRET)
        provider = self._provider(jwt_secret=self.JWT_SECRET)
        result = await provider.authenticate({"Authorization": f"Bearer {jwt}"})
        assert result.method == "jwt"
        assert result.owner_id == "jwt-user"

    @pytest.mark.asyncio
    async def test_bearer_token_with_jwt_secret_validates(self):
        jwt = make_test_jwt({"sub": "user-123", "exp": _future_exp()}, self.JWT_SECRET)
        provider = self._provider(jwt_secret=self.JWT_SECRET, api_key=self.API_KEY)
        result = await provider.authenticate({"Authorization": f"Bearer {jwt}"})
        assert result.method == "jwt"

    @pytest.mark.asyncio
    async def test_bearer_with_expired_jwt_raises_auth_error(self):
        jwt = make_test_jwt({"sub": "user-xyz", "exp": _past_exp()}, self.JWT_SECRET)
        provider = self._provider(jwt_secret=self.JWT_SECRET)
        with pytest.raises(AuthError):
            await provider.authenticate({"Authorization": f"Bearer {jwt}"})

    @pytest.mark.asyncio
    async def test_bearer_without_jwt_secret_falls_through_to_open(self):
        """If no JWT secret configured, Bearer token is ignored → open access."""
        fake_token = "header.payload.sig"
        provider = self._provider(jwt_secret=None, api_key=None)
        result = await provider.authenticate({"Authorization": f"Bearer {fake_token}"})
        assert result.method == "open"

    @pytest.mark.asyncio
    async def test_lowercase_authorization_header_works(self):
        """Case-insensitive header lookup."""
        jwt = make_test_jwt({"sub": "user-lower", "exp": _future_exp()}, self.JWT_SECRET)
        provider = self._provider(jwt_secret=self.JWT_SECRET)
        result = await provider.authenticate({"authorization": f"Bearer {jwt}"})
        assert result.method == "jwt"
        assert result.owner_id == "user-lower"

    # ---- API key paths -----------------------------------------------------

    @pytest.mark.asyncio
    async def test_x_cortex_api_key_header_used(self):
        provider = self._provider(api_key=self.API_KEY)
        result = await provider.authenticate({"X-Cortex-API-Key": self.API_KEY})
        assert result.method == "api_key"

    @pytest.mark.asyncio
    async def test_x_cortex_api_key_lowercase_header_works(self):
        provider = self._provider(api_key=self.API_KEY)
        result = await provider.authenticate({"x-cortex-api-key": self.API_KEY})
        assert result.method == "api_key"

    @pytest.mark.asyncio
    async def test_legacy_x_api_key_header_works(self):
        """Backward compat: old self-host setups use X-API-Key."""
        provider = self._provider(api_key=self.API_KEY)
        result = await provider.authenticate({"X-API-Key": self.API_KEY})
        assert result.method == "api_key"

    @pytest.mark.asyncio
    async def test_invalid_x_cortex_api_key_raises_auth_error(self):
        provider = self._provider(api_key=self.API_KEY)
        with pytest.raises(AuthError):
            await provider.authenticate({"X-Cortex-API-Key": "wrong-key"})

    # ---- JWT beats API key -------------------------------------------------

    @pytest.mark.asyncio
    async def test_jwt_takes_priority_over_api_key_header(self):
        jwt = make_test_jwt({"sub": "jwt-wins", "exp": _future_exp()}, self.JWT_SECRET)
        provider = self._provider(jwt_secret=self.JWT_SECRET, api_key=self.API_KEY)
        result = await provider.authenticate({
            "Authorization": f"Bearer {jwt}",
            "X-Cortex-API-Key": self.API_KEY,
        })
        assert result.method == "jwt"
        assert result.owner_id == "jwt-wins"

    # ---- Open access mode --------------------------------------------------

    @pytest.mark.asyncio
    async def test_open_access_no_secrets_configured(self):
        """When no secrets are configured, any request is open access."""
        provider = self._provider(jwt_secret=None, api_key=None)
        result = await provider.authenticate({})
        assert result.method == "open"
        assert result.owner_id == "default"

    @pytest.mark.asyncio
    async def test_open_access_no_headers_provided(self):
        """Even with secrets configured, missing credentials → open access."""
        provider = self._provider(jwt_secret=self.JWT_SECRET, api_key=self.API_KEY)
        result = await provider.authenticate({})
        assert result.method == "open"

    @pytest.mark.asyncio
    async def test_open_access_empty_headers(self):
        provider = self._provider()
        result = await provider.authenticate({})
        assert result.method == "open"
        assert result.owner_id == "default"


# ---------------------------------------------------------------------------
# FastAPI integration — http_server + verify_api_key dependency
# ---------------------------------------------------------------------------

pytest.importorskip("fastapi", reason="fastapi not installed")
pytest.importorskip("httpx", reason="httpx not installed")

from contextlib import contextmanager  # noqa: E402

from fastapi.testclient import TestClient  # noqa: E402


@contextmanager
def _make_test_client(
    jwt_secret: Optional[str] = None,
    api_key: Optional[str] = None,
    require_auth: bool = False,
    env_overrides: Optional[dict] = None,
):
    """Create a TestClient with a minimal fake plugin and configurable auth."""
    from cortex.api.http_server import create_app

    class _FakeConfig:
        owner_id: str = "test-owner"
        max_remember_body_bytes: int = 1_000_000

    class _FakeStorage:
        async def initialize(self): pass
        async def close(self): pass
        async def log_request(self, **_): pass

    class _FakePlugin:
        def __init__(self):
            self.storage = _FakeStorage()
            self.config = _FakeConfig()

    env = {}
    if jwt_secret:
        env["SUPABASE_JWT_SECRET"] = jwt_secret
    if api_key:
        env["CORTEX_API_KEY"] = api_key
    if require_auth:
        env["CORTEX_AUTH_REQUIRE"] = "true"
    if env_overrides:
        env.update(env_overrides)

    with patch.dict("os.environ", env, clear=False):
        with patch("cortex.api.plugin.CortexPlugin") as MockPlugin:
            MockPlugin.return_value = _FakePlugin()
            app = create_app()
            with TestClient(app, raise_server_exceptions=False) as client:
                yield client


class TestFastAPIAuthIntegration:
    JWT_SECRET = "integration-test-secret"
    API_KEY = "integration-api-key"

    def test_valid_jwt_bearer_allows_access(self):
        jwt = make_test_jwt({"sub": "int-user-1", "exp": _future_exp()}, self.JWT_SECRET)
        with _make_test_client(jwt_secret=self.JWT_SECRET) as client:
            resp = client.get(
                "/api/v1/health",
                headers={"Authorization": f"Bearer {jwt}"},
            )
            # Must NOT be 401 — auth passes, any other status is from the handler
            assert resp.status_code != 401

    def test_valid_api_key_header_allows_access(self):
        with _make_test_client(api_key=self.API_KEY) as client:
            resp = client.get(
                "/api/v1/health",
                headers={"X-API-Key": self.API_KEY},
            )
            # Must NOT be 401
            assert resp.status_code != 401

    def test_invalid_api_key_returns_401(self):
        with _make_test_client(api_key=self.API_KEY) as client:
            resp = client.get(
                "/api/v1/memories",
                headers={"X-API-Key": "wrong-key"},
            )
            assert resp.status_code == 401

    def test_expired_jwt_returns_401(self):
        jwt = make_test_jwt({"sub": "user-xyz", "exp": _past_exp()}, self.JWT_SECRET)
        with _make_test_client(jwt_secret=self.JWT_SECRET) as client:
            resp = client.get(
                "/api/v1/memories",
                headers={"Authorization": f"Bearer {jwt}"},
            )
            assert resp.status_code == 401

    def test_tampered_jwt_returns_401(self):
        jwt = make_test_jwt({"sub": "user-xyz", "exp": _future_exp()}, self.JWT_SECRET)
        parts = jwt.split(".")
        parts[2] = parts[2][:-1] + ("A" if parts[2][-1] != "A" else "B")
        tampered = ".".join(parts)
        with _make_test_client(jwt_secret=self.JWT_SECRET) as client:
            resp = client.get(
                "/api/v1/memories",
                headers={"Authorization": f"Bearer {tampered}"},
            )
            assert resp.status_code == 401

    def test_open_access_no_secrets_succeeds(self):
        """No secrets configured → open access, any request goes through auth gate."""
        with _make_test_client() as client:
            resp = client.get("/api/v1/health")
            # Should not be 401 — open access
            assert resp.status_code != 401

    def test_public_endpoints_skip_auth(self):
        """Health endpoint is always reachable even without credentials (JWT mode)."""
        # Public endpoints only exempt in JWT mode — use jwt_secret to activate
        with _make_test_client(jwt_secret=self.JWT_SECRET) as client:
            # /api/v1/health is in public_endpoints, no auth header needed
            resp = client.get("/api/v1/health")
            assert resp.status_code != 401

    def test_docs_endpoint_skips_auth(self):
        with _make_test_client(jwt_secret=self.JWT_SECRET) as client:
            resp = client.get("/docs")
            assert resp.status_code != 401

    def test_openapi_json_skips_auth(self):
        with _make_test_client(jwt_secret=self.JWT_SECRET) as client:
            resp = client.get("/openapi.json")
            assert resp.status_code != 401

    def test_x_cortex_api_key_header_parsed(self):
        """New X-Cortex-API-Key header is accepted by the auth gate."""
        # Use JWT mode so we can pair jwt_secret + api_key; api_key-only mode
        # accepts both X-API-Key and X-Cortex-API-Key for backward compat.
        with _make_test_client(jwt_secret=self.JWT_SECRET, api_key=self.API_KEY) as client:
            resp = client.get(
                "/api/v1/memories",
                headers={"X-Cortex-API-Key": self.API_KEY},
            )
            # Either 200 (found) or 422/404 (endpoint variation) — not 401
            assert resp.status_code != 401

    def test_missing_credentials_with_require_auth_returns_401(self):
        """When require_auth=True and no credentials, must return 401."""
        # Requires jwt_secret (JWT mode) + CORTEX_AUTH_REQUIRE=true
        with _make_test_client(
            jwt_secret=self.JWT_SECRET, require_auth=True
        ) as client:
            resp = client.get("/api/v1/memories")  # no auth header
            assert resp.status_code == 401

    def test_detail_message_on_auth_required(self):
        """401 response should include a useful detail message."""
        with _make_test_client(
            jwt_secret=self.JWT_SECRET, require_auth=True
        ) as client:
            resp = client.get("/api/v1/memories")
            assert resp.status_code == 401
            body = resp.json()
            assert "detail" in body


# Issue #593 -- API key auth owner_id resolution via X-Owner-Id header
# ---------------------------------------------------------------------------

def _make_mock_request(headers):
    """Build a minimal mock Request with Starlette-style Headers."""
    from starlette.datastructures import Headers

    req = MagicMock()
    req.url.path = "/some/endpoint"
    req.state = MagicMock(spec=[])
    req.headers = Headers(headers=headers)
    return req


class TestAPIKeyOwnerIdResolution:
    """Verify legacy API-key auth propagates owner_id from X-Owner-Id header (#593)."""

    API_KEY = "legacy-key-for-owner-tests"

    def _make_verify(self):
        from cortex.api.deps import make_auth_dependency
        return make_auth_dependency(api_key=self.API_KEY, auth_provider=None)

    @pytest.mark.asyncio
    async def test_api_key_with_x_owner_id_header_uses_header_value(self):
        """API key + X-Owner-Id header sets request.state.owner_id to header value."""
        verify = self._make_verify()
        req = _make_mock_request({"x-api-key": self.API_KEY, "x-owner-id": "alice"})
        await verify(request=req, x_api_key=self.API_KEY, x_owner_id="alice")
        assert req.state.owner_id == "alice"

    @pytest.mark.asyncio
    async def test_api_key_without_x_owner_id_uses_sentinel(self):
        """API key without X-Owner-Id header falls back to _UNAUTHENTICATED sentinel."""
        from cortex.api.deps import _UNAUTHENTICATED
        verify = self._make_verify()
        req = _make_mock_request({"x-api-key": self.API_KEY})
        await verify(request=req, x_api_key=self.API_KEY, x_owner_id=None)
        assert req.state.owner_id is _UNAUTHENTICATED

    @pytest.mark.asyncio
    async def test_api_key_with_empty_x_owner_id_uses_sentinel(self):
        """Empty X-Owner-Id is treated as absent, falls back to _UNAUTHENTICATED sentinel."""
        from cortex.api.deps import _UNAUTHENTICATED
        verify = self._make_verify()
        req = _make_mock_request({"x-api-key": self.API_KEY, "x-owner-id": ""})
        await verify(request=req, x_api_key=self.API_KEY, x_owner_id="")
        assert req.state.owner_id is _UNAUTHENTICATED

    @pytest.mark.asyncio
    async def test_invalid_api_key_with_x_owner_id_still_rejects(self):
        """Wrong API key + X-Owner-Id must raise 401."""
        from fastapi import HTTPException as _HTTPException
        verify = self._make_verify()
        req = _make_mock_request({"x-api-key": "wrong-key", "x-owner-id": "alice"})
        with pytest.raises(_HTTPException) as exc_info:
            await verify(request=req, x_api_key="wrong-key", x_owner_id="alice")
        assert exc_info.value.status_code == 401





# ---------------------------------------------------------------------------
# b64url helpers (edge cases)
# ---------------------------------------------------------------------------

class TestB64Helpers:
    def test_encode_decode_roundtrip(self):
        original = b"hello world! \x00\xff"
        encoded = _b64url_encode(original)
        decoded = _b64url_decode(encoded)
        assert decoded == original

    def test_no_padding_in_encoded(self):
        encoded = _b64url_encode(b"test")
        assert "=" not in encoded

    def test_decode_with_missing_padding(self):
        """Decoder should handle tokens without padding."""
        data = b"supabase-user-id"
        encoded = base64.urlsafe_b64encode(data).decode()
        # Strip all padding
        stripped = encoded.rstrip("=")
        assert _b64url_decode(stripped) == data
