"""Tests for SessionManager."""

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

from skop_runner.session_manager import SessionManager, NotebookSession


class TestNotebookSession:
    """Tests for NotebookSession dataclass."""

    def test_create_session(self):
        """Test creating a NotebookSession."""
        now = datetime.now()
        session = NotebookSession(
            session_id="session-123",
            kernel_id="kernel-456",
            notebook_path="notebooks/test.ipynb",
            created_at=now,
            kernel_name="python3",
            python_path="/usr/bin/python3",
            env_name="my-env",
        )

        assert session.session_id == "session-123"
        assert session.kernel_id == "kernel-456"
        assert session.notebook_path == "notebooks/test.ipynb"
        assert session.created_at == now
        assert session.kernel_name == "python3"
        assert session.python_path == "/usr/bin/python3"
        assert session.env_name == "my-env"

    def test_session_defaults(self):
        """Test NotebookSession default values."""
        session = NotebookSession(
            session_id="session-123",
            kernel_id="kernel-456",
            notebook_path="test.ipynb",
            created_at=datetime.now(),
        )

        assert session.kernel_name == "python3"
        assert session.python_path is None
        assert session.env_name is None


class TestSessionManager:
    """Tests for SessionManager."""

    @pytest.fixture
    def mock_kernel_backend(self):
        """Create a mock KernelBackend."""
        backend = MagicMock()
        backend.start_kernel = AsyncMock(return_value="kernel-123")
        backend.stop_kernel = AsyncMock()
        backend.get_kernel_connection_info = MagicMock(return_value=None)
        return backend

    @pytest.fixture
    def session_manager(self, mock_kernel_backend):
        """Create a SessionManager with mock kernel backend."""
        return SessionManager(mock_kernel_backend)

    @pytest.fixture(autouse=True)
    def _reset_conn_infos(self):
        """Reset connection infos between tests."""
        self._conn_infos = {}

    def setup_kernel_connection(self, backend, kernel_id, session_id, python_path=None, env_name=None):
        """Helper to set up a mock kernel connection info response."""
        conn_info = {
            "session_id": session_id,
            "python_path": python_path,
            "env_name": env_name,
        }

        self._conn_infos[kernel_id] = conn_info

        # Configure get_kernel_connection_info to return the right info
        conn_infos = self._conn_infos

        def get_conn_info(kid):
            return conn_infos.get(kid)

        backend.get_kernel_connection_info = MagicMock(side_effect=get_conn_info)
        return conn_info

    # ========== get_or_create_session tests ==========

    @pytest.mark.asyncio
    async def test_create_session_new_notebook(self, session_manager, mock_kernel_backend):
        """Test creating a new session for a notebook."""
        # Setup kernel connection that will be created
        self.setup_kernel_connection(
            mock_kernel_backend, "kernel-123", "session-456"
        )

        session, created = await session_manager.get_or_create_session("notebooks/test.ipynb")

        assert created is True
        assert session.notebook_path == "notebooks/test.ipynb"
        assert session.kernel_id == "kernel-123"
        assert session.session_id == "session-456"
        assert session.kernel_name == "python3"
        mock_kernel_backend.start_kernel.assert_called_once_with(
            kernel_name="python3",
            python_path=None,
            env_name=None,
        )

    @pytest.mark.asyncio
    async def test_get_existing_session(self, session_manager, mock_kernel_backend):
        """Test that existing session is returned without creating new kernel."""
        # Create first session
        self.setup_kernel_connection(
            mock_kernel_backend, "kernel-123", "session-456"
        )
        session1, created1 = await session_manager.get_or_create_session("notebooks/test.ipynb")
        assert created1 is True

        # Reset mock to track second call
        mock_kernel_backend.start_kernel.reset_mock()

        # Request session for same notebook
        session2, created2 = await session_manager.get_or_create_session("notebooks/test.ipynb")

        assert created2 is False
        assert session2.session_id == session1.session_id
        assert session2.kernel_id == session1.kernel_id
        # Should NOT have started a new kernel
        mock_kernel_backend.start_kernel.assert_not_called()

    @pytest.mark.asyncio
    async def test_create_session_with_custom_kernel(self, session_manager, mock_kernel_backend):
        """Test creating session with custom kernel settings."""
        self.setup_kernel_connection(
            mock_kernel_backend,
            "kernel-123",
            "session-456",
            python_path="/custom/python",
            env_name="my-venv",
        )

        session, created = await session_manager.get_or_create_session(
            notebook_path="test.ipynb",
            kernel_name="custom-kernel",
            python_path="/custom/python",
            env_name="my-venv",
        )

        assert created is True
        assert session.kernel_name == "custom-kernel"
        assert session.python_path == "/custom/python"
        assert session.env_name == "my-venv"
        mock_kernel_backend.start_kernel.assert_called_once_with(
            kernel_name="custom-kernel",
            python_path="/custom/python",
            env_name="my-venv",
        )

    @pytest.mark.asyncio
    async def test_create_session_normalizes_path(self, session_manager, mock_kernel_backend):
        """Test that paths are normalized for consistent lookup."""
        self.setup_kernel_connection(
            mock_kernel_backend, "kernel-123", "session-456"
        )

        session1, created1 = await session_manager.get_or_create_session("  notebooks/test.ipynb  ")
        assert created1 is True
        mock_kernel_backend.start_kernel.reset_mock()

        # Should find same session with slightly different path
        session2, created2 = await session_manager.get_or_create_session("notebooks/test.ipynb")

        assert created2 is False
        assert session1.session_id == session2.session_id
        mock_kernel_backend.start_kernel.assert_not_called()

    @pytest.mark.asyncio
    async def test_create_session_kernel_start_fails(self, session_manager, mock_kernel_backend):
        """Test error handling when kernel fails to start."""
        mock_kernel_backend.start_kernel.side_effect = RuntimeError("Kernel failed")

        with pytest.raises(RuntimeError, match="Failed to create session"):
            await session_manager.get_or_create_session("test.ipynb")

    @pytest.mark.asyncio
    async def test_create_session_connection_not_found(self, session_manager, mock_kernel_backend):
        """Test error when kernel starts but connection info is missing.

        Should also cleanup the orphaned kernel.
        """
        # get_kernel_connection_info returns None (default)
        mock_kernel_backend.get_kernel_connection_info = MagicMock(return_value=None)

        with pytest.raises(RuntimeError, match="connection not found"):
            await session_manager.get_or_create_session("test.ipynb")

        # Kernel should have been cleaned up
        mock_kernel_backend.stop_kernel.assert_called_once_with("kernel-123")

    # ========== close_session tests ==========

    @pytest.mark.asyncio
    async def test_close_session_cleanup(self, session_manager, mock_kernel_backend):
        """Test that closing session stops kernel and removes session."""
        self.setup_kernel_connection(
            mock_kernel_backend, "kernel-123", "session-456"
        )
        await session_manager.get_or_create_session("test.ipynb")

        # Close the session
        success = await session_manager.close_session("test.ipynb")

        assert success is True
        mock_kernel_backend.stop_kernel.assert_called_once_with("kernel-123")

        # Session should be gone
        assert session_manager.get_session_for_path("test.ipynb") is None

    @pytest.mark.asyncio
    async def test_close_nonexistent_session(self, session_manager, mock_kernel_backend):
        """Test closing a session that doesn't exist returns False."""
        success = await session_manager.close_session("nonexistent.ipynb")

        assert success is False
        mock_kernel_backend.stop_kernel.assert_not_called()

    @pytest.mark.asyncio
    async def test_close_session_kernel_stop_error(self, session_manager, mock_kernel_backend):
        """Test that session is removed even if kernel stop fails."""
        self.setup_kernel_connection(
            mock_kernel_backend, "kernel-123", "session-456"
        )
        await session_manager.get_or_create_session("test.ipynb")

        mock_kernel_backend.stop_kernel.side_effect = RuntimeError("Stop failed")

        # Should still return True (session is removed from tracking)
        success = await session_manager.close_session("test.ipynb")

        assert success is True
        assert session_manager.get_session_for_path("test.ipynb") is None

    # ========== get_session_for_path tests ==========

    @pytest.mark.asyncio
    async def test_get_session_for_path_exists(self, session_manager, mock_kernel_backend):
        """Test getting session for existing notebook."""
        self.setup_kernel_connection(
            mock_kernel_backend, "kernel-123", "session-456"
        )
        created_session, _ = await session_manager.get_or_create_session("test.ipynb")

        session = session_manager.get_session_for_path("test.ipynb")

        assert session is not None
        assert session.session_id == created_session.session_id

    def test_get_session_for_path_not_exists(self, session_manager):
        """Test getting session for nonexistent notebook returns None."""
        session = session_manager.get_session_for_path("nonexistent.ipynb")

        assert session is None

    # ========== get_kernel_for_path tests ==========

    @pytest.mark.asyncio
    async def test_get_kernel_for_path_exists(self, session_manager, mock_kernel_backend):
        """Test getting kernel ID for existing notebook."""
        self.setup_kernel_connection(
            mock_kernel_backend, "kernel-123", "session-456"
        )
        await session_manager.get_or_create_session("test.ipynb")

        kernel_id = session_manager.get_kernel_for_path("test.ipynb")

        assert kernel_id == "kernel-123"

    def test_get_kernel_for_path_not_exists(self, session_manager):
        """Test getting kernel ID for nonexistent notebook returns None."""
        kernel_id = session_manager.get_kernel_for_path("nonexistent.ipynb")

        assert kernel_id is None

    # ========== list_sessions tests ==========

    @pytest.mark.asyncio
    async def test_list_sessions_empty(self, session_manager):
        """Test listing sessions when none exist."""
        sessions = session_manager.list_sessions()

        assert sessions == []

    @pytest.mark.asyncio
    async def test_list_sessions_multiple(self, session_manager, mock_kernel_backend):
        """Test listing multiple sessions."""
        # Create first session
        mock_kernel_backend.start_kernel.return_value = "kernel-1"
        self.setup_kernel_connection(
            mock_kernel_backend, "kernel-1", "session-1"
        )
        await session_manager.get_or_create_session("notebook1.ipynb")

        # Create second session
        mock_kernel_backend.start_kernel.return_value = "kernel-2"
        self.setup_kernel_connection(
            mock_kernel_backend, "kernel-2", "session-2"
        )
        await session_manager.get_or_create_session("notebook2.ipynb")

        sessions = session_manager.list_sessions()

        assert len(sessions) == 2
        paths = [s["notebook_path"] for s in sessions]
        assert "notebook1.ipynb" in paths
        assert "notebook2.ipynb" in paths

    @pytest.mark.asyncio
    async def test_list_sessions_format(self, session_manager, mock_kernel_backend):
        """Test that list_sessions returns correct format."""
        self.setup_kernel_connection(
            mock_kernel_backend,
            "kernel-123",
            "session-456",
            python_path="/usr/bin/python3",
            env_name="my-env",
        )
        await session_manager.get_or_create_session(
            "test.ipynb",
            kernel_name="python3",
            python_path="/usr/bin/python3",
            env_name="my-env",
        )

        sessions = session_manager.list_sessions()

        assert len(sessions) == 1
        s = sessions[0]
        assert s["session_id"] == "session-456"
        assert s["kernel_id"] == "kernel-123"
        assert s["notebook_path"] == "test.ipynb"
        assert s["kernel_name"] == "python3"
        assert s["python_path"] == "/usr/bin/python3"
        assert s["env_name"] == "my-env"
        assert "created_at" in s

    # ========== multiple sessions tests ==========

    @pytest.mark.asyncio
    async def test_multiple_sessions_different_kernels(self, session_manager, mock_kernel_backend):
        """Test that each notebook gets its own kernel."""
        # First notebook
        mock_kernel_backend.start_kernel.return_value = "kernel-1"
        self.setup_kernel_connection(mock_kernel_backend, "kernel-1", "session-1")
        session1, created1 = await session_manager.get_or_create_session("notebook1.ipynb")

        # Second notebook
        mock_kernel_backend.start_kernel.return_value = "kernel-2"
        self.setup_kernel_connection(mock_kernel_backend, "kernel-2", "session-2")
        session2, created2 = await session_manager.get_or_create_session("notebook2.ipynb")

        # Both should be newly created
        assert created1 is True
        assert created2 is True

        # They should have different kernels
        assert session1.kernel_id != session2.kernel_id
        assert session1.session_id != session2.session_id

        # start_kernel should be called twice
        assert mock_kernel_backend.start_kernel.call_count == 2

    @pytest.mark.asyncio
    async def test_session_persistence_across_requests(self, session_manager, mock_kernel_backend):
        """Test that sessions persist across multiple requests."""
        self.setup_kernel_connection(mock_kernel_backend, "kernel-123", "session-456")

        # First request creates session
        session1, created1 = await session_manager.get_or_create_session("test.ipynb")
        assert created1 is True

        # Simulate multiple requests (like from frontend)
        for _ in range(5):
            session, created = await session_manager.get_or_create_session("test.ipynb")
            assert created is False  # Subsequent requests should not create new sessions
            assert session.session_id == session1.session_id
            assert session.kernel_id == session1.kernel_id

        # Only one kernel should have been started
        assert mock_kernel_backend.start_kernel.call_count == 1

    # ========== close_all_sessions tests ==========

    @pytest.mark.asyncio
    async def test_close_all_sessions(self, session_manager, mock_kernel_backend):
        """Test closing all sessions."""
        # Create multiple sessions
        mock_kernel_backend.start_kernel.return_value = "kernel-1"
        self.setup_kernel_connection(mock_kernel_backend, "kernel-1", "session-1")
        await session_manager.get_or_create_session("notebook1.ipynb")

        mock_kernel_backend.start_kernel.return_value = "kernel-2"
        self.setup_kernel_connection(mock_kernel_backend, "kernel-2", "session-2")
        await session_manager.get_or_create_session("notebook2.ipynb")

        result = await session_manager.close_all_sessions()

        assert len(result["closed"]) == 2
        assert len(result["failed"]) == 0
        assert mock_kernel_backend.stop_kernel.call_count == 2

    @pytest.mark.asyncio
    async def test_close_all_sessions_empty(self, session_manager):
        """Test closing all sessions when none exist."""
        result = await session_manager.close_all_sessions()

        assert result["closed"] == []
        assert result["failed"] == []

    # ========== get_session_for_kernel tests ==========

    @pytest.mark.asyncio
    async def test_get_session_for_kernel(self, session_manager, mock_kernel_backend):
        """Test finding session by kernel ID."""
        self.setup_kernel_connection(mock_kernel_backend, "kernel-123", "session-456")
        await session_manager.get_or_create_session("test.ipynb")

        session = session_manager.get_session_for_kernel("kernel-123")

        assert session is not None
        assert session.notebook_path == "test.ipynb"

    def test_get_session_for_kernel_not_found(self, session_manager):
        """Test finding session by nonexistent kernel ID."""
        session = session_manager.get_session_for_kernel("nonexistent-kernel")

        assert session is None

    # ========== connect_to_kernel tests ==========

    @pytest.mark.asyncio
    async def test_connect_to_kernel(self, session_manager, mock_kernel_backend):
        """Test connecting a notebook to an existing kernel."""
        # First notebook creates a kernel
        mock_kernel_backend.start_kernel.return_value = "kernel-123"
        self.setup_kernel_connection(mock_kernel_backend, "kernel-123", "session-456")
        session1, _ = await session_manager.get_or_create_session("notebook1.ipynb")

        # Second notebook connects to same kernel
        session2 = await session_manager.connect_to_kernel(
            notebook_path="notebook2.ipynb",
            kernel_id="kernel-123",
        )

        # Both should share the same kernel
        assert session1.kernel_id == session2.kernel_id == "kernel-123"

        # But have different notebook paths
        assert session1.notebook_path == "notebook1.ipynb"
        assert session2.notebook_path == "notebook2.ipynb"

        # Only one kernel should have been started
        assert mock_kernel_backend.start_kernel.call_count == 1

    @pytest.mark.asyncio
    async def test_connect_to_nonexistent_kernel(self, session_manager, mock_kernel_backend):
        """Test connecting to a kernel that doesn't exist."""
        # get_kernel_connection_info returns None for unknown kernels
        mock_kernel_backend.get_kernel_connection_info = MagicMock(return_value=None)

        with pytest.raises(ValueError, match="Kernel .* not found"):
            await session_manager.connect_to_kernel(
                notebook_path="test.ipynb",
                kernel_id="nonexistent-kernel",
            )

    @pytest.mark.asyncio
    async def test_connect_notebook_already_has_session(self, session_manager, mock_kernel_backend):
        """Test error when notebook already has a session."""
        self.setup_kernel_connection(mock_kernel_backend, "kernel-123", "session-456")
        await session_manager.get_or_create_session("test.ipynb")

        with pytest.raises(RuntimeError, match="already has a session"):
            await session_manager.connect_to_kernel(
                notebook_path="test.ipynb",
                kernel_id="kernel-123",
            )

    # ========== kernel sharing tests ==========

    @pytest.mark.asyncio
    async def test_close_session_preserves_shared_kernel(self, session_manager, mock_kernel_backend):
        """Test that close_session doesn't kill a kernel that's shared with other notebooks."""
        # Create first session
        mock_kernel_backend.start_kernel.return_value = "kernel-123"
        self.setup_kernel_connection(mock_kernel_backend, "kernel-123", "session-456")
        await session_manager.get_or_create_session("notebook1.ipynb")

        # Connect second notebook to same kernel (sharing)
        await session_manager.connect_to_kernel("notebook2.ipynb", "kernel-123")

        # Close the FIRST notebook's session
        success = await session_manager.close_session("notebook1.ipynb")

        assert success is True
        # First notebook's session should be gone
        assert session_manager.get_session_for_path("notebook1.ipynb") is None
        # Second notebook's session should still exist
        assert session_manager.get_session_for_path("notebook2.ipynb") is not None
        # Kernel should NOT have been stopped (other notebook still using it)
        mock_kernel_backend.stop_kernel.assert_not_called()

    @pytest.mark.asyncio
    async def test_close_last_shared_session_stops_kernel(self, session_manager, mock_kernel_backend):
        """Test that kernel is stopped when the last notebook using it closes."""
        # Create first session
        mock_kernel_backend.start_kernel.return_value = "kernel-123"
        self.setup_kernel_connection(mock_kernel_backend, "kernel-123", "session-456")
        await session_manager.get_or_create_session("notebook1.ipynb")

        # Connect second notebook to same kernel (sharing)
        await session_manager.connect_to_kernel("notebook2.ipynb", "kernel-123")

        # Close both sessions
        await session_manager.close_session("notebook1.ipynb")
        mock_kernel_backend.stop_kernel.assert_not_called()  # Still shared

        await session_manager.close_session("notebook2.ipynb")
        # Now kernel should be stopped (no more users)
        mock_kernel_backend.stop_kernel.assert_called_once_with("kernel-123")

    # ========== disconnect_from_kernel tests ==========

    @pytest.mark.asyncio
    async def test_disconnect_from_kernel(self, session_manager, mock_kernel_backend):
        """Test disconnecting a notebook from a shared kernel."""
        # Create first session
        mock_kernel_backend.start_kernel.return_value = "kernel-123"
        self.setup_kernel_connection(mock_kernel_backend, "kernel-123", "session-456")
        await session_manager.get_or_create_session("notebook1.ipynb")

        # Connect second notebook to same kernel
        await session_manager.connect_to_kernel("notebook2.ipynb", "kernel-123")

        # Disconnect second notebook
        success = await session_manager.disconnect_from_kernel("notebook2.ipynb")

        assert success is True
        # Session should be gone
        assert session_manager.get_session_for_path("notebook2.ipynb") is None
        # But first notebook's session should still exist
        assert session_manager.get_session_for_path("notebook1.ipynb") is not None
        # Kernel should NOT have been stopped (other notebook still using it)
        mock_kernel_backend.stop_kernel.assert_not_called()

    @pytest.mark.asyncio
    async def test_disconnect_nonexistent_session(self, session_manager):
        """Test disconnecting a notebook that has no session."""
        success = await session_manager.disconnect_from_kernel("nonexistent.ipynb")

        assert success is False

    # ========== concurrency tests ==========

    @pytest.mark.asyncio
    async def test_concurrent_session_creation(self, session_manager, mock_kernel_backend):
        """Test that concurrent session requests are serialized correctly."""
        import asyncio

        call_count = 0
        original_start = mock_kernel_backend.start_kernel

        async def slow_start_kernel(**kwargs):
            nonlocal call_count
            call_count += 1
            await asyncio.sleep(0.1)  # Simulate slow kernel start
            return f"kernel-{call_count}"

        mock_kernel_backend.start_kernel = slow_start_kernel

        # Set up connections that will be created
        self.setup_kernel_connection(mock_kernel_backend, "kernel-1", "session-1")

        # Create multiple concurrent requests for the same notebook
        tasks = [
            session_manager.get_or_create_session("test.ipynb")
            for _ in range(3)
        ]

        results = await asyncio.gather(*tasks)

        # All should get the same session (unpack tuples)
        session_ids = [r[0].session_id for r in results]
        assert len(set(session_ids)) == 1

        # Only one should have been created
        created_flags = [r[1] for r in results]
        assert created_flags.count(True) == 1  # Only one was actually created

        # Only one kernel should have been started
        assert call_count == 1

    @pytest.mark.asyncio
    async def test_concurrent_different_notebooks(self, session_manager, mock_kernel_backend):
        """Test concurrent session creation for different notebooks."""
        import asyncio

        call_count = 0

        async def start_kernel_counter(**kwargs):
            nonlocal call_count
            call_count += 1
            kernel_id = f"kernel-{call_count}"
            self.setup_kernel_connection(
                mock_kernel_backend, kernel_id, f"session-{call_count}"
            )
            return kernel_id

        mock_kernel_backend.start_kernel = start_kernel_counter

        # Create sessions for different notebooks concurrently
        tasks = [
            session_manager.get_or_create_session(f"notebook{i}.ipynb")
            for i in range(3)
        ]

        results = await asyncio.gather(*tasks)

        # Each should have a different kernel (unpack tuples)
        kernel_ids = [r[0].kernel_id for r in results]
        assert len(set(kernel_ids)) == 3

        # All should be newly created
        created_flags = [r[1] for r in results]
        assert all(created_flags)

    # ========== list_sessions_with_status tests ==========

    @pytest.mark.asyncio
    async def test_list_sessions_with_status_returns_execution_state(self, session_manager, mock_kernel_backend):
        """Test that list_sessions_with_status includes execution state per session."""
        mock_kernel_backend.start_kernel.return_value = "kernel-1"
        self.setup_kernel_connection(mock_kernel_backend, "kernel-1", "session-1")
        await session_manager.get_or_create_session("notebook1.ipynb")

        mock_kernel_backend.start_kernel.return_value = "kernel-2"
        self.setup_kernel_connection(mock_kernel_backend, "kernel-2", "session-2")
        await session_manager.get_or_create_session("notebook2.ipynb")

        # Mock get_kernel_status to return different states
        async def mock_status(kernel_id):
            if kernel_id == "kernel-1":
                return {"status": "running", "kernel_id": kernel_id, "execution_state": "idle"}
            return {"status": "running", "kernel_id": kernel_id, "execution_state": "busy"}

        mock_kernel_backend.get_kernel_status = mock_status

        sessions = await session_manager.list_sessions_with_status()

        assert len(sessions) == 2
        by_path = {s["notebook_path"]: s for s in sessions}
        assert by_path["notebook1.ipynb"]["execution_state"] == "idle"
        assert by_path["notebook2.ipynb"]["execution_state"] == "busy"

    @pytest.mark.asyncio
    async def test_list_sessions_with_status_handles_dead_kernel(self, session_manager, mock_kernel_backend):
        """Test that dead kernels (exception) get execution_state 'unknown'."""
        self.setup_kernel_connection(mock_kernel_backend, "kernel-123", "session-456")
        await session_manager.get_or_create_session("test.ipynb")

        # Mock get_kernel_status to raise an exception (dead kernel)
        async def mock_status(kernel_id):
            raise RuntimeError("Kernel not found")

        mock_kernel_backend.get_kernel_status = mock_status

        sessions = await session_manager.list_sessions_with_status()

        assert len(sessions) == 1
        assert sessions[0]["execution_state"] == "unknown"

    @pytest.mark.asyncio
    async def test_list_sessions_with_status_not_found_returns_dead(self, session_manager, mock_kernel_backend):
        """Test that kernels not in _connections get execution_state 'dead'."""
        self.setup_kernel_connection(mock_kernel_backend, "kernel-123", "session-456")
        await session_manager.get_or_create_session("test.ipynb")

        # Mock get_kernel_status to return not_found (kernel removed from _connections)
        async def mock_status(kernel_id):
            return {"status": "not_found", "kernel_id": kernel_id}

        mock_kernel_backend.get_kernel_status = mock_status

        sessions = await session_manager.list_sessions_with_status()

        assert len(sessions) == 1
        assert sessions[0]["execution_state"] == "dead"

    @pytest.mark.asyncio
    async def test_list_sessions_with_status_empty(self, session_manager):
        """Test list_sessions_with_status returns empty list when no sessions."""
        sessions = await session_manager.list_sessions_with_status()
        assert sessions == []

    # ========== protocol compliance tests ==========

    def test_session_manager_uses_protocol_not_internals(self):
        """SessionManager should not access _connections directly."""
        from pathlib import Path

        sm_path = Path(__file__).parent.parent / "skop_runner" / "session_manager.py"
        source = sm_path.read_text()

        # Should not contain direct access to _connections
        assert "_connections" not in source, "SessionManager should not access _connections directly"

        # Should not reference KernelManager
        assert "kernel_manager" not in source.lower().replace("kernel_backend", ""), \
            "SessionManager should not reference KernelManager"


class TestSessionPersistence:
    """Tests for session disk persistence."""

    @pytest.fixture
    def mock_kernel_backend(self):
        """Create a mock KernelBackend."""
        backend = MagicMock()
        backend.start_kernel = AsyncMock(return_value="kernel-123")
        backend.stop_kernel = AsyncMock()
        backend.get_kernel_connection_info = MagicMock(return_value=None)
        return backend

    @pytest.fixture(autouse=True)
    def _reset_conn_infos(self):
        """Reset connection infos between tests."""
        self._conn_infos = {}

    def setup_kernel_connection(self, backend, kernel_id, session_id, python_path=None, env_name=None):
        """Helper to set up a mock kernel connection info response."""
        conn_info = {
            "session_id": session_id,
            "python_path": python_path,
            "env_name": env_name,
        }
        self._conn_infos[kernel_id] = conn_info
        conn_infos = self._conn_infos

        def get_conn_info(kid):
            return conn_infos.get(kid)

        backend.get_kernel_connection_info = MagicMock(side_effect=get_conn_info)
        return conn_info

    @pytest.mark.asyncio
    async def test_save_and_load_from_disk(self, tmp_path, mock_kernel_backend):
        """Create sessions, save, create new manager, load, verify restored."""
        persistence_dir = str(tmp_path / "state")
        sm1 = SessionManager(mock_kernel_backend, persistence_dir=persistence_dir)

        # Create two sessions
        mock_kernel_backend.start_kernel.return_value = "kernel-1"
        self.setup_kernel_connection(mock_kernel_backend, "kernel-1", "session-1")
        await sm1.get_or_create_session("notebook1.ipynb")

        mock_kernel_backend.start_kernel.return_value = "kernel-2"
        self.setup_kernel_connection(mock_kernel_backend, "kernel-2", "session-2")
        await sm1.get_or_create_session("notebook2.ipynb")

        # Verify file was written
        sessions_file = tmp_path / "state" / "sessions.json"
        assert sessions_file.exists()

        # Create a new manager and load from disk
        sm2 = SessionManager(mock_kernel_backend, persistence_dir=persistence_dir)
        restored = await sm2.load_from_disk()

        assert restored == 2
        assert sm2.get_session_for_path("notebook1.ipynb") is not None
        assert sm2.get_session_for_path("notebook2.ipynb") is not None
        assert sm2.get_session_for_path("notebook1.ipynb").kernel_id == "kernel-1"
        assert sm2.get_session_for_path("notebook2.ipynb").kernel_id == "kernel-2"

    @pytest.mark.asyncio
    async def test_load_discards_dead_kernel(self, tmp_path, mock_kernel_backend):
        """Sessions with dead kernels should be discarded on load."""
        persistence_dir = str(tmp_path / "state")
        sm1 = SessionManager(mock_kernel_backend, persistence_dir=persistence_dir)

        # Create a session
        self.setup_kernel_connection(mock_kernel_backend, "kernel-123", "session-456")
        await sm1.get_or_create_session("test.ipynb")

        # Now simulate kernel being dead (get_kernel_connection_info returns None)
        mock_kernel_backend.get_kernel_connection_info = MagicMock(return_value=None)

        sm2 = SessionManager(mock_kernel_backend, persistence_dir=persistence_dir)
        restored = await sm2.load_from_disk()

        assert restored == 0
        assert sm2.get_session_for_path("test.ipynb") is None

    @pytest.mark.asyncio
    async def test_load_handles_corrupted_file(self, tmp_path, mock_kernel_backend):
        """Corrupted JSON file should be handled gracefully."""
        persistence_dir = str(tmp_path / "state")
        (tmp_path / "state").mkdir(parents=True)
        (tmp_path / "state" / "sessions.json").write_text("not valid json {{{")

        sm = SessionManager(mock_kernel_backend, persistence_dir=persistence_dir)
        restored = await sm.load_from_disk()

        assert restored == 0

    @pytest.mark.asyncio
    async def test_load_handles_version_mismatch(self, tmp_path, mock_kernel_backend):
        """Wrong version number should cause file to be discarded."""
        persistence_dir = str(tmp_path / "state")
        (tmp_path / "state").mkdir(parents=True)
        (tmp_path / "state" / "sessions.json").write_text(
            json.dumps({"version": 999, "sessions": {}})
        )

        sm = SessionManager(mock_kernel_backend, persistence_dir=persistence_dir)
        restored = await sm.load_from_disk()

        assert restored == 0

    @pytest.mark.asyncio
    async def test_save_creates_directory(self, tmp_path, mock_kernel_backend):
        """Parent directory should be created if it doesn't exist."""
        persistence_dir = str(tmp_path / "deeply" / "nested" / "state")
        sm = SessionManager(mock_kernel_backend, persistence_dir=persistence_dir)

        self.setup_kernel_connection(mock_kernel_backend, "kernel-123", "session-456")
        await sm.get_or_create_session("test.ipynb")

        sessions_file = tmp_path / "deeply" / "nested" / "state" / "sessions.json"
        assert sessions_file.exists()

    @pytest.mark.asyncio
    async def test_close_session_updates_disk(self, tmp_path, mock_kernel_backend):
        """Closing a session should update the disk file."""
        persistence_dir = str(tmp_path / "state")
        sm = SessionManager(mock_kernel_backend, persistence_dir=persistence_dir)

        self.setup_kernel_connection(mock_kernel_backend, "kernel-123", "session-456")
        await sm.get_or_create_session("test.ipynb")

        # Verify session is on disk
        data = json.loads((tmp_path / "state" / "sessions.json").read_text())
        assert "test.ipynb" in data["sessions"]

        # Close session
        await sm.close_session("test.ipynb")

        # Verify session is removed from disk
        data = json.loads((tmp_path / "state" / "sessions.json").read_text())
        assert "test.ipynb" not in data["sessions"]

    @pytest.mark.asyncio
    async def test_remove_sessions_for_kernel_updates_disk(self, tmp_path, mock_kernel_backend):
        """Removing sessions for a dead kernel should update disk."""
        persistence_dir = str(tmp_path / "state")
        sm = SessionManager(mock_kernel_backend, persistence_dir=persistence_dir)

        self.setup_kernel_connection(mock_kernel_backend, "kernel-123", "session-456")
        await sm.get_or_create_session("test.ipynb")

        # Remove sessions for dead kernel
        removed = sm.remove_sessions_for_kernel("kernel-123")
        assert removed == ["test.ipynb"]

        # Verify disk is updated
        data = json.loads((tmp_path / "state" / "sessions.json").read_text())
        assert len(data["sessions"]) == 0

    @pytest.mark.asyncio
    async def test_file_permissions(self, tmp_path, mock_kernel_backend):
        """Sessions file should have 600 permissions."""
        import os
        import stat

        persistence_dir = str(tmp_path / "state")
        sm = SessionManager(mock_kernel_backend, persistence_dir=persistence_dir)

        self.setup_kernel_connection(mock_kernel_backend, "kernel-123", "session-456")
        await sm.get_or_create_session("test.ipynb")

        sessions_file = tmp_path / "state" / "sessions.json"
        mode = stat.S_IMODE(os.stat(sessions_file).st_mode)
        assert mode == 0o600

    @pytest.mark.asyncio
    async def test_no_persistence_without_dir(self, mock_kernel_backend):
        """Without persistence_dir, no file should be written."""
        sm = SessionManager(mock_kernel_backend)  # No persistence_dir

        self.setup_kernel_connection(mock_kernel_backend, "kernel-123", "session-456")
        await sm.get_or_create_session("test.ipynb")

        # _save_to_disk should be a no-op
        assert sm._persistence_path is None

    @pytest.mark.asyncio
    async def test_load_from_disk_no_file(self, tmp_path, mock_kernel_backend):
        """Loading when file doesn't exist should return 0."""
        persistence_dir = str(tmp_path / "state")
        sm = SessionManager(mock_kernel_backend, persistence_dir=persistence_dir)

        restored = await sm.load_from_disk()
        assert restored == 0


class TestRenameSession:
    """Tests for SessionManager.rename_session."""

    @pytest.fixture
    def mock_kernel_backend(self):
        backend = MagicMock()
        backend.start_kernel = AsyncMock(return_value="kernel-123")
        backend.stop_kernel = AsyncMock()
        backend.get_kernel_connection_info = MagicMock(return_value=None)
        return backend

    @pytest.fixture(autouse=True)
    def _reset_conn_infos(self):
        self._conn_infos = {}

    def setup_kernel_connection(self, backend, kernel_id, session_id, python_path=None, env_name=None):
        conn_info = {"session_id": session_id, "python_path": python_path, "env_name": env_name}
        self._conn_infos[kernel_id] = conn_info
        conn_infos = self._conn_infos
        backend.get_kernel_connection_info = MagicMock(side_effect=lambda kid: conn_infos.get(kid))
        return conn_info

    async def _create_session(self, sm, backend, notebook_path, kernel_id="kernel-123", session_id="session-abc"):
        """Helper to create a session at the given path."""
        backend.start_kernel = AsyncMock(return_value=kernel_id)
        self.setup_kernel_connection(backend, kernel_id, session_id)
        session, _ = await sm.get_or_create_session(notebook_path)
        return session

    @pytest.mark.asyncio
    async def test_rename_session_updates_key(self, mock_kernel_backend):
        sm = SessionManager(mock_kernel_backend)
        await self._create_session(sm, mock_kernel_backend, "old.ipynb")

        result = sm.rename_session("old.ipynb", "new.ipynb")

        assert result is True
        assert sm.get_session_for_path("old.ipynb") is None
        assert sm.get_session_for_path("new.ipynb") is not None

    @pytest.mark.asyncio
    async def test_rename_session_updates_notebook_path_field(self, mock_kernel_backend):
        sm = SessionManager(mock_kernel_backend)
        await self._create_session(sm, mock_kernel_backend, "old.ipynb")

        sm.rename_session("old.ipynb", "new.ipynb")

        session = sm.get_session_for_path("new.ipynb")
        assert session is not None
        assert session.notebook_path == "new.ipynb"

    @pytest.mark.asyncio
    async def test_rename_session_preserves_session_data(self, mock_kernel_backend):
        sm = SessionManager(mock_kernel_backend)
        original = await self._create_session(sm, mock_kernel_backend, "old.ipynb", "k1", "s1")

        sm.rename_session("old.ipynb", "new.ipynb")

        renamed = sm.get_session_for_path("new.ipynb")
        assert renamed.session_id == original.session_id
        assert renamed.kernel_id == original.kernel_id

    def test_rename_session_nonexistent_returns_false(self, mock_kernel_backend):
        sm = SessionManager(mock_kernel_backend)
        result = sm.rename_session("nonexistent.ipynb", "new.ipynb")
        assert result is False

    def test_rename_session_same_path_returns_false(self, mock_kernel_backend):
        sm = SessionManager(mock_kernel_backend)
        result = sm.rename_session("same.ipynb", "same.ipynb")
        assert result is False

    @pytest.mark.asyncio
    async def test_rename_session_folder_prefix_replacement(self, mock_kernel_backend):
        sm = SessionManager(mock_kernel_backend)
        await self._create_session(sm, mock_kernel_backend, "folder/a.ipynb", "k1", "s1")

        mock_kernel_backend.start_kernel = AsyncMock(return_value="k2")
        self.setup_kernel_connection(mock_kernel_backend, "k2", "s2")
        await sm.get_or_create_session("folder/sub/b.ipynb")

        result = sm.rename_session("folder", "renamed_folder", is_folder=True)

        assert result is True
        assert sm.get_session_for_path("folder/a.ipynb") is None
        assert sm.get_session_for_path("folder/sub/b.ipynb") is None
        assert sm.get_session_for_path("renamed_folder/a.ipynb") is not None
        assert sm.get_session_for_path("renamed_folder/sub/b.ipynb") is not None

    @pytest.mark.asyncio
    async def test_rename_session_does_not_affect_unrelated(self, mock_kernel_backend):
        sm = SessionManager(mock_kernel_backend)
        await self._create_session(sm, mock_kernel_backend, "folder/a.ipynb", "k1", "s1")

        mock_kernel_backend.start_kernel = AsyncMock(return_value="k2")
        self.setup_kernel_connection(mock_kernel_backend, "k2", "s2")
        await sm.get_or_create_session("other/b.ipynb")

        sm.rename_session("folder", "renamed_folder", is_folder=True)

        # Unrelated session should be untouched
        assert sm.get_session_for_path("other/b.ipynb") is not None

    @pytest.mark.asyncio
    async def test_rename_session_persists_to_disk(self, tmp_path, mock_kernel_backend):
        persistence_dir = str(tmp_path / "state")
        sm = SessionManager(mock_kernel_backend, persistence_dir=persistence_dir)
        await self._create_session(sm, mock_kernel_backend, "old.ipynb")

        sm.rename_session("old.ipynb", "new.ipynb")

        # Read persisted file and check
        data = json.loads((tmp_path / "state" / "sessions.json").read_text())
        assert "new.ipynb" in data["sessions"]
        assert "old.ipynb" not in data["sessions"]
        assert data["sessions"]["new.ipynb"]["notebook_path"] == "new.ipynb"
