"""Unit tests for navi.core.planning."""

from navi.core.planning import PlanningEngine, _parse_plan_steps
from navi.core.events import PlanReady
from navi.llm.base import LLMResponse, Message
from tests.conftest_factory import make_profile


class RecordingLLM:
    def __init__(self, responses):
        self.responses = list(responses)
        self.calls = []

    async def complete(self, messages, **kwargs):
        self.calls.append(messages)
        return LLMResponse(
            content=self.responses.pop(0),
            tool_calls=None,
            finish_reason="stop",
        )


class FakeContextBuilder:
    def build_system_prompt(self, profile):
        return "base system prompt"

    def _mcp_context_msg(self, profile=None):
        if profile and profile.mcp_servers:
            return Message(
                role="system",
                content="gnexus-book instructions with mcp_gnexus-book_search_docs",
            )
        return None


class TestParsePlanSteps:
    def test_basic_numbered_list(self):
        text = "**Steps:**\n1. First step\n2. Second step\n3. Third step"
        assert _parse_plan_steps(text) == [("", "First step"), ("", "Second step"), ("", "Third step")]

    def test_parenthesised_numbers(self):
        text = "**Steps:**\n1) Step one\n2) Step two"
        assert _parse_plan_steps(text) == [("", "Step one"), ("", "Step two")]

    def test_ignores_bracket_prefixes(self):
        text = "**Steps:**\n1. [TOOL] Do thing\n2. Normal step"
        assert _parse_plan_steps(text) == [("", "Normal step")]

    def test_steps_carry_milestone_marker(self):
        """A single uppercase letter in brackets at the start of a step line is
        parsed as the milestone label; multi-word bracketed tags are not."""
        text = (
            "**Steps:**\n"
            "1. [A] read config.py → TOOL: filesystem\n"
            "2. [A] implement parser → AGENT: developer\n"
            "3. [B] run tests → TOOL: terminal\n"
            "4. [TOOL] skipped line → ignored\n"
        )
        assert _parse_plan_steps(text) == [
            ("A", "read config.py → TOOL: filesystem"),
            ("A", "implement parser → AGENT: developer"),
            ("B", "run tests → TOOL: terminal"),
        ]

    def test_empty_steps_section(self):
        text = "**Steps:**\n\n**Notes:** nothing"
        assert _parse_plan_steps(text) == []

    def test_no_steps_section(self):
        text = "Some random text without steps"
        assert _parse_plan_steps(text) == []


class TestPlanningPrompt:
    async def test_planning_prompt_includes_profile_mcp_and_persistence_rules(self):
        profile = make_profile(
            "server_admin",
            planning_phase2_enabled=False,
            mcp_servers={"gnexus-book": ["read", "write"]},
        )
        llm = RecordingLLM([
            "TASK: document infra\n"
            "GOAL: docs updated\n"
            "UNKNOWNS: NONE\n"
            "RESOURCES:\n"
            "- mcp_gnexus-book_search_docs: search docs\n"
            "- context sources: gnexus-book\n"
            "KNOWLEDGE SOURCE ASSESSMENT:\n"
            "- Domain: infrastructure\n"
            "- Primary source: connected knowledge servers\n"
            "- Fallback: docs\n"
            "KNOWLEDGE CAPTURE:\n"
            "- New information to save: stable infra facts\n"
            "- Target: connected knowledge server\n"
            "- Duplication check: search target\n"
            "- Rationale: reusable\n"
            "COMPLEXITY: medium\n"
            "SUBTASKS:\n"
            "1. Search docs\n"
            "2. Persist facts\n"
            "REFLECT: no\n"
            "COMMITMENTS: checkpoint",
            "## Plan\n\n"
            "**Task:** document infra\n"
            "**Goal:** docs updated\n\n"
            "**Milestones:**\nA. Inspect\nB. Persist\nC. Report\n\n"
            "**Steps:**\n"
            "1. Search gnexus-book → TOOL: mcp_gnexus-book_search_docs\n"
            "2. Knowledge persistence checkpoint → TOOL: mcp_gnexus-book_propose_doc_change\n"
            "3. Final synthesis → SELF\n\n"
            "**Parallel:** NONE\n"
            "**Risks:** NONE",
        ])
        engine = PlanningEngine(FakeContextBuilder())
        context = [Message(role="user", content="update infra docs")]

        events = []
        async for event in engine.run(context, profile, llm, mem=None, tool_schemas=[]):
            events.append(event)

        phase1_prompt = llm.calls[0][0].content
        phase3_prompt = llm.calls[1][0].content
        assert "gnexus-book instructions" in phase1_prompt
        assert "memory` is only for personal user facts and preferences" in phase1_prompt
        assert "Never use memory for infrastructure inventory" in phase1_prompt
        assert "knowledge persistence checkpoint" in phase3_prompt
        assert "Do not plan unavailable MCP tool calls" in phase3_prompt

    async def test_planning_prompt_includes_direct_shortcut(self):
        profile = make_profile(
            "developer",
            planning_phase2_enabled=False,
        )
        llm = RecordingLLM(["DIRECT"])
        engine = PlanningEngine(FakeContextBuilder())
        context = [Message(role="user", content="hello")]

        async for _event in engine.run(context, profile, llm, mem=None, tool_schemas=[]):
            pass

        phase1_prompt = llm.calls[0][0].content
        assert "CRITICAL DIRECT shortcut" in phase1_prompt
        assert "greeting" in phase1_prompt
        assert "DIRECT (uppercase)" in phase1_prompt

    async def test_planning_prompt_omits_mcp_when_profile_has_no_mcp_servers(self):
        profile = make_profile(
            "developer",
            planning_phase2_enabled=False,
            mcp_servers={},
        )
        llm = RecordingLLM(["DIRECT"])
        engine = PlanningEngine(FakeContextBuilder())
        context = [Message(role="user", content="hello")]

        async for _event in engine.run(context, profile, llm, mem=None, tool_schemas=[]):
            pass

        phase1_prompt = llm.calls[0][0].content
        assert "gnexus-book instructions" not in phase1_prompt
        assert "Connected MCP knowledge servers are authoritative only when the active profile exposes their tools" in phase1_prompt

    async def test_planning_flags(self):
        """Planning messages must have correct is_display / is_context flags."""
        profile = make_profile("developer", planning_phase2_enabled=False)
        llm = RecordingLLM([
            "TASK: test\nGOAL: done\nUNKNOWNS: NONE\nRESOURCES: NONE\n"
            "KNOWLEDGE SOURCE ASSESSMENT: NONE\nKNOWLEDGE CAPTURE: NONE\n"
            "COMPLEXITY: low\nSUBTASKS:\n1. Step one\nREFLECT: no\nCOMMITMENTS: none",
            "## Plan\n\n**Task:** test\n**Goal:** done\n\n**Steps:**\n1. Step one → SELF\n",
        ])
        engine = PlanningEngine(FakeContextBuilder())
        context = [Message(role="user", content="hello")]
        messages = []

        async for _event in engine.run(context, profile, llm, mem=None, tool_schemas=[], messages=messages):
            pass

        # Messages list should contain: user, plan context (is_display=False), plan marker (is_context=False), prompt
        plan_ctx = [m for m in messages if m.role == "assistant" and not m.is_plan and m.is_display is False]
        plan_marker = [m for m in messages if m.is_plan is True]
        prompt_msgs = [m for m in messages if m.role == "user" and m.content.startswith("Plan is ready")]

        assert len(plan_ctx) == 1
        assert plan_ctx[0].is_context is True
        assert len(plan_marker) == 1
        assert plan_marker[0].is_context is False
        assert plan_marker[0].is_display is True
        assert len(prompt_msgs) == 1
        assert prompt_msgs[0].is_display is False


_OBSERVE_ANALYSIS = (
    "TASK: look at directory X\n"
    "GOAL: report what is in directory X\n"
    "MODE: observe\n"
    "UNKNOWNS: NONE\nRESOURCES: NONE\nKNOWLEDGE SOURCE ASSESSMENT: NONE\n"
    "KNOWLEDGE CAPTURE: NONE\nCOMPLEXITY: simple\nSUBTASKS:\n1. List directory\n"
    "REFLECT: no\nCOMMITMENTS: none"
)
_ACT_ANALYSIS = _OBSERVE_ANALYSIS.replace("MODE: observe", "MODE: act")


class TestObserveSkip:
    async def test_observe_skips_phase3(self):
        profile = make_profile(
            "developer",
            planning_phase2_enabled=False,
            observe_skips_plan_enabled=True,
        )
        # Only one response needed — observe must stop after Phase 1.
        llm = RecordingLLM([_OBSERVE_ANALYSIS])
        engine = PlanningEngine(FakeContextBuilder())
        context = [Message(role="user", content="look at directory X")]

        events = []
        async for event in engine.run(context, profile, llm, mem=None, tool_schemas=[]):
            events.append(event)

        # Only Phase 1 ran — single LLM call, no plan produced.
        assert len(llm.calls) == 1
        assert not any(isinstance(e, PlanReady) for e in events)

    async def test_observe_flag_off_still_plans(self):
        profile = make_profile(
            "developer",
            planning_phase2_enabled=False,
            observe_skips_plan_enabled=False,
        )
        llm = RecordingLLM([_OBSERVE_ANALYSIS, "## Plan\n\n**Steps:**\n1. List → TOOL: filesystem\n"])
        engine = PlanningEngine(FakeContextBuilder())
        context = [Message(role="user", content="look at directory X")]

        events = []
        async for event in engine.run(context, profile, llm, mem=None, tool_schemas=[]):
            events.append(event)

        # Default-off: observe classification is ignored, Phase 3 runs.
        assert len(llm.calls) == 2
        assert any(isinstance(e, PlanReady) for e in events)

    async def test_act_mode_still_plans(self):
        profile = make_profile(
            "developer",
            planning_phase2_enabled=False,
            observe_skips_plan_enabled=True,
        )
        llm = RecordingLLM([_ACT_ANALYSIS, "## Plan\n\n**Steps:**\n1. Build → TOOL: filesystem\n"])
        engine = PlanningEngine(FakeContextBuilder())
        context = [Message(role="user", content="build a thing")]

        events = []
        async for event in engine.run(context, profile, llm, mem=None, tool_schemas=[]):
            events.append(event)

        # act mode does not skip — Phase 3 runs even with the flag on.
        assert len(llm.calls) == 2
        assert any(isinstance(e, PlanReady) for e in events)


class TestPhase1Prompt:
    async def test_phase1_prompt_includes_mode_classification(self):
        profile = make_profile("developer", planning_phase2_enabled=False)
        llm = RecordingLLM(["DIRECT"])
        engine = PlanningEngine(FakeContextBuilder())
        context = [Message(role="user", content="hello")]

        async for _event in engine.run(context, profile, llm, mem=None, tool_schemas=[]):
            pass

        phase1_prompt = llm.calls[0][0].content
        assert "MODE: observe | act" in phase1_prompt
        assert "reading files to answer is still observe" in phase1_prompt


class _MemKv:
    """Minimal in-memory KV store for the todo auto-populate isolation test."""

    def __init__(self):
        self._d: dict[tuple, str] = {}

    async def get(self, user_id, session_id, scope, key):
        return self._d.get((user_id or "", session_id, scope, key))

    async def set(self, user_id, session_id, scope, key, value):
        self._d[(user_id or "", session_id, scope, key)] = value


class TestTodoIsolation:
    async def test_auto_todo_lands_in_todo_session_row(self, monkeypatch):
        """Planning auto-populates the todo into the row scoped by
        current_todo_session_id (the sub-agent's run id), not the parent session id.
        The parent's todo must stay empty so its goal-anchoring stays accurate."""
        from navi.tools._internal.base import (
            current_session_id,
            current_todo_session_id,
        )
        from navi.tools import todo as todo_mod

        kv = _MemKv()
        monkeypatch.setattr(todo_mod, "_kv_store", kv)

        profile = make_profile("developer", planning_phase2_enabled=False)
        llm = RecordingLLM(
            [_ACT_ANALYSIS, "## Plan\n\n**Steps:**\n1. Build → TOOL: filesystem\n"]
        )
        engine = PlanningEngine(FakeContextBuilder())
        context = [Message(role="user", content="build a thing")]

        t_tok = current_todo_session_id.set("sub_run_xyz")
        s_tok = current_session_id.set("parent_sess")
        try:
            async for _event in engine.run(context, profile, llm, mem=None, tool_schemas=[]):
                pass
        finally:
            current_todo_session_id.reset(t_tok)
            current_session_id.reset(s_tok)

        # Sub-agent's KV row received the plan step; parent row is empty.
        assert await kv.get(None, "sub_run_xyz", "todo", "tasks") is not None
        assert await kv.get(None, "parent_sess", "todo", "tasks") is None
