"""
Unit tests for Reno AI SDK — covers bug fixes and new features.
"""

import json
import pytest
from unittest.mock import Mock, patch, MagicMock

from renoai import (
    Reno,
    Conversation,
    RenoError,
    RenoConnectionError,
    RenoTimeoutError,
    RenoValidationError,
)
from renoai.models import Completion, Choice, Usage, StreamChunk


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

def _make_response(body: dict, status: int = 200, headers: dict = None) -> Mock:
    """Build a mock requests.Response."""
    resp = Mock()
    resp.status_code = status
    resp.json.return_value = body
    resp.text = json.dumps(body)
    resp.headers = headers or {}
    return resp


SAMPLE_COMPLETION = {
    "id": "test-123",
    "created": 1_700_000_000,
    "model": "gemma2:2b-instruct",
    "choices": [
        {
            "index": 0,
            "message": {"role": "assistant", "content": "Test response"},
            "finish_reason": "stop",
        }
    ],
    "usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15},
}


# ===========================================================================
# Client initialisation
# ===========================================================================

class TestRenoInit:
    def test_defaults(self):
        client = Reno(api_key="reno_sk_test")
        assert client.base_url == "http://127.0.0.1:8000/api/v1"
        assert client.timeout == 30
        assert "Bearer reno_sk_test" in client.session.headers["Authorization"]
        client.close()

    def test_custom_base_url_strips_trailing_slash(self):
        client = Reno(api_key="reno_sk_test", base_url="https://api.example.com/v1/")
        assert client.base_url == "https://api.example.com/v1"
        client.close()

    def test_empty_api_key_raises(self):
        with pytest.raises(ValueError):
            Reno(api_key="")

    def test_whitespace_api_key_raises(self):
        with pytest.raises(ValueError):
            Reno(api_key="   ")

    def test_context_manager(self):
        with Reno(api_key="reno_sk_test") as client:
            assert client is not None

    def test_repr(self):
        client = Reno(api_key="reno_sk_test")
        assert "Reno(" in repr(client)
        client.close()


# ===========================================================================
# Exceptions
# ===========================================================================

class TestExceptions:
    def test_basic_fields(self):
        err = RenoError(code=1001, message="Bad key", details="hint")
        assert err.code == 1001
        assert err.message == "Bad key"
        assert err.details == "hint"
        assert "[1001]" in str(err)

    def test_is_retryable_true(self):
        err = RenoError(code=5001, message="rate limited")
        assert err.is_retryable is True

    def test_is_retryable_false(self):
        err = RenoError(code=1002, message="invalid key")
        assert err.is_retryable is False

    def test_retry_after_stored(self):
        err = RenoError(code=5001, message="rate limited", retry_after=42.0)
        assert err.retry_after == 42.0

    def test_user_friendly_known_code(self):
        err = RenoError(code=1001, message="test")
        text = err.user_friendly()
        assert "Invalid API Key Format" in text
        assert "💡" in text           # proper emoji, not mojibake

    def test_user_friendly_unknown_code(self):
        err = RenoError(code=9999, message="mystery")
        text = err.user_friendly()
        assert "Unknown Error" in text

    def test_user_friendly_includes_retry_after(self):
        err = RenoError(code=5001, message="slow down", retry_after=30.0)
        text = err.user_friendly()
        assert "30s" in text

    def test_connection_error_code(self):
        assert RenoConnectionError().code == 6002

    def test_timeout_error_code(self):
        assert RenoTimeoutError().code == 6003

    def test_validation_error_code(self):
        assert RenoValidationError("bad input").code == 2001

    def test_all_errors_inherit_reno_error(self):
        for cls in (RenoConnectionError, RenoTimeoutError, RenoValidationError):
            assert issubclass(cls, RenoError)


# ===========================================================================
# Models
# ===========================================================================

class TestModels:
    def test_usage(self):
        u = Usage({"prompt_tokens": 3, "completion_tokens": 7, "total_tokens": 10})
        assert u.total_tokens == 10
        assert "10" in str(u)

    def test_choice_content_and_role(self):
        c = Choice({"index": 0, "message": {"role": "assistant", "content": "Hi"}, "finish_reason": "stop"})
        assert c.content == "Hi"
        assert c.role == "assistant"
        assert str(c) == "Hi"

    def test_choice_to_message(self):
        c = Choice({"index": 0, "message": {"role": "assistant", "content": "Hi"}, "finish_reason": "stop"})
        msg = c.to_message()
        assert msg == {"role": "assistant", "content": "Hi"}

    def test_completion_text(self):
        comp = Completion(SAMPLE_COMPLETION)
        assert comp.text == "Test response"
        assert comp.content == "Test response"
        assert str(comp) == "Test response"

    def test_completion_to_message(self):
        comp = Completion(SAMPLE_COMPLETION)
        msg = comp.to_message()
        assert msg == {"role": "assistant", "content": "Test response"}

    def test_completion_empty_choices(self):
        comp = Completion({"id": "x", "choices": [], "usage": {}})
        assert comp.text == ""
        assert comp.to_message() == {"role": "assistant", "content": ""}

    def test_completion_to_dict(self):
        comp = Completion(SAMPLE_COMPLETION)
        assert comp.to_dict() == SAMPLE_COMPLETION

    def test_stream_chunk_delta(self):
        chunk = StreamChunk({"id": "c1", "choices": [{"delta": {"content": "hello"}, "finish_reason": None}]})
        assert chunk.delta == "hello"
        assert chunk.is_final is False
        assert str(chunk) == "hello"

    def test_stream_chunk_final(self):
        chunk = StreamChunk({"id": "c2", "choices": [{"delta": {}, "finish_reason": "stop"}]})
        assert chunk.delta is None
        assert chunk.is_final is True

    def test_stream_chunk_empty_choices(self):
        chunk = StreamChunk({"id": "c3", "choices": []})
        assert chunk.delta is None
        assert chunk.finish_reason is None


# ===========================================================================
# Message validation
# ===========================================================================

class TestMessageValidation:
    def _client(self):
        return Reno(api_key="reno_sk_test")

    def test_empty_messages_raises(self):
        with self.client() as c:
            with pytest.raises(RenoValidationError, match="at least one"):
                c.chat([])

    def test_missing_role_raises(self):
        with self.client() as c:
            with pytest.raises(RenoValidationError, match="'role'"):
                c.chat([{"content": "hello"}])

    def test_missing_content_raises(self):
        with self.client() as c:
            with pytest.raises(RenoValidationError, match="'content'"):
                c.chat([{"role": "user"}])

    def test_invalid_role_raises(self):
        with self.client() as c:
            with pytest.raises(RenoValidationError, match="invalid role"):
                c.chat([{"role": "god", "content": "hello"}])

    def test_empty_content_raises(self):
        with self.client() as c:
            with pytest.raises(RenoValidationError, match="non-empty"):
                c.chat([{"role": "user", "content": "  "}])

    def test_non_dict_message_raises(self):
        with self.client() as c:
            with pytest.raises(RenoValidationError):
                c.chat(["not a dict"])

    def test_invalid_temperature_raises(self):
        with self.client() as c:
            with pytest.raises(RenoValidationError, match="temperature"):
                c.chat([{"role": "user", "content": "hi"}], temperature=3.0)

    def test_invalid_max_tokens_raises(self):
        with self.client() as c:
            with pytest.raises(RenoValidationError, match="max_tokens"):
                c.chat([{"role": "user", "content": "hi"}], max_tokens=-5)

    def test_empty_prompt_raises(self):
        with self.client() as c:
            with pytest.raises(RenoValidationError, match="non-empty"):
                c.ask("")


# ===========================================================================
# Request / response flow
# ===========================================================================

class TestRequestFlow:
    @patch("requests.Session.post")
    def test_ask_success(self, mock_post):
        mock_post.return_value = _make_response(SAMPLE_COMPLETION)
        with Reno(api_key="reno_sk_test") as c:
            assert c.ask("Hello?") == "Test response"

    @patch("requests.Session.post")
    def test_chat_returns_completion(self, mock_post):
        mock_post.return_value = _make_response(SAMPLE_COMPLETION)
        with Reno(api_key="reno_sk_test") as c:
            resp = c.chat([{"role": "user", "content": "Hi"}])
        assert isinstance(resp, Completion)
        assert resp.text == "Test response"

    @patch("requests.Session.post")
    def test_data_envelope_unwrapped(self, mock_post):
        """API wraps payload in a 'data' key — client should unwrap it."""
        mock_post.return_value = _make_response({"data": SAMPLE_COMPLETION})
        with Reno(api_key="reno_sk_test") as c:
            resp = c.chat([{"role": "user", "content": "Hi"}])
        assert resp.text == "Test response"

    @patch("requests.Session.post")
    def test_error_in_200_body(self, mock_post):
        """BUG FIX: error embedded in a 2xx response must be raised."""
        mock_post.return_value = _make_response(
            {"error": {"code": 1002, "message": "Invalid API key"}}
        )
        with Reno(api_key="reno_sk_test") as c:
            with pytest.raises(RenoError) as exc_info:
                c.ask("Test")
        assert exc_info.value.code == 1002

    @patch("requests.Session.post")
    def test_400_with_error_key(self, mock_post):
        mock_post.return_value = _make_response(
            {"error": {"code": 1003, "message": "Missing key"}},
            status=401,
        )
        with Reno(api_key="reno_sk_test") as c:
            with pytest.raises(RenoError) as exc_info:
                c.ask("Test")
        assert exc_info.value.code == 1003

    @patch("requests.Session.post")
    def test_400_no_error_key_still_raises(self, mock_post):
        """BUG FIX: 400 body without 'error' key must still raise, not silently pass."""
        resp = Mock()
        resp.status_code = 400
        resp.headers = {}
        resp.json.return_value = {"detail": "something went wrong"}
        resp.text = '{"detail": "something went wrong"}'
        mock_post.return_value = resp

        with Reno(api_key="reno_sk_test") as c:
            with pytest.raises(RenoError):
                c.ask("Test")

    @patch("requests.Session.post")
    def test_500_raises_reno_error(self, mock_post):
        resp = Mock()
        resp.status_code = 503
        resp.headers = {}
        resp.json.side_effect = ValueError("no json")
        resp.text = "Service Unavailable"
        mock_post.return_value = resp

        with Reno(api_key="reno_sk_test") as c:
            with pytest.raises(RenoError):
                c.ask("Test")

    @patch("requests.Session.post")
    def test_retry_after_header_propagated(self, mock_post):
        resp = Mock()
        resp.status_code = 429
        resp.headers = {"Retry-After": "60"}
        resp.json.return_value = {"error": {"code": 5001, "message": "Rate limited"}}
        resp.text = ""
        mock_post.return_value = resp

        with Reno(api_key="reno_sk_test") as c:
            with pytest.raises(RenoError) as exc_info:
                c.ask("Test")
        assert exc_info.value.retry_after == 60.0

    @patch("requests.Session.post")
    def test_connection_error(self, mock_post):
        import requests as _req
        mock_post.side_effect = _req.exceptions.ConnectionError("refused")
        with Reno(api_key="reno_sk_test") as c:
            with pytest.raises(RenoConnectionError):
                c.ask("Test")

    @patch("requests.Session.post")
    def test_timeout_error(self, mock_post):
        import requests as _req
        mock_post.side_effect = _req.exceptions.Timeout()
        with Reno(api_key="reno_sk_test") as c:
            with pytest.raises(RenoTimeoutError):
                c.ask("Test")

    @patch("requests.Session.post")
    def test_invalid_json_response(self, mock_post):
        resp = Mock()
        resp.status_code = 200
        resp.json.side_effect = ValueError("bad json")
        resp.text = "not json"
        resp.headers = {}
        mock_post.return_value = resp

        with Reno(api_key="reno_sk_test") as c:
            with pytest.raises(RenoError, match="Invalid JSON"):
                c.ask("Test")


# ===========================================================================
# Streaming
# ===========================================================================

class TestStreaming:
    def _make_stream_lines(self, deltas: list, error: dict = None) -> list:
        lines = []
        for d in deltas:
            chunk = {"id": "s1", "choices": [{"delta": {"content": d}, "finish_reason": None}]}
            lines.append(f"data: {json.dumps(chunk)}".encode())
        if error:
            lines.append(f"data: {json.dumps({'error': error})}".encode())
        lines.append(b"data: [DONE]")
        return lines

    @patch("requests.Session.post")
    def test_stream_yields_chunks(self, mock_post):
        lines = self._make_stream_lines(["Hello", " world"])
        resp = MagicMock()
        resp.status_code = 200
        resp.iter_lines.return_value = iter(lines)
        resp.__enter__ = lambda s: s
        resp.__exit__ = MagicMock(return_value=False)
        mock_post.return_value = resp

        with Reno(api_key="reno_sk_test") as c:
            chunks = list(c.chat([{"role": "user", "content": "Hi"}], stream=True))

        assert [ch.delta for ch in chunks] == ["Hello", " world"]

    @patch("requests.Session.post")
    def test_stream_text_helper(self, mock_post):
        lines = self._make_stream_lines(["A", "B", "C"])
        resp = MagicMock()
        resp.status_code = 200
        resp.iter_lines.return_value = iter(lines)
        resp.__enter__ = lambda s: s
        resp.__exit__ = MagicMock(return_value=False)
        mock_post.return_value = resp

        with Reno(api_key="reno_sk_test") as c:
            result = "".join(c.stream_text("Go"))
        assert result == "ABC"

    @patch("requests.Session.post")
    def test_stream_error_in_chunk(self, mock_post):
        """BUG FIX: errors inside stream chunks must be raised, not swallowed."""
        lines = self._make_stream_lines(
            [], error={"code": 7001, "message": "Content policy"}
        )
        resp = MagicMock()
        resp.status_code = 200
        resp.iter_lines.return_value = iter(lines)
        resp.__enter__ = lambda s: s
        resp.__exit__ = MagicMock(return_value=False)
        mock_post.return_value = resp

        with Reno(api_key="reno_sk_test") as c:
            with pytest.raises(RenoError) as exc_info:
                list(c.chat([{"role": "user", "content": "Hi"}], stream=True))
        assert exc_info.value.code == 7001

    @patch("requests.Session.post")
    def test_stream_skips_malformed_lines(self, mock_post):
        lines = [
            b"data: {broken json",
            b"data: " + json.dumps({"id": "x", "choices": [{"delta": {"content": "ok"}, "finish_reason": None}]}).encode(),
            b"data: [DONE]",
        ]
        resp = MagicMock()
        resp.status_code = 200
        resp.iter_lines.return_value = iter(lines)
        resp.__enter__ = lambda s: s
        resp.__exit__ = MagicMock(return_value=False)
        mock_post.return_value = resp

        with Reno(api_key="reno_sk_test") as c:
            chunks = list(c.chat([{"role": "user", "content": "Hi"}], stream=True))
        assert len(chunks) == 1
        assert chunks[0].delta == "ok"

    @patch("requests.Session.post")
    def test_stream_connection_error(self, mock_post):
        import requests as _req
        mock_post.side_effect = _req.exceptions.ConnectionError("lost")
        with Reno(api_key="reno_sk_test") as c:
            with pytest.raises(RenoConnectionError):
                list(c.chat([{"role": "user", "content": "Hi"}], stream=True))

    @patch("requests.Session.post")
    def test_stream_timeout_error(self, mock_post):
        import requests as _req
        mock_post.side_effect = _req.exceptions.Timeout()
        with Reno(api_key="reno_sk_test") as c:
            with pytest.raises(RenoTimeoutError):
                list(c.chat([{"role": "user", "content": "Hi"}], stream=True))


# ===========================================================================
# Conversation helper
# ===========================================================================

class TestConversation:
    @patch("requests.Session.post")
    def test_say_appends_history(self, mock_post):
        mock_post.return_value = _make_response(SAMPLE_COMPLETION)
        with Reno(api_key="reno_sk_test") as c:
            conv = Conversation(c, system="Be helpful.")
            reply = conv.say("Hello")
        assert reply == "Test response"
        # system + user + assistant = 3 items
        assert len(conv.history) == 3
        assert conv.history[0]["role"] == "system"
        assert conv.history[1]["role"] == "user"
        assert conv.history[2]["role"] == "assistant"

    @patch("requests.Session.post")
    def test_reset_keeps_system(self, mock_post):
        mock_post.return_value = _make_response(SAMPLE_COMPLETION)
        with Reno(api_key="reno_sk_test") as c:
            conv = Conversation(c, system="Be helpful.")
            conv.say("Hi")
            conv.reset(keep_system=True)
        assert len(conv.history) == 1
        assert conv.history[0]["role"] == "system"

    @patch("requests.Session.post")
    def test_reset_clears_all(self, mock_post):
        mock_post.return_value = _make_response(SAMPLE_COMPLETION)
        with Reno(api_key="reno_sk_test") as c:
            conv = Conversation(c, system="Be helpful.")
            conv.say("Hi")
            conv.reset(keep_system=False)
        assert conv.history == []

    def test_repr(self):
        with Reno(api_key="reno_sk_test") as c:
            conv = Conversation(c)
        assert "Conversation(" in repr(conv)


if __name__ == "__main__":
    pytest.main([__file__, "-v"])
