"""Unit tests for navi.tools.todo."""

import pytest

from navi.llm.base import Message
from navi.tools.todo import (
    TodoTool,
    get_progress_message,
    get_task_snapshot,
    has_open_steps,
    load_tasks_for,
    set_tasks,
)
from navi.tools._internal.base import ToolContext
from navi.store import KvStore
from tests.conftest_factory import FakePool


class FakeKvStore(KvStore):
    """In-memory KV store for tests."""

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

    async def _get_pool(self):
        return FakePool()

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

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

    async def get_all(self, user_id, session_id, scope):
        return {
            k[3]: v
            for k, v in self._data.items()
            if k[:3] == (user_id or "", session_id, scope)
        }

    async def delete(self, user_id, session_id, scope, key):
        self._data.pop((user_id or "", session_id, scope, key), None)

    async def clear_scope(self, user_id, session_id, scope):
        keys = [k for k in self._data if k[:3] == (user_id or "", session_id, scope)]
        for k in keys:
            del self._data[k]


@pytest.fixture(autouse=True)
def _fake_kv():
    store = FakeKvStore()
    from navi.tools import todo as _mod
    _mod._kv_store = store
    yield store
    _mod._kv_store = None


# ── TodoTool execute tests ────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_set_tasks(_fake_kv):
    tool = TodoTool()
    result = await tool.execute({"op": "set", "tasks": ["task A", "task B"]}, ctx=ToolContext(session_id="sess1", user_id="user1"))
    assert result.success is True
    assert "task A" in result.output
    assert "task B" in result.output


@pytest.mark.asyncio
async def test_view_empty(_fake_kv):
    tool = TodoTool()
    result = await tool.execute({"op": "view"}, ctx=ToolContext(session_id="sess1", user_id="user1"))
    assert result.success is True
    assert "No plan set" in result.output


@pytest.mark.asyncio
async def test_update_status(_fake_kv):
    tool = TodoTool()
    await tool.execute({"op": "set", "tasks": ["task A"]}, ctx=ToolContext(session_id="sess1", user_id="user1"))
    result = await tool.execute({"op": "update", "index": 1, "status": "done", "validation": "tested"}, ctx=ToolContext(session_id="sess1", user_id="user1"))
    assert result.success is True
    assert "done" in result.output


@pytest.mark.asyncio
async def test_done_requires_validation(_fake_kv):
    tool = TodoTool()
    await tool.execute({"op": "set", "tasks": ["task A"]}, ctx=ToolContext(session_id="sess1", user_id="user1"))
    result = await tool.execute({"op": "update", "index": 1, "status": "done"}, ctx=ToolContext(session_id="sess1", user_id="user1"))
    assert result.success is False
    assert "validation" in result.error.lower()


@pytest.mark.asyncio
async def test_failed_without_validation_warns(_fake_kv):
    tool = TodoTool()
    await tool.execute({"op": "set", "tasks": ["task A"]}, ctx=ToolContext(session_id="sess1", user_id="user1"))
    result = await tool.execute({"op": "update", "index": 1, "status": "failed"}, ctx=ToolContext(session_id="sess1", user_id="user1"))
    assert result.success is True
    assert "Tip" in result.output


@pytest.mark.asyncio
async def test_clear(_fake_kv):
    tool = TodoTool()
    await tool.execute({"op": "set", "tasks": ["task A"]}, ctx=ToolContext(session_id="sess1", user_id="user1"))
    result = await tool.execute({"op": "clear"}, ctx=ToolContext(session_id="sess1", user_id="user1"))
    assert result.success is True
    assert "cleared" in result.output.lower()


# ── Public API tests ─────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_get_task_snapshot(_fake_kv):
    await set_tasks("sess1", ["t1", "t2"])
    snapshot = await get_task_snapshot("sess1")
    assert snapshot == frozenset({("t1", "pending"), ("t2", "pending")})


@pytest.mark.asyncio
async def test_has_open_steps_true_when_pending(_fake_kv):
    await set_tasks("sess1", ["t1", "t2"])
    assert await has_open_steps("sess1") is True


@pytest.mark.asyncio
async def test_has_open_steps_true_when_in_progress(_fake_kv):
    tool = TodoTool()
    await tool.execute(
        {"op": "set", "tasks": ["a", "b"]},
        ctx=ToolContext(session_id="sess1", user_id="user1"),
    )
    await tool.execute(
        {"op": "update", "index": 1, "status": "in_progress"},
        ctx=ToolContext(session_id="sess1", user_id="user1"),
    )
    assert await has_open_steps("sess1") is True


@pytest.mark.asyncio
async def test_has_open_steps_false_when_all_closed(_fake_kv):
    tool = TodoTool()
    await tool.execute(
        {"op": "set", "tasks": ["a", "b"]},
        ctx=ToolContext(session_id="sess1", user_id="user1"),
    )
    await tool.execute(
        {"op": "update", "index": 1, "status": "done", "validation": "ok"},
        ctx=ToolContext(session_id="sess1", user_id="user1"),
    )
    await tool.execute(
        {"op": "update", "index": 2, "status": "skipped"},
        ctx=ToolContext(session_id="sess1", user_id="user1"),
    )
    assert await has_open_steps("sess1") is False


@pytest.mark.asyncio
async def test_has_open_steps_false_when_remaining_only_failed(_fake_kv):
    """failed is terminal (the model closed the step deliberately) — it does not
    count as 'open', so a plan whose only remaining step is failed does not
    trigger a final-turn intercept."""
    tool = TodoTool()
    await tool.execute(
        {"op": "set", "tasks": ["a", "b"]},
        ctx=ToolContext(session_id="sess1", user_id="user1"),
    )
    await tool.execute(
        {"op": "update", "index": 1, "status": "done", "validation": "ok"},
        ctx=ToolContext(session_id="sess1", user_id="user1"),
    )
    await tool.execute(
        {"op": "update", "index": 2, "status": "failed"},
        ctx=ToolContext(session_id="sess1", user_id="user1"),
    )
    assert await has_open_steps("sess1") is False


@pytest.mark.asyncio
async def test_has_open_steps_false_for_empty_plan(_fake_kv):
    """No plan -> no intercept (protects casual messages and observe-only runs)."""
    assert await has_open_steps("never-planned") is False


@pytest.mark.asyncio
async def test_get_progress_message(_fake_kv):
    await set_tasks("sess1", ["t1", "t2"])
    msg = await get_progress_message("sess1", first_iteration=True)
    assert isinstance(msg, Message)
    assert msg.role == "system"
    assert "TODO progress" in msg.content


# ── Sub-agent todo isolation ──────────────────────────────────────────────────
#
# current_todo_session_id scopes a sub-agent's todo to its own ephemeral run id
# so its plan/updates do NOT clobber the parent session's todo (which the parent's
# goal-anchoring reads every iteration). These tests pin the _sid() precedence and
# the end-to-end isolation of the TodoTool writes.


@pytest.mark.asyncio
async def test_sid_prefers_todo_session_id_over_session_and_explicit(_fake_kv):
    from navi.tools._internal.base import current_session_id, current_todo_session_id

    from navi.tools.todo import _sid

    t_tok = current_todo_session_id.set("sub_run_123")
    s_tok = current_session_id.set("parent_sess")
    try:
        # todo-session-id wins over both current_session_id and an explicit arg.
        assert _sid() == "sub_run_123"
        assert _sid("explicit_sess") == "sub_run_123"
    finally:
        current_todo_session_id.reset(t_tok)
        current_session_id.reset(s_tok)


@pytest.mark.asyncio
async def test_sid_falls_back_to_session_id_when_todo_unset(_fake_kv):
    from navi.tools._internal.base import current_session_id

    from navi.tools.todo import _sid

    s_tok = current_session_id.set("parent_sess")
    try:
        assert _sid() == "parent_sess"
        # Explicit arg still wins over the ContextVar fallback when todo-id is unset.
        assert _sid("explicit_sess") == "explicit_sess"
    finally:
        current_session_id.reset(s_tok)
    # And with everything unset, the default sentinel is used.
    assert _sid() == "__default__"


@pytest.mark.asyncio
async def test_todo_tool_writes_isolated_subagent_row(_fake_kv):
    """A sub-agent (current_todo_session_id set to its run id) writes its todo into
    its own KV row, leaving the parent session's todo untouched."""
    from navi.tools._internal.base import current_session_id, current_todo_session_id

    tool = TodoTool()
    # Parent session already has a todo plan.
    await tool.execute(
        {"op": "set", "tasks": ["parent task A"]},
        ctx=ToolContext(session_id="parent_sess", user_id="user1"),
    )

    # Sub-agent context: todo scoped to its run id; ctx.session_id is still the
    # parent session id (other tools rely on it), but todo must isolate.
    t_tok = current_todo_session_id.set("sub_run_abc")
    s_tok = current_session_id.set("parent_sess")
    try:
        await tool.execute(
            {"op": "set", "tasks": ["subagent step 1", "subagent step 2"]},
            ctx=ToolContext(session_id="parent_sess", user_id="user1"),
        )
        await tool.execute(
            {"op": "update", "index": 1, "status": "done", "validation": "verified"},
            ctx=ToolContext(session_id="parent_sess", user_id="user1"),
        )
    finally:
        current_todo_session_id.reset(t_tok)
        current_session_id.reset(s_tok)

    # Sub-agent row carries its own tasks.
    sub_snapshot = await get_task_snapshot("sub_run_abc")
    assert sub_snapshot == frozenset(
        {("subagent step 1", "done"), ("subagent step 2", "pending")}
    )
    # Parent row is untouched — original single task, still pending.
    parent_snapshot = await get_task_snapshot("parent_sess")
    assert parent_snapshot == frozenset({("parent task A", "pending")})


@pytest.mark.asyncio
async def test_progress_message_reads_isolated_rows(_fake_kv):
    """get_progress_message is keyed by the sid it is given, so the parent's
    goal-anchoring (which passes parent_sess) never sees the sub-agent's todo."""
    from navi.tools._internal.base import current_session_id, current_todo_session_id

    tool = TodoTool()
    # Sub-agent writes a 2-step plan into its own row.
    t_tok = current_todo_session_id.set("sub_run_def")
    s_tok = current_session_id.set("parent_sess")
    try:
        await tool.execute(
            {"op": "set", "tasks": ["sub step 1", "sub step 2"]},
            ctx=ToolContext(session_id="parent_sess", user_id="user1"),
        )
    finally:
        current_todo_session_id.reset(t_tok)
        current_session_id.reset(s_tok)

    # Parent's progress message (keyed by parent_sess) sees no plan.
    parent_msg = await get_progress_message("parent_sess")
    assert parent_msg is None
    # Sub-agent's row is readable by its own run id.
    sub_msg = await get_progress_message("sub_run_def")
    assert sub_msg is not None
    assert "sub step 1" in sub_msg.content


# ── load_tasks_for: explicit-id reader for REST / TodoUpdated emitter ─────────


@pytest.mark.asyncio
async def test_load_tasks_for_returns_serialisable_dicts(_fake_kv):
    await set_tasks("sess1", ["t1", "t2"])
    tasks = await load_tasks_for("sess1", None)
    assert tasks == [
        {"index": 1, "text": "t1", "status": "pending", "validation": "", "milestone": ""},
        {"index": 2, "text": "t2", "status": "pending", "validation": "", "milestone": ""},
    ]


@pytest.mark.asyncio
async def test_load_tasks_for_empty_when_no_plan(_fake_kv):
    assert await load_tasks_for("no-such-sess", None) == []


@pytest.mark.asyncio
async def test_load_tasks_for_respects_user_id(_fake_kv):
    """Rows are keyed by (user_id, session_id); load_tasks_for reads only the
    requested user's row, not another user's for the same session id."""
    await _fake_kv.set(
        "userA", "sess1", "todo", "tasks",
        '[{"text": "A task", "status": "pending", "validation": ""}]',
    )
    await _fake_kv.set(
        "userB", "sess1", "todo", "tasks",
        '[{"text": "B task", "status": "done", "validation": "x"}]',
    )
    assert await load_tasks_for("sess1", "userA") == [
        {"index": 1, "text": "A task", "status": "pending", "validation": "", "milestone": ""},
    ]
    assert await load_tasks_for("sess1", "userB") == [
        {"index": 1, "text": "B task", "status": "done", "validation": "x", "milestone": ""},
    ]
    # No row for userC → empty.
    assert await load_tasks_for("sess1", "userC") == []


@pytest.mark.asyncio
async def test_load_tasks_for_uses_explicit_kv_not_global(_fake_kv):
    """When an explicit kv is passed, the module-global _kv_store is ignored —
    the REST route resolves its own KvStore via deps."""

    class _MemKv:
        def __init__(self):
            self._d = {}

        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

    # The autouse _fake_kv fixture backs the module global with a row.
    await set_tasks("sess1", ["global-row"])
    # A separate in-memory kv with different contents must win when passed.
    other = _MemKv()
    await other.set(None, "sess1", "todo", "tasks", '[{"text": "kv-row", "status": "done", "validation": "x"}]')
    tasks = await load_tasks_for("sess1", None, kv=other)
    assert tasks == [{"index": 1, "text": "kv-row", "status": "done", "validation": "x", "milestone": ""}]
    # And the global (no kv arg) still reads the global row.
    assert await load_tasks_for("sess1", None) == [
        {"index": 1, "text": "global-row", "status": "pending", "validation": "", "milestone": ""},
    ]


# ── milestone grouping label (from the planner) ──────────────────────────────


@pytest.mark.asyncio
async def test_set_tasks_accepts_milestone_tuples(_fake_kv):
    """The planner passes list[tuple[milestone, text]]; set_tasks stores the
    milestone label so it flows through to the REST/WS payload."""
    await set_tasks("sess1", [("A", "read spec"), ("A", "write parser"), ("B", "run tests")])
    tasks = await load_tasks_for("sess1", None)
    assert [t["milestone"] for t in tasks] == ["A", "A", "B"]
    assert [t["text"] for t in tasks] == ["read spec", "write parser", "run tests"]


@pytest.mark.asyncio
async def test_set_tasks_legacy_strings_get_empty_milestone(_fake_kv):
    """Legacy list[str] callers keep working: milestone defaults to ''."""
    await set_tasks("sess1", ["t1", "t2"])
    tasks = await load_tasks_for("sess1", None)
    assert all(t["milestone"] == "" for t in tasks)


@pytest.mark.asyncio
async def test_render_prefixed_with_milestone(_fake_kv):
    """The textual ToolResult render prefixes the milestone letter when set,
    so the chat card shows grouping without the full TUI side-panel."""
    await set_tasks("sess1", [("A", "read spec"), ("B", "run tests")])
    tool = TodoTool()
    result = await tool.execute({"op": "view"}, ctx=ToolContext(session_id="sess1"))
    assert "[A] read spec" in result.output
    assert "[B] run tests" in result.output


# ── step_text_for_update / started_metadata_for_call ────────────────────────
# The call-card step text: the LLM's update args carry only the index, so the
# text is read from the current plan row before the tool runs.


@pytest.mark.asyncio
async def test_step_text_for_update_returns_text_for_index(_fake_kv):
    from navi.tools.todo import step_text_for_update

    await set_tasks("sess1", ["read spec", "write parser", "run tests"])
    ctx = ToolContext(session_id="sess1", user_id="user1")
    assert await step_text_for_update(2, ctx) == "write parser"
    assert await step_text_for_update(3, ctx) == "run tests"


@pytest.mark.asyncio
async def test_step_text_for_update_out_of_range_returns_none(_fake_kv):
    from navi.tools.todo import step_text_for_update

    await set_tasks("sess1", ["only one"])
    ctx = ToolContext(session_id="sess1", user_id="user1")
    assert await step_text_for_update(9, ctx) is None
    assert await step_text_for_update(0, ctx) is None


@pytest.mark.asyncio
async def test_step_text_for_update_no_plan_returns_none(_fake_kv):
    from navi.tools.todo import step_text_for_update

    ctx = ToolContext(session_id="no-plan", user_id="user1")
    assert await step_text_for_update(1, ctx) is None


@pytest.mark.asyncio
async def test_step_text_for_update_bad_index_returns_none(_fake_kv):
    from navi.tools.todo import step_text_for_update

    await set_tasks("sess1", ["a"])
    ctx = ToolContext(session_id="sess1", user_id="user1")
    assert await step_text_for_update(None, ctx) is None
    assert await step_text_for_update("oops", ctx) is None


@pytest.mark.asyncio
async def test_started_metadata_for_call_todo_update_attaches_step_text(_fake_kv):
    from navi.tools.todo import started_metadata_for_call

    await set_tasks("sess1", ["read spec", "write parser"])

    class _TC:
        name = "todo"
        arguments = {"op": "update", "index": 2, "status": "in_progress"}

    ctx = ToolContext(session_id="sess1", user_id="user1")
    meta = await started_metadata_for_call(_TC(), ctx)
    assert meta == {"step_text": "write parser"}


@pytest.mark.asyncio
async def test_started_metadata_for_call_empty_for_non_update(_fake_kv):
    from navi.tools.todo import started_metadata_for_call

    await set_tasks("sess1", ["a"])

    class _TC:
        name = "todo"
        arguments = {"op": "view"}

    ctx = ToolContext(session_id="sess1", user_id="user1")
    assert await started_metadata_for_call(_TC(), ctx) == {}


@pytest.mark.asyncio
async def test_started_metadata_for_call_empty_for_other_tools(_fake_kv):
    from navi.tools.todo import started_metadata_for_call

    class _TC:
        name = "filesystem"
        arguments = {"op": "update", "index": 1}

    ctx = ToolContext(session_id="sess1", user_id="user1")
    assert await started_metadata_for_call(_TC(), ctx) == {}


@pytest.mark.asyncio
async def test_started_metadata_for_call_empty_when_step_missing(_fake_kv):
    """If the step text can't be resolved (no plan / bad index), no metadata is
    attached — the renderer falls back to its placeholder."""
    from navi.tools.todo import started_metadata_for_call

    class _TC:
        name = "todo"
        arguments = {"op": "update", "index": 5, "status": "done"}

    ctx = ToolContext(session_id="no-plan", user_id="user1")
    assert await started_metadata_for_call(_TC(), ctx) == {}
