"""Tests for agent-initiated mutation notifications (Phase 2)."""

import json
import pytest
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock

from skop_runner.message_handler import MessageHandler
from skop_runner.file_manager import FileManager
from skop_runner.output_buffer import BUFFERED_METHODS


# =============================================================================
# Helpers
# =============================================================================

def _make_mock_backend():
    """Lightweight MagicMock backend for non-kernel tests."""
    backend = MagicMock()
    backend.start_kernel = AsyncMock(return_value="kernel-123")
    backend.stop_kernel = AsyncMock()
    backend.restart_kernel = AsyncMock()
    backend.interrupt_kernel = AsyncMock()
    backend.shutdown_all = AsyncMock(return_value={"stopped": [], "failed": {}})
    backend.execute_code = AsyncMock(
        return_value=AsyncMock(
            __aiter__=lambda self: self,
            __anext__=AsyncMock(side_effect=StopAsyncIteration),
        )
    )
    backend.send_input_reply = AsyncMock()
    backend.get_kernel_status = AsyncMock(
        return_value={"status": "not_found", "kernel_id": "nonexistent"}
    )
    backend.get_kernel_info = MagicMock(return_value=None)
    backend.get_kernel_connection_info = MagicMock(return_value=None)
    backend.list_kernels = MagicMock(return_value=[])
    backend.list_available_kernels = AsyncMock(return_value=[])
    backend.get_execution_count = MagicMock(return_value=0)
    backend.validate_kernel = AsyncMock(return_value=False)
    backend.is_ready = MagicMock(return_value=False)
    backend.set_root_directory = AsyncMock(return_value=str(Path(tempfile.gettempdir()) / "test"))
    backend.initialize = AsyncMock()
    backend.close = AsyncMock()
    return backend


NOTEBOOK_TEMPLATE = {
    "nbformat": 4,
    "nbformat_minor": 5,
    "metadata": {},
    "cells": [
        {
            "id": "cell-1",
            "cell_type": "code",
            "source": "print('hello')",
            "execution_count": None,
            "outputs": [],
            "metadata": {},
        },
        {
            "id": "cell-2",
            "cell_type": "markdown",
            "source": "# Title",
            "metadata": {},
        },
    ],
}


# =============================================================================
# Fixtures
# =============================================================================

@pytest.fixture
def temp_project():
    with tempfile.TemporaryDirectory() as tmpdir:
        project = Path(tmpdir)
        # Write a test notebook
        (project / "test.ipynb").write_text(json.dumps(NOTEBOOK_TEMPLATE))
        # Write a plain file
        (project / "hello.py").write_text("print('hi')")
        yield project


@pytest.fixture
def handler(temp_project):
    """MessageHandler wired with notification capture."""
    backend = _make_mock_backend()
    fm = FileManager(str(temp_project))
    h = MessageHandler(backend, fm)

    notifications: list[dict] = []

    async def capture(method: str, params: dict):
        notifications.append({"method": method, "params": params})

    h.set_notification_sender(capture)
    h.notifications = notifications  # expose for assertions
    return h


# =============================================================================
# Source tagging
# =============================================================================

class TestSourceTagging:
    @pytest.mark.asyncio
    async def test_agent_source_emits_notification(self, handler, temp_project):
        """Agent-sourced notebook.updateCell should emit notification."""
        resp = await handler.handle_message({
            "id": "1",
            "method": "notebook.updateCell",
            "source": "agent",
            "params": {"path": "test.ipynb", "cell_id": "cell-1", "source": "x = 1"},
        })
        assert "result" in resp
        assert resp["result"]["success"] is True
        assert len(handler.notifications) == 1
        assert handler.notifications[0]["method"] == "notebook.cellUpdated"

    @pytest.mark.asyncio
    async def test_browser_source_no_notification(self, handler, temp_project):
        """Request without source field should NOT emit notification."""
        resp = await handler.handle_message({
            "id": "1",
            "method": "notebook.updateCell",
            "params": {"path": "test.ipynb", "cell_id": "cell-1", "source": "x = 1"},
        })
        assert "result" in resp
        assert resp["result"]["success"] is True
        assert len(handler.notifications) == 0


# =============================================================================
# Notebook mutation notifications
# =============================================================================

class TestNotebookMutationNotifications:
    @pytest.mark.asyncio
    async def test_update_cell_agent_notification(self, handler):
        resp = await handler.handle_message({
            "id": "1",
            "method": "notebook.updateCell",
            "source": "agent",
            "params": {"path": "test.ipynb", "cell_id": "cell-1", "source": "new_code()"},
        })
        assert "result" in resp
        notif = handler.notifications[-1]
        assert notif["method"] == "notebook.cellUpdated"
        assert notif["params"]["path"] == "test.ipynb"
        assert notif["params"]["cell_id"] == "cell-1"
        assert notif["params"]["source"] == "new_code()"

    @pytest.mark.asyncio
    async def test_update_cell_no_agent_no_notification(self, handler):
        resp = await handler.handle_message({
            "id": "1",
            "method": "notebook.updateCell",
            "params": {"path": "test.ipynb", "cell_id": "cell-1", "source": "new_code()"},
        })
        assert "result" in resp
        assert len(handler.notifications) == 0

    @pytest.mark.asyncio
    async def test_add_cell_agent_notification(self, handler):
        resp = await handler.handle_message({
            "id": "1",
            "method": "notebook.addCell",
            "source": "agent",
            "params": {
                "path": "test.ipynb",
                "cell_type": "code",
                "source": "y = 2",
                "after_cell_id": "cell-1",
            },
        })
        assert "result" in resp
        new_cell_id = resp["result"]["cell_id"]
        notif = handler.notifications[-1]
        assert notif["method"] == "notebook.cellAdded"
        assert notif["params"]["path"] == "test.ipynb"
        assert notif["params"]["cell_id"] == new_cell_id
        assert notif["params"]["cell_type"] == "code"
        assert notif["params"]["source"] == "y = 2"
        assert notif["params"]["after_cell_id"] == "cell-1"

    @pytest.mark.asyncio
    async def test_add_cell_no_agent_no_notification(self, handler):
        resp = await handler.handle_message({
            "id": "1",
            "method": "notebook.addCell",
            "params": {"path": "test.ipynb", "cell_type": "code", "source": "y = 2"},
        })
        assert "result" in resp
        assert len(handler.notifications) == 0

    @pytest.mark.asyncio
    async def test_delete_cell_agent_notification(self, handler):
        resp = await handler.handle_message({
            "id": "1",
            "method": "notebook.deleteCell",
            "source": "agent",
            "params": {"path": "test.ipynb", "cell_id": "cell-1"},
        })
        assert "result" in resp
        notif = handler.notifications[-1]
        assert notif["method"] == "notebook.cellDeleted"
        assert notif["params"]["path"] == "test.ipynb"
        assert notif["params"]["cell_id"] == "cell-1"

    @pytest.mark.asyncio
    async def test_delete_cell_no_agent_no_notification(self, handler):
        resp = await handler.handle_message({
            "id": "1",
            "method": "notebook.deleteCell",
            "params": {"path": "test.ipynb", "cell_id": "cell-1"},
        })
        assert "result" in resp
        assert len(handler.notifications) == 0


# =============================================================================
# File mutation notifications
# =============================================================================

class TestFileMutationNotifications:
    @pytest.mark.asyncio
    async def test_file_write_agent_notification(self, handler):
        resp = await handler.handle_message({
            "id": "1",
            "method": "file.write",
            "source": "agent",
            "params": {"path": "hello.py", "content": "print('updated')"},
        })
        assert "result" in resp
        notif = handler.notifications[-1]
        assert notif["method"] == "file.written"
        assert notif["params"]["path"] == "hello.py"

    @pytest.mark.asyncio
    async def test_file_create_agent_notification(self, handler):
        resp = await handler.handle_message({
            "id": "1",
            "method": "file.create",
            "source": "agent",
            "params": {"path": "new_file.py"},
        })
        assert "result" in resp
        notif = handler.notifications[-1]
        assert notif["method"] == "file.created"
        assert notif["params"]["path"] == "new_file.py"
        assert notif["params"]["type"] == "file"

    @pytest.mark.asyncio
    async def test_file_create_folder_agent_notification(self, handler):
        resp = await handler.handle_message({
            "id": "1",
            "method": "file.createFolder",
            "source": "agent",
            "params": {"path": "new_dir"},
        })
        assert "result" in resp
        notif = handler.notifications[-1]
        assert notif["method"] == "file.created"
        assert notif["params"]["path"] == "new_dir"
        assert notif["params"]["type"] == "directory"

    @pytest.mark.asyncio
    async def test_file_delete_agent_notification(self, handler):
        resp = await handler.handle_message({
            "id": "1",
            "method": "file.delete",
            "source": "agent",
            "params": {"path": "hello.py"},
        })
        assert "result" in resp
        notif = handler.notifications[-1]
        assert notif["method"] == "file.deleted"
        assert notif["params"]["path"] == "hello.py"

    @pytest.mark.asyncio
    async def test_file_rename_agent_notification(self, handler, temp_project):
        resp = await handler.handle_message({
            "id": "1",
            "method": "file.rename",
            "source": "agent",
            "params": {"old_path": "hello.py", "new_path": "hello_renamed.py"},
        })
        assert "result" in resp
        notif = handler.notifications[-1]
        assert notif["method"] == "file.renamed"
        assert notif["params"]["old_path"] == "hello.py"
        assert notif["params"]["new_path"] == "hello_renamed.py"

    @pytest.mark.asyncio
    async def test_file_move_agent_notification(self, handler, temp_project):
        # Create a destination directory first
        (temp_project / "subdir").mkdir()
        resp = await handler.handle_message({
            "id": "1",
            "method": "file.move",
            "source": "agent",
            "params": {"source": "hello.py", "dest": "subdir/hello.py"},
        })
        assert "result" in resp
        notif = handler.notifications[-1]
        assert notif["method"] == "file.moved"
        assert notif["params"]["old_path"] == "hello.py"
        assert notif["params"]["new_path"] == "subdir/hello.py"

    # --- Non-agent (browser) file operations must NOT emit notifications ---

    @pytest.mark.asyncio
    async def test_file_write_no_agent_no_notification(self, handler):
        resp = await handler.handle_message({
            "id": "1",
            "method": "file.write",
            "params": {"path": "hello.py", "content": "print('updated')"},
        })
        assert "result" in resp
        assert len(handler.notifications) == 0

    @pytest.mark.asyncio
    async def test_file_create_no_agent_no_notification(self, handler):
        resp = await handler.handle_message({
            "id": "1",
            "method": "file.create",
            "params": {"path": "new_file.py"},
        })
        assert "result" in resp
        assert len(handler.notifications) == 0

    @pytest.mark.asyncio
    async def test_file_create_folder_no_agent_no_notification(self, handler):
        resp = await handler.handle_message({
            "id": "1",
            "method": "file.createFolder",
            "params": {"path": "new_dir"},
        })
        assert "result" in resp
        assert len(handler.notifications) == 0

    @pytest.mark.asyncio
    async def test_file_delete_no_agent_no_notification(self, handler):
        resp = await handler.handle_message({
            "id": "1",
            "method": "file.delete",
            "params": {"path": "hello.py"},
        })
        assert "result" in resp
        assert len(handler.notifications) == 0

    @pytest.mark.asyncio
    async def test_file_rename_no_agent_no_notification(self, handler):
        resp = await handler.handle_message({
            "id": "1",
            "method": "file.rename",
            "params": {"old_path": "hello.py", "new_path": "hello_renamed.py"},
        })
        assert "result" in resp
        assert len(handler.notifications) == 0

    @pytest.mark.asyncio
    async def test_file_move_no_agent_no_notification(self, handler, temp_project):
        (temp_project / "subdir").mkdir()
        resp = await handler.handle_message({
            "id": "1",
            "method": "file.move",
            "params": {"source": "hello.py", "dest": "subdir/hello.py"},
        })
        assert "result" in resp
        assert len(handler.notifications) == 0


# =============================================================================
# Optimistic concurrency
# =============================================================================

class TestOptimisticConcurrency:
    @pytest.mark.asyncio
    async def test_expected_source_matches(self, handler):
        """Update succeeds when expected_source matches the cell's current source."""
        resp = await handler.handle_message({
            "id": "1",
            "method": "notebook.updateCell",
            "source": "agent",
            "params": {
                "path": "test.ipynb",
                "cell_id": "cell-1",
                "source": "new_code()",
                "expected_source": "print('hello')",
            },
        })
        assert "result" in resp
        assert resp["result"]["success"] is True

    @pytest.mark.asyncio
    async def test_expected_source_mismatch(self, handler):
        """Update fails with -32009 when expected_source doesn't match."""
        resp = await handler.handle_message({
            "id": "1",
            "method": "notebook.updateCell",
            "source": "agent",
            "params": {
                "path": "test.ipynb",
                "cell_id": "cell-1",
                "source": "new_code()",
                "expected_source": "wrong_source()",
            },
        })
        assert "error" in resp
        assert resp["error"]["code"] == -32009
        assert resp["error"]["message"] == "Cell source changed"
        assert resp["error"]["data"]["current_source"] == "print('hello')"

    @pytest.mark.asyncio
    async def test_expected_source_omitted(self, handler):
        """Update succeeds when expected_source is not provided (backward compat)."""
        resp = await handler.handle_message({
            "id": "1",
            "method": "notebook.updateCell",
            "params": {
                "path": "test.ipynb",
                "cell_id": "cell-1",
                "source": "new_code()",
            },
        })
        assert "result" in resp
        assert resp["result"]["success"] is True

    @pytest.mark.asyncio
    async def test_expected_source_invalid_type(self, handler):
        """Non-string expected_source returns a validation error."""
        resp = await handler.handle_message({
            "id": "1",
            "method": "notebook.updateCell",
            "params": {
                "path": "test.ipynb",
                "cell_id": "cell-1",
                "source": "new_code()",
                "expected_source": 123,
            },
        })
        assert "error" in resp
        assert "expected_source must be a string" in resp["error"]["message"]


# =============================================================================
# Buffered methods
# =============================================================================

class TestBufferedMethods:
    def test_all_mutation_notifications_buffered(self):
        """All new notification types should be in BUFFERED_METHODS."""
        expected = {
            "notebook.cellUpdated",
            "notebook.cellAdded",
            "notebook.cellDeleted",
            "file.written",
            "file.created",
            "file.deleted",
            "file.renamed",
            "file.moved",
        }
        assert expected.issubset(BUFFERED_METHODS)
