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

from brynq import ollama_adapter

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

@pytest.fixture
def mock_http_connection():
    with patch("http.client.HTTPConnection") as mock:
        yield mock

@pytest.fixture
def ollama_models_response():
    return {"models": [{"name": "llama3:latest", "size": 100}]}

@pytest.fixture
def ollama_chat_response():
    return {"message": {"content": "Hello back"}}

@pytest.fixture
def ollama_generate_response():
    return {"response": "Generated text"}

class TestIsOllamaRunning:
    def test_returns_true_when_server_responds(self, mock_urlopen):
        mock_urlopen.return_value.__enter__.return_value.status = 200
        assert ollama_adapter.is_ollama_running() is True

    def test_returns_false_on_connection_refused(self, mock_urlopen):
        mock_urlopen.side_effect = ConnectionRefusedError()
        assert ollama_adapter.is_ollama_running() is False

    def test_returns_false_on_timeout(self, mock_urlopen):
        mock_urlopen.side_effect = socket.timeout()
        assert ollama_adapter.is_ollama_running() is False

class TestListModels:
    @patch("brynq.ollama_adapter.is_ollama_running", return_value=True)
    def test_returns_model_list(self, mock_is_running, mock_urlopen, ollama_models_response):
        mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(ollama_models_response).encode("utf-8")
        models = ollama_adapter.list_models()
        assert len(models) == 1
        assert models[0]["name"] == "llama3:latest"

    @patch("brynq.ollama_adapter.is_ollama_running", return_value=True)
    def test_returns_empty_when_no_models(self, mock_is_running, mock_urlopen):
        mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps({"models": []}).encode("utf-8")
        assert ollama_adapter.list_models() == []

    @patch("brynq.ollama_adapter.is_ollama_running", return_value=False)
    def test_raises_connection_error_when_down(self, mock_is_running):
        with pytest.raises(ConnectionError):
            ollama_adapter.list_models()

    @patch("brynq.ollama_adapter.is_ollama_running", return_value=True)
    def test_parses_model_metadata_fields(self, mock_is_running, mock_urlopen):
        mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps({
            "models": [{"name": "test:latest", "size": 123, "modified_at": "2024-01-01", "digest": "abc", "details": {"family": "llama"}}]
        }).encode("utf-8")
        models = ollama_adapter.list_models()
        assert models[0]["size"] == 123
        assert models[0]["modified_at"] == "2024-01-01"

class TestChat:
    @patch("brynq.ollama_adapter.list_models", return_value=[{"name": "llama3:latest"}])
    @patch("brynq.ollama_adapter.is_ollama_running", return_value=True)
    def test_non_streaming_returns_string(self, mock_is_running, mock_list, mock_urlopen, ollama_chat_response):
        mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(ollama_chat_response).encode("utf-8")
        res = ollama_adapter.chat("llama3:latest", [{"role": "user", "content": "hi"}])
        assert res == "Hello back"

    @patch("brynq.ollama_adapter.list_models", return_value=[{"name": "llama3:latest"}])
    @patch("brynq.ollama_adapter.is_ollama_running", return_value=True)
    def test_passes_model_and_messages_in_body(self, mock_is_running, mock_list, mock_urlopen, ollama_chat_response):
        mock_req = mock_urlopen.return_value.__enter__.return_value
        mock_req.read.return_value = json.dumps(ollama_chat_response).encode("utf-8")
        ollama_adapter.chat("llama3:latest", [{"role": "user", "content": "hi"}])
        call_args = mock_urlopen.call_args[0][0]
        body = json.loads(call_args.data.decode("utf-8"))
        assert body["model"] == "llama3:latest"
        assert body["messages"] == [{"role": "user", "content": "hi"}]

    @patch("brynq.ollama_adapter.list_models", return_value=[{"name": "llama3:latest"}])
    @patch("brynq.ollama_adapter.is_ollama_running", return_value=True)
    def test_forwards_kwargs_to_body(self, mock_is_running, mock_list, mock_urlopen, ollama_chat_response):
        mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(ollama_chat_response).encode("utf-8")
        ollama_adapter.chat("llama3:latest", [], temperature=0.7)
        call_args = mock_urlopen.call_args[0][0]
        body = json.loads(call_args.data.decode("utf-8"))
        assert body["temperature"] == 0.7

    @patch("brynq.ollama_adapter.is_ollama_running", return_value=False)
    def test_raises_connection_error_when_down(self, mock_is_running):
        with pytest.raises(ConnectionError):
            ollama_adapter.chat("llama3:latest", [])

    @patch("brynq.ollama_adapter.list_models", return_value=[{"name": "other:latest"}])
    @patch("brynq.ollama_adapter.is_ollama_running", return_value=True)
    def test_raises_value_error_for_unknown_model(self, mock_is_running, mock_list):
        with pytest.raises(ValueError):
            ollama_adapter.chat("llama3:latest", [])

    @patch("brynq.ollama_adapter.list_models", return_value=[{"name": "llama3:latest"}])
    @patch("brynq.ollama_adapter.is_ollama_running", return_value=True)
    def test_streaming_returns_generator(self, mock_is_running, mock_list, mock_http_connection):
        mock_conn = mock_http_connection.return_value
        mock_resp = MagicMock()
        mock_resp.status = 200
        mock_resp.read.side_effect = [b'{"message": {"content": "a"}}\n{"message": {"content": "b"}}\n', b'']
        mock_conn.getresponse.return_value = mock_resp

        res = ollama_adapter.chat("llama3:latest", [], stream=True)
        import types
        assert isinstance(res, types.GeneratorType)

    @patch("brynq.ollama_adapter.list_models", return_value=[{"name": "llama3:latest"}])
    @patch("brynq.ollama_adapter.is_ollama_running", return_value=True)
    def test_streaming_yields_content_chunks(self, mock_is_running, mock_list, mock_http_connection):
        mock_conn = mock_http_connection.return_value
        mock_resp = MagicMock()
        mock_resp.status = 200
        mock_resp.read.side_effect = [b'{"message": {"content": "a"}}\n{"message": {"content": "b"}}\n', b'']
        mock_conn.getresponse.return_value = mock_resp

        res = list(ollama_adapter.chat("llama3:latest", [], stream=True))
        assert res == ["a", "b"]

class TestGenerate:
    @patch("brynq.ollama_adapter.list_models", return_value=[{"name": "llama3:latest"}])
    @patch("brynq.ollama_adapter.is_ollama_running", return_value=True)
    def test_non_streaming_returns_string(self, mock_is_running, mock_list, mock_urlopen, ollama_generate_response):
        mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(ollama_generate_response).encode("utf-8")
        res = ollama_adapter.generate("llama3:latest", "hi")
        assert res == "Generated text"

    @patch("brynq.ollama_adapter.list_models", return_value=[{"name": "llama3:latest"}])
    @patch("brynq.ollama_adapter.is_ollama_running", return_value=True)
    def test_passes_prompt_in_body(self, mock_is_running, mock_list, mock_urlopen, ollama_generate_response):
        mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(ollama_generate_response).encode("utf-8")
        ollama_adapter.generate("llama3:latest", "hi")
        call_args = mock_urlopen.call_args[0][0]
        body = json.loads(call_args.data.decode("utf-8"))
        assert body["prompt"] == "hi"

    @patch("brynq.ollama_adapter.list_models", return_value=[{"name": "llama3:latest"}])
    @patch("brynq.ollama_adapter.is_ollama_running", return_value=True)
    def test_streaming_returns_generator(self, mock_is_running, mock_list, mock_http_connection):
        mock_conn = mock_http_connection.return_value
        mock_resp = MagicMock()
        mock_resp.status = 200
        mock_resp.read.side_effect = [b'{"response": "a"}\n', b'']
        mock_conn.getresponse.return_value = mock_resp
        res = ollama_adapter.generate("llama3:latest", "hi", stream=True)
        import types
        assert isinstance(res, types.GeneratorType)

    @patch("brynq.ollama_adapter.list_models", return_value=[{"name": "llama3:latest"}])
    @patch("brynq.ollama_adapter.is_ollama_running", return_value=True)
    def test_streaming_yields_response_tokens(self, mock_is_running, mock_list, mock_http_connection):
        mock_conn = mock_http_connection.return_value
        mock_resp = MagicMock()
        mock_resp.status = 200
        mock_resp.read.side_effect = [b'{"response": "a"}\n{"response": "b"}\n', b'']
        mock_conn.getresponse.return_value = mock_resp
        res = list(ollama_adapter.generate("llama3:latest", "hi", stream=True))
        assert res == ["a", "b"]

    @patch("brynq.ollama_adapter.is_ollama_running", return_value=False)
    def test_raises_connection_error_when_down(self, mock_is_running):
        with pytest.raises(ConnectionError):
            ollama_adapter.generate("llama3:latest", "hi")

    @patch("brynq.ollama_adapter.list_models", return_value=[{"name": "other:latest"}])
    @patch("brynq.ollama_adapter.is_ollama_running", return_value=True)
    def test_raises_value_error_for_unknown_model(self, mock_is_running, mock_list):
        with pytest.raises(ValueError):
            ollama_adapter.generate("llama3:latest", "hi")

class TestGetModelInfo:
    @patch("brynq.ollama_adapter.list_models", return_value=[{"name": "llama3:latest"}])
    @patch("brynq.ollama_adapter.is_ollama_running", return_value=True)
    def test_returns_model_details(self, mock_is_running, mock_list, mock_urlopen):
        mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps({"modelfile": "..."}).encode("utf-8")
        res = ollama_adapter.get_model_info("llama3:latest")
        assert res["modelfile"] == "..."

    @patch("brynq.ollama_adapter.list_models", return_value=[{"name": "other:latest"}])
    @patch("brynq.ollama_adapter.is_ollama_running", return_value=True)
    def test_raises_for_unknown_model(self, mock_is_running, mock_list):
        with pytest.raises(ValueError):
            ollama_adapter.get_model_info("llama3:latest")

class TestInternalHelpers:
    def test_url_builder(self):
        assert ollama_adapter._url("/api/tags") == "http://localhost:11434/api/tags"

    def test_post_json_http_error_includes_detail(self, mock_urlopen):
        err = urllib.error.HTTPError("url", 400, "Bad Request", {}, None)
        err.read = MagicMock(return_value=b"some error detail")
        mock_urlopen.side_effect = err
        with pytest.raises(RuntimeError, match="some error detail"):
            ollama_adapter._post_json("/api/chat", {})

    def test_post_json_connection_error_message(self, mock_urlopen):
        mock_urlopen.side_effect = ConnectionRefusedError("refused")
        with pytest.raises(ConnectionError, match="Is Ollama running?"):
            ollama_adapter._post_json("/api/chat", {})

    @patch("brynq.ollama_adapter.list_models", return_value=[{"name": "llama3:latest"}])
    def test_validate_model_accepts_bare_name(self, mock_list):
        # Should not raise
        ollama_adapter._validate_model("llama3")

    @patch("brynq.ollama_adapter.list_models", return_value=[{"name": "llama3:latest"}])
    def test_validate_model_accepts_tagged_name(self, mock_list):
        # Should not raise
        ollama_adapter._validate_model("llama3:latest")
