"""Unit tests for GCP service account authentication.

Tests credential loading, validation, and token management for GCP authentication.
"""

import builtins
import json
import os
import tempfile
from unittest.mock import Mock, patch, MagicMock

import pytest

from mca_sdk.config import MCAConfig
from mca_sdk.core.gcp_auth import (
    GCPCredentials,
    load_credentials,
    validate_credentials,
    get_credentials_for_config,
)
from mca_sdk.utils.exceptions import ConfigurationError

# Sample valid service account JSON
VALID_SERVICE_ACCOUNT = {
    "type": "service_account",
    "project_id": "test-project",
    "private_key_id": "key123",
    "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEv...test...==\n-----END PRIVATE KEY-----\n",
    "client_email": "test@test-project.iam.gserviceaccount.com",
    "client_id": "123456789",
    "auth_uri": "https://accounts.google.com/o/oauth2/auth",
    "token_uri": "https://oauth2.googleapis.com/token",
    "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
    "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/test%40test-project.iam.gserviceaccount.com",
}


class TestGCPCredentials:
    """Test GCPCredentials dataclass."""

    def test_create_from_dict(self):
        """Test creating GCPCredentials from dictionary."""
        creds = GCPCredentials.from_dict(VALID_SERVICE_ACCOUNT)

        assert creds.project_id == "test-project"
        assert creds.client_email == "test@test-project.iam.gserviceaccount.com"
        assert creds.private_key.startswith("-----BEGIN PRIVATE KEY-----")
        assert creds.type == "service_account"

    def test_create_from_dict_missing_required_field(self):
        """Test that missing required fields raise ConfigurationError."""
        invalid_data = VALID_SERVICE_ACCOUNT.copy()
        del invalid_data["project_id"]

        with pytest.raises(ConfigurationError, match="project_id"):
            GCPCredentials.from_dict(invalid_data)

    def test_to_safe_dict_masks_sensitive_data(self):
        """Test that to_safe_dict masks private key but keeps identifiers."""
        creds = GCPCredentials.from_dict(VALID_SERVICE_ACCOUNT)
        output = creds.to_safe_dict()

        # Should mask private key and sensitive IDs
        assert output["private_key"] == "[REDACTED]"
        assert output["private_key_id"] == "[REDACTED]"
        assert output["client_id"] == "[REDACTED]"
        assert output["client_x509_cert_url"] == "[REDACTED]"

        # Should keep identifiers for debugging
        assert output["project_id"] == "test-project"
        assert output["client_email"] == "test@test-project.iam.gserviceaccount.com"

        # Should NOT contain private key material
        assert "-----BEGIN" not in output["private_key"]

        # Should keep non-sensitive URI fields
        assert output["auth_uri"] == "https://accounts.google.com/o/oauth2/auth"
        assert output["token_uri"] == "https://oauth2.googleapis.com/token"

    def test_repr_masks_private_key(self):
        """Test that repr shows identifiers but not private key."""
        creds = GCPCredentials.from_dict(VALID_SERVICE_ACCOUNT)
        repr_str = repr(creds)

        # Should show identifiers for debugging
        assert "project_id='test-project'" in repr_str
        assert "client_email='test@test-project.iam.gserviceaccount.com'" in repr_str
        assert "type='service_account'" in repr_str

        # Should NOT contain private key
        assert "-----BEGIN" not in repr_str
        assert "private_key" not in repr_str


class TestLoadCredentials:
    """Test credential loading from various sources."""

    @patch("google.oauth2.service_account.Credentials.from_service_account_info")
    def test_load_from_file(self, mock_from_info):
        """Test loading credentials from JSON file."""
        # Mock google.auth credentials creation
        mock_google_creds = Mock()
        mock_from_info.return_value = mock_google_creds

        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump(VALID_SERVICE_ACCOUNT, f)
            temp_path = f.name

        try:
            creds = load_credentials(credentials_path=temp_path)
            assert creds.project_id == "test-project"
            assert creds.client_email == "test@test-project.iam.gserviceaccount.com"
            assert creds.google_credentials == mock_google_creds
        finally:
            os.unlink(temp_path)

    @patch("google.oauth2.service_account.Credentials.from_service_account_info")
    def test_load_from_json_string(self, mock_from_info):
        """Test loading credentials from JSON string."""
        # Mock google.auth credentials creation
        mock_google_creds = Mock()
        mock_from_info.return_value = mock_google_creds

        json_string = json.dumps(VALID_SERVICE_ACCOUNT)
        creds = load_credentials(credentials_json=json_string)

        assert creds.project_id == "test-project"
        assert creds.client_email == "test@test-project.iam.gserviceaccount.com"
        assert creds.google_credentials == mock_google_creds

    def test_load_from_adc_fallback(self):
        """Test falling back to Application Default Credentials."""
        with patch("google.auth.default") as mock_default:
            mock_creds = Mock()
            mock_creds.project_id = "adc-project"
            mock_default.return_value = (mock_creds, "adc-project")

            creds = load_credentials()

            # Should fall back to ADC when no explicit credentials provided
            mock_default.assert_called_once()

    def test_load_from_file_not_found(self):
        """Test error when credential file doesn't exist."""
        with pytest.raises(ConfigurationError, match="not found"):
            load_credentials(credentials_path="/nonexistent/path.json")

    def test_load_from_invalid_json_file(self):
        """Test error when JSON file is malformed."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            f.write("invalid json{")
            temp_path = f.name

        try:
            with pytest.raises(ConfigurationError, match="Invalid JSON"):
                load_credentials(credentials_path=temp_path)
        finally:
            os.unlink(temp_path)

    def test_load_from_invalid_json_string(self):
        """Test error when JSON string is malformed."""
        with pytest.raises(ConfigurationError, match="Invalid JSON"):
            load_credentials(credentials_json="invalid json{")

    @patch("google.oauth2.service_account.Credentials.from_service_account_info")
    def test_priority_file_over_json_string(self, mock_from_info):
        """Test that credentials_path takes priority over credentials_json."""
        # Mock google.auth credentials creation
        mock_google_creds = Mock()
        mock_from_info.return_value = mock_google_creds

        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump(VALID_SERVICE_ACCOUNT, f)
            temp_path = f.name

        try:
            other_creds = VALID_SERVICE_ACCOUNT.copy()
            other_creds["project_id"] = "other-project"

            creds = load_credentials(
                credentials_path=temp_path, credentials_json=json.dumps(other_creds)
            )

            # Should use file, not JSON string
            assert creds.project_id == "test-project"
        finally:
            os.unlink(temp_path)


class TestValidateCredentials:
    """Test credential validation."""

    def test_validate_valid_credentials(self):
        """Test validation passes for valid credentials."""
        creds = GCPCredentials.from_dict(VALID_SERVICE_ACCOUNT)

        # Should not raise
        validate_credentials(creds)

    def test_validate_missing_required_field(self):
        """Test validation fails for missing required fields."""
        invalid = VALID_SERVICE_ACCOUNT.copy()
        del invalid["client_email"]

        with pytest.raises(ConfigurationError, match="client_email"):
            creds = GCPCredentials.from_dict(invalid)

    def test_validate_invalid_email_format(self):
        """Test validation fails for invalid email format."""
        invalid = VALID_SERVICE_ACCOUNT.copy()
        invalid["client_email"] = "not-an-email"

        creds = GCPCredentials.from_dict(invalid)
        with pytest.raises(ConfigurationError, match="email"):
            validate_credentials(creds)

    def test_validate_invalid_private_key_format(self):
        """Test validation fails for invalid private key format."""
        invalid = VALID_SERVICE_ACCOUNT.copy()
        invalid["private_key"] = "not-a-private-key"

        creds = GCPCredentials.from_dict(invalid)
        with pytest.raises(ConfigurationError, match="private key"):
            validate_credentials(creds)

    def test_validate_wrong_account_type(self):
        """Test validation fails for non-service account type."""
        invalid = VALID_SERVICE_ACCOUNT.copy()
        invalid["type"] = "user"

        creds = GCPCredentials.from_dict(invalid)
        with pytest.raises(ConfigurationError, match="service_account"):
            validate_credentials(creds)


class TestGetCredentialsForConfig:
    """Test integration with MCAConfig."""

    @patch("google.oauth2.service_account.Credentials.from_service_account_info")
    def test_get_credentials_from_config_path(self, mock_from_info):
        """Test getting credentials when gcp_credentials_path is set."""
        # Mock google.auth credentials creation
        mock_google_creds = Mock()
        mock_from_info.return_value = mock_google_creds

        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump(VALID_SERVICE_ACCOUNT, f)
            temp_path = f.name

        try:
            config = MCAConfig(service_name="test-service", gcp_credentials_path=temp_path)

            creds = get_credentials_for_config(config)
            assert creds.project_id == "test-project"
        finally:
            os.unlink(temp_path)

    @patch("google.oauth2.service_account.Credentials.from_service_account_info")
    def test_get_credentials_from_config_json(self, mock_from_info):
        """Test getting credentials when gcp_credentials_json is set."""
        # Mock google.auth credentials creation
        mock_google_creds = Mock()
        mock_from_info.return_value = mock_google_creds

        config = MCAConfig(
            service_name="test-service",
            gcp_credentials_json=json.dumps(VALID_SERVICE_ACCOUNT),
        )

        creds = get_credentials_for_config(config)
        assert creds.project_id == "test-project"

    def test_get_credentials_adc_when_no_config(self):
        """Test falling back to ADC when no GCP config provided."""
        config = MCAConfig(service_name="test-service")

        with patch("google.auth.default") as mock_default:
            mock_creds = Mock()
            mock_creds.project_id = "adc-project"
            mock_default.return_value = (mock_creds, "adc-project")

            creds = get_credentials_for_config(config)
            mock_default.assert_called_once()

    def test_get_credentials_none_when_adc_unavailable(self):
        """Test returning None when ADC is unavailable."""
        config = MCAConfig(service_name="test-service")

        with patch("google.auth.default") as mock_default:
            mock_default.side_effect = Exception("ADC not available")

            creds = get_credentials_for_config(config)
            assert creds is None


class TestTokenManagement:
    """Test token refresh and caching."""

    @patch("google.auth.default")
    def test_automatic_token_refresh(self, mock_default):
        """Test that tokens are automatically refreshed when expired."""
        # Mock credentials with expired token
        mock_creds = MagicMock()
        mock_creds.token = "old-token"
        mock_creds.expired = True
        mock_creds.project_id = "test-project"

        # Mock the refresh method
        def refresh_side_effect(request):
            mock_creds.token = "new-token"
            mock_creds.expired = False

        mock_creds.refresh = Mock(side_effect=refresh_side_effect)
        mock_default.return_value = (mock_creds, "test-project")

        # Load credentials
        creds = load_credentials()

        # Simulate token refresh
        assert mock_creds.expired
        mock_creds.refresh(Mock())
        assert not mock_creds.expired
        assert mock_creds.token == "new-token"

    @patch("google.auth.default")
    def test_token_refresh_error_handling(self, mock_default):
        """Test handling of token refresh errors."""
        mock_creds = MagicMock()
        mock_creds.expired = True
        mock_creds.refresh = Mock(side_effect=Exception("Refresh failed"))
        mock_default.return_value = (mock_creds, "test-project")

        creds = load_credentials()

        # Should propagate refresh errors
        with pytest.raises(Exception, match="Refresh failed"):
            mock_creds.refresh(Mock())


class TestRequiredScopes:
    """Test that required GCP scopes are configured."""

    def test_credentials_have_monitoring_scope(self):
        """Test credentials include Cloud Monitoring scope."""
        creds = GCPCredentials.from_dict(VALID_SERVICE_ACCOUNT)

        # Check that required scopes are documented
        required_scopes = [
            "https://www.googleapis.com/auth/monitoring.write",
            "https://www.googleapis.com/auth/logging.write",
            "https://www.googleapis.com/auth/trace.append",
        ]

        # This test documents the required scopes
        # Actual scope validation happens at GCP API call time
        assert len(required_scopes) == 3


class TestCredentialSecurity:
    """Test security aspects of credential handling."""

    def test_credentials_not_logged(self):
        """Test that private key is not exposed in logs."""
        creds = GCPCredentials.from_dict(VALID_SERVICE_ACCOUNT)

        # repr should not contain private key (but can show identifiers)
        repr_str = repr(creds)
        assert "BEGIN PRIVATE KEY" not in repr_str
        assert "private_key" not in repr_str.lower() or "[REDACTED]" in repr_str

    def test_credentials_not_in_error_messages(self):
        """Test that credentials don't leak in error messages."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            f.write("invalid json")
            temp_path = f.name

        try:
            with pytest.raises(ConfigurationError) as exc_info:
                load_credentials(credentials_path=temp_path)

            # Error message should not contain file contents
            assert "invalid json" not in str(exc_info.value)
        finally:
            os.unlink(temp_path)

    def test_config_repr_masks_credentials(self):
        """Test that MCAConfig repr masks GCP credentials."""
        config = MCAConfig(
            service_name="test-service",
            gcp_credentials_json=json.dumps(VALID_SERVICE_ACCOUNT),
        )

        repr_str = repr(config)

        # Should not contain private key
        assert "BEGIN PRIVATE KEY" not in repr_str


class TestCodeReviewFixes:
    """Tests for code review findings fixes."""

    @patch("google.oauth2.service_account.Credentials.from_service_account_info")
    def test_sensitive_string_callback_pattern(self, mock_from_info):
        """Test that SensitiveString uses callback pattern correctly (Fix #1)."""
        from mca_sdk.config.settings import SensitiveString

        mock_google_creds = Mock()
        mock_from_info.return_value = mock_google_creds

        # Wrap credentials in SensitiveString
        sensitive_json = SensitiveString(json.dumps(VALID_SERVICE_ACCOUNT))

        # Should load without extracting raw value to variable
        creds = load_credentials(credentials_json=sensitive_json)

        assert creds.project_id == "test-project"
        assert creds.google_credentials == mock_google_creds

    def test_specific_file_errors(self):
        """Test specific error handling for file operations (Fix #2)."""
        # FileNotFoundError
        with pytest.raises(ConfigurationError, match="not found"):
            load_credentials(credentials_path="/nonexistent/file.json")

        # PermissionError
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump(VALID_SERVICE_ACCOUNT, f)
            temp_path = f.name

        try:
            # os.chmod(temp_path, 0o000)  # Might be bypassed by root user
            # Mock open to raise PermissionError specifically for this file
            original_open = builtins.open

            def mock_open(file, *args, **kwargs):
                if file == temp_path:
                    raise PermissionError("Permission denied")
                return original_open(file, *args, **kwargs)

            with patch("builtins.open", side_effect=mock_open):
                with pytest.raises(ConfigurationError, match="Permission denied"):
                    load_credentials(credentials_path=temp_path)
        finally:
            os.unlink(temp_path)

        # JSONDecodeError
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            f.write("invalid json{")
            temp_path = f.name

        try:
            with pytest.raises(ConfigurationError, match="Invalid JSON"):
                load_credentials(credentials_path=temp_path)
        finally:
            os.unlink(temp_path)

    @patch("google.oauth2.service_account.Credentials.from_service_account_info")
    def test_import_error_detection(self, mock_from_info):
        """Test that import errors are properly detected (Fix #3)."""
        # Simulate missing google-auth library
        mock_from_info.side_effect = ImportError("No module named 'google.oauth2'")

        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump(VALID_SERVICE_ACCOUNT, f)
            temp_path = f.name

        try:
            with pytest.raises(ConfigurationError, match="google-auth library not installed"):
                load_credentials(credentials_path=temp_path)
        finally:
            os.unlink(temp_path)

        # Simulate corrupted library
        mock_from_info.side_effect = ImportError("Something else broke")

        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump(VALID_SERVICE_ACCOUNT, f)
            temp_path = f.name

        try:
            with pytest.raises(ConfigurationError, match="Failed to import google-auth"):
                load_credentials(credentials_path=temp_path)
        finally:
            os.unlink(temp_path)

    def test_project_id_validation(self, caplog):
        """Test project ID validation (Fix #4)."""
        import logging

        # Missing project_id - should raise error
        invalid = VALID_SERVICE_ACCOUNT.copy()
        invalid["project_id"] = ""

        creds = GCPCredentials.from_dict(invalid)
        with pytest.raises(ConfigurationError, match="project_id is required"):
            validate_credentials(creds)

        # Unusual format - should warn but not fail (supports legacy projects)
        unusual_ids = ["Test-Project", "1test", "my.domain.com:project"]
        for project_id in unusual_ids:
            invalid = VALID_SERVICE_ACCOUNT.copy()
            invalid["project_id"] = project_id

            creds = GCPCredentials.from_dict(invalid)

            with caplog.at_level(logging.WARNING):
                validate_credentials(creds)  # Should succeed with warning
                assert "unusual format" in caplog.text.lower()

    def test_adc_without_project_raises_error(self):
        """Test that ADC without project_id fails fast (Fix #5)."""
        with patch("google.auth.default") as mock_default:
            # ADC returns None for project_id
            mock_creds = Mock()
            mock_default.return_value = (mock_creds, None)

            with pytest.raises(ConfigurationError, match="Could not determine GCP project_id"):
                load_credentials()

    @patch("google.oauth2.service_account.Credentials.from_service_account_info")
    def test_custom_oauth_scopes(self, mock_from_info):
        """Test custom OAuth scopes configuration (Fix #6)."""
        mock_google_creds = Mock()
        mock_from_info.return_value = mock_google_creds

        custom_scopes = [
            "https://www.googleapis.com/auth/bigquery",
            "https://www.googleapis.com/auth/cloud-platform",
        ]

        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump(VALID_SERVICE_ACCOUNT, f)
            temp_path = f.name

        try:
            creds = load_credentials(credentials_path=temp_path, scopes=custom_scopes)

            # Verify custom scopes were passed to google-auth
            mock_from_info.assert_called_once()
            call_kwargs = mock_from_info.call_args[1]
            assert call_kwargs["scopes"] == custom_scopes
        finally:
            os.unlink(temp_path)

    @patch("google.oauth2.service_account.Credentials.from_service_account_info")
    def test_default_scopes_used_when_none_specified(self, mock_from_info):
        """Test that default scopes are used when not specified."""
        mock_google_creds = Mock()
        mock_from_info.return_value = mock_google_creds

        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump(VALID_SERVICE_ACCOUNT, f)
            temp_path = f.name

        try:
            creds = load_credentials(credentials_path=temp_path)

            # Verify default scopes were used
            mock_from_info.assert_called_once()
            call_kwargs = mock_from_info.call_args[1]
            assert "https://www.googleapis.com/auth/monitoring.write" in call_kwargs["scopes"]
            assert "https://www.googleapis.com/auth/logging.write" in call_kwargs["scopes"]
            assert "https://www.googleapis.com/auth/trace.append" in call_kwargs["scopes"]
        finally:
            os.unlink(temp_path)
