"""Tests for rbsp.indexer.deletion — tombstone bitmap soft deletes."""

from __future__ import annotations

import threading

import pytest

from rbsp.indexer.deletion import DeletionBitmap, DeletionTracker


# -----------------------------------------------------------------------
# DeletionBitmap — basics
# -----------------------------------------------------------------------

class TestDeletionBitmapBasics:

    def test_empty_bitmap(self):
        bm = DeletionBitmap()
        assert bm.deleted_count() == 0
        assert bm.deleted_docs() == set()
        assert not bm.is_deleted(0)
        assert bm.is_live(0)

    def test_preallocated_bitmap(self):
        bm = DeletionBitmap(max_doc_id=128)
        assert bm.deleted_count() == 0

    def test_delete_and_query(self):
        bm = DeletionBitmap()
        bm.delete(7)
        assert bm.is_deleted(7)
        assert not bm.is_live(7)
        assert bm.deleted_count() == 1

    def test_delete_idempotent(self):
        bm = DeletionBitmap()
        bm.delete(3)
        bm.delete(3)
        bm.delete(3)
        assert bm.deleted_count() == 1

    def test_undelete(self):
        bm = DeletionBitmap()
        bm.delete(10)
        assert bm.is_deleted(10)
        bm.undelete(10)
        assert not bm.is_deleted(10)
        assert bm.deleted_count() == 0

    def test_undelete_noop_for_unset(self):
        bm = DeletionBitmap()
        bm.undelete(999)
        assert bm.deleted_count() == 0

    def test_undelete_out_of_range(self):
        bm = DeletionBitmap(max_doc_id=8)
        bm.undelete(1000)  # should not raise
        assert bm.deleted_count() == 0

    def test_live_count(self):
        bm = DeletionBitmap()
        bm.delete(0)
        bm.delete(5)
        assert bm.live_count(100) == 98

    def test_deleted_docs_set(self):
        bm = DeletionBitmap()
        ids = {2, 17, 63, 64, 100}
        for i in ids:
            bm.delete(i)
        assert bm.deleted_docs() == ids

    def test_auto_grow(self):
        bm = DeletionBitmap(max_doc_id=8)
        bm.delete(500)
        assert bm.is_deleted(500)
        assert bm.deleted_count() == 1

    def test_is_deleted_out_of_range(self):
        bm = DeletionBitmap(max_doc_id=8)
        assert not bm.is_deleted(9999)

    def test_repr(self):
        bm = DeletionBitmap()
        bm.delete(1)
        r = repr(bm)
        assert "deleted=1" in r


# -----------------------------------------------------------------------
# DeletionBitmap — serialization
# -----------------------------------------------------------------------

class TestDeletionBitmapSerialization:

    def test_round_trip_empty(self):
        bm = DeletionBitmap()
        data = bm.to_bytes()
        bm2 = DeletionBitmap.from_bytes(data)
        assert bm2.deleted_count() == 0
        assert bm2.deleted_docs() == set()

    def test_round_trip_with_data(self):
        bm = DeletionBitmap()
        for i in [0, 1, 7, 8, 15, 16, 255, 1000]:
            bm.delete(i)
        data = bm.to_bytes()
        bm2 = DeletionBitmap.from_bytes(data)
        assert bm2.deleted_count() == bm.deleted_count()
        assert bm2.deleted_docs() == bm.deleted_docs()

    def test_invalid_header(self):
        with pytest.raises(ValueError, match="Invalid"):
            DeletionBitmap.from_bytes(b"XXXX\x01\x00\x00\x00\x00\x00\x00\x00\x00")

    def test_unsupported_version(self):
        # Craft valid header but wrong version
        import struct
        data = b"RBDL" + struct.pack("<BII", 99, 0, 0)
        with pytest.raises(ValueError, match="version"):
            DeletionBitmap.from_bytes(data)


# -----------------------------------------------------------------------
# DeletionBitmap — merge
# -----------------------------------------------------------------------

class TestDeletionBitmapMerge:

    def test_merge_disjoint(self):
        a = DeletionBitmap()
        a.delete(1)
        a.delete(3)
        b = DeletionBitmap()
        b.delete(2)
        b.delete(4)
        c = a.merge(b)
        assert c.deleted_docs() == {1, 2, 3, 4}
        assert c.deleted_count() == 4

    def test_merge_overlapping(self):
        a = DeletionBitmap()
        a.delete(1)
        a.delete(3)
        b = DeletionBitmap()
        b.delete(3)
        b.delete(5)
        c = a.merge(b)
        assert c.deleted_docs() == {1, 3, 5}
        assert c.deleted_count() == 3

    def test_merge_empty(self):
        a = DeletionBitmap()
        a.delete(10)
        b = DeletionBitmap()
        c = a.merge(b)
        assert c.deleted_docs() == {10}

    def test_merge_both_empty(self):
        a = DeletionBitmap()
        b = DeletionBitmap()
        c = a.merge(b)
        assert c.deleted_count() == 0

    def test_merge_different_sizes(self):
        a = DeletionBitmap()
        a.delete(1)
        b = DeletionBitmap()
        b.delete(500)
        c = a.merge(b)
        assert c.deleted_docs() == {1, 500}

    def test_merge_does_not_mutate_originals(self):
        a = DeletionBitmap()
        a.delete(1)
        b = DeletionBitmap()
        b.delete(2)
        _ = a.merge(b)
        assert a.deleted_docs() == {1}
        assert b.deleted_docs() == {2}


# -----------------------------------------------------------------------
# DeletionBitmap — thread safety
# -----------------------------------------------------------------------

class TestDeletionBitmapThreadSafety:

    def test_concurrent_deletes(self):
        bm = DeletionBitmap()
        errors = []

        def worker(start: int, count: int):
            try:
                for i in range(start, start + count):
                    bm.delete(i)
            except Exception as exc:
                errors.append(exc)

        threads = [threading.Thread(target=worker, args=(i * 100, 100)) for i in range(8)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert not errors
        assert bm.deleted_count() == 800


# -----------------------------------------------------------------------
# DeletionTracker
# -----------------------------------------------------------------------

class TestDeletionTracker:

    def test_delete_and_check(self):
        tracker = DeletionTracker()
        tracker.delete_document("seg1", 10)
        assert tracker.is_deleted("seg1", 10)
        assert not tracker.is_deleted("seg1", 11)

    def test_cross_segment_isolation(self):
        tracker = DeletionTracker()
        tracker.delete_document("seg1", 5)
        assert not tracker.is_deleted("seg2", 5)

    def test_unknown_segment_not_deleted(self):
        tracker = DeletionTracker()
        assert not tracker.is_deleted("nonexistent", 0)

    def test_delete_by_query(self):
        tracker = DeletionTracker()
        tracker.delete_by_query("seg1", {10, 20, 30})
        assert tracker.is_deleted("seg1", 10)
        assert tracker.is_deleted("seg1", 20)
        assert tracker.is_deleted("seg1", 30)
        assert not tracker.is_deleted("seg1", 15)

    def test_pending_deletions(self):
        tracker = DeletionTracker()
        assert tracker.pending_deletions("seg1") == 0
        tracker.delete_document("seg1", 1)
        tracker.delete_document("seg1", 2)
        assert tracker.pending_deletions("seg1") == 2

    def test_pending_deletions_unknown_segment(self):
        tracker = DeletionTracker()
        assert tracker.pending_deletions("unknown") == 0

    def test_get_bitmap_creates_on_demand(self):
        tracker = DeletionTracker()
        bm = tracker.get_bitmap("new_seg")
        assert isinstance(bm, DeletionBitmap)
        assert bm.deleted_count() == 0

    def test_get_bitmap_returns_same_instance(self):
        tracker = DeletionTracker()
        bm1 = tracker.get_bitmap("seg1")
        bm2 = tracker.get_bitmap("seg1")
        assert bm1 is bm2

    def test_should_merge_no_segment(self):
        tracker = DeletionTracker()
        assert not tracker.should_merge("nonexistent")

    def test_should_merge_below_threshold(self):
        tracker = DeletionTracker()
        # Delete 1 out of 100 allocated bit slots
        tracker.delete_document("seg1", 0)
        assert not tracker.should_merge("seg1", threshold=0.3)

    def test_should_merge_above_threshold(self):
        tracker = DeletionTracker()
        for i in range(50):
            tracker.delete_document("seg1", i)
        # 50 deleted out of ~56 allocated bits (7 bytes * 8 = 56)
        assert tracker.should_merge("seg1", threshold=0.3)

    def test_should_merge_custom_threshold(self):
        tracker = DeletionTracker()
        for i in range(50):
            tracker.delete_document("seg1", i)
        assert not tracker.should_merge("seg1", threshold=0.99)
