"""
This program has been developed by students from the bachelor Computer Science at Utrecht
University within the Software Project course.
© Copyright Utrecht University (Department of Information and Computing Sciences)
"""

import asyncio
from unittest.mock import ANY, AsyncMock, MagicMock, patch

import pytest

from control_backend.agents.communication.ri_communication_agent import RICommunicationAgent


def speech_agent_path():
    return "control_backend.agents.communication.ri_communication_agent.RobotSpeechAgent"


def gesture_agent_path():
    return "control_backend.agents.communication.ri_communication_agent.RobotGestureAgent"


@pytest.fixture
def zmq_context(mocker):
    mock_context = mocker.patch(
        "control_backend.agents.communication.ri_communication_agent.Context.instance"
    )
    mock_context.return_value = MagicMock()
    return mock_context


def negotiation_message(
    actuation_port: int = 5556,
    bind_main: bool = False,
    bind_actuation: bool = False,
    main_port: int = 5555,
):
    return {
        "endpoint": "negotiate/ports",
        "data": [
            {"id": "main", "port": main_port, "bind": bind_main},
            {"id": "actuation", "port": actuation_port, "bind": bind_actuation},
        ],
    }


@pytest.mark.asyncio
async def test_setup_success_connects_and_starts_robot(zmq_context):
    fake_socket = zmq_context.return_value.socket.return_value
    fake_socket.send_json = AsyncMock()
    fake_socket.recv_json = AsyncMock(return_value=negotiation_message())
    fake_socket.send_multipart = AsyncMock()

    with (
        patch(speech_agent_path(), autospec=True) as MockSpeech,
        patch(gesture_agent_path(), autospec=True) as MockGesture,
    ):
        MockSpeech.return_value.start = AsyncMock()
        MockGesture.return_value.start = AsyncMock()
        agent = RICommunicationAgent("ri_comm", address="tcp://localhost:5555", bind=False)

        def close_coro(coro):
            coro.close()
            return MagicMock()

        agent.add_behavior = MagicMock(side_effect=close_coro)

        await agent.setup()

    fake_socket.connect.assert_any_call("tcp://localhost:5555")
    fake_socket.send_json.assert_any_call({"endpoint": "negotiate/ports", "data": {}})
    MockSpeech.return_value.start.assert_awaited_once()
    MockGesture.return_value.start.assert_awaited_once()
    MockSpeech.assert_called_once_with(ANY, address="tcp://localhost:5556", bind=False)
    MockGesture.assert_called_once_with(
        ANY,
        address="tcp://localhost:5556",
        bind=False,
        gesture_data=[],
        single_gesture_data=[],
    )
    agent.add_behavior.assert_called_once()

    assert agent.connected is True


@pytest.mark.asyncio
async def test_setup_binds_when_requested(zmq_context):
    fake_socket = zmq_context.return_value.socket.return_value
    fake_socket.send_json = AsyncMock()
    fake_socket.recv_json = AsyncMock(return_value=negotiation_message(bind_main=True))
    fake_socket.send_multipart = AsyncMock()

    agent = RICommunicationAgent("ri_comm", address="tcp://localhost:5555", bind=True)

    def close_coro(coro):
        coro.close()
        return MagicMock()

    agent.add_behavior = MagicMock(side_effect=close_coro)

    with (
        patch(speech_agent_path(), autospec=True) as MockSpeech,
        patch(gesture_agent_path(), autospec=True) as MockGesture,
    ):
        MockSpeech.return_value.start = AsyncMock()
        MockGesture.return_value.start = AsyncMock()
        await agent.setup()
    fake_socket.bind.assert_any_call("tcp://localhost:5555")
    agent.add_behavior.assert_called_once()


@pytest.mark.asyncio
async def test_negotiate_invalid_endpoint_retries(zmq_context):
    fake_socket = zmq_context.return_value.socket.return_value
    fake_socket.send_json = AsyncMock()
    fake_socket.recv_json = AsyncMock(return_value={"endpoint": "ping", "data": {}})
    fake_socket.send_multipart = AsyncMock()

    agent = RICommunicationAgent("ri_comm", address="tcp://localhost:5555", bind=False)
    agent._req_socket = fake_socket

    success = await agent._negotiate_connection(max_retries=1)
    assert success is False


@pytest.mark.asyncio
async def test_negotiate_timeout(zmq_context):
    fake_socket = zmq_context.return_value.socket.return_value
    fake_socket.send_json = AsyncMock()
    fake_socket.recv_json = AsyncMock(side_effect=asyncio.TimeoutError)
    fake_socket.send_multipart = AsyncMock()

    agent = RICommunicationAgent("ri_comm", address="tcp://localhost:5555", bind=False)
    agent._req_socket = fake_socket

    success = await agent._negotiate_connection(max_retries=1)

    assert success is False


@pytest.mark.asyncio
async def test_handle_negotiation_response_updates_req_socket(zmq_context):
    fake_socket = zmq_context.return_value.socket.return_value
    agent = RICommunicationAgent("ri_comm", address="tcp://localhost:5555", bind=False)
    agent._req_socket = fake_socket
    with (
        patch(speech_agent_path(), autospec=True) as MockSpeech,
        patch(gesture_agent_path(), autospec=True) as MockGesture,
    ):
        MockSpeech.return_value.start = AsyncMock()
        MockGesture.return_value.start = AsyncMock()
        await agent._handle_negotiation_response(
            negotiation_message(
                main_port=6000,
                actuation_port=6001,
                bind_main=False,
                bind_actuation=False,
            )
        )

    fake_socket.connect.assert_any_call("tcp://localhost:6000")


@pytest.mark.asyncio
async def test_handle_disconnection_publishes_and_reconnects():
    pub_socket = AsyncMock()
    pub_socket.close = MagicMock()
    agent = RICommunicationAgent("ri_comm")
    agent.pub_socket = pub_socket
    agent.connected = True
    agent._negotiate_connection = AsyncMock(return_value=True)

    await agent._handle_disconnection()
    pub_socket.send_multipart.assert_awaited()
    assert agent.connected is True


@pytest.mark.asyncio
async def test_listen_loop_handles_non_ping(zmq_context):
    fake_socket = zmq_context.return_value.socket.return_value
    fake_socket.send_json = AsyncMock()

    async def recv_once():
        agent._running = False
        return {"endpoint": "negotiate/ports", "data": {}}

    fake_socket.recv_json = recv_once
    agent = RICommunicationAgent("ri_comm")
    agent._req_socket = fake_socket
    agent.pub_socket = AsyncMock()
    agent.connected = True
    agent._running = True

    await agent._listen_loop()

    fake_socket.send_json.assert_called()


@pytest.mark.asyncio
async def test_negotiate_unexpected_error(zmq_context):
    fake_socket = zmq_context.return_value.socket.return_value
    fake_socket.send_json = AsyncMock()
    fake_socket.recv_json = AsyncMock(side_effect=Exception("boom"))
    agent = RICommunicationAgent("ri_comm")
    agent._req_socket = fake_socket

    assert await agent._negotiate_connection(max_retries=1) is False


@pytest.mark.asyncio
async def test_negotiate_handle_response_error(zmq_context):
    fake_socket = zmq_context.return_value.socket.return_value
    fake_socket.send_json = AsyncMock()
    fake_socket.recv_json = AsyncMock(return_value=negotiation_message())

    agent = RICommunicationAgent("ri_comm")
    agent._req_socket = fake_socket
    agent._handle_negotiation_response = AsyncMock(side_effect=Exception("bad response"))

    assert await agent._negotiate_connection(max_retries=1) is False


@pytest.mark.asyncio
async def test_setup_warns_on_failed_negotiate(zmq_context, mocker):
    fake_socket = zmq_context.return_value.socket.return_value
    fake_socket.send_json = AsyncMock()
    fake_socket.recv_json = AsyncMock()
    agent = RICommunicationAgent("ri_comm")

    def swallow(coro):
        coro.close()

    agent.add_behavior = swallow
    agent._negotiate_connection = AsyncMock(return_value=False)

    await agent.setup()

    assert agent.connected is False


@pytest.mark.asyncio
async def test_handle_negotiation_response_unhandled_id():
    agent = RICommunicationAgent("ri_comm")

    await agent._handle_negotiation_response(
        {"data": [{"id": "other", "port": 5000, "bind": False}]}
    )


@pytest.mark.asyncio
async def test_handle_negotiation_response_audio(zmq_context):
    agent = RICommunicationAgent("ri_comm")

    with patch(
        "control_backend.agents.communication.ri_communication_agent.VADAgent", autospec=True
    ) as MockVAD:
        MockVAD.return_value.start = AsyncMock()

        await agent._handle_negotiation_response(
            {"data": [{"id": "audio", "port": 7000, "bind": False}]}
        )

        MockVAD.assert_called_once_with(
            audio_in_address="tcp://localhost:7000", audio_in_bind=False
        )
        MockVAD.return_value.start.assert_awaited_once()


@pytest.mark.asyncio
async def test_stop_closes_sockets():
    req = MagicMock()
    pub = MagicMock()
    agent = RICommunicationAgent("ri_comm")
    agent._req_socket = req
    agent.pub_socket = pub

    await agent.stop()

    req.close.assert_called_once()
    pub.close.assert_called_once()


@pytest.mark.asyncio
async def test_listen_loop_not_connected(monkeypatch):
    agent = RICommunicationAgent("ri_comm")
    agent._running = True
    agent.connected = False
    agent._req_socket = AsyncMock()

    async def fake_sleep(duration):
        agent._running = False

    monkeypatch.setattr("asyncio.sleep", fake_sleep)

    await agent._listen_loop()


@pytest.mark.asyncio
async def test_listen_loop_send_and_recv_timeout():
    req = AsyncMock()
    req.send_json = AsyncMock(side_effect=TimeoutError)
    req.recv_json = AsyncMock(side_effect=TimeoutError)

    agent = RICommunicationAgent("ri_comm")
    agent._req_socket = req
    agent.pub_socket = AsyncMock()
    agent.connected = True
    agent._running = True

    async def stop_run():
        agent._running = False

    agent._handle_disconnection = AsyncMock(side_effect=stop_run)

    await agent._listen_loop()

    agent._handle_disconnection.assert_awaited()


@pytest.mark.asyncio
async def test_listen_loop_missing_endpoint(monkeypatch):
    req = AsyncMock()
    req.send_json = AsyncMock()

    async def recv_once():
        agent._running = False
        return {"data": {}}

    req.recv_json = recv_once

    agent = RICommunicationAgent("ri_comm")
    agent._req_socket = req
    agent.pub_socket = AsyncMock()
    agent.connected = True
    agent._running = True

    await agent._listen_loop()


@pytest.mark.asyncio
async def test_listen_loop_generic_exception():
    req = AsyncMock()
    req.send_json = AsyncMock()
    req.recv_json = AsyncMock(side_effect=ValueError("boom"))

    agent = RICommunicationAgent("ri_comm")
    agent._req_socket = req
    agent.pub_socket = AsyncMock()
    agent.connected = True
    agent._running = True

    with pytest.raises(ValueError):
        await agent._listen_loop()


@pytest.mark.asyncio
async def test_handle_disconnection_timeout(monkeypatch):
    pub = AsyncMock()
    pub.close = MagicMock()
    pub.send_multipart = AsyncMock(side_effect=TimeoutError)

    agent = RICommunicationAgent("ri_comm")
    agent.pub_socket = pub
    agent._negotiate_connection = AsyncMock(return_value=False)

    await agent._handle_disconnection()

    pub.send_multipart.assert_awaited()


@pytest.mark.asyncio
async def test_listen_loop_ping_sends_internal(zmq_context):
    fake_socket = zmq_context.return_value.socket.return_value
    fake_socket.send_json = AsyncMock()
    pub_socket = AsyncMock()

    agent = RICommunicationAgent("ri_comm")
    agent._req_socket = fake_socket
    agent.pub_socket = pub_socket
    agent.connected = True
    agent._running = True

    async def recv_once():
        agent._running = False
        return {"endpoint": "ping", "data": {}}

    fake_socket.recv_json = recv_once

    await agent._listen_loop()

    pub_socket.send_multipart.assert_awaited()


@pytest.mark.asyncio
async def test_negotiate_req_socket_none_causes_retry(zmq_context):
    agent = RICommunicationAgent("ri_comm")
    agent._req_socket = None

    result = await agent._negotiate_connection(max_retries=1)

    assert result is False
