"""Tests for the thread summary tool."""

from __future__ import annotations

import json
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, patch

import pytest

import mindroom.tools  # noqa: F401
from mindroom.config.agent import AgentConfig
from mindroom.config.main import Config
from mindroom.custom_tools.thread_summary import ThreadSummaryTools
from mindroom.message_target import MessageTarget
from mindroom.thread_summary import THREAD_SUMMARY_MAX_LENGTH, ThreadSummaryWriteError, _ThreadSummaryWriteResult
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


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 _write_result(
    *,
    event_id: str = "$summary-event:localhost",
    message_count: int = 3,
    summary: str = "done",
) -> _ThreadSummaryWriteResult:
    return _ThreadSummaryWriteResult(
        event_id=event_id,
        message_count=message_count,
        summary=summary,
    )


def test_thread_summary_tool_registered_and_instantiates() -> None:
    """Thread summary 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_summary" in TOOL_METADATA
    assert isinstance(
        get_tool_by_name("thread_summary", runtime_paths_for(config), worker_target=None),
        ThreadSummaryTools,
    )


@pytest.mark.asyncio
async def test_thread_summary_tool_requires_runtime_context() -> None:
    """Tool calls should fail clearly outside Matrix runtime context."""
    payload = json.loads(await ThreadSummaryTools().set_thread_summary("summary"))

    assert payload["status"] == "error"
    assert payload["tool"] == "thread_summary"
    assert payload["action"] == "set"
    assert "context" in payload["message"]


@pytest.mark.asyncio
async def test_set_thread_summary_defaults_to_context_room_and_thread() -> None:
    """The tool should default to the current room and resolved thread context."""
    tool = ThreadSummaryTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_summary.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$ctx-thread:localhost"),
        ) as mock_normalize,
        patch(
            "mindroom.custom_tools.thread_summary.set_manual_thread_summary",
            new=AsyncMock(
                return_value=_write_result(
                    summary="🧵 Ready for review",
                ),
            ),
        ) as mock_set,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.set_thread_summary("  🧵 Ready\nfor\t review  "))

    assert payload == {
        "action": "set",
        "event_id": "$summary-event:localhost",
        "message_count": 3,
        "pinned": True,
        "room_id": "!room:localhost",
        "status": "ok",
        "summary": "🧵 Ready for review",
        "thread_id": "$ctx-thread:localhost",
        "tool": "thread_summary",
    }
    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",
        "  🧵 Ready\nfor\t review  ",
        config=context.config,
        runtime_paths=context.runtime_paths,
        conversation_cache=context.conversation_cache,
        pin=True,
    )


@pytest.mark.asyncio
async def test_set_thread_summary_returns_helper_summary() -> None:
    """The tool should return the normalized summary produced by the shared write helper."""
    tool = ThreadSummaryTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_summary.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$ctx-thread:localhost"),
        ),
        patch(
            "mindroom.custom_tools.thread_summary.set_manual_thread_summary",
            new=AsyncMock(return_value=_write_result(summary="Fix ISSUE-116")),
        ) as mock_set,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.set_thread_summary("# **Fix** [ISSUE-116](http://example.com)"))

    assert payload["status"] == "ok"
    assert payload["summary"] == "Fix ISSUE-116"
    mock_set.assert_awaited_once_with(
        context.client,
        context.room_id,
        "$ctx-thread:localhost",
        "# **Fix** [ISSUE-116](http://example.com)",
        config=context.config,
        runtime_paths=context.runtime_paths,
        conversation_cache=context.conversation_cache,
        pin=True,
    )


@pytest.mark.asyncio
async def test_set_thread_summary_rejects_blank_room_id() -> None:
    """Explicit blank room IDs should not silently fall back to the context room."""
    tool = ThreadSummaryTools()
    context = _make_context()

    with tool_runtime_context(context):
        payload = json.loads(await tool.set_thread_summary("done", room_id="   "))

    assert payload["status"] == "error"
    assert payload["room_id"] == "   "
    assert payload["message"] == "room_id must be a non-empty string when provided."


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

    with (
        patch(
            "mindroom.custom_tools.thread_summary.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$thread-root:localhost"),
        ) as mock_normalize,
        patch(
            "mindroom.custom_tools.thread_summary.set_manual_thread_summary",
            new=AsyncMock(return_value=_write_result(message_count=4)),
        ) as mock_set,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.set_thread_summary("done", thread_id="$reply-event: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",
        "done",
        config=context.config,
        runtime_paths=context.runtime_paths,
        conversation_cache=context.conversation_cache,
        pin=True,
    )


@pytest.mark.asyncio
async def test_set_thread_summary_requires_explicit_thread_context_for_room_reply() -> None:
    """Room-level replies should not invent a thread target from plain reply context."""
    tool = ThreadSummaryTools()
    context = _make_context(
        thread_id=None,
        reply_to_event_id="$root-event:localhost",
    )

    with tool_runtime_context(context):
        payload = json.loads(await tool.set_thread_summary("done"))

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


@pytest.mark.asyncio
async def test_set_thread_summary_cross_room_requires_authorization() -> None:
    """Explicit room targeting should enforce the same room access checks as other Matrix tools."""
    tool = ThreadSummaryTools()
    context = _make_context()

    with tool_runtime_context(context):
        payload = json.loads(await tool.set_thread_summary("done", room_id="!other:localhost"))

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


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

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

    assert payload["status"] == "error"
    assert payload["action"] == "set"
    assert payload["room_id"] == "!other:localhost"
    assert "thread_id is required" in payload["message"]


@pytest.mark.asyncio
async def test_set_thread_summary_rejects_blank_summary() -> None:
    """Blank summaries should be rejected before any Matrix work starts."""
    tool = ThreadSummaryTools()
    context = _make_context()

    with tool_runtime_context(context):
        payload = json.loads(await tool.set_thread_summary("   "))

    assert payload["status"] == "error"
    assert payload["action"] == "set"
    assert payload["room_id"] == context.room_id
    assert "summary must be a non-empty string" in payload["message"]


@pytest.mark.asyncio
async def test_set_thread_summary_rejects_non_string_summary() -> None:
    """Malformed tool args should return the normal error payload instead of crashing."""
    tool = ThreadSummaryTools()
    context = _make_context()
    invalid_summary: Any = 123

    with tool_runtime_context(context):
        payload = json.loads(await tool.set_thread_summary(invalid_summary))

    assert payload["status"] == "error"
    assert payload["action"] == "set"
    assert payload["room_id"] == context.room_id
    assert "summary must be a non-empty string" in payload["message"]


@pytest.mark.asyncio
async def test_set_thread_summary_rejects_overlong_summary() -> None:
    """Oversized summaries should return the helper's validation error."""
    tool = ThreadSummaryTools()
    context = _make_context()

    with (
        patch(
            "mindroom.custom_tools.thread_summary.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$ctx-thread:localhost"),
        ),
        patch(
            "mindroom.custom_tools.thread_summary.set_manual_thread_summary",
            new=AsyncMock(
                side_effect=ThreadSummaryWriteError(
                    f"summary must be {THREAD_SUMMARY_MAX_LENGTH} characters or fewer after whitespace normalization.",
                ),
            ),
        ),
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.set_thread_summary("x" * (THREAD_SUMMARY_MAX_LENGTH + 1)))

    assert payload["status"] == "error"
    assert payload["room_id"] == context.room_id
    assert (
        payload["message"]
        == f"summary must be {THREAD_SUMMARY_MAX_LENGTH} characters or fewer after whitespace normalization."
    )


@pytest.mark.asyncio
async def test_set_thread_summary_returns_helper_error_for_send_failure() -> None:
    """Tool errors should pass through shared manual-summary write failures."""
    tool = ThreadSummaryTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_summary.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$ctx-thread:localhost"),
        ),
        patch(
            "mindroom.custom_tools.thread_summary.set_manual_thread_summary",
            new=AsyncMock(side_effect=ThreadSummaryWriteError("Failed to send thread summary event.")),
        ),
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.set_thread_summary("failed write"))

    assert payload["status"] == "error"
    assert payload["thread_id"] == "$ctx-thread:localhost"
    assert payload["message"] == "Failed to send thread summary event."


@pytest.mark.asyncio
async def test_set_thread_summary_returns_error_when_normalize_raises() -> None:
    """Normalization exceptions should return the standard error payload."""
    tool = ThreadSummaryTools()
    context = _make_context(thread_id=None)

    with (
        patch(
            "mindroom.custom_tools.thread_summary.resolve_thread_root_event_id_for_client",
            new=AsyncMock(side_effect=TimeoutError("timed out")),
        ),
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.set_thread_summary("done", thread_id="$reply-event:localhost"))

    assert payload["status"] == "error"
    assert payload["thread_id"] == "$reply-event:localhost"
    assert payload["message"] == "Failed to resolve a canonical thread root for the target event."


@pytest.mark.asyncio
async def test_set_thread_summary_returns_error_when_fetch_raises() -> None:
    """History fetch exceptions should surface through the shared write helper."""
    tool = ThreadSummaryTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_summary.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$ctx-thread:localhost"),
        ),
        patch(
            "mindroom.custom_tools.thread_summary.set_manual_thread_summary",
            new=AsyncMock(side_effect=ThreadSummaryWriteError("Failed to fetch thread history for the target thread.")),
        ),
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.set_thread_summary("done"))

    assert payload["status"] == "error"
    assert payload["thread_id"] == "$ctx-thread:localhost"
    assert payload["message"] == "Failed to fetch thread history for the target thread."


@pytest.mark.asyncio
async def test_set_thread_summary_returns_error_when_send_raises() -> None:
    """Manual-send exceptions should surface through the shared write helper."""
    tool = ThreadSummaryTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_summary.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$ctx-thread:localhost"),
        ),
        patch(
            "mindroom.custom_tools.thread_summary.set_manual_thread_summary",
            new=AsyncMock(side_effect=ThreadSummaryWriteError("Failed to send thread summary event.")),
        ),
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.set_thread_summary("failed write"))

    assert payload["status"] == "error"
    assert payload["thread_id"] == "$ctx-thread:localhost"
    assert payload["message"] == "Failed to send thread summary event."


@pytest.mark.asyncio
async def test_set_thread_summary_pins_by_default() -> None:
    """Asking an agent to set a title should make it survive automatic re-summarization."""
    tool = ThreadSummaryTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_summary.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$ctx-thread:localhost"),
        ),
        patch(
            "mindroom.custom_tools.thread_summary.set_manual_thread_summary",
            new=AsyncMock(return_value=_write_result(summary="A fixed title")),
        ) as mock_set,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.set_thread_summary("A fixed title"))

    assert payload["status"] == "ok"
    assert payload["pinned"] is True
    assert mock_set.await_args.kwargs["pin"] is True


@pytest.mark.asyncio
async def test_set_thread_summary_forwards_pin_false() -> None:
    """pin=False writes a summary and releases the thread for automatic summaries."""
    tool = ThreadSummaryTools()
    context = _make_context(thread_id="$ctx-thread:localhost")

    with (
        patch(
            "mindroom.custom_tools.thread_summary.resolve_thread_root_event_id_for_client",
            new=AsyncMock(return_value="$ctx-thread:localhost"),
        ),
        patch(
            "mindroom.custom_tools.thread_summary.set_manual_thread_summary",
            new=AsyncMock(return_value=_write_result(summary="A routine title")),
        ) as mock_set,
        tool_runtime_context(context),
    ):
        payload = json.loads(await tool.set_thread_summary("A routine title", pin=False))

    assert payload["status"] == "ok"
    assert payload["pinned"] is False
    assert mock_set.await_args.kwargs["pin"] is False
