"""Tests for rbsp.core.bloom -- BloomFilter and CountingBloomFilter."""

import os
import sys
import unittest

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

from rbsp.core.bloom import BloomFilter, CountingBloomFilter


class TestBloomFilterMembership(unittest.TestCase):
    """Basic membership semantics."""

    def setUp(self):
        self.bf = BloomFilter(expected_items=1000, fp_rate=0.01)

    def test_empty_filter_contains_nothing(self):
        self.assertNotIn("anything", self.bf)
        self.assertNotIn(b"bytes", self.bf)
        self.assertNotIn(42, self.bf)

    def test_add_and_check_string(self):
        self.bf.add("hello")
        self.assertIn("hello", self.bf)

    def test_add_and_check_bytes(self):
        self.bf.add(b"raw")
        self.assertIn(b"raw", self.bf)

    def test_add_and_check_int(self):
        self.bf.add(12345)
        self.assertIn(12345, self.bf)

    def test_not_added_not_found(self):
        self.bf.add("present")
        self.assertNotIn("absent", self.bf)

    def test_count_tracks_insertions(self):
        self.assertEqual(len(self.bf), 0)
        self.bf.add("a")
        self.bf.add("b")
        self.assertEqual(len(self.bf), 2)

    def test_clear_resets(self):
        self.bf.add("a")
        self.bf.clear()
        self.assertEqual(len(self.bf), 0)
        self.assertNotIn("a", self.bf)

    def test_many_insertions_no_false_negatives(self):
        """Bloom filters NEVER produce false negatives."""
        items = [f"item_{i}" for i in range(500)]
        for item in items:
            self.bf.add(item)
        for item in items:
            self.assertIn(item, self.bf)


class TestBloomFilterFalsePositiveRate(unittest.TestCase):
    """Verify that the observed FP rate stays within bounds."""

    def test_fp_rate_within_tolerance(self):
        n = 5000
        bf = BloomFilter(expected_items=n, fp_rate=0.01)
        for i in range(n):
            bf.add(f"inserted_{i}")

        # Check 10000 items that were NOT inserted
        false_positives = 0
        test_count = 10000
        for i in range(test_count):
            if f"not_inserted_{i}" in bf:
                false_positives += 1

        observed_rate = false_positives / test_count
        # Allow up to 3x the target rate (statistical headroom)
        self.assertLess(observed_rate, 0.03,
                        f"FP rate {observed_rate:.4f} exceeds 3x target of 0.01")

    def test_estimated_fp_rate_property(self):
        bf = BloomFilter(expected_items=1000, fp_rate=0.01)
        self.assertEqual(bf.estimated_fp_rate, 0.0)
        for i in range(100):
            bf.add(f"item_{i}")
        rate = bf.estimated_fp_rate
        self.assertGreater(rate, 0.0)
        self.assertLess(rate, 1.0)


class TestBloomFilterProperties(unittest.TestCase):
    """Test properties and configuration."""

    def test_num_bits_positive(self):
        bf = BloomFilter(expected_items=100)
        self.assertGreater(bf.num_bits, 0)

    def test_num_hashes_positive(self):
        bf = BloomFilter(expected_items=100)
        self.assertGreater(bf.num_hashes, 0)

    def test_lower_fp_rate_more_bits(self):
        bf_loose = BloomFilter(expected_items=1000, fp_rate=0.1)
        bf_tight = BloomFilter(expected_items=1000, fp_rate=0.001)
        self.assertGreater(bf_tight.num_bits, bf_loose.num_bits)

    def test_invalid_fp_rate_raises(self):
        with self.assertRaises(ValueError):
            BloomFilter(expected_items=100, fp_rate=0.0)
        with self.assertRaises(ValueError):
            BloomFilter(expected_items=100, fp_rate=1.0)
        with self.assertRaises(ValueError):
            BloomFilter(expected_items=100, fp_rate=-0.1)


class TestBloomFilterSerialization(unittest.TestCase):
    """Test binary round-trip serialization."""

    def test_serialize_and_restore(self):
        bf = BloomFilter(expected_items=500, fp_rate=0.01)
        items = [f"ser_{i}" for i in range(200)]
        for item in items:
            bf.add(item)

        data = bf.to_bytes()
        bf2 = BloomFilter.from_bytes(data)

        for item in items:
            self.assertIn(item, bf2)
        self.assertNotIn("never_added", bf2)

    def test_serialized_size_reasonable(self):
        bf = BloomFilter(expected_items=1000, fp_rate=0.01)
        data = bf.to_bytes()
        # Header (16 bytes) + bit array
        import math
        expected_min = 16 + math.ceil(bf.num_bits / 8)
        self.assertGreaterEqual(len(data), expected_min)

    def test_from_bytes_too_short(self):
        with self.assertRaises(ValueError):
            BloomFilter.from_bytes(b"\x00" * 8)


class TestBloomFilterUnion(unittest.TestCase):
    """Test merging two bloom filters."""

    def test_union_contains_both(self):
        bf1 = BloomFilter(expected_items=100, fp_rate=0.01)
        bf2 = BloomFilter(expected_items=100, fp_rate=0.01)
        bf1.add("only_in_1")
        bf2.add("only_in_2")

        merged = bf1.union(bf2)
        self.assertIn("only_in_1", merged)
        self.assertIn("only_in_2", merged)

    def test_union_incompatible_raises(self):
        bf1 = BloomFilter(expected_items=100, fp_rate=0.01)
        bf2 = BloomFilter(expected_items=10000, fp_rate=0.01)
        with self.assertRaises(ValueError):
            bf1.union(bf2)


class TestCountingBloomFilter(unittest.TestCase):
    """Test counting bloom filter with deletion support."""

    def setUp(self):
        self.cbf = CountingBloomFilter(expected_items=1000, fp_rate=0.01)

    def test_add_and_check(self):
        self.cbf.add("hello")
        self.assertIn("hello", self.cbf)

    def test_remove_success(self):
        self.cbf.add("removable")
        self.assertIn("removable", self.cbf)
        result = self.cbf.remove("removable")
        self.assertTrue(result)
        self.assertNotIn("removable", self.cbf)

    def test_remove_absent_returns_false(self):
        result = self.cbf.remove("never_added")
        self.assertFalse(result)

    def test_add_remove_add(self):
        self.cbf.add("cycle")
        self.cbf.remove("cycle")
        self.assertNotIn("cycle", self.cbf)
        self.cbf.add("cycle")
        self.assertIn("cycle", self.cbf)

    def test_multiple_adds_require_multiple_removes(self):
        # Adding same item twice should require two removes to fully clear
        # (if no counter collision with other items)
        self.cbf.add("dup")
        self.cbf.add("dup")
        self.cbf.remove("dup")
        # May still appear present due to remaining counter > 0
        # This is expected behavior for counting bloom filters
        self.cbf.remove("dup")
        self.assertNotIn("dup", self.cbf)

    def test_count(self):
        self.assertEqual(len(self.cbf), 0)
        self.cbf.add("a")
        self.cbf.add("b")
        self.assertEqual(len(self.cbf), 2)
        self.cbf.remove("a")
        self.assertEqual(len(self.cbf), 1)

    def test_clear(self):
        self.cbf.add("x")
        self.cbf.clear()
        self.assertEqual(len(self.cbf), 0)
        self.assertNotIn("x", self.cbf)

    def test_properties(self):
        self.assertGreater(self.cbf.num_slots, 0)
        self.assertGreater(self.cbf.num_hashes, 0)

    def test_no_false_negatives(self):
        items = [f"cbf_{i}" for i in range(300)]
        for item in items:
            self.cbf.add(item)
        for item in items:
            self.assertIn(item, self.cbf)

    def test_bytes_and_ints(self):
        self.cbf.add(b"bytes_val")
        self.cbf.add(99)
        self.assertIn(b"bytes_val", self.cbf)
        self.assertIn(99, self.cbf)
        self.cbf.remove(b"bytes_val")
        self.assertNotIn(b"bytes_val", self.cbf)


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