"""Tests for rbsp.query.explain -- Explanation, format_explanation, ExplainableBM25."""

import math
import os
import sys
import unittest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from rbsp.query.explain import Explanation, format_explanation, ExplainableBM25


# ---------------------------------------------------------------------------
# Explanation dataclass
# ---------------------------------------------------------------------------

class TestExplanation(unittest.TestCase):
    """Tests for the Explanation dataclass."""

    def test_basic_fields(self):
        e = Explanation(score=1.5, description="test node")
        self.assertAlmostEqual(e.score, 1.5)
        self.assertEqual(e.description, "test node")
        self.assertEqual(e.details, [])

    def test_details_default_is_empty_list(self):
        e = Explanation(score=0.0, description="empty")
        self.assertIsInstance(e.details, list)
        self.assertEqual(len(e.details), 0)

    def test_details_with_children(self):
        child = Explanation(score=0.5, description="child")
        parent = Explanation(score=1.0, description="parent", details=[child])
        self.assertEqual(len(parent.details), 1)
        self.assertIs(parent.details[0], child)

    def test_str_calls_format_explanation(self):
        e = Explanation(score=2.0, description="root")
        result = str(e)
        self.assertIn("2.00", result)
        self.assertIn("root", result)


# ---------------------------------------------------------------------------
# format_explanation
# ---------------------------------------------------------------------------

class TestFormatExplanation(unittest.TestCase):
    """Tests for format_explanation."""

    def test_single_node(self):
        e = Explanation(score=3.14, description="pi score")
        text = format_explanation(e)
        self.assertEqual(text, "3.14 = pi score")

    def test_indented_children(self):
        child = Explanation(score=1.0, description="child node")
        parent = Explanation(score=2.0, description="parent node", details=[child])
        text = format_explanation(parent)
        lines = text.split("\n")
        self.assertEqual(len(lines), 2)
        # Parent is at indent=0
        self.assertTrue(lines[0].startswith("2.00"))
        # Child is at indent=2
        self.assertTrue(lines[1].startswith("  1.00"))

    def test_custom_indent(self):
        e = Explanation(score=1.0, description="indented")
        text = format_explanation(e, indent=4)
        self.assertTrue(text.startswith("    1.00"))

    def test_nested_three_levels(self):
        grandchild = Explanation(score=0.1, description="level 3")
        child = Explanation(score=0.5, description="level 2", details=[grandchild])
        root = Explanation(score=1.0, description="level 1", details=[child])
        text = format_explanation(root)
        lines = text.split("\n")
        self.assertEqual(len(lines), 3)
        # root: indent 0, child: indent 2, grandchild: indent 4
        self.assertFalse(lines[0].startswith(" "))
        self.assertTrue(lines[1].startswith("  "))
        self.assertTrue(lines[2].startswith("    "))

    def test_multiple_children(self):
        c1 = Explanation(score=0.3, description="c1")
        c2 = Explanation(score=0.7, description="c2")
        root = Explanation(score=1.0, description="sum", details=[c1, c2])
        text = format_explanation(root)
        lines = text.split("\n")
        self.assertEqual(len(lines), 3)

    def test_score_formatting_two_decimals(self):
        e = Explanation(score=1.23456, description="precise")
        text = format_explanation(e)
        self.assertIn("1.23", text)
        # Should not show more than 2 decimal places in the formatted number
        self.assertNotIn("1.2345", text)


# ---------------------------------------------------------------------------
# ExplainableBM25
# ---------------------------------------------------------------------------

class TestExplainableBM25(unittest.TestCase):
    """Tests for ExplainableBM25.explain."""

    def setUp(self):
        self.scorer = ExplainableBM25(k1=1.2, b=0.75)
        self.num_docs = 100
        self.avg_doc_len = 50.0
        self.doc_freqs = {"python": 10, "java": 20, "code": 80}

    def test_default_parameters(self):
        s = ExplainableBM25()
        self.assertAlmostEqual(s.k1, 1.2)
        self.assertAlmostEqual(s.b, 0.75)

    def test_custom_parameters(self):
        s = ExplainableBM25(k1=2.0, b=0.5)
        self.assertAlmostEqual(s.k1, 2.0)
        self.assertAlmostEqual(s.b, 0.5)

    def test_explain_returns_explanation(self):
        result = self.scorer.explain(
            query_terms=["python"],
            doc_term_freqs={"python": 3},
            doc_len=50,
            num_docs=self.num_docs,
            avg_doc_len=self.avg_doc_len,
            doc_freqs=self.doc_freqs,
        )
        self.assertIsInstance(result, Explanation)

    def test_root_description_is_sum_of(self):
        result = self.scorer.explain(
            query_terms=["python"],
            doc_term_freqs={"python": 3},
            doc_len=50,
            num_docs=self.num_docs,
            avg_doc_len=self.avg_doc_len,
            doc_freqs=self.doc_freqs,
        )
        self.assertEqual(result.description, "sum of:")

    def test_single_term_present(self):
        result = self.scorer.explain(
            query_terms=["python"],
            doc_term_freqs={"python": 3},
            doc_len=50,
            num_docs=self.num_docs,
            avg_doc_len=self.avg_doc_len,
            doc_freqs=self.doc_freqs,
        )
        self.assertGreater(result.score, 0.0)
        self.assertEqual(len(result.details), 1)
        term_exp = result.details[0]
        self.assertIn("python", term_exp.description)
        self.assertIn("BM25", term_exp.description)
        # Should have IDF and TF sub-explanations
        self.assertEqual(len(term_exp.details), 2)

    def test_single_term_absent(self):
        result = self.scorer.explain(
            query_terms=["missing"],
            doc_term_freqs={"python": 3},
            doc_len=50,
            num_docs=self.num_docs,
            avg_doc_len=self.avg_doc_len,
            doc_freqs=self.doc_freqs,
        )
        self.assertAlmostEqual(result.score, 0.0)
        self.assertEqual(len(result.details), 1)
        term_exp = result.details[0]
        self.assertAlmostEqual(term_exp.score, 0.0)
        self.assertIn("tf=0", term_exp.description)
        # Absent term still has IDF sub-explanation
        self.assertEqual(len(term_exp.details), 1)

    def test_multi_term_present(self):
        result = self.scorer.explain(
            query_terms=["python", "java"],
            doc_term_freqs={"python": 3, "java": 2},
            doc_len=50,
            num_docs=self.num_docs,
            avg_doc_len=self.avg_doc_len,
            doc_freqs=self.doc_freqs,
        )
        self.assertGreater(result.score, 0.0)
        self.assertEqual(len(result.details), 2)
        # Total should be sum of children
        child_sum = sum(d.score for d in result.details)
        self.assertAlmostEqual(result.score, child_sum)

    def test_multi_term_partial_match(self):
        result = self.scorer.explain(
            query_terms=["python", "missing"],
            doc_term_freqs={"python": 3},
            doc_len=50,
            num_docs=self.num_docs,
            avg_doc_len=self.avg_doc_len,
            doc_freqs=self.doc_freqs,
        )
        self.assertGreater(result.score, 0.0)
        self.assertEqual(len(result.details), 2)
        # "missing" should contribute 0
        self.assertAlmostEqual(result.details[1].score, 0.0)

    def test_idf_breakdown(self):
        result = self.scorer.explain(
            query_terms=["python"],
            doc_term_freqs={"python": 1},
            doc_len=50,
            num_docs=self.num_docs,
            avg_doc_len=self.avg_doc_len,
            doc_freqs=self.doc_freqs,
        )
        term_exp = result.details[0]
        idf_exp = term_exp.details[0]  # First child is IDF
        # Verify IDF value
        expected_idf = math.log((100 - 10 + 0.5) / (10 + 0.5) + 1.0)
        self.assertAlmostEqual(idf_exp.score, expected_idf)
        self.assertIn("idf", idf_exp.description)
        self.assertIn("docFreq=10", idf_exp.description)
        self.assertIn("numDocs=100", idf_exp.description)

    def test_tf_breakdown(self):
        result = self.scorer.explain(
            query_terms=["python"],
            doc_term_freqs={"python": 5},
            doc_len=50,
            num_docs=self.num_docs,
            avg_doc_len=self.avg_doc_len,
            doc_freqs=self.doc_freqs,
        )
        term_exp = result.details[0]
        tf_exp = term_exp.details[1]  # Second child is TF
        # Compute expected TF normalization
        tf = 5
        k1, b = 1.2, 0.75
        numerator = tf * (k1 + 1.0)
        denominator = tf + k1 * (1.0 - b + b * 50 / 50.0)
        expected_tf = numerator / denominator
        self.assertAlmostEqual(tf_exp.score, expected_tf)
        self.assertIn("tf_norm", tf_exp.description)

    def test_score_matches_manual_bm25(self):
        """Verify the total score matches a manual BM25 computation."""
        tf = 3
        df = 10
        N = 100
        avgdl = 50.0
        dl = 50
        k1, b = 1.2, 0.75

        idf = math.log((N - df + 0.5) / (df + 0.5) + 1.0)
        tf_norm = (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * dl / avgdl))
        expected = idf * tf_norm

        result = self.scorer.explain(
            query_terms=["python"],
            doc_term_freqs={"python": tf},
            doc_len=dl,
            num_docs=N,
            avg_doc_len=avgdl,
            doc_freqs={"python": df},
        )
        self.assertAlmostEqual(result.score, expected)

    def test_empty_query(self):
        result = self.scorer.explain(
            query_terms=[],
            doc_term_freqs={"python": 3},
            doc_len=50,
            num_docs=self.num_docs,
            avg_doc_len=self.avg_doc_len,
            doc_freqs=self.doc_freqs,
        )
        self.assertAlmostEqual(result.score, 0.0)
        self.assertEqual(len(result.details), 0)

    def test_str_output_is_readable(self):
        result = self.scorer.explain(
            query_terms=["python", "java"],
            doc_term_freqs={"python": 3, "java": 2},
            doc_len=50,
            num_docs=self.num_docs,
            avg_doc_len=self.avg_doc_len,
            doc_freqs=self.doc_freqs,
        )
        text = str(result)
        self.assertIn("sum of:", text)
        self.assertIn("python", text)
        self.assertIn("java", text)
        self.assertIn("idf", text)


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