"""Stdlib-only tests (no pytest): plain asserts in functions called from a
__main__ block. Run: `python3 test_bloom_filter.py`.

The headline tests pin the exact numbers this toy's commentary quotes -- the
751 false positives, the permanence of `key:431`, and the fact that k=20 is
worse than k=7 -- so those claims cannot rot silently. The rest are unit
checks on the guarantee and on the load-bearing lines found by counterfactual.
"""

from bloom_filter import BloomFilter, bits_per_key_for, optimal_k, predicted_fp

M, N, PROBES = 1000, 100, 100_000
MEMBERS = [f"key:{i}" for i in range(N)]
ABSENT = [f"key:{i}" for i in range(N, N + PROBES)]


def build(salt="A", k=7, m=M, members=MEMBERS):
    filt = BloomFilter(m=m, k=k, salt=salt)
    for key in members:
        filt.add(key)
    return filt


def test_no_false_negatives_ever():
    """The one hard guarantee: a member is never reported absent."""
    filt = build()
    assert all(key in filt for key in MEMBERS)


def test_headline_false_positive_count():
    """Pins the number §6 is built on: 751 of 100000 absent keys, 0.751%."""
    filt = build()
    fps = [key for key in ABSENT if key in filt]
    assert len(fps) == 751, len(fps)
    assert sum(filt.bits) == 492, sum(filt.bits)
    assert fps[0] == "key:431", fps[0]


def test_a_false_positive_is_permanent_not_probabilistic():
    """The aha. Same key, same filter, 1000 queries, 1000 identical answers."""
    filt = build()
    assert all("key:431" in filt for _ in range(1000))
    # And a clean absent key stays clean just as reliably.
    assert not any("key:100" in filt for _ in range(1000))


def test_key_431_is_an_exact_fingerprint_clone_of_member_key_45():
    """Why key:431 is poisoned: _indices reduces a key to (a % m, b % m),
    which is 500000 states, not 2**256. key:431 lands on key:45's."""
    filt = build()
    assert sorted(filt._indices("key:431")) == sorted(filt._indices("key:45"))
    assert "key:45" in MEMBERS


def test_only_rehashing_clears_the_poison():
    """Retry can't help -- the positions are fixed. A new salt can."""
    a, b = build(salt="A"), build(salt="B")
    assert "key:431" in a
    assert "key:431" not in b


def test_more_hashing_is_not_more_accuracy():
    """§6's boundary condition: k=20 does 2.9x the work for 7.1x the error."""
    best = build(k=7)
    worse = build(k=20)
    best_fps = sum(1 for key in ABSENT if key in best)
    worse_fps = sum(1 for key in ABSENT if key in worse)
    assert best_fps == 751, best_fps
    assert worse_fps == 5344, worse_fps
    assert worse.fill_ratio() == 0.861, worse.fill_ratio()


def test_optimal_k_and_the_formula_agree_with_the_page():
    assert optimal_k(M, N) == 7
    assert round(predicted_fp(M, 7, N), 6) == 0.008194
    assert round(bits_per_key_for(0.01), 2) == 9.59
    # optimal_k floors at 1: a filter with fewer than 1.44 bits per key would
    # otherwise be told to use zero hashes, which admits everything.
    assert optimal_k(m=100, n=1000) == 1


def test_odd_b_gives_every_key_k_distinct_positions():
    """`b | 1` is load-bearing: without it ~0.9% of keys collapse to fewer
    than k positions, because b can be a multiple of a divisor of m."""
    filt = build()
    assert all(len(set(filt._indices(key))) == 7 for key in MEMBERS + ABSENT[:5000])


def test_bits_are_never_cleared_so_the_poisoned_set_only_grows():
    small = build(members=MEMBERS[:50])
    big = build(members=MEMBERS)
    poisoned_small = {key for key in ABSENT[:5000] if key in small}
    poisoned_big = {key for key in ABSENT[:5000] if key in big}
    assert poisoned_small < poisoned_big          # strict superset, no losses
    assert len(poisoned_big - poisoned_small) == 27


def test_deleting_by_clearing_bits_destroys_the_guarantee():
    """Why there is no remove(): the bits are shared, so clearing one key's
    seven positions evicts eight other members as collateral damage."""
    filt = build()
    for index in filt._indices("key:0"):
        filt.bits[index] = 0
    absent_members = [key for key in MEMBERS if key not in filt]
    assert len(absent_members) == 9, absent_members
    assert absent_members[:3] == ["key:0", "key:22", "key:41"]


TESTS = [
    test_no_false_negatives_ever,
    test_headline_false_positive_count,
    test_a_false_positive_is_permanent_not_probabilistic,
    test_key_431_is_an_exact_fingerprint_clone_of_member_key_45,
    test_only_rehashing_clears_the_poison,
    test_more_hashing_is_not_more_accuracy,
    test_optimal_k_and_the_formula_agree_with_the_page,
    test_odd_b_gives_every_key_k_distinct_positions,
    test_bits_are_never_cleared_so_the_poisoned_set_only_grows,
    test_deleting_by_clearing_bits_destroys_the_guarantee,
]


if __name__ == "__main__":
    failed = 0
    for test in TESTS:
        try:
            test()
        except AssertionError as exc:
            failed += 1
            print(f"FAIL  {test.__name__}: {exc}")
        else:
            print(f"PASS  {test.__name__}")
    if failed:
        print(f"\n{failed} of {len(TESTS)} tests FAILED")
        raise SystemExit(1)
    print(f"\nAll {len(TESTS)} tests PASSED")
