"""Unit tests for bootstrap module."""

import os
from unittest.mock import MagicMock, Mock, patch

import pytest
from botocore.exceptions import ClientError

from augint_billing_lib import bootstrap
from augint_billing_lib.bootstrap import build_service, process_event_and_apply_plan_moves


@pytest.mark.unit
class TestBootstrap:
    """Test bootstrap functions."""

    @patch.dict(
        os.environ,
        {
            "STACK_NAME": "test-stack",
            "AWS_REGION": "us-east-1",
            "STRIPE_SECRET_KEY": "sk_test_123",
            "TABLE_NAME": "test-table",
            "FREE_USAGE_PLAN_ID": "FREE",
            "METERED_USAGE_PLAN_ID": "METERED",
        },
    )
    @patch("augint_billing_lib.bootstrap._discover_usage_plan_ids")
    @patch("augint_billing_lib.bootstrap.boto3")
    @patch("augint_billing_lib.bootstrap.StripeAdapter")
    @patch("augint_billing_lib.bootstrap.DdbRepo")
    @patch("augint_billing_lib.bootstrap.BillingService")
    def test_process_event_and_apply_plan_moves(
        self, mock_service_class, mock_repo_class, mock_stripe_class, mock_boto3, mock_discover
    ):
        """Test processing Stripe event and applying plan moves."""
        # Setup mocks
        mock_discover.return_value = {"FREE_10K": "free-id", "METERED": "metered-id"}

        mock_service = Mock()
        mock_service_class.return_value = mock_service
        mock_service.handle_stripe_event.return_value = {
            "target_plan": "metered",
            "stripe_customer_id": "cus_test",
            "subscription_item_id": "si_test",
        }
        mock_service.apply_plan_move_for_customer.return_value = 1

        event = {
            "detail": {
                "type": "payment_method.attached",
                "data": {"object": {"customer": "cus_test"}},
            }
        }

        result = process_event_and_apply_plan_moves(event)

        assert result["target_plan"] == "metered"
        assert result["moved"] == 1
        assert result["stripe_customer_id"] == "cus_test"
        mock_service.handle_stripe_event.assert_called_once()
        mock_service.apply_plan_move_for_customer.assert_called_once()

    @patch.dict(
        os.environ,
        {
            "STACK_NAME": "test-stack",
            "AWS_REGION": "us-east-1",
            "STRIPE_SECRET_KEY": "sk_test_123",
            "TABLE_NAME": "test-table",
        },
    )
    @patch("augint_billing_lib.bootstrap.boto3")
    @patch("augint_billing_lib.bootstrap.StripeAdapter")
    @patch("augint_billing_lib.bootstrap.DdbRepo")
    @patch("augint_billing_lib.bootstrap.ApiGwUsage")
    @patch("augint_billing_lib.bootstrap.BillingService")
    def test_build_service_with_usage(
        self, mock_service_class, mock_usage_class, mock_repo_class, mock_stripe_class, mock_boto3
    ):
        """Test building service with usage source."""
        # Setup mocks
        mock_table = Mock()
        mock_boto3.resource.return_value.Table.return_value = mock_table

        service = build_service(include_usage=True)

        assert service is not None
        mock_service_class.assert_called_once()
        mock_usage_class.assert_called_once()

    @patch.dict(
        os.environ,
        {
            "STACK_NAME": "test-stack",
            "AWS_REGION": "us-east-1",
            "STRIPE_SECRET_KEY": "sk_test_123",
            "TABLE_NAME": "test-table",
        },
    )
    @patch("augint_billing_lib.bootstrap.boto3")
    @patch("augint_billing_lib.bootstrap.StripeAdapter")
    @patch("augint_billing_lib.bootstrap.DdbRepo")
    @patch("augint_billing_lib.bootstrap.BillingService")
    def test_build_service_without_usage(
        self, mock_service_class, mock_repo_class, mock_stripe_class, mock_boto3
    ):
        """Test building service without usage source."""
        # Setup mocks
        mock_table = Mock()
        mock_boto3.resource.return_value.Table.return_value = mock_table

        service = build_service(include_usage=False)

        assert service is not None
        mock_service_class.assert_called_once()
        # Usage source should be None
        call_args = mock_service_class.call_args
        assert call_args[1].get("usage") is None


@pytest.mark.unit
class TestCfnOutputs:
    """Test suite for _cfn_outputs function exception handling."""

    @patch("augint_billing_lib.bootstrap.boto3")
    @patch("augint_billing_lib.bootstrap.log_event")
    def test_successful_stack_query(self, mock_log, mock_boto):
        """Test successful CloudFormation stack output retrieval."""
        # Arrange
        mock_client = MagicMock()
        mock_boto.client.return_value = mock_client
        mock_client.describe_stacks.return_value = {
            "Stacks": [
                {
                    "Outputs": [
                        {"OutputKey": "TableName", "OutputValue": "my-table"},
                        {"OutputKey": "ApiId", "OutputValue": "api-123"},
                    ]
                }
            ]
        }

        # Act
        result = bootstrap._cfn_outputs("test-stack", "us-east-1")

        # Assert
        assert result == {"TableName": "my-table", "ApiId": "api-123"}
        mock_client.describe_stacks.assert_called_once_with(StackName="test-stack")
        mock_log.assert_not_called()

    @patch("augint_billing_lib.bootstrap.boto3")
    @patch("augint_billing_lib.bootstrap.log_event")
    def test_stack_not_found_returns_empty_dict(self, mock_log, mock_boto):
        """Test ValidationError returns empty dict and logs info."""
        # Arrange
        mock_client = MagicMock()
        mock_boto.client.return_value = mock_client
        error_response = {
            "Error": {
                "Code": "ValidationError",
                "Message": "Stack with id test-stack does not exist",
            }
        }
        mock_client.describe_stacks.side_effect = ClientError(error_response, "DescribeStacks")

        # Act
        result = bootstrap._cfn_outputs("test-stack", "us-east-1")

        # Assert
        assert result == {}
        mock_log.assert_called_once_with("info", "stack_not_found", stack_name="test-stack")

    @patch("augint_billing_lib.bootstrap.boto3")
    @patch("augint_billing_lib.bootstrap.log_event")
    def test_access_denied_is_logged_and_raised(self, mock_log, mock_boto):
        """Test AccessDenied error is logged and re-raised."""
        # Arrange
        mock_client = MagicMock()
        mock_boto.client.return_value = mock_client
        error_response = {
            "Error": {
                "Code": "AccessDeniedException",
                "Message": "User is not authorized to perform: cloudformation:DescribeStacks",
            }
        }
        error = ClientError(error_response, "DescribeStacks")
        mock_client.describe_stacks.side_effect = error

        # Act & Assert
        with pytest.raises(ClientError) as exc_info:
            bootstrap._cfn_outputs("test-stack", "us-east-1")

        assert exc_info.value == error
        mock_log.assert_called_once_with(
            "error",
            "cloudformation_error",
            stack_name="test-stack",
            error_code="AccessDeniedException",
            error_message=str(error),
        )

    @patch("augint_billing_lib.bootstrap.boto3")
    @patch("augint_billing_lib.bootstrap.log_event")
    def test_throttling_is_logged_and_raised(self, mock_log, mock_boto):
        """Test throttling error is logged and re-raised."""
        # Arrange
        mock_client = MagicMock()
        mock_boto.client.return_value = mock_client
        error_response = {"Error": {"Code": "ThrottlingException", "Message": "Rate exceeded"}}
        error = ClientError(error_response, "DescribeStacks")
        mock_client.describe_stacks.side_effect = error

        # Act & Assert
        with pytest.raises(ClientError) as exc_info:
            bootstrap._cfn_outputs("test-stack", "us-east-1")

        assert exc_info.value == error
        mock_log.assert_called_once_with(
            "error",
            "cloudformation_error",
            stack_name="test-stack",
            error_code="ThrottlingException",
            error_message=str(error),
        )

    @patch("augint_billing_lib.bootstrap.boto3")
    @patch("augint_billing_lib.bootstrap.log_event")
    def test_network_error_is_logged_and_raised(self, mock_log, mock_boto):
        """Test network errors are logged and re-raised."""
        # Arrange
        mock_client = MagicMock()
        mock_boto.client.return_value = mock_client
        error = ConnectionError("Network is unreachable")
        mock_client.describe_stacks.side_effect = error

        # Act & Assert
        with pytest.raises(ConnectionError) as exc_info:
            bootstrap._cfn_outputs("test-stack", "us-east-1")

        assert exc_info.value == error
        mock_log.assert_called_once_with(
            "error",
            "unexpected_error_fetching_stack",
            stack_name="test-stack",
            error_type="ConnectionError",
            error=str(error),
        )

    @patch("augint_billing_lib.bootstrap.boto3")
    @patch("augint_billing_lib.bootstrap.log_event")
    def test_malformed_response_missing_outputs(self, mock_log, mock_boto):
        """Test handling of response missing expected keys."""
        # Arrange
        mock_client = MagicMock()
        mock_boto.client.return_value = mock_client

        # Response missing Outputs key
        mock_client.describe_stacks.return_value = {
            "Stacks": [{}]  # No Outputs key
        }

        # Act
        result = bootstrap._cfn_outputs("test-stack", "us-east-1")

        # Assert
        assert result == {}
        mock_log.assert_not_called()

    @patch("augint_billing_lib.bootstrap.boto3")
    @patch("augint_billing_lib.bootstrap.log_event")
    def test_malformed_response_empty_stacks(self, mock_log, mock_boto):
        """Test handling of empty stacks list."""
        # Arrange
        mock_client = MagicMock()
        mock_boto.client.return_value = mock_client
        mock_client.describe_stacks.return_value = {
            "Stacks": []  # Empty list
        }

        # Act
        result = bootstrap._cfn_outputs("test-stack", "us-east-1")

        # Assert
        assert result == {}
        mock_log.assert_not_called()

    @patch("augint_billing_lib.bootstrap.boto3")
    @patch("augint_billing_lib.bootstrap.log_event")
    def test_malformed_output_missing_keys(self, mock_log, mock_boto):
        """Test handling of outputs missing required keys."""
        # Arrange
        mock_client = MagicMock()
        mock_boto.client.return_value = mock_client
        mock_client.describe_stacks.return_value = {
            "Stacks": [
                {
                    "Outputs": [
                        {"OutputKey": "TableName", "OutputValue": "my-table"},
                        {"OutputKey": "MissingValue"},  # Missing OutputValue
                        {"OutputValue": "orphan-value"},  # Missing OutputKey
                    ]
                }
            ]
        }

        # Act
        result = bootstrap._cfn_outputs("test-stack", "us-east-1")

        # Assert
        # Only the complete output should be included
        assert result == {"TableName": "my-table"}
        mock_log.assert_not_called()

    @patch("augint_billing_lib.bootstrap.boto3")
    @patch("augint_billing_lib.bootstrap.log_event")
    def test_timeout_error_is_logged_and_raised(self, mock_log, mock_boto):
        """Test timeout errors are logged and re-raised."""
        # Arrange
        mock_client = MagicMock()
        mock_boto.client.return_value = mock_client
        error = TimeoutError("Request timed out")
        mock_client.describe_stacks.side_effect = error

        # Act & Assert
        with pytest.raises(TimeoutError) as exc_info:
            bootstrap._cfn_outputs("test-stack", "us-east-1")

        assert exc_info.value == error
        mock_log.assert_called_once_with(
            "error",
            "unexpected_error_fetching_stack",
            stack_name="test-stack",
            error_type="TimeoutError",
            error=str(error),
        )

    @patch("augint_billing_lib.bootstrap.boto3")
    @patch("augint_billing_lib.bootstrap.log_event")
    def test_json_decode_error_is_logged_and_raised(self, mock_log, mock_boto):
        """Test JSON parsing errors are logged and re-raised."""
        # Arrange
        mock_client = MagicMock()
        mock_boto.client.return_value = mock_client

        # Simulate a response that causes JSON parsing to fail
        error = ValueError("Invalid JSON response")
        mock_client.describe_stacks.side_effect = error

        # Act & Assert
        with pytest.raises(ValueError, match="Invalid JSON response") as exc_info:
            bootstrap._cfn_outputs("test-stack", "us-east-1")

        assert exc_info.value == error
        mock_log.assert_called_once_with(
            "error",
            "unexpected_error_fetching_stack",
            stack_name="test-stack",
            error_type="ValueError",
            error=str(error),
        )


@pytest.mark.unit
class TestDiscoverTableName:
    """Test suite for _discover_table_name function with new exception handling."""

    @patch("augint_billing_lib.bootstrap._cfn_outputs")
    def test_uses_cloudformation_output_when_available(self, mock_cfn):
        """Test that CloudFormation output is preferred."""
        # Arrange
        mock_cfn.return_value = {"TableName": "cfn-table"}

        # Act
        result = bootstrap._discover_table_name("test-stack", "us-east-1")

        # Assert
        assert result == "cfn-table"
        mock_cfn.assert_called_once_with("test-stack", "us-east-1")

    @patch.dict("os.environ", {"TABLE_NAME": "env-table"})
    @patch("augint_billing_lib.bootstrap._cfn_outputs")
    def test_falls_back_to_env_var_when_cfn_empty(self, mock_cfn):
        """Test fallback to TABLE_NAME env var."""
        # Arrange
        mock_cfn.return_value = {}

        # Act
        result = bootstrap._discover_table_name("test-stack", "us-east-1")

        # Assert
        assert result == "env-table"

    @patch.dict("os.environ", {}, clear=True)
    @patch("augint_billing_lib.bootstrap._cfn_outputs")
    def test_uses_default_when_no_config(self, mock_cfn):
        """Test fallback to default value."""
        # Arrange
        mock_cfn.return_value = {}

        # Act
        result = bootstrap._discover_table_name("test-stack", "us-east-1")

        # Assert
        assert result == "customer_links"

    @patch("augint_billing_lib.bootstrap.boto3")
    @patch("augint_billing_lib.bootstrap.log_event")
    def test_propagates_cfn_errors(self, mock_log, mock_boto):
        """Test that CloudFormation errors are propagated."""
        # Arrange
        mock_client = MagicMock()
        mock_boto.client.return_value = mock_client
        error_response = {"Error": {"Code": "AccessDeniedException", "Message": "Access denied"}}
        mock_client.describe_stacks.side_effect = ClientError(error_response, "DescribeStacks")

        # Act & Assert
        with pytest.raises(ClientError):
            bootstrap._discover_table_name("test-stack", "us-east-1")
