"""
Tests for the RerankerClient (Synesis backend).

All Synesis HTTP calls are mocked — no external service needed.
"""

from decimal import Decimal
from unittest.mock import MagicMock, patch

from django.test import TestCase

from library.services.fusion import SearchCandidate
from library.services.reranker import RerankerClient


def _make_candidate(chunk_uid: str, text_preview: str = "Some text", **kwargs):
    """Helper to create a SearchCandidate."""
    return SearchCandidate(
        chunk_uid=chunk_uid,
        item_uid=kwargs.get("item_uid", f"item_{chunk_uid}"),
        item_title=kwargs.get("item_title", f"Title {chunk_uid}"),
        library_type=kwargs.get("library_type", "technical"),
        text_preview=text_preview,
        chunk_s3_key=f"chunks/{chunk_uid}/chunk_0.txt",
        chunk_index=0,
        score=kwargs.get("score", 0.5),
        source=kwargs.get("source", "vector"),
    )


def _mock_reranker_model():
    """Create a mock LLMModel for reranking.

    ``base_url`` follows the project-wide convention: it is the
    OpenAI-style ``/v1`` root. Every service client (embedding, vision,
    concepts, reranker) appends a path-only segment to it.
    """
    model = MagicMock()
    model.name = "qwen3-vl-reranker-2b"
    model.api.base_url = "http://pan.helu.ca:8400/v1"
    model.api.api_key = ""
    model.api.timeout_seconds = 30
    model.input_cost_per_1k = Decimal("0")
    model.output_cost_per_1k = Decimal("0")
    return model


class RerankerClientInitTest(TestCase):
    """Tests for RerankerClient initialization."""

    def test_initializes_with_model(self):
        """Client initializes correctly from LLMModel."""
        model = _mock_reranker_model()
        client = RerankerClient(model)

        self.assertEqual(client.model_name, "qwen3-vl-reranker-2b")
        self.assertEqual(client.base_url, "http://pan.helu.ca:8400/v1")


class RerankerClientRerankTest(TestCase):
    """Tests for RerankerClient.rerank()."""

    @patch("library.services.reranker.requests.post")
    def test_basic_rerank(self, mock_post):
        """Rerank returns candidates with updated scores."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "results": [
                {"index": 1, "score": 0.95, "document": {"text": "relevant"}},
                {"index": 0, "score": 0.30, "document": {"text": "less relevant"}},
            ],
            "usage": {"document_count": 2, "elapsed_ms": 42.0},
        }
        mock_post.return_value = mock_response

        model = _mock_reranker_model()
        client = RerankerClient(model)

        candidates = [
            _make_candidate("a", text_preview="less relevant text"),
            _make_candidate("b", text_preview="very relevant text"),
        ]

        reranked = client.rerank(query="test query", candidates=candidates)

        self.assertEqual(len(reranked), 2)
        # 'b' (index 1, score 0.95) should be first
        self.assertEqual(reranked[0].chunk_uid, "b")
        self.assertAlmostEqual(reranked[0].score, 0.95)
        # 'a' (index 0, score 0.30) should be second
        self.assertEqual(reranked[1].chunk_uid, "a")
        self.assertAlmostEqual(reranked[1].score, 0.30)

    @patch("library.services.reranker.requests.post")
    def test_instruction_included_in_payload(self, mock_post):
        """Reranker instruction is sent in the API payload."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "results": [{"index": 0, "score": 0.8}],
            "usage": {"document_count": 1, "elapsed_ms": 10},
        }
        mock_post.return_value = mock_response

        model = _mock_reranker_model()
        client = RerankerClient(model)
        candidates = [_make_candidate("a")]

        client.rerank(
            query="how to wire a motor",
            candidates=candidates,
            instruction="Re-rank based on procedural relevance.",
        )

        # Check the payload sent to Synesis
        call_args = mock_post.call_args
        payload = call_args.kwargs.get("json") or call_args[1].get("json")
        self.assertEqual(payload["instruction"], "Re-rank based on procedural relevance.")
        self.assertEqual(payload["query"]["text"], "how to wire a motor")

    @patch("library.services.reranker.requests.post")
    def test_top_n_included(self, mock_post):
        """top_n parameter is sent to Synesis."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "results": [{"index": 0, "score": 0.9}],
            "usage": {"document_count": 1, "elapsed_ms": 10},
        }
        mock_post.return_value = mock_response

        model = _mock_reranker_model()
        client = RerankerClient(model)
        candidates = [_make_candidate("a"), _make_candidate("b")]

        client.rerank(query="test", candidates=candidates, top_n=1)

        payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
        self.assertEqual(payload["top_n"], 1)

    @patch("library.services.reranker.requests.post")
    def test_documents_use_text_preview(self, mock_post):
        """Document text comes from candidate.text_preview."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "results": [{"index": 0, "score": 0.5}],
            "usage": {"document_count": 1, "elapsed_ms": 10},
        }
        mock_post.return_value = mock_response

        model = _mock_reranker_model()
        client = RerankerClient(model)
        candidates = [_make_candidate("a", text_preview="Motor wiring procedures")]

        client.rerank(query="test", candidates=candidates)

        payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
        self.assertEqual(payload["documents"][0]["text"], "Motor wiring procedures")

    def test_empty_candidates_returns_empty(self):
        """Empty candidate list returns empty without API call."""
        model = _mock_reranker_model()
        client = RerankerClient(model)

        result = client.rerank(query="test", candidates=[])

        self.assertEqual(result, [])

    @patch("library.services.reranker.requests.post")
    def test_reranked_flag_in_metadata(self, mock_post):
        """Reranked candidates have 'reranked: True' in metadata."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "results": [{"index": 0, "score": 0.9}],
            "usage": {"document_count": 1, "elapsed_ms": 10},
        }
        mock_post.return_value = mock_response

        model = _mock_reranker_model()
        client = RerankerClient(model)
        candidates = [_make_candidate("a")]

        reranked = client.rerank(query="test", candidates=candidates)

        self.assertTrue(reranked[0].metadata.get("reranked"))

    @patch("library.services.reranker.requests.post")
    def test_api_error_raises(self, mock_post):
        """HTTP errors propagate as exceptions."""
        mock_response = MagicMock()
        mock_response.status_code = 500
        mock_response.text = "Internal Server Error"
        mock_response.raise_for_status.side_effect = Exception("Server error")
        mock_post.return_value = mock_response

        model = _mock_reranker_model()
        client = RerankerClient(model)
        candidates = [_make_candidate("a")]

        with self.assertRaises(Exception):
            client.rerank(query="test", candidates=candidates)

    @patch("library.services.reranker.requests.post")
    def test_request_url_is_base_plus_rerank(self, mock_post):
        """URL is ``{base_url}/rerank`` — base_url already contains ``/v1``.

        Regression guard: earlier versions prepended ``/v1/`` inside the
        client, which produced ``…/v1/v1/rerank`` and 404 against Synesis
        when the ``LLMApi.base_url`` followed the same convention as the
        embedding / chat / vision clients.
        """
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "results": [{"index": 0, "score": 0.5}],
        }
        mock_post.return_value = mock_response

        model = _mock_reranker_model()
        client = RerankerClient(model)
        candidates = [_make_candidate("a")]

        client.rerank(query="test", candidates=candidates)

        # First positional arg to requests.post is the URL.
        called_url = mock_post.call_args.args[0] if mock_post.call_args.args \
            else mock_post.call_args.kwargs.get("url")
        self.assertEqual(called_url, "http://pan.helu.ca:8400/v1/rerank")

    @patch("library.services.reranker.requests.post")
    def test_no_instruction_omits_field(self, mock_post):
        """Empty instruction is not sent in payload."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "results": [{"index": 0, "score": 0.5}],
        }
        mock_post.return_value = mock_response

        model = _mock_reranker_model()
        client = RerankerClient(model)
        candidates = [_make_candidate("a")]

        client.rerank(query="test", candidates=candidates, instruction="")

        payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
        self.assertNotIn("instruction", payload)
