import uuid
from datetime import UTC, datetime, timedelta
from typing import Any, ClassVar
from unittest.mock import AsyncMock, patch

import pytest
from authlib.integrations.httpx_client import AsyncOAuth2Client
from authlib.oauth2.rfc7636 import create_s256_code_challenge
from pydantic import BaseModel, SecretStr, ValidationError
from sqlalchemy.ext.asyncio import AsyncSession

from tracecat import config
from tracecat.auth.types import Role
from tracecat.authz.scopes import SERVICE_PRINCIPAL_SCOPES
from tracecat.db.models import OAuthIntegration
from tracecat.exceptions import TracecatAuthorizationError
from tracecat.integrations.enums import OAuthGrantType
from tracecat.integrations.providers.base import (
    AuthorizationCodeOAuthProvider,
    ClientCredentialsOAuthProvider,
)
from tracecat.integrations.schemas import (
    IntegrationUpdate,
    ProviderConfig,
    ProviderKey,
    ProviderMetadata,
    ProviderScopes,
)
from tracecat.integrations.service import (
    InsecureOAuthEndpointError,
    IntegrationService,
)
from tracecat.integrations.types import TokenResponse

pytestmark = pytest.mark.usefixtures("db")


# Mock OAuth Provider for testing
class MockProviderConfig(BaseModel):
    """Configuration for mock OAuth provider."""

    redirect_uri: str | None = None


class MockOAuthProvider(AuthorizationCodeOAuthProvider):
    """Mock OAuth provider for testing."""

    id: ClassVar[str] = "mock_provider"
    default_authorization_endpoint: ClassVar[str] = (  # type: ignore[assignment]
        "https://mock.provider/oauth/authorize"
    )
    default_token_endpoint: ClassVar[str] = "https://mock.provider/oauth/token"  # type: ignore[assignment]
    config_model: ClassVar[type[BaseModel]] = MockProviderConfig  # type: ignore[assignment]
    scopes: ClassVar[ProviderScopes] = ProviderScopes(
        default=["read", "write"],
    )
    metadata: ClassVar[ProviderMetadata] = ProviderMetadata(
        id="mock_provider",
        name="Mock Provider",
        description="A mock OAuth provider for testing",
        api_docs_url="https://mock.provider/docs",
        requires_config=True,
    )


class MockOAuthProviderWithPKCE(MockOAuthProvider):
    """Mock OAuth provider that uses PKCE and additional parameters."""

    id: ClassVar[str] = "mock_provider_pkce"
    metadata: ClassVar[ProviderMetadata] = ProviderMetadata(
        id="mock_provider_pkce",
        name="Mock Provider with PKCE",
        description="A mock OAuth provider with PKCE for testing",
    )

    def _use_pkce(self) -> bool:
        """Enable PKCE for this provider."""
        return True

    def _get_additional_authorize_params(self) -> dict[str, Any]:
        """Add custom authorization parameters."""
        return {"custom_auth_param": "auth_value", "audience": "test-api"}

    def _get_additional_token_params(self) -> dict[str, Any]:
        """Add custom token exchange parameters."""
        return {"custom_token_param": "token_value", "resource": "test-resource"}


class MockCCOAuthProvider(ClientCredentialsOAuthProvider):
    """Mock OAuth provider for client credentials testing."""

    id: ClassVar[str] = "mock_cc_provider"
    default_authorization_endpoint: ClassVar[str] = (  # type: ignore[assignment]
        "https://mock.provider/oauth/authorize"
    )
    default_token_endpoint: ClassVar[str] = "https://mock.provider/oauth/token"  # type: ignore[assignment]
    config_model: ClassVar[type[BaseModel]] = MockProviderConfig  # type: ignore[assignment]
    scopes: ClassVar[ProviderScopes] = ProviderScopes(
        default=["read", "write"],
    )
    metadata: ClassVar[ProviderMetadata] = ProviderMetadata(
        id="mock_cc_provider",
        name="Mock CC Provider",
        description="A mock OAuth provider for client credentials testing",
        requires_config=True,
    )


@pytest.fixture
def encryption_key(monkeypatch: pytest.MonkeyPatch) -> str:
    """Set up encryption key for testing."""
    from cryptography.fernet import Fernet

    key = Fernet.generate_key().decode()
    monkeypatch.setattr(config, "TRACECAT__DB_ENCRYPTION_KEY", key)
    return key


@pytest.fixture
async def integration_service(
    session: AsyncSession, svc_role: Role, encryption_key: str
) -> IntegrationService:
    """Create an integration service instance for testing."""
    return IntegrationService(session=session, role=svc_role)


@pytest.fixture
def mock_token_response() -> TokenResponse:
    """Create a mock token response."""
    return TokenResponse(
        access_token=SecretStr("mock_access_token"),
        refresh_token=SecretStr("mock_refresh_token"),
        expires_in=3600,
        scope="read write",
        token_type="Bearer",
    )


@pytest.fixture
def mock_provider(monkeypatch: pytest.MonkeyPatch) -> MockOAuthProvider:
    """Create a mock OAuth provider instance."""
    # Patch the config value directly (env var won't work since config is already loaded)
    monkeypatch.setattr("tracecat.config.TRACECAT__PUBLIC_APP_URL", "http://localhost")
    return MockOAuthProvider(
        client_id="mock_client_id", client_secret="mock_client_secret"
    )


@pytest.fixture
def mock_pkce_provider(
    monkeypatch: pytest.MonkeyPatch,
) -> MockOAuthProviderWithPKCE:
    """Create a mock OAuth provider instance with PKCE enabled."""
    # Patch the config value directly (env var won't work since config is already loaded)
    monkeypatch.setattr("tracecat.config.TRACECAT__PUBLIC_APP_URL", "http://localhost")
    return MockOAuthProviderWithPKCE(
        client_id="mock_client_id", client_secret="mock_client_secret"
    )


@pytest.fixture
def mock_cc_provider(monkeypatch: pytest.MonkeyPatch) -> MockCCOAuthProvider:
    """Create a mock client credentials OAuth provider instance."""
    # Patch the config value directly (env var won't work since config is already loaded)
    monkeypatch.setattr("tracecat.config.TRACECAT__PUBLIC_APP_URL", "http://localhost")
    return MockCCOAuthProvider(
        client_id="mock_cc_client_id", client_secret="mock_cc_client_secret"
    )


@pytest.mark.anyio
class TestIntegrationService:
    """Test the IntegrationService class."""

    async def test_service_initialization_requires_workspace(
        self, session: AsyncSession
    ) -> None:
        """Test that service initialization requires a workspace ID."""
        # Create a role without workspace_id
        role_without_workspace = Role(
            type="service",
            user_id=uuid.uuid4(),
            workspace_id=None,
            service_id="tracecat-service",
            scopes=SERVICE_PRINCIPAL_SCOPES["tracecat-service"],
        )

        # Attempt to create service without workspace should raise error
        with pytest.raises(TracecatAuthorizationError):
            IntegrationService(session=session, role=role_without_workspace)

    async def test_store_and_get_integration(
        self,
        integration_service: IntegrationService,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test storing and retrieving an integration."""
        provider_key = ProviderKey(
            id="test_provider",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )

        # Store integration without user_id (workspace-level)
        integration = await integration_service.store_integration(
            provider_key=provider_key,
            access_token=mock_token_response.access_token,
            refresh_token=mock_token_response.refresh_token,
            expires_in=mock_token_response.expires_in,
            scope=mock_token_response.scope,
        )

        assert integration.provider_id == provider_key.id
        assert integration.user_id is None  # workspace-level integration
        assert integration.expires_at is not None
        assert integration.scope == mock_token_response.scope
        assert integration.grant_type == OAuthGrantType.AUTHORIZATION_CODE

        # Retrieve integration
        retrieved = await integration_service.get_integration(provider_key=provider_key)
        assert retrieved is not None
        assert retrieved.id == integration.id
        assert retrieved.provider_id == provider_key.id
        assert retrieved.grant_type == provider_key.grant_type

    async def test_update_existing_integration(
        self,
        integration_service: IntegrationService,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test updating an existing integration."""
        provider_key = ProviderKey(
            id="test_provider",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )

        # Store initial integration
        initial = await integration_service.store_integration(
            provider_key=provider_key,
            access_token=SecretStr("initial_token"),
            refresh_token=SecretStr("initial_refresh"),
            expires_in=1800,
            scope="read",
        )

        # Update with new tokens (with different expiry)
        updated = await integration_service.store_integration(
            provider_key=provider_key,
            access_token=mock_token_response.access_token,
            refresh_token=mock_token_response.refresh_token,
            expires_in=7200,  # Different expiry time
            scope=mock_token_response.scope,
        )

        # Should be same integration, but updated
        assert updated.id == initial.id
        assert updated.scope == mock_token_response.scope
        # The expires_at should be different (may be >= due to timing)
        assert updated.expires_at is not None, "Expires at should not be None"
        assert initial.expires_at is not None, "Expires at should not be None"
        assert updated.expires_at >= initial.expires_at, (
            "New expiry should be greater than or equal to old expiry"
        )

    async def test_get_integration_with_filters(
        self,
        integration_service: IntegrationService,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test get_integration with various filter combinations."""
        # Create provider keys
        provider1 = ProviderKey(
            id="provider1",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )
        provider2 = ProviderKey(
            id="provider2",
            grant_type=OAuthGrantType.CLIENT_CREDENTIALS,
        )

        # Store integrations
        integration1 = await integration_service.store_integration(
            provider_key=provider1,
            access_token=mock_token_response.access_token,
            refresh_token=mock_token_response.refresh_token,
        )

        integration2 = await integration_service.store_integration(
            provider_key=provider2,
            access_token=SecretStr("cc_token"),
        )

        # Test get by provider key - should find exact match
        found = await integration_service.get_integration(provider_key=provider1)
        assert found is not None
        assert found.id == integration1.id
        assert found.provider_id == provider1.id
        assert found.grant_type == provider1.grant_type

        # Test get by different provider key
        found2 = await integration_service.get_integration(provider_key=provider2)
        assert found2 is not None
        assert found2.id == integration2.id
        assert found2.provider_id == provider2.id
        assert found2.grant_type == provider2.grant_type

        # Test get non-existent provider
        non_existent = ProviderKey(
            id="non_existent",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )
        not_found = await integration_service.get_integration(provider_key=non_existent)
        assert not_found is None

        # Test get with wrong grant type
        wrong_grant = ProviderKey(
            id="provider1",  # Same ID
            grant_type=OAuthGrantType.CLIENT_CREDENTIALS,  # Different grant type
        )
        not_found_wrong_grant = await integration_service.get_integration(
            provider_key=wrong_grant
        )
        assert not_found_wrong_grant is None

    async def test_store_integration_with_user_id(
        self,
        integration_service: IntegrationService,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test that user_id parameter is accepted and stored correctly."""
        provider_key = ProviderKey(
            id="test_provider",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )

        # Store workspace-level integration (user_id=None is tested in other tests)
        workspace_integration = await integration_service.store_integration(
            provider_key=provider_key,
            access_token=SecretStr("workspace_token"),
            user_id=None,  # No user_id = workspace-level
        )

        # Verify workspace-level integration
        assert workspace_integration.user_id is None
        assert workspace_integration.workspace_id == integration_service.workspace_id

        # Note: Testing actual user_id insertion requires foreign key setup with user table,
        # but the method signature and parameter handling is covered

    async def test_store_integration_updates_endpoints(
        self,
        integration_service: IntegrationService,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test updating stored endpoints on an existing integration."""
        provider_key = ProviderKey(
            id="test_provider",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )

        # Store initial integration with explicit endpoints
        integration = await integration_service.store_integration(
            provider_key=provider_key,
            access_token=mock_token_response.access_token,
            authorization_endpoint="https://api.v1.example.com/authorize",
            token_endpoint="https://api.v1.example.com/token",
        )

        assert (
            integration.authorization_endpoint == "https://api.v1.example.com/authorize"
        )
        assert integration.token_endpoint == "https://api.v1.example.com/token"

        # Update with new endpoints
        updated = await integration_service.store_integration(
            provider_key=provider_key,
            access_token=SecretStr("new_access_token"),
            authorization_endpoint="https://api.v2.example.com/authorize",
            token_endpoint="https://api.v2.example.com/token",
        )

        # Should be same integration but with updated endpoints
        assert updated.id == integration.id
        assert updated.authorization_endpoint == "https://api.v2.example.com/authorize"
        assert updated.token_endpoint == "https://api.v2.example.com/token"

    async def test_list_integrations(
        self,
        integration_service: IntegrationService,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test listing integrations with optional filtering."""
        # Store multiple integrations
        providers = {
            ProviderKey(
                id="provider1",
                grant_type=OAuthGrantType.AUTHORIZATION_CODE,
            ),
            ProviderKey(
                id="provider2",
                grant_type=OAuthGrantType.AUTHORIZATION_CODE,
            ),
            ProviderKey(
                id="provider3",
                grant_type=OAuthGrantType.AUTHORIZATION_CODE,
            ),
        }
        for provider_key in providers:
            await integration_service.store_integration(
                provider_key=provider_key,
                access_token=mock_token_response.access_token,
                expires_in=3600,
            )

        # List all integrations
        all_integrations = await integration_service.list_integrations()
        assert len(all_integrations) == 3

        # List filtered integrations
        filtered = await integration_service.list_integrations(
            provider_keys={
                ProviderKey(
                    id="provider1", grant_type=OAuthGrantType.AUTHORIZATION_CODE
                ),
                ProviderKey(
                    id="provider2", grant_type=OAuthGrantType.AUTHORIZATION_CODE
                ),
            }
        )
        assert len(filtered) == 2
        assert all(
            ProviderKey(id=i.provider_id, grant_type=i.grant_type) in providers
            for i in filtered
        )

    async def test_disconnect_integration(
        self,
        integration_service: IntegrationService,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test disconnecting an integration (clears tokens but keeps record)."""
        provider_key = ProviderKey(
            id="test_provider",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )

        # Store integration with tokens and scope
        integration = await integration_service.store_integration(
            provider_key=provider_key,
            access_token=mock_token_response.access_token,
            refresh_token=mock_token_response.refresh_token,
            expires_in=3600,
            scope="read write",
        )

        # Verify tokens and metadata are stored
        assert integration.encrypted_access_token != b""
        assert integration.encrypted_refresh_token is not None
        assert integration.expires_at is not None
        assert integration.scope == "read write"

        # Disconnect integration
        await integration_service.disconnect_integration(integration=integration)

        # Verify tokens are wiped but record remains
        assert integration.encrypted_access_token == b""
        assert integration.encrypted_refresh_token is None
        assert integration.expires_at is None
        assert integration.scope is None
        assert integration.requested_scopes is None

        # Verify integration still exists in database
        retrieved = await integration_service.get_integration(provider_key=provider_key)
        assert retrieved is not None
        assert retrieved.id == integration.id
        assert retrieved.provider_id == provider_key.id
        assert retrieved.grant_type == provider_key.grant_type
        assert retrieved.encrypted_access_token == b""

    async def test_disconnect_integration_with_provider_config(
        self,
        integration_service: IntegrationService,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test disconnecting an integration preserves stored endpoints."""
        provider_key = ProviderKey(
            id="test_provider",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )

        # Store provider config first
        await integration_service.store_provider_config(
            provider_key=provider_key,
            client_id="test_client_id",
            client_secret=SecretStr("test_client_secret"),
            authorization_endpoint="https://api.example.com/oauth/authorize",
            token_endpoint="https://api.example.com/oauth/token",
        )

        # Store integration with tokens
        integration = await integration_service.store_integration(
            provider_key=provider_key,
            access_token=mock_token_response.access_token,
            refresh_token=mock_token_response.refresh_token,
            scope="read write",
        )

        # Verify endpoint metadata is preserved
        original_authorization_endpoint = integration.authorization_endpoint
        original_token_endpoint = integration.token_endpoint
        original_use_workspace_creds = integration.use_workspace_credentials

        # Disconnect integration
        await integration_service.disconnect_integration(integration=integration)

        # Verify tokens are wiped but provider config is preserved
        assert integration.encrypted_access_token == b""
        assert integration.encrypted_refresh_token is None
        assert integration.scope is None
        assert integration.authorization_endpoint == original_authorization_endpoint
        assert integration.token_endpoint == original_token_endpoint
        assert integration.use_workspace_credentials == original_use_workspace_creds

    async def test_remove_integration(
        self,
        integration_service: IntegrationService,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test removing an integration (deletes entire record)."""
        provider_key = ProviderKey(
            id="test_provider",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )

        # Store integration
        integration = await integration_service.store_integration(
            provider_key=provider_key,
            access_token=mock_token_response.access_token,
        )

        # Remove integration
        await integration_service.remove_integration(integration=integration)

        # Verify it's gone
        retrieved = await integration_service.get_integration(provider_key=provider_key)
        assert retrieved is None

    async def test_remove_integration_with_provider_config(
        self,
        integration_service: IntegrationService,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test removing an integration that has provider config (deletes everything)."""
        provider_key = ProviderKey(
            id="test_provider",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )

        # Store provider config first
        await integration_service.store_provider_config(
            provider_key=provider_key,
            client_id="test_client_id",
            client_secret=SecretStr("test_client_secret"),
            authorization_endpoint="https://api.example.com/oauth/authorize",
            token_endpoint="https://api.example.com/oauth/token",
        )

        # Store integration with tokens
        integration = await integration_service.store_integration(
            provider_key=provider_key,
            access_token=mock_token_response.access_token,
            refresh_token=mock_token_response.refresh_token,
        )

        # Verify integration exists with config
        assert integration.authorization_endpoint is not None
        assert integration.token_endpoint is not None
        assert integration.use_workspace_credentials is True

        # Remove integration
        await integration_service.remove_integration(integration=integration)

        # Verify entire record is gone (including provider config)
        retrieved = await integration_service.get_integration(provider_key=provider_key)
        assert retrieved is None

    async def test_disconnect_vs_remove_integration_behavior(
        self,
        integration_service: IntegrationService,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test the difference between disconnect and remove operations."""
        # Create two identical integrations for comparison
        disconnect_provider = ProviderKey(
            id="disconnect_provider",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )
        remove_provider = ProviderKey(
            id="remove_provider",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )

        # Store first integration for disconnect test
        disconnect_integration = await integration_service.store_integration(
            provider_key=disconnect_provider,
            access_token=mock_token_response.access_token,
            refresh_token=mock_token_response.refresh_token,
            expires_in=3600,
            scope="read write",
        )

        # Store second integration for remove test
        remove_integration = await integration_service.store_integration(
            provider_key=remove_provider,
            access_token=mock_token_response.access_token,
            refresh_token=mock_token_response.refresh_token,
            expires_in=3600,
            scope="read write",
        )

        # Disconnect first integration
        await integration_service.disconnect_integration(
            integration=disconnect_integration
        )

        # Remove second integration
        await integration_service.remove_integration(integration=remove_integration)

        # Verify disconnect behavior: record exists but tokens are cleared
        disconnected = await integration_service.get_integration(
            provider_key=disconnect_provider
        )
        assert disconnected is not None
        assert disconnected.encrypted_access_token == b""
        assert disconnected.encrypted_refresh_token is None
        assert disconnected.scope is None

        # Verify remove behavior: record is completely gone
        removed = await integration_service.get_integration(
            provider_key=remove_provider
        )
        assert removed is None

    async def test_token_encryption_decryption(
        self,
        integration_service: IntegrationService,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test that tokens are properly encrypted and decrypted."""
        provider_key = ProviderKey(
            id="test_provider",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )

        # Type narrow
        assert mock_token_response.refresh_token is not None, (
            "Refresh token should not be None"
        )

        # Store integration
        integration = await integration_service.store_integration(
            provider_key=provider_key,
            access_token=mock_token_response.access_token,
            refresh_token=mock_token_response.refresh_token,
        )

        # Verify tokens are encrypted in DB
        assert (
            integration.encrypted_access_token
            != mock_token_response.access_token.get_secret_value().encode()
        )
        assert integration.encrypted_refresh_token is not None, (
            "Refresh token should not be None"
        )
        assert (
            integration.encrypted_refresh_token
            != mock_token_response.refresh_token.get_secret_value().encode()
        )

        # Get decrypted tokens
        access_token, refresh_token = integration_service.get_decrypted_tokens(
            integration
        )
        assert access_token == mock_token_response.access_token.get_secret_value()
        assert refresh_token == mock_token_response.refresh_token.get_secret_value()

        # Get access token only
        access_token_only = await integration_service.get_access_token(integration)
        assert access_token_only is not None
        assert (
            access_token_only.get_secret_value()
            == mock_token_response.access_token.get_secret_value()
        )

    async def test_token_expiration_checks(
        self,
        integration_service: IntegrationService,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test token expiration and refresh checks."""
        provider_key = ProviderKey(
            id="test_provider",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )

        # Store integration that expires in 1 hour
        integration = await integration_service.store_integration(
            provider_key=provider_key,
            access_token=mock_token_response.access_token,
            expires_in=3600,
        )

        # Should not be expired or need refresh
        assert not integration.is_expired
        assert not integration.needs_refresh

        # Manually set expiration to past (use UTC)
        integration.expires_at = datetime.now(UTC) - timedelta(hours=1)
        assert integration.is_expired
        assert integration.needs_refresh

        # Set expiration to 4 minutes in future (within refresh window)
        integration.expires_at = datetime.now(UTC) + timedelta(minutes=4)
        assert not integration.is_expired
        assert integration.needs_refresh

    async def test_refresh_token_if_not_needed(
        self,
        integration_service: IntegrationService,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test that refresh is skipped when token doesn't need refresh."""
        provider_key = ProviderKey(
            id="test_provider",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )

        # Store provider config
        await integration_service.store_provider_config(
            provider_key=provider_key,
            client_id="test_client_id",
            client_secret=SecretStr("test_client_secret"),
        )

        # Store integration with long expiry (1 hour)
        integration = await integration_service.store_integration(
            provider_key=provider_key,
            access_token=mock_token_response.access_token,
            refresh_token=mock_token_response.refresh_token,
            expires_in=3600,  # 1 hour
        )

        # Integration should not need refresh
        assert not integration.needs_refresh

        # Mock the provider registry (should not be called)
        with patch(
            "tracecat.integrations.service.get_provider_class"
        ) as mock_get_provider:
            # This should not be called since refresh is not needed
            mock_get_provider.return_value = MockOAuthProvider

            # Attempt refresh - should return immediately without refreshing
            refreshed = await integration_service.refresh_token_if_needed(integration)

            # Should be the same instance, unchanged
            assert refreshed.id == integration.id
            assert refreshed.expires_at == integration.expires_at

            # Registry should not have been accessed
            mock_get_provider.assert_not_called()

    async def test_refresh_token_if_needed(
        self,
        integration_service: IntegrationService,
        mock_provider: MockOAuthProvider,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test automatic token refresh when needed."""
        provider_key = ProviderKey(
            id=mock_provider.id,
            grant_type=mock_provider.grant_type,
        )

        # Mock the provider registry
        with patch(
            "tracecat.integrations.service.get_provider_class"
        ) as mock_get_provider:
            mock_get_provider.return_value = MockOAuthProvider

            # Mock the refresh_access_token method
            with patch.object(
                MockOAuthProvider, "refresh_access_token", new_callable=AsyncMock
            ) as mock_refresh:
                mock_refresh.return_value = TokenResponse(
                    access_token=SecretStr("refreshed_token"),
                    refresh_token=SecretStr("new_refresh_token"),
                    expires_in=7200,
                    scope="read write",
                    token_type="Bearer",
                )

                # Store provider config first to set client credentials
                await integration_service.store_provider_config(
                    provider_key=provider_key,
                    client_id="mock_client_id",
                    client_secret=SecretStr("mock_client_secret"),
                )

                # Store integration that needs refresh
                integration = await integration_service.store_integration(
                    provider_key=provider_key,
                    access_token=mock_token_response.access_token,
                    refresh_token=mock_token_response.refresh_token,
                    expires_in=60,  # Expires in 1 minute
                )

                # Refresh the token
                refreshed = await integration_service.refresh_token_if_needed(
                    integration
                )

                # Verify refresh was called
                mock_refresh.assert_called_once()

                # Verify tokens were updated
                access_token, refresh_token = integration_service.get_decrypted_tokens(
                    refreshed
                )
                assert access_token == "refreshed_token"
                assert refresh_token == "new_refresh_token"
                # New expiry should be different (potentially same due to timing)
                assert refreshed.expires_at is not None, "Expires at should not be None"
                assert integration.expires_at is not None, (
                    "Expires at should not be None"
                )
                assert refreshed.expires_at >= integration.expires_at, (
                    "New expiry should be greater than or equal to old expiry"
                )

    async def test_refresh_token_if_needed_no_refresh_token(
        self,
        integration_service: IntegrationService,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test refresh when no refresh token is available."""
        provider_key = ProviderKey(
            id="test_provider",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )

        # Store integration without refresh token
        integration = await integration_service.store_integration(
            provider_key=provider_key,
            access_token=mock_token_response.access_token,
            refresh_token=None,  # No refresh token
            expires_in=60,  # Short expiry
        )

        # Force expiration by setting expires_at to past
        integration.expires_at = datetime.now(UTC) - timedelta(hours=1)
        integration_service.session.add(integration)
        await integration_service.session.commit()
        await integration_service.session.refresh(integration)

        # Attempt refresh - should return unchanged
        refreshed = await integration_service.refresh_token_if_needed(integration)

        # Should be the same instance, unchanged
        assert refreshed.id == integration.id
        assert refreshed.expires_at == integration.expires_at

    async def test_refresh_token_if_needed_provider_not_found(
        self,
        integration_service: IntegrationService,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test refresh when provider is not found in registry - should handle gracefully."""
        provider_key = ProviderKey(
            id="unknown_provider",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )

        # Store provider config first to set client credentials
        await integration_service.store_provider_config(
            provider_key=provider_key,
            client_id="unknown_client_id",
            client_secret=SecretStr("unknown_client_secret"),
        )

        # Store integration with tokens that will expire
        integration = await integration_service.store_integration(
            provider_key=provider_key,
            access_token=mock_token_response.access_token,
            refresh_token=mock_token_response.refresh_token,
            expires_in=60,  # Short expiry
        )

        # Force expiration
        integration.expires_at = datetime.now(UTC) - timedelta(hours=1)
        integration_service.session.add(integration)
        await integration_service.session.commit()
        await integration_service.session.refresh(integration)

        # Mock the provider registry to return None (provider not found)
        with patch(
            "tracecat.integrations.service.get_provider_class"
        ) as mock_get_provider:
            # Make get_provider_class return None to simulate provider not found
            mock_get_provider.return_value = None

            # Attempt refresh - should return unchanged integration gracefully
            refreshed = await integration_service.refresh_token_if_needed(integration)

            # Should be the same instance, unchanged
            assert refreshed.id == integration.id
            assert refreshed.expires_at == integration.expires_at
            # Token should remain unchanged
            access_token, _ = integration_service.get_decrypted_tokens(refreshed)
            assert access_token == mock_token_response.access_token.get_secret_value()

    async def test_refresh_token_if_needed_no_rotation(
        self,
        integration_service: IntegrationService,
        mock_provider: MockOAuthProvider,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test refresh when provider doesn't rotate refresh token."""
        provider_key = ProviderKey(
            id=mock_provider.id,
            grant_type=mock_provider.grant_type,
        )

        # Store the original refresh token for later comparison
        original_refresh_token = mock_token_response.refresh_token

        # Mock the provider registry
        with patch(
            "tracecat.integrations.service.get_provider_class"
        ) as mock_get_provider:
            mock_get_provider.return_value = MockOAuthProvider

            # Mock the refresh_access_token method to return None for refresh_token
            with patch.object(
                MockOAuthProvider, "refresh_access_token", new_callable=AsyncMock
            ) as mock_refresh:
                mock_refresh.return_value = TokenResponse(
                    access_token=SecretStr("refreshed_token"),
                    refresh_token=None,  # No new refresh token
                    expires_in=7200,
                    scope="read write",
                    token_type="Bearer",
                )

                # Store provider config first to set client credentials
                await integration_service.store_provider_config(
                    provider_key=provider_key,
                    client_id="mock_client_id",
                    client_secret=SecretStr("mock_client_secret"),
                )

                # Store integration that needs refresh
                integration = await integration_service.store_integration(
                    provider_key=provider_key,
                    access_token=mock_token_response.access_token,
                    refresh_token=original_refresh_token,
                    expires_in=60,  # Expires in 1 minute
                )

                # Store the original encrypted refresh token
                original_encrypted = integration.encrypted_refresh_token

                # Refresh the token
                refreshed = await integration_service.refresh_token_if_needed(
                    integration
                )

                # Verify refresh was called
                mock_refresh.assert_called_once()

                # Verify access token was updated
                access_token, refresh_token = integration_service.get_decrypted_tokens(
                    refreshed
                )
                assert access_token == "refreshed_token"

                # Verify refresh token was NOT changed
                assert refreshed.encrypted_refresh_token == original_encrypted
                assert original_refresh_token is not None, (
                    "Original refresh token should not be None"
                )
                assert refresh_token == original_refresh_token.get_secret_value()

    async def test_encryption_key_mismatch(
        self,
        session: AsyncSession,
        svc_role: Role,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test decryption failure with wrong encryption key."""
        from cryptography.fernet import Fernet

        # Create service with first encryption key
        key1 = Fernet.generate_key().decode()
        with patch.object(config, "TRACECAT__DB_ENCRYPTION_KEY", key1):
            service1 = IntegrationService(session=session, role=svc_role)

            # Store integration
            integration = await service1.store_integration(
                provider_key=ProviderKey(
                    id="test_provider",
                    grant_type=OAuthGrantType.AUTHORIZATION_CODE,
                ),
                access_token=mock_token_response.access_token,
                refresh_token=mock_token_response.refresh_token,
            )

        # Create service with different encryption key
        key2 = Fernet.generate_key().decode()
        with patch.object(config, "TRACECAT__DB_ENCRYPTION_KEY", key2):
            service2 = IntegrationService(session=session, role=svc_role)

            # Attempt to decrypt with wrong key should raise error
            with pytest.raises(Exception):  # noqa: B017
                service2.get_decrypted_tokens(integration)

            with pytest.raises(Exception):  # noqa: B017
                await service2.get_access_token(integration)

    async def test_store_and_get_provider_config(
        self,
        integration_service: IntegrationService,
    ) -> None:
        """Test storing and retrieving provider configuration."""
        provider_key = ProviderKey(
            id="test_provider",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )
        client_id = "test_client_id"
        client_secret = SecretStr("test_client_secret")
        authorization_endpoint = "https://api.example.com/oauth/authorize"
        token_endpoint = "https://api.example.com/oauth/token"

        # Store provider config
        integration = await integration_service.store_provider_config(
            provider_key=provider_key,
            client_id=client_id,
            client_secret=client_secret,
            authorization_endpoint=authorization_endpoint,
            token_endpoint=token_endpoint,
        )

        assert integration.use_workspace_credentials is True
        assert integration.authorization_endpoint == authorization_endpoint
        assert integration.token_endpoint == token_endpoint

        # Get provider config
        config = integration_service.get_provider_config(
            integration=integration, default_scopes=["user.read"]
        )
        assert config is not None
        assert config.client_id == client_id
        assert config.client_secret is not None
        assert (
            config.client_secret.get_secret_value() == client_secret.get_secret_value()
        )
        assert config.authorization_endpoint == authorization_endpoint
        assert config.token_endpoint == token_endpoint

    @pytest.mark.parametrize(
        ("authorization_endpoint", "token_endpoint"),
        [
            (
                "http://api.example.com/oauth/authorize",
                "https://api.example.com/oauth/token",
            ),
            (
                "https://api.example.com/oauth/authorize",
                "http://api.example.com/oauth/token",
            ),
        ],
    )
    async def test_store_provider_config_rejects_insecure_endpoints(
        self,
        integration_service: IntegrationService,
        authorization_endpoint: str,
        token_endpoint: str,
    ) -> None:
        """Ensure insecure OAuth endpoints are rejected before persistence."""
        provider_key = ProviderKey(
            id="test_provider",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )

        with pytest.raises(InsecureOAuthEndpointError):
            await integration_service.store_provider_config(
                provider_key=provider_key,
                client_id="client",
                client_secret=SecretStr("secret"),
                authorization_endpoint=authorization_endpoint,
                token_endpoint=token_endpoint,
            )

    @pytest.mark.parametrize(
        ("authorization_endpoint", "token_endpoint"),
        [
            (
                "http://api.example.com/oauth/authorize",
                "https://api.example.com/oauth/token",
            ),
            (
                "https://api.example.com/oauth/authorize",
                "http://api.example.com/oauth/token",
            ),
        ],
    )
    async def test_get_provider_config_rejects_insecure_stored_endpoints(
        self,
        integration_service: IntegrationService,
        session: AsyncSession,
        authorization_endpoint: str,
        token_endpoint: str,
    ) -> None:
        """Ensure provider config is not returned when stored endpoints are insecure."""
        provider_key = ProviderKey(
            id="test_provider_insecure",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )

        insecure_integration = OAuthIntegration(
            workspace_id=integration_service.workspace_id,
            provider_id=provider_key.id,
            grant_type=provider_key.grant_type,
            user_id=None,
            encrypted_access_token=b"",
            encrypted_refresh_token=None,
            encrypted_client_id=integration_service.encrypt_client_credential("client"),
            encrypted_client_secret=integration_service.encrypt_client_credential(
                "secret"
            ),
            use_workspace_credentials=True,
            authorization_endpoint=authorization_endpoint,
            token_endpoint=token_endpoint,
        )

        session.add(insecure_integration)
        await session.commit()
        await session.refresh(insecure_integration)

        config = integration_service.get_provider_config(
            integration=insecure_integration,
            default_scopes=["user.read"],
        )

        assert config is None

    @pytest.mark.parametrize(
        ("field", "value"),
        [
            ("authorization_endpoint", "http://api.example.com/oauth/authorize"),
            ("token_endpoint", "http://api.example.com/oauth/token"),
        ],
    )
    def test_integration_update_requires_https(
        self,
        field: str,
        value: str,
    ) -> None:
        """Ensure request schema rejects insecure OAuth endpoints."""
        with pytest.raises(ValidationError):
            # Explicitly construct kwargs to avoid type checker issues with **{field: value}
            # when client_secret expects SecretStr | None
            match field:
                case "authorization_endpoint":
                    IntegrationUpdate(
                        grant_type=OAuthGrantType.AUTHORIZATION_CODE,
                        authorization_endpoint=value,
                    )
                case "token_endpoint":
                    IntegrationUpdate(
                        grant_type=OAuthGrantType.AUTHORIZATION_CODE,
                        token_endpoint=value,
                    )
                case _:
                    raise ValueError(f"Unexpected field: {field}")

    async def test_store_provider_config_includes_default_endpoints(
        self,
        integration_service: IntegrationService,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Ensure default endpoints are stored when provider is registered."""

        provider_key = ProviderKey(
            id=MockOAuthProvider.id,
            grant_type=MockOAuthProvider.grant_type,
        )

        monkeypatch.setattr(
            "tracecat.integrations.service.get_provider_class",
            lambda key: (
                MockOAuthProvider
                if key.id == provider_key.id
                and key.grant_type == provider_key.grant_type
                else None
            ),
        )

        integration = await integration_service.store_provider_config(
            provider_key=provider_key,
            client_id="mock-client",
            client_secret=SecretStr("mock-secret"),
        )

        assert (
            integration.authorization_endpoint
            == MockOAuthProvider.default_authorization_endpoint
        )
        assert integration.token_endpoint == MockOAuthProvider.default_token_endpoint

        config = integration_service.get_provider_config(
            integration=integration,
            provider_impl=MockOAuthProvider,
            default_scopes=MockOAuthProvider.scopes.default,
        )

        assert config is not None
        assert config.authorization_endpoint == (
            MockOAuthProvider.default_authorization_endpoint
        )
        assert config.token_endpoint == MockOAuthProvider.default_token_endpoint

    async def test_remove_provider_config(
        self,
        integration_service: IntegrationService,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test removing provider configuration."""
        provider_key = ProviderKey(
            id="test_provider",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )

        # First, store provider config without tokens
        await integration_service.store_provider_config(
            provider_key=provider_key,
            client_id="test_client",
            client_secret=SecretStr("test_secret"),
        )

        # Remove provider config - should delete entire record
        removed = await integration_service.remove_provider_config(
            provider_key=provider_key
        )
        assert removed is True

        # Verify it's gone
        integration = await integration_service.get_integration(
            provider_key=provider_key
        )
        assert integration is None

        # Now test with existing tokens
        await integration_service.store_integration(
            provider_key=provider_key,
            access_token=mock_token_response.access_token,
        )
        await integration_service.store_provider_config(
            provider_key=provider_key,
            client_id="test_client",
            client_secret=SecretStr("test_secret"),
        )

        # Remove provider config - should only clear credentials
        removed = await integration_service.remove_provider_config(
            provider_key=provider_key,
        )
        assert removed is True

        # Integration should still exist but without credentials
        integration = await integration_service.get_integration(
            provider_key=provider_key
        )
        assert integration is not None
        assert integration.encrypted_client_id is None
        assert integration.encrypted_client_secret is None
        assert integration.use_workspace_credentials is False

    async def test_refresh_token_if_needed_client_credentials(
        self,
        integration_service: IntegrationService,
        mock_cc_provider: MockCCOAuthProvider,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test automatic token refresh for client credentials grant type."""
        provider_key = ProviderKey(
            id="mock_cc_provider",
            grant_type=OAuthGrantType.CLIENT_CREDENTIALS,
        )

        # Mock the provider registry
        with patch(
            "tracecat.integrations.service.get_provider_class"
        ) as mock_get_provider:
            mock_get_provider.return_value = MockCCOAuthProvider

            # Mock the get_client_credentials_token method
            with patch.object(
                MockCCOAuthProvider,
                "get_client_credentials_token",
                new_callable=AsyncMock,
            ) as mock_get_token:
                mock_get_token.return_value = TokenResponse(
                    access_token=SecretStr("new_cc_token"),
                    refresh_token=None,  # CC flow doesn't use refresh tokens
                    expires_in=7200,
                    scope="read write",
                    token_type="Bearer",
                )

                # Store provider config first to set client credentials
                await integration_service.store_provider_config(
                    provider_key=provider_key,
                    client_id="mock_cc_client_id",
                    client_secret=SecretStr("mock_cc_client_secret"),
                )

                # Store integration
                integration = await integration_service.store_integration(
                    provider_key=provider_key,
                    access_token=mock_token_response.access_token,
                    expires_in=60,  # Expires in 1 minute
                )

                # Refresh the token
                refreshed = await integration_service.refresh_token_if_needed(
                    integration
                )

                # Verify get_client_credentials_token was called
                mock_get_token.assert_called_once()

                # Verify token was updated
                access_token, refresh_token = integration_service.get_decrypted_tokens(
                    refreshed
                )
                assert access_token == "new_cc_token"
                assert refresh_token is None  # CC flow doesn't use refresh tokens

    async def test_client_credentials_provider_initialization(
        self, mock_cc_provider: MockCCOAuthProvider
    ) -> None:
        """Test client credentials OAuth provider initialization."""
        assert mock_cc_provider.id == "mock_cc_provider"
        assert mock_cc_provider.client_id == "mock_cc_client_id"
        assert mock_cc_provider.client_secret == "mock_cc_client_secret"
        assert mock_cc_provider.requested_scopes == ["read", "write"]
        assert mock_cc_provider.grant_type == OAuthGrantType.CLIENT_CREDENTIALS

    async def test_store_integration_with_grant_type(
        self,
        integration_service: IntegrationService,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test storing integration with grant type."""
        # Store AC integration
        ac_provider_key = ProviderKey(
            id="ac_provider",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )
        await integration_service.store_provider_config(
            provider_key=ac_provider_key,
            client_id="ac_client",
            client_secret=SecretStr("ac_secret"),
        )
        ac_integration = await integration_service.store_integration(
            provider_key=ac_provider_key,
            access_token=mock_token_response.access_token,
            refresh_token=mock_token_response.refresh_token,
        )
        assert ac_integration.grant_type == "authorization_code"

        # Store CC integration
        cc_provider_key = ProviderKey(
            id="cc_provider",
            grant_type=OAuthGrantType.CLIENT_CREDENTIALS,
        )
        await integration_service.store_provider_config(
            provider_key=cc_provider_key,
            client_id="cc_client",
            client_secret=SecretStr("cc_secret"),
        )
        cc_integration = await integration_service.store_integration(
            provider_key=cc_provider_key,
            access_token=mock_token_response.access_token,
        )
        assert cc_integration.grant_type == "client_credentials"

    async def test_list_integrations_mixed_grant_types(
        self,
        integration_service: IntegrationService,
        mock_token_response: TokenResponse,
    ) -> None:
        """Test listing integrations with different grant types."""
        # Create AC provider
        ac_provider_key = ProviderKey(
            id="provider_ac",
            grant_type=OAuthGrantType.AUTHORIZATION_CODE,
        )
        await integration_service.store_provider_config(
            provider_key=ac_provider_key,
            client_id="ac_client",
            client_secret=SecretStr("ac_secret"),
        )
        await integration_service.store_integration(
            provider_key=ac_provider_key,
            access_token=mock_token_response.access_token,
            refresh_token=mock_token_response.refresh_token,
        )

        # Create CC provider
        cc_provider_key = ProviderKey(
            id="provider_cc",
            grant_type=OAuthGrantType.CLIENT_CREDENTIALS,
        )
        await integration_service.store_provider_config(
            provider_key=cc_provider_key,
            client_id="cc_client",
            client_secret=SecretStr("cc_secret"),
        )
        await integration_service.store_integration(
            provider_key=cc_provider_key,
            access_token=mock_token_response.access_token,
        )

        # List all integrations
        all_integrations = await integration_service.list_integrations()

        # Find our integrations
        ac_integration = next(
            (
                i
                for i in all_integrations
                if ProviderKey(id=i.provider_id, grant_type=i.grant_type)
                == ac_provider_key
            ),
            None,
        )
        cc_integration = next(
            (
                i
                for i in all_integrations
                if ProviderKey(id=i.provider_id, grant_type=i.grant_type)
                == cc_provider_key
            ),
            None,
        )

        assert ac_integration is not None
        assert ac_integration.grant_type == OAuthGrantType.AUTHORIZATION_CODE

        assert cc_integration is not None
        assert cc_integration.grant_type == OAuthGrantType.CLIENT_CREDENTIALS


@pytest.mark.anyio
class TestBaseOAuthProvider:
    """Test the BaseOAuthProvider class."""

    async def test_provider_initialization(
        self, mock_provider: MockOAuthProvider
    ) -> None:
        """Test OAuth provider initialization."""
        assert mock_provider.id == "mock_provider"
        assert mock_provider.client_id == "mock_client_id"
        assert mock_provider.client_secret == "mock_client_secret"
        assert set(mock_provider.requested_scopes) == {
            "read",
            "write",
        }  # Compare as sets
        assert mock_provider.grant_type == OAuthGrantType.AUTHORIZATION_CODE
        assert mock_provider.redirect_uri() == "http://localhost/integrations/callback"

    async def test_get_authorization_url(
        self, mock_provider: MockOAuthProvider
    ) -> None:
        """Test generating authorization URL."""
        state = "test_state_123"

        with patch.object(
            AsyncOAuth2Client, "create_authorization_url"
        ) as mock_create_url:
            mock_create_url.return_value = (
                "https://mock.provider/oauth/authorize?client_id=mock_client_id&state=test_state_123",
                state,
            )

            url, code_verifier = await mock_provider.get_authorization_url(state)

            assert "mock.provider/oauth/authorize" in url
            assert state in url
            assert code_verifier is None
            mock_create_url.assert_called_once_with(
                mock_provider.authorization_endpoint,
                state=state,
            )

    async def test_get_authorization_url_with_pkce(
        self, mock_pkce_provider: MockOAuthProviderWithPKCE
    ) -> None:
        """Test generating authorization URL when PKCE is enabled."""
        state = "test_state_pkce"
        code_verifier = "pkce_verifier_value_1234567890"
        expected_challenge = create_s256_code_challenge(code_verifier)

        with (
            patch(
                "tracecat.integrations.providers.base.secrets.token_urlsafe",
                return_value=code_verifier,
            ),
            patch.object(
                AsyncOAuth2Client, "create_authorization_url"
            ) as mock_create_url,
        ):
            mock_create_url.return_value = (
                "https://mock.provider/oauth/authorize?client_id=mock_client_id&state=test_state_pkce",
                state,
            )

            url, returned_verifier = await mock_pkce_provider.get_authorization_url(
                state
            )

            assert "mock.provider/oauth/authorize" in url
            assert returned_verifier == code_verifier
            mock_create_url.assert_called_once_with(
                mock_pkce_provider.authorization_endpoint,
                state=state,
                custom_auth_param="auth_value",
                audience="test-api",
                code_challenge=expected_challenge,
                code_challenge_method="S256",
            )

    async def test_exchange_code_for_token(
        self, mock_provider: MockOAuthProvider, mock_token_response: TokenResponse
    ) -> None:
        """Test exchanging authorization code for tokens."""
        code = "test_auth_code"
        state = "test_state"

        # Type narrow
        assert mock_token_response.refresh_token is not None, (
            "Refresh token should not be None"
        )

        with patch.object(
            AsyncOAuth2Client, "fetch_token", new_callable=AsyncMock
        ) as mock_fetch:
            mock_fetch.return_value = {
                "access_token": mock_token_response.access_token.get_secret_value(),
                "refresh_token": mock_token_response.refresh_token.get_secret_value(),
                "expires_in": mock_token_response.expires_in,
                "scope": mock_token_response.scope,
                "token_type": mock_token_response.token_type,
            }

            result = await mock_provider.exchange_code_for_token(code, state)

            assert (
                result.access_token.get_secret_value()
                == mock_token_response.access_token.get_secret_value()
            )
            assert result.refresh_token is not None, "Refresh token should not be None"
            assert (
                result.refresh_token.get_secret_value()
                == mock_token_response.refresh_token.get_secret_value()
            )
            assert result.expires_in == mock_token_response.expires_in
            assert result.scope == mock_token_response.scope

            mock_fetch.assert_called_once_with(
                mock_provider.token_endpoint,
                code=code,
                state=state,
            )

    async def test_refresh_access_token(self, mock_provider: MockOAuthProvider) -> None:
        """Test refreshing access token."""
        refresh_token = "old_refresh_token"

        with patch.object(
            AsyncOAuth2Client, "refresh_token", new_callable=AsyncMock
        ) as mock_refresh:
            mock_refresh.return_value = {
                "access_token": "new_access_token",
                "refresh_token": "new_refresh_token",
                "expires_in": 3600,
                "scope": "read write",
                "token_type": "Bearer",
            }

            result = await mock_provider.refresh_access_token(refresh_token)

            assert result.access_token.get_secret_value() == "new_access_token"
            assert result.refresh_token is not None, "Refresh token should not be None"
            assert result.refresh_token.get_secret_value() == "new_refresh_token"
            assert result.expires_in == 3600

            mock_refresh.assert_called_once_with(
                mock_provider.token_endpoint,
                refresh_token=refresh_token,
            )

    async def test_from_config(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Test creating provider from configuration."""
        monkeypatch.setattr(
            "tracecat.config.TRACECAT__PUBLIC_APP_URL", "http://localhost"
        )

        config = ProviderConfig(
            client_id="config_client_id",
            client_secret=SecretStr("config_client_secret"),
            authorization_endpoint="https://custom.example.com/oauth/authorize",
            token_endpoint="https://custom.example.com/oauth/token",
        )

        provider = MockOAuthProvider.from_config(config)

        assert provider.client_id == "config_client_id"
        assert provider.client_secret == "config_client_secret"
        assert (
            provider.authorization_endpoint
            == "https://custom.example.com/oauth/authorize"
        )
        assert provider.token_endpoint == "https://custom.example.com/oauth/token"

    async def test_scope_empty_array_uses_defaults(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Test that empty scope array is respected (not using defaults)."""
        monkeypatch.setattr(
            "tracecat.config.TRACECAT__PUBLIC_APP_URL", "http://localhost"
        )
        provider = MockOAuthProvider(
            client_id="test_id", client_secret="test_secret", scopes=[]
        )
        assert provider.requested_scopes == []  # Empty array is respected

    async def test_scope_override_replaces_defaults(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Test that provided scopes override defaults completely."""
        monkeypatch.setattr(
            "tracecat.config.TRACECAT__PUBLIC_APP_URL", "http://localhost"
        )
        provider = MockOAuthProvider(
            client_id="test_id",
            client_secret="test_secret",
            scopes=["custom.read", "custom.write", "admin"],
        )
        assert provider.requested_scopes == ["custom.read", "custom.write", "admin"]

    async def test_scope_none_uses_defaults(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Test that None scopes fall back to defaults."""
        monkeypatch.setattr(
            "tracecat.config.TRACECAT__PUBLIC_APP_URL", "http://localhost"
        )
        provider = MockOAuthProvider(
            client_id="test_id", client_secret="test_secret", scopes=None
        )
        assert provider.requested_scopes == ["read", "write"]

    async def test_multiple_providers_isolated_scopes(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Test that scope overrides are isolated between provider instances."""
        monkeypatch.setattr(
            "tracecat.config.TRACECAT__PUBLIC_APP_URL", "http://localhost"
        )

        # Create first provider with custom scopes
        provider1 = MockOAuthProvider(
            client_id="test_id_1",
            client_secret="test_secret_1",
            scopes=["scope1", "scope2"],
        )

        # Create second provider with different scopes
        provider2 = MockOAuthProvider(
            client_id="test_id_2",
            client_secret="test_secret_2",
            scopes=["scope3", "scope4"],
        )

        # Create third provider with defaults
        provider3 = MockOAuthProvider(
            client_id="test_id_3", client_secret="test_secret_3"
        )

        # Verify each has its own scopes
        assert provider1.requested_scopes == ["scope1", "scope2"]
        assert provider2.requested_scopes == ["scope3", "scope4"]
        assert provider3.requested_scopes == ["read", "write"]

    async def test_exchange_code_for_token_error_handling(
        self, mock_provider: MockOAuthProvider
    ) -> None:
        """Test error handling during token exchange."""
        code = "test_auth_code"
        state = "test_state"

        # Test with network/API error
        with patch.object(
            AsyncOAuth2Client, "fetch_token", new_callable=AsyncMock
        ) as mock_fetch:
            # Mock fetch_token to raise an exception
            mock_fetch.side_effect = Exception("Network error")

            # Should raise the exception
            with pytest.raises(Exception, match="Network error"):
                await mock_provider.exchange_code_for_token(code, state)

            mock_fetch.assert_called_once_with(
                mock_provider.token_endpoint,
                code=code,
                state=state,
            )


@pytest.mark.anyio
class TestClientCredentialsOAuthProvider:
    """Test the ClientCredentialsOAuthProvider class."""

    async def test_client_credentials_token_acquisition(
        self, mock_cc_provider: MockCCOAuthProvider
    ) -> None:
        """Test acquiring token using client credentials flow."""
        with patch.object(
            AsyncOAuth2Client, "fetch_token", new_callable=AsyncMock
        ) as mock_fetch:
            mock_fetch.return_value = {
                "access_token": "cc_access_token",
                "expires_in": 3600,
                "scope": "read write",
                "token_type": "Bearer",
            }

            result = await mock_cc_provider.get_client_credentials_token()

            assert result.access_token.get_secret_value() == "cc_access_token"
            assert result.refresh_token is None  # CC flow doesn't have refresh token
            assert result.expires_in == 3600
            assert result.scope == "read write"

            mock_fetch.assert_called_once_with(
                mock_cc_provider.token_endpoint,
                grant_type="client_credentials",
            )

    async def test_client_credentials_no_refresh_token(
        self, mock_cc_provider: MockCCOAuthProvider
    ) -> None:
        """Test that client credentials provider doesn't support refresh tokens."""
        # Client credentials flow should not have refresh_access_token method
        # The refresh is done by getting a new token with credentials
        assert not hasattr(mock_cc_provider, "refresh_access_token")
        assert hasattr(mock_cc_provider, "get_client_credentials_token")


# NOTE: Workflow integration test removed as it requires Temporal server
# The test would verify that OAuth tokens are accessible within workflow actions
# by creating a test workflow that retrieves tokens via core.secrets.get


@pytest.mark.anyio
class TestIntegrationServiceScopes:
    """Test IntegrationService scope handling."""

    async def test_store_provider_config_with_empty_scopes(
        self,
        integration_service: IntegrationService,
        mock_provider: MockOAuthProvider,
    ) -> None:
        """Test storing provider config with empty scopes array."""
        provider_key = ProviderKey(
            id=mock_provider.id, grant_type=mock_provider.grant_type
        )

        # Store with empty scopes
        integration = await integration_service.store_provider_config(
            provider_key=provider_key,
            client_id="test_client_id",
            client_secret=SecretStr("test_secret"),
            requested_scopes=[],
        )

        # Empty scopes are stored explicitly as empty string
        assert integration.requested_scopes == ""

    async def test_store_provider_config_scope_updates(
        self,
        integration_service: IntegrationService,
        mock_provider: MockOAuthProvider,
    ) -> None:
        """Test updating scopes through multiple store operations."""
        provider_key = ProviderKey(
            id=mock_provider.id, grant_type=mock_provider.grant_type
        )

        # Initial store with default scopes
        integration = await integration_service.store_provider_config(
            provider_key=provider_key,
            client_id="test_client_id",
            client_secret=SecretStr("test_secret"),
            requested_scopes=["read", "write"],
        )
        assert integration.requested_scopes == "read write"

        # Update with custom scopes
        integration = await integration_service.store_provider_config(
            provider_key=provider_key,
            requested_scopes=["custom.read", "custom.admin"],
        )
        assert integration.requested_scopes == "custom.read custom.admin"

        # Update with empty scopes
        integration = await integration_service.store_provider_config(
            provider_key=provider_key,
            requested_scopes=[],
        )
        assert integration.requested_scopes == ""

        # Update with None (should not change existing empty value)
        integration = await integration_service.store_provider_config(
            provider_key=provider_key,
            requested_scopes=None,
        )
        assert integration.requested_scopes == ""  # Unchanged

    async def test_get_provider_config_scope_fallback(
        self,
        integration_service: IntegrationService,
        mock_provider: MockOAuthProvider,
    ) -> None:
        """Test get_provider_config falls back to default scopes when none stored."""
        provider_key = ProviderKey(
            id=mock_provider.id, grant_type=mock_provider.grant_type
        )

        # Store without scopes
        integration = await integration_service.store_provider_config(
            provider_key=provider_key,
            client_id="test_client_id",
            client_secret=SecretStr("test_secret"),
        )

        # Get config with default scopes
        config = integration_service.get_provider_config(
            integration=integration,
            default_scopes=["fallback.read", "fallback.write"],
        )

        assert config is not None
        assert config.scopes == ["fallback.read", "fallback.write"]
