"""The sticker-description cache write must not block the event loop.

``gateway.sticker_cache._save_cache`` ends in ``os.replace``, whose duration is
unbounded under filesystem pressure.  Its only production caller is Telegram's
``_handle_sticker`` -- an inbound-message coroutine -- so every sticker that
missed the cache paid that rename inline on the loop, stalling every other
adapter and every in-flight turn in the process.

These tests pin the two invariants of the off-loop form: the write runs on a
worker thread, and the lock keeps two concurrent load/merge/save triples from
losing an entry in the DURABLE file.
"""
import asyncio
import json
import threading

import pytest

from gateway import sticker_cache


@pytest.fixture()
def cache_file(tmp_path, monkeypatch):
    path = tmp_path / "sticker_cache.json"
    monkeypatch.setattr(sticker_cache, "CACHE_PATH", path, raising=True)
    return path


def test_the_write_runs_off_the_loop_thread(cache_file, monkeypatch):
    """The rename must execute on a worker thread, not the loop thread."""
    seen: dict[str, int] = {}
    real_save = sticker_cache._save_cache

    def _record(cache):
        seen["thread"] = threading.get_ident()
        return real_save(cache)

    monkeypatch.setattr(sticker_cache, "_save_cache", _record, raising=True)

    async def scenario():
        seen["loop"] = threading.get_ident()
        await sticker_cache.cache_sticker_description_async("uid_thread", "A dog")

    asyncio.run(scenario())
    assert seen["thread"] != seen["loop"], (
        "the cache write ran on the event-loop thread; the off-loop dispatch "
        "is not in effect"
    )


def test_concurrent_writes_do_not_lose_an_entry(cache_file, monkeypatch):
    """Off-loop dispatch removed the loop's accidental serialization.

    ``cache_sticker_description`` is a read-modify-write: ``_load_cache`` ->
    merge -> ``_save_cache``.  ``atomic_json_write`` makes each WRITE atomic;
    it does not make the TRIPLE atomic.  While the call was inline on the loop,
    the loop serialized every caller and the race could not be observed.
    Moving it to a worker thread introduces real concurrency, so the lock has
    to arrive in the SAME change -- otherwise two stickers described at once
    silently drop one of the two descriptions.

    Asserts on the DURABLE FILE, not on the API.
    """
    # With the lock in place the second caller cannot reach the barrier, so
    # the first one MUST time out here. That timeout is the green path's
    # cost, so keep it small.
    entered = threading.Barrier(2, timeout=0.5)
    real_load = sticker_cache._load_cache
    done = threading.Event()

    def _interleaving_load():
        data = real_load()
        # Force both callers to observe the SAME pre-state, which is what a
        # lost update requires. Only the first two callers rendezvous.
        if not done.is_set():
            try:
                entered.wait()
            except threading.BrokenBarrierError:
                pass
        return data

    monkeypatch.setattr(
        sticker_cache, "_load_cache", _interleaving_load, raising=True
    )

    async def scenario():
        try:
            await asyncio.gather(
                sticker_cache.cache_sticker_description_async("uid_a", "A"),
                sticker_cache.cache_sticker_description_async("uid_b", "B"),
            )
        finally:
            done.set()

    asyncio.run(scenario())

    durable = json.loads(cache_file.read_text())
    assert sorted(durable) == ["uid_a", "uid_b"], (
        "a concurrent read-modify-write lost an entry from the durable cache "
        f"file: keys = {sorted(durable)}. atomic_json_write makes each write "
        "atomic, not the load/merge/save triple."
    )
