"""Unit tests for src/timmy/research.py — ResearchOrchestrator pipeline.

Refs #972 (governing spec), #975 (ResearchOrchestrator).
"""

from __future__ import annotations

from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

pytestmark = pytest.mark.unit


# ---------------------------------------------------------------------------
# list_templates
# ---------------------------------------------------------------------------


class TestListTemplates:
    def test_returns_list(self, tmp_path, monkeypatch):
        (tmp_path / "tool_evaluation.md").write_text("---\n---\n# T")
        (tmp_path / "game_analysis.md").write_text("---\n---\n# G")
        monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path)

        from timmy.research import list_templates

        result = list_templates()
        assert isinstance(result, list)
        assert "tool_evaluation" in result
        assert "game_analysis" in result

    def test_returns_empty_when_dir_missing(self, tmp_path, monkeypatch):
        monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path / "nonexistent")

        from timmy.research import list_templates

        assert list_templates() == []


# ---------------------------------------------------------------------------
# load_template
# ---------------------------------------------------------------------------


class TestLoadTemplate:
    def _write_template(self, path: Path, name: str, body: str) -> None:
        (path / f"{name}.md").write_text(body, encoding="utf-8")

    def test_loads_and_strips_frontmatter(self, tmp_path, monkeypatch):
        self._write_template(
            tmp_path,
            "tool_evaluation",
            "---\nname: Tool Evaluation\ntype: research\n---\n# Tool Eval: {domain}",
        )
        monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path)

        from timmy.research import load_template

        result = load_template("tool_evaluation", {"domain": "PDF parsing"})
        assert "# Tool Eval: PDF parsing" in result
        assert "name: Tool Evaluation" not in result

    def test_fills_slots(self, tmp_path, monkeypatch):
        self._write_template(tmp_path, "arch", "Connect {system_a} to {system_b}")
        monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path)

        from timmy.research import load_template

        result = load_template("arch", {"system_a": "Kafka", "system_b": "Postgres"})
        assert "Kafka" in result
        assert "Postgres" in result

    def test_unfilled_slots_preserved(self, tmp_path, monkeypatch):
        self._write_template(tmp_path, "t", "Hello {name} and {other}")
        monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path)

        from timmy.research import load_template

        result = load_template("t", {"name": "World"})
        assert "{other}" in result

    def test_raises_file_not_found_for_missing_template(self, tmp_path, monkeypatch):
        monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path)

        from timmy.research import load_template

        with pytest.raises(FileNotFoundError, match="nonexistent"):
            load_template("nonexistent")

    def test_no_slots_returns_raw_body(self, tmp_path, monkeypatch):
        self._write_template(tmp_path, "plain", "---\n---\nJust text here")
        monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path)

        from timmy.research import load_template

        result = load_template("plain")
        assert result == "Just text here"


# ---------------------------------------------------------------------------
# _check_cache
# ---------------------------------------------------------------------------


class TestCheckCache:
    def test_returns_none_when_no_hits(self):
        mock_mem = MagicMock()
        mock_mem.search.return_value = []

        with patch("timmy.research.SemanticMemory", return_value=mock_mem):
            from timmy.research import _check_cache

            content, score = _check_cache("some topic")

        assert content is None
        assert score == 0.0

    def test_returns_content_above_threshold(self):
        mock_mem = MagicMock()
        mock_mem.search.return_value = [("cached report text", 0.91)]

        with patch("timmy.research.SemanticMemory", return_value=mock_mem):
            from timmy.research import _check_cache

            content, score = _check_cache("same topic")

        assert content == "cached report text"
        assert score == pytest.approx(0.91)

    def test_returns_none_below_threshold(self):
        mock_mem = MagicMock()
        mock_mem.search.return_value = [("old report", 0.60)]

        with patch("timmy.research.SemanticMemory", return_value=mock_mem):
            from timmy.research import _check_cache

            content, score = _check_cache("slightly different topic")

        assert content is None
        assert score == 0.0

    def test_degrades_gracefully_on_import_error(self):
        with patch("timmy.research.SemanticMemory", None):
            from timmy.research import _check_cache

            content, score = _check_cache("topic")

        assert content is None
        assert score == 0.0


# ---------------------------------------------------------------------------
# _store_result
# ---------------------------------------------------------------------------


class TestStoreResult:
    def test_calls_store_memory(self):
        mock_store = MagicMock()

        with patch("timmy.research.store_memory", mock_store):
            from timmy.research import _store_result

            _store_result("test topic", "# Report\n\nContent here.")

        mock_store.assert_called_once()
        call_kwargs = mock_store.call_args
        assert "test topic" in str(call_kwargs)

    def test_degrades_gracefully_on_error(self):
        mock_store = MagicMock(side_effect=RuntimeError("db error"))
        with patch("timmy.research.store_memory", mock_store):
            from timmy.research import _store_result

            # Should not raise
            _store_result("topic", "report")


# ---------------------------------------------------------------------------
# _save_to_disk
# ---------------------------------------------------------------------------


class TestSaveToDisk:
    def test_writes_file(self, tmp_path, monkeypatch):
        monkeypatch.setattr("timmy.research._DOCS_ROOT", tmp_path / "research")

        from timmy.research import _save_to_disk

        path = _save_to_disk("Test Topic: PDF Parsing", "# Test Report")
        assert path is not None
        assert path.exists()
        assert path.read_text() == "# Test Report"

    def test_slugifies_topic_name(self, tmp_path, monkeypatch):
        monkeypatch.setattr("timmy.research._DOCS_ROOT", tmp_path / "research")

        from timmy.research import _save_to_disk

        path = _save_to_disk("My Complex Topic! v2.0", "content")
        assert path is not None
        # Should be slugified: no special chars
        assert " " not in path.name
        assert "!" not in path.name

    def test_returns_none_on_error(self, monkeypatch):
        monkeypatch.setattr(
            "timmy.research._DOCS_ROOT",
            Path("/nonexistent_root/deeply/nested"),
        )

        with patch("pathlib.Path.mkdir", side_effect=PermissionError("denied")):
            from timmy.research import _save_to_disk

            result = _save_to_disk("topic", "report")

        assert result is None


# ---------------------------------------------------------------------------
# run_research — end-to-end with mocks
# ---------------------------------------------------------------------------


class TestRunResearch:
    @pytest.mark.asyncio
    async def test_returns_cached_result_when_cache_hit(self):
        cached_report = "# Cached Report\n\nPreviously computed."
        with (
            patch("timmy.research._check_cache", return_value=(cached_report, 0.93)),
        ):
            from timmy.research import run_research

            result = await run_research("some topic")

        assert result.cached is True
        assert result.cache_similarity == pytest.approx(0.93)
        assert result.report == cached_report
        assert result.synthesis_backend == "cache"

    @pytest.mark.asyncio
    async def test_skips_cache_when_requested(self, tmp_path, monkeypatch):
        monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path)

        with (
            patch("timmy.research._check_cache", return_value=("cached", 0.99)) as mock_cache,
            patch(
                "timmy.research._formulate_queries",
                new=AsyncMock(return_value=["q1"]),
            ),
            patch("timmy.research._execute_search", new=AsyncMock(return_value=[])),
            patch("timmy.research._fetch_pages", new=AsyncMock(return_value=[])),
            patch(
                "timmy.research._synthesize",
                new=AsyncMock(return_value=("# Fresh report", "ollama")),
            ),
            patch("timmy.research._store_result"),
        ):
            from timmy.research import run_research

            result = await run_research("topic", skip_cache=True)

        mock_cache.assert_not_called()
        assert result.cached is False
        assert result.report == "# Fresh report"

    @pytest.mark.asyncio
    async def test_full_pipeline_no_search_results(self, tmp_path, monkeypatch):
        monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path)

        with (
            patch("timmy.research._check_cache", return_value=(None, 0.0)),
            patch(
                "timmy.research._formulate_queries",
                new=AsyncMock(return_value=["query 1", "query 2"]),
            ),
            patch("timmy.research._execute_search", new=AsyncMock(return_value=[])),
            patch("timmy.research._fetch_pages", new=AsyncMock(return_value=[])),
            patch(
                "timmy.research._synthesize",
                new=AsyncMock(return_value=("# Report", "ollama")),
            ),
            patch("timmy.research._store_result"),
        ):
            from timmy.research import run_research

            result = await run_research("a new topic")

        assert not result.cached
        assert result.query_count == 2
        assert result.sources_fetched == 0
        assert result.report == "# Report"
        assert result.synthesis_backend == "ollama"

    @pytest.mark.asyncio
    async def test_returns_result_with_error_on_bad_template(self, tmp_path, monkeypatch):
        monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path)

        with (
            patch("timmy.research._check_cache", return_value=(None, 0.0)),
            patch(
                "timmy.research._formulate_queries",
                new=AsyncMock(return_value=["q1"]),
            ),
            patch("timmy.research._execute_search", new=AsyncMock(return_value=[])),
            patch("timmy.research._fetch_pages", new=AsyncMock(return_value=[])),
            patch(
                "timmy.research._synthesize",
                new=AsyncMock(return_value=("# Report", "ollama")),
            ),
            patch("timmy.research._store_result"),
        ):
            from timmy.research import run_research

            result = await run_research("topic", template="nonexistent_template")

        assert len(result.errors) == 1
        assert "nonexistent_template" in result.errors[0]

    @pytest.mark.asyncio
    async def test_saves_to_disk_when_requested(self, tmp_path, monkeypatch):
        monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path)
        monkeypatch.setattr("timmy.research._DOCS_ROOT", tmp_path / "research")

        with (
            patch("timmy.research._check_cache", return_value=(None, 0.0)),
            patch(
                "timmy.research._formulate_queries",
                new=AsyncMock(return_value=["q1"]),
            ),
            patch("timmy.research._execute_search", new=AsyncMock(return_value=[])),
            patch("timmy.research._fetch_pages", new=AsyncMock(return_value=[])),
            patch(
                "timmy.research._synthesize",
                new=AsyncMock(return_value=("# Saved Report", "ollama")),
            ),
            patch("timmy.research._store_result"),
        ):
            from timmy.research import run_research

            result = await run_research("disk topic", save_to_disk=True)

        assert result.report == "# Saved Report"
        saved_files = list((tmp_path / "research").glob("*.md"))
        assert len(saved_files) == 1
        assert saved_files[0].read_text() == "# Saved Report"

    @pytest.mark.asyncio
    async def test_result_is_not_empty_after_synthesis(self, tmp_path, monkeypatch):
        monkeypatch.setattr("timmy.research._SKILLS_ROOT", tmp_path)

        with (
            patch("timmy.research._check_cache", return_value=(None, 0.0)),
            patch(
                "timmy.research._formulate_queries",
                new=AsyncMock(return_value=["q"]),
            ),
            patch("timmy.research._execute_search", new=AsyncMock(return_value=[])),
            patch("timmy.research._fetch_pages", new=AsyncMock(return_value=[])),
            patch(
                "timmy.research._synthesize",
                new=AsyncMock(return_value=("# Non-empty", "ollama")),
            ),
            patch("timmy.research._store_result"),
        ):
            from timmy.research import run_research

            result = await run_research("topic")

        assert not result.is_empty()


# ---------------------------------------------------------------------------
# ResearchResult
# ---------------------------------------------------------------------------


class TestResearchResult:
    def test_is_empty_when_no_report(self):
        from timmy.research import ResearchResult

        r = ResearchResult(topic="t", query_count=0, sources_fetched=0, report="")
        assert r.is_empty()

    def test_is_not_empty_with_content(self):
        from timmy.research import ResearchResult

        r = ResearchResult(topic="t", query_count=1, sources_fetched=1, report="# Report")
        assert not r.is_empty()

    def test_default_cached_false(self):
        from timmy.research import ResearchResult

        r = ResearchResult(topic="t", query_count=0, sources_fetched=0, report="x")
        assert r.cached is False

    def test_errors_defaults_to_empty_list(self):
        from timmy.research import ResearchResult

        r = ResearchResult(topic="t", query_count=0, sources_fetched=0, report="x")
        assert r.errors == []
