"""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,
    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_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": ""},
        {"index": 2, "text": "t2", "status": "pending", "validation": ""},
    ]


@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": ""},
    ]
    assert await load_tasks_for("sess1", "userB") == [
        {"index": 1, "text": "B task", "status": "done", "validation": "x"},
    ]
    # 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"}]
    # 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": ""},
    ]
