"""
Tests for outbox pattern components including:
- Broker factory (create_broker, get_available_brokers)
- State machine (SagaStateMachine, SagaStepStateMachine)
- Outbox types (OutboxEvent, OutboxConfig)
- Outbox storage (InMemoryOutboxStorage)
- Compensation graph edge cases
- Declarative saga decorators
"""

import pytest
import asyncio
from unittest.mock import Mock, patch, MagicMock, AsyncMock
from io import StringIO
import sys


# ============================================
# BROKER FACTORY TESTS
# ============================================

class TestBrokerFactory:
    
    def test_get_available_brokers_returns_list(self):
        """Test get_available_brokers returns at least memory"""
        from sage.outbox.brokers.factory import get_available_brokers
        
        available = get_available_brokers()
        
        assert isinstance(available, list)
        assert "memory" in available
    
    def test_print_available_brokers_output(self, capsys):
        """Test print_available_brokers produces output"""
        from sage.outbox.brokers.factory import print_available_brokers
        
        print_available_brokers()
        
        captured = capsys.readouterr()
        assert "Available Brokers" in captured.out
        assert "memory" in captured.out
    
    def test_create_broker_memory(self):
        """Test creating memory broker"""
        from sage.outbox.brokers.factory import create_broker
        from sage.outbox.brokers.memory import InMemoryBroker
        
        broker = create_broker("memory")
        
        assert isinstance(broker, InMemoryBroker)
    
    def test_create_broker_memory_uppercase(self):
        """Test creating memory broker with uppercase"""
        from sage.outbox.brokers.factory import create_broker
        from sage.outbox.brokers.memory import InMemoryBroker
        
        broker = create_broker("MEMORY")
        
        assert isinstance(broker, InMemoryBroker)
    
    def test_create_broker_memory_whitespace(self):
        """Test creating memory broker with whitespace"""
        from sage.outbox.brokers.factory import create_broker
        from sage.outbox.brokers.memory import InMemoryBroker
        
        broker = create_broker("  memory  ")
        
        assert isinstance(broker, InMemoryBroker)
    
    def test_create_broker_unknown_type(self):
        """Test creating broker with unknown type raises ValueError"""
        from sage.outbox.brokers.factory import create_broker
        
        with pytest.raises(ValueError) as exc_info:
            create_broker("unknown_broker")
        
        assert "Unknown broker type:" in str(exc_info.value)
        assert "unknown_broker" in str(exc_info.value)
    
    def test_create_broker_kafka_available(self):
        """Test creating Kafka broker when available"""
        from sage.outbox.brokers.factory import create_broker, get_available_brokers
        
        if "kafka" not in get_available_brokers():
            pytest.skip("Kafka not available")
        
        broker = create_broker("kafka")
        assert broker is not None
    
    def test_create_broker_kafka_with_config(self):
        """Test creating Kafka broker with configuration"""
        from sage.outbox.brokers.factory import create_broker, get_available_brokers
        
        if "kafka" not in get_available_brokers():
            pytest.skip("Kafka not available")
        
        broker = create_broker(
            "kafka",
            bootstrap_servers="localhost:9092",
            client_id="test-client"
        )
        assert broker is not None
    
    def test_create_broker_rabbitmq_available(self):
        """Test creating RabbitMQ broker when available"""
        from sage.outbox.brokers.factory import create_broker, get_available_brokers
        
        if "rabbitmq" not in get_available_brokers():
            pytest.skip("RabbitMQ not available")
        
        broker = create_broker("rabbitmq")
        assert broker is not None
    
    def test_create_broker_rabbitmq_aliases(self):
        """Test creating RabbitMQ broker with aliases"""
        from sage.outbox.brokers.factory import create_broker, get_available_brokers
        
        if "rabbitmq" not in get_available_brokers():
            pytest.skip("RabbitMQ not available")
        
        # Test 'rabbit' alias
        broker1 = create_broker("rabbit")
        assert broker1 is not None
        
        # Test 'amqp' alias
        broker2 = create_broker("amqp")
        assert broker2 is not None
    
    def test_create_broker_from_env_memory(self, monkeypatch):
        """Test create_broker_from_env with memory"""
        from sage.outbox.brokers.factory import create_broker_from_env
        from sage.outbox.brokers.memory import InMemoryBroker
        
        monkeypatch.setenv("BROKER_TYPE", "memory")
        
        broker = create_broker_from_env()
        
        assert isinstance(broker, InMemoryBroker)
    
    def test_create_broker_from_env_default(self, monkeypatch):
        """Test create_broker_from_env defaults to memory"""
        from sage.outbox.brokers.factory import create_broker_from_env
        from sage.outbox.brokers.memory import InMemoryBroker
        
        # Remove env var if set
        monkeypatch.delenv("BROKER_TYPE", raising=False)
        
        broker = create_broker_from_env()
        
        assert isinstance(broker, InMemoryBroker)
    
    def test_create_broker_from_env_unknown(self, monkeypatch):
        """Test create_broker_from_env with unknown type raises ValueError"""
        from sage.outbox.brokers.factory import create_broker_from_env
        
        monkeypatch.setenv("BROKER_TYPE", "unknown_type")
        
        with pytest.raises(ValueError) as exc_info:
            create_broker_from_env()
        
        assert "Unknown BROKER_TYPE" in str(exc_info.value)


# ============================================
# STATE MACHINE TESTS
# ============================================

class TestSagaStateMachine:
    
    @pytest.mark.asyncio
    async def test_state_machine_with_saga_with_steps(self):
        """Test state machine has_steps guard with saga that has steps"""
        from sage.state_machine import SagaStateMachine
        
        # Mock saga with steps
        mock_saga = Mock()
        mock_saga.steps = [Mock(), Mock()]
        mock_saga.completed_steps = []
        
        sm = SagaStateMachine(saga=mock_saga)
        
        assert sm.has_steps() is True
    
    @pytest.mark.asyncio
    async def test_state_machine_with_saga_no_steps(self):
        """Test state machine has_steps guard with saga without steps"""
        from sage.state_machine import SagaStateMachine
        
        # Mock saga without steps
        mock_saga = Mock()
        mock_saga.steps = []
        mock_saga.completed_steps = []
        
        sm = SagaStateMachine(saga=mock_saga)
        
        assert sm.has_steps() is False
    
    @pytest.mark.asyncio
    async def test_state_machine_has_completed_steps_true(self):
        """Test has_completed_steps guard returns True when steps completed"""
        from sage.state_machine import SagaStateMachine
        
        mock_saga = Mock()
        mock_saga.steps = [Mock()]
        mock_saga.completed_steps = [Mock()]
        
        sm = SagaStateMachine(saga=mock_saga)
        
        assert sm.has_completed_steps() is True
    
    @pytest.mark.asyncio
    async def test_state_machine_has_completed_steps_false(self):
        """Test has_completed_steps guard returns False when no steps completed"""
        from sage.state_machine import SagaStateMachine
        
        mock_saga = Mock()
        mock_saga.steps = [Mock()]
        mock_saga.completed_steps = []
        
        sm = SagaStateMachine(saga=mock_saga)
        
        assert sm.has_completed_steps() is False
    
    @pytest.mark.asyncio
    async def test_state_machine_callbacks_with_saga(self):
        """Test state machine callbacks invoke saga methods"""
        from sage.state_machine import SagaStateMachine
        
        # Mock saga with callback methods
        mock_saga = Mock()
        mock_saga.steps = [Mock()]
        mock_saga.completed_steps = []
        mock_saga._on_enter_executing = AsyncMock()
        mock_saga._on_exit_pending = AsyncMock()
        
        sm = SagaStateMachine(saga=mock_saga)
        await sm.activate_initial_state()
        
        # Trigger start transition
        await sm.start()
        
        # Verify callbacks were called
        mock_saga._on_enter_executing.assert_awaited_once()
        mock_saga._on_exit_pending.assert_awaited_once()
    
    @pytest.mark.asyncio
    async def test_state_machine_callback_saga_without_method(self):
        """Test state machine handles saga without callback methods gracefully"""
        from sage.state_machine import SagaStateMachine
        
        # Mock saga without callback methods - use spec to strictly control attributes
        mock_saga = Mock(spec=['steps', 'completed_steps'])
        mock_saga.steps = [Mock()]
        mock_saga.completed_steps = []
        
        sm = SagaStateMachine(saga=mock_saga)
        await sm.activate_initial_state()
        
        # Should not raise even without callbacks
        await sm.start()
        assert sm.current_state.id == "executing"
    
    def test_validate_state_transition_valid(self):
        """Test validate_state_transition with valid transitions"""
        from sage.state_machine import validate_state_transition
        
        assert validate_state_transition("Pending", "Executing") is True
        assert validate_state_transition("Executing", "Completed") is True
        assert validate_state_transition("Executing", "Compensating") is True
        assert validate_state_transition("Executing", "Failed") is True
        assert validate_state_transition("Compensating", "RolledBack") is True
        assert validate_state_transition("Compensating", "Failed") is True
    
    def test_validate_state_transition_invalid(self):
        """Test validate_state_transition with invalid transitions"""
        from sage.state_machine import validate_state_transition
        
        assert validate_state_transition("Pending", "Completed") is False
        assert validate_state_transition("Completed", "Executing") is False
        assert validate_state_transition("Failed", "Completed") is False
        assert validate_state_transition("RolledBack", "Pending") is False
    
    def test_get_valid_next_states(self):
        """Test get_valid_next_states returns correct states"""
        from sage.state_machine import get_valid_next_states
        
        assert get_valid_next_states("Pending") == ["Executing"]
        assert set(get_valid_next_states("Executing")) == {"Completed", "Compensating", "Failed"}
        assert set(get_valid_next_states("Compensating")) == {"RolledBack", "Failed"}
        assert get_valid_next_states("Completed") == []
        assert get_valid_next_states("Failed") == []
        assert get_valid_next_states("RolledBack") == []
    
    def test_get_valid_next_states_unknown(self):
        """Test get_valid_next_states with unknown state"""
        from sage.state_machine import get_valid_next_states
        
        assert get_valid_next_states("Unknown") == []


class TestSagaStepStateMachine:
    
    @pytest.mark.asyncio
    async def test_step_state_machine_initialization(self):
        """Test step state machine initializes correctly"""
        from sage.state_machine import SagaStepStateMachine
        
        sm = SagaStepStateMachine(step_name="test_step")
        await sm.activate_initial_state()
        
        assert sm.step_name == "test_step"
        assert sm.current_state.id == "pending"
    
    @pytest.mark.asyncio
    async def test_step_state_machine_full_success_path(self):
        """Test step state machine through success path"""
        from sage.state_machine import SagaStepStateMachine
        
        sm = SagaStepStateMachine(step_name="payment")
        await sm.activate_initial_state()
        
        await sm.start()
        assert sm.current_state.id == "executing"
        
        await sm.succeed()
        assert sm.current_state.id == "completed"
    
    @pytest.mark.asyncio
    async def test_step_state_machine_failure_path(self):
        """Test step state machine through failure path"""
        from sage.state_machine import SagaStepStateMachine
        
        sm = SagaStepStateMachine(step_name="failing_step")
        await sm.activate_initial_state()
        
        await sm.start()
        await sm.fail()
        
        assert sm.current_state.id == "failed"
    
    @pytest.mark.asyncio
    async def test_step_state_machine_compensation_path(self):
        """Test step state machine through compensation path"""
        from sage.state_machine import SagaStepStateMachine
        
        sm = SagaStepStateMachine(step_name="compensated_step")
        await sm.activate_initial_state()
        
        await sm.start()
        await sm.succeed()
        await sm.compensate()
        
        assert sm.current_state.id == "compensating"
        
        await sm.compensation_success()
        assert sm.current_state.id == "compensated"
    
    @pytest.mark.asyncio
    async def test_step_state_machine_compensation_failure(self):
        """Test step compensation failure transitions to failed"""
        from sage.state_machine import SagaStepStateMachine
        
        sm = SagaStepStateMachine(step_name="comp_fail_step")
        await sm.activate_initial_state()
        
        await sm.start()
        await sm.succeed()
        await sm.compensate()
        await sm.compensation_failure()
        
        assert sm.current_state.id == "failed"


# ============================================
# OUTBOX TYPES TESTS
# ============================================

class TestOutboxTypes:
    
    def test_outbox_config_defaults(self):
        """Test OutboxConfig has sensible defaults"""
        from sage.outbox.types import OutboxConfig
        
        config = OutboxConfig()
        
        assert config.batch_size == 100
        assert config.poll_interval_seconds == 1.0
        assert config.max_retries == 10
    
    def test_outbox_config_custom_values(self):
        """Test OutboxConfig with custom values"""
        from sage.outbox.types import OutboxConfig
        
        config = OutboxConfig(
            batch_size=50,
            poll_interval_seconds=2.5,
            max_retries=5
        )
        
        assert config.batch_size == 50
        assert config.poll_interval_seconds == 2.5
        assert config.max_retries == 5
    
    def test_outbox_config_from_env(self, monkeypatch):
        """Test OutboxConfig.from_env reads environment"""
        from sage.outbox.types import OutboxConfig
        
        monkeypatch.setenv("OUTBOX_BATCH_SIZE", "200")
        monkeypatch.setenv("OUTBOX_MAX_RETRIES", "15")
        
        config = OutboxConfig.from_env()
        
        assert config.batch_size == 200
        assert config.max_retries == 15
    
    def test_outbox_event_to_dict(self):
        """Test OutboxEvent to_dict method"""
        from sage.outbox.types import OutboxEvent
        
        event = OutboxEvent(
            saga_id="saga-123",
            event_type="order.created",
            payload={"order_id": "123", "amount": 99.99}
        )
        
        event_dict = event.to_dict()
        
        assert event_dict["saga_id"] == "saga-123"
        assert event_dict["event_type"] == "order.created"
        assert "payload" in event_dict
        assert "created_at" in event_dict
    
    def test_outbox_event_from_dict(self):
        """Test OutboxEvent from_dict method"""
        from sage.outbox.types import OutboxEvent, OutboxStatus
        from datetime import datetime, timezone
        
        data = {
            "event_id": "evt-123",
            "saga_id": "saga-456",
            "event_type": "payment.processed",
            "payload": {"amount": 50.00},
            "status": "pending",
        }
        
        event = OutboxEvent.from_dict(data)
        
        assert event.event_id == "evt-123"
        assert event.saga_id == "saga-456"
        assert event.event_type == "payment.processed"
        assert event.status == OutboxStatus.PENDING
    
    def test_outbox_event_aggregate_id_defaults_to_saga_id(self):
        """Test aggregate_id defaults to saga_id"""
        from sage.outbox.types import OutboxEvent
        
        event = OutboxEvent(
            saga_id="saga-789",
            event_type="test.event",
            payload={}
        )
        
        assert event.aggregate_id == "saga-789"


# ============================================
# OUTBOX STORAGE MEMORY TESTS  
# ============================================

class TestOutboxMemoryStorage:
    
    @pytest.mark.asyncio
    async def test_memory_storage_get_nonexistent_event(self):
        """Test getting nonexistent event returns None"""
        from sage.outbox.storage.memory import InMemoryOutboxStorage
        
        storage = InMemoryOutboxStorage()
        
        event = await storage.get_by_id("nonexistent-id")
        
        assert event is None
    
    @pytest.mark.asyncio
    async def test_memory_storage_update_nonexistent_raises(self):
        """Test updating nonexistent event raises error"""
        from sage.outbox.storage.memory import InMemoryOutboxStorage
        from sage.outbox.storage.base import OutboxStorageError
        from sage.outbox.types import OutboxStatus
        
        storage = InMemoryOutboxStorage()
        
        with pytest.raises(OutboxStorageError):
            await storage.update_status("nonexistent-id", OutboxStatus.SENT)
    
    @pytest.mark.asyncio
    async def test_memory_storage_claim_empty(self):
        """Test claiming events when none available"""
        from sage.outbox.storage.memory import InMemoryOutboxStorage
        
        storage = InMemoryOutboxStorage()
        
        events = await storage.claim_batch(worker_id="worker-1", batch_size=10)
        
        assert events == []
    
    @pytest.mark.asyncio
    async def test_memory_storage_insert_and_claim(self):
        """Test inserting and claiming events"""
        from sage.outbox.storage.memory import InMemoryOutboxStorage
        from sage.outbox.types import OutboxEvent, OutboxStatus
        
        storage = InMemoryOutboxStorage()
        
        # Insert event
        event = OutboxEvent(
            saga_id="saga-1",
            event_type="test.event",
            payload={"data": "test"}
        )
        await storage.insert(event)
        
        # Claim it
        claimed = await storage.claim_batch(worker_id="worker-1", batch_size=10)
        
        assert len(claimed) == 1
        assert claimed[0].event_id == event.event_id
        assert claimed[0].status == OutboxStatus.CLAIMED
        assert claimed[0].worker_id == "worker-1"
    
    @pytest.mark.asyncio
    async def test_memory_storage_get_pending_count(self):
        """Test getting pending event count"""
        from sage.outbox.storage.memory import InMemoryOutboxStorage
        from sage.outbox.types import OutboxEvent
        
        storage = InMemoryOutboxStorage()
        
        # Initially empty
        assert await storage.get_pending_count() == 0
        
        # Add event
        event = OutboxEvent(
            saga_id="saga-1",
            event_type="test.event",
            payload={}
        )
        await storage.insert(event)
        
        assert await storage.get_pending_count() == 1
    
    @pytest.mark.asyncio
    async def test_memory_storage_clear(self):
        """Test clearing all events"""
        from sage.outbox.storage.memory import InMemoryOutboxStorage
        from sage.outbox.types import OutboxEvent
        
        storage = InMemoryOutboxStorage()
        
        event = OutboxEvent(
            saga_id="saga-1",
            event_type="test.event",
            payload={}
        )
        await storage.insert(event)
        
        storage.clear()
        
        assert await storage.get_pending_count() == 0


# ============================================
# KAFKA/RABBITMQ BROKER TESTS (Mock-based)
# ============================================

class TestKafkaBrokerMocked:
    """Tests for Kafka broker using mocks"""
    
    def test_kafka_broker_config_defaults(self):
        """Test KafkaBrokerConfig has sensible defaults"""
        from sage.outbox.brokers.kafka import KafkaBrokerConfig, KAFKA_AVAILABLE
        
        if not KAFKA_AVAILABLE:
            pytest.skip("Kafka not available")
        
        config = KafkaBrokerConfig()
        
        assert config.bootstrap_servers is not None
        assert config.client_id is not None
    
    def test_kafka_broker_from_env(self, monkeypatch):
        """Test KafkaBroker.from_env creates broker from environment"""
        from sage.outbox.brokers.kafka import KafkaBroker, KAFKA_AVAILABLE
        
        if not KAFKA_AVAILABLE:
            pytest.skip("Kafka not available")
        
        monkeypatch.setenv("KAFKA_BOOTSTRAP_SERVERS", "localhost:9092")
        monkeypatch.setenv("KAFKA_CLIENT_ID", "test-client")
        
        broker = KafkaBroker.from_env()
        
        assert broker is not None
        assert broker.config.bootstrap_servers == "localhost:9092"


class TestRabbitMQBrokerMocked:
    """Tests for RabbitMQ broker using mocks"""
    
    def test_rabbitmq_broker_config_defaults(self):
        """Test RabbitMQBrokerConfig has sensible defaults"""
        from sage.outbox.brokers.rabbitmq import RabbitMQBrokerConfig, RABBITMQ_AVAILABLE
        
        if not RABBITMQ_AVAILABLE:
            pytest.skip("RabbitMQ not available")
        
        config = RabbitMQBrokerConfig()
        
        assert config.url is not None
        assert config.exchange_name is not None
    
    def test_rabbitmq_broker_from_env(self, monkeypatch):
        """Test RabbitMQBroker.from_env creates broker from environment"""
        from sage.outbox.brokers.rabbitmq import RabbitMQBroker, RABBITMQ_AVAILABLE
        
        if not RABBITMQ_AVAILABLE:
            pytest.skip("RabbitMQ not available")
        
        monkeypatch.setenv("RABBITMQ_URL", "amqp://guest:guest@localhost/")
        monkeypatch.setenv("RABBITMQ_EXCHANGE", "test-exchange")
        
        broker = RabbitMQBroker.from_env()
        
        assert broker is not None
        assert broker.config.exchange_name == "test-exchange"


# ============================================
# COMPENSATION GRAPH EDGE CASES
# ============================================

class TestCompensationGraph:
    
    def test_compensation_graph_empty(self):
        """Test compensation graph with no nodes"""
        from sage.compensation_graph import SagaCompensationGraph
        
        graph = SagaCompensationGraph()
        
        order = graph.get_compensation_order()
        
        assert order == []
    
    def test_compensation_graph_single_step(self):
        """Test compensation graph with single registered step"""
        from sage.compensation_graph import SagaCompensationGraph, CompensationType
        
        async def compensate(ctx):
            pass
        
        graph = SagaCompensationGraph()
        graph.register_compensation("step1", compensate, compensation_type=CompensationType.MECHANICAL)
        graph.mark_step_executed("step1")
        
        order = graph.get_compensation_order()
        
        assert len(order) == 1
        assert "step1" in order[0]
    
    def test_compensation_graph_multiple_independent(self):
        """Test compensation graph with independent steps"""
        from sage.compensation_graph import SagaCompensationGraph
        
        async def compensate(ctx):
            pass
        
        graph = SagaCompensationGraph()
        graph.register_compensation("step1", compensate)
        graph.register_compensation("step2", compensate)
        graph.register_compensation("step3", compensate)
        graph.mark_step_executed("step1")
        graph.mark_step_executed("step2")
        graph.mark_step_executed("step3")
        
        order = graph.get_compensation_order()
        
        # All independent, should be in single level
        assert len(order) == 1
        assert set(order[0]) == {"step1", "step2", "step3"}
    
    def test_compensation_graph_repr(self):
        """Test compensation graph string representation"""
        from sage.compensation_graph import SagaCompensationGraph
        
        graph = SagaCompensationGraph()
        
        repr_str = repr(graph)
        
        assert "SagaCompensationGraph" in repr_str
        assert "nodes=0" in repr_str


# ============================================
# DECORATORS EDGE CASES
# ============================================

class TestDeclarativeSaga:
    
    @pytest.mark.asyncio
    async def test_saga_empty_run(self):
        """Test running saga with no steps"""
        from sage.decorators import Saga
        
        class EmptySaga(Saga):
            pass
        
        saga = EmptySaga()
        # run() returns the context dict for declarative sagas
        result = await saga.run({})
        
        assert isinstance(result, dict)
        # Empty saga returns empty context
        assert len(saga.get_steps()) == 0
    
    @pytest.mark.asyncio
    async def test_saga_step_with_no_compensation(self):
        """Test saga step without compensation defined"""
        from sage.decorators import Saga, step
        
        class NoCompSaga(Saga):
            @step(name="action_only")
            async def action_only(self, ctx):
                return {"done": True}
        
        saga = NoCompSaga()
        result = await saga.run({})
        
        assert isinstance(result, dict)
        assert result.get("done") is True
        assert len(saga.get_steps()) == 1


# ============================================
# OUTBOX ERRORS COVERAGE
# ============================================

class TestOutboxErrors:
    
    def test_outbox_publish_error(self):
        """Test OutboxPublishError includes event info"""
        from sage.outbox.types import OutboxEvent, OutboxPublishError
        
        event = OutboxEvent(
            saga_id="saga-1",
            event_type="test.event",
            payload={}
        )
        
        original_error = ValueError("Connection refused")
        error = OutboxPublishError(event, "Broker unavailable", cause=original_error)
        
        assert event.event_id in str(error)
        assert "Broker unavailable" in str(error)
        assert error.cause == original_error
    
    def test_outbox_claim_error(self):
        """Test OutboxClaimError"""
        from sage.outbox.types import OutboxClaimError
        
        error = OutboxClaimError("Failed to acquire lock")
        
        assert "lock" in str(error)
