"""Unit tests for SessionService."""

import asyncio
import json
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch, call
import pytest
import pytest_asyncio

from src.services.session_service import SessionService


def make_mock_session(current_session_id=None):
    """Create a mock AgentSession."""
    session = MagicMock()
    session.current_session_id = current_session_id
    session.on_error = None
    session.cancel = AsyncMock()
    session.start = AsyncMock()
    session.model = None
    return session


def make_service():
    return SessionService()


@pytest.fixture
def temp_dir(tmp_path):
    return tmp_path


# ---------------------------------------------------------------------------
# create_session
# ---------------------------------------------------------------------------

class TestCreateSession:
    @pytest.mark.asyncio
    async def test_basic_session_created_and_stored(self, temp_dir):
        service = make_service()
        mock_session = make_mock_session()

        with patch("src.services.session_service.AgentSession", return_value=mock_session) as MockAgentSession:
            session = await service.create_session("item-1", temp_dir, config={})

        assert session is mock_session
        assert service.sessions["item-1"] is mock_session

    @pytest.mark.asyncio
    async def test_model_from_explicit_param(self, temp_dir):
        service = make_service()
        mock_session = make_mock_session()

        with patch("src.services.session_service.AgentSession", return_value=mock_session) as MockAS:
            await service.create_session("item-1", temp_dir, config={"model": "config-model"}, model="explicit-model")
            kwargs = MockAS.call_args.kwargs
        assert kwargs["model"] == "explicit-model"

    @pytest.mark.asyncio
    async def test_model_falls_back_to_config(self, temp_dir):
        service = make_service()
        mock_session = make_mock_session()

        with patch("src.services.session_service.AgentSession", return_value=mock_session) as MockAS:
            await service.create_session("item-1", temp_dir, config={"model": "config-model"})
            kwargs = MockAS.call_args.kwargs
        assert kwargs["model"] == "config-model"

    @pytest.mark.asyncio
    async def test_system_prompt_appends_project_context(self, temp_dir):
        service = make_service()
        mock_session = make_mock_session()
        config = {"system_prompt": "You are helpful.", "project_context": "My project."}

        with patch("src.services.session_service.AgentSession", return_value=mock_session) as MockAS:
            await service.create_session("item-1", temp_dir, config=config)
            kwargs = MockAS.call_args.kwargs
        assert "You are helpful." in kwargs["system_prompt"]
        assert "My project." in kwargs["system_prompt"]

    @pytest.mark.asyncio
    async def test_system_prompt_without_project_context(self, temp_dir):
        service = make_service()
        mock_session = make_mock_session()
        config = {"system_prompt": "Just a prompt."}

        with patch("src.services.session_service.AgentSession", return_value=mock_session) as MockAS:
            await service.create_session("item-1", temp_dir, config=config)
            kwargs = MockAS.call_args.kwargs
        assert kwargs["system_prompt"] == "Just a prompt."

    @pytest.mark.asyncio
    async def test_default_on_message_stores_last_message(self, temp_dir):
        service = make_service()
        mock_session = make_mock_session()

        with patch("src.services.session_service.AgentSession", return_value=mock_session) as MockAS:
            await service.create_session("item-1", temp_dir, config={})
            # Extract the on_message callback passed to AgentSession
            on_message = MockAS.call_args.kwargs["on_message"]

        await on_message("hello world")
        assert service._last_agent_messages["item-1"] == "hello world"

    @pytest.mark.asyncio
    async def test_custom_on_message_preserved(self, temp_dir):
        service = make_service()
        mock_session = make_mock_session()
        custom_cb = AsyncMock()

        with patch("src.services.session_service.AgentSession", return_value=mock_session) as MockAS:
            await service.create_session("item-1", temp_dir, config={}, on_message=custom_cb)
            on_message = MockAS.call_args.kwargs["on_message"]

        assert on_message is custom_cb

    # NOTE: JSON-string parsing of allowed_commands / allowed_builtin_tools
    # is handled by db.get_agent_config. This service expects config dicts
    # to arrive with already-parsed Python types — the equivalent boundary
    # tests live in test_database_service.py::TestGetAgentConfig.

    @pytest.mark.asyncio
    async def test_allowed_commands_list_passed_directly(self, temp_dir):
        service = make_service()
        mock_session = make_mock_session()
        config = {"allowed_commands": ["git", "make"]}

        with patch("src.services.session_service.AgentSession", return_value=mock_session) as MockAS:
            await service.create_session("item-1", temp_dir, config=config)
            kwargs = MockAS.call_args.kwargs
        assert kwargs["allowed_commands"] == ["git", "make"]

    @pytest.mark.asyncio
    async def test_allowed_builtin_tools_list_passed_directly(self, temp_dir):
        service = make_service()
        mock_session = make_mock_session()
        config = {"allowed_builtin_tools": ["WebSearch"]}

        with patch("src.services.session_service.AgentSession", return_value=mock_session) as MockAS:
            await service.create_session("item-1", temp_dir, config=config)
            kwargs = MockAS.call_args.kwargs
        assert kwargs["allowed_builtin_tools"] == ["WebSearch"]

    @pytest.mark.asyncio
    async def test_bash_yolo_passed_to_session(self, temp_dir):
        service = make_service()
        mock_session = make_mock_session()

        with patch("src.services.session_service.AgentSession", return_value=mock_session) as MockAS:
            await service.create_session("item-1", temp_dir, config={"bash_yolo": True})
            kwargs = MockAS.call_args.kwargs
        assert kwargs["bash_yolo"] is True

    @pytest.mark.asyncio
    async def test_callbacks_forwarded(self, temp_dir):
        service = make_service()
        mock_session = make_mock_session()
        cbs = {
            "on_tool_use": AsyncMock(),
            "on_thinking": AsyncMock(),
            "on_complete": AsyncMock(),
            "on_error": AsyncMock(),
            "on_clarify": AsyncMock(),
            "on_create_todo": AsyncMock(),
            "on_set_commit_message": AsyncMock(),
            "on_request_command": AsyncMock(),
            "on_request_tool": AsyncMock(),
            "on_view_board": AsyncMock(),
            "on_delete_todo": AsyncMock(),
            "on_create_epic": AsyncMock(),
            "on_create_shortcut": AsyncMock(),
        }

        with patch("src.services.session_service.AgentSession", return_value=mock_session) as MockAS:
            await service.create_session("item-1", temp_dir, config={}, **cbs)
            kwargs = MockAS.call_args.kwargs

        for name, cb in cbs.items():
            assert kwargs[name] is cb, f"Expected {name} to be forwarded"


# ---------------------------------------------------------------------------
# start_session_task
# ---------------------------------------------------------------------------

class TestStartSessionTask:
    @pytest.mark.asyncio
    async def test_task_stored_in_agent_tasks(self, temp_dir):
        service = make_service()
        mock_session = make_mock_session()

        task = await service.start_session_task("item-1", mock_session, "do work")
        assert "item-1" in service._agent_tasks
        # Wait for task to complete
        await task

    @pytest.mark.asyncio
    async def test_session_start_called_with_prompt(self, temp_dir):
        service = make_service()
        mock_session = make_mock_session()

        task = await service.start_session_task("item-1", mock_session, "do work")
        await task
        mock_session.start.assert_called_once_with("do work", attachments=None, resume_session_id=None)

    @pytest.mark.asyncio
    async def test_session_start_with_attachments_and_resume(self, temp_dir):
        service = make_service()
        mock_session = make_mock_session()
        attachments = [{"filename": "a.png", "dest": "/tmp/a.png"}]

        task = await service.start_session_task("item-1", mock_session, "prompt", attachments=attachments, resume_session_id="sess-99")
        await task
        mock_session.start.assert_called_once_with("prompt", attachments=attachments, resume_session_id="sess-99")

    @pytest.mark.asyncio
    async def test_on_error_called_when_start_raises(self, temp_dir):
        service = make_service()
        mock_session = make_mock_session()
        mock_session.start = AsyncMock(side_effect=RuntimeError("boom"))
        on_error = AsyncMock()
        mock_session.on_error = on_error

        task = await service.start_session_task("item-1", mock_session, "prompt")
        await task
        on_error.assert_called_once_with("boom")

    @pytest.mark.asyncio
    async def test_no_error_callback_swallowed_gracefully(self, temp_dir):
        service = make_service()
        mock_session = make_mock_session()
        mock_session.start = AsyncMock(side_effect=RuntimeError("crash"))
        mock_session.on_error = None  # no callback

        task = await service.start_session_task("item-1", mock_session, "prompt")
        # Should not raise
        await task


# ---------------------------------------------------------------------------
# pause_session
# ---------------------------------------------------------------------------

class TestPauseSession:
    @pytest.mark.asyncio
    async def test_returns_session_id(self):
        service = make_service()
        mock_session = make_mock_session(current_session_id="sess-abc")
        service.sessions["item-1"] = mock_session

        session_id = await service.pause_session("item-1")
        assert session_id == "sess-abc"

    @pytest.mark.asyncio
    async def test_returns_none_when_no_session(self):
        service = make_service()
        session_id = await service.pause_session("no-such-item")
        assert session_id is None

    @pytest.mark.asyncio
    async def test_session_cleaned_up_after_pause(self):
        service = make_service()
        mock_session = make_mock_session(current_session_id="sess-abc")
        service.sessions["item-1"] = mock_session

        await service.pause_session("item-1")
        assert "item-1" not in service.sessions

    @pytest.mark.asyncio
    async def test_returns_none_when_session_has_no_session_id(self):
        service = make_service()
        mock_session = make_mock_session(current_session_id=None)
        service.sessions["item-1"] = mock_session

        session_id = await service.pause_session("item-1")
        assert session_id is None


# ---------------------------------------------------------------------------
# cleanup_session
# ---------------------------------------------------------------------------

class TestCleanupSession:
    @pytest.mark.asyncio
    async def test_session_removed(self):
        service = make_service()
        mock_session = make_mock_session()
        service.sessions["item-1"] = mock_session

        await service.cleanup_session("item-1")
        assert "item-1" not in service.sessions
        mock_session.cancel.assert_called_once()

    @pytest.mark.asyncio
    async def test_task_cancelled(self):
        service = make_service()
        mock_session = make_mock_session()
        service.sessions["item-1"] = mock_session

        # Create a real long-running task
        async def long_running():
            await asyncio.sleep(100)

        task = asyncio.create_task(long_running())
        service._agent_tasks["item-1"] = task

        await service.cleanup_session("item-1")
        assert task.cancelled()

    @pytest.mark.asyncio
    async def test_last_message_cleared(self):
        service = make_service()
        service._last_agent_messages["item-1"] = "some text"
        service.sessions["item-1"] = make_mock_session()

        await service.cleanup_session("item-1")
        assert "item-1" not in service._last_agent_messages

    @pytest.mark.asyncio
    async def test_commit_message_cleared(self):
        service = make_service()
        service._commit_messages["item-1"] = "feat: done"
        service.sessions["item-1"] = make_mock_session()

        await service.cleanup_session("item-1")
        assert "item-1" not in service._commit_messages

    @pytest.mark.asyncio
    async def test_cancel_exception_swallowed(self):
        service = make_service()
        mock_session = make_mock_session()
        mock_session.cancel = AsyncMock(side_effect=Exception("cancel error"))
        service.sessions["item-1"] = mock_session

        # Should not raise
        await service.cleanup_session("item-1")

    @pytest.mark.asyncio
    async def test_cleanup_nonexistent_item_noop(self):
        service = make_service()
        # Should not raise
        await service.cleanup_session("no-such-item")

    @pytest.mark.asyncio
    async def test_already_done_task_not_cancelled(self):
        service = make_service()

        async def quick():
            pass

        task = asyncio.create_task(quick())
        await task  # already done

        service._agent_tasks["item-1"] = task
        # Should not raise
        await service.cleanup_session("item-1")


# ---------------------------------------------------------------------------
# cleanup_all_sessions
# ---------------------------------------------------------------------------

class TestCleanupAllSessions:
    @pytest.mark.asyncio
    async def test_all_sessions_cleaned(self):
        service = make_service()
        for i in range(3):
            service.sessions[f"item-{i}"] = make_mock_session()

        await service.cleanup_all_sessions()
        assert len(service.sessions) == 0

    @pytest.mark.asyncio
    async def test_tasks_without_sessions_cleaned(self):
        service = make_service()

        async def long_running():
            await asyncio.sleep(100)

        task = asyncio.create_task(long_running())
        service._agent_tasks["item-orphan"] = task

        await service.cleanup_all_sessions()
        assert "item-orphan" not in service._agent_tasks

    @pytest.mark.asyncio
    async def test_exception_per_item_does_not_abort(self):
        service = make_service()
        bad_session = make_mock_session()
        bad_session.cancel = AsyncMock(side_effect=Exception("fail"))
        good_session = make_mock_session()

        service.sessions["bad"] = bad_session
        service.sessions["good"] = good_session

        # Should not raise
        await service.cleanup_all_sessions()
        assert len(service.sessions) == 0


# ---------------------------------------------------------------------------
# remove_session
# ---------------------------------------------------------------------------

class TestRemoveSession:
    def test_removes_existing_session(self):
        service = make_service()
        service.sessions["item-1"] = make_mock_session()

        service.remove_session("item-1")
        assert "item-1" not in service.sessions

    def test_remove_nonexistent_is_noop(self):
        service = make_service()
        # Should not raise
        service.remove_session("ghost")

    def test_does_not_cancel_session(self):
        service = make_service()
        mock_session = make_mock_session()
        service.sessions["item-1"] = mock_session

        service.remove_session("item-1")
        mock_session.cancel.assert_not_called()


# ---------------------------------------------------------------------------
# get_session / get_last_message
# ---------------------------------------------------------------------------

class TestGetters:
    def test_get_session_returns_session(self):
        service = make_service()
        mock_session = make_mock_session()
        service.sessions["item-1"] = mock_session

        assert service.get_session("item-1") is mock_session

    def test_get_session_returns_none_when_missing(self):
        service = make_service()
        assert service.get_session("no-item") is None

    def test_get_last_message_returns_message(self):
        service = make_service()
        service._last_agent_messages["item-1"] = "last msg"
        assert service.get_last_message("item-1") == "last msg"

    def test_get_last_message_returns_none_when_missing(self):
        service = make_service()
        assert service.get_last_message("no-item") is None


# ---------------------------------------------------------------------------
# set_commit_message / get_commit_message
# ---------------------------------------------------------------------------

class TestCommitMessages:
    def test_set_commit_message_returns_confirmation(self):
        service = make_service()
        result = service.set_commit_message("item-1", "feat: add thing")
        assert "feat: add thing" in result

    def test_set_then_get_commit_message(self):
        service = make_service()
        service.set_commit_message("item-1", "fix: bug")
        msg = service.get_commit_message("item-1")
        assert msg == "fix: bug"

    def test_get_commit_message_pops_entry(self):
        service = make_service()
        service.set_commit_message("item-1", "chore: cleanup")
        service.get_commit_message("item-1")
        # Second call should return None (popped)
        assert service.get_commit_message("item-1") is None

    def test_get_commit_message_returns_none_when_not_set(self):
        service = make_service()
        assert service.get_commit_message("no-item") is None


# ---------------------------------------------------------------------------
# _parse_plugins
# ---------------------------------------------------------------------------

class TestParsePlugins:
    def test_none_returns_none_when_no_auto_discovered(self, tmp_path):
        service = make_service()
        # Patch the plugins dir to a non-existent path
        with patch.object(Path, "is_dir", return_value=False):
            result = service._parse_plugins(None)
        assert result is None

    def test_string_path_parsed(self, tmp_path):
        service = make_service()
        plugins_json = json.dumps(["/some/plugin/path"])

        with patch.object(Path, "is_dir", return_value=False):
            result = service._parse_plugins(plugins_json)

        assert result is not None
        assert any(p["path"] == "/some/plugin/path" for p in result)

    def test_dict_entry_with_path_key(self, tmp_path):
        service = make_service()
        plugins_json = json.dumps([{"path": "/plugin/dir", "type": "local"}])

        with patch.object(Path, "is_dir", return_value=False):
            result = service._parse_plugins(plugins_json)

        assert result is not None
        assert any(p["path"] == "/plugin/dir" for p in result)

    def test_duplicate_paths_deduplicated(self, tmp_path):
        service = make_service()
        plugins_json = json.dumps(["/same/path", "/same/path"])

        with patch.object(Path, "is_dir", return_value=False):
            result = service._parse_plugins(plugins_json)

        paths = [p["path"] for p in result]
        assert paths.count("/same/path") == 1

    def test_invalid_json_returns_none(self, tmp_path):
        service = make_service()

        with patch.object(Path, "is_dir", return_value=False):
            result = service._parse_plugins("{bad json")

        assert result is None

    def test_empty_string_entries_ignored(self, tmp_path):
        service = make_service()
        plugins_json = json.dumps(["", "   ", "/valid/path"])

        with patch.object(Path, "is_dir", return_value=False):
            result = service._parse_plugins(plugins_json)

        assert result is not None
        paths = [p["path"] for p in result]
        assert "/valid/path" in paths
        assert "" not in paths

    def test_non_list_json_returns_none(self, tmp_path):
        service = make_service()
        plugins_json = json.dumps({"not": "a list"})

        with patch.object(Path, "is_dir", return_value=False):
            result = service._parse_plugins(plugins_json)

        assert result is None

    def test_auto_discovered_plugins_included(self, tmp_path):
        service = make_service()
        # Create a fake plugin directory structure
        plugin_dir = tmp_path / "my-plugin"
        plugin_dir.mkdir()
        manifest = plugin_dir / ".claude-plugin" / "plugin.json"
        manifest.parent.mkdir()
        manifest.write_text('{"name": "my-plugin"}')

        # Patch the plugins dir to our tmp_path
        with patch("src.services.session_service.Path") as MockPath:
            # Make __file__ parent chain resolve to tmp_path
            mock_plugins_dir = MagicMock()
            mock_plugins_dir.is_dir.return_value = True
            mock_plugins_dir.iterdir.return_value = [plugin_dir]

            # Make the entry look like a valid plugin
            mock_entry = MagicMock()
            mock_entry.is_dir.return_value = True
            mock_entry.name = "my-plugin"
            mock_manifest = MagicMock()
            mock_manifest.exists.return_value = True
            mock_entry.__truediv__ = lambda self, key: mock_manifest if key == ".claude-plugin/plugin.json" else MagicMock()
            mock_entry.resolve.return_value = plugin_dir

            mock_plugins_dir.iterdir.return_value = [mock_entry]
            mock_plugins_dir.__truediv__ = MagicMock(return_value=mock_manifest)

            # Use real Path for the plugins_dir lookup
            real_path = Path(__file__)
            MockPath.return_value = real_path
            MockPath.__file__ = str(real_path)

            # Fall back to direct test of the real autodiscovery path
            pass

        # Direct integration test: a real plugins/ dir next to src/
        import src.services.session_service as ss_mod
        real_plugins_dir = Path(ss_mod.__file__).parent.parent.parent / "plugins"
        # Just verify the method doesn't crash regardless of whether plugins/ exists
        result = service._parse_plugins(None)
        # Result is either None or a list — both are valid
        assert result is None or isinstance(result, list)
