"""
Unit tests for Platform API Authentication.

These tests verify the APITokenAuthentication class functionality including:
- Bearer token parsing and validation
- Token format validation (ss_live_* and ss_test_*)
- Active/inactive token handling
- Expired token handling
- Token lookup and authentication flow
- Error responses for invalid tokens
- Request attribute attachment (api_token, tenant)
- Optional authentication behavior

Following CLAUDE.md guidelines: Using mocks instead of @pytest.mark.django_db
for fast, isolated unit tests.
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from django.utils import timezone
from datetime import timedelta
from rest_framework import exceptions

from smoothschedule.platform.api.authentication import (
    APITokenAuthentication,
    OptionalAPITokenAuthentication,
)


class TestAPITokenAuthentication:
    """
    Test suite for APITokenAuthentication class.

    Tests the core authentication logic for API tokens including
    header parsing, token validation, and error handling.
    """

    def setup_method(self):
        """Set up common test fixtures."""
        self.auth = APITokenAuthentication()
        self.mock_request = Mock()
        self.mock_tenant = Mock()
        self.mock_tenant.id = 1
        self.mock_tenant.schema_name = 'demo'
        self.mock_tenant.name = 'Demo Business'

    # ========== Header Parsing Tests ==========

    def test_authenticate_returns_none_when_no_auth_header(self):
        """Should return None when no Authorization header is present."""
        with patch('rest_framework.authentication.get_authorization_header', return_value=b''):
            result = self.auth.authenticate(self.mock_request)
            assert result is None

    def test_authenticate_returns_none_for_non_bearer_token(self):
        """Should return None for non-Bearer auth schemes (e.g., Basic auth)."""
        with patch('rest_framework.authentication.get_authorization_header', return_value=b'Basic dXNlcjpwYXNz'):
            result = self.auth.authenticate(self.mock_request)
            assert result is None

    def test_authenticate_raises_on_unicode_decode_error(self):
        """Should raise AuthenticationFailed on invalid UTF-8 in header."""
        with patch('rest_framework.authentication.get_authorization_header', return_value=b'\xff\xfe'):
            with pytest.raises(exceptions.AuthenticationFailed) as exc_info:
                self.auth.authenticate(self.mock_request)

            assert exc_info.value.detail == 'Invalid token header. Token string should not contain invalid characters.'
            assert exc_info.value.status_code == 401
            assert exc_info.value.get_codes() == 'authentication_error'

    def test_authenticate_raises_when_bearer_but_no_credentials(self):
        """Should raise AuthenticationFailed when 'Bearer' is present but no token."""
        with patch('rest_framework.authentication.get_authorization_header', return_value=b'Bearer'):
            with pytest.raises(exceptions.AuthenticationFailed) as exc_info:
                self.auth.authenticate(self.mock_request)

            assert exc_info.value.detail == 'Invalid token header. No credentials provided.'
            assert exc_info.value.get_codes() == 'authentication_error'

    def test_authenticate_raises_when_token_contains_spaces(self):
        """Should raise AuthenticationFailed when token contains spaces."""
        with patch('rest_framework.authentication.get_authorization_header',
                   return_value=b'Bearer ss_live_abc123 extra_part'):
            with pytest.raises(exceptions.AuthenticationFailed) as exc_info:
                self.auth.authenticate(self.mock_request)

            assert exc_info.value.detail == 'Invalid token header. Token string should not contain spaces.'
            assert exc_info.value.get_codes() == 'authentication_error'

    def test_authenticate_accepts_bearer_case_insensitive(self):
        """Should accept 'bearer', 'Bearer', 'BEARER' case-insensitively."""
        test_cases = ['bearer', 'Bearer', 'BEARER', 'BeArEr']

        for bearer_variant in test_cases:
            mock_token = Mock()
            mock_token.is_active = True
            mock_token.is_expired.return_value = False
            mock_token.tenant = self.mock_tenant

            header = f'{bearer_variant} ss_live_abc123'.encode()

            with patch('rest_framework.authentication.get_authorization_header', return_value=header):
                with patch.object(self.auth, 'authenticate_token', return_value=(None, mock_token)):
                    result = self.auth.authenticate(self.mock_request)
                    assert result == (None, mock_token)

    # ========== Token Format Validation Tests ==========

    def test_authenticate_token_raises_on_invalid_prefix(self):
        """Should raise AuthenticationFailed for tokens without ss_live_ or ss_test_ prefix."""
        invalid_keys = [
            'invalid_token_format',
            'sk_live_abc123',  # Wrong prefix
            'api_key_abc123',
            'ss_prod_abc123',  # Wrong environment
            '',
            'ss_',
        ]

        for invalid_key in invalid_keys:
            with pytest.raises(exceptions.AuthenticationFailed) as exc_info:
                self.auth.authenticate_token(invalid_key, self.mock_request)

            assert exc_info.value.detail == 'Invalid API token format.'
            assert exc_info.value.get_codes() == 'authentication_error'

    def test_authenticate_token_accepts_live_token_format(self):
        """Should accept tokens with ss_live_ prefix."""
        mock_token = Mock()
        mock_token.is_active = True
        mock_token.is_expired.return_value = False
        mock_token.tenant = self.mock_tenant

        with patch('smoothschedule.platform.api.models.APIToken.get_by_key', return_value=mock_token):
            result = self.auth.authenticate_token('ss_live_abc123def456', self.mock_request)
            assert result == (None, mock_token)

    def test_authenticate_token_accepts_test_token_format(self):
        """Should accept tokens with ss_test_ prefix (sandbox mode)."""
        mock_token = Mock()
        mock_token.is_active = True
        mock_token.is_expired.return_value = False
        mock_token.tenant = self.mock_tenant

        with patch('smoothschedule.platform.api.models.APIToken.get_by_key', return_value=mock_token):
            result = self.auth.authenticate_token('ss_test_abc123def456', self.mock_request)
            assert result == (None, mock_token)

    # ========== Token Lookup and Validation Tests ==========

    def test_authenticate_token_raises_when_token_not_found(self):
        """Should raise AuthenticationFailed when token doesn't exist in database."""
        with patch('smoothschedule.platform.api.models.APIToken.get_by_key', return_value=None):
            with pytest.raises(exceptions.AuthenticationFailed) as exc_info:
                self.auth.authenticate_token('ss_live_nonexistent', self.mock_request)

            assert exc_info.value.detail == 'Invalid API token.'
            assert exc_info.value.get_codes() == 'authentication_error'

    def test_authenticate_token_raises_when_token_inactive(self):
        """Should raise AuthenticationFailed when token is_active=False (revoked)."""
        mock_token = Mock()
        mock_token.is_active = False

        with patch('smoothschedule.platform.api.models.APIToken.get_by_key', return_value=mock_token):
            with pytest.raises(exceptions.AuthenticationFailed) as exc_info:
                self.auth.authenticate_token('ss_live_revoked', self.mock_request)

            assert exc_info.value.detail == 'API token has been revoked.'
            assert exc_info.value.get_codes() == 'authentication_error'

    def test_authenticate_token_raises_when_token_expired(self):
        """Should raise AuthenticationFailed when token has expired."""
        mock_token = Mock()
        mock_token.is_active = True
        mock_token.is_expired.return_value = True

        with patch('smoothschedule.platform.api.models.APIToken.get_by_key', return_value=mock_token):
            with pytest.raises(exceptions.AuthenticationFailed) as exc_info:
                self.auth.authenticate_token('ss_live_expired', self.mock_request)

            assert exc_info.value.detail == 'API token has expired.'
            assert exc_info.value.get_codes() == 'authentication_error'

    def test_authenticate_token_succeeds_with_valid_active_token(self):
        """Should successfully authenticate with a valid, active, non-expired token."""
        mock_token = Mock()
        mock_token.is_active = True
        mock_token.is_expired.return_value = False
        mock_token.tenant = self.mock_tenant

        with patch('smoothschedule.platform.api.models.APIToken.get_by_key', return_value=mock_token):
            with patch.object(self.auth, '_update_last_used') as mock_update:
                user, auth = self.auth.authenticate_token('ss_live_valid123', self.mock_request)

                # Should return None as user (API tokens are not tied to users)
                assert user is None
                # Should return the token as auth
                assert auth == mock_token
                # Should update last_used timestamp
                mock_update.assert_called_once_with(mock_token)

    # ========== Request Attribute Attachment Tests ==========

    def test_authenticate_token_attaches_api_token_to_request(self):
        """Should attach api_token attribute to request object."""
        mock_token = Mock()
        mock_token.is_active = True
        mock_token.is_expired.return_value = False
        mock_token.tenant = self.mock_tenant

        with patch('smoothschedule.platform.api.models.APIToken.get_by_key', return_value=mock_token):
            with patch.object(self.auth, '_update_last_used'):
                self.auth.authenticate_token('ss_live_valid123', self.mock_request)

                assert hasattr(self.mock_request, 'api_token')
                assert self.mock_request.api_token == mock_token

    def test_authenticate_token_attaches_tenant_to_request(self):
        """Should attach tenant attribute to request object from token."""
        mock_token = Mock()
        mock_token.is_active = True
        mock_token.is_expired.return_value = False
        mock_token.tenant = self.mock_tenant

        with patch('smoothschedule.platform.api.models.APIToken.get_by_key', return_value=mock_token):
            with patch.object(self.auth, '_update_last_used'):
                self.auth.authenticate_token('ss_live_valid123', self.mock_request)

                assert hasattr(self.mock_request, 'tenant')
                assert self.mock_request.tenant == self.mock_tenant

    # ========== Last Used Timestamp Update Tests ==========

    def test_update_last_used_updates_timestamp(self):
        """Should update token's last_used_at timestamp."""
        mock_token = Mock()
        mock_token.save = Mock()

        with patch('django.utils.timezone.now') as mock_now:
            fake_now = timezone.now()
            mock_now.return_value = fake_now

            self.auth._update_last_used(mock_token)

            assert mock_token.last_used_at == fake_now
            mock_token.save.assert_called_once_with(update_fields=['last_used_at'])

    def test_update_last_used_silently_fails_on_exception(self):
        """Should not raise exception if updating last_used_at fails."""
        mock_token = Mock()
        mock_token.save.side_effect = Exception('Database error')

        # Should not raise, just silently continue
        try:
            self.auth._update_last_used(mock_token)
        except Exception:
            pytest.fail("_update_last_used should not raise exceptions")

    # ========== WWW-Authenticate Header Tests ==========

    def test_authenticate_header_returns_correct_value(self):
        """Should return proper WWW-Authenticate header for 401 responses."""
        result = self.auth.authenticate_header(self.mock_request)
        assert result == 'Bearer realm="api"'

    # ========== Integration Flow Tests ==========

    def test_full_authentication_flow_with_valid_token(self):
        """Test complete authentication flow from header to successful auth."""
        mock_token = Mock()
        mock_token.is_active = True
        mock_token.is_expired.return_value = False
        mock_token.tenant = self.mock_tenant

        auth_header = b'Bearer ss_live_complete_test_token_12345'

        with patch('rest_framework.authentication.get_authorization_header', return_value=auth_header):
            with patch('smoothschedule.platform.api.models.APIToken.get_by_key', return_value=mock_token):
                with patch.object(self.auth, '_update_last_used'):
                    user, auth = self.auth.authenticate(self.mock_request)

                    assert user is None
                    assert auth == mock_token
                    assert self.mock_request.api_token == mock_token
                    assert self.mock_request.tenant == self.mock_tenant


class TestOptionalAPITokenAuthentication:
    """
    Test suite for OptionalAPITokenAuthentication class.

    This class allows optional token authentication without raising
    exceptions for invalid tokens - useful for endpoints that support
    multiple auth methods or anonymous access.
    """

    def setup_method(self):
        """Set up common test fixtures."""
        self.auth = OptionalAPITokenAuthentication()
        self.mock_request = Mock()

    def test_returns_none_when_no_auth_header(self):
        """Should return None when no Authorization header present."""
        with patch('rest_framework.authentication.get_authorization_header', return_value=b''):
            result = self.auth.authenticate(self.mock_request)
            assert result is None

    def test_returns_none_on_unicode_decode_error(self):
        """Should return None instead of raising on invalid UTF-8."""
        with patch('rest_framework.authentication.get_authorization_header', return_value=b'\xff\xfe'):
            result = self.auth.authenticate(self.mock_request)
            assert result is None

    def test_returns_none_for_non_bearer_token(self):
        """Should return None for non-Bearer auth schemes."""
        with patch('rest_framework.authentication.get_authorization_header', return_value=b'Basic dXNlcjpwYXNz'):
            result = self.auth.authenticate(self.mock_request)
            assert result is None

    def test_returns_none_for_invalid_bearer_format(self):
        """Should return None for malformed Bearer tokens instead of raising."""
        # Bearer with no token
        with patch('rest_framework.authentication.get_authorization_header', return_value=b'Bearer'):
            result = self.auth.authenticate(self.mock_request)
            assert result is None

        # Bearer with spaces
        with patch('rest_framework.authentication.get_authorization_header',
                   return_value=b'Bearer token with spaces'):
            result = self.auth.authenticate(self.mock_request)
            assert result is None

    def test_returns_none_when_authenticate_token_fails(self):
        """Should return None when token authentication fails instead of raising."""
        auth_header = b'Bearer ss_live_invalid_token'

        with patch('rest_framework.authentication.get_authorization_header', return_value=auth_header):
            # Mock authenticate_token to raise AuthenticationFailed
            with patch.object(self.auth, 'authenticate_token',
                            side_effect=exceptions.AuthenticationFailed('Invalid token')):
                result = self.auth.authenticate(self.mock_request)
                assert result is None

    def test_succeeds_with_valid_token(self):
        """Should authenticate successfully with valid token."""
        mock_token = Mock()
        mock_token.is_active = True
        mock_token.is_expired.return_value = False
        mock_tenant = Mock()
        mock_token.tenant = mock_tenant

        auth_header = b'Bearer ss_live_valid_token'

        with patch('rest_framework.authentication.get_authorization_header', return_value=auth_header):
            with patch.object(self.auth, 'authenticate_token', return_value=(None, mock_token)):
                user, auth = self.auth.authenticate(self.mock_request)

                assert user is None
                assert auth == mock_token

    def test_optional_auth_use_case_for_public_endpoints(self):
        """
        Demonstrates use case: endpoints that optionally accept API tokens
        but also work anonymously.
        """
        # Case 1: Anonymous request (no token)
        with patch('rest_framework.authentication.get_authorization_header', return_value=b''):
            result = self.auth.authenticate(self.mock_request)
            assert result is None  # Allow anonymous access

        # Case 2: Valid token provided
        mock_token = Mock()
        mock_token.is_active = True
        mock_token.is_expired.return_value = False
        mock_token.tenant = Mock()

        with patch('rest_framework.authentication.get_authorization_header',
                   return_value=b'Bearer ss_live_valid'):
            with patch.object(self.auth, 'authenticate_token', return_value=(None, mock_token)):
                user, auth = self.auth.authenticate(self.mock_request)
                assert auth == mock_token

        # Case 3: Invalid token - still allow access (fall back to other auth)
        with patch('rest_framework.authentication.get_authorization_header',
                   return_value=b'Bearer ss_live_invalid'):
            with patch.object(self.auth, 'authenticate_token',
                            side_effect=exceptions.AuthenticationFailed()):
                result = self.auth.authenticate(self.mock_request)
                assert result is None  # Don't block request, let other auth handle


class TestTokenModeValidation:
    """
    Test sandbox vs live token mode handling.

    Verifies that both ss_live_* and ss_test_* tokens are properly
    recognized and processed.
    """

    def setup_method(self):
        """Set up common test fixtures."""
        self.auth = APITokenAuthentication()
        self.mock_request = Mock()

    def test_live_token_mode_accepted(self):
        """Should accept and process ss_live_* tokens."""
        mock_token = Mock()
        mock_token.is_active = True
        mock_token.is_expired.return_value = False
        mock_token.tenant = Mock()
        mock_token.is_sandbox = False

        with patch('smoothschedule.platform.api.models.APIToken.get_by_key', return_value=mock_token):
            with patch.object(self.auth, '_update_last_used'):
                user, auth = self.auth.authenticate_token('ss_live_production_key', self.mock_request)
                assert auth == mock_token

    def test_sandbox_token_mode_accepted(self):
        """Should accept and process ss_test_* tokens."""
        mock_token = Mock()
        mock_token.is_active = True
        mock_token.is_expired.return_value = False
        mock_token.tenant = Mock()
        mock_token.is_sandbox = True

        with patch('smoothschedule.platform.api.models.APIToken.get_by_key', return_value=mock_token):
            with patch.object(self.auth, '_update_last_used'):
                user, auth = self.auth.authenticate_token('ss_test_sandbox_key', self.mock_request)
                assert auth == mock_token

    def test_both_modes_use_same_validation_logic(self):
        """Both live and test tokens should go through same validation checks."""
        # Test that both modes check is_active
        for prefix in ['ss_live_', 'ss_test_']:
            mock_token = Mock()
            mock_token.is_active = False  # Revoked token

            with patch('smoothschedule.platform.api.models.APIToken.get_by_key', return_value=mock_token):
                with pytest.raises(exceptions.AuthenticationFailed) as exc_info:
                    self.auth.authenticate_token(f'{prefix}revoked_token', self.mock_request)

                assert exc_info.value.detail == 'API token has been revoked.'


class TestErrorResponseCodes:
    """
    Verify that authentication errors return correct error codes
    for proper API error handling.
    """

    def setup_method(self):
        """Set up common test fixtures."""
        self.auth = APITokenAuthentication()
        self.mock_request = Mock()

    def test_all_authentication_errors_have_authentication_error_code(self):
        """All AuthenticationFailed exceptions should use 'authentication_error' code."""
        test_cases = [
            # (header_value, expected_detail_substring)
            (b'\xff\xfe', 'invalid characters'),
            (b'Bearer', 'No credentials'),
            (b'Bearer token with spaces', 'should not contain spaces'),
        ]

        for header, detail_substring in test_cases:
            with patch('rest_framework.authentication.get_authorization_header', return_value=header):
                with pytest.raises(exceptions.AuthenticationFailed) as exc_info:
                    self.auth.authenticate(self.mock_request)

                assert exc_info.value.get_codes() == 'authentication_error'
                assert detail_substring.lower() in str(exc_info.value.detail).lower()

    def test_authentication_errors_return_401_status(self):
        """All authentication failures should return 401 Unauthorized."""
        with patch('rest_framework.authentication.get_authorization_header', return_value=b'Bearer'):
            with pytest.raises(exceptions.AuthenticationFailed) as exc_info:
                self.auth.authenticate(self.mock_request)

            # DRF's AuthenticationFailed defaults to 401
            assert exc_info.value.status_code == 401
