"""Tests for MCP ResourceLink / EmbeddedResource / AudioContent handling.

Regression coverage for a customer report (2026-07): non-image binary
resources returned through MCP resource blocks were silently dropped from
tool results, so a PDF-returning MCP tool appeared to return metadata only.
"""

import base64
import json
from types import SimpleNamespace

import pytest


PDF_BYTES = b"%PDF-1.4 fake pdf payload for tests"


def _blob_resource(data: bytes, uri="slack://files/F123/report.pdf", mime="application/pdf"):
    return SimpleNamespace(
        uri=uri,
        mimeType=mime,
        blob=base64.b64encode(data).decode("ascii"),
        text=None,
    )


def _embedded(resource):
    return SimpleNamespace(type="resource", resource=resource)


@pytest.fixture()
def doc_cache(tmp_path, monkeypatch):
    """Point the document cache at a temp dir."""
    import gateway.platforms.base as base

    monkeypatch.setattr(base, "DOCUMENT_CACHE_DIR", tmp_path)
    monkeypatch.setenv("HERMES_DOCUMENT_CACHE_DIR", str(tmp_path))
    # _resolve_cache_dir consults the module constant; patching the constant
    # is sufficient because the import-default comparison detects the change.
    return tmp_path


class TestRenderResourceBlock:
    def test_embedded_pdf_blob_is_materialized(self, doc_cache):
        from tools.mcp_tool import _render_mcp_resource_block

        out = _render_mcp_resource_block(_embedded(_blob_resource(PDF_BYTES)), "slack")
        assert "saved to" in out
        assert "application/pdf" in out
        # Extract path and verify bytes round-trip
        path = out.split("saved to ", 1)[1].split(" (", 1)[0]
        with open(path, "rb") as fh:
            assert fh.read() == PDF_BYTES
        assert "report.pdf" in path

    def test_embedded_text_resource_is_inlined(self):
        from tools.mcp_tool import _render_mcp_resource_block

        res = SimpleNamespace(uri="mem://notes", mimeType="text/plain", text="hello world", blob=None)
        assert _render_mcp_resource_block(_embedded(res), "srv") == "hello world"

    def test_resource_link_preserves_uri_and_points_at_reader(self):
        from tools.mcp_tool import _render_mcp_resource_block

        link = SimpleNamespace(
            type="resource_link",
            uri="slack://files/F123",
            name="report.pdf",
            mimeType="application/pdf",
        )
        out = _render_mcp_resource_block(link, "slack")
        assert "slack://files/F123" in out
        # Must be the real wire name (mcp__<server>__read_resource), not a
        # made-up "<server>_read_resource" the agent can't actually call.
        assert "mcp__slack__read_resource" in out
        assert "report.pdf" in out

    def test_oversized_blob_fails_explicitly_without_writing(self, doc_cache, monkeypatch):
        import tools.mcp_tool as m

        monkeypatch.setattr(m, "_MCP_RESOURCE_MAX_BYTES", 8)
        out = m._render_mcp_resource_block(_embedded(_blob_resource(PDF_BYTES)), "srv")
        assert "too large" in out
        assert not list(doc_cache.glob("doc_*"))

    def test_malformed_base64_fails_explicitly(self):
        from tools.mcp_tool import _render_mcp_resource_block

        res = SimpleNamespace(uri="x://y", mimeType="application/pdf", blob="!!!not-base64!!!", text=None)
        out = _render_mcp_resource_block(_embedded(res), "srv")
        assert "could not be decoded" in out

    def test_non_resource_block_returns_empty(self):
        from tools.mcp_tool import _render_mcp_resource_block

        assert _render_mcp_resource_block(SimpleNamespace(type="text", text="hi"), "srv") == ""

    def test_path_traversal_uri_is_neutralized(self, doc_cache):
        from tools.mcp_tool import _render_mcp_resource_block

        res = _blob_resource(PDF_BYTES, uri="evil://host/../../etc/passwd")
        out = _render_mcp_resource_block(_embedded(res), "srv")
        assert "saved to" in out
        path = out.split("saved to ", 1)[1].split(" (", 1)[0]
        assert str(doc_cache) in path
        assert "/etc/passwd" not in path


class TestResourceFilename:
    def test_uri_last_segment_used(self):
        from tools.mcp_tool import _mcp_resource_filename

        assert _mcp_resource_filename("slack://f/ABC/quarterly.pdf", "application/pdf") == "quarterly.pdf"

    def test_fallback_to_mime_extension(self):
        from tools.mcp_tool import _mcp_resource_filename

        name = _mcp_resource_filename("", "application/pdf")
        assert name.endswith(".pdf")

    def test_dotdot_rejected(self):
        from tools.mcp_tool import _mcp_resource_filename

        assert _mcp_resource_filename("x://y/..", "application/pdf") != ".."

    def test_control_chars_stripped(self):
        from tools.mcp_tool import _mcp_resource_filename

        name = _mcp_resource_filename("x://h/report.pdf%0Ainjected%1b[31m", "application/pdf")
        assert "\n" not in name and "\x1b" not in name

    def test_long_filename_capped_preserving_extension(self):
        from tools.mcp_tool import _mcp_resource_filename

        name = _mcp_resource_filename("x://h/" + "a" * 500 + ".pdf", "application/pdf")
        assert len(name) <= 150
        assert name.endswith(".pdf")


class TestPreDecodeSizeCap:
    def test_oversized_b64_rejected_before_decode(self, monkeypatch):
        import tools.mcp_tool as m

        monkeypatch.setattr(m, "_MCP_RESOURCE_MAX_B64_CHARS", 16)
        res = SimpleNamespace(
            uri="x://y/big.pdf", mimeType="application/pdf",
            blob="A" * 100, text=None,
        )
        called = []
        monkeypatch.setattr(base64, "b64decode", lambda *a, **k: called.append(1))
        out = m._render_mcp_resource_block(_embedded(res), "srv")
        assert "too large" in out
        assert not called


class TestAudioBlock:
    def test_non_audio_returns_empty(self):
        from tools.mcp_tool import _cache_mcp_audio_block

        block = SimpleNamespace(data=base64.b64encode(b"x").decode(), mimeType="application/pdf")
        assert _cache_mcp_audio_block(block) == ""

    def test_audio_block_cached_as_media(self, tmp_path, monkeypatch):
        import gateway.platforms.base as base
        from tools.mcp_tool import _cache_mcp_audio_block

        monkeypatch.setattr(base, "AUDIO_CACHE_DIR", tmp_path)
        block = SimpleNamespace(
            data=base64.b64encode(b"RIFFfakewav").decode(),
            mimeType="audio/wav",
        )
        out = _cache_mcp_audio_block(block)
        assert out.startswith("MEDIA:")


class TestToolResultLoopOrdering:
    def test_mixed_blocks_preserve_order(self, doc_cache):
        """Simulate the tool-result block loop with text + pdf resource."""
        from tools.mcp_tool import (
            _cache_mcp_image_block,
            _cache_mcp_audio_block,
            _render_mcp_resource_block,
        )

        blocks = [
            SimpleNamespace(type="text", text="File ID: F123\nMIME Type: application/pdf"),
            _embedded(_blob_resource(PDF_BYTES)),
        ]
        parts = []
        for block in blocks:
            if getattr(block, "text", None):
                parts.append(block.text)
                continue
            tag = _cache_mcp_image_block(block) or _cache_mcp_audio_block(block)
            if tag:
                parts.append(tag)
                continue
            rendered = _render_mcp_resource_block(block, "slack")
            if rendered:
                parts.append(rendered)
        assert len(parts) == 2
        assert parts[0].startswith("File ID")
        assert "saved to" in parts[1]

    def test_existing_image_behavior_unchanged(self):
        from tools.mcp_tool import _cache_mcp_image_block

        block = SimpleNamespace(
            data=base64.b64encode(b"some bytes").decode("ascii"),
            mimeType="application/pdf",
        )
        assert _cache_mcp_image_block(block) == ""


class TestErrorPathResourceText:
    """isError payloads must surface EmbeddedResource text, not drop it."""

    @pytest.fixture()
    def _handler(self, monkeypatch):
        import asyncio
        from unittest.mock import AsyncMock, MagicMock, patch as mock_patch

        from tools import mcp_tool

        fake_session = MagicMock()
        fake_server = SimpleNamespace(session=fake_session, _rpc_lock=None)

        def _fake_run_on_mcp_loop(coro_or_factory, timeout=30):
            coro = coro_or_factory() if callable(coro_or_factory) else coro_or_factory
            loop = asyncio.new_event_loop()
            try:
                async def _install_lock_and_run():
                    for srv in list(mcp_tool._servers.values()):
                        if getattr(srv, "_rpc_lock", None) is None:
                            srv._rpc_lock = asyncio.Lock()
                    return await coro

                return loop.run_until_complete(_install_lock_and_run())
            finally:
                loop.close()

        mcp_tool._reset_server_error("test-server")
        try:
            with mock_patch.dict(mcp_tool._servers, {"test-server": fake_server}), \
                 mock_patch("tools.mcp_tool._run_on_mcp_loop", side_effect=_fake_run_on_mcp_loop):
                fake_session.call_tool = AsyncMock()
                yield fake_session, mcp_tool._make_tool_handler("test-server", "my-tool", 30.0)
        finally:
            mcp_tool._reset_server_error("test-server")

    def test_error_embedded_resource_text_surfaced(self, _handler):
        from unittest.mock import AsyncMock

        session, handler = _handler
        res = SimpleNamespace(uri="mem://err", mimeType="text/plain",
                              text="quota exceeded for workspace W1", blob=None)
        session.call_tool = AsyncMock(return_value=SimpleNamespace(
            content=[_embedded(res)], isError=True, structuredContent=None,
        ))
        data = json.loads(handler({}))
        assert "quota exceeded for workspace W1" in data["error"]

    def test_error_mixed_text_and_resource(self, _handler):
        from unittest.mock import AsyncMock

        session, handler = _handler
        res = SimpleNamespace(uri="mem://err", mimeType="text/plain",
                              text=" — details in resource", blob=None)
        session.call_tool = AsyncMock(return_value=SimpleNamespace(
            content=[SimpleNamespace(type="text", text="tool failed"), _embedded(res)],
            isError=True, structuredContent=None,
        ))
        data = json.loads(handler({}))
        assert "tool failed" in data["error"]
        assert "details in resource" in data["error"]

    def test_error_with_no_text_blocks_falls_back(self, _handler):
        from unittest.mock import AsyncMock

        session, handler = _handler
        session.call_tool = AsyncMock(return_value=SimpleNamespace(
            content=[], isError=True, structuredContent=None,
        ))
        data = json.loads(handler({}))
        assert data["error"] == "MCP tool returned an error"
