"""Tests for rbsp.query.proximity -- SpanQuery, ProximityScorer, span_match, _min_distance."""

import os
import sys
import unittest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from rbsp.query.proximity import SpanQuery, ProximityScorer, span_match, _min_distance


# ---------------------------------------------------------------------------
# SpanQuery
# ---------------------------------------------------------------------------

class TestSpanQuery(unittest.TestCase):
    """Tests for the SpanQuery dataclass."""

    def test_fields(self):
        sq = SpanQuery(terms=["hello", "world"], max_span=5)
        self.assertEqual(sq.terms, ["hello", "world"])
        self.assertEqual(sq.max_span, 5)

    def test_frozen(self):
        sq = SpanQuery(terms=["a"], max_span=3)
        with self.assertRaises(AttributeError):
            sq.max_span = 10


# ---------------------------------------------------------------------------
# _min_distance
# ---------------------------------------------------------------------------

class TestMinDistance(unittest.TestCase):
    """Tests for the _min_distance helper."""

    def test_identical_positions(self):
        self.assertEqual(_min_distance([5], [5]), 0)

    def test_adjacent_positions(self):
        self.assertEqual(_min_distance([1], [2]), 1)

    def test_multiple_positions_finds_minimum(self):
        self.assertEqual(_min_distance([1, 10, 20], [5, 15, 25]), 4)

    def test_overlapping_lists(self):
        self.assertEqual(_min_distance([1, 3, 7], [3, 5, 9]), 0)

    def test_non_overlapping_lists(self):
        self.assertEqual(_min_distance([1, 2, 3], [10, 11, 12]), 7)

    def test_single_element_lists(self):
        self.assertEqual(_min_distance([0], [100]), 100)

    def test_interleaved(self):
        self.assertEqual(_min_distance([1, 5, 9], [3, 7, 11]), 2)


# ---------------------------------------------------------------------------
# ProximityScorer — score
# ---------------------------------------------------------------------------

class TestProximityScorerScore(unittest.TestCase):
    """Tests for ProximityScorer.score."""

    def setUp(self):
        self.scorer = ProximityScorer(decay=0.9)

    def test_single_term_returns_zero(self):
        score = self.scorer.score(["alpha"], {"alpha": [1, 5, 10]})
        self.assertAlmostEqual(score, 0.0)

    def test_no_terms_present_returns_zero(self):
        score = self.scorer.score(["alpha", "beta"], {})
        self.assertAlmostEqual(score, 0.0)

    def test_one_term_present_returns_zero(self):
        score = self.scorer.score(["alpha", "beta"], {"alpha": [1, 5]})
        self.assertAlmostEqual(score, 0.0)

    def test_two_terms_adjacent(self):
        # min_distance = 1, score = decay^(1-1) / 1 = 1.0
        score = self.scorer.score(
            ["alpha", "beta"],
            {"alpha": [1], "beta": [2]},
        )
        self.assertAlmostEqual(score, 1.0)

    def test_two_terms_distance_two(self):
        # min_distance = 2, score = 0.9^(2-1) / 1 = 0.9
        score = self.scorer.score(
            ["alpha", "beta"],
            {"alpha": [1], "beta": [3]},
        )
        self.assertAlmostEqual(score, 0.9)

    def test_two_terms_same_position(self):
        # min_distance = 0, score = 0.9^(-1) / 1 -- decay^(0-1)
        # But distance of 0 means: 0.9^(0-1) = 0.9^-1 ~ 1.111
        score = self.scorer.score(
            ["alpha", "beta"],
            {"alpha": [5], "beta": [5]},
        )
        expected = 0.9 ** (-1) / 1
        self.assertAlmostEqual(score, expected)

    def test_three_terms(self):
        # Three terms -> 3 pairs: (a,b), (a,c), (b,c)
        positions = {
            "a": [1],
            "b": [2],
            "c": [4],
        }
        score = self.scorer.score(["a", "b", "c"], positions)
        # min_distance(a,b)=1 -> 0.9^0 = 1.0
        # min_distance(a,c)=3 -> 0.9^2 = 0.81
        # min_distance(b,c)=2 -> 0.9^1 = 0.9
        expected = (1.0 + 0.81 + 0.9) / 3
        self.assertAlmostEqual(score, expected)

    def test_empty_positions_list_ignored(self):
        score = self.scorer.score(
            ["alpha", "beta"],
            {"alpha": [1], "beta": []},
        )
        self.assertAlmostEqual(score, 0.0)

    def test_decay_one(self):
        scorer = ProximityScorer(decay=1.0)
        # With decay=1.0, all distances contribute equally: 1.0^anything = 1.0
        score = scorer.score(
            ["a", "b"],
            {"a": [1], "b": [100]},
        )
        self.assertAlmostEqual(score, 1.0)


# ---------------------------------------------------------------------------
# ProximityScorer — score_normalized
# ---------------------------------------------------------------------------

class TestProximityScorerNormalized(unittest.TestCase):
    """Tests for ProximityScorer.score_normalized."""

    def setUp(self):
        self.scorer = ProximityScorer(decay=0.9)

    def test_zero_when_no_match(self):
        norm = self.scorer.score_normalized(["a"], {"a": [1]})
        self.assertAlmostEqual(norm, 0.0)

    def test_in_zero_one_range(self):
        norm = self.scorer.score_normalized(
            ["a", "b"],
            {"a": [1], "b": [2]},
        )
        self.assertGreater(norm, 0.0)
        self.assertLess(norm, 1.0)

    def test_saturating_normalization(self):
        # raw / (raw + 1)
        raw = self.scorer.score(
            ["a", "b"],
            {"a": [1], "b": [2]},
        )
        expected = raw / (raw + 1.0)
        norm = self.scorer.score_normalized(
            ["a", "b"],
            {"a": [1], "b": [2]},
        )
        self.assertAlmostEqual(norm, expected)


# ---------------------------------------------------------------------------
# ProximityScorer — decay validation
# ---------------------------------------------------------------------------

class TestProximityScorerValidation(unittest.TestCase):
    """Tests for ProximityScorer decay validation."""

    def test_decay_zero_raises(self):
        with self.assertRaises(ValueError):
            ProximityScorer(decay=0.0)

    def test_decay_negative_raises(self):
        with self.assertRaises(ValueError):
            ProximityScorer(decay=-0.5)

    def test_decay_greater_than_one_raises(self):
        with self.assertRaises(ValueError):
            ProximityScorer(decay=1.1)

    def test_decay_one_is_valid(self):
        scorer = ProximityScorer(decay=1.0)
        self.assertIsNotNone(scorer)

    def test_decay_small_positive_is_valid(self):
        scorer = ProximityScorer(decay=0.01)
        self.assertIsNotNone(scorer)


# ---------------------------------------------------------------------------
# ProximityScorer.minimum_span
# ---------------------------------------------------------------------------

class TestMinimumSpan(unittest.TestCase):
    """Tests for ProximityScorer.minimum_span."""

    def test_empty_list(self):
        self.assertEqual(ProximityScorer.minimum_span([]), 0)

    def test_single_list(self):
        self.assertEqual(ProximityScorer.minimum_span([[1, 5, 10]]), 0)

    def test_two_lists_adjacent(self):
        span = ProximityScorer.minimum_span([[1, 5], [2, 8]])
        self.assertEqual(span, 1)

    def test_two_lists_overlapping(self):
        span = ProximityScorer.minimum_span([[1, 5, 10], [5, 15]])
        self.assertEqual(span, 0)

    def test_three_lists(self):
        span = ProximityScorer.minimum_span([[1, 10], [3, 12], [5, 14]])
        # Best window: [1,3,5] -> 5-1=4 or [10,12,14] -> 14-10=4
        self.assertEqual(span, 4)

    def test_raises_on_empty_position_list(self):
        with self.assertRaises(ValueError):
            ProximityScorer.minimum_span([[1, 2], []])

    def test_single_element_lists(self):
        span = ProximityScorer.minimum_span([[1], [10], [20]])
        self.assertEqual(span, 19)

    def test_perfect_overlap(self):
        span = ProximityScorer.minimum_span([[5], [5], [5]])
        self.assertEqual(span, 0)


# ---------------------------------------------------------------------------
# span_match
# ---------------------------------------------------------------------------

class TestSpanMatch(unittest.TestCase):
    """Tests for the span_match function."""

    def test_empty_terms(self):
        self.assertEqual(span_match({"a": [1, 2]}, [], 5), [])

    def test_missing_term(self):
        self.assertEqual(span_match({"a": [1, 2]}, ["a", "b"], 5), [])

    def test_single_term_returns_all_positions(self):
        result = span_match({"a": [1, 5, 10]}, ["a"], 3)
        self.assertEqual(result, [1, 5, 10])

    def test_two_terms_within_span(self):
        result = span_match(
            {"a": [1, 10], "b": [3, 20]},
            ["a", "b"],
            max_span=5,
        )
        # Window [1, 3] -> span = 2, fits in 5 -> start = 1
        # Window [3, 10] -> span = 7, does not fit
        # Window [10, 20] -> span = 10, does not fit
        self.assertEqual(result, [1])

    def test_two_terms_all_within_span(self):
        result = span_match(
            {"a": [1, 5], "b": [2, 6]},
            ["a", "b"],
            max_span=2,
        )
        # [1,2] -> span=1, fits -> 1
        # [2,5] -> span=3, no
        # [5,6] -> span=1, fits -> 5
        self.assertEqual(result, [1, 5])

    def test_empty_positions_returns_empty(self):
        result = span_match({"a": [1], "b": []}, ["a", "b"], 10)
        self.assertEqual(result, [])

    def test_large_span_matches_all(self):
        result = span_match(
            {"a": [1, 100], "b": [50, 150]},
            ["a", "b"],
            max_span=1000,
        )
        # All windows fit
        # [1, 50] -> span=49, fits -> 1
        # [50, 100] -> span=50, fits -> 50
        # [100, 150] -> span=50, fits -> 100
        self.assertEqual(result, [1, 50, 100])


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