"""Tests for the thread tags tool."""

from __future__ import annotations

import json
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, patch

import pytest

import mindroom.tools  # noqa: F401
from mindroom import thread_tag_vocabulary
from mindroom.config.agent import AgentConfig
from mindroom.config.main import Config
from mindroom.custom_tools.thread_tags import ThreadTagsTools
from mindroom.matrix.client_thread_history import RoomThreadsPageError
from mindroom.message_target import MessageTarget
from mindroom.thread_tags import ThreadTagRecord, ThreadTagsError, ThreadTagsListing, ThreadTagsState
from mindroom.tool_system.metadata import TOOL_METADATA, get_tool_by_name
from mindroom.tool_system.runtime_context import ToolRuntimeContext, tool_runtime_context
from tests.conftest import bind_runtime_paths, make_event_cache_mock, runtime_paths_for, test_runtime_paths

if TYPE_CHECKING:
    from mindroom.constants import RuntimePaths


def _make_context(
    *,
    room_id: str = "!room:localhost",
    thread_id: str | None = "$thread:localhost",
    reply_to_event_id: str | None = None,
) -> ToolRuntimeContext:
    runtime_root = Path(tempfile.mkdtemp())
    config = bind_runtime_paths(
        Config(agents={"general": AgentConfig(display_name="General Agent")}),
        test_runtime_paths(runtime_root),
    )
    return ToolRuntimeContext(
        agent_name="general",
        target=MessageTarget.resolve(
            room_id=room_id,
            thread_id=thread_id,
            reply_to_event_id=reply_to_event_id,
        ),
        requester_id="@user:localhost",
        client=AsyncMock(),
        config=config,
        runtime_paths=runtime_paths_for(config),
        conversation_cache=AsyncMock(),
        event_cache=make_event_cache_mock(),
        room=None,
        storage_path=None,
    )


def _state(thread_root_id: str, **tags: ThreadTagRecord) -> ThreadTagsState:
    return ThreadTagsState(
        room_id="!room:localhost",
        thread_root_id=thread_root_id,
        tags=tags,
    )


def _listing(tag_state: dict[str, ThreadTagsState]) -> ThreadTagsListing:
    return ThreadTagsListing(
        tag_state=tag_state,
        include_untagged=False,
        truncated=False,
    )


def _record(
    *,
    note: str | None = None,
    data: dict[str, object] | None = None,
) -> ThreadTagRecord:
    return ThreadTagRecord(
        set_by="@user:localhost",
        set_at=datetime(2026, 3, 21, 19, 2, 3, tzinfo=UTC),
        note=note,
        data=data or {},
    )


def test_thread_tags_tool_registered_and_instantiates() -> None:
    """Thread tags should be available from the metadata registry."""
    runtime_root = Path(tempfile.mkdtemp())
    config = bind_runtime_paths(
        Config(agents={"general": AgentConfig(display_name="General Agent")}),
        test_runtime_paths(runtime_root),
    )

    assert "thread_tags" in TOOL_METADATA
    assert TOOL_METADATA["thread_tags"].requires_room_context
    assert isinstance(
        get_tool_by_name("thread_tags", runtime_paths_for(config), worker_target=None),
        ThreadTagsTools,
    )


@pytest.mark.asyncio
async def test_thread_tags_tool_requires_runtime_context() -> None:
    """Tool calls should fail clearly outside Matrix runtime context."""
    payload = json.loads(await ThreadTagsTools().tag_thread("topic"))

    assert payload["status"] == "error"
    assert payload["tool"] == "thread_tags"
    assert "context" in payload["message"]


@pytest.mark.asyncio
async def test_untag_thread_requires_runtime_context() -> None:
    """Untag should fail clearly outside Matrix runtime context."""
    payload = json.loads(await ThreadTagsTools().untag_thread("topic"))

    assert payload["status"] == "error"
    assert payload["tool"] == "thread_tags"
    assert "context" in payload["message"]


@pytest.mark.asyncio
async def test_list_thread_tags_requires_runtime_context() -> None:
    """List should fail clearly outside Matrix runtime context."""
    payload = json.loads(await ThreadTagsTools().list_thread_tags())

    assert payload["status"] == "error"
    assert payload["tool"] == "thread_tags"
    assert "context" in payload["message"]


@pytest.mark.asyncio
async def test_tag_thread_defaults_to_context_thread_id() -> None:
    """Tag should use the active thread root when not overridden."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_tags.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$ctx-thread:localhost"),
        ) as mock_normalize,
        patch(
            "mindroom.custom_tools.thread_tags.set_thread_tag",
            new=AsyncMock(return_value=_state("$ctx-thread:localhost", topic=_record())),
        ) as mock_set,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.tag_thread("topic"))

    assert payload["status"] == "ok"
    assert payload["action"] == "tag"
    assert payload["thread_id"] == "$ctx-thread:localhost"
    mock_normalize.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$ctx-thread:localhost",
        conversation_cache=context.conversation_cache,
    )
    mock_set.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$ctx-thread:localhost",
        "topic",
        set_by=context.requester_id,
        note=None,
        data=None,
    )


@pytest.mark.asyncio
async def test_tag_thread_explicit_thread_id_overrides_same_room_context() -> None:
    """An explicit thread target should win over the active same-room thread context."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_tags.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$explicit-thread:localhost"),
        ) as mock_normalize,
        patch(
            "mindroom.custom_tools.thread_tags.set_thread_tag",
            new=AsyncMock(return_value=_state("$explicit-thread:localhost", topic=_record())),
        ) as mock_set,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.tag_thread("topic", thread_id="$explicit-event:localhost"))

    assert payload["status"] == "ok"
    assert payload["thread_id"] == "$explicit-thread:localhost"
    mock_normalize.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$explicit-event:localhost",
        conversation_cache=context.conversation_cache,
    )
    mock_set.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$explicit-thread:localhost",
        "topic",
        set_by=context.requester_id,
        note=None,
        data=None,
    )


@pytest.mark.asyncio
async def test_tag_thread_explicit_same_room_id_keeps_context_thread_fallback() -> None:
    """Repeating the current room ID should still target the active thread for writes."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_tags.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$ctx-thread:localhost"),
        ) as mock_normalize,
        patch(
            "mindroom.custom_tools.thread_tags.set_thread_tag",
            new=AsyncMock(return_value=_state("$ctx-thread:localhost", topic=_record())),
        ) as mock_set,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.tag_thread("topic", room_id=context.room_id))

    assert payload["status"] == "ok"
    assert payload["thread_id"] == "$ctx-thread:localhost"
    mock_normalize.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$ctx-thread:localhost",
        conversation_cache=context.conversation_cache,
    )
    mock_set.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$ctx-thread:localhost",
        "topic",
        set_by=context.requester_id,
        note=None,
        data=None,
    )


@pytest.mark.asyncio
async def test_untag_thread_defaults_to_context_thread_id() -> None:
    """Untag should use the active thread root when not overridden."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_tags.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$ctx-thread:localhost"),
        ) as mock_normalize,
        patch(
            "mindroom.custom_tools.thread_tags.remove_thread_tag",
            new=AsyncMock(return_value=_state("$ctx-thread:localhost")),
        ) as mock_remove,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.untag_thread("topic"))

    assert payload["status"] == "ok"
    assert payload["action"] == "untag"
    assert payload["thread_id"] == "$ctx-thread:localhost"
    assert payload["tags"] == {}
    mock_normalize.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$ctx-thread:localhost",
        conversation_cache=context.conversation_cache,
    )
    mock_remove.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$ctx-thread:localhost",
        "topic",
        requester_user_id=context.requester_id,
    )


@pytest.mark.asyncio
async def test_untag_thread_coerces_free_form_tag_input() -> None:
    """Untag should canonicalize free-form input exactly like tag creation."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_tags.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$ctx-thread:localhost"),
        ),
        patch(
            "mindroom.custom_tools.thread_tags.remove_thread_tag",
            new=AsyncMock(return_value=_state("$ctx-thread:localhost")),
        ) as mock_remove,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.untag_thread("Follow Up"))

    assert payload["status"] == "ok"
    assert payload["tag"] == "follow-up"
    mock_remove.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$ctx-thread:localhost",
        "follow-up",
        requester_user_id=context.requester_id,
    )


@pytest.mark.asyncio
async def test_untag_thread_preserves_long_canonical_tag_beside_prefix_sibling() -> None:
    """Untag must target a valid long ID instead of its 25-character sibling."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id="$ctx-thread:localhost")
    long_tag = "abcdefghijklmnopqrstuvwxyz"
    short_sibling = long_tag[:25]

    with (
        patch(
            "mindroom.custom_tools.thread_tags.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$ctx-thread:localhost"),
        ),
        patch(
            "mindroom.custom_tools.thread_tags.remove_thread_tag",
            new=AsyncMock(return_value=_state("$ctx-thread:localhost", **{short_sibling: _record()})),
        ) as remove,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.untag_thread(long_tag))

    assert payload["status"] == "ok"
    assert payload["tag"] == long_tag
    remove.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$ctx-thread:localhost",
        long_tag,
        requester_user_id=context.requester_id,
    )


@pytest.mark.asyncio
async def test_untag_thread_explicit_thread_id_overrides_same_room_context() -> None:
    """An explicit untag target should not be silently replaced by context state."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_tags.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$explicit-thread:localhost"),
        ) as mock_normalize,
        patch(
            "mindroom.custom_tools.thread_tags.remove_thread_tag",
            new=AsyncMock(return_value=_state("$explicit-thread:localhost")),
        ) as mock_remove,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.untag_thread("topic", thread_id="$explicit-event:localhost"))

    assert payload["status"] == "ok"
    assert payload["thread_id"] == "$explicit-thread:localhost"
    mock_normalize.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$explicit-event:localhost",
        conversation_cache=context.conversation_cache,
    )
    mock_remove.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$explicit-thread:localhost",
        "topic",
        requester_user_id=context.requester_id,
    )


@pytest.mark.asyncio
async def test_untag_thread_explicit_same_room_id_keeps_context_thread_fallback() -> None:
    """Repeating the current room ID should still target the active thread for untag."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_tags.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$ctx-thread:localhost"),
        ) as mock_normalize,
        patch(
            "mindroom.custom_tools.thread_tags.remove_thread_tag",
            new=AsyncMock(return_value=_state("$ctx-thread:localhost")),
        ) as mock_remove,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.untag_thread("topic", room_id=context.room_id))

    assert payload["status"] == "ok"
    assert payload["thread_id"] == "$ctx-thread:localhost"
    mock_normalize.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$ctx-thread:localhost",
        conversation_cache=context.conversation_cache,
    )
    mock_remove.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$ctx-thread:localhost",
        "topic",
        requester_user_id=context.requester_id,
    )


@pytest.mark.asyncio
async def test_list_thread_tags_defaults_to_context_thread_id() -> None:
    """List should use the active thread root when not overridden."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_tags.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$ctx-thread:localhost"),
        ) as mock_normalize,
        patch(
            "mindroom.custom_tools.thread_tags.list_tagged_threads",
            new=AsyncMock(
                return_value=_listing(
                    {"$ctx-thread:localhost": _state("$ctx-thread:localhost", resolved=_record(note="done"))},
                ),
            ),
        ) as mock_list,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.list_thread_tags())

    assert payload["status"] == "ok"
    assert payload["action"] == "list"
    assert payload["thread_id"] == "$ctx-thread:localhost"
    assert payload["tags"]["resolved"]["note"] == "done"
    mock_normalize.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$ctx-thread:localhost",
        conversation_cache=context.conversation_cache,
    )
    mock_list.assert_awaited_once_with(
        context.client,
        context.room_id,
        tag=None,
        include_tag=None,
        exclude_tag=None,
    )


@pytest.mark.asyncio
async def test_list_thread_tags_explicit_thread_id_overrides_same_room_context() -> None:
    """Room-wide context should not override an explicit list target in the same room."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_tags.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$explicit-thread:localhost"),
        ) as mock_normalize,
        patch(
            "mindroom.custom_tools.thread_tags.list_tagged_threads",
            new=AsyncMock(
                return_value=_listing(
                    {"$explicit-thread:localhost": _state("$explicit-thread:localhost", resolved=_record(note="done"))},
                ),
            ),
        ) as mock_list,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.list_thread_tags(thread_id="$explicit-event:localhost"))

    assert payload["status"] == "ok"
    assert payload["thread_id"] == "$explicit-thread:localhost"
    assert payload["tags"]["resolved"]["note"] == "done"
    mock_normalize.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$explicit-event:localhost",
        conversation_cache=context.conversation_cache,
    )
    mock_list.assert_awaited_once_with(
        context.client,
        context.room_id,
        tag=None,
        include_tag=None,
        exclude_tag=None,
    )


@pytest.mark.asyncio
@pytest.mark.parametrize("method_name", ["tag_thread", "untag_thread", "list_thread_tags"])
async def test_thread_tags_explicit_room_target_requires_authorization(method_name: str) -> None:
    """Explicit room targeting should enforce the same room access checks as matrix_message."""
    tool = ThreadTagsTools()
    context = _make_context()

    with tool_runtime_context(context):
        if method_name == "tag_thread":
            payload = json.loads(await tool.tag_thread("topic", room_id="!other:localhost"))
        elif method_name == "untag_thread":
            payload = json.loads(await tool.untag_thread("topic", room_id="!other:localhost"))
        else:
            payload = json.loads(await tool.list_thread_tags(room_id="!other:localhost"))

    assert payload["status"] == "error"
    assert payload["room_id"] == "!other:localhost"
    assert "Not authorized" in payload["message"]


@pytest.mark.asyncio
@pytest.mark.parametrize(
    ("method_name", "args"),
    [
        ("tag_thread", ("topic",)),
        ("untag_thread", ("topic",)),
        ("list_thread_tags", ()),
    ],
)
async def test_thread_tags_reject_blank_explicit_room_id(
    method_name: str,
    args: tuple[str, ...],
) -> None:
    """Explicit blank room IDs should not fall back to the current room."""
    tool = ThreadTagsTools()
    context = _make_context()

    with tool_runtime_context(context):
        if method_name == "tag_thread":
            payload = json.loads(await tool.tag_thread(*args, room_id=""))
        elif method_name == "untag_thread":
            payload = json.loads(await tool.untag_thread(*args, room_id=""))
        else:
            payload = json.loads(await tool.list_thread_tags(room_id=""))

    assert payload["status"] == "error"
    assert payload["room_id"] == ""
    assert "room_id" in payload["message"]


@pytest.mark.asyncio
@pytest.mark.parametrize(
    ("method_name", "args"),
    [
        ("tag_thread", ("topic",)),
        ("untag_thread", ("topic",)),
        ("list_thread_tags", ()),
    ],
)
async def test_thread_tags_reject_non_string_room_id(
    method_name: str,
    args: tuple[str, ...],
) -> None:
    """Explicit non-string room IDs should fail with a normal tool payload."""
    tool = ThreadTagsTools()
    context = _make_context()

    with tool_runtime_context(context):
        if method_name == "tag_thread":
            payload = json.loads(await tool.tag_thread(*args, room_id=123))
        elif method_name == "untag_thread":
            payload = json.loads(await tool.untag_thread(*args, room_id=123))
        else:
            payload = json.loads(await tool.list_thread_tags(room_id=123))

    assert payload["status"] == "error"
    assert payload["room_id"] == 123
    assert "room_id" in payload["message"]


@pytest.mark.asyncio
async def test_thread_tags_cross_room_does_not_inherit_context_thread() -> None:
    """Cross-room tagging should not silently reuse the origin room thread context."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id="$origin-thread:localhost")

    with (
        patch("mindroom.custom_tools.thread_tags.room_access_allowed", return_value=True),
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.tag_thread("topic", room_id="!other:localhost"))

    assert payload["status"] == "error"
    assert payload["action"] == "tag"
    assert "thread_id is required" in payload["message"]


@pytest.mark.asyncio
async def test_tag_thread_normalizes_explicit_thread_id_before_write() -> None:
    """Explicit event IDs should be normalized to the canonical thread root."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id=None)

    with (
        patch(
            "mindroom.custom_tools.thread_tags.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$thread-root:localhost"),
        ) as mock_normalize,
        patch(
            "mindroom.custom_tools.thread_tags.set_thread_tag",
            new=AsyncMock(
                return_value=_state(
                    "$thread-root:localhost",
                    blocked=_record(data={"blocked_by": ["$other:localhost"]}),
                ),
            ),
        ) as mock_set,
        tool_runtime_context(context),
    ):
        payload = json.loads(
            await tool.tag_thread(
                "blocked",
                thread_id="$reply-event:localhost",
                data={"blocked_by": ["$other:localhost"]},
            ),
        )

    assert payload["status"] == "ok"
    assert payload["thread_id"] == "$thread-root:localhost"
    mock_normalize.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$reply-event:localhost",
        conversation_cache=context.conversation_cache,
    )
    mock_set.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$thread-root:localhost",
        "blocked",
        set_by=context.requester_id,
        note=None,
        data={"blocked_by": ["$other:localhost"]},
    )


@pytest.mark.asyncio
async def test_tag_thread_returns_error_when_normalization_fails() -> None:
    """Normalization failures should surface as structured errors instead of guessing."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id="$reply:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_tags.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value=None),
        ),
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.tag_thread("topic"))

    assert payload["status"] == "error"
    assert payload["action"] == "tag"
    assert payload["thread_id"] == "$reply:localhost"
    assert "canonical thread root" in payload["message"]


@pytest.mark.asyncio
async def test_tag_thread_surfaces_write_failures() -> None:
    """State write failures should return structured tool errors."""
    tool = ThreadTagsTools()
    context = _make_context()

    with (
        patch(
            "mindroom.custom_tools.thread_tags.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$thread:localhost"),
        ),
        patch(
            "mindroom.custom_tools.thread_tags.set_thread_tag",
            new=AsyncMock(side_effect=ThreadTagsError("write failed")),
        ),
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.tag_thread("topic"))

    assert payload["status"] == "error"
    assert payload["thread_id"] == "$thread:localhost"
    assert payload["message"] == "write failed"


@pytest.mark.asyncio
async def test_list_thread_tags_uses_room_wide_listing_without_thread_context() -> None:
    """Room-level replies without thread context should not infer a synthetic thread root."""
    tool = ThreadTagsTools()
    context = _make_context(
        thread_id=None,
        reply_to_event_id="$root-event:localhost",
    )

    with (
        patch(
            "mindroom.custom_tools.thread_tags.list_tagged_threads",
            new=AsyncMock(
                return_value=_listing({"$thread-one:localhost": _state("$thread-one:localhost", resolved=_record())}),
            ),
        ) as mock_list,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.list_thread_tags())

    assert payload["status"] == "ok"
    assert payload["room_wide"] is True
    assert payload["threads"]["$thread-one:localhost"]["resolved"]["data"] == {}
    assert payload["threads"]["$thread-one:localhost"]["resolved"]["set_by"] == "@user:localhost"
    assert payload["threads"]["$thread-one:localhost"]["resolved"]["set_at"].startswith("2026-03-21T19:02:03")
    mock_list.assert_awaited_once_with(
        context.client,
        context.room_id,
        tag=None,
        include_tag=None,
        exclude_tag=None,
        include_untagged=False,
    )


@pytest.mark.asyncio
async def test_list_thread_tags_lists_room_wide_when_no_thread_is_available() -> None:
    """No thread target should switch the tool into room-wide listing mode."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id=None, reply_to_event_id=None)

    with (
        patch(
            "mindroom.custom_tools.thread_tags.list_tagged_threads",
            new=AsyncMock(
                return_value=_listing(
                    {
                        "$thread-two:localhost": _state(
                            "$thread-two:localhost",
                            blocked=_record(data={"blocked_by": ["$other:localhost"]}),
                            resolved=_record(note="done"),
                        ),
                    },
                ),
            ),
        ) as mock_list,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.list_thread_tags(tag="blocked"))

    assert payload["status"] == "ok"
    assert payload["room_wide"] is True
    assert payload["tag"] == "blocked"
    assert list(payload["threads"]) == ["$thread-two:localhost"]
    assert list(payload["threads"]["$thread-two:localhost"]) == ["blocked"]
    assert payload["threads"]["$thread-two:localhost"]["blocked"]["data"] == {"blocked_by": ["$other:localhost"]}
    assert payload["threads"]["$thread-two:localhost"]["blocked"]["set_by"] == "@user:localhost"
    assert datetime.fromisoformat(payload["threads"]["$thread-two:localhost"]["blocked"]["set_at"]).tzinfo is not None
    mock_list.assert_awaited_once_with(
        context.client,
        context.room_id,
        tag="blocked",
        include_tag=None,
        exclude_tag=None,
        include_untagged=False,
    )


@pytest.mark.asyncio
async def test_list_thread_tags_room_wide_filters_by_include_tag_only() -> None:
    """Room-wide listing should keep only threads that carry the included tag."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id=None, reply_to_event_id=None)

    with (
        patch(
            "mindroom.custom_tools.thread_tags.list_tagged_threads",
            new=AsyncMock(
                return_value=_listing(
                    {
                        "$thread-one:localhost": _state(
                            "$thread-one:localhost",
                            blocked=_record(data={"blocked_by": ["$other:localhost"]}),
                        ),
                        "$thread-three:localhost": _state(
                            "$thread-three:localhost",
                            blocked=_record(data={"blocked_by": ["$other:localhost"]}),
                            resolved=_record(note="done"),
                        ),
                    },
                ),
            ),
        ) as mock_list,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.list_thread_tags(include_tag="blocked"))

    assert payload["status"] == "ok"
    assert payload["room_wide"] is True
    assert payload["include_tag"] == "blocked"
    assert payload["exclude_tag"] is None
    assert list(payload["threads"]) == ["$thread-one:localhost", "$thread-three:localhost"]
    assert set(payload["threads"]["$thread-one:localhost"]) == {"blocked"}
    assert set(payload["threads"]["$thread-three:localhost"]) == {"blocked", "resolved"}
    mock_list.assert_awaited_once_with(
        context.client,
        context.room_id,
        tag=None,
        include_tag="blocked",
        exclude_tag=None,
        include_untagged=False,
    )


@pytest.mark.asyncio
async def test_list_thread_tags_room_wide_requires_tag_and_include_tag_together() -> None:
    """Room-wide listing should require both tag filters when tag and include_tag are combined."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id=None, reply_to_event_id=None)

    with (
        patch(
            "mindroom.custom_tools.thread_tags.list_tagged_threads",
            new=AsyncMock(
                return_value=_listing(
                    {
                        "$thread-two:localhost": _state(
                            "$thread-two:localhost",
                            blocked=_record(data={"blocked_by": ["$other:localhost"]}),
                            waiting=_record(data={"waiting_on": ["@owner:localhost"]}),
                        ),
                    },
                ),
            ),
        ) as mock_list,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.list_thread_tags(tag="blocked", include_tag="waiting"))

    assert payload["status"] == "ok"
    assert payload["room_wide"] is True
    assert payload["tag"] == "blocked"
    assert payload["include_tag"] == "waiting"
    assert list(payload["threads"]) == ["$thread-two:localhost"]
    assert list(payload["threads"]["$thread-two:localhost"]) == ["blocked"]
    mock_list.assert_awaited_once_with(
        context.client,
        context.room_id,
        tag="blocked",
        include_tag="waiting",
        exclude_tag=None,
        include_untagged=False,
    )


@pytest.mark.asyncio
async def test_list_thread_tags_room_wide_requires_tag_without_excluded_tag() -> None:
    """Room-wide listing should keep threads with the tag unless they also carry the excluded tag."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id=None, reply_to_event_id=None)

    with (
        patch(
            "mindroom.custom_tools.thread_tags.list_tagged_threads",
            new=AsyncMock(
                return_value=_listing(
                    {
                        "$thread-one:localhost": _state(
                            "$thread-one:localhost",
                            blocked=_record(data={"blocked_by": ["$other:localhost"]}),
                        ),
                    },
                ),
            ),
        ) as mock_list,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.list_thread_tags(tag="blocked", exclude_tag="resolved"))

    assert payload["status"] == "ok"
    assert payload["room_wide"] is True
    assert payload["tag"] == "blocked"
    assert payload["exclude_tag"] == "resolved"
    assert list(payload["threads"]) == ["$thread-one:localhost"]
    assert list(payload["threads"]["$thread-one:localhost"]) == ["blocked"]
    mock_list.assert_awaited_once_with(
        context.client,
        context.room_id,
        tag="blocked",
        include_tag=None,
        exclude_tag="resolved",
        include_untagged=False,
    )


@pytest.mark.asyncio
async def test_list_thread_tags_room_wide_returns_empty_when_include_tag_matches_nothing() -> None:
    """Room-wide listing should return an empty result when no thread has the included tag."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id=None, reply_to_event_id=None)

    with (
        patch(
            "mindroom.custom_tools.thread_tags.list_tagged_threads",
            new=AsyncMock(
                return_value=_listing({}),
            ),
        ) as mock_list,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.list_thread_tags(include_tag="nonexistent"))

    assert payload["status"] == "ok"
    assert payload["room_wide"] is True
    assert payload["include_tag"] == "nonexistent"
    assert payload["threads"] == {}
    mock_list.assert_awaited_once_with(
        context.client,
        context.room_id,
        tag=None,
        include_tag="nonexistent",
        exclude_tag=None,
        include_untagged=False,
    )


@pytest.mark.asyncio
async def test_list_thread_tags_room_wide_returns_empty_when_exclude_tag_filters_all_threads() -> None:
    """Room-wide listing should return an empty result when every thread has the excluded tag."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id=None, reply_to_event_id=None)

    with (
        patch(
            "mindroom.custom_tools.thread_tags.list_tagged_threads",
            new=AsyncMock(
                return_value=_listing({}),
            ),
        ) as mock_list,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.list_thread_tags(exclude_tag="resolved"))

    assert payload["status"] == "ok"
    assert payload["room_wide"] is True
    assert payload["exclude_tag"] == "resolved"
    assert payload["threads"] == {}
    mock_list.assert_awaited_once_with(
        context.client,
        context.room_id,
        tag=None,
        include_tag=None,
        exclude_tag="resolved",
        include_untagged=False,
    )


@pytest.mark.asyncio
async def test_list_thread_tags_room_wide_normalizes_mixed_case_include_and_exclude_tags() -> None:
    """Room-wide listing should normalize include and exclude tag inputs before filtering."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id=None, reply_to_event_id=None)

    with (
        patch(
            "mindroom.custom_tools.thread_tags.list_tagged_threads",
            new=AsyncMock(
                return_value=_listing(
                    {
                        "$thread-one:localhost": _state(
                            "$thread-one:localhost",
                            blocked=_record(data={"blocked_by": ["$other:localhost"]}),
                        ),
                    },
                ),
            ),
        ) as mock_list,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.list_thread_tags(include_tag="BloCked", exclude_tag="ReSoLved"))

    assert payload["status"] == "ok"
    assert payload["room_wide"] is True
    assert payload["include_tag"] == "blocked"
    assert payload["exclude_tag"] == "resolved"
    assert list(payload["threads"]) == ["$thread-one:localhost"]
    mock_list.assert_awaited_once_with(
        context.client,
        context.room_id,
        tag=None,
        include_tag="blocked",
        exclude_tag="resolved",
        include_untagged=False,
    )


@pytest.mark.asyncio
async def test_list_thread_tags_coerces_all_free_form_tag_filters() -> None:
    """Every list filter should use the same model-facing tag coercion."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id=None, reply_to_event_id=None)

    with (
        patch(
            "mindroom.custom_tools.thread_tags.list_tagged_threads",
            new=AsyncMock(return_value=_listing({})),
        ) as mock_list,
        tool_runtime_context(context),
    ):
        payload = json.loads(
            await tool.list_thread_tags(
                tag="Follow Up",
                include_tag=" Needs Review ",
                exclude_tag="Already_Resolved",
            ),
        )

    assert payload["status"] == "ok"
    assert payload["tag"] == "follow-up"
    assert payload["include_tag"] == "needs-review"
    assert payload["exclude_tag"] == "already-resolved"
    mock_list.assert_awaited_once_with(
        context.client,
        context.room_id,
        tag="follow-up",
        include_tag="needs-review",
        exclude_tag="already-resolved",
        include_untagged=False,
    )


@pytest.mark.asyncio
async def test_list_thread_tags_preserves_long_canonical_filter_beside_prefix_sibling() -> None:
    """List filters must preserve exact 26-50 character canonical tag IDs."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id=None, reply_to_event_id=None)
    long_tag = "abcdefghijklmnopqrstuvwxyz"
    short_sibling = long_tag[:25]

    with (
        patch(
            "mindroom.custom_tools.thread_tags.list_tagged_threads",
            new=AsyncMock(return_value=_listing({})),
        ) as list_tags,
        tool_runtime_context(context),
    ):
        payload = json.loads(
            await tool.list_thread_tags(
                tag=long_tag,
                include_tag=long_tag,
                exclude_tag=short_sibling,
            ),
        )

    assert payload["tag"] == long_tag
    assert payload["include_tag"] == long_tag
    assert payload["exclude_tag"] == short_sibling
    list_tags.assert_awaited_once_with(
        context.client,
        context.room_id,
        tag=long_tag,
        include_tag=long_tag,
        exclude_tag=short_sibling,
        include_untagged=False,
    )


@pytest.mark.asyncio
async def test_list_thread_tags_explicit_same_room_target_can_list_room_wide_from_thread_context() -> None:
    """An explicit same-room target should disable thread fallback and allow room-wide listing."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_tags.resolve_thread_root_event_id_for_client",
            new=AsyncMock(),
        ) as mock_normalize,
        patch(
            "mindroom.custom_tools.thread_tags.list_tagged_threads",
            new=AsyncMock(
                return_value=_listing(
                    {
                        "$thread-two:localhost": _state(
                            "$thread-two:localhost",
                            blocked=_record(data={"blocked_by": ["$other:localhost"]}),
                            resolved=_record(note="done"),
                        ),
                    },
                ),
            ),
        ) as mock_list,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.list_thread_tags(room_id=context.room_id, tag="blocked"))

    assert payload["status"] == "ok"
    assert payload["room_wide"] is True
    assert list(payload["threads"]) == ["$thread-two:localhost"]
    assert list(payload["threads"]["$thread-two:localhost"]) == ["blocked"]
    mock_list.assert_awaited_once_with(
        context.client,
        context.room_id,
        tag="blocked",
        include_tag=None,
        exclude_tag=None,
        include_untagged=False,
    )
    mock_normalize.assert_not_awaited()


@pytest.mark.asyncio
async def test_list_thread_tags_room_wide_returns_error_on_room_state_failure() -> None:
    """Room-wide listing should surface helper read failures as tool errors."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id=None, reply_to_event_id=None)

    with (
        patch(
            "mindroom.custom_tools.thread_tags.list_tagged_threads",
            new=AsyncMock(side_effect=ThreadTagsError("room state forbidden")),
        ),
        tool_runtime_context(context),
    ):
        payload = json.loads(
            await tool.list_thread_tags(include_tag="blocked", exclude_tag="resolved", include_untagged=True),
        )

    assert payload["status"] == "error"
    assert payload["action"] == "list"
    assert payload["room_id"] == context.room_id
    assert payload["tag"] is None
    assert payload["include_tag"] == "blocked"
    assert payload["exclude_tag"] == "resolved"
    assert payload["include_untagged"] is True
    assert payload["message"] == "room state forbidden"


@pytest.mark.asyncio
async def test_list_thread_tags_include_untagged_headline_query_from_runtime_context() -> None:
    """The unresolved-threads query should return tagged-not-resolved and untagged roots."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id="$ctx-thread:localhost")
    listing = ThreadTagsListing(
        tag_state={
            "$blocked-thread:localhost": _state(
                "$blocked-thread:localhost",
                blocked=_record(data={"blocked_by": ["$other:localhost"]}),
            ),
            "$untagged-thread:localhost": _state("$untagged-thread:localhost"),
        },
        include_untagged=True,
        truncated=False,
    )

    with (
        patch(
            "mindroom.custom_tools.thread_tags.list_tagged_threads",
            new=AsyncMock(return_value=listing),
        ) as mock_list,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.list_thread_tags(exclude_tag="resolved", include_untagged=True))

    assert payload["status"] == "ok"
    assert payload["room_wide"] is True
    assert payload["include_untagged"] is True
    assert payload["truncated"] is False
    assert list(payload["threads"]) == ["$blocked-thread:localhost", "$untagged-thread:localhost"]
    assert payload["threads"]["$untagged-thread:localhost"] == {}
    assert "tag_state" not in payload
    mock_list.assert_awaited_once_with(
        context.client,
        context.room_id,
        tag=None,
        include_tag=None,
        exclude_tag="resolved",
        include_untagged=True,
    )


@pytest.mark.asyncio
async def test_list_thread_tags_include_untagged_preserves_matrix_thread_order_in_json() -> None:
    """The JSON payload should preserve non-lexicographic /threads order for callers."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id=None)
    listing = ThreadTagsListing(
        tag_state={
            "$z_thread:localhost": _state("$z_thread:localhost"),
            "$m_thread:localhost": _state("$m_thread:localhost", blocked=_record()),
            "$a_thread:localhost": _state("$a_thread:localhost"),
        },
        include_untagged=True,
        truncated=False,
    )

    with (
        patch("mindroom.custom_tools.thread_tags.list_tagged_threads", new=AsyncMock(return_value=listing)),
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.list_thread_tags(include_untagged=True))

    assert list(payload["threads"]) == ["$z_thread:localhost", "$m_thread:localhost", "$a_thread:localhost"]
    assert "tag_state" not in payload


@pytest.mark.asyncio
async def test_list_thread_tags_rejects_thread_id_with_include_untagged() -> None:
    """Explicit thread targets are incompatible with include_untagged room-wide enumeration."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id=None)

    with tool_runtime_context(context):
        payload = json.loads(
            await tool.list_thread_tags(thread_id="$thread:localhost", include_untagged=True),
        )

    assert payload["status"] == "error"
    assert payload["action"] == "list"
    assert payload["thread_id"] == "$thread:localhost"
    assert payload["message"] == "`include_untagged=True` is only valid for room-wide queries; do not pass `thread_id`."


@pytest.mark.asyncio
async def test_list_thread_tags_include_untagged_suppresses_in_thread_context_fallback() -> None:
    """include_untagged should force room-wide listing even inside an active thread."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id="$ctx-thread:localhost")
    listing = ThreadTagsListing(
        tag_state={"$untagged-thread:localhost": _state("$untagged-thread:localhost")},
        include_untagged=True,
        truncated=False,
    )

    with (
        patch(
            "mindroom.custom_tools.thread_tags.resolve_thread_root_event_id_for_client",
            new=AsyncMock(),
        ) as mock_normalize,
        patch(
            "mindroom.custom_tools.thread_tags.list_tagged_threads",
            new=AsyncMock(return_value=listing),
        ) as mock_list,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.list_thread_tags(include_untagged=True))

    assert payload["status"] == "ok"
    assert payload["room_wide"] is True
    assert list(payload["threads"]) == ["$untagged-thread:localhost"]
    mock_list.assert_awaited_once_with(
        context.client,
        context.room_id,
        tag=None,
        include_tag=None,
        exclude_tag=None,
        include_untagged=True,
    )
    mock_normalize.assert_not_awaited()


@pytest.mark.asyncio
async def test_list_thread_tags_include_untagged_tag_filter_excludes_untagged_threads() -> None:
    """tag= should not return synthesized untagged entries in include_untagged mode."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id=None)
    listing = ThreadTagsListing(
        tag_state={"$resolved-thread:localhost": _state("$resolved-thread:localhost", resolved=_record())},
        include_untagged=True,
        truncated=False,
    )

    with (
        patch(
            "mindroom.custom_tools.thread_tags.list_tagged_threads",
            new=AsyncMock(return_value=listing),
        ) as mock_list,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.list_thread_tags(tag="resolved", include_untagged=True))

    assert payload["status"] == "ok"
    assert list(payload["threads"]) == ["$resolved-thread:localhost"]
    assert "resolved" in payload["threads"]["$resolved-thread:localhost"]
    mock_list.assert_awaited_once_with(
        context.client,
        context.room_id,
        tag="resolved",
        include_tag=None,
        exclude_tag=None,
        include_untagged=True,
    )


@pytest.mark.asyncio
async def test_list_thread_tags_include_untagged_surfaces_enumeration_error_fields() -> None:
    """Room thread enumeration failures should preserve structured error details."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id=None)

    with (
        patch(
            "mindroom.custom_tools.thread_tags.list_tagged_threads",
            new=AsyncMock(
                side_effect=RoomThreadsPageError(
                    response="rate limited",
                    errcode="M_LIMIT_EXCEEDED",
                    retry_after_ms=250,
                ),
            ),
        ),
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.list_thread_tags(exclude_tag="resolved", include_untagged=True))

    assert payload["status"] == "error"
    assert payload["action"] == "list"
    assert payload["room_id"] == context.room_id
    assert payload["message"] == "Failed to enumerate room thread roots: rate limited"
    assert payload["response"] == "rate limited"
    assert payload["errcode"] == "M_LIMIT_EXCEEDED"
    assert payload["retry_after_ms"] == 250
    assert payload["include_untagged"] is True


@pytest.mark.asyncio
async def test_list_thread_tags_include_untagged_returns_truncated_flag() -> None:
    """Cap-hit listings should expose truncated=True in the tool payload."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id=None)
    listing = ThreadTagsListing(
        tag_state={"$thread:localhost": _state("$thread:localhost")},
        include_untagged=True,
        truncated=True,
    )

    with (
        patch("mindroom.custom_tools.thread_tags.list_tagged_threads", new=AsyncMock(return_value=listing)),
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.list_thread_tags(include_untagged=True))

    assert payload["status"] == "ok"
    assert payload["include_untagged"] is True
    assert payload["truncated"] is True
    assert payload["threads"]["$thread:localhost"] == {}


def test_list_thread_tags_schema_exposes_include_untagged_parameter() -> None:
    """The Agno-visible schema should expose include_untagged to models."""
    function = ThreadTagsTools().async_functions["list_thread_tags"]
    function.process_entrypoint(strict=False)

    schema = function.parameters["properties"]["include_untagged"]

    assert schema["type"] == "boolean"
    assert "include_untagged" not in function.parameters["required"]
    assert "enumerate every thread root" in schema["description"]
    assert "Defaults to False" in schema["description"]


@pytest.mark.asyncio
async def test_list_thread_tags_filters_thread_specific_payload() -> None:
    """Thread-specific listing should support a tag filter through the shared helper."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_tags.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$ctx-thread:localhost"),
        ),
        patch(
            "mindroom.custom_tools.thread_tags.list_tagged_threads",
            new=AsyncMock(
                return_value=_listing(
                    {
                        "$ctx-thread:localhost": _state(
                            "$ctx-thread:localhost",
                            resolved=_record(),
                            blocked=_record(data={"blocked_by": ["$other:localhost"]}),
                        ),
                    },
                ),
            ),
        ) as mock_list,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.list_thread_tags(tag="blocked"))

    assert payload["status"] == "ok"
    assert payload["tags"]["blocked"]["data"] == {"blocked_by": ["$other:localhost"]}
    assert payload["tags"]["blocked"]["set_by"] == "@user:localhost"
    assert datetime.fromisoformat(payload["tags"]["blocked"]["set_at"]).tzinfo is not None
    mock_list.assert_awaited_once_with(
        context.client,
        context.room_id,
        tag="blocked",
        include_tag=None,
        exclude_tag=None,
    )


@pytest.mark.asyncio
async def test_list_thread_tags_thread_specific_include_exclude_filters() -> None:
    """Thread-specific listing should respect include_tag and exclude_tag filters."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_tags.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$ctx-thread:localhost"),
        ),
        patch(
            "mindroom.custom_tools.thread_tags.list_tagged_threads",
            new=AsyncMock(
                side_effect=[
                    _listing(
                        {
                            "$ctx-thread:localhost": _state(
                                "$ctx-thread:localhost",
                                resolved=_record(),
                                blocked=_record(data={"blocked_by": ["$other:localhost"]}),
                            ),
                        },
                    ),
                    _listing({}),
                    _listing({}),
                    _listing({}),
                ],
            ),
        ),
        tool_runtime_context(context),
    ):
        # include_tag matches → thread returned with all tags
        payload = json.loads(await tool.list_thread_tags(include_tag="blocked"))
        assert payload["status"] == "ok"
        assert "blocked" in payload["tags"]
        assert "resolved" in payload["tags"]

        # exclude_tag matches → thread excluded (empty tags)
        payload = json.loads(await tool.list_thread_tags(exclude_tag="resolved"))
        assert payload["status"] == "ok"
        assert payload["tags"] == {}

        # include_tag matches but exclude_tag also matches → excluded
        payload = json.loads(
            await tool.list_thread_tags(include_tag="blocked", exclude_tag="resolved"),
        )
        assert payload["status"] == "ok"
        assert payload["tags"] == {}

        # include_tag doesn't match → excluded
        payload = json.loads(await tool.list_thread_tags(include_tag="nonexistent"))
        assert payload["status"] == "ok"
        assert payload["tags"] == {}


@pytest.mark.asyncio
async def test_untag_thread_canonical_skips_normalization() -> None:
    """Canonical mode should clear the marker without fetching the live event."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id=None, reply_to_event_id="$orphaned-root:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_tags.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value=None),
        ) as mock_normalize,
        patch(
            "mindroom.custom_tools.thread_tags.remove_thread_tag",
            new=AsyncMock(return_value=_state("$orphaned-root:localhost")),
        ) as mock_remove,
        tool_runtime_context(context),
    ):
        payload = json.loads(
            await tool.untag_thread("topic", thread_id="$orphaned-root:localhost", canonical=True),
        )

    assert payload["status"] == "ok"
    assert payload["action"] == "untag"
    assert payload["thread_id"] == "$orphaned-root:localhost"
    mock_normalize.assert_not_awaited()
    mock_remove.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$orphaned-root:localhost",
        "topic",
        requester_user_id=context.requester_id,
    )


# -- vocabulary-aware tool description --


def _vocabulary_runtime_paths(
    tmp_path: Path,
    room_tags: dict[str, list[str]],
) -> RuntimePaths:
    """Create runtime paths with room-scoped ranked vocabulary snapshots."""
    runtime_paths = test_runtime_paths(tmp_path)
    for room_id, tags in room_tags.items():
        path = thread_tag_vocabulary._snapshot_path(runtime_paths, room_id)
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(
            json.dumps(
                {
                    "version": 1,
                    "room_id": room_id,
                    "built_at": datetime.now(UTC).isoformat(),
                    "tags": [{"tag": tag, "count": len(tags) - index} for index, tag in enumerate(tags)],
                },
            ),
            encoding="utf-8",
        )
    return runtime_paths


def test_tag_thread_description_embeds_ranked_room_vocabulary(tmp_path: Path) -> None:
    """The tag_thread description lists the top tags ranked, without counts."""
    room_id = "!room-a:localhost"
    runtime_paths = _vocabulary_runtime_paths(
        tmp_path,
        {room_id: [f"tag{i:02d}" for i in range(25)]},
    )
    tool = ThreadTagsTools(
        runtime_paths=runtime_paths,
        current_room_id=room_id,
    )

    description = tool.async_functions["tag_thread"].description

    assert description is not None
    assert "rebuilt once a day" in description
    assert "tag00, tag01" in description
    assert "tag19" in description
    assert "tag20" not in description  # capped at the top 20
    assert "25" not in description.split("Most-used short tags", 1)[1]  # no counts in the ranked list


def test_tag_thread_description_never_leaks_another_room_vocabulary(tmp_path: Path) -> None:
    """The current room's tool schema excludes tags from every other room."""
    room_a = "!room-a:localhost"
    room_b = "!room-b:localhost"
    runtime_paths = _vocabulary_runtime_paths(
        tmp_path,
        {room_a: ["alpha"], room_b: ["secret-beta"]},
    )
    tool = ThreadTagsTools(
        runtime_paths=runtime_paths,
        current_room_id=room_a,
    )

    description = tool.async_functions["tag_thread"].description

    assert description is not None
    assert "alpha" in description
    assert "secret-beta" not in description


def test_tag_thread_description_without_snapshot_keeps_guidance(tmp_path: Path) -> None:
    """With no snapshot on disk the description still carries tagging guidance."""
    room_id = "!room-a:localhost"
    tool = ThreadTagsTools(
        runtime_paths=_vocabulary_runtime_paths(tmp_path, {}),
        current_room_id=room_id,
    )

    description = tool.async_functions["tag_thread"].description

    assert description is not None
    assert "Prefer existing tags" in description
    assert "Thread lifecycle state cannot be changed" in description
    assert "No reusable short tags are in use yet" in description


def test_tag_thread_description_untouched_without_room_identity(tmp_path: Path) -> None:
    """Construction without Matrix room identity exposes no vocabulary snapshot."""
    tool = ThreadTagsTools(runtime_paths=_vocabulary_runtime_paths(tmp_path, {}))

    assert tool.async_functions["tag_thread"].description is None


@pytest.mark.asyncio
async def test_tag_thread_coerces_free_form_tag_input() -> None:
    """Free-form tag input is coerced to the canonical slug before writing."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_tags.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$ctx-thread:localhost"),
        ),
        patch(
            "mindroom.custom_tools.thread_tags.set_thread_tag",
            new=AsyncMock(return_value=_state("$ctx-thread:localhost", **{"follow-up": _record()})),
        ) as mock_set,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.tag_thread("Follow Up"))

    assert payload["status"] == "ok"
    assert payload["tag"] == "follow-up"
    mock_set.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$ctx-thread:localhost",
        "follow-up",
        set_by=context.requester_id,
        note=None,
        data=None,
    )


@pytest.mark.asyncio
async def test_tag_thread_preserves_long_canonical_tag() -> None:
    """Exact canonical IDs should round-trip through creation and references."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id="$ctx-thread:localhost")
    long_tag = "abcdefghijklmnopqrstuvwxyz"

    with (
        patch(
            "mindroom.custom_tools.thread_tags.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$ctx-thread:localhost"),
        ),
        patch(
            "mindroom.custom_tools.thread_tags.set_thread_tag",
            new=AsyncMock(return_value=_state("$ctx-thread:localhost", **{long_tag: _record()})),
        ) as set_tag,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.tag_thread(long_tag))

    assert payload["tag"] == long_tag
    set_tag.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$ctx-thread:localhost",
        long_tag,
        set_by=context.requester_id,
        note=None,
        data=None,
    )


@pytest.mark.asyncio
async def test_tag_thread_rejects_tag_with_no_valid_characters() -> None:
    """Tag input that coerces to nothing returns a structured error before Matrix I/O."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_tags.set_thread_tag",
            new=AsyncMock(),
        ) as mock_set,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.tag_thread("!!!"))

    assert payload["status"] == "error"
    assert "at least one letter" in payload["message"]
    mock_set.assert_not_awaited()


@pytest.mark.asyncio
@pytest.mark.parametrize("method_name", ["tag", "untag"])
@pytest.mark.parametrize("tag", ["resolved", "Resolved", "resolved!"])
async def test_thread_tag_mutations_reject_resolved_before_matrix_io(method_name: str, tag: str) -> None:
    """Topic-tag mutations must reject normalized lifecycle state before Matrix I/O."""
    tool = ThreadTagsTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch("mindroom.custom_tools.thread_tags.set_thread_tag", new=AsyncMock()) as mock_set,
        patch("mindroom.custom_tools.thread_tags.remove_thread_tag", new=AsyncMock()) as mock_remove,
        tool_runtime_context(context),
    ):
        if method_name == "tag":
            payload = json.loads(await tool.tag_thread(tag))
        else:
            payload = json.loads(await tool.untag_thread(tag))

    assert payload["status"] == "error"
    assert payload["action"] == method_name
    assert "thread lifecycle state" in payload["message"]
    mock_set.assert_not_awaited()
    mock_remove.assert_not_awaited()
