"""LangChain adapter for Sentinely.

Provides two integration approaches:

1. **SentinelyTool** — wraps individual ``BaseTool`` instances so every
   invocation is scored before the real tool runs.
2. **SentinelyCallbackHandler** — attaches to an ``AgentExecutor``'s callback
   list and intercepts *all* tool calls automatically.

A convenience function :func:`protect_langchain` ties everything together in
a single call.

All LangChain imports are deferred so that ``sentinely`` can be installed
without LangChain being a hard dependency.
"""

from __future__ import annotations

import asyncio
import warnings
from typing import TYPE_CHECKING, Any

from sentinely.exceptions import SentinelyBlockedError

if TYPE_CHECKING:
    from sentinely import ProtectedAgent


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _run_async(coro: Any) -> Any:
    """Run *coro* from sync context, handling the case where an event loop
    is already running (e.g. inside Jupyter or an async framework)."""
    try:
        loop = asyncio.get_running_loop()
    except RuntimeError:
        loop = None

    if loop is not None and loop.is_running():
        # We're inside a running loop — use nest_asyncio if available,
        # otherwise create a brand-new loop in a thread.
        import concurrent.futures

        with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
            return pool.submit(asyncio.run, coro).result()

    return asyncio.run(coro)


# ---------------------------------------------------------------------------
# Approach 1 — Tool wrapper
# ---------------------------------------------------------------------------

class SentinelyTool:
    """Drop-in replacement for any LangChain ``BaseTool``.

    Runs a Sentinely intent check **before** the real tool executes.

    Usage::

        from langchain_core.tools import BaseTool
        from sentinely.adapters.langchain import SentinelyTool

        wrapped = SentinelyTool(original_tool, protected_agent)
        # Use *wrapped* wherever you'd use *original_tool*.
    """

    def __init__(self, tool: Any, protected_agent: ProtectedAgent) -> None:
        # Lazy-import so the module loads even without langchain installed.
        try:
            from langchain_core.tools import BaseTool as _BaseTool
        except ImportError:
            _BaseTool = None  # type: ignore[assignment,misc]

        if _BaseTool is not None and not isinstance(tool, _BaseTool):
            raise TypeError(
                f"Expected a LangChain BaseTool instance, got {type(tool).__name__}"
            )

        self._wrapped_tool = tool
        self._protected_agent = protected_agent

        # Mirror the attributes LangChain expects on a tool.
        self.name: str = getattr(tool, "name", "unknown_tool")
        self.description: str = getattr(tool, "description", "")

    # -- sync path ---------------------------------------------------------

    def _run(self, *args: Any, **kwargs: Any) -> Any:
        try:
            _run_async(
                self._protected_agent.score_action(
                    tool_name=self.name,
                    tool_params={"args": args, "kwargs": kwargs},
                    description=self.description,
                )
            )
        except SentinelyBlockedError as exc:
            return f"[SENTINELY BLOCKED] {exc.reason} (risk score: {exc.risk_score})"

        return self._wrapped_tool._run(*args, **kwargs)

    def invoke(self, input: Any, config: Any = None, **kwargs: Any) -> Any:  # noqa: A002
        """LangChain >=0.2 calls ``invoke`` instead of ``_run``."""
        try:
            _run_async(
                self._protected_agent.score_action(
                    tool_name=self.name,
                    tool_params={"input": input},
                    description=self.description,
                )
            )
        except SentinelyBlockedError as exc:
            return f"[SENTINELY BLOCKED] {exc.reason} (risk score: {exc.risk_score})"

        return self._wrapped_tool.invoke(input, config, **kwargs)

    # -- async path --------------------------------------------------------

    async def _arun(self, *args: Any, **kwargs: Any) -> Any:
        await self._protected_agent.score_action(
            tool_name=self.name,
            tool_params={"args": args, "kwargs": kwargs},
            description=self.description,
        )
        return await self._wrapped_tool._arun(*args, **kwargs)

    async def ainvoke(self, input: Any, config: Any = None, **kwargs: Any) -> Any:  # noqa: A002
        await self._protected_agent.score_action(
            tool_name=self.name,
            tool_params={"input": input},
            description=self.description,
        )
        return await self._wrapped_tool.ainvoke(input, config, **kwargs)

    # Expose run as an alias so it can be used as a regular tool.
    def run(self, tool_input: Any, **kwargs: Any) -> Any:
        return self._run(tool_input, **kwargs)


# ---------------------------------------------------------------------------
# Approach 2 — Callback handler
# ---------------------------------------------------------------------------

class SentinelyCallbackHandler:
    """Add to any LangChain agent's ``callbacks`` list.

    Automatically intercepts **all** tool calls via the
    ``on_tool_start`` hook.

    Usage::

        from sentinely.adapters.langchain import SentinelyCallbackHandler

        handler = SentinelyCallbackHandler(protected_agent)
        executor = AgentExecutor(agent=agent, tools=tools, callbacks=[handler])
    """

    def __init__(self, protected_agent: ProtectedAgent) -> None:
        self._protected_agent = protected_agent

    # LangChain calls this before every tool invocation.
    def on_tool_start(
        self,
        serialized: dict[str, Any],
        input_str: str,
        **kwargs: Any,
    ) -> None:
        tool_name = serialized.get("name", "unknown_tool")
        _run_async(
            self._protected_agent.score_action(
                tool_name=tool_name,
                tool_params={"input": input_str},
                description=serialized.get("description", ""),
            )
        )
        # If score_action raises SentinelyBlockedError it propagates up
        # and LangChain will stop execution.

    def on_tool_error(self, error: BaseException, **kwargs: Any) -> None:
        # Let Sentinely errors propagate naturally.
        pass

    # Required callback stubs so LangChain doesn't complain.
    def on_tool_end(self, output: str, **kwargs: Any) -> None:
        pass

    def on_llm_start(self, serialized: dict[str, Any], prompts: list[str], **kwargs: Any) -> None:
        pass

    def on_llm_end(self, response: Any, **kwargs: Any) -> None:
        pass

    def on_llm_error(self, error: BaseException, **kwargs: Any) -> None:
        pass

    def on_chain_start(self, serialized: dict[str, Any], inputs: dict[str, Any], **kwargs: Any) -> None:
        pass

    def on_chain_end(self, outputs: dict[str, Any], **kwargs: Any) -> None:
        pass

    def on_chain_error(self, error: BaseException, **kwargs: Any) -> None:
        pass


# ---------------------------------------------------------------------------
# Convenience function
# ---------------------------------------------------------------------------

def protect_langchain(
    agent_executor: Any,
    task: str,
    policy: str = "strict",
    **kwargs: Any,
) -> Any:
    """One-function integration for LangChain ``AgentExecutor``.

    Usage::

        from sentinely.adapters.langchain import protect_langchain

        agent = create_openai_functions_agent(llm, tools, prompt)
        executor = AgentExecutor(agent=agent, tools=tools)
        protected = protect_langchain(executor, task="analyze sales data")

        result = await protected.invoke({"input": "analyze sales data"})

    This wraps the executor with :func:`sentinely.protect` **and** installs a
    :class:`SentinelyCallbackHandler` on the underlying executor.
    """
    from sentinely import protect

    sentinel_agent = protect(agent_executor, task=task, policy=policy, **kwargs)

    # Install callback on the underlying executor if it supports callbacks.
    if hasattr(agent_executor, "callbacks"):
        existing = agent_executor.callbacks or []
        agent_executor.callbacks = list(existing) + [
            SentinelyCallbackHandler(sentinel_agent)
        ]

    return sentinel_agent


# ---------------------------------------------------------------------------
# Deprecated aliases — kept for backwards compatibility
# Will be removed in a future release.
# ---------------------------------------------------------------------------

def _deprecated(old_name: str, new_name: str) -> type:
    """Return a subclass that emits a DeprecationWarning on instantiation."""
    # We look up the new class from the module globals at call time so this
    # helper doesn't depend on definition order.
    _globals = globals()

    class _DeprecatedAlias(_globals[new_name]):  # type: ignore[misc]
        def __init__(self, *args: Any, **kwargs: Any) -> None:
            warnings.warn(
                f"{old_name} has been renamed to {new_name}. "
                f"Please update your imports. "
                f"{old_name} will be removed in a future release.",
                DeprecationWarning,
                stacklevel=2,
            )
            super().__init__(*args, **kwargs)

    _DeprecatedAlias.__name__ = old_name
    _DeprecatedAlias.__qualname__ = old_name
    return _DeprecatedAlias


SentinelTool = _deprecated("SentinelTool", "SentinelyTool")
SentinelCallbackHandler = _deprecated("SentinelCallbackHandler", "SentinelyCallbackHandler")
