"""Tests for rbsp.query.reranker -- Cross-Encoder Reranking Pipeline."""

from __future__ import annotations

import os
import random
import sys
import time
import unittest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))

from rbsp.query.reranker import (
    CohereReranker,
    CrossEncoder,
    DummyCrossEncoder,
    RerankerConfig,
    RerankerPipeline,
    TransformerReranker,
    _minmax_normalize,
    _simple_tokenize,
)


# ---------------------------------------------------------------------------
# Helper: doc_fn for tests
# ---------------------------------------------------------------------------

def _make_doc_fn(docs: dict[str, str]):
    """Create a doc_fn from a doc_id -> text mapping."""
    def doc_fn(doc_id: str) -> str:
        return docs.get(doc_id, "")
    return doc_fn


# ---------------------------------------------------------------------------
# CrossEncoder interface
# ---------------------------------------------------------------------------

class TestCrossEncoder(unittest.TestCase):
    """Tests for the CrossEncoder abstract base class."""

    def test_score_batch_raises_not_implemented(self):
        encoder = CrossEncoder()
        with self.assertRaises(NotImplementedError):
            encoder.score_batch("query", ["doc"])

    def test_score_delegates_to_score_batch(self):
        class FixedScorer(CrossEncoder):
            def score_batch(self, query, documents):
                return [42.0] * len(documents)

        encoder = FixedScorer()
        self.assertAlmostEqual(encoder.score("q", "d"), 42.0)

    def test_score_returns_float(self):
        class EchoLength(CrossEncoder):
            def score_batch(self, query, documents):
                return [float(len(doc)) for doc in documents]

        encoder = EchoLength()
        result = encoder.score("q", "hello")
        self.assertIsInstance(result, float)
        self.assertAlmostEqual(result, 5.0)

    def test_score_batch_returns_list_of_floats(self):
        class ConstScorer(CrossEncoder):
            def score_batch(self, query, documents):
                return [1.5] * len(documents)

        encoder = ConstScorer()
        scores = encoder.score_batch("q", ["a", "b", "c"])
        self.assertIsInstance(scores, list)
        self.assertEqual(len(scores), 3)
        for s in scores:
            self.assertIsInstance(s, float)


# ---------------------------------------------------------------------------
# DummyCrossEncoder
# ---------------------------------------------------------------------------

class TestDummyCrossEncoder(unittest.TestCase):
    """Tests for the DummyCrossEncoder BM25-like scorer."""

    def setUp(self):
        self.encoder = DummyCrossEncoder()

    def test_matching_terms_score_higher(self):
        scores = self.encoder.score_batch(
            "python function",
            ["python function definition", "java class method"],
        )
        self.assertEqual(len(scores), 2)
        self.assertGreater(scores[0], scores[1])

    def test_no_matching_terms_zero(self):
        scores = self.encoder.score_batch("alpha beta", ["gamma delta"])
        self.assertAlmostEqual(scores[0], 0.0)

    def test_empty_query_zeros(self):
        scores = self.encoder.score_batch("", ["hello world", "foo bar"])
        self.assertEqual(scores, [0.0, 0.0])

    def test_empty_documents_list(self):
        scores = self.encoder.score_batch("hello", [])
        self.assertEqual(scores, [])

    def test_empty_document_text(self):
        scores = self.encoder.score_batch("hello", [""])
        self.assertAlmostEqual(scores[0], 0.0)

    def test_multiple_documents_ranking(self):
        scores = self.encoder.score_batch(
            "search engine",
            [
                "search engine optimization is important",
                "search for documents",
                "no relevant terms here",
            ],
        )
        self.assertEqual(len(scores), 3)
        self.assertGreater(scores[0], scores[1])
        self.assertGreater(scores[1], scores[2])

    def test_repeated_terms_increase_score(self):
        scores = self.encoder.score_batch(
            "search",
            ["search", "search search search"],
        )
        self.assertGreater(scores[1], scores[0])

    def test_case_insensitive(self):
        scores = self.encoder.score_batch("Hello World", ["HELLO world here"])
        self.assertGreater(scores[0], 0.0)

    def test_score_single_via_base_class(self):
        score = self.encoder.score("test query", "test query document")
        self.assertIsInstance(score, float)
        self.assertGreater(score, 0.0)

    def test_custom_params(self):
        encoder = DummyCrossEncoder(avg_doc_len=10.0, k1=2.0)
        scores = encoder.score_batch("hello", ["hello world"])
        self.assertGreater(scores[0], 0.0)

    def test_zero_avg_doc_len_uses_default(self):
        encoder = DummyCrossEncoder(avg_doc_len=0.0)
        self.assertAlmostEqual(encoder._avg_doc_len, 256.0)

    def test_repr(self):
        r = repr(self.encoder)
        self.assertIn("DummyCrossEncoder", r)
        self.assertIn("256.0", r)

    def test_deterministic_scoring(self):
        scores_a = self.encoder.score_batch("test", ["hello world"])
        scores_b = self.encoder.score_batch("test", ["hello world"])
        self.assertAlmostEqual(scores_a[0], scores_b[0])


# ---------------------------------------------------------------------------
# RerankerConfig
# ---------------------------------------------------------------------------

class TestRerankerConfig(unittest.TestCase):
    """Tests for RerankerConfig dataclass with slots."""

    def test_defaults(self):
        cfg = RerankerConfig()
        self.assertEqual(cfg.top_n, 50)
        self.assertEqual(cfg.batch_size, 32)
        self.assertAlmostEqual(cfg.score_threshold, 0.0)
        self.assertTrue(cfg.normalize)

    def test_custom_values(self):
        cfg = RerankerConfig(
            top_n=20, batch_size=8, score_threshold=0.5, normalize=False,
        )
        self.assertEqual(cfg.top_n, 20)
        self.assertEqual(cfg.batch_size, 8)
        self.assertAlmostEqual(cfg.score_threshold, 0.5)
        self.assertFalse(cfg.normalize)

    def test_has_slots(self):
        cfg = RerankerConfig()
        self.assertTrue(hasattr(cfg, "__slots__"))
        with self.assertRaises(AttributeError):
            cfg.nonexistent = True  # type: ignore[attr-defined]

    def test_repr(self):
        cfg = RerankerConfig()
        r = repr(cfg)
        self.assertIn("RerankerConfig", r)
        self.assertIn("top_n=50", r)


# ---------------------------------------------------------------------------
# RerankerPipeline.rerank()
# ---------------------------------------------------------------------------

class TestRerankerPipelineRerank(unittest.TestCase):
    """Tests for RerankerPipeline.rerank() -- pure cross-encoder reranking."""

    def setUp(self):
        self.encoder = DummyCrossEncoder()
        self.docs = {
            "d1": "python search engine implementation",
            "d2": "java spring boot application",
            "d3": "python machine learning tutorial search",
            "d4": "go microservices architecture",
            "d5": "python data search pipeline optimization",
        }
        self.doc_fn = _make_doc_fn(self.docs)

    def test_reranks_by_relevance(self):
        first_stage = [
            ("d2", 5.0),
            ("d1", 4.0),
            ("d3", 3.0),
            ("d4", 2.0),
            ("d5", 1.0),
        ]
        result = RerankerPipeline(self.encoder).rerank(
            "python search", first_stage, self.doc_fn,
        )
        top_ids = [doc_id for doc_id, _ in result[:3]]
        self.assertIn("d1", top_ids)
        self.assertIn("d3", top_ids)
        self.assertIn("d5", top_ids)

    def test_preserves_all_doc_ids(self):
        first_stage = [("d1", 5.0), ("d2", 4.0), ("d3", 3.0)]
        result = RerankerPipeline(self.encoder).rerank(
            "python", first_stage, self.doc_fn,
        )
        result_ids = {doc_id for doc_id, _ in result}
        self.assertEqual(result_ids, {"d1", "d2", "d3"})

    def test_empty_first_stage(self):
        result = RerankerPipeline(self.encoder).rerank(
            "query", [], self.doc_fn,
        )
        self.assertEqual(result, [])

    def test_single_candidate(self):
        result = RerankerPipeline(self.encoder).rerank(
            "python", [("d1", 5.0)], self.doc_fn,
        )
        self.assertEqual(len(result), 1)
        self.assertEqual(result[0][0], "d1")

    def test_top_k_limits_output(self):
        first_stage = [("d1", 5.0), ("d2", 4.0), ("d3", 3.0)]
        result = RerankerPipeline(self.encoder).rerank(
            "python", first_stage, self.doc_fn, top_k=2,
        )
        self.assertEqual(len(result), 2)

    def test_top_k_none_returns_all(self):
        first_stage = [("d1", 5.0), ("d2", 4.0), ("d3", 3.0)]
        result = RerankerPipeline(self.encoder).rerank(
            "python", first_stage, self.doc_fn, top_k=None,
        )
        self.assertEqual(len(result), 3)

    def test_sorted_descending(self):
        first_stage = [("d1", 5.0), ("d2", 4.0), ("d3", 3.0), ("d4", 2.0)]
        result = RerankerPipeline(self.encoder).rerank(
            "python search", first_stage, self.doc_fn,
        )
        scores = [s for _, s in result]
        self.assertEqual(scores, sorted(scores, reverse=True))


# ---------------------------------------------------------------------------
# RerankerPipeline -- top_n truncation
# ---------------------------------------------------------------------------

class TestRerankerPipelineTopN(unittest.TestCase):
    """Tests for top_n candidate truncation."""

    def test_top_n_limits_candidates(self):
        seen_count = []

        class TrackingEncoder(CrossEncoder):
            def score_batch(self, query, documents):
                seen_count.append(len(documents))
                return [0.5] * len(documents)

        cfg = RerankerConfig(top_n=3, batch_size=100)
        pipeline = RerankerPipeline(TrackingEncoder(), cfg)
        first_stage = [(f"d{i}", float(i)) for i in range(10)]
        docs = {f"d{i}": f"doc {i}" for i in range(10)}
        pipeline.rerank("test", first_stage, _make_doc_fn(docs))
        self.assertEqual(sum(seen_count), 3)


# ---------------------------------------------------------------------------
# RerankerPipeline -- batching
# ---------------------------------------------------------------------------

class TestRerankerPipelineBatching(unittest.TestCase):
    """Tests that batch_size splits documents correctly."""

    def test_batch_size_respected(self):
        call_sizes = []

        class TrackingEncoder(CrossEncoder):
            def score_batch(self, query, documents):
                call_sizes.append(len(documents))
                return [0.5] * len(documents)

        cfg = RerankerConfig(top_n=100, batch_size=2)
        pipeline = RerankerPipeline(TrackingEncoder(), cfg)
        first_stage = [(f"d{i}", float(i)) for i in range(5)]
        docs = {f"d{i}": f"doc {i}" for i in range(5)}
        pipeline.rerank("test", first_stage, _make_doc_fn(docs))
        self.assertEqual(call_sizes, [2, 2, 1])

    def test_batch_size_1_same_result_as_large_batch(self):
        encoder = DummyCrossEncoder()
        docs = {
            "d1": "alpha beta gamma",
            "d2": "alpha delta",
            "d3": "epsilon zeta",
        }
        first_stage = [("d1", 3.0), ("d2", 2.0), ("d3", 1.0)]

        cfg1 = RerankerConfig(batch_size=1, normalize=False)
        cfg100 = RerankerConfig(batch_size=100, normalize=False)
        r1 = RerankerPipeline(encoder, cfg1).rerank(
            "alpha beta", first_stage, _make_doc_fn(docs),
        )
        r100 = RerankerPipeline(encoder, cfg100).rerank(
            "alpha beta", first_stage, _make_doc_fn(docs),
        )
        self.assertEqual([d for d, _ in r1], [d for d, _ in r100])
        for (_, s1), (_, s100) in zip(r1, r100):
            self.assertAlmostEqual(s1, s100, places=5)


# ---------------------------------------------------------------------------
# RerankerPipeline -- score_threshold filtering
# ---------------------------------------------------------------------------

class TestRerankerPipelineThreshold(unittest.TestCase):
    """Tests for score_threshold filtering."""

    def test_threshold_filters_low_scores(self):
        class FixedEncoder(CrossEncoder):
            def score_batch(self, query, documents):
                return [float(i) for i in range(len(documents))]

        cfg = RerankerConfig(score_threshold=1.5, normalize=False, top_n=100)
        pipeline = RerankerPipeline(FixedEncoder(), cfg)
        first_stage = [(f"d{i}", 5.0) for i in range(5)]
        docs = {f"d{i}": f"doc {i}" for i in range(5)}
        result = pipeline.rerank("test", first_stage, _make_doc_fn(docs))
        self.assertEqual(len(result), 3)
        for _, score in result:
            self.assertGreaterEqual(score, 1.5)

    def test_zero_threshold_keeps_all(self):
        encoder = DummyCrossEncoder()
        cfg = RerankerConfig(score_threshold=0.0, normalize=False)
        first_stage = [("d1", 5.0), ("d2", 3.0)]
        docs = {"d1": "hello", "d2": "world"}
        result = RerankerPipeline(encoder, cfg).rerank(
            "nothing matches", first_stage, _make_doc_fn(docs),
        )
        self.assertEqual(len(result), 2)


# ---------------------------------------------------------------------------
# RerankerPipeline -- normalization
# ---------------------------------------------------------------------------

class TestRerankerPipelineNormalization(unittest.TestCase):
    """Tests for score normalization behavior."""

    def test_normalize_true_scores_in_unit_range(self):
        encoder = DummyCrossEncoder()
        cfg = RerankerConfig(normalize=True)
        first_stage = [("d1", 5.0), ("d2", 4.0), ("d3", 3.0)]
        docs = {
            "d1": "python search engine",
            "d2": "python code",
            "d3": "java application",
        }
        result = RerankerPipeline(encoder, cfg).rerank(
            "python search", first_stage, _make_doc_fn(docs),
        )
        for _, score in result:
            self.assertGreaterEqual(score, 0.0)
            self.assertLessEqual(score, 1.0)

    def test_normalize_false_raw_scores(self):
        class HighScorer(CrossEncoder):
            def score_batch(self, query, documents):
                return [99.0] * len(documents)

        cfg = RerankerConfig(normalize=False)
        pipeline = RerankerPipeline(HighScorer(), cfg)
        result = pipeline.rerank("test", [("d1", 1.0)], lambda _: "doc")
        self.assertAlmostEqual(result[0][1], 99.0)

    def test_all_equal_scores_normalize_to_zero(self):
        class ConstEncoder(CrossEncoder):
            def score_batch(self, query, documents):
                return [5.0] * len(documents)

        cfg = RerankerConfig(normalize=True)
        pipeline = RerankerPipeline(ConstEncoder(), cfg)
        result = pipeline.rerank(
            "test", [("d1", 1.0), ("d2", 2.0)], lambda _: "doc",
        )
        for _, score in result:
            self.assertAlmostEqual(score, 0.0)

    def test_normalize_single_result(self):
        encoder = DummyCrossEncoder()
        cfg = RerankerConfig(normalize=True)
        result = RerankerPipeline(encoder, cfg).rerank(
            "hello", [("d1", 5.0)], lambda _: "hello world",
        )
        self.assertEqual(len(result), 1)
        self.assertAlmostEqual(result[0][1], 0.0)


# ---------------------------------------------------------------------------
# RerankerPipeline.rerank_with_fusion() -- score fusion
# ---------------------------------------------------------------------------

class TestRerankerPipelineFusion(unittest.TestCase):
    """Tests for rerank_with_fusion() -- linear interpolation of scores."""

    def setUp(self):
        self.encoder = DummyCrossEncoder()
        self.docs = {
            "d1": "python search engine implementation",
            "d2": "java spring boot application",
            "d3": "python machine learning",
        }
        self.doc_fn = _make_doc_fn(self.docs)

    def test_fusion_returns_results(self):
        first_stage = [("d1", 5.0), ("d2", 4.0), ("d3", 3.0)]
        result = RerankerPipeline(self.encoder).rerank_with_fusion(
            "python search", first_stage, self.doc_fn,
        )
        self.assertGreater(len(result), 0)

    def test_fusion_alpha_one_equals_pure_reranker(self):
        first_stage = [("d1", 5.0), ("d2", 4.0), ("d3", 3.0)]
        cfg = RerankerConfig(normalize=True)
        pipeline = RerankerPipeline(self.encoder, cfg)
        result_pure = pipeline.rerank("python search", first_stage, self.doc_fn)
        result_fused = pipeline.rerank_with_fusion(
            "python search", first_stage, self.doc_fn, alpha=1.0,
        )
        ids_pure = [d for d, _ in result_pure]
        ids_fused = [d for d, _ in result_fused]
        self.assertEqual(ids_pure, ids_fused)

    def test_fusion_alpha_zero_respects_first_stage(self):
        first_stage = [("d2", 10.0), ("d1", 5.0), ("d3", 1.0)]
        result = RerankerPipeline(self.encoder).rerank_with_fusion(
            "python search", first_stage, self.doc_fn, alpha=0.0,
        )
        self.assertEqual(result[0][0], "d2")

    def test_fusion_top_k(self):
        first_stage = [("d1", 5.0), ("d2", 4.0), ("d3", 3.0)]
        result = RerankerPipeline(self.encoder).rerank_with_fusion(
            "python search", first_stage, self.doc_fn, top_k=2,
        )
        self.assertEqual(len(result), 2)

    def test_fusion_empty_first_stage(self):
        result = RerankerPipeline(self.encoder).rerank_with_fusion(
            "query", [], self.doc_fn,
        )
        self.assertEqual(result, [])

    def test_fusion_sorted_descending(self):
        first_stage = [("d1", 5.0), ("d2", 4.0), ("d3", 3.0)]
        result = RerankerPipeline(self.encoder).rerank_with_fusion(
            "python search", first_stage, self.doc_fn,
        )
        scores = [s for _, s in result]
        self.assertEqual(scores, sorted(scores, reverse=True))

    def test_fusion_scores_in_unit_range(self):
        first_stage = [("d1", 5.0), ("d2", 4.0), ("d3", 3.0)]
        result = RerankerPipeline(self.encoder).rerank_with_fusion(
            "python search", first_stage, self.doc_fn, alpha=0.5,
        )
        for _, score in result:
            self.assertGreaterEqual(score, 0.0)
            self.assertLessEqual(score, 1.0)


# ---------------------------------------------------------------------------
# Two-stage pipeline integration
# ---------------------------------------------------------------------------

class TestTwoStagePipeline(unittest.TestCase):
    """Tests for the full two-stage retrieve-then-rerank pattern."""

    def test_first_stage_then_rerank(self):
        # Simulate first-stage BM25 results
        first_stage = [
            ("doc_java", 8.0),
            ("doc_python_search", 6.0),
            ("doc_python_algo", 4.0),
            ("doc_go", 2.0),
        ]
        docs = {
            "doc_java": "java enterprise bean factory",
            "doc_python_search": "python search engine index query retrieval",
            "doc_python_algo": "python algorithm sorting",
            "doc_go": "go goroutine channel",
        }
        encoder = DummyCrossEncoder()
        pipeline = RerankerPipeline(encoder, RerankerConfig(normalize=True))
        result = pipeline.rerank(
            "python search", first_stage, _make_doc_fn(docs),
        )
        # Cross-encoder should promote python search doc to top
        self.assertEqual(result[0][0], "doc_python_search")

    def test_rerank_then_fusion(self):
        first_stage = [("d1", 10.0), ("d2", 5.0), ("d3", 1.0)]
        docs = {
            "d1": "nothing relevant",
            "d2": "python search code",
            "d3": "python search engine optimization",
        }
        encoder = DummyCrossEncoder()
        pipeline = RerankerPipeline(encoder)
        # Pure reranking should promote d3 (most terms match)
        pure = pipeline.rerank("python search", first_stage, _make_doc_fn(docs))
        # Fusion should blend first-stage (d1 high) with reranker (d3 high)
        fused = pipeline.rerank_with_fusion(
            "python search", first_stage, _make_doc_fn(docs), alpha=0.5,
        )
        # Both should return all 3 docs
        self.assertEqual(len(pure), 3)
        self.assertEqual(len(fused), 3)

    def test_fusion_changes_ranking_vs_pure_firststage(self):
        first_stage = [
            ("irrelevant", 10.0),
            ("relevant", 1.0),
        ]
        docs = {
            "irrelevant": "java enterprise bean",
            "relevant": "python search engine query",
        }
        encoder = DummyCrossEncoder()
        pipeline = RerankerPipeline(encoder)
        result = pipeline.rerank_with_fusion(
            "python search", first_stage, _make_doc_fn(docs), alpha=0.8,
        )
        # With alpha=0.8, the cross-encoder signal (relevant doc) should win
        self.assertEqual(result[0][0], "relevant")


# ---------------------------------------------------------------------------
# RerankerPipeline -- properties and repr
# ---------------------------------------------------------------------------

class TestRerankerPipelineProperties(unittest.TestCase):
    """Tests for RerankerPipeline accessor properties."""

    def test_encoder_property(self):
        encoder = DummyCrossEncoder()
        pipeline = RerankerPipeline(encoder)
        self.assertIs(pipeline.encoder, encoder)

    def test_config_property(self):
        cfg = RerankerConfig(top_n=42)
        pipeline = RerankerPipeline(DummyCrossEncoder(), cfg)
        self.assertIs(pipeline.config, cfg)

    def test_default_config(self):
        pipeline = RerankerPipeline(DummyCrossEncoder())
        self.assertIsInstance(pipeline.config, RerankerConfig)

    def test_repr(self):
        pipeline = RerankerPipeline(DummyCrossEncoder())
        r = repr(pipeline)
        self.assertIn("RerankerPipeline", r)
        self.assertIn("DummyCrossEncoder", r)


# ---------------------------------------------------------------------------
# CohereReranker / TransformerReranker -- import error handling
# ---------------------------------------------------------------------------

class TestCohereRerankerImportError(unittest.TestCase):
    """Tests that CohereReranker raises clear ImportError when cohere missing."""

    def test_instantiation_succeeds_without_cohere(self):
        reranker = CohereReranker(api_key="test-key")
        self.assertIsInstance(reranker, CrossEncoder)

    def test_score_batch_raises_import_error(self):
        reranker = CohereReranker(api_key="test-key")
        with self.assertRaises(ImportError) as ctx:
            reranker.score_batch("query", ["doc"])
        self.assertIn("cohere", str(ctx.exception))

    def test_repr(self):
        reranker = CohereReranker(api_key="test-key", model="test-model")
        self.assertIn("CohereReranker", repr(reranker))
        self.assertIn("test-model", repr(reranker))


class TestTransformerRerankerImportError(unittest.TestCase):
    """Tests that TransformerReranker raises clear ImportError when missing."""

    def test_instantiation_succeeds_without_lib(self):
        reranker = TransformerReranker()
        self.assertIsInstance(reranker, CrossEncoder)

    def test_score_batch_raises_import_error(self):
        reranker = TransformerReranker()
        with self.assertRaises(ImportError) as ctx:
            reranker.score_batch("query", ["doc"])
        self.assertIn("sentence-transformers", str(ctx.exception))

    def test_repr(self):
        reranker = TransformerReranker(model_name="test/model")
        self.assertIn("TransformerReranker", repr(reranker))
        self.assertIn("test/model", repr(reranker))


# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------

class TestRerankerEdgeCases(unittest.TestCase):
    """Tests for edge cases in the reranking pipeline."""

    def test_single_document(self):
        encoder = DummyCrossEncoder()
        cfg = RerankerConfig(normalize=True)
        pipeline = RerankerPipeline(encoder, cfg)
        result = pipeline.rerank("hello", [("d1", 5.0)], lambda _: "hello world")
        self.assertEqual(len(result), 1)
        self.assertEqual(result[0][0], "d1")

    def test_large_result_set(self):
        rng = random.Random(42)
        encoder = DummyCrossEncoder()
        cfg = RerankerConfig(top_n=150, normalize=True)
        pipeline = RerankerPipeline(encoder, cfg)

        words = ["python", "search", "engine", "data", "code", "test",
                 "file", "query", "index", "rank", "score", "match"]
        docs = {}
        first_stage = []
        for i in range(150):
            n_words = rng.randint(5, 20)
            text = " ".join(rng.choices(words, k=n_words))
            doc_id = f"d{i}"
            docs[doc_id] = text
            first_stage.append((doc_id, rng.uniform(0.0, 10.0)))

        result = pipeline.rerank("python search", first_stage, _make_doc_fn(docs))
        self.assertEqual(len(result), 150)
        scores = [s for _, s in result]
        self.assertEqual(scores, sorted(scores, reverse=True))

    def test_negative_cross_encoder_scores(self):
        class NegativeScorer(CrossEncoder):
            def score_batch(self, query, documents):
                return [-1.0 * (i + 1) for i in range(len(documents))]

        cfg = RerankerConfig(normalize=False)
        pipeline = RerankerPipeline(NegativeScorer(), cfg)
        first_stage = [("d1", 5.0), ("d2", 4.0), ("d3", 3.0)]
        docs = {"d1": "a", "d2": "b", "d3": "c"}
        result = pipeline.rerank("test", first_stage, _make_doc_fn(docs))
        self.assertEqual(len(result), 3)
        # d1 gets -1.0 (highest), d3 gets -3.0 (lowest)
        self.assertEqual(result[0][0], "d1")
        self.assertEqual(result[-1][0], "d3")
        for _, score in result:
            self.assertLess(score, 0.0)

    def test_negative_scores_with_normalization(self):
        class NegativeScorer(CrossEncoder):
            def score_batch(self, query, documents):
                return [-10.0, -5.0, 0.0]

        cfg = RerankerConfig(normalize=True)
        pipeline = RerankerPipeline(NegativeScorer(), cfg)
        first_stage = [("d1", 1.0), ("d2", 2.0), ("d3", 3.0)]
        result = pipeline.rerank("q", first_stage, lambda _: "doc")
        for _, score in result:
            self.assertGreaterEqual(score, 0.0)
            self.assertLessEqual(score, 1.0)

    def test_empty_query_string(self):
        encoder = DummyCrossEncoder()
        cfg = RerankerConfig(normalize=False)
        pipeline = RerankerPipeline(encoder, cfg)
        first_stage = [("d1", 5.0)]
        result = pipeline.rerank("", first_stage, lambda _: "hello world")
        self.assertEqual(len(result), 1)
        self.assertAlmostEqual(result[0][1], 0.0)


# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------

class TestSimpleTokenize(unittest.TestCase):
    """Tests for _simple_tokenize helper."""

    def test_basic(self):
        tokens = _simple_tokenize("Hello World")
        self.assertEqual(tokens, ["hello", "world"])

    def test_underscores_kept(self):
        tokens = _simple_tokenize("my_function_name")
        self.assertEqual(tokens, ["my_function_name"])

    def test_punctuation_stripped(self):
        tokens = _simple_tokenize("hello, world! foo-bar")
        self.assertEqual(tokens, ["hello", "world", "foo", "bar"])

    def test_empty_string(self):
        self.assertEqual(_simple_tokenize(""), [])

    def test_numbers(self):
        tokens = _simple_tokenize("version 3 python3")
        self.assertEqual(tokens, ["version", "3", "python3"])


class TestMinmaxNormalize(unittest.TestCase):
    """Tests for _minmax_normalize helper."""

    def test_basic(self):
        result = _minmax_normalize([1.0, 5.0, 3.0])
        self.assertAlmostEqual(result[0], 0.0)
        self.assertAlmostEqual(result[1], 1.0)
        self.assertAlmostEqual(result[2], 0.5)

    def test_empty_list(self):
        self.assertEqual(_minmax_normalize([]), [])

    def test_single_value(self):
        result = _minmax_normalize([5.0])
        self.assertAlmostEqual(result[0], 0.0)

    def test_all_equal(self):
        result = _minmax_normalize([3.0, 3.0, 3.0])
        self.assertEqual(result, [0.0, 0.0, 0.0])


# ---------------------------------------------------------------------------
# Performance
# ---------------------------------------------------------------------------

class TestRerankerPerformance(unittest.TestCase):
    """Performance tests for the reranking pipeline."""

    def test_rerank_100_docs_under_500ms(self):
        """Reranking 100 documents with DummyCrossEncoder should be fast."""
        rng = random.Random(42)
        words = ["python", "search", "engine", "data", "code",
                 "test", "file", "query", "index", "rank"]
        docs = {}
        first_stage = []
        for i in range(100):
            text = " ".join(rng.choices(words, k=rng.randint(10, 50)))
            doc_id = f"d{i}"
            docs[doc_id] = text
            first_stage.append((doc_id, rng.uniform(0.0, 10.0)))

        encoder = DummyCrossEncoder()
        cfg = RerankerConfig(top_n=100, normalize=True)
        pipeline = RerankerPipeline(encoder, cfg)

        t0 = time.perf_counter()
        result = pipeline.rerank("python search", first_stage, _make_doc_fn(docs))
        elapsed = time.perf_counter() - t0

        self.assertEqual(len(result), 100)
        self.assertLess(elapsed, 0.5, f"Rerank 100 docs took {elapsed:.3f}s")

    def test_rerank_with_fusion_100_docs_under_500ms(self):
        """Fusion reranking 100 documents should be fast."""
        rng = random.Random(99)
        words = ["alpha", "beta", "gamma", "delta", "epsilon",
                 "search", "code", "python", "function", "class"]
        docs = {}
        first_stage = []
        for i in range(100):
            text = " ".join(rng.choices(words, k=rng.randint(10, 50)))
            doc_id = f"d{i}"
            docs[doc_id] = text
            first_stage.append((doc_id, rng.uniform(0.0, 10.0)))

        encoder = DummyCrossEncoder()
        pipeline = RerankerPipeline(encoder, RerankerConfig(top_n=100))

        t0 = time.perf_counter()
        result = pipeline.rerank_with_fusion(
            "python search", first_stage, _make_doc_fn(docs), alpha=0.7,
        )
        elapsed = time.perf_counter() - t0

        self.assertEqual(len(result), 100)
        self.assertLess(elapsed, 0.5, f"Fusion rerank 100 docs took {elapsed:.3f}s")


if __name__ == "__main__":
    unittest.main()
