"""Camera ffmpeg process-handle race (code review finding).

_run_jpeg_loop()/_run_h264_loop() used to operate on the shared instance
attribute (self._proc_jpeg / self._proc_h264) in their cleanup, instead of a
local reference to the process they themselves started - the same bug
_run_mjpeg_loop() already had fixed with a documented local-`proc` pattern.

If a loop's task is cancelled (e.g. via CameraCache.reset() after a stream
URL rotation) while a new task has already started and assigned its own
process to the shared attribute, the cancelled task's cleanup would kill
and null out the NEWER process instead of its own - leaking its own actual
ffmpeg child as an orphan that nothing ever cleans up again.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from kobrax_moonraker_bridge import CameraCache


def _fake_proc(name):
    """A minimal stand-in for asyncio.subprocess.Process good enough to
    drive _run_jpeg_loop()'s read/kill/wait/returncode usage."""
    proc = MagicMock(name=name)
    proc.returncode = 0
    proc.kill = MagicMock()
    proc.wait = AsyncMock()
    proc.stdout = MagicMock()
    proc.stderr = MagicMock()
    proc.stderr.read = AsyncMock(return_value=b"")
    return proc


@pytest.mark.asyncio
async def test_jpeg_loop_cleanup_does_not_kill_a_newer_process():
    cache = CameraCache()
    cache._url = "http://printer/live/streamtoken"

    old_proc = _fake_proc("old")
    new_proc = _fake_proc("new")

    # old_proc's stdout.read blocks forever until cancelled - simulating the
    # loop being stuck reading from a stale connection, same as the real bug.
    stuck = asyncio.Event()

    async def old_stdout_read(_n):
        await stuck.wait()
        return b""

    old_proc.stdout.read = old_stdout_read

    create_calls = []

    async def fake_create_subprocess_exec(*args, **kwargs):
        create_calls.append(1)
        return old_proc if len(create_calls) == 1 else new_proc

    with patch("kobrax_moonraker_bridge.asyncio.create_subprocess_exec",
               side_effect=fake_create_subprocess_exec), \
         patch("kobrax_moonraker_bridge._find_ffmpeg", return_value="ffmpeg"):
        task = asyncio.create_task(cache._run_jpeg_loop())
        # Let the loop start and assign old_proc to the shared attribute.
        await asyncio.sleep(0.05)
        assert cache._proc_jpeg is old_proc

        # Simulate a second loop iteration's process already having been
        # assigned to the shared attribute before the cancelled task's
        # cleanup runs - the exact race window from the bug report.
        cache._proc_jpeg = new_proc

        task.cancel()
        try:
            await asyncio.wait_for(task, timeout=2.0)
        except (asyncio.CancelledError, asyncio.TimeoutError):
            pass

    # The cancelled task must have killed/waited on ITS OWN process (old_proc),
    # not the newer one that had already taken over the shared attribute.
    old_proc.kill.assert_called_once()
    new_proc.kill.assert_not_called()
    # And it must not have clobbered the newer process's slot.
    assert cache._proc_jpeg is new_proc
