"""Tests for message handler."""

import asyncio
import re
import pytest
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

from skop_runner.message_handler import MessageHandler
from skop_runner.file_manager import FileManager
from skop_runner.kernel_backend import KernelInfo, KernelStatus


# =============================================================================
# MockKernelBackend - simulates kernel behavior for unit tests
# =============================================================================


class MockKernelBackend:
    """Mock kernel backend that simulates kernel behavior without Jupyter."""

    def __init__(self):
        self._kernels = {}
        self._execution_count = 0
        self._input_event = asyncio.Event()
        self._input_value = None
        self._awaiting_input = False
        self._next_kernel_id = 0

    async def start_kernel(self, kernel_name="python3", python_path=None, env_name=None, cwd=None):
        kernel_id = f"mock-kernel-{self._next_kernel_id}"
        self._next_kernel_id += 1
        self._kernels[kernel_id] = {
            "kernel_name": kernel_name,
            "python_path": python_path or "/usr/bin/python3",
            "env_name": env_name,
        }
        return kernel_id

    async def stop_kernel(self, kernel_id):
        self._kernels.pop(kernel_id, None)

    async def restart_kernel(self, kernel_id):
        if kernel_id not in self._kernels:
            raise RuntimeError(f"Kernel {kernel_id} not found")

    async def interrupt_kernel(self, kernel_id):
        if kernel_id not in self._kernels:
            raise RuntimeError(f"Kernel {kernel_id} not found")

    async def shutdown_all(self, timeout=5.0):
        stopped = list(self._kernels.keys())
        self._kernels.clear()
        return {"stopped": stopped, "failed": {}}

    async def execute_code(self, kernel_id, code, timeout=300.0):
        self._execution_count += 1
        async for output in self._generate_outputs(code):
            yield output

    async def _generate_outputs(self, code):
        """Generate mock outputs based on code content."""
        if "clear_output" in code and "should be cleared" in code:
            yield {"output_type": "stream", "name": "stdout", "text": "should be cleared\n"}
            yield {"output_type": "clear_output", "wait": False}
            yield {"output_type": "stream", "name": "stdout", "text": "visible\n"}
        elif "clear_output" in code:
            yield {"output_type": "stream", "name": "stdout", "text": "before\n"}
            yield {"output_type": "clear_output", "wait": False}
            yield {"output_type": "stream", "name": "stdout", "text": "after\n"}
        elif "update_display" in code and "display_id='stream-test'" in code:
            yield {"output_type": "display_data", "data": {"text/html": "<b>v1</b>"}, "metadata": {}, "display_id": "stream-test"}
            yield {"output_type": "update_display_data", "data": {"text/html": "<b>v2</b>"}, "metadata": {}, "display_id": "stream-test"}
        elif "update_display" in code and "display_id='update-test'" in code:
            yield {"output_type": "display_data", "data": {"text/html": "<b>initial</b>"}, "metadata": {}, "display_id": "update-test"}
            yield {"output_type": "update_display_data", "data": {"text/html": "<b>final</b>"}, "metadata": {}, "display_id": "update-test"}
        elif "display_id='should-be-stripped'" in code:
            yield {"output_type": "display_data", "data": {"text/html": "<b>test</b>"}, "metadata": {}, "display_id": "should-be-stripped"}
        elif "input(" in code:
            match = re.search(r"input\(['\"](.+?)['\"]\)", code)
            prompt = match.group(1) if match else ""
            yield {"output_type": "input_request", "prompt": prompt, "password": False}
            self._awaiting_input = True
            self._input_event.clear()
            await self._input_event.wait()
            value = self._input_value
            if "Got:" in code:
                yield {"output_type": "stream", "name": "stdout", "text": f"Got: {value}\n"}
        else:
            yield {"output_type": "stream", "name": "stdout", "text": "Hello\n"}

    async def send_input_reply(self, kernel_id, value):
        if not self._awaiting_input:
            raise RuntimeError(f"Kernel {kernel_id} is not waiting for input")
        self._input_value = value
        self._awaiting_input = False
        self._input_event.set()

    async def get_kernel_status(self, kernel_id):
        if kernel_id in self._kernels:
            return {"status": "running", "kernel_id": kernel_id, "execution_state": "idle"}
        return {"status": "not_found", "kernel_id": kernel_id}

    def get_kernel_info(self, kernel_id):
        info = self._kernels.get(kernel_id)
        if info is None:
            return None
        return KernelInfo(
            kernel_id=kernel_id,
            status=KernelStatus.IDLE,
            python_path=info["python_path"],
            env_name=info["env_name"],
        )

    def get_kernel_connection_info(self, kernel_id):
        info = self._kernels.get(kernel_id)
        if info is None:
            return None
        return {
            "session_id": f"mock-session-{kernel_id}",
            "python_path": info["python_path"],
            "env_name": info["env_name"],
        }

    def list_kernels(self):
        return [{"kernel_id": k, "status": "running"} for k in self._kernels]

    async def list_available_kernels(self):
        return []

    def get_execution_count(self, kernel_id):
        return self._execution_count

    async def validate_kernel(self, kernel_id):
        return kernel_id in self._kernels

    def is_ready(self):
        return True

    async def set_root_directory(self, path):
        return path

    async def initialize(self, **kwargs):
        pass

    async def close(self):
        self._kernels.clear()


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


@pytest.fixture
def temp_project():
    """Create a temporary project directory."""
    with tempfile.TemporaryDirectory() as tmpdir:
        project = Path(tmpdir)
        (project / "test.py").write_text("print('hello')")
        (project / "src").mkdir()
        yield project


def _make_mock_backend():
    """Create a simple MagicMock KernelBackend 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


@pytest.fixture
def mock_backend():
    """Create a mock KernelBackend."""
    return _make_mock_backend()


@pytest.fixture
def message_handler(temp_project, mock_backend):
    """Create a message handler for non-kernel tests."""
    fm = FileManager(str(temp_project))
    return MessageHandler(mock_backend, fm)


@pytest.fixture
def kernel_message_handler(temp_project):
    """Create a message handler with MockKernelBackend for kernel tests."""
    backend = MockKernelBackend()
    fm = FileManager(str(temp_project))
    return MessageHandler(backend, fm)


class TestMessageRouting:
    """Test message routing and error handling."""

    @pytest.mark.asyncio
    async def test_missing_method(self, message_handler):
        """Test handling message without method."""
        response = await message_handler.handle_message({"id": "1"})

        assert response["jsonrpc"] == "2.0"
        assert response["id"] == "1"
        assert response["error"]["code"] == -32600
        assert "Missing method" in response["error"]["message"]

    @pytest.mark.asyncio
    async def test_unknown_method(self, message_handler):
        """Test handling unknown method."""
        response = await message_handler.handle_message({
            "id": "1",
            "method": "unknown.method"
        })

        assert response["error"]["code"] == -32601
        assert "Unknown method" in response["error"]["message"]

    @pytest.mark.asyncio
    async def test_missing_parameter(self, message_handler):
        """Test handling missing required parameter."""
        response = await message_handler.handle_message({
            "id": "1",
            "method": "kernel.stop",
            "params": {}
        })

        assert response["error"]["code"] == -32602
        assert "Missing parameter" in response["error"]["message"]


class TestFileHandlers:
    """Test file operation handlers."""

    @pytest.mark.asyncio
    async def test_file_list(self, message_handler, temp_project):
        """Test file.list handler."""
        response = await message_handler.handle_message({
            "id": "1",
            "method": "file.list",
            "params": {"path": ""}
        })

        assert "result" in response
        items = response["result"]["items"]
        names = [i["name"] for i in items]
        assert "src" in names
        assert "test.py" in names

    @pytest.mark.asyncio
    async def test_file_read(self, message_handler):
        """Test file.read handler."""
        response = await message_handler.handle_message({
            "id": "1",
            "method": "file.read",
            "params": {"path": "test.py"}
        })

        assert "result" in response
        assert response["result"]["content"] == "print('hello')"

    @pytest.mark.asyncio
    async def test_file_read_not_found(self, message_handler):
        """Test file.read with non-existent file."""
        response = await message_handler.handle_message({
            "id": "1",
            "method": "file.read",
            "params": {"path": "nonexistent.py"}
        })

        assert response["error"]["code"] == -32001
        assert "File not found" in response["error"]["message"]

    @pytest.mark.asyncio
    async def test_file_write(self, message_handler, temp_project):
        """Test file.write handler."""
        response = await message_handler.handle_message({
            "id": "1",
            "method": "file.write",
            "params": {"path": "new_file.txt", "content": "Hello!"}
        })

        assert response["result"]["success"] is True
        assert (temp_project / "new_file.txt").read_text() == "Hello!"

    @pytest.mark.asyncio
    async def test_file_create(self, message_handler, temp_project):
        """Test file.create handler."""
        response = await message_handler.handle_message({
            "id": "1",
            "method": "file.create",
            "params": {"path": "empty.txt"}
        })

        assert response["result"]["success"] is True
        assert (temp_project / "empty.txt").exists()

    @pytest.mark.asyncio
    async def test_file_create_already_exists(self, message_handler):
        """Test file.create with existing file."""
        response = await message_handler.handle_message({
            "id": "1",
            "method": "file.create",
            "params": {"path": "test.py"}
        })

        assert response["error"]["code"] == -32002

    @pytest.mark.asyncio
    async def test_file_create_folder(self, message_handler, temp_project):
        """Test file.createFolder handler."""
        response = await message_handler.handle_message({
            "id": "1",
            "method": "file.createFolder",
            "params": {"path": "new_folder"}
        })

        assert response["result"]["success"] is True
        assert (temp_project / "new_folder").is_dir()

    @pytest.mark.asyncio
    async def test_file_delete(self, message_handler, temp_project):
        """Test file.delete handler."""
        response = await message_handler.handle_message({
            "id": "1",
            "method": "file.delete",
            "params": {"path": "test.py"}
        })

        assert response["result"]["success"] is True
        assert not (temp_project / "test.py").exists()

    @pytest.mark.asyncio
    async def test_file_rename(self, message_handler, temp_project):
        """Test file.rename handler."""
        response = await message_handler.handle_message({
            "id": "1",
            "method": "file.rename",
            "params": {"old_path": "test.py", "new_path": "renamed.py"}
        })

        assert response["result"]["success"] is True
        assert not (temp_project / "test.py").exists()
        assert (temp_project / "renamed.py").exists()

    @pytest.mark.asyncio
    async def test_file_info(self, message_handler):
        """Test file.info handler."""
        response = await message_handler.handle_message({
            "id": "1",
            "method": "file.info",
            "params": {"path": "test.py"}
        })

        assert "result" in response
        info = response["result"]["info"]
        assert info["name"] == "test.py"
        assert info["type"] == "file"

    @pytest.mark.asyncio
    async def test_file_exists(self, message_handler):
        """Test file.exists handler."""
        response = await message_handler.handle_message({
            "id": "1",
            "method": "file.exists",
            "params": {"path": "test.py"}
        })

        assert response["result"]["exists"] is True

        response = await message_handler.handle_message({
            "id": "2",
            "method": "file.exists",
            "params": {"path": "nonexistent.py"}
        })

        assert response["result"]["exists"] is False


class TestWorkspaceHandlers:
    """Test workspace operation handlers."""

    @pytest.mark.asyncio
    async def test_workspace_get_root(self, message_handler, temp_project):
        """Test workspace.getRoot handler."""
        response = await message_handler.handle_message({
            "id": "1",
            "method": "workspace.getRoot",
            "params": {}
        })

        assert "result" in response
        # Use resolve() for macOS symlink handling
        assert response["result"]["root"] == str(temp_project.resolve())


class TestEnvHandlers:
    """Test environment operation handlers."""

    @pytest.mark.asyncio
    async def test_env_packages_requires_path(self, message_handler):
        """Test env.packages requires env_path or python_path."""
        response = await message_handler.handle_message({
            "id": "1",
            "method": "env.packages",
            "params": {}
        })

        assert "error" in response
        assert "env_path or python_path" in response["error"]["message"]

    @pytest.mark.asyncio
    async def test_env_packages_with_python_path(self, message_handler):
        """Test env.packages with python_path."""
        mock_packages = [{"name": "pip", "version": "23.0"}]

        with patch.object(
            message_handler.env_manager,
            "get_installed_packages",
            return_value=mock_packages,
        ):
            response = await message_handler.handle_message({
                "id": "1",
                "method": "env.packages",
                "params": {"python_path": "/usr/bin/python3"}
            })

        assert "result" in response
        assert response["result"]["packages"] == mock_packages

    @pytest.mark.asyncio
    async def test_env_packages_with_env_path(self, message_handler, temp_project):
        """Test env.packages with env_path."""
        mock_packages = [{"name": "requests", "version": "2.28.0"}]

        with patch.object(
            message_handler.env_manager,
            "get_installed_packages",
            return_value=mock_packages,
        ):
            response = await message_handler.handle_message({
                "id": "1",
                "method": "env.packages",
                "params": {"env_path": str(temp_project / ".venv")}
            })

        assert "result" in response
        assert response["result"]["packages"] == mock_packages


class TestKernelHandlers:
    """Test kernel operation handlers."""

    @pytest.mark.asyncio
    async def test_kernel_list_empty(self, kernel_message_handler):
        """Test kernel.list with no kernels."""
        response = await kernel_message_handler.handle_message({
            "id": "1",
            "method": "kernel.list",
            "params": {}
        })

        assert "result" in response
        assert response["result"]["kernels"] == []

    @pytest.mark.asyncio
    async def test_kernel_start_stop(self, kernel_message_handler):
        """Test kernel.start and kernel.stop handlers."""
        # Start kernel
        response = await kernel_message_handler.handle_message({
            "id": "1",
            "method": "kernel.start",
            "params": {}
        })

        assert "result" in response
        kernel_id = response["result"]["kernel_id"]
        assert kernel_id is not None

        # Check it's running
        response = await kernel_message_handler.handle_message({
            "id": "2",
            "method": "kernel.status",
            "params": {"kernel_id": kernel_id}
        })

        assert response["result"]["status"] == "running"

        # Stop kernel
        response = await kernel_message_handler.handle_message({
            "id": "3",
            "method": "kernel.stop",
            "params": {"kernel_id": kernel_id}
        })

        assert response["result"]["success"] is True

    @pytest.mark.asyncio
    async def test_kernel_execute(self, kernel_message_handler):
        """Test kernel.execute handler."""
        # Start kernel
        response = await kernel_message_handler.handle_message({
            "id": "1",
            "method": "kernel.start",
            "params": {}
        })
        kernel_id = response["result"]["kernel_id"]

        try:
            # Execute code
            response = await kernel_message_handler.handle_message({
                "id": "2",
                "method": "kernel.execute",
                "params": {"kernel_id": kernel_id, "code": "print('Hello')"}
            })

            assert "result" in response
            outputs = response["result"]["outputs"]
            assert any(o["output_type"] == "stream" for o in outputs)
        finally:
            # Cleanup
            await kernel_message_handler.handle_message({
                "id": "99",
                "method": "kernel.stop",
                "params": {"kernel_id": kernel_id}
            })

    @pytest.mark.asyncio
    async def test_kernel_not_found(self, kernel_message_handler):
        """Test kernel operations with invalid kernel_id."""
        response = await kernel_message_handler.handle_message({
            "id": "1",
            "method": "kernel.status",
            "params": {"kernel_id": "nonexistent"}
        })

        # Backend returns status: "not_found" for non-existent kernels
        assert "result" in response
        assert response["result"]["status"] == "not_found"


class TestStateSnapshot:
    """Test state.snapshot RPC handler."""

    @pytest.mark.asyncio
    async def test_state_snapshot_returns_sessions_and_kernels(self, kernel_message_handler):
        """Test state.snapshot returns sessions with execution state and kernels."""
        # Create a session (which starts a kernel)
        response = await kernel_message_handler.handle_message({
            "id": "1",
            "method": "session.create",
            "params": {"notebook_path": "test.ipynb"}
        })
        assert "result" in response
        kernel_id = response["result"]["kernel_id"]

        try:
            # Call state.snapshot
            response = await kernel_message_handler.handle_message({
                "id": "2",
                "method": "state.snapshot",
                "params": {}
            })

            result = response["result"]
            assert "sessions" in result
            assert "kernels" in result
            assert "project_root" in result

            # Verify sessions include execution_state
            assert len(result["sessions"]) == 1
            session = result["sessions"][0]
            assert session["notebook_path"] == "test.ipynb"
            assert session["kernel_id"] == kernel_id
            assert "execution_state" in session
            assert session["execution_state"] == "idle"

            # Verify kernels list
            assert len(result["kernels"]) >= 1
        finally:
            await kernel_message_handler.handle_message({
                "id": "99",
                "method": "kernel.stop",
                "params": {"kernel_id": kernel_id}
            })

    @pytest.mark.asyncio
    async def test_state_snapshot_empty_when_no_sessions(self, kernel_message_handler):
        """Test state.snapshot returns empty lists when no sessions."""
        response = await kernel_message_handler.handle_message({
            "id": "1",
            "method": "state.snapshot",
            "params": {}
        })

        result = response["result"]
        assert result["sessions"] == []
        assert result["kernels"] == []
        assert result["project_root"] is not None  # temp_project provides a root

    @pytest.mark.asyncio
    async def test_state_snapshot_multiple_sessions(self, kernel_message_handler):
        """Test state.snapshot with multiple sessions."""
        kernel_ids = []
        try:
            for name in ["nb1.ipynb", "nb2.ipynb"]:
                response = await kernel_message_handler.handle_message({
                    "id": f"create-{name}",
                    "method": "session.create",
                    "params": {"notebook_path": name}
                })
                kernel_ids.append(response["result"]["kernel_id"])

            response = await kernel_message_handler.handle_message({
                "id": "snap",
                "method": "state.snapshot",
                "params": {}
            })

            result = response["result"]
            assert len(result["sessions"]) == 2
            paths = {s["notebook_path"] for s in result["sessions"]}
            assert paths == {"nb1.ipynb", "nb2.ipynb"}
        finally:
            for kid in kernel_ids:
                await kernel_message_handler.handle_message({
                    "id": "cleanup",
                    "method": "kernel.stop",
                    "params": {"kernel_id": kid}
                })

    @pytest.mark.asyncio
    async def test_state_snapshot_includes_buffered_outputs(self, temp_project):
        """Test state.snapshot includes and drains buffered outputs."""
        from skop_runner.output_buffer import OutputBuffer

        output_buffer = OutputBuffer()
        output_buffer.add("kernel.output", {"cell_id": "c1", "output": {"output_type": "stream"}})
        output_buffer.add("kernel.statusChanged", {"kernel_id": "k1", "execution_state": "idle"})

        backend = MockKernelBackend()
        fm = FileManager(str(temp_project))
        handler = MessageHandler(backend, fm, output_buffer=output_buffer)

        response = await handler.handle_message({
            "id": "1",
            "method": "state.snapshot",
            "params": {}
        })

        result = response["result"]
        assert "buffered_outputs" in result
        assert len(result["buffered_outputs"]) == 2
        assert result["buffered_outputs"][0]["method"] == "kernel.output"
        assert result["buffered_outputs"][1]["method"] == "kernel.statusChanged"

    @pytest.mark.asyncio
    async def test_state_snapshot_drain_clears_buffer(self, temp_project):
        """Test that draining via state.snapshot clears the buffer."""
        from skop_runner.output_buffer import OutputBuffer

        output_buffer = OutputBuffer()
        output_buffer.add("kernel.output", {"cell_id": "c1"})

        backend = MockKernelBackend()
        fm = FileManager(str(temp_project))
        handler = MessageHandler(backend, fm, output_buffer=output_buffer)

        # First snapshot drains
        await handler.handle_message({"id": "1", "method": "state.snapshot", "params": {}})
        assert len(output_buffer) == 0

        # Second snapshot returns empty
        response = await handler.handle_message({"id": "2", "method": "state.snapshot", "params": {}})
        assert response["result"]["buffered_outputs"] == []

    @pytest.mark.asyncio
    async def test_state_snapshot_empty_when_no_buffer(self, kernel_message_handler):
        """Test state.snapshot returns empty buffered_outputs when no output_buffer provided."""
        response = await kernel_message_handler.handle_message({
            "id": "1",
            "method": "state.snapshot",
            "params": {}
        })

        result = response["result"]
        assert result["buffered_outputs"] == []


class TestNotebookHandlers:
    """Test notebook operation handlers."""

    @pytest.mark.asyncio
    async def test_notebook_create(self, message_handler, temp_project):
        """Test notebook.create handler."""
        response = await message_handler.handle_message({
            "id": "1",
            "method": "notebook.create",
            "params": {"path": "new_notebook.ipynb"}
        })

        assert "result" in response
        notebook = response["result"]["notebook"]
        assert notebook["nbformat"] == 4
        assert notebook["cells"] == []
        assert notebook["metadata"]["kernelspec"]["name"] == "python3"

        # Verify file was created
        assert (temp_project / "new_notebook.ipynb").exists()

    @pytest.mark.asyncio
    async def test_notebook_create_already_exists(self, message_handler, temp_project):
        """Test notebook.create when file already exists."""
        # Create file first
        (temp_project / "existing.ipynb").write_text("{}")

        response = await message_handler.handle_message({
            "id": "1",
            "method": "notebook.create",
            "params": {"path": "existing.ipynb"}
        })

        assert "error" in response
        assert response["error"]["code"] == -32002  # FileExistsError

    @pytest.mark.asyncio
    async def test_notebook_read(self, message_handler, temp_project):
        """Test notebook.read handler."""
        # Create a valid notebook first
        import json
        notebook_content = {
            "nbformat": 4,
            "nbformat_minor": 5,
            "metadata": {},
            "cells": [
                {
                    "id": "cell-1",
                    "cell_type": "code",
                    "source": "print('hello')",
                    "execution_count": None,
                    "outputs": [],
                    "metadata": {},
                }
            ]
        }
        (temp_project / "test.ipynb").write_text(json.dumps(notebook_content))

        response = await message_handler.handle_message({
            "id": "1",
            "method": "notebook.read",
            "params": {"path": "test.ipynb"}
        })

        assert "result" in response
        notebook = response["result"]["notebook"]
        assert notebook["nbformat"] == 4
        assert len(notebook["cells"]) == 1
        assert notebook["cells"][0]["source"] == "print('hello')"

    @pytest.mark.asyncio
    async def test_notebook_read_not_found(self, message_handler):
        """Test notebook.read with non-existent file."""
        response = await message_handler.handle_message({
            "id": "1",
            "method": "notebook.read",
            "params": {"path": "nonexistent.ipynb"}
        })

        assert "error" in response
        assert response["error"]["code"] == -32001  # FileNotFoundError

    @pytest.mark.asyncio
    async def test_notebook_write(self, message_handler, temp_project):
        """Test notebook.write handler."""
        notebook = {
            "nbformat": 4,
            "nbformat_minor": 5,
            "metadata": {
                "kernelspec": {
                    "name": "python3",
                    "display_name": "Python 3",
                    "language": "python",
                }
            },
            "cells": [
                {
                    "id": "cell-1",
                    "cell_type": "code",
                    "source": "x = 42",
                    "execution_count": 1,
                    "outputs": [],
                    "metadata": {},
                }
            ]
        }

        response = await message_handler.handle_message({
            "id": "1",
            "method": "notebook.write",
            "params": {"path": "output.ipynb", "notebook": notebook}
        })

        assert "result" in response
        assert response["result"]["success"] is True

        # Verify file was written
        assert (temp_project / "output.ipynb").exists()

    @pytest.mark.asyncio
    async def test_notebook_write_invalid(self, message_handler):
        """Test notebook.write with invalid notebook."""
        response = await message_handler.handle_message({
            "id": "1",
            "method": "notebook.write",
            "params": {"path": "bad.ipynb", "notebook": {"invalid": "structure"}}
        })

        assert "error" in response
        assert response["error"]["code"] == -32003  # ValueError

    @pytest.mark.asyncio
    async def test_notebook_validate_valid(self, message_handler):
        """Test notebook.validate with valid notebook."""
        notebook = {
            "nbformat": 4,
            "nbformat_minor": 5,
            "metadata": {},
            "cells": []
        }

        response = await message_handler.handle_message({
            "id": "1",
            "method": "notebook.validate",
            "params": {"notebook": notebook}
        })

        assert "result" in response
        assert response["result"]["valid"] is True
        assert response["result"]["errors"] == []

    @pytest.mark.asyncio
    async def test_notebook_validate_invalid(self, message_handler):
        """Test notebook.validate with invalid notebook."""
        response = await message_handler.handle_message({
            "id": "1",
            "method": "notebook.validate",
            "params": {"notebook": {"not": "a notebook"}}
        })

        assert "result" in response
        assert response["result"]["valid"] is False
        assert len(response["result"]["errors"]) > 0

    @pytest.mark.asyncio
    async def test_notebook_roundtrip(self, message_handler, temp_project):
        """Test creating, reading, modifying, and writing a notebook."""
        # 1. Create notebook
        response = await message_handler.handle_message({
            "id": "1",
            "method": "notebook.create",
            "params": {"path": "roundtrip.ipynb"}
        })
        assert "result" in response
        notebook = response["result"]["notebook"]

        # 2. Modify notebook (add a cell)
        notebook["cells"].append({
            "id": "new-cell",
            "cell_type": "code",
            "source": "print('added')",
            "execution_count": None,
            "outputs": [],
            "metadata": {},
        })

        # 3. Write modified notebook
        response = await message_handler.handle_message({
            "id": "2",
            "method": "notebook.write",
            "params": {"path": "roundtrip.ipynb", "notebook": notebook}
        })
        assert response["result"]["success"] is True

        # 4. Read it back
        response = await message_handler.handle_message({
            "id": "3",
            "method": "notebook.read",
            "params": {"path": "roundtrip.ipynb"}
        })
        loaded = response["result"]["notebook"]

        assert len(loaded["cells"]) == 1
        assert loaded["cells"][0]["source"] == "print('added')"

    @pytest.mark.asyncio
    async def test_notebook_path_traversal_blocked(self, message_handler):
        """Test that path traversal is blocked for notebook operations."""
        response = await message_handler.handle_message({
            "id": "1",
            "method": "notebook.read",
            "params": {"path": "../../../etc/passwd"}
        })

        assert "error" in response
        # Should be blocked by path validation


# =============================================================================
# Jupyter Protocol RPC Layer Tests
# =============================================================================


class TestJupyterProtocolStreaming:
    """Tests for Jupyter protocol message streaming through RPC layer."""

    @pytest.fixture
    async def kernel_handler(self, temp_project):
        """Create a message handler with MockKernelBackend and notification capture."""
        backend = MockKernelBackend()
        fm = FileManager(str(temp_project))
        handler = MessageHandler(backend, fm)

        # Track notifications
        notifications = []

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

        handler.set_notification_sender(capture_notification)
        handler.notifications = notifications  # Attach for test access

        # Start a kernel
        response = await handler.handle_message({
            "id": "1",
            "method": "kernel.start",
            "params": {}
        })
        handler.kernel_id = response["result"]["kernel_id"]

        yield handler

    @pytest.mark.asyncio
    async def test_clear_output_streamed_in_notification(self, kernel_handler):
        """kernel.execute streams clear_output in kernel.output notification."""
        code = """
from IPython.display import clear_output
print('before')
clear_output()
print('after')
"""
        response = await kernel_handler.handle_message({
            "id": "2",
            "method": "kernel.execute",
            "params": {
                "kernel_id": kernel_handler.kernel_id,
                "code": code,
                "cell_id": "test-cell-1"
            }
        })

        assert "result" in response

        # Find clear_output in streamed notifications
        clear_notifications = [
            n for n in kernel_handler.notifications
            if n["method"] == "kernel.output"
            and n["params"]["output"].get("output_type") == "clear_output"
        ]
        assert len(clear_notifications) >= 1
        assert "wait" in clear_notifications[0]["params"]["output"]

    @pytest.mark.asyncio
    async def test_clear_output_clears_final_outputs(self, kernel_handler):
        """clear_output (wait=False) clears outputs list in final response."""
        code = """
from IPython.display import clear_output
print('should be cleared')
clear_output(wait=False)
print('visible')
"""
        response = await kernel_handler.handle_message({
            "id": "2",
            "method": "kernel.execute",
            "params": {
                "kernel_id": kernel_handler.kernel_id,
                "code": code,
                "cell_id": "test-cell-2"
            }
        })

        assert "result" in response
        outputs = response["result"]["outputs"]

        # Only "visible" should remain, not "should be cleared"
        all_text = "".join(
            o.get("text", "") for o in outputs if o.get("output_type") == "stream"
        )
        assert "visible" in all_text
        assert "should be cleared" not in all_text

    @pytest.mark.asyncio
    async def test_update_display_data_streamed_with_display_id(self, kernel_handler):
        """kernel.execute streams update_display_data with display_id in notification."""
        code = """
from IPython.display import display, update_display, HTML
display(HTML('<b>v1</b>'), display_id='stream-test')
update_display(HTML('<b>v2</b>'), display_id='stream-test')
"""
        response = await kernel_handler.handle_message({
            "id": "2",
            "method": "kernel.execute",
            "params": {
                "kernel_id": kernel_handler.kernel_id,
                "code": code,
                "cell_id": "test-cell-3"
            }
        })

        assert "result" in response

        # Find update_display_data in streamed notifications
        update_notifications = [
            n for n in kernel_handler.notifications
            if n["method"] == "kernel.output"
            and n["params"]["output"].get("output_type") == "update_display_data"
        ]
        assert len(update_notifications) >= 1
        assert update_notifications[0]["params"]["output"]["display_id"] == "stream-test"

    @pytest.mark.asyncio
    async def test_update_display_data_updates_in_place(self, kernel_handler):
        """update_display_data updates existing display_data in final outputs."""
        code = """
from IPython.display import display, update_display, HTML
display(HTML('<b>initial</b>'), display_id='update-test')
update_display(HTML('<b>final</b>'), display_id='update-test')
"""
        response = await kernel_handler.handle_message({
            "id": "2",
            "method": "kernel.execute",
            "params": {
                "kernel_id": kernel_handler.kernel_id,
                "code": code,
                "cell_id": "test-cell-4"
            }
        })

        assert "result" in response
        outputs = response["result"]["outputs"]

        # Should have only one display_data output (updated in place)
        display_outputs = [o for o in outputs if o.get("output_type") == "display_data"]
        assert len(display_outputs) == 1
        # Should contain the updated content
        assert "final" in display_outputs[0]["data"]["text/html"]
        assert "initial" not in display_outputs[0]["data"]["text/html"]

    @pytest.mark.asyncio
    async def test_display_id_stripped_from_final_outputs(self, kernel_handler):
        """display_id is stripped from final outputs (not valid in nbformat)."""
        code = """
from IPython.display import display, HTML
display(HTML('<b>test</b>'), display_id='should-be-stripped')
"""
        response = await kernel_handler.handle_message({
            "id": "2",
            "method": "kernel.execute",
            "params": {
                "kernel_id": kernel_handler.kernel_id,
                "code": code,
                "cell_id": "test-cell-5"
            }
        })

        assert "result" in response
        outputs = response["result"]["outputs"]

        # display_id should be stripped from all outputs
        for output in outputs:
            assert "display_id" not in output

    @pytest.mark.asyncio
    async def test_input_request_streamed_in_notification(self, kernel_handler):
        """kernel.execute streams input_request in kernel.output notification."""
        outputs_received = []
        execution_done = asyncio.Event()

        async def run_execution():
            response = await kernel_handler.handle_message({
                "id": "2",
                "method": "kernel.execute",
                "params": {
                    "kernel_id": kernel_handler.kernel_id,
                    "code": "x = input('Enter: ')",
                    "cell_id": "test-cell-6"
                }
            })
            outputs_received.append(response)
            execution_done.set()

        task = asyncio.create_task(run_execution())

        # Wait for input_request notification
        for _ in range(50):
            await asyncio.sleep(0.1)
            input_notifications = [
                n for n in kernel_handler.notifications
                if n["method"] == "kernel.output"
                and n["params"]["output"].get("output_type") == "input_request"
            ]
            if input_notifications:
                break

        assert len(input_notifications) >= 1
        assert input_notifications[0]["params"]["output"]["prompt"] == "Enter: "
        assert input_notifications[0]["params"]["output"]["password"] is False

        # Send input reply to unblock
        await kernel_handler.handle_message({
            "id": "3",
            "method": "kernel.inputReply",
            "params": {
                "kernel_id": kernel_handler.kernel_id,
                "value": "test"
            }
        })

        await asyncio.wait_for(execution_done.wait(), timeout=5.0)
        task.cancel()
        try:
            await task
        except asyncio.CancelledError:
            pass

    @pytest.mark.asyncio
    async def test_input_request_not_in_final_outputs(self, kernel_handler):
        """input_request is transient and not included in final outputs."""
        execution_done = asyncio.Event()
        final_response = {}

        async def run_execution():
            response = await kernel_handler.handle_message({
                "id": "2",
                "method": "kernel.execute",
                "params": {
                    "kernel_id": kernel_handler.kernel_id,
                    "code": "x = input('prompt')",
                    "cell_id": "test-cell-7"
                }
            })
            final_response.update(response)
            execution_done.set()

        task = asyncio.create_task(run_execution())

        # Wait for input_request
        for _ in range(50):
            await asyncio.sleep(0.1)
            if any(
                n["params"]["output"].get("output_type") == "input_request"
                for n in kernel_handler.notifications
                if n["method"] == "kernel.output"
            ):
                break

        # Send reply
        await kernel_handler.handle_message({
            "id": "3",
            "method": "kernel.inputReply",
            "params": {"kernel_id": kernel_handler.kernel_id, "value": "test"}
        })

        await asyncio.wait_for(execution_done.wait(), timeout=5.0)

        # Verify input_request is NOT in final outputs
        outputs = final_response["result"]["outputs"]
        input_outputs = [o for o in outputs if o.get("output_type") == "input_request"]
        assert len(input_outputs) == 0

        task.cancel()
        try:
            await task
        except asyncio.CancelledError:
            pass


class TestKernelInputReplyRPC:
    """Tests for kernel.inputReply RPC handler."""

    @pytest.fixture
    async def kernel_handler(self, temp_project):
        """Create a message handler with MockKernelBackend."""
        backend = MockKernelBackend()
        fm = FileManager(str(temp_project))
        handler = MessageHandler(backend, fm)

        notifications = []

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

        handler.set_notification_sender(capture_notification)
        handler.notifications = notifications

        response = await handler.handle_message({
            "id": "1",
            "method": "kernel.start",
            "params": {}
        })
        handler.kernel_id = response["result"]["kernel_id"]

        yield handler

    @pytest.mark.asyncio
    async def test_input_reply_returns_success(self, kernel_handler):
        """kernel.inputReply returns success when kernel is awaiting input."""
        execution_done = asyncio.Event()

        async def run_execution():
            await kernel_handler.handle_message({
                "id": "2",
                "method": "kernel.execute",
                "params": {
                    "kernel_id": kernel_handler.kernel_id,
                    "code": "input('test')",
                    "cell_id": "test-cell"
                }
            })
            execution_done.set()

        task = asyncio.create_task(run_execution())

        # Wait for input_request
        for _ in range(50):
            await asyncio.sleep(0.1)
            if any(
                n["params"]["output"].get("output_type") == "input_request"
                for n in kernel_handler.notifications
                if n["method"] == "kernel.output"
            ):
                break

        # Send input reply via RPC
        response = await kernel_handler.handle_message({
            "id": "3",
            "method": "kernel.inputReply",
            "params": {
                "kernel_id": kernel_handler.kernel_id,
                "value": "test_value"
            }
        })

        assert "result" in response
        assert response["result"]["success"] is True

        await asyncio.wait_for(execution_done.wait(), timeout=5.0)
        task.cancel()
        try:
            await task
        except asyncio.CancelledError:
            pass

    @pytest.mark.asyncio
    async def test_input_reply_delivers_value_to_kernel(self, kernel_handler):
        """kernel.inputReply delivers the value and execution completes with it."""
        execution_done = asyncio.Event()
        final_response = {}

        async def run_execution():
            response = await kernel_handler.handle_message({
                "id": "2",
                "method": "kernel.execute",
                "params": {
                    "kernel_id": kernel_handler.kernel_id,
                    "code": "x = input('Enter: ')\nprint(f'Got: {x}')",
                    "cell_id": "test-cell"
                }
            })
            final_response.update(response)
            execution_done.set()

        task = asyncio.create_task(run_execution())

        # Wait for input_request
        for _ in range(50):
            await asyncio.sleep(0.1)
            if any(
                n["params"]["output"].get("output_type") == "input_request"
                for n in kernel_handler.notifications
                if n["method"] == "kernel.output"
            ):
                break

        # Send input reply
        await kernel_handler.handle_message({
            "id": "3",
            "method": "kernel.inputReply",
            "params": {
                "kernel_id": kernel_handler.kernel_id,
                "value": "hello_rpc"
            }
        })

        await asyncio.wait_for(execution_done.wait(), timeout=5.0)

        # Verify the input value was used
        outputs = final_response["result"]["outputs"]
        stream_outputs = [o for o in outputs if o.get("output_type") == "stream"]
        all_text = "".join(o.get("text", "") for o in stream_outputs)
        assert "Got: hello_rpc" in all_text

        task.cancel()
        try:
            await task
        except asyncio.CancelledError:
            pass

    @pytest.mark.asyncio
    async def test_input_reply_fails_when_not_awaiting(self, kernel_handler):
        """kernel.inputReply returns error when kernel is not awaiting input."""
        response = await kernel_handler.handle_message({
            "id": "2",
            "method": "kernel.inputReply",
            "params": {
                "kernel_id": kernel_handler.kernel_id,
                "value": "should_fail"
            }
        })

        assert "error" in response
        assert "not waiting for input" in response["error"]["message"]


# =============================================================================
# Protocol Compliance Tests
# =============================================================================


class TestProtocolCompliance:
    """Tests that verify MessageHandler doesn't leak Jupyter-specific imports."""

    def test_message_handler_no_jupyter_imports(self):
        """MessageHandler should not import Jupyter classes."""
        import ast

        handler_path = Path(__file__).parent.parent / "skop_runner" / "message_handler.py"
        source = handler_path.read_text()
        tree = ast.parse(source)

        forbidden = ["jupyter", "JupyterServerManager", "JupyterAPIClient"]

        for node in ast.walk(tree):
            if isinstance(node, ast.Import):
                for alias in node.names:
                    for f in forbidden:
                        assert f not in alias.name.lower(), \
                            f"Found forbidden import '{alias.name}' in message_handler.py"
            elif isinstance(node, ast.ImportFrom):
                if node.module:
                    for f in forbidden:
                        assert f not in node.module.lower(), \
                            f"Found forbidden import from '{node.module}' in message_handler.py"

    def test_message_handler_uses_kernel_backend(self):
        """MessageHandler should use KernelBackend, not KernelManager."""
        handler_path = Path(__file__).parent.parent / "skop_runner" / "message_handler.py"
        source = handler_path.read_text()

        # Should import KernelBackend
        assert "from .kernel_backend import KernelBackend" in source

        # Should not import KernelManager
        assert "from .kernel_manager import" not in source


class TestExecutionQueue:
    """Test execution queue integration in message handler."""

    @pytest.fixture
    async def kernel_handler(self, temp_project):
        """Create handler with MockKernelBackend, notification capture, and started kernel."""
        backend = MockKernelBackend()
        fm = FileManager(str(temp_project))
        handler = MessageHandler(backend, fm)

        notifications = []

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

        handler.set_notification_sender(capture_notification)
        handler.notifications = notifications

        # Start a kernel
        response = await handler.handle_message({
            "id": "1",
            "method": "kernel.start",
            "params": {}
        })
        handler.kernel_id = response["result"]["kernel_id"]

        yield handler

        # Cleanup
        await handler._execution_queue.cleanup_kernel(handler.kernel_id)

    @pytest.mark.asyncio
    async def test_kernel_execute_goes_through_queue(self, kernel_handler):
        """Execute 2 cells via queue, verify sequential execution and execution_start notifications."""
        # Execute two cells
        r1 = await kernel_handler.handle_message({
            "id": "2",
            "method": "kernel.execute",
            "params": {"kernel_id": kernel_handler.kernel_id, "code": "print('a')", "cell_id": "cell-a", "request_id": "req-a"}
        })
        r2 = await kernel_handler.handle_message({
            "id": "3",
            "method": "kernel.execute",
            "params": {"kernel_id": kernel_handler.kernel_id, "code": "print('b')", "cell_id": "cell-b", "request_id": "req-b"}
        })

        assert "result" in r1
        assert r1["result"]["request_id"] == "req-a"
        assert "result" in r2
        assert r2["result"]["request_id"] == "req-b"

        # Verify execution_start notifications were sent
        exec_starts = [
            n for n in kernel_handler.notifications
            if n["method"] == "kernel.output"
            and n["params"]["output"].get("output_type") == "execution_start"
        ]
        assert len(exec_starts) == 2
        assert exec_starts[0]["params"]["cell_id"] == "cell-a"
        assert exec_starts[1]["params"]["cell_id"] == "cell-b"

    @pytest.mark.asyncio
    async def test_execution_cancel_clears_queue(self, kernel_handler):
        """Cancel an empty queue — should be a no-op."""
        response = await kernel_handler.handle_message({
            "id": "2",
            "method": "execution.cancel",
            "params": {"kernel_id": kernel_handler.kernel_id}
        })

        assert "result" in response
        assert response["result"]["success"] is True
        assert response["result"]["cancelled_cells"] == []
        assert response["result"]["interrupted_cells"] == []

    @pytest.mark.asyncio
    async def test_execution_cancel_unknown_kernel(self, kernel_handler):
        """Cancel with unknown kernel — should be a no-op."""
        response = await kernel_handler.handle_message({
            "id": "2",
            "method": "execution.cancel",
            "params": {"kernel_id": "nonexistent-kernel"}
        })

        assert "result" in response
        assert response["result"]["success"] is True
        assert response["result"]["cancelled_cells"] == []

    @pytest.mark.asyncio
    async def test_state_snapshot_includes_execution_queues(self, kernel_handler):
        """State snapshot should include execution_queues key."""
        response = await kernel_handler.handle_message({
            "id": "2",
            "method": "state.snapshot",
            "params": {}
        })

        result = response["result"]
        assert "execution_queues" in result
        assert isinstance(result["execution_queues"], dict)

    @pytest.mark.asyncio
    async def test_execute_batch_sends_execution_end_per_cell(self, kernel_handler):
        """kernel.executeBatch sends execution_end notification after each cell completes."""
        response = await kernel_handler.handle_message({
            "id": "2",
            "method": "kernel.executeBatch",
            "params": {
                "kernel_id": kernel_handler.kernel_id,
                "cells": [
                    {"cell_id": "cell-a", "code": "x = 1", "request_id": "req-a"},
                    {"cell_id": "cell-b", "code": "y = 2", "request_id": "req-b"},
                ],
                "stop_on_error": True,
            },
        })

        assert "result" in response
        assert len(response["result"]["results"]) == 2

        # Verify execution_end notifications were sent (one per cell)
        exec_ends = [
            n for n in kernel_handler.notifications
            if n["method"] == "kernel.output"
            and n["params"]["output"].get("output_type") == "execution_end"
        ]
        assert len(exec_ends) == 2
        assert exec_ends[0]["params"]["cell_id"] == "cell-a"
        assert exec_ends[0]["params"]["output"]["status"] == "ok"
        assert exec_ends[0]["params"]["request_id"] == "req-a"
        assert exec_ends[1]["params"]["cell_id"] == "cell-b"
        assert exec_ends[1]["params"]["output"]["status"] == "ok"
        assert exec_ends[1]["params"]["request_id"] == "req-b"

        # Verify execution_start also sent (one per cell, before execution_end)
        exec_starts = [
            n for n in kernel_handler.notifications
            if n["method"] == "kernel.output"
            and n["params"]["output"].get("output_type") == "execution_start"
        ]
        assert len(exec_starts) == 2

    @pytest.mark.asyncio
    async def test_execute_batch_execution_end_includes_execution_count(self, kernel_handler):
        """kernel.executeBatch execution_end includes correct execution_count per cell."""
        response = await kernel_handler.handle_message({
            "id": "2",
            "method": "kernel.executeBatch",
            "params": {
                "kernel_id": kernel_handler.kernel_id,
                "cells": [
                    {"cell_id": "cell-a", "code": "x = 1", "request_id": "req-a"},
                    {"cell_id": "cell-b", "code": "y = 2", "request_id": "req-b"},
                    {"cell_id": "cell-c", "code": "z = 3", "request_id": "req-c"},
                ],
            },
        })

        assert "result" in response
        results = response["result"]["results"]
        assert len(results) == 3

        exec_ends = [
            n for n in kernel_handler.notifications
            if n["method"] == "kernel.output"
            and n["params"]["output"].get("output_type") == "execution_end"
        ]
        assert len(exec_ends) == 3

        # Each execution_end has matching execution_count from the result
        for i, end_notif in enumerate(exec_ends):
            assert end_notif["params"]["output"]["execution_count"] == results[i]["execution_count"]
            assert end_notif["params"]["output"]["status"] == results[i]["status"]

        # Verify ordering: execution_start → execution_end for each cell
        output_notifs = [
            n for n in kernel_handler.notifications
            if n["method"] == "kernel.output"
            and n["params"]["output"].get("output_type") in ("execution_start", "execution_end")
        ]
        # Should be: start-a, end-a, start-b, end-b, start-c, end-c
        assert len(output_notifs) == 6
        assert output_notifs[0]["params"]["output"]["output_type"] == "execution_start"
        assert output_notifs[0]["params"]["cell_id"] == "cell-a"
        assert output_notifs[1]["params"]["output"]["output_type"] == "execution_end"
        assert output_notifs[1]["params"]["cell_id"] == "cell-a"
        assert output_notifs[2]["params"]["output"]["output_type"] == "execution_start"
        assert output_notifs[2]["params"]["cell_id"] == "cell-b"
        assert output_notifs[3]["params"]["output"]["output_type"] == "execution_end"
        assert output_notifs[3]["params"]["cell_id"] == "cell-b"


class TestSessionUpdatePath:
    """Test session.updatePath RPC handler."""

    def _insert_session(self, handler, notebook_path, kernel_id="k-1", session_id="s-1"):
        """Directly insert a session into the session manager for testing."""
        from datetime import datetime
        from skop_runner.session_manager import NotebookSession

        sm = handler.session_manager
        normalized = sm._normalize_path(notebook_path)
        sm._sessions[normalized] = NotebookSession(
            session_id=session_id,
            kernel_id=kernel_id,
            notebook_path=normalized,
            created_at=datetime.now(),
        )

    @pytest.mark.asyncio
    async def test_renames_existing_session(self, message_handler):
        """session.updatePath renames a session from old to new path."""
        self._insert_session(message_handler, "old.ipynb")

        result = await message_handler.handle_message(
            {
                "method": "session.updatePath",
                "params": {"old_path": "old.ipynb", "new_path": "new.ipynb"},
            }
        )

        assert result["result"]["success"] is True
        assert result["result"]["updated"] is True

        # Verify session now lives under new path
        get_result = await message_handler.handle_message(
            {"method": "session.get", "params": {"notebook_path": "new.ipynb"}}
        )
        assert get_result["result"]["session"] is not None

        # Old path should have no session
        old_result = await message_handler.handle_message(
            {"method": "session.get", "params": {"notebook_path": "old.ipynb"}}
        )
        assert old_result["result"]["session"] is None

    @pytest.mark.asyncio
    async def test_returns_false_for_nonexistent_session(self, message_handler):
        """session.updatePath returns updated=False when no session exists."""
        result = await message_handler.handle_message(
            {
                "method": "session.updatePath",
                "params": {"old_path": "nonexistent.ipynb", "new_path": "new.ipynb"},
            }
        )

        assert result["result"]["success"] is True
        assert result["result"]["updated"] is False

    @pytest.mark.asyncio
    async def test_folder_rename(self, message_handler):
        """session.updatePath with is_folder=True renames nested sessions."""
        self._insert_session(message_handler, "folder/a.ipynb", kernel_id="k-a", session_id="s-a")
        self._insert_session(message_handler, "folder/sub/b.ipynb", kernel_id="k-b", session_id="s-b")

        result = await message_handler.handle_message(
            {
                "method": "session.updatePath",
                "params": {
                    "old_path": "folder",
                    "new_path": "renamed",
                    "is_folder": True,
                },
            }
        )

        assert result["result"]["success"] is True
        assert result["result"]["updated"] is True

        # Verify sessions moved
        a_result = await message_handler.handle_message(
            {"method": "session.get", "params": {"notebook_path": "renamed/a.ipynb"}}
        )
        assert a_result["result"]["session"] is not None

        b_result = await message_handler.handle_message(
            {"method": "session.get", "params": {"notebook_path": "renamed/sub/b.ipynb"}}
        )
        assert b_result["result"]["session"] is not None

    @pytest.mark.asyncio
    async def test_missing_old_path_returns_error(self, message_handler):
        """session.updatePath returns error when old_path is missing."""
        result = await message_handler.handle_message(
            {
                "method": "session.updatePath",
                "params": {"new_path": "new.ipynb"},
            }
        )

        assert "error" in result
        assert "old_path" in result["error"]["message"]

    @pytest.mark.asyncio
    async def test_missing_new_path_returns_error(self, message_handler):
        """session.updatePath returns error when new_path is missing."""
        result = await message_handler.handle_message(
            {
                "method": "session.updatePath",
                "params": {"old_path": "old.ipynb"},
            }
        )

        assert "error" in result
        assert "new_path" in result["error"]["message"]
