# tests/unit/services/memory/procedural/test_memory.py
"""Unit tests for ProceduralMemory class."""

from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4

import pytest

from app.services.memory.procedural.memory import ProceduralMemory
from app.services.memory.types import ProcedureCreate, Step


def create_mock_procedure_model(
    name="deploy_api",
    trigger_pattern="deploy.*api",
    project_id=None,
    agent_type_id=None,
    success_count=5,
    failure_count=1,
):
    """Create a mock procedure model for testing."""
    mock = MagicMock()
    mock.id = uuid4()
    mock.project_id = project_id
    mock.agent_type_id = agent_type_id
    mock.name = name
    mock.trigger_pattern = trigger_pattern
    mock.steps = [
        {"order": 1, "action": "build", "parameters": {}},
        {"order": 2, "action": "test", "parameters": {}},
        {"order": 3, "action": "deploy", "parameters": {}},
    ]
    mock.success_count = success_count
    mock.failure_count = failure_count
    mock.last_used = datetime.now(UTC)
    mock.embedding = None
    mock.created_at = datetime.now(UTC)
    mock.updated_at = datetime.now(UTC)
    mock.success_rate = (
        success_count / (success_count + failure_count)
        if (success_count + failure_count) > 0
        else 0.0
    )
    mock.total_uses = success_count + failure_count
    return mock


class TestProceduralMemoryInit:
    """Tests for ProceduralMemory initialization."""

    def test_init_creates_memory(self) -> None:
        """Test that init creates memory instance."""
        mock_session = AsyncMock()
        memory = ProceduralMemory(session=mock_session)

        assert memory._session is mock_session

    def test_init_with_embedding_generator(self) -> None:
        """Test init with embedding generator."""
        mock_session = AsyncMock()
        mock_embedding_gen = AsyncMock()
        memory = ProceduralMemory(
            session=mock_session, embedding_generator=mock_embedding_gen
        )

        assert memory._embedding_generator is mock_embedding_gen

    @pytest.mark.asyncio
    async def test_create_factory_method(self) -> None:
        """Test create factory method."""
        mock_session = AsyncMock()
        memory = await ProceduralMemory.create(session=mock_session)

        assert memory is not None
        assert memory._session is mock_session


class TestProceduralMemoryRecordProcedure:
    """Tests for procedure recording methods."""

    @pytest.fixture
    def mock_session(self) -> AsyncMock:
        """Create a mock database session."""
        session = AsyncMock()
        session.add = MagicMock()
        session.flush = AsyncMock()
        session.refresh = AsyncMock()
        # Mock no existing procedure found
        mock_result = MagicMock()
        mock_result.scalar_one_or_none.return_value = None
        session.execute.return_value = mock_result
        return session

    @pytest.fixture
    def memory(self, mock_session: AsyncMock) -> ProceduralMemory:
        """Create a ProceduralMemory instance."""
        return ProceduralMemory(session=mock_session)

    @pytest.mark.asyncio
    async def test_record_new_procedure(
        self,
        memory: ProceduralMemory,
        mock_session: AsyncMock,
    ) -> None:
        """Test recording a new procedure."""
        procedure_data = ProcedureCreate(
            name="build_project",
            trigger_pattern="build.*project",
            steps=[
                {"order": 1, "action": "npm install"},
                {"order": 2, "action": "npm run build"},
            ],
            project_id=uuid4(),
        )

        result = await memory.record_procedure(procedure_data)

        assert result.name == "build_project"
        assert result.trigger_pattern == "build.*project"
        mock_session.add.assert_called_once()
        mock_session.flush.assert_called_once()

    @pytest.mark.asyncio
    async def test_record_updates_existing(
        self,
        memory: ProceduralMemory,
        mock_session: AsyncMock,
    ) -> None:
        """Test that recording duplicate procedure updates existing."""
        # Mock existing procedure found
        existing_mock = create_mock_procedure_model()
        find_result = MagicMock()
        find_result.scalar_one_or_none.return_value = existing_mock

        # Mock update result
        updated_mock = create_mock_procedure_model(success_count=6)
        update_result = MagicMock()
        update_result.scalar_one.return_value = updated_mock

        mock_session.execute.side_effect = [find_result, update_result]

        procedure_data = ProcedureCreate(
            name="deploy_api",
            trigger_pattern="deploy.*api",
            steps=[{"order": 1, "action": "deploy"}],
        )

        _ = await memory.record_procedure(procedure_data)

        # Should have called execute twice (find + update)
        assert mock_session.execute.call_count == 2


class TestProceduralMemoryFindMatching:
    """Tests for procedure matching methods."""

    @pytest.fixture
    def mock_session(self) -> AsyncMock:
        """Create a mock database session."""
        session = AsyncMock()
        mock_result = MagicMock()
        mock_result.scalars.return_value.all.return_value = []
        session.execute.return_value = mock_result
        return session

    @pytest.fixture
    def memory(self, mock_session: AsyncMock) -> ProceduralMemory:
        """Create a ProceduralMemory instance."""
        return ProceduralMemory(session=mock_session)

    @pytest.mark.asyncio
    async def test_find_matching(
        self,
        memory: ProceduralMemory,
    ) -> None:
        """Test finding matching procedures."""
        results = await memory.find_matching("deploy api")

        assert isinstance(results, list)

    @pytest.mark.asyncio
    async def test_find_matching_with_project_filter(
        self,
        memory: ProceduralMemory,
    ) -> None:
        """Test finding matching procedures with project filter."""
        project_id = uuid4()
        results = await memory.find_matching(
            "deploy api",
            project_id=project_id,
        )

        assert isinstance(results, list)

    @pytest.mark.asyncio
    async def test_find_matching_returns_results(
        self,
        memory: ProceduralMemory,
        mock_session: AsyncMock,
    ) -> None:
        """Test that find_matching returns results."""
        procedures = [
            create_mock_procedure_model(name="deploy_api"),
            create_mock_procedure_model(name="deploy_frontend"),
        ]

        mock_result = MagicMock()
        mock_result.scalars.return_value.all.return_value = procedures
        mock_session.execute.return_value = mock_result

        results = await memory.find_matching("deploy")

        assert len(results) == 2


class TestProceduralMemoryGetBestProcedure:
    """Tests for get_best_procedure method."""

    @pytest.fixture
    def mock_session(self) -> AsyncMock:
        """Create a mock database session."""
        session = AsyncMock()
        return session

    @pytest.fixture
    def memory(self, mock_session: AsyncMock) -> ProceduralMemory:
        """Create a ProceduralMemory instance."""
        return ProceduralMemory(session=mock_session)

    @pytest.mark.asyncio
    async def test_get_best_procedure_none(
        self,
        memory: ProceduralMemory,
        mock_session: AsyncMock,
    ) -> None:
        """Test get_best_procedure returns None when no match."""
        mock_result = MagicMock()
        mock_result.scalars.return_value.all.return_value = []
        mock_session.execute.return_value = mock_result

        result = await memory.get_best_procedure("unknown_task")

        assert result is None

    @pytest.mark.asyncio
    async def test_get_best_procedure_returns_highest_success_rate(
        self,
        memory: ProceduralMemory,
        mock_session: AsyncMock,
    ) -> None:
        """Test get_best_procedure returns highest success rate."""
        low_success = create_mock_procedure_model(
            name="deploy_v1", success_count=3, failure_count=7
        )
        high_success = create_mock_procedure_model(
            name="deploy_v2", success_count=9, failure_count=1
        )

        mock_result = MagicMock()
        mock_result.scalars.return_value.all.return_value = [high_success, low_success]
        mock_session.execute.return_value = mock_result

        result = await memory.get_best_procedure("deploy")

        assert result is not None
        assert result.name == "deploy_v2"


class TestProceduralMemoryRecordOutcome:
    """Tests for outcome recording."""

    @pytest.fixture
    def mock_session(self) -> AsyncMock:
        """Create a mock database session."""
        session = AsyncMock()
        return session

    @pytest.fixture
    def memory(self, mock_session: AsyncMock) -> ProceduralMemory:
        """Create a ProceduralMemory instance."""
        return ProceduralMemory(session=mock_session)

    @pytest.mark.asyncio
    async def test_record_outcome_success(
        self,
        memory: ProceduralMemory,
        mock_session: AsyncMock,
    ) -> None:
        """Test recording a successful outcome."""
        existing_mock = create_mock_procedure_model()

        # First query: find procedure
        find_result = MagicMock()
        find_result.scalar_one_or_none.return_value = existing_mock

        # Second query: update
        updated_mock = create_mock_procedure_model(success_count=6)
        update_result = MagicMock()
        update_result.scalar_one.return_value = updated_mock

        mock_session.execute.side_effect = [find_result, update_result]

        result = await memory.record_outcome(existing_mock.id, success=True)

        assert result.success_count == 6

    @pytest.mark.asyncio
    async def test_record_outcome_failure(
        self,
        memory: ProceduralMemory,
        mock_session: AsyncMock,
    ) -> None:
        """Test recording a failure outcome."""
        existing_mock = create_mock_procedure_model()

        # First query: find procedure
        find_result = MagicMock()
        find_result.scalar_one_or_none.return_value = existing_mock

        # Second query: update
        updated_mock = create_mock_procedure_model(failure_count=2)
        update_result = MagicMock()
        update_result.scalar_one.return_value = updated_mock

        mock_session.execute.side_effect = [find_result, update_result]

        result = await memory.record_outcome(existing_mock.id, success=False)

        assert result.failure_count == 2

    @pytest.mark.asyncio
    async def test_record_outcome_not_found(
        self,
        memory: ProceduralMemory,
        mock_session: AsyncMock,
    ) -> None:
        """Test recording outcome for non-existent procedure raises error."""
        mock_result = MagicMock()
        mock_result.scalar_one_or_none.return_value = None
        mock_session.execute.return_value = mock_result

        with pytest.raises(ValueError, match="Procedure not found"):
            await memory.record_outcome(uuid4(), success=True)


class TestProceduralMemoryUpdateSteps:
    """Tests for step updates."""

    @pytest.fixture
    def mock_session(self) -> AsyncMock:
        """Create a mock database session."""
        session = AsyncMock()
        return session

    @pytest.fixture
    def memory(self, mock_session: AsyncMock) -> ProceduralMemory:
        """Create a ProceduralMemory instance."""
        return ProceduralMemory(session=mock_session)

    @pytest.mark.asyncio
    async def test_update_steps(
        self,
        memory: ProceduralMemory,
        mock_session: AsyncMock,
    ) -> None:
        """Test updating steps."""
        existing_mock = create_mock_procedure_model()

        # First query: find procedure
        find_result = MagicMock()
        find_result.scalar_one_or_none.return_value = existing_mock

        # Second query: update
        updated_mock = create_mock_procedure_model()
        updated_mock.steps = [
            {"order": 1, "action": "new_step_1", "parameters": {}},
            {"order": 2, "action": "new_step_2", "parameters": {}},
        ]
        update_result = MagicMock()
        update_result.scalar_one.return_value = updated_mock

        mock_session.execute.side_effect = [find_result, update_result]

        new_steps = [
            Step(order=1, action="new_step_1"),
            Step(order=2, action="new_step_2"),
        ]

        result = await memory.update_steps(existing_mock.id, new_steps)

        assert len(result.steps) == 2

    @pytest.mark.asyncio
    async def test_update_steps_not_found(
        self,
        memory: ProceduralMemory,
        mock_session: AsyncMock,
    ) -> None:
        """Test updating steps for non-existent procedure raises error."""
        mock_result = MagicMock()
        mock_result.scalar_one_or_none.return_value = None
        mock_session.execute.return_value = mock_result

        with pytest.raises(ValueError, match="Procedure not found"):
            await memory.update_steps(uuid4(), [Step(order=1, action="test")])


class TestProceduralMemoryStats:
    """Tests for statistics methods."""

    @pytest.fixture
    def mock_session(self) -> AsyncMock:
        """Create a mock database session."""
        session = AsyncMock()
        return session

    @pytest.fixture
    def memory(self, mock_session: AsyncMock) -> ProceduralMemory:
        """Create a ProceduralMemory instance."""
        return ProceduralMemory(session=mock_session)

    @pytest.mark.asyncio
    async def test_get_stats_empty(
        self,
        memory: ProceduralMemory,
        mock_session: AsyncMock,
    ) -> None:
        """Test getting stats for empty project."""
        mock_result = MagicMock()
        mock_result.scalars.return_value.all.return_value = []
        mock_session.execute.return_value = mock_result

        stats = await memory.get_stats(uuid4())

        assert stats["total_procedures"] == 0
        assert stats["avg_success_rate"] == 0.0

    @pytest.mark.asyncio
    async def test_get_stats_with_data(
        self,
        memory: ProceduralMemory,
        mock_session: AsyncMock,
    ) -> None:
        """Test getting stats with data."""
        procedures = [
            create_mock_procedure_model(success_count=8, failure_count=2),
            create_mock_procedure_model(success_count=6, failure_count=4),
        ]

        mock_result = MagicMock()
        mock_result.scalars.return_value.all.return_value = procedures
        mock_session.execute.return_value = mock_result

        stats = await memory.get_stats(uuid4())

        assert stats["total_procedures"] == 2
        assert stats["total_uses"] == 20  # (8+2) + (6+4)

    @pytest.mark.asyncio
    async def test_count(
        self,
        memory: ProceduralMemory,
        mock_session: AsyncMock,
    ) -> None:
        """Test counting procedures."""
        procedures = [create_mock_procedure_model() for _ in range(5)]
        mock_result = MagicMock()
        mock_result.scalars.return_value.all.return_value = procedures
        mock_session.execute.return_value = mock_result

        count = await memory.count(uuid4())

        assert count == 5


class TestProceduralMemoryDelete:
    """Tests for delete operations."""

    @pytest.fixture
    def mock_session(self) -> AsyncMock:
        """Create a mock database session."""
        session = AsyncMock()
        return session

    @pytest.fixture
    def memory(self, mock_session: AsyncMock) -> ProceduralMemory:
        """Create a ProceduralMemory instance."""
        return ProceduralMemory(session=mock_session)

    @pytest.mark.asyncio
    async def test_delete(
        self,
        memory: ProceduralMemory,
        mock_session: AsyncMock,
    ) -> None:
        """Test deleting a procedure."""
        existing_mock = create_mock_procedure_model()
        mock_result = MagicMock()
        mock_result.scalar_one_or_none.return_value = existing_mock
        mock_session.execute.return_value = mock_result
        mock_session.delete = AsyncMock()

        result = await memory.delete(existing_mock.id)

        assert result is True
        mock_session.delete.assert_called_once()

    @pytest.mark.asyncio
    async def test_delete_not_found(
        self,
        memory: ProceduralMemory,
        mock_session: AsyncMock,
    ) -> None:
        """Test deleting non-existent procedure."""
        mock_result = MagicMock()
        mock_result.scalar_one_or_none.return_value = None
        mock_session.execute.return_value = mock_result

        result = await memory.delete(uuid4())

        assert result is False


class TestProceduralMemoryGetById:
    """Tests for get_by_id method."""

    @pytest.fixture
    def mock_session(self) -> AsyncMock:
        """Create a mock database session."""
        session = AsyncMock()
        return session

    @pytest.fixture
    def memory(self, mock_session: AsyncMock) -> ProceduralMemory:
        """Create a ProceduralMemory instance."""
        return ProceduralMemory(session=mock_session)

    @pytest.mark.asyncio
    async def test_get_by_id(
        self,
        memory: ProceduralMemory,
        mock_session: AsyncMock,
    ) -> None:
        """Test getting procedure by ID."""
        existing_mock = create_mock_procedure_model()
        mock_result = MagicMock()
        mock_result.scalar_one_or_none.return_value = existing_mock
        mock_session.execute.return_value = mock_result

        result = await memory.get_by_id(existing_mock.id)

        assert result is not None
        assert result.name == "deploy_api"

    @pytest.mark.asyncio
    async def test_get_by_id_not_found(
        self,
        memory: ProceduralMemory,
        mock_session: AsyncMock,
    ) -> None:
        """Test get_by_id returns None when not found."""
        mock_result = MagicMock()
        mock_result.scalar_one_or_none.return_value = None
        mock_session.execute.return_value = mock_result

        result = await memory.get_by_id(uuid4())

        assert result is None
