"""Tests for rbsp.core.lsh -- shingling, MinHash, and LSH index."""

import os
import sys
import unittest

sys.path.insert(0, os.path.dirname(__file__))
from conftest import RBSPTestCase, SAMPLE_TEXTS

from rbsp.core.lsh import (
    LSHIndex,
    MinHasher,
    ngram_shingles,
    word_shingles,
)


class TestNgramShingles(unittest.TestCase):
    """Test character n-gram extraction."""

    def test_basic(self):
        shingles = ngram_shingles("abcde", n=3)
        self.assertEqual(shingles, {hash("abc"), hash("bcd"), hash("cde")})

    def test_normalizes_to_lowercase(self):
        shingles = ngram_shingles("ABC", n=3)
        self.assertIn(hash("abc"), shingles)

    def test_too_short(self):
        self.assertEqual(ngram_shingles("ab", n=3), set())

    def test_exact_length(self):
        shingles = ngram_shingles("abc", n=3)
        self.assertEqual(shingles, {hash("abc")})

    def test_empty_string(self):
        self.assertEqual(ngram_shingles("", n=3), set())

    def test_custom_n(self):
        shingles = ngram_shingles("hello", n=2)
        self.assertEqual(shingles, {hash("he"), hash("el"), hash("ll"), hash("lo")})

    def test_overlap_counted_once(self):
        # "aaa" with n=2 -> {hash("aa")} (set deduplicates)
        shingles = ngram_shingles("aaa", n=2)
        self.assertEqual(shingles, {hash("aa")})


class TestWordShingles(unittest.TestCase):
    """Test word-level n-gram extraction."""

    def test_basic(self):
        shingles = word_shingles("the quick brown fox", n=2)
        self.assertIn(hash("the quick"), shingles)
        self.assertIn(hash("quick brown"), shingles)
        self.assertIn(hash("brown fox"), shingles)
        self.assertEqual(len(shingles), 3)

    def test_too_few_words(self):
        self.assertEqual(word_shingles("hello", n=2), set())

    def test_normalizes_to_lowercase(self):
        shingles = word_shingles("Hello World", n=2)
        self.assertIn(hash("hello world"), shingles)

    def test_trigrams(self):
        shingles = word_shingles("a b c d", n=3)
        self.assertIn(hash("a b c"), shingles)
        self.assertIn(hash("b c d"), shingles)
        self.assertEqual(len(shingles), 2)


class TestMinHasher(unittest.TestCase):
    """Test MinHash signature computation and Jaccard estimation."""

    def setUp(self):
        self.hasher = MinHasher(num_hashes=128, seed=42)

    def test_signature_length(self):
        sig = self.hasher.signature({"a", "b", "c"})
        self.assertEqual(len(sig), 128)

    def test_deterministic(self):
        shingles = {"hello", "world"}
        sig1 = self.hasher.signature(shingles)
        sig2 = self.hasher.signature(shingles)
        self.assertEqual(sig1, sig2)

    def test_identical_sets_jaccard_one(self):
        s = {"a", "b", "c"}
        sig_a = self.hasher.signature(s)
        sig_b = self.hasher.signature(s)
        j = MinHasher.estimate_jaccard(sig_a, sig_b)
        self.assertEqual(j, 1.0)

    def test_disjoint_sets_low_jaccard(self):
        sig_a = self.hasher.signature({"a", "b", "c", "d", "e"})
        sig_b = self.hasher.signature({"v", "w", "x", "y", "z"})
        j = MinHasher.estimate_jaccard(sig_a, sig_b)
        self.assertLess(j, 0.2)

    def test_overlapping_sets_moderate_jaccard(self):
        sig_a = self.hasher.signature({"a", "b", "c", "d"})
        sig_b = self.hasher.signature({"c", "d", "e", "f"})
        j = MinHasher.estimate_jaccard(sig_a, sig_b)
        # True Jaccard = 2/6 = 0.333...
        self.assertGreater(j, 0.1)
        self.assertLess(j, 0.7)

    def test_estimate_accuracy_high_overlap(self):
        """For highly overlapping sets the MinHash estimate should be close."""
        set_a = {f"item_{i}" for i in range(100)}
        set_b = {f"item_{i}" for i in range(90)}  # 90% overlap
        sig_a = self.hasher.signature(set_a)
        sig_b = self.hasher.signature(set_b)
        j = MinHasher.estimate_jaccard(sig_a, sig_b)
        # True Jaccard = 90/100 = 0.9
        self.assertGreater(j, 0.7)

    def test_mismatched_lengths_raise(self):
        sig_a = MinHasher(num_hashes=64).signature({"a"})
        sig_b = MinHasher(num_hashes=128).signature({"a"})
        with self.assertRaises(ValueError):
            MinHasher.estimate_jaccard(sig_a, sig_b)

    def test_empty_sigs(self):
        j = MinHasher.estimate_jaccard([], [])
        self.assertEqual(j, 0.0)

    def test_different_seed_different_sig(self):
        h1 = MinHasher(num_hashes=64, seed=1)
        h2 = MinHasher(num_hashes=64, seed=2)
        s1 = h1.signature({"a", "b"})
        s2 = h2.signature({"a", "b"})
        self.assertNotEqual(s1, s2)


class TestLSHIndex(unittest.TestCase):
    """Test the band-based LSH index for approximate nearest neighbors."""

    def setUp(self):
        self.lsh = LSHIndex(num_hashes=128, bands=16, rows=8, seed=42)

    def test_insert_and_len(self):
        self.assertEqual(len(self.lsh), 0)
        self.lsh.insert("doc1", ngram_shingles("hello world"))
        self.assertEqual(len(self.lsh), 1)

    def test_contains(self):
        self.lsh.insert("doc1", ngram_shingles("hello world"))
        self.assertIn("doc1", self.lsh)
        self.assertNotIn("doc2", self.lsh)

    def test_remove(self):
        self.lsh.insert("doc1", ngram_shingles("hello world"))
        result = self.lsh.remove("doc1")
        self.assertTrue(result)
        self.assertNotIn("doc1", self.lsh)
        self.assertEqual(len(self.lsh), 0)

    def test_remove_absent(self):
        self.assertFalse(self.lsh.remove("nonexistent"))

    def test_query_finds_similar(self):
        self.lsh.insert("fox1", ngram_shingles(SAMPLE_TEXTS[0]))
        self.lsh.insert("fox2", ngram_shingles(SAMPLE_TEXTS[1]))
        self.lsh.insert("prog", ngram_shingles(SAMPLE_TEXTS[2]))

        results = self.lsh.query(ngram_shingles(SAMPLE_TEXTS[0]), top_k=5)
        # The query text is identical to fox1, so fox1 should be in results
        ids = [r[0] for r in results]
        self.assertIn("fox1", ids)

    def test_query_by_id(self):
        self.lsh.insert("a", ngram_shingles(SAMPLE_TEXTS[0]))
        self.lsh.insert("b", ngram_shingles(SAMPLE_TEXTS[1]))
        self.lsh.insert("c", ngram_shingles(SAMPLE_TEXTS[2]))

        results = self.lsh.query_by_id("a", top_k=5)
        # "a" itself should not be in results
        ids = [r[0] for r in results]
        self.assertNotIn("a", ids)

    def test_query_by_id_missing_raises(self):
        with self.assertRaises(KeyError):
            self.lsh.query_by_id("missing")

    def test_top_k_limits_results(self):
        for i in range(20):
            self.lsh.insert(f"doc{i}", ngram_shingles(f"document number {i} with some content"))
        results = self.lsh.query(
            ngram_shingles("document number 5 with some content"),
            top_k=3,
        )
        self.assertLessEqual(len(results), 3)

    def test_min_similarity_filters(self):
        self.lsh.insert("a", ngram_shingles("completely unrelated alpha"))
        self.lsh.insert("b", ngram_shingles("totally different beta text"))
        results = self.lsh.query(
            ngram_shingles("something else entirely"),
            min_similarity=0.99,
        )
        # Very high threshold should filter out most results
        for _, sim in results:
            self.assertGreaterEqual(sim, 0.99)

    def test_results_sorted_descending(self):
        texts = [
            "the quick brown fox jumps over the lazy dog",
            "the quick brown fox leaps over the lazy dog",
            "programming in python is fun and productive",
        ]
        for i, t in enumerate(texts):
            self.lsh.insert(i, ngram_shingles(t))
        results = self.lsh.query(ngram_shingles(texts[0]), top_k=10)
        if len(results) > 1:
            for j in range(len(results) - 1):
                self.assertGreaterEqual(results[j][1], results[j + 1][1])

    def test_threshold_property(self):
        t = self.lsh.threshold
        self.assertGreater(t, 0.0)
        self.assertLess(t, 1.0)

    def test_invalid_params_raise(self):
        with self.assertRaises(ValueError):
            LSHIndex(num_hashes=100, bands=16, rows=8)  # 100 != 16*8

    def test_insert_returns_signature(self):
        sig = self.lsh.insert("doc", ngram_shingles("test content"))
        self.assertEqual(len(sig), 128)
        self.assertIsInstance(sig, list)


class TestLSHSimilarityOrdering(unittest.TestCase):
    """Verify that LSH ranks similar documents higher than dissimilar ones."""

    def test_similar_ranked_above_dissimilar(self):
        lsh = LSHIndex(num_hashes=128, bands=16, rows=8)
        lsh.insert("similar", ngram_shingles(SAMPLE_TEXTS[1]))
        lsh.insert("different", ngram_shingles(SAMPLE_TEXTS[2]))

        results = lsh.query(ngram_shingles(SAMPLE_TEXTS[0]), top_k=10)
        if len(results) >= 2:
            id_order = [r[0] for r in results]
            # "similar" (SAMPLE_TEXTS[1]) should rank before "different"
            if "similar" in id_order and "different" in id_order:
                self.assertLess(
                    id_order.index("similar"),
                    id_order.index("different"),
                )


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