"""
Testy dla agent0/client.py
"""

import asyncio
import json
import sys
import os
import pytest
import pytest_asyncio
from unittest.mock import AsyncMock, MagicMock, patch, call

# Dodaj agent0 do ścieżki
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))

import client
from client import _is_connection_error, mcp_tool_to_anthropic, load_config


# ---------------------------------------------------------------------------
# Testy _is_connection_error
# ---------------------------------------------------------------------------

class TestIsConnectionError:

    def test_ConnectionRefusedError(self):
        assert _is_connection_error(ConnectionRefusedError("refused")) is True

    def test_ConnectionResetError(self):
        assert _is_connection_error(ConnectionResetError("reset")) is True

    def test_EOFError(self):
        assert _is_connection_error(EOFError("eof")) is True

    def test_exception_with_EOF_in_message(self):
        exc = RuntimeError("EOF reached")
        assert _is_connection_error(exc) is True

    def test_exception_with_Connection_in_message(self):
        exc = RuntimeError("Connection closed by peer")
        assert _is_connection_error(exc) is True

    def test_class_with_BrokenPipe_in_name(self):
        class BrokenPipeTransport(Exception):
            pass
        assert _is_connection_error(BrokenPipeTransport("x")) is True

    def test_class_with_Transport_in_name(self):
        class TransportError(Exception):
            pass
        assert _is_connection_error(TransportError("x")) is True

    def test_class_with_ReadError_in_name(self):
        class ReadError(Exception):
            pass
        assert _is_connection_error(ReadError("x")) is True

    def test_class_with_WriteError_in_name(self):
        class WriteError(Exception):
            pass
        assert _is_connection_error(WriteError("x")) is True

    def test_class_with_EndOfStream_in_name(self):
        class EndOfStream(Exception):
            pass
        assert _is_connection_error(EndOfStream("x")) is True

    def test_class_with_RemoteProtocol_in_name(self):
        class RemoteProtocolError(Exception):
            pass
        assert _is_connection_error(RemoteProtocolError("x")) is True

    def test_regular_exception_is_not_connection_error(self):
        assert _is_connection_error(ValueError("invalid input")) is False

    def test_runtime_error_without_keywords(self):
        assert _is_connection_error(RuntimeError("something went wrong")) is False

    def test_key_error(self):
        assert _is_connection_error(KeyError("missing_key")) is False

    def test_type_error(self):
        assert _is_connection_error(TypeError("wrong type")) is False


# ---------------------------------------------------------------------------
# Testy mcp_tool_to_anthropic
# ---------------------------------------------------------------------------

class TestMcpToolToAnthropic:

    def _make_tool(self, name, description, schema):
        tool = MagicMock()
        tool.name = name
        tool.description = description
        tool.inputSchema = schema
        return tool

    def test_basic_conversion(self):
        schema = {"type": "object", "properties": {}}
        tool = self._make_tool("my_tool", "does stuff", schema)
        result = mcp_tool_to_anthropic(tool)
        assert result == {
            "name": "my_tool",
            "description": "does stuff",
            "input_schema": schema,
        }

    def test_none_description_becomes_empty_string(self):
        tool = self._make_tool("t", None, {})
        result = mcp_tool_to_anthropic(tool)
        assert result["description"] == ""

    def test_empty_description(self):
        tool = self._make_tool("t", "", {})
        result = mcp_tool_to_anthropic(tool)
        assert result["description"] == ""


# ---------------------------------------------------------------------------
# Testy load_config
# ---------------------------------------------------------------------------

class TestLoadConfig:

    def test_loads_json(self, tmp_path):
        cfg = {"anthropic_api_key": "sk-test", "gateway_url": "http://x/mcp/"}
        f = tmp_path / "config.json"
        f.write_text(json.dumps(cfg))
        with patch.object(client, "CONFIG_PATH", str(f)):
            result = load_config()
        assert result == cfg

    def test_missing_file_raises(self, tmp_path):
        with patch.object(client, "CONFIG_PATH", str(tmp_path / "missing.json")):
            with pytest.raises(FileNotFoundError):
                load_config()


# ---------------------------------------------------------------------------
# Pomocniki do testów run()
# ---------------------------------------------------------------------------

def _make_fake_tool(name="echo", description="echoes"):
    t = MagicMock()
    t.name = name
    t.description = description
    t.inputSchema = {"type": "object", "properties": {}}
    return t


def _make_text_response(text="Hello"):
    """Tworzy mock odpowiedzi Anthropic bez tool_use."""
    block = MagicMock()
    block.text = text
    block.type = "text"
    resp = MagicMock()
    resp.content = [block]
    resp.stop_reason = "end_turn"
    return resp


def _make_tool_response(name="echo", input_data=None, tool_id="id1"):
    """Tworzy mock odpowiedzi Anthropic z tool_use."""
    block = MagicMock()
    block.type = "tool_use"
    block.name = name
    block.input = input_data or {}
    block.id = tool_id
    resp = MagicMock()
    resp.content = [block]
    resp.stop_reason = "tool_use"
    return resp


def _config_patch(extra=None):
    """Patch load_config z poprawnym kluczem."""
    cfg = {
        "anthropic_api_key": "sk-ant-test",
        "gateway_url": "http://127.0.0.1:8000/mcp/",
        "model": "claude-test",
    }
    if extra:
        cfg.update(extra)
    return patch.object(client, "load_config", return_value=cfg)


# ---------------------------------------------------------------------------
# Testy run() — scenariusze wysokopoziomowe
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_run_exits_on_eoferror():
    """EOFError w input() powinien zakończyć run() bez wyjątku."""
    fake_tool = _make_fake_tool()
    fake_tools_result = MagicMock()
    fake_tools_result.tools = [fake_tool]

    session_mock = AsyncMock()
    session_mock.initialize = AsyncMock()
    session_mock.list_tools = AsyncMock(return_value=fake_tools_result)
    session_mock._received_notification = AsyncMock()

    with _config_patch(), \
         patch("client.anthropic.AsyncAnthropic"), \
         patch("client.streamable_http_client") as mock_http, \
         patch("asyncio.get_event_loop") as mock_loop:

        # streamable_http_client jako async context manager
        read_mock, write_mock = AsyncMock(), AsyncMock()
        mock_http.return_value.__aenter__ = AsyncMock(return_value=(read_mock, write_mock, None))
        mock_http.return_value.__aexit__ = AsyncMock(return_value=False)

        # ClientSession jako async context manager
        with patch("client.ClientSession") as mock_session_cls:
            mock_session_cls.return_value.__aenter__ = AsyncMock(return_value=session_mock)
            mock_session_cls.return_value.__aexit__ = AsyncMock(return_value=False)

            # input() rzuca EOFError
            loop_mock = MagicMock()
            mock_loop.return_value = loop_mock
            loop_mock.run_in_executor = AsyncMock(side_effect=EOFError())

            await client.run()  # nie powinno rzucić wyjątku


@pytest.mark.asyncio
async def test_run_exits_on_keyboard_interrupt_in_input():
    """KeyboardInterrupt w input() kończy run() czysto."""
    fake_tools_result = MagicMock()
    fake_tools_result.tools = [_make_fake_tool()]

    session_mock = AsyncMock()
    session_mock.initialize = AsyncMock()
    session_mock.list_tools = AsyncMock(return_value=fake_tools_result)
    session_mock._received_notification = AsyncMock()

    with _config_patch(), \
         patch("client.anthropic.AsyncAnthropic"), \
         patch("client.streamable_http_client") as mock_http, \
         patch("asyncio.get_event_loop") as mock_loop:

        mock_http.return_value.__aenter__ = AsyncMock(return_value=(AsyncMock(), AsyncMock(), None))
        mock_http.return_value.__aexit__ = AsyncMock(return_value=False)

        with patch("client.ClientSession") as mock_session_cls:
            mock_session_cls.return_value.__aenter__ = AsyncMock(return_value=session_mock)
            mock_session_cls.return_value.__aexit__ = AsyncMock(return_value=False)

            loop_mock = MagicMock()
            mock_loop.return_value = loop_mock
            loop_mock.run_in_executor = AsyncMock(side_effect=KeyboardInterrupt())

            await client.run()


@pytest.mark.asyncio
async def test_run_sends_message_to_anthropic():
    """Wiadomość użytkownika trafia do Anthropic i odpowiedź jest wypisana."""
    fake_tools_result = MagicMock()
    fake_tools_result.tools = [_make_fake_tool()]

    session_mock = AsyncMock()
    session_mock.initialize = AsyncMock()
    session_mock.list_tools = AsyncMock(return_value=fake_tools_result)
    session_mock._received_notification = AsyncMock()

    text_resp = _make_text_response("Cześć!")
    anthropic_mock = MagicMock()
    # Pierwsza odpowiedź — tekst, potem EOFError kończy pętlę
    anthropic_mock.messages.create = AsyncMock(return_value=text_resp)

    call_count = 0

    async def fake_executor(executor, fn):
        nonlocal call_count
        call_count += 1
        if call_count == 1:
            return "Hej Claude"
        raise EOFError()

    with _config_patch(), \
         patch("client.anthropic.AsyncAnthropic", return_value=anthropic_mock), \
         patch("client.streamable_http_client") as mock_http, \
         patch("asyncio.get_event_loop") as mock_loop:

        mock_http.return_value.__aenter__ = AsyncMock(return_value=(AsyncMock(), AsyncMock(), None))
        mock_http.return_value.__aexit__ = AsyncMock(return_value=False)

        with patch("client.ClientSession") as mock_session_cls:
            mock_session_cls.return_value.__aenter__ = AsyncMock(return_value=session_mock)
            mock_session_cls.return_value.__aexit__ = AsyncMock(return_value=False)

            loop_mock = MagicMock()
            mock_loop.return_value = loop_mock
            loop_mock.run_in_executor = fake_executor

            await client.run()

    anthropic_mock.messages.create.assert_called_once()
    call_args = anthropic_mock.messages.create.call_args
    messages = call_args.kwargs["messages"]
    assert messages[0] == {"role": "user", "content": "Hej Claude"}


@pytest.mark.asyncio
async def test_run_reconnects_on_connection_error():
    """Błąd połączenia powoduje reconnect, historia messages jest zachowana."""
    fake_tools_result = MagicMock()
    fake_tools_result.tools = [_make_fake_tool()]

    # Dwie sesje: pierwsza pada z błędem, druga kończy EOFError
    session_first = AsyncMock()
    session_first.initialize = AsyncMock()
    session_first.list_tools = AsyncMock(return_value=fake_tools_result)
    session_first._received_notification = AsyncMock()

    session_second = AsyncMock()
    session_second.initialize = AsyncMock()
    session_second.list_tools = AsyncMock(return_value=fake_tools_result)
    session_second._received_notification = AsyncMock()

    text_resp = _make_text_response("OK")
    anthropic_mock = MagicMock()
    anthropic_mock.messages.create = AsyncMock(return_value=text_resp)

    connect_count = 0
    input_count = 0

    class FakeConnectionError(Exception):
        pass

    async def fake_executor(executor, fn):
        nonlocal input_count
        input_count += 1
        if input_count == 1:
            return "wiadomość"  # pierwsza sesja: wysyłamy wiadomość
        raise EOFError()       # druga sesja: kończymy

    class FakeStreamableHTTP:
        def __init__(self):
            self._count = 0

        def __call__(self, url):
            return self

        async def __aenter__(self):
            nonlocal connect_count
            connect_count += 1
            return (AsyncMock(), AsyncMock(), None)

        async def __aexit__(self, *args):
            # Pierwsze połączenie pada z błędem połączenia PO wejściu
            if connect_count == 1:
                raise ConnectionResetError("reset")
            return False

    fake_http = FakeStreamableHTTP()
    sessions = [session_first, session_second]
    session_idx = 0

    class FakeClientSession:
        def __init__(self, *args, **kwargs):
            pass

        async def __aenter__(self):
            nonlocal session_idx
            s = sessions[session_idx]
            session_idx += 1
            return s

        async def __aexit__(self, *args):
            return False

    with _config_patch(), \
         patch("client.anthropic.AsyncAnthropic", return_value=anthropic_mock), \
         patch("client.streamable_http_client", fake_http), \
         patch("client.ClientSession", FakeClientSession), \
         patch("asyncio.get_event_loop") as mock_loop, \
         patch("asyncio.sleep", new_callable=AsyncMock):

        loop_mock = MagicMock()
        mock_loop.return_value = loop_mock
        loop_mock.run_in_executor = fake_executor

        await client.run()

    # Połączono dwa razy
    assert connect_count == 2
    # Wiadomość wysłana w pierwszej sesji
    anthropic_mock.messages.create.assert_called_once()


@pytest.mark.asyncio
async def test_run_non_connection_error_propagates():
    """Błąd nie-połączeniowy powinien się propagować (nie reconnectować)."""
    fake_tools_result = MagicMock()
    fake_tools_result.tools = [_make_fake_tool()]

    session_mock = AsyncMock()
    session_mock.initialize = AsyncMock()
    session_mock.list_tools = AsyncMock(return_value=fake_tools_result)
    session_mock._received_notification = AsyncMock()

    text_resp = _make_text_response("OK")
    anthropic_mock = MagicMock()
    anthropic_mock.messages.create = AsyncMock(return_value=text_resp)

    async def fake_executor(executor, fn):
        return "test"  # zawsze zwraca input

    with _config_patch(), \
         patch("client.anthropic.AsyncAnthropic", return_value=anthropic_mock), \
         patch("client.streamable_http_client") as mock_http, \
         patch("asyncio.get_event_loop") as mock_loop:

        # streamable_http powoduje ValueError (nie-połączeniowy)
        mock_http.return_value.__aenter__ = AsyncMock(side_effect=ValueError("logic error"))
        mock_http.return_value.__aexit__ = AsyncMock(return_value=False)

        with patch("client.ClientSession"):
            loop_mock = MagicMock()
            mock_loop.return_value = loop_mock
            loop_mock.run_in_executor = fake_executor

            with pytest.raises(ValueError, match="logic error"):
                await client.run()


@pytest.mark.asyncio
async def test_run_history_preserved_across_reconnect():
    """Historia messages jest zachowana po reconnect."""
    fake_tools_result = MagicMock()
    fake_tools_result.tools = [_make_fake_tool()]

    text_resp = _make_text_response("Odpowiedź")
    anthropic_mock = MagicMock()
    anthropic_mock.messages.create = AsyncMock(return_value=text_resp)

    input_count = 0
    connect_count = 0

    async def fake_executor(executor, fn):
        nonlocal input_count
        input_count += 1
        if input_count == 1:
            return "pierwsza wiadomość"
        raise EOFError()

    class FakeStreamableHTTP:
        def __call__(self, url):
            return self

        async def __aenter__(self):
            nonlocal connect_count
            connect_count += 1
            return (AsyncMock(), AsyncMock(), None)

        async def __aexit__(self, *args):
            if connect_count == 1:
                raise ConnectionResetError("reset")
            return False

    sessions_created = []

    class FakeSession:
        def __init__(self, *args, **kwargs):
            self.initialize = AsyncMock()
            self.list_tools = AsyncMock(return_value=fake_tools_result)
            self.call_tool = AsyncMock(return_value=MagicMock(content=[MagicMock(text="ok")]))
            self._received_notification = AsyncMock()
            sessions_created.append(self)

        async def __aenter__(self):
            return self

        async def __aexit__(self, *args):
            return False

    captured_messages = []

    async def capture_create(**kwargs):
        captured_messages.append(list(kwargs["messages"]))
        return text_resp

    anthropic_mock.messages.create = capture_create

    fake_http = FakeStreamableHTTP()

    with _config_patch(), \
         patch("client.anthropic.AsyncAnthropic", return_value=anthropic_mock), \
         patch("client.streamable_http_client", fake_http), \
         patch("client.ClientSession", FakeSession), \
         patch("asyncio.get_event_loop") as mock_loop, \
         patch("asyncio.sleep", new_callable=AsyncMock):

        loop_mock = MagicMock()
        mock_loop.return_value = loop_mock
        loop_mock.run_in_executor = fake_executor

        await client.run()

    # Wiadomość wysłana w sesji 1
    assert len(captured_messages) >= 1
    assert captured_messages[0][0]["role"] == "user"
    assert captured_messages[0][0]["content"] == "pierwsza wiadomość"


@pytest.mark.asyncio
async def test_run_tools_refreshed_on_notification():
    """Po tools_dirty=True lista narzędzi jest odświeżana."""
    tool_v1 = _make_fake_tool("tool_a", "v1")
    tool_v2a = _make_fake_tool("tool_a", "v1")
    tool_v2b = _make_fake_tool("tool_b", "new")

    tools_v1 = MagicMock()
    tools_v1.tools = [tool_v1]

    tools_v2 = MagicMock()
    tools_v2.tools = [tool_v2a, tool_v2b]

    list_tools_results = [tools_v1, tools_v1, tools_v2]
    list_tools_idx = 0

    text_resp = _make_text_response("OK")
    anthropic_mock = MagicMock()
    anthropic_mock.messages.create = AsyncMock(return_value=text_resp)

    saved_notification_handler = None
    input_count = 0

    async def fake_executor(executor, fn):
        nonlocal input_count
        input_count += 1
        if input_count == 1:
            # Wyzwól notyfikację po pierwszym inpucie (przed zwróceniem)
            from mcp.types import ToolListChangedNotification, ServerNotification
            notif = MagicMock()
            notif.root = MagicMock(spec=ToolListChangedNotification)
            if saved_notification_handler:
                await saved_notification_handler(notif)
            return "coś"
        raise EOFError()

    class FakeSession:
        def __init__(self, *args, **kwargs):
            self.initialize = AsyncMock()
            self.call_tool = AsyncMock(return_value=MagicMock(content=[MagicMock(text="ok")]))
            self._received_notification = AsyncMock()
            self._list_calls = 0

        async def list_tools(self):
            nonlocal list_tools_idx
            r = list_tools_results[min(list_tools_idx, len(list_tools_results) - 1)]
            list_tools_idx += 1
            return r

        async def __aenter__(self):
            return self

        async def __aexit__(self, *args):
            return False

    sessions_created = []

    class FakeSessionCls:
        def __new__(cls, *args, **kwargs):
            s = FakeSession()
            sessions_created.append(s)
            return s

    with _config_patch(), \
         patch("client.anthropic.AsyncAnthropic", return_value=anthropic_mock), \
         patch("client.streamable_http_client") as mock_http, \
         patch("client.ClientSession", FakeSessionCls), \
         patch("asyncio.get_event_loop") as mock_loop:

        mock_http.return_value.__aenter__ = AsyncMock(return_value=(AsyncMock(), AsyncMock(), None))
        mock_http.return_value.__aexit__ = AsyncMock(return_value=False)

        loop_mock = MagicMock()
        mock_loop.return_value = loop_mock

        async def capturing_executor(executor, fn):
            nonlocal saved_notification_handler
            if sessions_created:
                saved_notification_handler = sessions_created[0]._received_notification
                # pobierz handler ustawiony przez run()
                saved_notification_handler = sessions_created[0].__dict__.get(
                    "_received_notification", AsyncMock()
                )
            return await fake_executor(executor, fn)

        loop_mock.run_in_executor = capturing_executor

        await client.run()

    # list_tools powinno zostać wywołane co najmniej raz przy inicjalizacji
    assert list_tools_idx >= 1


@pytest.mark.asyncio
async def test_run_invalid_api_key_exits(capsys):
    """Brak/niepoprawny klucz API kończy się sys.exit(1)."""
    with patch.object(client, "load_config", return_value={
        "anthropic_api_key": "sk-...",
        "gateway_url": "http://x/mcp/",
    }):
        with pytest.raises(SystemExit) as exc_info:
            await client.run()
        assert exc_info.value.code == 1
