import json
from typing import cast, Callable

import pytest

from langchain_core.messages import ToolCall, AIMessage, HumanMessage
from langchain_core.tools import StructuredTool
from pydantic import BaseModel

from frisk_sdk.adapters.langchain.langchain_framework_adapter import (
    LangchainFrameworkAdapter,
)
from frisk_sdk.framework_adapter.framework_adapter import NormalizedToolCall
from frisk_sdk.core.llm_reasoning import LLM_REASONING_ARG_NAME


class _ToolCallObj:
    """Minimal object-like ToolCall stand-in.

    LangchainFrameworkAdapter supports both dict-style and attribute-style access.
    This gives us a stable unit-test object without depending on LangChain internals.
    """

    def __init__(self, *, id: str | None = None, name: str | None = None):
        self.id = id
        self.name = name


@pytest.mark.unit
def test_serialize_tool_args_json_roundtrip():
    adapter = LangchainFrameworkAdapter()

    args = {"a": 1, "b": {"c": True}}
    s = adapter.serialize_tool_args(args).value

    assert json.loads(cast(str, s)) == args


@pytest.mark.unit
def test_serialize_tool_args_redact_true_redacts_all_and_tracks_star_path():
    """With redact=True, the core redaction currently removes all keys.

    This test asserts the contract exposed by the adapter:
    - returned JSON is still valid
    - content is fully redacted (empty object)
    - redacted_paths records a wildcard ('*')
    """

    adapter = LangchainFrameworkAdapter()

    args = {"a": 1, "b": {"c": True, "d": "secret"}}
    result = adapter.serialize_tool_args(args, redact=True)

    assert result.redacted_paths

    redacted_value = json.loads(cast(str, result.value))
    assert redacted_value == {}
    assert "*" in result.redacted_paths


@pytest.mark.unit
def test_tool_args_to_dict_redact_selected_paths_removes_only_selected_keys():
    adapter = LangchainFrameworkAdapter()

    args = {"keep": 1, "secret": "x", "nested": {"secret": "y", "keep": 2}}

    # Redact only specific paths.
    result = adapter.tool_args_to_dict(args, redact=["secret", "nested.secret"])
    assert result is not None

    # Paths should be tracked.
    assert set(result.redacted_paths) == {"secret", "nested.secret"}

    # Selected redacted keys should be removed.
    assert "secret" not in result.value
    assert "secret" not in result.value["nested"]

    # Non-redacted values should remain.
    assert result.value["keep"] == 1
    assert result.value["nested"]["keep"] == 2


@pytest.mark.unit
def test_serialize_agent_state_uses_langchain_dumps():
    """serialize_agent_state should return a string produced by langchain_core.load.dumps.

    The "messages" key is excluded from the serialized state.
    """

    from langchain_core.load import dumps

    adapter = LangchainFrameworkAdapter()

    state = {"messages": [{"role": "user", "content": "hi"}], "x": 1}
    expected_state = {"x": 1}  # messages key is excluded
    assert adapter.serialize_agent_state(state).value == dumps(expected_state)


@pytest.mark.unit
def test_serialize_agent_state_excludes_keys():
    """serialize_agent_state should exclude all keys listed in EXCLUDED_STATE_KEYS."""

    from langchain_core.load import dumps

    adapter = LangchainFrameworkAdapter()

    # Verify that "messages" is in the excluded keys
    assert "messages" in adapter.EXCLUDED_STATE_KEYS

    # Test that excluded keys are filtered out
    state = {
        "messages": [{"role": "user", "content": "test"}],
        "other_field": "value",
        "number": 42,
    }
    expected_state = {"other_field": "value", "number": 42}
    expected_serialized_state = dumps(expected_state)
    actual_serialized_state = adapter.serialize_agent_state(state).value
    assert json.loads(cast(str, actual_serialized_state)) == json.loads(
        expected_serialized_state
    )


@pytest.mark.unit
def test_agent_state_to_dict_combines_excluded_keys_with_redact_option():
    adapter = LangchainFrameworkAdapter()

    # Provide both an excluded key (messages) and a secret we want to redact.
    state = {
        "messages": [{"role": "user", "content": "hi"}],
        "secret": "top-secret",
        "nested": {"secret": "nested-secret", "keep": 1},
        "keep": True,
    }

    redacted = adapter.agent_state_to_dict(state, redact=["secret", "nested.secret"])

    # Excluded keys should be removed.
    assert "messages" not in redacted.value

    # Redacted keys should be removed.
    assert "secret" not in redacted.value
    assert "secret" not in redacted.value["nested"]

    # Non-redacted field should remain.
    assert redacted.value["keep"] is True
    assert redacted.value["nested"]["keep"] == 1

    # Paths should be tracked (including excluded keys).
    assert set(redacted.redacted_paths) == {"messages", "secret", "nested.secret"}


@pytest.mark.unit
def test_serialize_agent_state_redact_true_redacts_all_after_excluding_messages():
    adapter = LangchainFrameworkAdapter()

    state = {
        "messages": [{"role": "user", "content": "hi"}],
        "pii": {"ssn": "111-22-3333"},
        "x": 1,
    }

    result = adapter.serialize_agent_state(state, redact=True)

    payload = json.loads(cast(str, result.value))
    assert "messages" not in payload

    # With redact=True, the remaining payload is fully redacted.
    assert payload == {}
    assert result.redacted_paths
    assert "*" in result.redacted_paths


@pytest.mark.unit
def test_get_or_create_tool_call_id_prefers_attribute_id():
    adapter = LangchainFrameworkAdapter()

    tc = _ToolCallObj(id="attr-id", name="tool")
    assert adapter.get_or_create_tool_call_id(cast(ToolCall, tc)) == "attr-id"


@pytest.mark.unit
def test_get_or_create_tool_call_id_uses_dict_id():
    adapter = LangchainFrameworkAdapter()

    tc = {"id": "dict-id", "name": "tool", "args": {"x": 1}}
    assert adapter.get_or_create_tool_call_id(cast(ToolCall, tc)) == "dict-id"


@pytest.mark.unit
def test_get_or_create_tool_call_id_generates_when_missing(
    monkeypatch: pytest.MonkeyPatch,
):
    adapter = LangchainFrameworkAdapter()
    monkeypatch.setattr(adapter, "_cuid_generator", lambda: "gen-id")

    tc = _ToolCallObj(id=None, name="tool")
    assert adapter.get_or_create_tool_call_id(cast(ToolCall, tc)) == "gen-id"


@pytest.mark.unit
def test_get_tool_name_prefers_attribute_name():
    adapter = LangchainFrameworkAdapter()

    tc = _ToolCallObj(id="id", name="attr-name")
    assert adapter.get_tool_name(cast(ToolCall, tc)) == "attr-name"


@pytest.mark.unit
def test_get_tool_name_uses_dict_name():
    adapter = LangchainFrameworkAdapter()

    tc = {"id": "id", "name": "dict-name", "args": {"x": 1}}
    assert adapter.get_tool_name(cast(ToolCall, tc)) == "dict-name"


@pytest.mark.unit
def test_get_tool_name_returns_none_when_missing_on_object():
    adapter = LangchainFrameworkAdapter()

    tc = _ToolCallObj(id="id", name=None)
    assert adapter.get_tool_name(cast(ToolCall, tc)) is None


@pytest.mark.unit
def test_normalize_tool_call_from_dict_tool_call(monkeypatch: pytest.MonkeyPatch):
    adapter = LangchainFrameworkAdapter()
    monkeypatch.setattr(adapter, "_cuid_generator", lambda: "gen-id")

    tc = {"name": "my_tool", "args": {"x": 1}}

    info = adapter.normalize_tool_call(cast(ToolCall, tc))
    assert isinstance(info, NormalizedToolCall)
    assert info == NormalizedToolCall(id="gen-id", name="my_tool", args={"x": 1})


@pytest.mark.unit
def test_normalize_tool_call_raises_for_object_without_getitem():
    """LangchainFrameworkAdapter.normalize_tool_call expects ToolCall-style indexing (tool_call['args']).

    We keep this as an explicit contract test: callers should pass dict-like ToolCall objects
    (e.g., langchain_core.messages.ToolCall).
    """

    adapter = LangchainFrameworkAdapter()

    with pytest.raises(TypeError):
        adapter.normalize_tool_call(cast(ToolCall, _ToolCallObj(id="id", name="tool")))


@pytest.mark.unit
def test_get_user_query():
    adapter = LangchainFrameworkAdapter()
    message_history = [
        HumanMessage(content="hello"),
        AIMessage(content="Hi, I'm ready to help."),
        HumanMessage(content="Please execute this tool call."),
    ]

    agent_state = {"messages": message_history}
    assert adapter.extract_prompt(agent_state) == "Please execute this tool call."


@pytest.mark.unit
def test_convert_callable_to_tool_passthrough_for_base_tool() -> None:
    adapter = LangchainFrameworkAdapter()

    def my_tool(x: int) -> int:
        """Add one to x."""

        return x + 1

    base_tool = StructuredTool.from_function(my_tool)
    out = adapter.convert_callable_to_tool(cast(Callable, base_tool))

    assert out is base_tool


@pytest.mark.unit
def test_convert_callable_to_tool_converts_plain_function_to_structured_tool() -> None:
    adapter = LangchainFrameworkAdapter()

    def my_tool(x: int) -> int:
        """Add one to x."""

        return x + 1

    tool = adapter.convert_callable_to_tool(my_tool)

    assert isinstance(tool, StructuredTool)
    assert tool.name == "my_tool"


@pytest.mark.unit
def test_convert_callable_to_tool_raises_for_unsupported_type() -> None:
    adapter = LangchainFrameworkAdapter()

    with pytest.raises(ValueError):
        adapter.convert_callable_to_tool(cast(Callable, object()))


@pytest.mark.unit
def test_wrap_tools_wraps_all_tools_and_preserves_names() -> None:
    adapter = LangchainFrameworkAdapter()

    def tool_a(x: int) -> int:
        """Identity function for ints."""

        return x

    def tool_b(y: str) -> str:
        """Identity function for strings."""

        return y

    wrapped = adapter.wrap_tools([tool_a, tool_b])

    assert isinstance(wrapped, list)
    assert len(wrapped) == 2
    assert all(isinstance(t, StructuredTool) for t in wrapped)

    names = {t.name for t in wrapped}
    assert names == {"tool_a", "tool_b"}


@pytest.mark.unit
def test_wrap_tool_options_none_wraps_with_reasoning() -> None:
    adapter = LangchainFrameworkAdapter()

    def tool_a(x: int) -> int:
        """Add one to x."""

        return x + 1

    wrapped = adapter.wrap_tool(tool_a, None)

    assert isinstance(wrapped, StructuredTool)

    schema = cast(dict, cast(BaseModel, wrapped.args_schema).model_json_schema())
    props = cast(dict, schema.get("properties", {}))
    assert LLM_REASONING_ARG_NAME in props


@pytest.mark.unit
def test_wrap_tool_collect_reasoning_false_does_not_wrap() -> None:
    adapter = LangchainFrameworkAdapter()

    def tool_a(x: int) -> int:
        """Add one to x."""

        return x + 1

    wrapped = adapter.wrap_tool(tool_a, {"collect_reasoning": False})

    # Without reasoning collection, wrap_tool returns the StructuredTool conversion only.
    assert isinstance(wrapped, StructuredTool)

    schema = cast(dict, cast(BaseModel, wrapped.args_schema).model_json_schema())
    props = cast(dict, schema.get("properties", {}))
    assert LLM_REASONING_ARG_NAME not in props


@pytest.mark.unit
def test_wrap_tool_collect_reasoning_true_wraps_with_reasoning() -> None:
    adapter = LangchainFrameworkAdapter()

    def tool_a(x: int) -> int:
        """Add one to x."""

        return x + 1

    wrapped = adapter.wrap_tool(tool_a, {"collect_reasoning": True})

    assert isinstance(wrapped, StructuredTool)

    schema = cast(dict, cast(BaseModel, wrapped.args_schema).model_json_schema())
    props = cast(dict, schema.get("properties", {}))
    assert LLM_REASONING_ARG_NAME in props
