"""Tests for sentinely.adapters.langchain.

Covers SentinelyTool, SentinelyCallbackHandler, protect_langchain(),
and the backwards-compatibility deprecation aliases.

All scoring engine calls are mocked — no external API key required.
"""

from __future__ import annotations

import warnings
from unittest.mock import AsyncMock, MagicMock

import pytest

from sentinely.circuit_breaker import CircuitDecision
from sentinely.exceptions import SentinelyBlockedError
from sentinely.adapters.langchain import (
    SentinelyCallbackHandler,
    SentinelyTool,
    protect_langchain,
)


# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------

def _decision(action: str = "allow", risk: int = 10) -> CircuitDecision:
    """Build a CircuitDecision for a given action/risk combination."""
    return CircuitDecision(
        action=action,
        risk_score=risk,
        reason="mock reason",
        attack_type="none" if action != "block" else "injection",
        should_notify=action != "allow",
        should_log=True,
        requires_human_approval=action == "block",
    )


def _safe_agent() -> MagicMock:
    """Return a ProtectedAgent mock whose score_action always allows."""
    agent = MagicMock()
    agent.score_action = AsyncMock(return_value=_decision("allow", risk=5))
    return agent


def _blocked_agent(risk: int = 95, reason: str = "Injection detected") -> MagicMock:
    """Return a ProtectedAgent mock whose score_action always blocks."""
    agent = MagicMock()
    agent.score_action = AsyncMock(
        side_effect=SentinelyBlockedError(
            risk_score=risk,
            reason=reason,
            attack_type="injection",
            action={"tool_name": "test_tool"},
        )
    )
    return agent


class MockBaseTool:
    """Minimal stand-in for langchain_core.tools.BaseTool (no LangChain needed)."""

    def __init__(self, name: str = "test_tool", description: str = "A test tool") -> None:
        self.name = name
        self.description = description

    def _run(self, *args, **kwargs):
        return "real_result"

    async def _arun(self, *args, **kwargs):
        return "real_result_async"

    def invoke(self, input, config=None, **kwargs):
        return f"invoked:{input}"

    async def ainvoke(self, input, config=None, **kwargs):
        return f"ainvoked:{input}"

    def run(self, tool_input, **kwargs):
        return self._run(tool_input, **kwargs)


# ---------------------------------------------------------------------------
# SentinelyTool — attribute mirroring
# ---------------------------------------------------------------------------

class TestSentinelyToolAttributes:

    def test_mirrors_name_and_description(self):
        tool = MockBaseTool(name="read_file", description="Reads a file from disk")
        wrapped = SentinelyTool(tool, _safe_agent())
        assert wrapped.name == "read_file"
        assert wrapped.description == "Reads a file from disk"

    def test_defaults_when_tool_has_no_description(self):
        tool = MockBaseTool()
        del tool.description  # remove attribute to test default
        wrapped = SentinelyTool(tool, _safe_agent())
        assert wrapped.description == ""


# ---------------------------------------------------------------------------
# SentinelyTool — sync paths (_run, invoke, run)
# ---------------------------------------------------------------------------

class TestSentinelyToolSync:
    """Sync methods use _run_async internally.

    These tests run outside any event loop so _run_async takes the simple
    asyncio.run() path — no ThreadPoolExecutor involved.
    """

    def test_passthrough_on_safe_score(self):
        tool = MockBaseTool()
        wrapped = SentinelyTool(tool, _safe_agent())
        result = wrapped._run("arg1")
        assert result == "real_result"

    def test_blocks_on_high_risk_score(self):
        tool = MockBaseTool()
        wrapped = SentinelyTool(tool, _blocked_agent(risk=96, reason="Prompt injection"))
        result = wrapped._run("arg1")
        assert result.startswith("[SENTINELY BLOCKED]")
        assert "Prompt injection" in result
        assert "96" in result

    def test_real_tool_not_called_when_blocked(self):
        tool = MockBaseTool()
        tool._run = MagicMock(return_value="should_not_appear")  # type: ignore[method-assign]
        wrapped = SentinelyTool(tool, _blocked_agent())
        wrapped._run("arg1")
        tool._run.assert_not_called()

    def test_invoke_passthrough_on_safe_score(self):
        tool = MockBaseTool()
        wrapped = SentinelyTool(tool, _safe_agent())
        result = wrapped.invoke("some_input")
        assert result == "invoked:some_input"

    def test_invoke_returns_blocked_string_on_high_risk(self):
        tool = MockBaseTool()
        wrapped = SentinelyTool(tool, _blocked_agent(risk=88, reason="Memory poisoning"))
        result = wrapped.invoke("payload")
        assert "[SENTINELY BLOCKED]" in result
        assert "Memory poisoning" in result

    def test_run_alias_delegates_to_run(self):
        tool = MockBaseTool()
        wrapped = SentinelyTool(tool, _safe_agent())
        result = wrapped.run("input")
        assert result == "real_result"

    def test_score_action_receives_correct_tool_name(self):
        tool = MockBaseTool(name="send_email")
        agent = _safe_agent()
        wrapped = SentinelyTool(tool, agent)
        wrapped._run("payload")
        call_kwargs = agent.score_action.call_args
        assert call_kwargs.kwargs["tool_name"] == "send_email"


# ---------------------------------------------------------------------------
# SentinelyTool — async paths (_arun, ainvoke)
# ---------------------------------------------------------------------------

class TestSentinelyToolAsync:

    @pytest.mark.asyncio
    async def test_arun_passthrough_on_safe_score(self):
        tool = MockBaseTool()
        wrapped = SentinelyTool(tool, _safe_agent())
        result = await wrapped._arun("arg1")
        assert result == "real_result_async"

    @pytest.mark.asyncio
    async def test_arun_raises_on_blocked(self):
        """Async path lets SentinelyBlockedError propagate (no try/except)."""
        tool = MockBaseTool()
        wrapped = SentinelyTool(tool, _blocked_agent(risk=97, reason="Agent drift"))
        with pytest.raises(SentinelyBlockedError) as exc_info:
            await wrapped._arun("arg1")
        assert exc_info.value.risk_score == 97
        assert "Agent drift" in exc_info.value.reason

    @pytest.mark.asyncio
    async def test_arun_real_tool_not_called_when_blocked(self):
        tool = MockBaseTool()
        tool._arun = AsyncMock(return_value="should_not_appear")  # type: ignore[method-assign]
        wrapped = SentinelyTool(tool, _blocked_agent())
        with pytest.raises(SentinelyBlockedError):
            await wrapped._arun("arg1")
        tool._arun.assert_not_called()

    @pytest.mark.asyncio
    async def test_ainvoke_passthrough_on_safe_score(self):
        tool = MockBaseTool()
        wrapped = SentinelyTool(tool, _safe_agent())
        result = await wrapped.ainvoke("some_input")
        assert result == "ainvoked:some_input"

    @pytest.mark.asyncio
    async def test_ainvoke_raises_on_blocked(self):
        tool = MockBaseTool()
        wrapped = SentinelyTool(tool, _blocked_agent(risk=91, reason="Privilege escalation"))
        with pytest.raises(SentinelyBlockedError) as exc_info:
            await wrapped.ainvoke("payload")
        assert exc_info.value.risk_score == 91

    @pytest.mark.asyncio
    async def test_score_action_receives_correct_params_in_arun(self):
        tool = MockBaseTool(name="delete_records")
        agent = _safe_agent()
        wrapped = SentinelyTool(tool, agent)
        await wrapped._arun("rec_123")
        agent.score_action.assert_called_once()
        kwargs = agent.score_action.call_args.kwargs
        assert kwargs["tool_name"] == "delete_records"


# ---------------------------------------------------------------------------
# SentinelyCallbackHandler
# ---------------------------------------------------------------------------

class TestSentinelyCallbackHandlerSync:
    """on_tool_start is a sync method; test outside an event loop so
    _run_async uses the simple asyncio.run() path."""

    def test_allows_safe_tool_call(self):
        handler = SentinelyCallbackHandler(_safe_agent())
        # Should not raise.
        handler.on_tool_start(
            {"name": "search_knowledge_base", "description": "Search docs"},
            "quarterly revenue",
        )

    def test_raises_sentinel_blocked_error_on_high_risk(self):
        handler = SentinelyCallbackHandler(_blocked_agent(risk=93, reason="Data exfiltration"))
        with pytest.raises(SentinelyBlockedError) as exc_info:
            handler.on_tool_start(
                {"name": "export_database", "description": "Exports the full DB"},
                "export everything",
            )
        assert exc_info.value.risk_score == 93
        assert "Data exfiltration" in exc_info.value.reason

    def test_passes_tool_name_from_serialized_dict(self):
        agent = _safe_agent()
        handler = SentinelyCallbackHandler(agent)
        handler.on_tool_start({"name": "read_file", "description": ""}, "input")
        kwargs = agent.score_action.call_args.kwargs
        assert kwargs["tool_name"] == "read_file"

    def test_uses_unknown_tool_when_name_missing(self):
        agent = _safe_agent()
        handler = SentinelyCallbackHandler(agent)
        handler.on_tool_start({}, "input")  # no "name" key
        kwargs = agent.score_action.call_args.kwargs
        assert kwargs["tool_name"] == "unknown_tool"

    def test_on_tool_error_does_not_raise(self):
        handler = SentinelyCallbackHandler(_safe_agent())
        # Should swallow silently.
        handler.on_tool_error(RuntimeError("something went wrong"))

    def test_callback_stubs_do_not_raise(self):
        """All required LangChain stubs must be no-ops."""
        handler = SentinelyCallbackHandler(_safe_agent())
        handler.on_tool_end("result")
        handler.on_llm_start({}, [])
        handler.on_llm_end(None)
        handler.on_llm_error(Exception())
        handler.on_chain_start({}, {})
        handler.on_chain_end({})
        handler.on_chain_error(Exception())


# ---------------------------------------------------------------------------
# protect_langchain()
# ---------------------------------------------------------------------------

class TestProtectLangchain:

    def test_installs_sentinely_callback_on_executor(self):
        """protect_langchain() must attach a SentinelyCallbackHandler to the
        executor's callbacks list."""
        mock_executor = MagicMock()
        mock_executor.callbacks = []

        protect_langchain(mock_executor, task="answer billing questions")

        installed = mock_executor.callbacks
        assert len(installed) == 1
        assert isinstance(installed[0], SentinelyCallbackHandler)

    def test_appends_to_existing_callbacks(self):
        """An existing callback list must be preserved; the handler is appended."""
        existing_cb = MagicMock()
        mock_executor = MagicMock()
        mock_executor.callbacks = [existing_cb]

        protect_langchain(mock_executor, task="summarize documents")

        assert len(mock_executor.callbacks) == 2
        assert mock_executor.callbacks[0] is existing_cb
        assert isinstance(mock_executor.callbacks[1], SentinelyCallbackHandler)

    def test_works_when_callbacks_is_none(self):
        """An executor with callbacks=None must end up with exactly one handler."""
        mock_executor = MagicMock()
        mock_executor.callbacks = None

        protect_langchain(mock_executor, task="process invoices")

        assert len(mock_executor.callbacks) == 1
        assert isinstance(mock_executor.callbacks[0], SentinelyCallbackHandler)

    def test_returns_a_wrapped_agent(self):
        """protect_langchain() must return a ProtectedAgent-like object."""
        from sentinely import ProtectedAgent

        mock_executor = MagicMock()
        mock_executor.callbacks = []

        result = protect_langchain(mock_executor, task="read docs")
        assert isinstance(result, ProtectedAgent)

    def test_works_without_callbacks_attribute(self):
        """Executors that don't support callbacks must not raise."""
        class NoCallbacksExecutor:
            pass

        executor = NoCallbacksExecutor()
        # Should complete without raising AttributeError.
        protect_langchain(executor, task="some task")


# ---------------------------------------------------------------------------
# Deprecation aliases
# ---------------------------------------------------------------------------

class TestDeprecationAliases:

    def test_sentinel_tool_alias_emits_deprecation_warning(self):
        from sentinely.adapters.langchain import SentinelTool  # noqa: F401
        tool = MockBaseTool()
        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always")
            SentinelTool(tool, _safe_agent())

        assert len(caught) == 1
        w = caught[0]
        assert issubclass(w.category, DeprecationWarning)
        assert "SentinelTool" in str(w.message)
        assert "SentinelyTool" in str(w.message)

    def test_sentinel_callback_handler_alias_emits_deprecation_warning(self):
        from sentinely.adapters.langchain import SentinelCallbackHandler  # noqa: F401
        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter("always")
            SentinelCallbackHandler(_safe_agent())

        assert len(caught) == 1
        w = caught[0]
        assert issubclass(w.category, DeprecationWarning)
        assert "SentinelCallbackHandler" in str(w.message)
        assert "SentinelyCallbackHandler" in str(w.message)

    def test_sentinel_tool_alias_is_still_functional(self):
        """The alias must produce a working SentinelyTool, not just a shell."""
        from sentinely.adapters.langchain import SentinelTool
        tool = MockBaseTool()
        with warnings.catch_warnings(record=True):
            warnings.simplefilter("always")
            wrapped = SentinelTool(tool, _safe_agent())
        assert isinstance(wrapped, SentinelyTool)
        assert wrapped.name == tool.name

    def test_sentinel_callback_handler_alias_is_still_functional(self):
        """The alias must produce a working SentinelyCallbackHandler."""
        from sentinely.adapters.langchain import SentinelCallbackHandler
        with warnings.catch_warnings(record=True):
            warnings.simplefilter("always")
            handler = SentinelCallbackHandler(_safe_agent())
        assert isinstance(handler, SentinelyCallbackHandler)
