"""Comprehensive tests for CachedKvClient: queue, cache, eviction, multi-stream."""

import threading
import time
from unittest.mock import MagicMock

import pytest

from zg_storage.kv.cached_client import CachedKvClient

STREAM_A = b"\x01" * 32
STREAM_B = b"\x02" * 32
STREAM_A_HEX = "0x" + "01" * 32


def _make_client(
    max_queue_size=10,
    max_cache_entries=10000,
    upload_return=("0xtx", "0xroot", 0),
    **kwargs,
):
    """Create a CachedKvClient with mocked kv_client and indexer."""
    mock_kv = MagicMock()
    mock_indexer = MagicMock()

    # Each upload returns a unique tx_seq via side_effect
    call_count = [0]
    if isinstance(upload_return, tuple):

        def upload_side_effect(**kw):
            call_count[0] += 1
            return (upload_return[0], upload_return[1], call_count[0] - 1)

        mock_indexer.upload.side_effect = upload_side_effect
    else:
        mock_indexer.upload.return_value = upload_return

    client = CachedKvClient.__new__(CachedKvClient)
    client._init_common(
        kv_client=mock_kv,
        indexer=mock_indexer,
        max_queue_size=max_queue_size,
        max_cache_entries=max_cache_entries,
        **kwargs,
    )
    return client, mock_kv, mock_indexer


# ---------------------------------------------------------------------------
# Layer 1: current writes (uncommitted)
# ---------------------------------------------------------------------------


class TestCurrentWrites:
    def test_get_returns_uncommitted_write(self):
        client, _, _ = _make_client()
        client.set(STREAM_A, b"k1", b"v1")
        assert client.get_bytes(STREAM_A, b"k1") == b"v1"
        client.close()

    def test_overwrite_returns_latest(self):
        client, _, _ = _make_client()
        client.set(STREAM_A, b"k1", b"first")
        client.set(STREAM_A, b"k1", b"second")
        assert client.get_bytes(STREAM_A, b"k1") == b"second"
        client.close()

    def test_hex_stream_id(self):
        client, _, _ = _make_client()
        client.set(STREAM_A_HEX, b"k1", b"v1")
        assert client.get_bytes(STREAM_A, b"k1") == b"v1"
        client.close()


# ---------------------------------------------------------------------------
# Layer 2: committed cache
# ---------------------------------------------------------------------------


class TestCommittedCache:
    def test_get_from_cache_after_commit(self):
        """After commit, key moves from current writes to cache."""
        client, _, _ = _make_client()
        client.set(STREAM_A, b"k1", b"v1")
        client.commit()
        # No longer in current writes, but in cache
        assert client.get_bytes(STREAM_A, b"k1") == b"v1"
        client.close()

    def test_set_after_commit_works(self):
        """set/get works immediately after commit (non-blocking)."""
        client, _, _ = _make_client()
        client.set(STREAM_A, b"k1", b"v1")
        client.commit()
        client.set(STREAM_A, b"k2", b"v2")
        assert client.get_bytes(STREAM_A, b"k2") == b"v2"
        assert client.get_bytes(STREAM_A, b"k1") == b"v1"
        client.close()

    def test_commit_nothing_is_noop(self):
        """commit() with no pending writes silently returns."""
        client, _, mock_indexer = _make_client()
        client.commit()  # no writes — should be a no-op
        client.commit()  # still no-op
        mock_indexer.upload.assert_not_called()
        client.close()

    def test_cache_size_property(self):
        client, _, _ = _make_client()
        assert client.cache_size == 0
        client.set(STREAM_A, b"k1", b"v1")
        assert client.cache_size == 0  # not committed yet
        client.commit()
        assert client.cache_size == 1
        client.close()

    def test_current_write_shadows_cache(self):
        """Current write takes priority over committed cache."""
        client, _, _ = _make_client()
        client.set(STREAM_A, b"k1", b"old")
        client.commit()
        client.set(STREAM_A, b"k1", b"new")
        assert client.get_bytes(STREAM_A, b"k1") == b"new"
        client.close()


# ---------------------------------------------------------------------------
# Layer 3: KV node fallback
# ---------------------------------------------------------------------------


class TestKvNodeFallback:
    def test_get_falls_through_to_kv_node(self):
        """Key not in writes or cache -> falls through to KV node."""
        client, mock_kv, _ = _make_client()
        from zg_storage.types import Value
        from zg_storage.utils import encode_bytes_base64

        mock_kv.get_value.return_value = Value(
            version=1,
            data=encode_bytes_base64(b"remote_val"),
            size=10,
        )
        assert client.get_bytes(STREAM_A, b"missing") == b"remote_val"
        mock_kv.get_value.assert_called_once()
        client.close()


# ---------------------------------------------------------------------------
# Upload queue
# ---------------------------------------------------------------------------


class TestUploadQueue:
    def test_flush_waits_for_uploads(self):
        client, _, mock_indexer = _make_client()
        client.set(STREAM_A, b"k1", b"v1")
        client.commit()
        client.flush(timeout=5)
        mock_indexer.upload.assert_called_once()
        client.close()

    def test_flush_timeout(self):
        """flush times out when upload takes too long."""
        client, _, mock_indexer = _make_client()

        # Make upload block
        barrier = threading.Event()
        mock_indexer.upload.side_effect = lambda **kw: (
            barrier.wait(timeout=5),
            ("tx", "root", 0),
        )[-1]

        client.set(STREAM_A, b"k1", b"v1")
        client.commit()
        with pytest.raises(TimeoutError):
            client.flush(timeout=0.1)
        barrier.set()
        client.close()

    def test_upload_error_callback(self):
        errors = []
        client, _, mock_indexer = _make_client(on_commit_error=lambda exc: errors.append(exc))
        mock_indexer.upload.side_effect = RuntimeError("upload failed")

        client.set(STREAM_A, b"k1", b"v1")
        client.commit()
        client.flush(timeout=5)

        assert len(errors) == 1
        assert "upload failed" in str(errors[0])
        # Data still readable from cache despite upload failure
        assert client.get_bytes(STREAM_A, b"k1") == b"v1"
        # Future commits are rejected
        client.set(STREAM_A, b"k2", b"v2")
        with pytest.raises(RuntimeError, match="failed"):
            client.commit()
        client.close()

    def test_upload_failure_drains_queue(self):
        """On upload failure, remaining queued batches are discarded."""
        barrier = threading.Event()
        errors = []
        call_count = [0]

        client, _, mock_indexer = _make_client(
            max_queue_size=10,
            on_commit_error=lambda exc: errors.append(exc),
        )

        def upload_side(**kw):
            call_count[0] += 1
            if call_count[0] == 1:
                barrier.wait(timeout=5)
                raise RuntimeError("insufficient funds")
            return ("tx", "root", call_count[0])

        mock_indexer.upload.side_effect = upload_side

        # Queue 3 batches while worker is blocked on batch 1
        client.set(STREAM_A, b"k1", b"v1")
        client.commit()
        client.set(STREAM_A, b"k2", b"v2")
        client.commit()
        client.set(STREAM_A, b"k3", b"v3")
        client.commit()

        # Release batch 1 — it fails, should drain batches 2 and 3
        barrier.set()
        client.flush(timeout=5)

        # Only 1 upload attempted, batches 2 and 3 were drained
        assert call_count[0] == 1
        assert len(errors) == 1

        # Client is in failed state — commit rejected
        client.set(STREAM_A, b"k4", b"v4")
        with pytest.raises(RuntimeError, match="failed"):
            client.commit()

        # But all data is still readable from cache
        assert client.get_bytes(STREAM_A, b"k1") == b"v1"
        assert client.get_bytes(STREAM_A, b"k2") == b"v2"
        assert client.get_bytes(STREAM_A, b"k3") == b"v3"
        client.close()

    def test_multiple_commits_sequential(self):
        client, _, mock_indexer = _make_client()
        client.set(STREAM_A, b"k1", b"v1")
        client.commit()
        client.set(STREAM_A, b"k2", b"v2")
        client.commit()
        client.flush(timeout=5)
        assert mock_indexer.upload.call_count == 2
        client.close()

    def test_queue_full_blocks_commit(self):
        """commit() blocks when the upload queue is full, unblocks when worker drains it."""
        barrier = threading.Event()

        client, _, mock_indexer = _make_client(max_queue_size=1)
        # Make upload block until we release the barrier
        mock_indexer.upload.side_effect = lambda **kw: (
            barrier.wait(timeout=5),
            ("tx", "root", 0),
        )[-1]

        # Commit 1: worker picks it up via get(), starts processing (blocked on barrier).
        # Queue is now empty again (get() freed the slot).
        client.set(STREAM_A, b"k1", b"v1")
        client.commit()
        time.sleep(0.1)  # let worker pick up the item

        # Commit 2: fills the queue (maxsize=1). Worker still blocked on barrier.
        client.set(STREAM_A, b"k2", b"v2")
        client.commit()

        # Commit 3: should block because queue is full (1 item) and worker is busy.
        client.set(STREAM_A, b"k3", b"v3")
        committed = threading.Event()

        def do_commit():
            client.commit()
            committed.set()

        t = threading.Thread(target=do_commit)
        t.start()

        # Give commit() time to block
        assert not committed.wait(timeout=0.3), "commit() should block when queue is full"

        # Release the worker so it drains the queue
        barrier.set()

        # Now the third commit should complete
        assert committed.wait(timeout=5), "commit() should unblock after worker drains queue"

        t.join(timeout=5)
        client.close()

    def test_close_commits_pending_writes(self):
        client, _, mock_indexer = _make_client()
        client.set(STREAM_A, b"k1", b"v1")
        # Don't explicitly commit — close should commit and flush
        client.close()
        mock_indexer.upload.assert_called_once()


# ---------------------------------------------------------------------------
# Cache eviction with batch_id and finality
# ---------------------------------------------------------------------------


class TestCacheEviction:
    def test_eviction_when_cache_full_and_finalized(self):
        """Entries evicted from cache only when tx_seq is confirmed committed."""
        client, mock_kv, _ = _make_client(max_cache_entries=2)

        # Simulate get_transaction_result returning "Commit" for all
        mock_kv._client.get_transaction_result.return_value = "Commit"

        client.set(STREAM_A, b"k1", b"v1")
        client.commit()
        client.set(STREAM_A, b"k2", b"v2")
        client.commit()
        client.flush(timeout=5)

        # Cache has 2 entries (at limit)
        assert client.cache_size == 2

        # Adding k3 triggers eviction during commit
        client.set(STREAM_A, b"k3", b"v3")
        client.commit()
        client.flush(timeout=5)

        # Worker's _probe_finality should have marked tx_seqs as finalized,
        # and eviction should have trimmed the cache
        assert client.cache_size <= 2
        client.close()

    def test_no_eviction_when_not_finalized(self):
        """Cache grows beyond limit when nothing is finalized."""
        client, mock_kv, _ = _make_client(max_cache_entries=2)

        # get_transaction_result returns None (not committed)
        mock_kv._client.get_transaction_result.return_value = None

        client.set(STREAM_A, b"k1", b"v1")
        client.commit()
        client.set(STREAM_A, b"k2", b"v2")
        client.commit()
        client.flush(timeout=5)

        # Add a third entry — can't evict because nothing finalized
        client.set(STREAM_A, b"k3", b"v3")
        client.commit()
        client.flush(timeout=5)

        # Cache grew beyond limit (soft limit)
        assert client.cache_size == 3
        # But all data still readable
        assert client.get_bytes(STREAM_A, b"k1") == b"v1"
        assert client.get_bytes(STREAM_A, b"k2") == b"v2"
        assert client.get_bytes(STREAM_A, b"k3") == b"v3"
        client.close()

    def test_evicted_key_re_added_to_cache(self):
        """A key evicted from cache can be set again and re-enters cache."""
        client, mock_kv, _ = _make_client(max_cache_entries=1)
        mock_kv._client.get_transaction_result.return_value = "Commit"

        client.set(STREAM_A, b"k1", b"v1")
        client.commit()
        client.flush(timeout=5)

        # k1 is in cache. Add k2 to force eviction of k1.
        client.set(STREAM_A, b"k2", b"v2")
        client.commit()
        client.flush(timeout=5)

        # k1 should be evicted. Re-add it.
        client.set(STREAM_A, b"k1", b"v1_new")
        # Still in current writes (uncommitted)
        assert client.get_bytes(STREAM_A, b"k1") == b"v1_new"

        client.commit()
        # Now in cache
        assert client.get_bytes(STREAM_A, b"k1") == b"v1_new"
        client.close()

    def test_key_in_multiple_batches_read_at_each_stage(self):
        """Key written in two batches: reads return correct value at every stage."""
        barrier1 = threading.Event()
        barrier2 = threading.Event()
        call_count = [0]

        client, mock_kv, mock_indexer = _make_client()
        mock_kv._client.get_transaction_result.return_value = None

        def slow_upload(**kw):
            call_count[0] += 1
            if call_count[0] == 1:
                barrier1.wait(timeout=5)
                return ("tx1", "root1", 10)
            else:
                barrier2.wait(timeout=5)
                return ("tx2", "root2", 20)

        mock_indexer.upload.side_effect = slow_upload

        # Batch 1: k1 = v1
        client.set(STREAM_A, b"k1", b"v1")
        assert client.get_bytes(STREAM_A, b"k1") == b"v1"  # from current writes
        client.commit()
        assert client.get_bytes(STREAM_A, b"k1") == b"v1"  # from cache

        # Batch 2: k1 = v2 (overwrites in cache)
        client.set(STREAM_A, b"k1", b"v2")
        assert client.get_bytes(STREAM_A, b"k1") == b"v2"  # from current writes
        client.commit()
        assert client.get_bytes(STREAM_A, b"k1") == b"v2"  # from cache (batch 2)

        # Let batch 1 upload complete — should NOT stamp tx_seq on k1
        # because cache entry now has batch_id=2, not batch_id=1
        barrier1.set()
        time.sleep(0.2)

        # Value still v2
        assert client.get_bytes(STREAM_A, b"k1") == b"v2"
        with client._lock:
            entry = client._cache.get((STREAM_A, b"k1"))
            assert entry.tx_seq is None  # batch 1's tx_seq was NOT stamped

        # Let batch 2 upload complete — stamps tx_seq=20 on k1
        barrier2.set()
        client.flush(timeout=5)

        assert client.get_bytes(STREAM_A, b"k1") == b"v2"
        with client._lock:
            entry = client._cache.get((STREAM_A, b"k1"))
            assert entry.tx_seq == 20

        client.close()

    def test_key_in_multiple_batches_first_fails(self):
        """Batch 1 fails → batch 2 drained (never uploaded) → client failed."""
        errors = []
        call_count = [0]

        client, mock_kv, mock_indexer = _make_client(on_commit_error=lambda exc: errors.append(exc))

        def upload_side(**kw):
            call_count[0] += 1
            raise RuntimeError("network error")

        mock_indexer.upload.side_effect = upload_side

        # Batch 1: k1 = v1 (will fail upload)
        client.set(STREAM_A, b"k1", b"v1")
        client.commit()

        # Batch 2: k1 = v2 (will be drained, never uploaded)
        client.set(STREAM_A, b"k1", b"v2")
        client.commit()

        client.flush(timeout=5)

        # Only batch 1 was attempted; batch 2 was drained
        assert call_count[0] == 1
        assert len(errors) == 1

        # Value is v2 from cache (batch 2 overwrote batch 1 at commit time)
        assert client.get_bytes(STREAM_A, b"k1") == b"v2"

        # tx_seq is None — batch 1 failed, batch 2 never uploaded
        with client._lock:
            entry = client._cache.get((STREAM_A, b"k1"))
            assert entry is not None
            assert entry.tx_seq is None

        # Client is in failed state
        client.set(STREAM_A, b"k1", b"v3")
        with pytest.raises(RuntimeError, match="failed"):
            client.commit()

        client.close()

    def test_key_in_multiple_batches_second_fails(self):
        """Batch 1 succeeds (k1=v1), batch 2 fails (k1=v2).

        The cache keeps v2 (latest local write) with tx_seq=None.
        The KV node only has v1 from batch 1.
        Client enters failed state — no more commits allowed.
        """
        errors = []
        call_count = [0]

        client, mock_kv, mock_indexer = _make_client(
            on_commit_error=lambda exc: errors.append(exc),
        )
        mock_kv._client.get_transaction_result.return_value = None

        def upload_side(**kw):
            call_count[0] += 1
            if call_count[0] == 1:
                return ("tx1", "root1", 10)
            raise RuntimeError("upload failed")

        mock_indexer.upload.side_effect = upload_side

        # Batch 1: k1 = v1 (succeeds)
        client.set(STREAM_A, b"k1", b"v1")
        client.commit()

        # Batch 2: k1 = v2 (will fail upload)
        client.set(STREAM_A, b"k1", b"v2")
        client.commit()

        client.flush(timeout=5)

        # Error callback fired for batch 2
        assert len(errors) == 1
        assert "upload failed" in str(errors[0])

        # Cache returns v2 (latest local write), NOT v1 from KV node
        assert client.get_bytes(STREAM_A, b"k1") == b"v2"

        # tx_seq is None because:
        #   - Batch 1 upload succeeded (tx_seq=10) but didn't stamp it
        #     (cache entry has batch_id=2, not 1)
        #   - Batch 2 upload failed, so tx_seq never stamped
        with client._lock:
            entry = client._cache.get((STREAM_A, b"k1"))
            assert entry is not None
            assert entry.value == b"v2"
            assert entry.tx_seq is None

        # Client is in failed state — no more commits
        client.set(STREAM_A, b"k2", b"val2")
        with pytest.raises(RuntimeError, match="failed"):
            client.commit()

        client.close()

    def test_key_in_multiple_batches_eviction_after_finalized(self):
        """Key in two batches: eviction only happens after the LATEST batch's tx_seq is finalized."""
        client, mock_kv, mock_indexer = _make_client(max_cache_entries=1)

        upload_results = iter([("tx1", "root1", 10), ("tx2", "root2", 20), ("tx3", "root3", 30)])
        mock_indexer.upload.side_effect = lambda **kw: next(upload_results)

        # Only tx_seq=10 is finalized (batch 1's) — but k1's entry has batch_id=2
        # so tx_seq=10 never gets stamped on k1. k1 gets tx_seq=20 from batch 2.
        mock_kv._client.get_transaction_result.return_value = None
        finalized = set()

        def check_finality(tx_seq):
            if tx_seq in finalized:
                return "Commit"
            return None

        mock_kv._client.get_transaction_result.side_effect = check_finality

        # Batch 1: k1 = v1
        client.set(STREAM_A, b"k1", b"v1")
        client.commit()

        # Batch 2: k1 = v2 (overwrites)
        client.set(STREAM_A, b"k1", b"v2")
        client.commit()
        client.flush(timeout=5)

        # Cache has 1 entry (k1=v2, tx_seq=20). At limit.
        assert client.cache_size == 1

        # Finalize only tx_seq=10 (batch 1). Since k1's entry has tx_seq=20,
        # it's NOT evictable yet.
        finalized.add(10)

        # Add k2 — triggers eviction attempt, but k1 has tx_seq=20 (not finalized)
        client.set(STREAM_A, b"k2", b"val2")
        client.commit()
        client.flush(timeout=5)

        # k1 should NOT be evicted (tx_seq=20 not finalized)
        assert client.cache_size == 2  # grew beyond soft limit
        assert client.get_bytes(STREAM_A, b"k1") == b"v2"

        # Now finalize tx_seq=20 — k1 becomes evictable
        finalized.add(20)
        finalized.add(30)

        # Next commit cycle triggers eviction
        client.set(STREAM_A, b"k3", b"val3")
        client.commit()
        client.flush(timeout=5)

        # k1 should now be evicted (tx_seq=20 finalized)
        assert client.cache_size <= 2
        client.close()

    def test_lru_evicts_oldest_first(self):
        """LRU eviction removes the oldest (least recently used) entry."""
        client, mock_kv, _ = _make_client(max_cache_entries=2)
        mock_kv._client.get_transaction_result.return_value = "Commit"

        client.set(STREAM_A, b"k1", b"v1")
        client.commit()
        client.set(STREAM_A, b"k2", b"v2")
        client.commit()
        client.flush(timeout=5)

        # Touch k1 via get() to make k2 the LRU
        client.get(STREAM_A, b"k1")

        # Add k3 — should evict k2 (oldest/LRU), not k1
        client.set(STREAM_A, b"k3", b"v3")
        client.commit()
        client.flush(timeout=5)

        with client._lock:
            assert (STREAM_A, b"k1") in client._cache
            # k2 may or may not be evicted depending on probe timing,
            # but k1 should still be in cache since it was recently accessed
        client.close()


# ---------------------------------------------------------------------------
# Multi-stream
# ---------------------------------------------------------------------------


class TestMultiStream:
    def test_set_get_different_streams(self):
        client, _, _ = _make_client()
        client.set(STREAM_A, b"key", b"val_a")
        client.set(STREAM_B, b"key", b"val_b")
        assert client.get_bytes(STREAM_A, b"key") == b"val_a"
        assert client.get_bytes(STREAM_B, b"key") == b"val_b"
        client.close()

    def test_commit_multiple_streams(self):
        client, _, mock_indexer = _make_client()
        client.set(STREAM_A, b"k1", b"va")
        client.set(STREAM_B, b"k1", b"vb")
        client.commit()
        client.flush(timeout=5)

        # Both keys in cache
        assert client.get_bytes(STREAM_A, b"k1") == b"va"
        assert client.get_bytes(STREAM_B, b"k1") == b"vb"
        mock_indexer.upload.assert_called_once()
        client.close()


# ---------------------------------------------------------------------------
# Context manager and close
# ---------------------------------------------------------------------------


class TestLifecycle:
    def test_context_manager(self):
        client, _, _ = _make_client()
        with client:
            client.set(STREAM_A, b"k1", b"v1")
        # close was called — worker should be stopped

    def test_set_after_close_raises(self):
        client, _, _ = _make_client()
        client.close()
        with pytest.raises(RuntimeError, match="closed"):
            client.set(STREAM_A, b"k1", b"v1")

    def test_commit_after_close_raises(self):
        client, _, _ = _make_client()
        client.close()
        with pytest.raises(RuntimeError, match="closed"):
            client.commit()

    def test_double_close_safe(self):
        client, _, _ = _make_client()
        client.close()
        client.close()  # should not raise


# ---------------------------------------------------------------------------
# Reset after failure
# ---------------------------------------------------------------------------


class TestReset:
    def test_reset_clears_failed_state(self):
        """After reset(), commit() works again."""
        client, _, mock_indexer = _make_client()

        # Fail the first upload
        call_count = [0]

        def upload_side(**kw):
            call_count[0] += 1
            if call_count[0] == 1:
                raise RuntimeError("network error")
            return ("tx", "root", call_count[0])

        mock_indexer.upload.side_effect = upload_side

        client.set(STREAM_A, b"k1", b"v1")
        client.commit()
        client.flush(timeout=5)

        # Client is failed
        assert client.failed
        client.set(STREAM_A, b"k1", b"v1")
        with pytest.raises(RuntimeError, match="failed"):
            client.commit()

        # Reset and retry
        client.reset()
        assert not client.failed
        client.set(STREAM_A, b"k1", b"v1")
        client.commit()
        client.flush(timeout=5)

        assert call_count[0] == 2
        assert client.get_bytes(STREAM_A, b"k1") == b"v1"
        client.close()

    def test_reset_preserves_cache(self):
        """Cache entries survive reset — reads still work."""
        errors = []
        client, _, mock_indexer = _make_client(
            on_commit_error=lambda exc: errors.append(exc),
        )

        call_count = [0]

        def upload_side(**kw):
            call_count[0] += 1
            if call_count[0] == 1:
                return ("tx1", "root1", 10)
            raise RuntimeError("fail")

        mock_indexer.upload.side_effect = upload_side

        # Batch 1 succeeds
        client.set(STREAM_A, b"k1", b"v1")
        client.commit()

        # Batch 2 fails
        client.set(STREAM_A, b"k2", b"v2")
        client.commit()
        client.flush(timeout=5)

        assert client.failed

        # Both keys still readable from cache
        assert client.get_bytes(STREAM_A, b"k1") == b"v1"
        assert client.get_bytes(STREAM_A, b"k2") == b"v2"

        # Reset
        client.reset()

        # Cache still intact
        assert client.get_bytes(STREAM_A, b"k1") == b"v1"
        assert client.get_bytes(STREAM_A, b"k2") == b"v2"
        client.close()

    def test_reset_preserves_uncommitted_writes(self):
        """Writes accumulated via set() before reset are kept."""
        client, _, mock_indexer = _make_client()
        mock_indexer.upload.side_effect = RuntimeError("fail")

        client.set(STREAM_A, b"k1", b"v1")
        client.commit()
        client.flush(timeout=5)

        assert client.failed

        # Accumulate a write while failed
        client.set(STREAM_A, b"k2", b"v2")

        # Reset
        client.reset()

        # Fix the upload
        call_count = [0]

        def ok_upload(**kw):
            call_count[0] += 1
            return ("tx", "root", call_count[0])

        mock_indexer.upload.side_effect = ok_upload

        # k2 is still in current writes and can be committed
        client.commit()
        client.flush(timeout=5)

        assert client.get_bytes(STREAM_A, b"k2") == b"v2"
        assert call_count[0] == 1
        client.close()

    def test_reset_on_closed_client_raises(self):
        client, _, _ = _make_client()
        client.close()
        with pytest.raises(RuntimeError, match="closed"):
            client.reset()

    def test_failed_property(self):
        client, _, mock_indexer = _make_client()
        assert not client.failed

        mock_indexer.upload.side_effect = RuntimeError("fail")
        client.set(STREAM_A, b"k1", b"v1")
        client.commit()
        client.flush(timeout=5)

        assert client.failed
        client.reset()
        assert not client.failed
        client.close()
