"""
STOP-button tests for ``EncoderWorker._run_with_stop_check``.

QA finding: OTC-013 (concurrent-worker safety + responsive STOP).

``_run_with_stop_check`` is the v3 replacement for the v2 ``subprocess.run``
calls inside the av1an and ffmpeg-fallback encode paths. It:

  - Spawns the subprocess via ``Popen(start_new_session=True)`` so it can be
    signaled as a *process group* (reaches av1an's child encoders —
    SvtAv1EncApp / vpxenc / x265 — not just the av1an parent).
  - Polls ``self._stop`` every ~1 second.
  - On STOP: SIGTERM the process group, wait 5s, SIGKILL if still alive.
    Returns ``("stop", rc, stdout, stderr)``.
  - On normal exit: returns ``("ok", rc, stdout, stderr)``.
  - On overall timeout: SIGKILL the group. Returns ``("timeout", ...)``.

The 2 cases:
  - STOP requested mid-encode -> status="stop", SIGTERM + SIGKILL sent
    via os.killpg.
  - Happy path -> status="ok", rc=0, no signals sent.

Both cases mock ``subprocess.Popen`` (via the ``mock_subprocess_popen``
fixture or directly) and ``time.sleep`` (so the 1-second poll loop runs
instantly). ``os.killpg`` and ``os.getpgid`` are also patched so no real
process-group signaling happens.
"""

from __future__ import annotations

import io
import signal
import subprocess
import time
from unittest.mock import MagicMock

import pytest

from conftest import make_minimal_worker


def test_stop_terminates_subprocess(v3_module, monkeypatch):
    """STOP mid-encode -> status="stop", SIGTERM then SIGKILL sent to group.

    The poll loop runs:
      - Iteration 1: poll() -> None, _stop=False, sleep(1) [patched to no-op]
      - Iteration 2: poll() -> None, _stop=False, sleep(1) [patched to no-op]
      - Iteration 3: poll() -> None; side-effect sets _stop=True;
        stop branch fires: SIGTERM via os.killpg, proc.wait(5) raises
        TimeoutExpired (simulating av1an not responding to SIGTERM within
        the grace period), SIGKILL via os.killpg, proc.wait(2) returns
        None. status="stop", break.
    """
    worker = make_minimal_worker(v3_module)
    worker._stop = False

    # Patch time.sleep so the 1-second poll loop runs instantly.
    monkeypatch.setattr("time.sleep", lambda *a, **k: None)

    # Track os.killpg calls: (pgid, signal) tuples.
    killpg_calls: list[tuple[int, int]] = []

    def fake_killpg(pgid, sig):
        killpg_calls.append((pgid, sig))

    monkeypatch.setattr("os.killpg", fake_killpg)
    monkeypatch.setattr("os.getpgid", lambda pid: 99999)  # fake PGID

    # Build a fake Popen result. stdout/stderr are StringIO("") so the
    # v3 module's drainer threads (which call .read(4096)) hit EOF
    # immediately and exit cleanly.
    fake_proc = MagicMock()
    fake_proc.pid = 12345
    fake_proc.stdout = io.StringIO("")
    fake_proc.stderr = io.StringIO("")

    poll_calls = [0]

    def poll_side_effect():
        poll_calls[0] += 1
        # After 2 polls (i.e. on the 3rd), request STOP. This simulates
        # the user clicking the STOP button while the encode is running.
        if poll_calls[0] == 3:
            worker._stop = True
        # Always return None — the process never exits on its own; the
        # STOP branch handles termination.
        return None

    fake_proc.poll.side_effect = poll_side_effect

    # First proc.wait (after SIGTERM) raises TimeoutExpired -> triggers
    # the SIGKILL escalation branch. Second proc.wait (after SIGKILL)
    # returns None (process reaped).
    fake_proc.wait.side_effect = [
        subprocess.TimeoutExpired(cmd=["test"], timeout=5),
        None,
    ]

    monkeypatch.setattr("subprocess.Popen", lambda *a, **k: fake_proc)

    status, rc, stdout, stderr = worker._run_with_stop_check(
        cmd=["av1an", "-i", "x.mkv", "-o", "y.mkv"],
        timeout=60,
        log_prefix="  ",
    )

    assert status == "stop", (
        f"Expected status='stop' when STOP requested, got {status!r}"
    )
    # SIGTERM must be sent first (graceful), then SIGKILL after the 5s
    # grace period expires (simulated by the wait() TimeoutExpired).
    assert (99999, signal.SIGTERM) in killpg_calls, (
        f"SIGTERM not sent to process group. killpg calls: {killpg_calls}"
    )
    assert (99999, signal.SIGKILL) in killpg_calls, (
        f"SIGKILL not sent after wait() timed out. killpg calls: {killpg_calls}"
    )
    # SIGTERM should come before SIGKILL (graceful before forceful).
    sigterm_idx = killpg_calls.index((99999, signal.SIGTERM))
    sigkill_idx = killpg_calls.index((99999, signal.SIGKILL))
    assert sigterm_idx < sigkill_idx, (
        f"SIGTERM must be sent before SIGKILL. calls: {killpg_calls}"
    )


def test_happy_path_completes_normally(v3_module, monkeypatch):
    """Encode exits normally -> status="ok", rc=0, no signals sent.

    poll() returns 0 immediately (process exited cleanly). No STOP, no
    timeout, no os.killpg calls.
    """
    worker = make_minimal_worker(v3_module)
    worker._stop = False

    monkeypatch.setattr("time.sleep", lambda *a, **k: None)

    killpg_calls: list[tuple[int, int]] = []
    monkeypatch.setattr("os.killpg", lambda pgid, sig: killpg_calls.append((pgid, sig)))
    monkeypatch.setattr("os.getpgid", lambda pid: 99999)

    fake_proc = MagicMock()
    fake_proc.pid = 12345
    fake_proc.stdout = io.StringIO("av1an progress line\n")
    fake_proc.stderr = io.StringIO("")
    # First poll returns 0 (process exited cleanly with success).
    fake_proc.poll.return_value = 0
    fake_proc.wait.return_value = 0

    monkeypatch.setattr("subprocess.Popen", lambda *a, **k: fake_proc)

    status, rc, stdout, stderr = worker._run_with_stop_check(
        cmd=["av1an", "-i", "x.mkv", "-o", "y.mkv"],
        timeout=60,
    )

    assert status == "ok"
    assert rc == 0
    assert killpg_calls == [], (
        f"No signals should be sent on happy path. killpg calls: {killpg_calls}"
    )
    # Drainer threads should have captured the stdout content.
    assert "av1an progress line" in stdout
