"""Tests for rbsp.query.phrase -- PhraseQuery, PhraseScorer."""

import os
import sys
import unittest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from rbsp.query.phrase import PhraseQuery, PhraseScorer


class TestPhraseQuery(unittest.TestCase):
    """Tests for PhraseQuery dataclass."""

    def test_defaults(self):
        pq = PhraseQuery(terms=("hello", "world"))
        self.assertEqual(pq.terms, ("hello", "world"))
        self.assertEqual(pq.slop, 0)

    def test_custom_slop(self):
        pq = PhraseQuery(terms=("a", "b"), slop=3)
        self.assertEqual(pq.slop, 3)


class TestPhraseScorer(unittest.TestCase):
    """Tests for PhraseScorer scoring and matching."""

    def test_exact_phrase_match(self):
        """'hello world' found where hello@pos0, world@pos1."""
        position_data = {
            "hello": {"doc1": [0]},
            "world": {"doc1": [1]},
        }
        scorer = PhraseScorer(position_data)
        query = PhraseQuery(terms=("hello", "world"), slop=0)
        score = scorer.score_document(query, "doc1")
        self.assertGreater(score, 0.0)

    def test_no_phrase_match(self):
        """Terms present but not adjacent — no match with slop=0."""
        position_data = {
            "hello": {"doc1": [0]},
            "world": {"doc1": [5]},
        }
        scorer = PhraseScorer(position_data)
        query = PhraseQuery(terms=("hello", "world"), slop=0)
        score = scorer.score_document(query, "doc1")
        self.assertEqual(score, 0.0)

    def test_phrase_with_slop(self):
        """'hello world' with slop=1 matches when 1 word between them."""
        position_data = {
            "hello": {"doc1": [0]},
            "world": {"doc1": [2]},  # gap of 1 word between
        }
        scorer = PhraseScorer(position_data)
        query = PhraseQuery(terms=("hello", "world"), slop=1)
        score = scorer.score_document(query, "doc1")
        self.assertGreater(score, 0.0)

    def test_phrase_multiple_occurrences(self):
        """Phrase appears twice; score reflects both occurrences."""
        position_data = {
            "hello": {"doc1": [0, 10]},
            "world": {"doc1": [1, 11]},
        }
        scorer = PhraseScorer(position_data)
        query = PhraseQuery(terms=("hello", "world"), slop=0)
        score_two = scorer.score_document(query, "doc1")

        # Compare against single occurrence
        position_data_single = {
            "hello": {"doc1": [0]},
            "world": {"doc1": [1]},
        }
        scorer_single = PhraseScorer(position_data_single)
        score_one = scorer_single.score_document(query, "doc1")

        self.assertGreater(score_two, score_one)

    def test_single_term_phrase(self):
        """Single term 'hello' always matches if term is present."""
        position_data = {
            "hello": {"doc1": [0, 5, 10]},
        }
        scorer = PhraseScorer(position_data)
        query = PhraseQuery(terms=("hello",), slop=0)
        score = scorer.score_document(query, "doc1")
        self.assertGreater(score, 0.0)

    def test_empty_phrase(self):
        """Empty term list returns 0.0."""
        position_data = {
            "hello": {"doc1": [0]},
        }
        scorer = PhraseScorer(position_data)
        query = PhraseQuery(terms=(), slop=0)
        score = scorer.score_document(query, "doc1")
        self.assertEqual(score, 0.0)

    def test_find_matches_multiple_docs(self):
        """find_matches returns scored results across several documents."""
        position_data = {
            "hello": {"doc1": [0], "doc2": [3], "doc3": [0]},
            "world": {"doc1": [1], "doc2": [4], "doc3": [7]},
        }
        scorer = PhraseScorer(position_data)
        query = PhraseQuery(terms=("hello", "world"), slop=0)
        results = scorer.find_matches(query, {"doc1", "doc2", "doc3"})

        # doc1 and doc2 have adjacent terms; doc3 does not (gap of 7)
        matched_ids = [doc_id for doc_id, _ in results]
        self.assertIn("doc1", matched_ids)
        self.assertIn("doc2", matched_ids)
        self.assertNotIn("doc3", matched_ids)
        # Results sorted by descending score
        scores = [s for _, s in results]
        self.assertEqual(scores, sorted(scores, reverse=True))

    def test_score_tighter_match_higher(self):
        """Exact (slop=0) match scores higher than sloppy match."""
        position_data_exact = {
            "hello": {"doc1": [0]},
            "world": {"doc1": [1]},
        }
        position_data_sloppy = {
            "hello": {"doc1": [0]},
            "world": {"doc1": [3]},
        }
        query = PhraseQuery(terms=("hello", "world"), slop=3)

        score_exact = PhraseScorer(position_data_exact).score_document(query, "doc1")
        score_sloppy = PhraseScorer(position_data_sloppy).score_document(query, "doc1")

        self.assertGreater(score_exact, score_sloppy)

    def test_terms_reversed_no_match(self):
        """'world hello' doesn't match positions for 'hello world' with slop=0."""
        position_data = {
            "hello": {"doc1": [0]},
            "world": {"doc1": [1]},
        }
        scorer = PhraseScorer(position_data)
        # Query for "world hello" — reversed order
        query = PhraseQuery(terms=("world", "hello"), slop=0)
        score = scorer.score_document(query, "doc1")
        self.assertEqual(score, 0.0)

    def test_terms_reversed_with_slop(self):
        """With enough slop, reversed terms can match if positions allow it."""
        # "world" at pos 0, "hello" at pos 1 — query is ("hello", "world")
        # reversed: "world" needs to come after "hello", but "world" is before
        # To allow reversed: need "hello" at pos 5, "world" at pos 0
        # query ("world", "hello"): world@0, hello must be at 1..1+slop
        # With hello at pos 5 and slop=4: window is [1, 5] — matches
        position_data = {
            "world": {"doc1": [0]},
            "hello": {"doc1": [5]},
        }
        scorer = PhraseScorer(position_data)
        query_no_slop = PhraseQuery(terms=("world", "hello"), slop=0)
        query_slop = PhraseQuery(terms=("world", "hello"), slop=4)

        score_no = scorer.score_document(query_no_slop, "doc1")
        score_yes = scorer.score_document(query_slop, "doc1")

        self.assertEqual(score_no, 0.0)
        self.assertGreater(score_yes, 0.0)

    def test_missing_term_returns_zero(self):
        """If any query term is absent from position data, score is 0.0."""
        position_data = {
            "hello": {"doc1": [0, 5, 10]},
        }
        scorer = PhraseScorer(position_data)
        query = PhraseQuery(terms=("hello", "missing"), slop=5)
        score = scorer.score_document(query, "doc1")
        self.assertEqual(score, 0.0)

    def test_missing_doc_returns_zero(self):
        """Scoring a doc_id not in any position list returns 0.0."""
        position_data = {
            "hello": {"doc1": [0]},
            "world": {"doc1": [1]},
        }
        scorer = PhraseScorer(position_data)
        query = PhraseQuery(terms=("hello", "world"), slop=0)
        score = scorer.score_document(query, "doc_missing")
        self.assertEqual(score, 0.0)

    def test_score_in_unit_range(self):
        """All scores must be in [0.0, 1.0]."""
        position_data = {
            "a": {"d1": [0, 4, 8, 12, 16]},
            "b": {"d1": [1, 5, 9, 13, 17]},
            "c": {"d1": [2, 6, 10, 14, 18]},
        }
        scorer = PhraseScorer(position_data)
        query = PhraseQuery(terms=("a", "b", "c"), slop=0)
        score = scorer.score_document(query, "d1")
        self.assertGreaterEqual(score, 0.0)
        self.assertLessEqual(score, 1.0)


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