"""Tests for novyx-agent — unit tests with mocked LLM and Novyx."""

from __future__ import annotations

import json
from typing import Any, Dict, List, Tuple
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from novyx_agent import Agent, AsyncAgent, MemoryConfig, RunResult, tool
from novyx_agent.providers import LLMProvider, AsyncLLMProvider
from novyx_agent.types import Message, Role, ToolDef


# ---------------------------------------------------------------------------
# Mock LLM providers
# ---------------------------------------------------------------------------

class MockLLM(LLMProvider):
    """Controllable mock LLM for testing."""

    def __init__(self, responses: List[Tuple[str, List[Dict[str, Any]], Dict[str, int]]]) -> None:
        self._responses = list(responses)
        self._call_count = 0
        self.calls: List[Dict[str, Any]] = []

    def chat(
        self,
        messages: List[Message],
        tools: List[ToolDef],
        *,
        model: str,
        temperature: float,
        max_tokens: int,
    ) -> Tuple[str, List[Dict[str, Any]], Dict[str, int]]:
        self.calls.append({
            "messages": messages,
            "tools": tools,
            "model": model,
        })
        idx = min(self._call_count, len(self._responses) - 1)
        self._call_count += 1
        return self._responses[idx]


class MockAsyncLLM(AsyncLLMProvider):
    """Controllable mock async LLM for testing."""

    def __init__(self, responses: List[Tuple[str, List[Dict[str, Any]], Dict[str, int]]]) -> None:
        self._responses = list(responses)
        self._call_count = 0
        self.calls: List[Dict[str, Any]] = []

    async def chat(
        self,
        messages: List[Message],
        tools: List[ToolDef],
        *,
        model: str,
        temperature: float,
        max_tokens: int,
    ) -> Tuple[str, List[Dict[str, Any]], Dict[str, int]]:
        self.calls.append({
            "messages": messages,
            "tools": tools,
            "model": model,
        })
        idx = min(self._call_count, len(self._responses) - 1)
        self._call_count += 1
        return self._responses[idx]


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

def make_agent(
    llm_responses: List[Tuple[str, List, Dict]],
    memory_config: MemoryConfig | None = None,
    tools_list: list | None = None,
) -> Tuple[Agent, MockLLM]:
    """Create an agent with mocked internals."""
    mock_llm = MockLLM(llm_responses)

    with patch("novyx_agent.agent.Novyx") as MockNovyx, \
         patch("novyx_agent.agent.get_provider") as mock_get_provider:
        mock_get_provider.return_value = mock_llm

        # Mock Novyx client
        nx_instance = MagicMock()
        nx_instance.recall.return_value = MagicMock(memories=[])
        nx_instance.remember.return_value = {"id": "mem_test", "status": "stored"}
        MockNovyx.return_value = nx_instance

        agent = Agent(
            name="TestAgent",
            api_key="nram_test_123_abc",
            model="test-model",
            memory=memory_config,
            tools=tools_list,
        )
        agent._llm = mock_llm
        agent._nx = nx_instance

    return agent, mock_llm


def make_async_agent(
    llm_responses: List[Tuple[str, List, Dict]],
    memory_config: MemoryConfig | None = None,
    tools_list: list | None = None,
) -> Tuple[AsyncAgent, MockAsyncLLM]:
    """Create an async agent with mocked internals."""
    mock_llm = MockAsyncLLM(llm_responses)

    with patch("novyx_agent.async_agent.AsyncNovyx") as MockAsyncNovyx, \
         patch("novyx_agent.async_agent.get_async_provider") as mock_get_provider:
        mock_get_provider.return_value = mock_llm

        # Mock AsyncNovyx client — use AsyncMock so each call creates a fresh coroutine
        nx_instance = MagicMock()
        nx_instance.recall = AsyncMock(return_value=MagicMock(memories=[]))
        nx_instance.remember = AsyncMock(return_value={"id": "mem_test", "status": "stored"})
        nx_instance.forget = AsyncMock(return_value=True)
        nx_instance.memories = AsyncMock(return_value=[])
        nx_instance.rollback = AsyncMock(return_value={"status": "ok"})
        nx_instance.rollback_preview = AsyncMock(return_value={"changes": []})
        nx_instance.audit = AsyncMock(return_value={"entries": []})
        nx_instance.eval_run = AsyncMock(return_value={"score": 0.95})
        nx_instance.eval_gate = AsyncMock(return_value={"passed": True})
        nx_instance.add_triple = AsyncMock(return_value={"id": "t_1"})
        nx_instance.query_triples = AsyncMock(return_value={"triples": []})
        MockAsyncNovyx.return_value = nx_instance

        agent = AsyncAgent(
            name="TestAsyncAgent",
            api_key="nram_test_123_abc",
            model="test-model",
            memory=memory_config,
            tools=tools_list,
        )
        agent._llm = mock_llm
        agent._nx = nx_instance

    return agent, mock_llm


# ---------------------------------------------------------------------------
# Tests: Tool decorator
# ---------------------------------------------------------------------------

class TestToolDecorator:
    def test_basic_tool(self) -> None:
        @tool
        def greet(name: str) -> str:
            """Say hello."""
            return f"Hello, {name}!"

        assert hasattr(greet, "_tool_def")
        assert greet._tool_def.name == "greet"
        assert greet._tool_def.description == "Say hello."
        assert len(greet._tool_def.parameters) == 1
        assert greet._tool_def.parameters[0].name == "name"
        assert greet._tool_def.parameters[0].type == "string"
        assert greet._tool_def.parameters[0].required is True

    def test_tool_with_defaults(self) -> None:
        @tool
        def search(query: str, limit: int = 10) -> str:
            """Search things."""
            return query

        assert len(search._tool_def.parameters) == 2
        assert search._tool_def.parameters[0].required is True
        assert search._tool_def.parameters[1].required is False
        assert search._tool_def.parameters[1].default == 10

    def test_tool_custom_name(self) -> None:
        @tool(name="web_search", description="Custom description")
        def search(query: str) -> str:
            return query

        assert search._tool_def.name == "web_search"
        assert search._tool_def.description == "Custom description"

    def test_tool_still_callable(self) -> None:
        @tool
        def add(a: int, b: int) -> int:
            """Add two numbers."""
            return a + b

        assert add(2, 3) == 5

    def test_tool_openai_schema(self) -> None:
        @tool
        def calc(expression: str) -> str:
            """Calculate a math expression."""
            return "42"

        schema = calc._tool_def.to_openai_schema()
        assert schema["type"] == "function"
        assert schema["function"]["name"] == "calc"
        assert "expression" in schema["function"]["parameters"]["properties"]

    def test_tool_anthropic_schema(self) -> None:
        @tool
        def calc(expression: str) -> str:
            """Calculate a math expression."""
            return "42"

        schema = calc._tool_def.to_anthropic_schema()
        assert schema["name"] == "calc"
        assert "input_schema" in schema
        assert "expression" in schema["input_schema"]["properties"]

    def test_tool_docstring_param_descriptions(self) -> None:
        """@tool should extract parameter descriptions from Google-style docstrings."""
        @tool
        def search_web(query: str, limit: int = 10, safe: bool = True) -> str:
            """Search the web for information.

            Args:
                query: The search query to execute
                limit: Maximum number of results to return
                safe: Enable safe search filtering
            """
            return "results"

        params = {p.name: p for p in search_web._tool_def.parameters}
        assert params["query"].description == "The search query to execute"
        assert params["limit"].description == "Maximum number of results to return"
        assert params["safe"].description == "Enable safe search filtering"

    def test_tool_no_docstring_fallback_to_type(self) -> None:
        """Without docstring, param descriptions should fall back to JSON type name."""
        @tool
        def bare(name: str, count: int, active: bool) -> str:
            return "ok"

        params = {p.name: p for p in bare._tool_def.parameters}
        assert params["name"].description == "string"
        assert params["count"].description == "integer"
        assert params["active"].description == "boolean"

    def test_tool_partial_docstring(self) -> None:
        """When only some params are documented, undocumented ones fall back to type."""
        @tool
        def partial(query: str, limit: int = 5) -> str:
            """Do something.

            Args:
                query: The query string
            """
            return "ok"

        params = {p.name: p for p in partial._tool_def.parameters}
        assert params["query"].description == "The query string"
        assert params["limit"].description == "integer"

    def test_tool_type_annotations(self) -> None:
        """Various type annotations should map to correct JSON Schema types."""
        @tool
        def typed(
            s: str,
            i: int,
            f: float,
            b: bool,
            l: list,
            d: dict,
        ) -> str:
            """Typed tool."""
            return "ok"

        params = {p.name: p for p in typed._tool_def.parameters}
        assert params["s"].type == "string"
        assert params["i"].type == "integer"
        assert params["f"].type == "number"
        assert params["b"].type == "boolean"
        assert params["l"].type == "array"
        assert params["d"].type == "object"

    def test_tool_list_and_dict_generic_types(self) -> None:
        """List[str] and Dict[str, Any] should map correctly."""
        @tool
        def generic(items: List[str], meta: Dict[str, Any]) -> str:
            """Generic types."""
            return "ok"

        params = {p.name: p for p in generic._tool_def.parameters}
        assert params["items"].type == "array"
        assert params["meta"].type == "object"


# ---------------------------------------------------------------------------
# Tests: Agent basics
# ---------------------------------------------------------------------------

class TestAgentBasics:
    def test_simple_run(self) -> None:
        agent, llm = make_agent([
            ("Hello! How can I help?", [], {"prompt_tokens": 10, "completion_tokens": 5}),
        ])

        result = agent.run("Hi there")
        assert isinstance(result, RunResult)
        assert result.output == "Hello! How can I help?"
        assert result.model == "test-model"
        assert len(llm.calls) == 1

    def test_run_returns_string_via_str(self) -> None:
        agent, _ = make_agent([
            ("The answer is 42.", [], {"prompt_tokens": 10, "completion_tokens": 5}),
        ])
        result = agent.run("What is the meaning of life?")
        assert str(result) == "The answer is 42."

    def test_tool_execution(self) -> None:
        @tool
        def get_weather(city: str) -> str:
            """Get current weather."""
            return f"Sunny in {city}"

        agent, _ = make_agent(
            [
                # Round 1: LLM calls the tool
                ("", [{"id": "call_1", "name": "get_weather", "arguments": {"city": "NYC"}}], {"prompt_tokens": 20, "completion_tokens": 10}),
                # Round 2: LLM gives final answer
                ("It's sunny in NYC!", [], {"prompt_tokens": 30, "completion_tokens": 10}),
            ],
            tools_list=[get_weather],
        )

        result = agent.run("What's the weather in NYC?")
        assert result.output == "It's sunny in NYC!"
        assert len(result.tool_calls) == 1
        assert result.tool_calls[0].tool == "get_weather"
        assert result.tool_calls[0].result == "Sunny in NYC"

    def test_multiple_tool_calls(self) -> None:
        @tool
        def lookup(term: str) -> str:
            """Look up a term."""
            return f"Definition of {term}"

        agent, _ = make_agent(
            [
                ("", [
                    {"id": "c1", "name": "lookup", "arguments": {"term": "AI"}},
                    {"id": "c2", "name": "lookup", "arguments": {"term": "ML"}},
                ], {"prompt_tokens": 20, "completion_tokens": 10}),
                ("AI is artificial intelligence, ML is machine learning.", [], {"prompt_tokens": 40, "completion_tokens": 20}),
            ],
            tools_list=[lookup],
        )

        result = agent.run("Define AI and ML")
        assert len(result.tool_calls) == 2

    def test_unknown_tool_handled(self) -> None:
        agent, _ = make_agent([
            ("", [{"id": "c1", "name": "nonexistent", "arguments": {}}], {"prompt_tokens": 10, "completion_tokens": 5}),
            ("Sorry, I couldn't do that.", [], {"prompt_tokens": 20, "completion_tokens": 10}),
        ])

        result = agent.run("Do something")
        assert "nonexistent" in result.tool_calls[0].result
        assert "Error" in result.tool_calls[0].result

    def test_tool_error_handled(self) -> None:
        @tool
        def broken() -> str:
            """A broken tool."""
            raise RuntimeError("Oops!")

        agent, _ = make_agent(
            [
                ("", [{"id": "c1", "name": "broken", "arguments": {}}], {"prompt_tokens": 10, "completion_tokens": 5}),
                ("The tool failed, sorry.", [], {"prompt_tokens": 20, "completion_tokens": 10}),
            ],
            tools_list=[broken],
        )

        result = agent.run("Use the broken tool")
        assert "Error" in result.tool_calls[0].result

    def test_max_tool_rounds(self) -> None:
        agent, _ = make_agent(
            # Always returns a tool call — should stop at max_tool_rounds
            [("", [{"id": f"c{i}", "name": "x", "arguments": {}}], {"prompt_tokens": 1, "completion_tokens": 1}) for i in range(15)],
        )
        agent._config.max_tool_rounds = 3
        result = agent.run("Loop forever")
        assert "maximum" in result.output.lower()

    def test_context_injected(self) -> None:
        """Extra context passed to run() should appear in system message."""
        agent, llm = make_agent([
            ("Got it.", [], {"prompt_tokens": 10, "completion_tokens": 5}),
        ])

        agent.run("Hello", context="User is a premium subscriber")
        system_msg = llm.calls[0]["messages"][0]
        assert "premium subscriber" in system_msg.content

    def test_usage_accumulates_across_rounds(self) -> None:
        """Token usage should sum across multiple LLM rounds."""
        @tool
        def noop() -> str:
            """No-op."""
            return "ok"

        agent, _ = make_agent(
            [
                ("", [{"id": "c1", "name": "noop", "arguments": {}}], {"prompt_tokens": 100, "completion_tokens": 50}),
                ("Done.", [], {"prompt_tokens": 200, "completion_tokens": 75}),
            ],
            tools_list=[noop],
        )

        result = agent.run("Do it")
        assert result.usage["prompt_tokens"] == 300
        assert result.usage["completion_tokens"] == 125

    def test_tool_non_string_result_converted(self) -> None:
        """Tool returning non-string should be converted via str()."""
        @tool
        def get_count() -> int:
            """Return a count."""
            return 42

        agent, _ = make_agent(
            [
                ("", [{"id": "c1", "name": "get_count", "arguments": {}}], {"prompt_tokens": 10, "completion_tokens": 5}),
                ("The count is 42.", [], {"prompt_tokens": 20, "completion_tokens": 10}),
            ],
            tools_list=[get_count],
        )

        result = agent.run("Get the count")
        assert result.tool_calls[0].result == "42"

    def test_memory_config_from_dict(self) -> None:
        """Agent should accept memory config as a plain dict."""
        agent, _ = make_agent(
            [("OK", [], {"prompt_tokens": 5, "completion_tokens": 2})],
            memory_config=MemoryConfig(auto_recall=False, recall_limit=3),
        )
        assert agent.config.memory.auto_recall is False
        assert agent.config.memory.recall_limit == 3

    def test_no_reset_method(self) -> None:
        """Agent should not have a reset() method (removed in favor of clear_history)."""
        agent, _ = make_agent([("OK", [], {})])
        assert not hasattr(agent, "reset")


# ---------------------------------------------------------------------------
# Tests: Memory integration
# ---------------------------------------------------------------------------

class TestMemoryIntegration:
    def test_auto_recall_called(self) -> None:
        agent, _ = make_agent([
            ("I remember that.", [], {"prompt_tokens": 10, "completion_tokens": 5}),
        ])

        agent.run("What do you remember?")
        agent._nx.recall.assert_called_once()

    def test_auto_recall_disabled(self) -> None:
        agent, _ = make_agent(
            [("Hello", [], {"prompt_tokens": 10, "completion_tokens": 5})],
            memory_config=MemoryConfig(auto_recall=False),
        )

        agent.run("Hello")
        agent._nx.recall.assert_not_called()

    def test_auto_remember_called(self) -> None:
        agent, _ = make_agent([
            ("Important answer.", [], {"prompt_tokens": 10, "completion_tokens": 5}),
        ])

        agent.run("Tell me something")
        agent._nx.remember.assert_called_once()

    def test_auto_remember_disabled(self) -> None:
        agent, _ = make_agent(
            [("Hello", [], {"prompt_tokens": 10, "completion_tokens": 5})],
            memory_config=MemoryConfig(auto_remember=False),
        )

        agent.run("Hello")
        agent._nx.remember.assert_not_called()

    def test_recall_failure_doesnt_break_run(self) -> None:
        agent, _ = make_agent([
            ("Still works!", [], {"prompt_tokens": 10, "completion_tokens": 5}),
        ])
        agent._nx.recall.side_effect = Exception("Network error")

        result = agent.run("Hello")
        assert result.output == "Still works!"

    def test_remember_failure_doesnt_break_run(self) -> None:
        agent, _ = make_agent([
            ("Still works!", [], {"prompt_tokens": 10, "completion_tokens": 5}),
        ])
        agent._nx.remember.side_effect = Exception("Network error")

        result = agent.run("Hello")
        assert result.output == "Still works!"

    def test_explicit_remember(self) -> None:
        agent, _ = make_agent([
            ("OK", [], {"prompt_tokens": 5, "completion_tokens": 2}),
        ])
        agent.remember("User likes Python")
        agent._nx.remember.assert_called_with("User likes Python")

    def test_explicit_recall(self) -> None:
        agent, _ = make_agent([
            ("OK", [], {"prompt_tokens": 5, "completion_tokens": 2}),
        ])
        agent.recall("preferences")
        agent._nx.recall.assert_called_with("preferences")

    def test_rollback(self) -> None:
        agent, _ = make_agent([
            ("OK", [], {"prompt_tokens": 5, "completion_tokens": 2}),
        ])
        agent.rollback("1 hour ago")
        agent._nx.rollback.assert_called_with("1 hour ago")

    def test_audit(self) -> None:
        agent, _ = make_agent([
            ("OK", [], {"prompt_tokens": 5, "completion_tokens": 2}),
        ])
        agent.audit(limit=10)
        agent._nx.audit.assert_called_with(limit=10)

    def test_eval(self) -> None:
        agent, _ = make_agent([
            ("OK", [], {"prompt_tokens": 5, "completion_tokens": 2}),
        ])
        agent.eval()
        agent._nx.eval_run.assert_called_once()

    def test_recall_with_memories_injects_context(self) -> None:
        """When recall returns memories, they should be injected into the system prompt."""
        agent, llm = make_agent([
            ("I know your preferences.", [], {"prompt_tokens": 10, "completion_tokens": 5}),
        ])

        # Mock recall returning actual memories
        mock_mem = MagicMock()
        mock_mem.observation = "User prefers Python"
        mock_mem.score = 0.85
        agent._nx.recall.return_value = MagicMock(memories=[mock_mem])

        result = agent.run("What do you know about me?")
        assert result.memories_recalled == 1

        # Check system message contains tiered memory context
        system_msg = llm.calls[0]["messages"][0]
        assert "User prefers Python" in system_msg.content
        assert "[strong]" in system_msg.content

    def test_recall_tags_passed(self) -> None:
        """Recall tags from MemoryConfig should be passed to the recall call."""
        agent, _ = make_agent(
            [("OK", [], {"prompt_tokens": 5, "completion_tokens": 2})],
            memory_config=MemoryConfig(recall_tags=["important", "user-pref"]),
        )

        agent.run("Hello")
        call_kwargs = agent._nx.recall.call_args
        assert call_kwargs[1]["tags"] == ["important", "user-pref"]

    def test_remember_tags_passed(self) -> None:
        """Remember tags from MemoryConfig should be passed to the remember call."""
        agent, _ = make_agent(
            [("Answer.", [], {"prompt_tokens": 5, "completion_tokens": 2})],
            memory_config=MemoryConfig(remember_tags=["conversation"]),
        )

        agent.run("Hello")
        call_kwargs = agent._nx.remember.call_args
        assert call_kwargs[1]["tags"] == ["conversation"]

    def test_remember_importance_passed(self) -> None:
        """Remember importance from MemoryConfig should be passed to the remember call."""
        agent, _ = make_agent(
            [("Answer.", [], {"prompt_tokens": 5, "completion_tokens": 2})],
            memory_config=MemoryConfig(remember_importance=8),
        )

        agent.run("Hello")
        call_kwargs = agent._nx.remember.call_args
        assert call_kwargs[1]["importance"] == 8

    def test_forget(self) -> None:
        """Agent.forget() should call through to Novyx."""
        agent, _ = make_agent([("OK", [], {})])
        agent.forget("mem_123")
        agent._nx.forget.assert_called_with("mem_123")

    def test_memories_list(self) -> None:
        """Agent.memories() should call through to Novyx."""
        agent, _ = make_agent([("OK", [], {})])
        agent.memories(limit=5)
        agent._nx.memories.assert_called_with(limit=5)

    def test_rollback_preview(self) -> None:
        """Agent.rollback_preview() should call through to Novyx."""
        agent, _ = make_agent([("OK", [], {})])
        agent.rollback_preview("30 minutes ago")
        agent._nx.rollback_preview.assert_called_with("30 minutes ago")

    def test_eval_gate(self) -> None:
        """Agent.eval_gate() should call through to Novyx."""
        agent, _ = make_agent([("OK", [], {})])
        agent.eval_gate(0.8)
        agent._nx.eval_gate.assert_called_with(0.8)

    def test_add_triple(self) -> None:
        """Agent.add_triple() should call through to Novyx."""
        agent, _ = make_agent([("OK", [], {})])
        agent.add_triple("Python", "is_a", "language")
        agent._nx.add_triple.assert_called_with("Python", "is_a", "language")

    def test_query_triples(self) -> None:
        """Agent.query_triples() should call through to Novyx."""
        agent, _ = make_agent([("OK", [], {})])
        agent.query_triples(subject="Python")
        agent._nx.query_triples.assert_called_with(subject="Python")


# ---------------------------------------------------------------------------
# Tests: Session management
# ---------------------------------------------------------------------------

class TestSession:
    def test_history_tracks_messages(self) -> None:
        agent, _ = make_agent([
            ("Response 1", [], {"prompt_tokens": 10, "completion_tokens": 5}),
            ("Response 2", [], {"prompt_tokens": 20, "completion_tokens": 10}),
        ])

        agent.run("First")
        agent.run("Second")
        assert len(agent.history) == 4  # 2 user + 2 assistant

    def test_clear_history(self) -> None:
        agent, _ = make_agent([
            ("Hi", [], {"prompt_tokens": 10, "completion_tokens": 5}),
        ])
        agent.run("Hello")
        assert len(agent.history) > 0
        agent.clear_history()
        assert len(agent.history) == 0

    def test_agent_repr(self) -> None:
        @tool
        def my_tool(x: str) -> str:
            """A tool."""
            return x

        agent, _ = make_agent(
            [("", [], {})],
            tools_list=[my_tool],
        )
        r = repr(agent)
        assert "TestAgent" in r
        assert "my_tool" in r

    def test_history_is_copy(self) -> None:
        """agent.history should return a copy, not the internal list."""
        agent, _ = make_agent([
            ("Hi", [], {"prompt_tokens": 10, "completion_tokens": 5}),
        ])
        agent.run("Hello")
        history = agent.history
        history.clear()  # Modify the copy
        assert len(agent.history) > 0  # Original unchanged

    def test_multi_turn_conversation(self) -> None:
        """History should accumulate across multiple runs."""
        agent, llm = make_agent([
            ("Hi!", [], {"prompt_tokens": 10, "completion_tokens": 5}),
            ("I'm good!", [], {"prompt_tokens": 20, "completion_tokens": 10}),
            ("Bye!", [], {"prompt_tokens": 30, "completion_tokens": 5}),
        ])

        agent.run("Hello")
        agent.run("How are you?")
        agent.run("Goodbye")

        # 3 user + 3 assistant = 6 messages in history
        assert len(agent.history) == 6

        # Third LLM call should include full conversation history
        third_call_msgs = llm.calls[2]["messages"]
        # system + 2 user + 2 assistant (history) + 1 user (current) = 6
        assert len(third_call_msgs) == 6

    def test_novyx_property(self) -> None:
        """agent.novyx should expose the underlying Novyx client."""
        agent, _ = make_agent([("OK", [], {})])
        assert agent.novyx is agent._nx

    def test_config_property(self) -> None:
        """agent.config should expose the agent configuration."""
        agent, _ = make_agent([("OK", [], {})])
        assert agent.config.name == "TestAgent"
        assert agent.config.model == "test-model"


# ---------------------------------------------------------------------------
# Tests: Agent tool decorator
# ---------------------------------------------------------------------------

class TestAgentToolDecorator:
    def test_register_via_decorator(self) -> None:
        agent, _ = make_agent([("OK", [], {})])

        @agent.tool
        def ping() -> str:
            """Ping."""
            return "pong"

        assert "ping" in agent._tools

    def test_register_undecorated_raises(self) -> None:
        agent, _ = make_agent([("OK", [], {})])

        def bare_fn() -> str:
            return "hi"

        with pytest.raises(ValueError, match="not decorated"):
            agent._register_tool(bare_fn)


# ---------------------------------------------------------------------------
# Tests: Types
# ---------------------------------------------------------------------------

class TestTypes:
    def test_run_result_defaults(self) -> None:
        r = RunResult(output="hello")
        assert r.memories_recalled == 0
        assert r.memories_stored == 0
        assert r.tool_calls == []

    def test_message_creation(self) -> None:
        m = Message(role=Role.USER, content="Hello")
        assert m.role == Role.USER
        assert m.content == "Hello"

    def test_memory_config_defaults(self) -> None:
        cfg = MemoryConfig()
        assert cfg.auto_recall is True
        assert cfg.auto_remember is True
        assert cfg.recall_limit == 5
        assert cfg.recall_threshold == 0.3


# ---------------------------------------------------------------------------
# Tests: Docstring parser
# ---------------------------------------------------------------------------

class TestDocstringParser:
    def test_google_style_basic(self) -> None:
        from novyx_agent.tools import _parse_docstring_params

        doc = """Search the web.

        Args:
            query: The search query
            limit: Max results
        """
        params = _parse_docstring_params(doc)
        assert params["query"] == "The search query"
        assert params["limit"] == "Max results"

    def test_google_style_with_types(self) -> None:
        from novyx_agent.tools import _parse_docstring_params

        doc = """Do something.

        Args:
            name (str): The user's name
            age (int): The user's age
        """
        params = _parse_docstring_params(doc)
        assert params["name"] == "The user's name"
        assert params["age"] == "The user's age"

    def test_empty_docstring(self) -> None:
        from novyx_agent.tools import _parse_docstring_params

        assert _parse_docstring_params("") == {}
        assert _parse_docstring_params("No args section here.") == {}

    def test_multiline_description(self) -> None:
        from novyx_agent.tools import _parse_docstring_params

        doc = """Do something.

        Args:
            query: The search query to use
                for finding relevant results
            limit: Max results
        """
        params = _parse_docstring_params(doc)
        assert "finding relevant results" in params["query"]
        assert params["limit"] == "Max results"


# ---------------------------------------------------------------------------
# Tests: Provider factory
# ---------------------------------------------------------------------------

class TestProviderFactory:
    def test_unknown_provider_raises(self) -> None:
        from novyx_agent.providers import get_provider
        with pytest.raises(ValueError, match="Unknown provider"):
            get_provider("nonexistent")

    def test_unknown_async_provider_raises(self) -> None:
        from novyx_agent.providers import get_async_provider
        with pytest.raises(ValueError, match="Unknown provider"):
            get_async_provider("nonexistent")


# ---------------------------------------------------------------------------
# Tests: AsyncAgent
# ---------------------------------------------------------------------------

class TestAsyncAgentBasics:
    @pytest.mark.asyncio
    async def test_simple_run(self) -> None:
        agent, llm = make_async_agent([
            ("Hello async!", [], {"prompt_tokens": 10, "completion_tokens": 5}),
        ])

        result = await agent.run("Hi there")
        assert isinstance(result, RunResult)
        assert result.output == "Hello async!"
        assert result.model == "test-model"
        assert len(llm.calls) == 1

    @pytest.mark.asyncio
    async def test_tool_execution(self) -> None:
        @tool
        def get_time() -> str:
            """Get current time."""
            return "3:00 PM"

        agent, _ = make_async_agent(
            [
                ("", [{"id": "c1", "name": "get_time", "arguments": {}}], {"prompt_tokens": 10, "completion_tokens": 5}),
                ("It's 3:00 PM.", [], {"prompt_tokens": 20, "completion_tokens": 10}),
            ],
            tools_list=[get_time],
        )

        result = await agent.run("What time is it?")
        assert result.output == "It's 3:00 PM."
        assert len(result.tool_calls) == 1
        assert result.tool_calls[0].result == "3:00 PM"

    @pytest.mark.asyncio
    async def test_auto_recall(self) -> None:
        agent, _ = make_async_agent([
            ("I recall.", [], {"prompt_tokens": 10, "completion_tokens": 5}),
        ])

        await agent.run("What do you remember?")
        agent._nx.recall.assert_called_once()

    @pytest.mark.asyncio
    async def test_auto_recall_disabled(self) -> None:
        agent, _ = make_async_agent(
            [("OK", [], {"prompt_tokens": 5, "completion_tokens": 2})],
            memory_config=MemoryConfig(auto_recall=False),
        )

        await agent.run("Hello")
        agent._nx.recall.assert_not_called()

    @pytest.mark.asyncio
    async def test_auto_remember(self) -> None:
        agent, _ = make_async_agent([
            ("Important.", [], {"prompt_tokens": 10, "completion_tokens": 5}),
        ])

        await agent.run("Tell me something")
        agent._nx.remember.assert_called_once()

    @pytest.mark.asyncio
    async def test_auto_remember_disabled(self) -> None:
        agent, _ = make_async_agent(
            [("OK", [], {"prompt_tokens": 5, "completion_tokens": 2})],
            memory_config=MemoryConfig(auto_remember=False),
        )

        await agent.run("Hello")
        agent._nx.remember.assert_not_called()

    @pytest.mark.asyncio
    async def test_recall_failure_resilient(self) -> None:
        agent, _ = make_async_agent([
            ("Still works!", [], {"prompt_tokens": 10, "completion_tokens": 5}),
        ])

        async def fail_recall(*a: Any, **kw: Any) -> Any:
            raise Exception("Network error")

        agent._nx.recall = fail_recall

        result = await agent.run("Hello")
        assert result.output == "Still works!"

    @pytest.mark.asyncio
    async def test_remember_failure_resilient(self) -> None:
        agent, _ = make_async_agent([
            ("Still works!", [], {"prompt_tokens": 10, "completion_tokens": 5}),
        ])

        async def fail_remember(*a: Any, **kw: Any) -> Any:
            raise Exception("Network error")

        agent._nx.remember = fail_remember

        result = await agent.run("Hello")
        assert result.output == "Still works!"

    @pytest.mark.asyncio
    async def test_max_tool_rounds(self) -> None:
        agent, _ = make_async_agent(
            [("", [{"id": f"c{i}", "name": "x", "arguments": {}}], {"prompt_tokens": 1, "completion_tokens": 1}) for i in range(15)],
        )
        agent._config.max_tool_rounds = 3
        result = await agent.run("Loop forever")
        assert "maximum" in result.output.lower()

    @pytest.mark.asyncio
    async def test_history_tracks(self) -> None:
        agent, _ = make_async_agent([
            ("R1", [], {"prompt_tokens": 5, "completion_tokens": 2}),
            ("R2", [], {"prompt_tokens": 10, "completion_tokens": 5}),
        ])

        await agent.run("First")
        await agent.run("Second")
        assert len(agent.history) == 4

    @pytest.mark.asyncio
    async def test_clear_history(self) -> None:
        agent, _ = make_async_agent([
            ("Hi", [], {"prompt_tokens": 5, "completion_tokens": 2}),
        ])
        await agent.run("Hello")
        assert len(agent.history) > 0
        agent.clear_history()
        assert len(agent.history) == 0

    def test_async_agent_repr(self) -> None:
        agent, _ = make_async_agent([("", [], {})])
        r = repr(agent)
        assert "AsyncAgent" in r
        assert "TestAsyncAgent" in r

    def test_no_reset_method(self) -> None:
        """AsyncAgent should not have a reset() method."""
        agent, _ = make_async_agent([("OK", [], {})])
        assert not hasattr(agent, "reset")


class TestAsyncAgentMemoryOps:
    @pytest.mark.asyncio
    async def test_explicit_remember(self) -> None:
        agent, _ = make_async_agent([("OK", [], {})])
        await agent.remember("User likes async")
        agent._nx.remember.assert_called_with("User likes async")

    @pytest.mark.asyncio
    async def test_explicit_recall(self) -> None:
        agent, _ = make_async_agent([("OK", [], {})])
        await agent.recall("preferences")
        agent._nx.recall.assert_called_with("preferences")

    @pytest.mark.asyncio
    async def test_forget(self) -> None:
        agent, _ = make_async_agent([("OK", [], {})])
        await agent.forget("mem_123")
        agent._nx.forget.assert_called_with("mem_123")

    @pytest.mark.asyncio
    async def test_memories(self) -> None:
        agent, _ = make_async_agent([("OK", [], {})])
        await agent.memories(limit=10)
        agent._nx.memories.assert_called_with(limit=10)

    @pytest.mark.asyncio
    async def test_rollback(self) -> None:
        agent, _ = make_async_agent([("OK", [], {})])
        await agent.rollback("1 hour ago")
        agent._nx.rollback.assert_called_with("1 hour ago")

    @pytest.mark.asyncio
    async def test_rollback_preview(self) -> None:
        agent, _ = make_async_agent([("OK", [], {})])
        await agent.rollback_preview("30 min ago")
        agent._nx.rollback_preview.assert_called_with("30 min ago")

    @pytest.mark.asyncio
    async def test_audit(self) -> None:
        agent, _ = make_async_agent([("OK", [], {})])
        await agent.audit(limit=5)
        agent._nx.audit.assert_called_with(limit=5)

    @pytest.mark.asyncio
    async def test_eval(self) -> None:
        agent, _ = make_async_agent([("OK", [], {})])
        await agent.eval()
        agent._nx.eval_run.assert_called_once()

    @pytest.mark.asyncio
    async def test_eval_gate(self) -> None:
        agent, _ = make_async_agent([("OK", [], {})])
        await agent.eval_gate(0.8)
        agent._nx.eval_gate.assert_called_with(0.8)

    @pytest.mark.asyncio
    async def test_add_triple(self) -> None:
        agent, _ = make_async_agent([("OK", [], {})])
        await agent.add_triple("Python", "is_a", "language")
        agent._nx.add_triple.assert_called_with("Python", "is_a", "language")

    @pytest.mark.asyncio
    async def test_query_triples(self) -> None:
        agent, _ = make_async_agent([("OK", [], {})])
        await agent.query_triples(subject="Python")
        agent._nx.query_triples.assert_called_with(subject="Python")

    def test_novyx_property(self) -> None:
        agent, _ = make_async_agent([("OK", [], {})])
        assert agent.novyx is agent._nx


# ---------------------------------------------------------------------------
# Tiered memory formatting
# ---------------------------------------------------------------------------


class TestTieredMemoryFormatting:
    """Tests for _format_tiered_memories — the Karpathy fix."""

    def _make_mem(self, observation: str, score: float) -> MagicMock:
        m = MagicMock()
        m.observation = observation
        m.score = score
        return m

    def test_strong_tier(self) -> None:
        from novyx_agent.agent import _format_tiered_memories
        result = _format_tiered_memories([self._make_mem("Important fact", 0.85)])
        assert "[strong]" in result
        assert "Important fact" in result
        assert "Core context" in result

    def test_moderate_tier(self) -> None:
        from novyx_agent.agent import _format_tiered_memories
        result = _format_tiered_memories([self._make_mem("Somewhat relevant", 0.50)])
        assert "[moderate]" in result
        assert "Supplementary" in result

    def test_background_tier(self) -> None:
        from novyx_agent.agent import _format_tiered_memories
        result = _format_tiered_memories([self._make_mem("Old question", 0.30)])
        assert "[background]" in result
        assert "may not be relevant" in result

    def test_boundary_strong(self) -> None:
        """Score exactly at 0.65 should be strong."""
        from novyx_agent.agent import _format_tiered_memories
        result = _format_tiered_memories([self._make_mem("Boundary", 0.65)])
        assert "[strong]" in result

    def test_boundary_moderate(self) -> None:
        """Score exactly at 0.45 should be moderate."""
        from novyx_agent.agent import _format_tiered_memories
        result = _format_tiered_memories([self._make_mem("Boundary", 0.45)])
        assert "[moderate]" in result

    def test_boundary_below_moderate(self) -> None:
        """Score just below 0.45 should be background."""
        from novyx_agent.agent import _format_tiered_memories
        result = _format_tiered_memories([self._make_mem("Boundary", 0.44)])
        assert "[background]" in result

    def test_background_capped_at_two(self) -> None:
        """Only the top 2 background memories should be included."""
        from novyx_agent.agent import _format_tiered_memories
        mems = [
            self._make_mem("BG1", 0.30),
            self._make_mem("BG2", 0.25),
            self._make_mem("BG3", 0.20),
            self._make_mem("BG4", 0.10),
        ]
        result = _format_tiered_memories(mems)
        assert "BG1" in result
        assert "BG2" in result
        assert "BG3" not in result
        assert "BG4" not in result

    def test_mixed_tiers(self) -> None:
        """Multiple tiers in one recall result."""
        from novyx_agent.agent import _format_tiered_memories
        mems = [
            self._make_mem("Core fact", 0.90),
            self._make_mem("Supplementary info", 0.55),
            self._make_mem("Old noise", 0.20),
        ]
        result = _format_tiered_memories(mems)
        assert "[strong]" in result
        assert "[moderate]" in result
        assert "[background]" in result
        assert "Core context" in result
        assert "Supplementary context" in result

    def test_empty_returns_empty(self) -> None:
        from novyx_agent.agent import _format_tiered_memories
        assert _format_tiered_memories([]) == ""

    def test_no_score_attribute_defaults_background(self) -> None:
        """Memory with no score attr should default to background."""
        from novyx_agent.agent import _format_tiered_memories
        m = MagicMock(spec=[])
        m.observation = "No score"
        m.score = None
        result = _format_tiered_memories([m])
        assert "[background]" in result

    @pytest.mark.asyncio
    async def test_async_agent_uses_tiered_formatting(self) -> None:
        """AsyncAgent should produce the same tiered format as sync Agent."""
        agent, llm = make_async_agent([
            ("I recall your preferences.", [], {"prompt_tokens": 10, "completion_tokens": 5}),
        ])

        mock_mem = MagicMock()
        mock_mem.observation = "User likes TypeScript"
        mock_mem.score = 0.75
        agent._nx.recall = AsyncMock(return_value=MagicMock(memories=[mock_mem]))

        result = await agent.run("What languages do I like?")
        assert result.memories_recalled == 1

        system_msg = llm.calls[0]["messages"][0]
        assert "[strong]" in system_msg.content
        assert "User likes TypeScript" in system_msg.content
