"""Tests for the MCP-native enhancements in src/roam/mcp_extras/.

Covers:
- Session memory (recent symbols + task hint, MTF behaviour, merge logic)
- Sampling compression (no-ctx fallback, payload shrink, summary parse)
- Progress phase classification
- Completions (FTS5 prefix, paths, commands)
- Watcher helpers (file filter, ignored dirs)
"""

from __future__ import annotations

import asyncio
import json
import sqlite3
from pathlib import Path

import pytest

from roam.mcp_extras import completions, progress, sampling, session, watcher

# ---------------------------------------------------------------------------
# Session memory
# ---------------------------------------------------------------------------


class _FakeCtx:
    """Minimal fake of ``fastmcp.Context`` used by helpers under test."""

    def __init__(self, session_id: str = "test-sess") -> None:
        self.session_id = session_id
        self.request_id = session_id
        self._state: dict = {}

    def get_state(self, key):
        return self._state.get(key)

    def set_state(self, key, value):
        self._state[key] = value

    def delete_state(self, key):
        self._state.pop(key, None)


class TestSessionMemory:
    def setup_method(self):
        self.ctx = _FakeCtx()
        session.reset_session(self.ctx)

    def test_remember_and_recall(self):
        session.remember_symbol(self.ctx, "foo")
        session.remember_symbol(self.ctx, "bar")
        assert session.recent_symbols(self.ctx) == ["bar", "foo"]

    def test_move_to_front(self):
        for s in ["a", "b", "c"]:
            session.remember_symbol(self.ctx, s)
        session.remember_symbol(self.ctx, "a")
        # MTF: 'a' should now be most-recent
        assert session.recent_symbols(self.ctx)[0] == "a"
        # And only one copy
        assert session.recent_symbols(self.ctx).count("a") == 1

    def test_task_hint_round_trip(self):
        session.remember_task_hint(self.ctx, "trace login flow")
        assert session.session_hint(self.ctx) == "trace login flow"

    def test_merge_explicit_wins_over_session(self):
        session.remember_symbol(self.ctx, "alpha")
        hint, recent = session.merge_with_explicit(self.ctx, explicit_recent="x,y,z", explicit_hint="hardcoded")
        assert hint == "hardcoded"
        assert recent == "x,y,z"

    def test_merge_session_fills_when_explicit_empty(self):
        session.remember_task_hint(self.ctx, "fix bug")
        session.remember_symbol(self.ctx, "verify_token")
        hint, recent = session.merge_with_explicit(self.ctx)
        assert hint == "fix bug"
        assert "verify_token" in recent

    def test_no_ctx_is_safe(self):
        session.remember_symbol(None, "foo")
        session.remember_task_hint(None, "x")
        assert session.recent_symbols(None) == []
        assert session.session_hint(None) == ""
        hint, recent = session.merge_with_explicit(None)
        assert hint == ""
        assert recent == ""

    def test_empty_inputs_ignored(self):
        session.remember_symbol(self.ctx, "")
        assert session.recent_symbols(self.ctx) == []

    def test_reset(self):
        session.remember_symbol(self.ctx, "foo")
        session.reset_session(self.ctx)
        assert session.recent_symbols(self.ctx) == []


# ---------------------------------------------------------------------------
# Sampling
# ---------------------------------------------------------------------------


class TestSampling:
    def test_no_ctx_returns_none(self):
        result = asyncio.run(sampling.compress_with_sampling(None, {"k": "v"}, task="x"))
        assert result is None

    def test_ctx_without_sample_returns_none(self):
        ctx = _FakeCtx()  # no .sample method
        result = asyncio.run(sampling.compress_with_sampling(ctx, {"k": "v"}, task="x"))
        assert result is None

    def test_mcp_sample_failure_returns_none(self, monkeypatch):
        monkeypatch.setenv("ROAM_AI_ENABLED", "1")
        from mcp.shared.exceptions import McpError
        from mcp.types import INTERNAL_ERROR, ErrorData

        class CtxBadSample(_FakeCtx):
            async def sample(self, *args, **kwargs):
                raise McpError(ErrorData(code=INTERNAL_ERROR, message="sampling failed"))

        ctx = CtxBadSample()
        result = asyncio.run(sampling.compress_with_sampling(ctx, {"k": "v"}, task="x"))
        assert result is None

    def test_client_without_sampling_capability_returns_none(self, monkeypatch):
        monkeypatch.setenv("ROAM_AI_ENABLED", "1")

        class CtxWithoutSamplingCapability(_FakeCtx):
            async def sample(self, *args, **kwargs):
                raise ValueError("Client does not support sampling")

        ctx = CtxWithoutSamplingCapability()
        result = asyncio.run(sampling.compress_with_sampling(ctx, {"k": "v"}, task="x"))
        assert result is None

    def test_unexpected_sample_failure_propagates(self, monkeypatch):
        monkeypatch.setenv("ROAM_AI_ENABLED", "1")

        class CtxBuggySample(_FakeCtx):
            async def sample(self, *args, **kwargs):
                raise RuntimeError("sampler bug")

        ctx = CtxBuggySample()
        with pytest.raises(RuntimeError, match="sampler bug"):
            asyncio.run(sampling.compress_with_sampling(ctx, {"k": "v"}, task="x"))

    def test_successful_sample(self, monkeypatch):
        """must set ROAM_AI_ENABLED=1."""
        monkeypatch.setenv("ROAM_AI_ENABLED", "1")
        captured = {}

        class CtxOK(_FakeCtx):
            async def sample(self, prompt, **kwargs):
                captured["prompt"] = prompt
                captured["max_tokens"] = kwargs.get("max_tokens")
                # Return an object with .text like fastmcp's SamplingResult
                return type("Result", (), {"text": "verdict: healthy. 0 issues."})()

        ctx = CtxOK()
        out = asyncio.run(sampling.compress_with_sampling(ctx, {"score": 92, "issues": []}, task="overview"))
        assert out is not None
        assert out["compressed"] is True
        assert "healthy" in out["summary"]
        assert "score" in captured["prompt"]  # payload included
        assert captured["max_tokens"] == 600

    def test_ai_disabled_returns_none(self, monkeypatch):
        """Pass 98 / without ROAM_AI_ENABLED, sampling is bypassed."""
        monkeypatch.delenv("ROAM_AI_ENABLED", raising=False)

        class CtxAlwaysSample(_FakeCtx):
            async def sample(self, *args, **kwargs):
                raise AssertionError("sample must not be invoked when ROAM_AI_ENABLED is unset")

        ctx = CtxAlwaysSample()
        out = asyncio.run(sampling.compress_with_sampling(ctx, {"score": 1}, task="overview"))
        assert out is None

    def test_payload_truncation(self):
        big = {"data": "x" * 200_000}
        text = sampling._shrink_payload(big)
        assert len(text) < 100_000
        assert "[truncated" in text

    def test_apply_compression_preserves_verdict(self):
        original = {
            "command": "health",
            "summary": {"verdict": "score=80"},
            "issues": ["a", "b"],
        }
        compressed = {"compressed": True, "summary": "all good", "tokens_estimated": 12}
        merged = sampling.maybe_apply_compression(original, compressed)
        assert merged["summary"]["verdict"] == "score=80"
        assert merged["summary"]["compressed"] is True
        assert merged["briefing"] == "all good"

    def test_apply_compression_no_op_on_none(self):
        original = {"summary": {"verdict": "x"}}
        merged = sampling.maybe_apply_compression(original, None)
        assert merged is original


# ---------------------------------------------------------------------------
# Progress phase classification
# ---------------------------------------------------------------------------


class TestProgressPhases:
    def test_discover(self):
        out = progress.classify_line("[discovery] discovering source files")
        assert out == (8, "discovering files")

    def test_parse_with_count(self):
        out = progress.classify_line("[parser] parsed 142 files")
        assert out is not None
        assert out[0] == 18
        assert "142 files" in out[1]

    def test_resolve(self):
        out = progress.classify_line("resolving references for 4k symbols")
        assert out == (55, "resolving references")

    def test_graph(self):
        out = progress.classify_line("[graph] building call graph")
        assert out is not None
        assert out[0] == 70

    def test_unrecognised_returns_none(self):
        assert progress.classify_line("hello world") is None
        assert progress.classify_line("") is None

    def test_monotonic_progress_in_runner(self):
        emitted: list[tuple[int, str]] = []

        def on_phase(pct, name):
            emitted.append((pct, name))

        # We can't easily run a real subprocess in CI; just check classify
        # produces values that the sync runner would forward in order.
        for line in [
            "[parse] parsing files",
            "[refs] resolving references",
            "[graph] building",
            "[health] computing health",
            "[parse] re-running parse",  # would NOT regress
        ]:
            classified = progress.classify_line(line)
            if classified is None:
                continue
            pct, name = classified
            if not emitted or pct > emitted[-1][0]:
                emitted.append((pct, name))

        pcts = [p for p, _ in emitted]
        assert pcts == sorted(pcts)
        assert pcts[-1] >= 70

    def test_runner_ignores_pythonpath_and_cwd_hijack(self, tmp_path: Path, monkeypatch):
        root = tmp_path / "repo"
        root.mkdir()
        (root / "pyproject.toml").write_text("[project]\nname='demo'\n", encoding="utf-8")
        (root / "app.py").write_text("def hello():\n    return 'world'\n", encoding="utf-8")
        (root / "roam.py").write_text("print('CWD_HIJACKED')\n", encoding="utf-8")

        pythonpath_hijack = tmp_path / "pythonpath"
        pythonpath_hijack.mkdir()
        (pythonpath_hijack / "roam.py").write_text("print('PYTHONPATH_HIJACKED')\n", encoding="utf-8")
        monkeypatch.setenv("PYTHONPATH", str(pythonpath_hijack))

        code, stdout, stderr = progress.run_with_phase_progress_sync(["index"], cwd=str(root))

        assert code == 0
        assert "CWD_HIJACKED" not in stdout
        assert "PYTHONPATH_HIJACKED" not in stdout
        assert "CWD_HIJACKED" not in stderr
        assert "PYTHONPATH_HIJACKED" not in stderr
        assert json.loads(stdout)["command"] == "index"


# ---------------------------------------------------------------------------
# Completions
# ---------------------------------------------------------------------------


@pytest.fixture
def fake_index_root(tmp_path: Path) -> Path:
    """Tiny .roam/index.db with a few symbols + files for prefix lookup."""
    roam_dir = tmp_path / ".roam"
    roam_dir.mkdir()
    db_path = roam_dir / "index.db"
    # AA4: completions._project_root_for requires a real project marker
    # (.git or pyproject.toml) next to .roam/index.db. The marker prevents
    # the walk-up from binding onto a stray .roam/index.db left in %TEMP%
    # by an earlier test session.
    (tmp_path / "pyproject.toml").write_text("[project]\nname='fake'\n", encoding="utf-8")

    conn = sqlite3.connect(str(db_path))
    conn.executescript(
        """
        CREATE TABLE symbols (
            id INTEGER PRIMARY KEY,
            name TEXT NOT NULL
        );
        CREATE TABLE files (
            id INTEGER PRIMARY KEY,
            path TEXT UNIQUE NOT NULL
        );
        CREATE VIRTUAL TABLE symbol_fts USING fts5(name);
        """
    )
    syms = [
        (1, "user_login"),
        (2, "user_logout"),
        (3, "user_locale"),
        (4, "validate_token"),
        (5, "process_payment"),
    ]
    conn.executemany("INSERT INTO symbols (id, name) VALUES (?, ?)", syms)
    for sid, name in syms:
        conn.execute("INSERT INTO symbol_fts (rowid, name) VALUES (?, ?)", (sid, name))
    files = [(1, "src/auth.py"), (2, "src/payments.py"), (3, "tests/test_auth.py")]
    conn.executemany("INSERT INTO files (id, path) VALUES (?, ?)", files)
    conn.commit()
    conn.close()
    return tmp_path


class TestCompletions:
    def test_symbols_prefix(self, fake_index_root: Path):
        out = completions.complete_symbols("user_lo", root=str(fake_index_root))
        assert "user_login" in out
        assert "user_logout" in out
        assert "validate_token" not in out

    def test_symbols_empty_prefix(self, fake_index_root: Path):
        assert completions.complete_symbols("", root=str(fake_index_root)) == []

    def test_paths_prefix(self, fake_index_root: Path):
        out = completions.complete_paths("src/", root=str(fake_index_root))
        assert "src/auth.py" in out
        assert "src/payments.py" in out
        assert "tests/test_auth.py" not in out

    def test_no_index_returns_empty(self, tmp_path: Path):
        assert completions.complete_symbols("foo", root=str(tmp_path)) == []
        assert completions.complete_paths("foo", root=str(tmp_path)) == []

    def test_commands_known_prefix(self):
        names = completions.complete_commands("he")
        assert "health" in names

    def test_complete_prefix_kind_all(self, fake_index_root: Path):
        out = completions.complete_prefix("user_", kind="all", root=str(fake_index_root))
        assert "symbols" in out
        assert "paths" in out
        assert "commands" in out

    def test_complete_prefix_unknown_kind(self, fake_index_root: Path):
        assert completions.complete_prefix("x", kind="bogus", root=str(fake_index_root)) == {}


# ---------------------------------------------------------------------------
# Watcher classification
# ---------------------------------------------------------------------------


class TestWatcherClassification:
    def test_code_file_python(self):
        assert watcher._is_code_file("src/foo.py") is True

    def test_non_code_lockfile(self):
        assert watcher._is_code_file("yarn.lock") is False
        assert watcher._is_code_file("Pipfile.lock") is False

    def test_image_skipped(self):
        assert watcher._is_code_file("docs/diagram.png") is False

    def test_ignored_dir_git(self):
        assert watcher._within_ignored("repo/.git/HEAD") is True

    def test_ignored_dir_node_modules(self):
        assert watcher._within_ignored("repo/node_modules/foo/bar.js") is True

    def test_normal_path_not_ignored(self):
        assert watcher._within_ignored("repo/src/main.py") is False


class TestWatcherStartup:
    @pytest.mark.parametrize("exc_type", [OSError, RuntimeError])
    def test_start_watcher_returns_none_on_expected_startup_failure(self, tmp_path: Path, monkeypatch, exc_type):
        (tmp_path / ".git").mkdir()

        class FakeHandler:
            pass

        class FakeObserver:
            def schedule(self, handler, path, recursive=False):
                self.handler = handler
                self.path = path
                self.recursive = recursive

            def start(self):
                raise exc_type("watch backend unavailable")

        monkeypatch.setattr(watcher, "_try_import_watchdog", lambda: (FakeObserver, FakeHandler))

        assert watcher.start_watcher(None, root=tmp_path) is None

    def test_start_watcher_propagates_unexpected_startup_failure(self, tmp_path: Path, monkeypatch):
        (tmp_path / ".git").mkdir()

        class FakeHandler:
            pass

        class FakeObserver:
            def schedule(self, handler, path, recursive=False):
                self.handler = handler
                self.path = path
                self.recursive = recursive

            def start(self):
                raise ValueError("bad observer setup")

        monkeypatch.setattr(watcher, "_try_import_watchdog", lambda: (FakeObserver, FakeHandler))

        with pytest.raises(ValueError, match="bad observer setup"):
            watcher.start_watcher(None, root=tmp_path)


# ---------------------------------------------------------------------------
# mcp_server integration -- ensure the new tool surfaces in the registry
# ---------------------------------------------------------------------------


class TestMCPServerIntegration:
    def test_roam_complete_in_workflow_preset(self):
        """``roam_complete`` ships under workflow presets, not core.

        The 2026-05-24 empirical-winners core rewrite moved completion
        out of core (low fire rate in dogfood audits) into
        ``_WORKFLOW_TOOLS``. It still reaches users under any of the
        specialised presets via the ``_CORE_TOOLS | _WORKFLOW_TOOLS``
        union — the tool function is always importable, but it only
        registers as an MCP wrapper under non-core presets.
        """
        from roam.mcp_server import _CORE_TOOLS, _WORKFLOW_TOOLS

        assert "roam_complete" in _WORKFLOW_TOOLS
        assert "roam_complete" not in _CORE_TOOLS

    def test_summarize_param_optional(self):
        # The understand/health/explore/repo_map tools accept summarize as
        # optional (default None = auto-decide based on context, or False =
        # explicit-off). What matters is summarize is NOT enabled by default —
        # otherwise every call would attempt a sampling round-trip that
        # silently no-ops without an MCP client.
        import inspect

        from roam.mcp_server import explore, health, repo_map, understand

        for fn in (explore, understand, health, repo_map):
            sig = inspect.signature(fn)
            assert "summarize" in sig.parameters
            # Accept either explicit-False or auto-decide-None; reject True.
            assert sig.parameters["summarize"].default in (False, None)
