import json
import urllib.error
import pytest
from unittest.mock import MagicMock, patch

from brynq import lmstudio_adapter

@pytest.fixture
def mock_urlopen():
    with patch("urllib.request.urlopen") as mock:
        yield mock

@pytest.fixture
def lmstudio_models_response():
    return {"data": [{"id": "local-model"}]}

@pytest.fixture
def lmstudio_chat_response():
    return {"choices": [{"message": {"content": "Hi"}}]}

@pytest.fixture
def lmstudio_completion_response():
    return {"choices": [{"text": "completed"}]}

class TestIsServerRunning:
    def test_returns_true_when_reachable(self, mock_urlopen):
        mock_urlopen.return_value.__enter__.return_value.read.return_value = b"{}"
        assert lmstudio_adapter.is_server_running() is True

    def test_returns_false_on_connection_error(self, mock_urlopen):
        mock_urlopen.side_effect = urllib.error.URLError("refused")
        assert lmstudio_adapter.is_server_running() is False

    def test_custom_port(self, mock_urlopen):
        mock_urlopen.return_value.__enter__.return_value.read.return_value = b"{}"
        lmstudio_adapter.is_server_running(port=1234)
        mock_urlopen.assert_called()
        req = mock_urlopen.call_args[0][0]
        assert "1234" in req.full_url

class TestListModels:
    def test_returns_model_ids(self, mock_urlopen, lmstudio_models_response):
        mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(lmstudio_models_response).encode("utf-8")
        models = lmstudio_adapter.list_models()
        assert models == ["local-model"]

    def test_empty_server(self, mock_urlopen):
        mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps({"data": []}).encode("utf-8")
        assert lmstudio_adapter.list_models() == []

    def test_raises_connection_error(self, mock_urlopen):
        mock_urlopen.side_effect = urllib.error.URLError("refused")
        with pytest.raises(lmstudio_adapter.LMStudioConnectionError):
            lmstudio_adapter.list_models()

class TestChat:
    @patch("brynq.lmstudio_adapter.is_server_running", return_value=True)
    def test_returns_content_string(self, mock_is_running, mock_urlopen, lmstudio_chat_response):
        mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(lmstudio_chat_response).encode("utf-8")
        res = lmstudio_adapter.chat("local-model", [{"role": "user", "content": "hi"}])
        assert res == "Hi"

    @patch("brynq.lmstudio_adapter.is_server_running", return_value=True)
    def test_sends_correct_body(self, mock_is_running, mock_urlopen, lmstudio_chat_response):
        mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(lmstudio_chat_response).encode("utf-8")
        lmstudio_adapter.chat("local-model", [{"role": "user", "content": "hi"}])
        call_args = mock_urlopen.call_args[0][0]
        body = json.loads(call_args.data.decode("utf-8"))
        assert body["model"] == "local-model"
        assert body["messages"] == [{"role": "user", "content": "hi"}]

    @patch("brynq.lmstudio_adapter.is_server_running", return_value=True)
    def test_passes_temperature_and_max_tokens(self, mock_is_running, mock_urlopen, lmstudio_chat_response):
        mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(lmstudio_chat_response).encode("utf-8")
        lmstudio_adapter.chat("local-model", [], temperature=0.5, max_tokens=100)
        call_args = mock_urlopen.call_args[0][0]
        body = json.loads(call_args.data.decode("utf-8"))
        assert body["temperature"] == 0.5
        assert body["max_tokens"] == 100

    @patch("brynq.lmstudio_adapter.is_server_running", return_value=False)
    def test_raises_connection_error(self, mock_is_running, mock_urlopen):
        mock_urlopen.side_effect = urllib.error.URLError("refused")
        with pytest.raises(lmstudio_adapter.LMStudioConnectionError):
            lmstudio_adapter.chat("local-model", [])

    @patch("brynq.lmstudio_adapter.is_server_running", return_value=True)
    def test_raises_model_not_found_on_404(self, mock_is_running, mock_urlopen):
        err = urllib.error.HTTPError("url", 404, "Not Found", {}, None)
        err.read = MagicMock(return_value=b"model not found")
        mock_urlopen.side_effect = err
        with pytest.raises(lmstudio_adapter.LMStudioModelNotFoundError):
            lmstudio_adapter.chat("local-model", [])

    @patch("brynq.lmstudio_adapter.is_server_running", return_value=True)
    def test_raises_value_error_on_empty_choices(self, mock_is_running, mock_urlopen):
        mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps({"choices": []}).encode("utf-8")
        with pytest.raises(ValueError):
            lmstudio_adapter.chat("local-model", [])

class TestComplete:
    @patch("brynq.lmstudio_adapter.is_server_running", return_value=True)
    def test_returns_text_string(self, mock_is_running, mock_urlopen, lmstudio_completion_response):
        mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(lmstudio_completion_response).encode("utf-8")
        res = lmstudio_adapter.complete("local-model", "hi")
        assert res == "completed"

    @patch("brynq.lmstudio_adapter.is_server_running", return_value=True)
    def test_sends_prompt_in_body(self, mock_is_running, mock_urlopen, lmstudio_completion_response):
        mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(lmstudio_completion_response).encode("utf-8")
        lmstudio_adapter.complete("local-model", "hi")
        call_args = mock_urlopen.call_args[0][0]
        body = json.loads(call_args.data.decode("utf-8"))
        assert body["prompt"] == "hi"

    @patch("brynq.lmstudio_adapter.is_server_running", return_value=False)
    def test_raises_connection_error(self, mock_is_running, mock_urlopen):
        mock_urlopen.side_effect = urllib.error.URLError("refused")
        with pytest.raises(lmstudio_adapter.LMStudioConnectionError):
            lmstudio_adapter.complete("local-model", "hi")

    @patch("brynq.lmstudio_adapter.is_server_running", return_value=True)
    def test_raises_model_not_found(self, mock_is_running, mock_urlopen):
        err = urllib.error.HTTPError("url", 404, "Not Found", {}, None)
        err.read = MagicMock(return_value=b"model not found")
        mock_urlopen.side_effect = err
        with pytest.raises(lmstudio_adapter.LMStudioModelNotFoundError):
            lmstudio_adapter.complete("local-model", "hi")

    @patch("brynq.lmstudio_adapter.is_server_running", return_value=True)
    def test_raises_value_error_on_empty_choices(self, mock_is_running, mock_urlopen):
        mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps({"choices": []}).encode("utf-8")
        with pytest.raises(ValueError):
            lmstudio_adapter.complete("local-model", "hi")

class TestErrorClasses:
    def test_connection_error_is_connection_error(self, mock_urlopen):
        mock_urlopen.side_effect = urllib.error.URLError("refused")
        with pytest.raises(lmstudio_adapter.LMStudioConnectionError):
            lmstudio_adapter.list_models()

    def test_api_error_is_runtime_error(self, mock_urlopen):
        err = urllib.error.HTTPError("url", 500, "Internal Server Error", {}, None)
        err.read = MagicMock(return_value=b"server err")
        mock_urlopen.side_effect = err
        with pytest.raises(lmstudio_adapter.LMStudioAPIError):
            lmstudio_adapter._http_post("http://url", {})

    @patch("brynq.lmstudio_adapter.is_server_running", return_value=True)
    def test_model_not_found_is_api_error(self, mock_is_running, mock_urlopen):
        err = urllib.error.HTTPError("url", 404, "Not Found", {}, None)
        err.read = MagicMock(return_value=b"model not found")
        mock_urlopen.side_effect = err
        with pytest.raises(lmstudio_adapter.LMStudioModelNotFoundError):
            lmstudio_adapter.chat("local-model", [])
