"""
v7 behavior tests — chunk-method retry on y4m pipe break.

Verifies the v4.0.0 fix for the "works up until near the end, never saves
chunks into a full file" production bug. When av1an's Hybrid chunk method
fails on a phone-recorded MP4 with sparse keyframes, every chunk's
encoder dies with:

    [h264 @ 0x...] error while decoding MB 35 25
    Encoding          Failed to read y4m frame delimiter. Read broken. EOF: 1

The fix retries the file with ``--chunk-method select`` (VapourSynth's
select() filter) before falling back to pure ffmpeg. This is faster than
the ffmpeg fallback (chunk-parallel still works) and produces identical-
quality output (same encoder, same params).

The production log that revealed this bug is at:
    logs/av1an.log.2026-07-13
"""
import importlib.util
import os
import shutil
import subprocess
import sys
from pathlib import Path
from unittest.mock import MagicMock

import pytest


OPENTRANSCODE_PATH = Path(__file__).resolve().parent.parent / "open-transcode.py"


def _load_opentranscode_module():
    if not OPENTRANSCODE_PATH.exists():
        pytest.skip(f"open-transcode.py not found at {OPENTRANSCODE_PATH}")
    _install_pyside6_stubs()
    spec = importlib.util.spec_from_file_location("open_transcode", str(OPENTRANSCODE_PATH))
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


def _install_pyside6_stubs():
    if any(name in sys.modules for name in
           ("PySide6", "PySide6.QtWidgets", "PySide6.QtCore", "PySide6.QtGui")):
        # Check if it's a real PySide6 or our stub. If real, leave it alone.
        if getattr(sys.modules.get("PySide6"), "_otc_stub", False):
            return
        # Real PySide6 — don't install stubs over it
        try:
            importlib.util.find_spec("PySide6.QtCore")
            return
        except (ImportError, ValueError):
            pass

    import types
    pyside6 = types.ModuleType("PySide6")
    pyside6._otc_stub = True
    qt_widgets = types.ModuleType("PySide6.QtWidgets")
    qt_core = types.ModuleType("PySide6.QtCore")
    qt_gui = types.ModuleType("PySide6.QtGui")

    class _QThread:
        def __init__(self, *a, **kw): pass
        def start(self): pass
        def isRunning(self): return False
        def wait(self, ms=None): pass

    class _Signal:
        def __init__(self, *a, **kw): pass
        def connect(self, *a, **kw): pass
        def emit(self, *a, **kw): pass

    def _Slot(*a, **kw):
        def deco(fn): return fn
        return deco

    qt_core.QThread = _QThread
    qt_core.Signal = _Signal
    qt_core.Slot = _Slot
    qt_core.Qt = MagicMock()
    qt_core.QPointF = MagicMock()
    qt_core.QRectF = MagicMock()
    qt_core.QTimer = MagicMock()

    class _QWidget:
        def __init__(self, *a, **kw): pass
    class _QMainWindow(_QWidget): pass
    qt_widgets.QWidget = _QWidget
    qt_widgets.QMainWindow = _QMainWindow
    for name in ("QApplication", "QVBoxLayout", "QHBoxLayout", "QLabel",
                 "QLineEdit", "QPushButton", "QComboBox", "QCheckBox",
                 "QTextEdit", "QFileDialog", "QGroupBox", "QStatusBar",
                 "QMessageBox", "QStyleFactory"):
        setattr(qt_widgets, name, MagicMock())

    for n in ("QFont", "QPalette", "QColor", "QPainter", "QPen", "QBrush",
              "QRadialGradient", "QFontMetrics"):
        setattr(qt_gui, n, MagicMock())

    pyside6.QtWidgets = qt_widgets
    pyside6.QtCore = qt_core
    pyside6.QtGui = qt_gui
    sys.modules["PySide6"] = pyside6
    sys.modules["PySide6.QtWidgets"] = qt_widgets
    sys.modules["PySide6.QtCore"] = qt_core
    sys.modules["PySide6.QtGui"] = qt_gui


@pytest.fixture(scope="module")
def opentranscode_module():
    return _load_opentranscode_module()


# ─────────────────────────────────────────────────────────────────────────────
# Realistic stderr from a y4m-pipe-break failure (extracted from the
# production log at logs/av1an.log.2026-07-13). The key markers are:
#   - "Failed to read y4m frame delimiter" (the broken y4m pipe)
#   - "[h264 @ ...] error while decoding MB" (decoder failing mid-GOP)
#   - "SUMMARY" + "Average Speed" (SVT-AV1's per-chunk summary block,
#     printed because the encoder ran briefly on partial data before the
#     pipe broke — this previously triggered the v6-03 "concat failure"
#     misdiagnosis)
# ─────────────────────────────────────────────────────────────────────────────
_Y4M_BREAK_STDERR = (
    "INFO encode_file: av1an_core::context: Input: 1920x1080 @ 29.763 fps, YUVJ420P, SDR\n"
    "INFO encode_file: av1an_core::scenes: scenecut: found 8 scene(s) "
    "[with extra_splits (298 frames): 16 scene(s)]\n"
    "DEBUG encode_file: av1an_core::context: Segmenting video\n"
    "DEBUG encode_file: av1an_core::context: Segment done\n"
    "INFO encode_file: av1an_core::context: \n"
    "        Encoding          Failed to read y4m frame delimiter. Read broken. EOF: 1\n"
    "        [h264 @ 0x55da365b30c0] error while decoding MB 35 25\n"
    "WARN encode_chunk{worker_id=5 total_chunks=16 chunk_index=\"00011\"}: "
    "av1an_core::broker: Encoder failed (on chunk 11):\n"
    "        Encoding          Failed to read y4m frame delimiter. Read broken. EOF: 1\n"
    "        SUMMARY -----------------------------------------------------------------\n"
    "        Average Speed:\t\t2.501 fps\n"
    "        [h264 @ 0x55da365b30c0] error while decoding MB 35 25\n"
    "ERROR av1an_core::broker: [chunk 4] [chunk 4] encoder failed 3 times, "
    "shutting down worker: encoder crashed: exit status: 0\n"
)


class TestY4mBreakPatternInErrorTable:
    """v4.0.0: 'Failed to read y4m frame delimiter' is in the error_patterns table."""

    def test_y4m_break_pattern_in_source(self, opentranscode_module):
        """The error_patterns table should include the y4m break pattern."""
        src = Path(OPENTRANSCODE_PATH).read_text()
        assert "Failed to read y4m frame delimiter" in src, \
            "error_patterns table should include 'Failed to read y4m frame delimiter'"

    def test_v7_01_retry_logic_in_source(self, opentranscode_module):
        """The v4.0.0 retry-with-select logic should be present in _encode_one."""
        src = Path(OPENTRANSCODE_PATH).read_text()
        assert 'chunk_method="select"' in src, \
            "_encode_one should have a retry path that passes chunk_method='select'"
        assert 'chunk_method_override' in src, \
            "_encode_one should cache the working chunk_method in chunk_method_override"

    def test_v6_03_diagnostic_guarded_by_y4m_check(self, opentranscode_module):
        """v4.0.0: the v6-03 SUMMARY-block concat-failure diagnostic must NOT
        fire when the y4m break marker is present (otherwise it misdiagnoses
        chunk-extraction failures as concat failures)."""
        src = Path(OPENTRANSCODE_PATH).read_text()
        # Find the v6-03 SUMMARY block check and verify it's guarded by
        # the y4m break exclusion.
        assert '"Failed to read y4m frame delimiter" not in stderr_full' in src, \
            "v6-03 SUMMARY block diagnostic must be guarded by y4m break exclusion"

    def test_vs_plugin_probe_in_source(self, opentranscode_module):
        """v4.0.0: env_probe should include the VapourSynth source plugin probe."""
        src = Path(OPENTRANSCODE_PATH).read_text()
        assert "_probe_vs_source_plugins" in src, \
            "env_probe should include _probe_vs_source_plugins helper"
        assert "_VS_PLUGIN_PROBE_PATHS" in src, \
            "env_probe should include the VS plugin path table"


class TestChunkMethodParameter:
    """v4.0.0: _encode_one accepts a chunk_method parameter for retries."""

    def test_encode_one_accepts_chunk_method_kwarg(self, opentranscode_module, tmp_path):
        """_encode_one should accept chunk_method as a keyword argument."""
        import inspect
        sig = inspect.signature(opentranscode_module.EncoderWorker._encode_one)
        params = list(sig.parameters.keys())
        assert "chunk_method" in params, \
            f"_encode_one should accept chunk_method parameter; got params: {params}"
        # Default should be None (no override)
        assert sig.parameters["chunk_method"].default is None, \
            "chunk_method default should be None"


class TestY4mBreakRetry:
    """v4.0.0: When av1an fails with the y4m break pattern, retry with select."""

    def test_y4m_break_triggers_select_retry(self, opentranscode_module, tmp_path):
        """When av1an fails with the y4m break pattern (and we're not already
        using select), the code should retry with --chunk-method select."""
        ffmpeg_bin = shutil.which("ffmpeg")
        if not ffmpeg_bin:
            pytest.skip("ffmpeg not available")

        # Create a real test video
        test_video = tmp_path / "input.mp4"
        subprocess.run(
            [ffmpeg_bin, "-f", "lavfi", "-i", "testsrc=duration=1:size=64x64:rate=24",
             "-c:v", "libx264", "-y", str(test_video)],
            capture_output=True, timeout=15,
        )
        if not test_video.exists():
            pytest.skip("Could not generate test video")

        av1_codec = next(c for c in opentranscode_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
        opus_audio = next(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)")
        mkv_container = next(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "mkv")
        original_res = next(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original")

        env = opentranscode_module.probe_environment()
        env.av1an_path = "/usr/bin/av1an"
        # Start with NO chunk_method_override — av1an auto-selects Hybrid
        env.av1an_flags = {
            "worker": "--workers",
            "video_params": "--video-params",
            "audio_params": "--audio-params",
            "svt_name": "svt-av1",
            "concat_method": "ffmpeg",
            "has_chunk_method": True,
            # NOTE: chunk_method_override intentionally absent — simulates
            # the production scenario where env_probe didn't pre-set it
        }

        worker = opentranscode_module.EncoderWorker(
            in_dir=tmp_path,
            out_dir=tmp_path / "output",
            video_codec=av1_codec,
            audio_profile=opus_audio,
            container=mkv_container,
            crf=32,
            preset_label="Fast (4)",
            delete_source=False,
            env=env,
            extensions={".mp4"},
            resolution=original_res,
            use_ffmpeg_fallback=False,
        )

        # Track which chunk_method each call used
        call_chunk_methods: list[str | None] = []

        def mock_run(cmd, **kw):
            # Extract the chunk method from the command
            chunk_m = None
            if "--chunk-method" in cmd:
                idx = cmd.index("--chunk-method")
                chunk_m = cmd[idx + 1] if idx + 1 < len(cmd) else None
            call_chunk_methods.append(chunk_m)

            if chunk_m is None or chunk_m == "hybrid":
                # First attempt (auto/Hybrid) — fail with y4m break
                return ("ok", 1, "", _Y4M_BREAK_STDERR)
            else:
                # Retry with select — succeed by producing a fake output
                # (the actual encode is mocked; we just create the file)
                # Find the output path (last arg of -o)
                if "-o" in cmd:
                    out_idx = cmd.index("-o")
                    out_path = Path(cmd[out_idx + 1])
                    out_path.parent.mkdir(parents=True, exist_ok=True)
                    out_path.write_bytes(b"\x00" * 8192)  # 8KB fake AV1 output
                return ("ok", 0, "", "encoding finished")

        worker._run_with_stop_check = mock_run

        # Mock _ffmpeg_fallback_encode — should NOT be called (select retry succeeds first)
        worker._ffmpeg_fallback_encode = MagicMock(return_value=True)

        logs: list[str] = []
        worker.log_msg.emit = lambda msg: logs.append(msg)

        # Setup required attributes
        worker._current_temps = []
        worker._file_res_map = {}
        worker._stop = False
        worker._consecutive_fail_count = 0
        worker._last_fail_pattern = None

        output_f = tmp_path / "output" / "input_archived.mkv"
        result = worker._encode_one(test_video, test_video, output_f, 1)

        # Should have succeeded via the select retry
        assert result is True, f"Expected retry to succeed. Logs: {logs}"

        # Should have been called twice: once with no chunk_method (auto),
        # once with chunk_method="select"
        assert len(call_chunk_methods) == 2, \
            f"Expected 2 calls (Hybrid fail + select retry), got {len(call_chunk_methods)}: {call_chunk_methods}"
        assert call_chunk_methods[0] is None, \
            f"First call should have no chunk_method (auto/Hybrid), got: {call_chunk_methods[0]}"
        assert call_chunk_methods[1] == "select", \
            f"Second call should use --chunk-method select, got: {call_chunk_methods[1]}"

        # Should have logged the RETRY message
        assert any("RETRY" in l and "select" in l for l in logs), \
            f"Expected RETRY message with 'select' in logs: {logs}"

        # Should NOT have called ffmpeg fallback (select retry succeeded)
        worker._ffmpeg_fallback_encode.assert_not_called()

        # fail_count should NOT be incremented (retry succeeded)
        assert worker.fail_count == 0, \
            f"fail_count should be 0 after successful select retry, got {worker.fail_count}"

        # v4.0.0: the working chunk_method should be cached for subsequent files
        assert env.av1an_flags.get("chunk_method_override") == "select", \
            f"chunk_method_override should be cached as 'select' for subsequent files"

    def test_y4m_break_no_retry_when_already_select(self, opentranscode_module, tmp_path):
        """When av1an fails with y4m break AND we're already using select,
        don't retry with select again (would infinite-loop). Fall through to
        the v6-01 ffmpeg fallback instead."""
        ffmpeg_bin = shutil.which("ffmpeg")
        if not ffmpeg_bin:
            pytest.skip("ffmpeg not available")

        test_video = tmp_path / "input.mp4"
        test_video.write_bytes(b"\x00" * 1024)

        av1_codec = next(c for c in opentranscode_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
        opus_audio = next(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)")
        mkv_container = next(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "mkv")
        original_res = next(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original")

        env = opentranscode_module.probe_environment()
        env.av1an_path = "/usr/bin/av1an"
        # Already forcing select — simulates the case where the user
        # passed --chunk-method select but it still failed (rare, but
        # possible if VapourSynth itself is broken)
        env.av1an_flags = {
            "worker": "--workers",
            "video_params": "--video-params",
            "audio_params": "--audio-params",
            "svt_name": "svt-av1",
            "concat_method": "ffmpeg",
            "has_chunk_method": True,
            "chunk_method_override": "select",
        }

        worker = opentranscode_module.EncoderWorker(
            in_dir=tmp_path,
            out_dir=tmp_path / "output",
            video_codec=av1_codec,
            audio_profile=opus_audio,
            container=mkv_container,
            crf=32,
            preset_label="Fast (4)",
            delete_source=False,
            env=env,
            extensions={".mp4"},
            resolution=original_res,
            use_ffmpeg_fallback=False,
        )

        call_count = [0]
        def mock_run(cmd, **kw):
            call_count[0] += 1
            # Always fail with y4m break (even on select retry)
            return ("ok", 1, "", _Y4M_BREAK_STDERR)
        worker._run_with_stop_check = mock_run

        # Mock ffmpeg fallback to succeed
        def mock_ffmpeg_fallback(file_path, encode_input, output_f):
            output_f.parent.mkdir(parents=True, exist_ok=True)
            output_f.write_bytes(b"\x00" * 8192)
            return True
        worker._ffmpeg_fallback_encode = mock_ffmpeg_fallback

        logs: list[str] = []
        worker.log_msg.emit = lambda msg: logs.append(msg)
        worker._current_temps = []
        worker._stop = False

        output_f = tmp_path / "output" / "input_archived.mkv"
        result = worker._encode_one(test_video, test_video, output_f, 1)

        # Should have succeeded via ffmpeg fallback (not select retry)
        assert result is True, f"Expected ffmpeg fallback to succeed. Logs: {logs}"

        # Should have been called only ONCE (no select retry since already select)
        assert call_count[0] == 1, \
            f"Expected 1 av1an call (no retry since already select), got {call_count[0]}"

        # Should NOT have logged the select RETRY message
        assert not any("RETRY" in l and "select" in l for l in logs), \
            f"Should not log select RETRY when already using select: {logs}"

        # Should have logged the ffmpeg fallback RETRY
        assert any("RETRY" in l and "ffmpeg fallback" in l for l in logs), \
            f"Expected ffmpeg fallback RETRY in logs: {logs}"

    def test_y4m_break_diagnosis_not_misdiagnosed_as_concat(self, opentranscode_module, tmp_path):
        """v4.0.0: the v6-03 'concat failure' diagnostic must NOT fire when the
        y4m break marker is present. The y4m break pattern's own diagnosis
        should be emitted instead."""
        ffmpeg_bin = shutil.which("ffmpeg")
        if not ffmpeg_bin:
            pytest.skip("ffmpeg not available")

        test_video = tmp_path / "input.mp4"
        test_video.write_bytes(b"\x00" * 1024)

        av1_codec = next(c for c in opentranscode_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
        opus_audio = next(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)")
        mkv_container = next(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "mkv")
        original_res = next(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original")

        env = opentranscode_module.probe_environment()
        env.av1an_path = "/usr/bin/av1an"
        # Force select so the retry doesn't happen (we just want to test
        # the diagnostic message)
        env.av1an_flags = {
            "worker": "--workers",
            "video_params": "--video-params",
            "audio_params": "--audio-params",
            "svt_name": "svt-av1",
            "concat_method": "ffmpeg",
            "has_chunk_method": True,
            "chunk_method_override": "select",
        }

        worker = opentranscode_module.EncoderWorker(
            in_dir=tmp_path,
            out_dir=tmp_path / "output",
            video_codec=av1_codec,
            audio_profile=opus_audio,
            container=mkv_container,
            crf=32,
            preset_label="Fast (4)",
            delete_source=False,
            env=env,
            extensions={".mp4"},
            resolution=original_res,
            use_ffmpeg_fallback=False,
        )

        def mock_run(cmd, **kw):
            # Fail with y4m break (which includes SUMMARY + Average Speed)
            return ("ok", 1, "", _Y4M_BREAK_STDERR)
        worker._run_with_stop_check = mock_run

        # Mock ffmpeg fallback to succeed
        worker._ffmpeg_fallback_encode = MagicMock(return_value=True)

        logs: list[str] = []
        worker.log_msg.emit = lambda msg: logs.append(msg)
        worker._current_temps = []
        worker._stop = False

        output_f = tmp_path / "output" / "input_archived.mkv"
        worker._encode_one(test_video, test_video, output_f, 1)

        # Should have emitted the y4m break diagnosis
        y4m_diagnosis = any(
            "y4m" in l.lower() and "DIAGNOSIS" in l for l in logs
        )
        assert y4m_diagnosis, \
            f"Should emit y4m break DIAGNOSIS. Logs: {logs}"

        # Should NOT have emitted the v6-03 concat failure diagnosis.
        # The v6-03 message starts with "SVT-AV1 encoder completed successfully
        # (SUMMARY block found in stderr)" — check for that unique prefix
        # to avoid matching the y4m break diagnosis which itself says "this
        # is NOT a concat failure".
        concat_diagnosis = any(
            "SVT-AV1 encoder completed successfully" in l
            or "post-encode merge step crashed" in l
            for l in logs
        )
        assert not concat_diagnosis, \
            f"Should NOT emit v6-03 concat failure diagnosis for y4m break. Logs: {logs}"


class TestVSPluginProbe:
    """v4.0.0: _probe_vs_source_plugins detects installed VS plugins."""

    def test_probe_returns_empty_when_no_plugins(self, opentranscode_module, tmp_path, monkeypatch):
        """When no VS plugin .so files exist in any search dir, the probe
        should return an empty list."""
        # Point HOME at an empty tmp dir so the home-dir search paths
        # don't accidentally find real plugins
        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share"))

        result = opentranscode_module._probe_vs_source_plugins()
        assert result == [], \
            f"Expected empty list when no plugins installed, got: {result}"

    def test_probe_detects_lsmash(self, opentranscode_module, tmp_path, monkeypatch):
        """When libvslsmashsource.so is in a search dir, the probe should
        return ['lsmash']."""
        # Create a fake VS plugin dir with a fake lsmash .so.
        # The probe searches ~/.local/lib/vapoursynth/ so we create it there.
        vs_dir = tmp_path / ".local" / "lib" / "vapoursynth"
        vs_dir.mkdir(parents=True)
        (vs_dir / "libvslsmashsource.so").write_bytes(b"\x00" * 16)

        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share"))

        result = opentranscode_module._probe_vs_source_plugins()
        assert "lsmash" in result, \
            f"Expected 'lsmash' in probe result, got: {result}"

    def test_probe_detects_multiple_plugins(self, opentranscode_module, tmp_path, monkeypatch):
        """When multiple plugins are installed, the probe should find them all."""
        vs_dir = tmp_path / ".local" / "lib" / "vapoursynth"
        vs_dir.mkdir(parents=True)
        (vs_dir / "libvslsmashsource.so").write_bytes(b"\x00" * 16)
        (vs_dir / "libffms2.so").write_bytes(b"\x00" * 16)
        (vs_dir / "libbestsource.so").write_bytes(b"\x00" * 16)

        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share"))

        result = opentranscode_module._probe_vs_source_plugins()
        assert "lsmash" in result
        assert "ffms2" in result
        assert "bestsource" in result
        assert len(result) == 3, f"Expected 3 plugins, got: {result}"


class TestCLIChunkMethodFlag:
    """v4.0.0: --chunk-method CLI flag is parsed and validated."""

    def test_chunk_method_flag_accepted(self, opentranscode_module):
        """--chunk-method should be accepted by the CLI parser."""
        parser = opentranscode_module.build_parser() if hasattr(opentranscode_module, "build_parser") else None
        if parser is None:
            # The v3 single file doesn't have build_parser; test the package's
            # cli module instead
            from opentranscode.cli import build_parser
            parser = build_parser()

        # Valid values
        for method in ("auto", "select", "hybrid", "segment",
                       "ffms2", "lsmash", "bestsource", "dgdecnv"):
            args = parser.parse_args(["--chunk-method", method])
            assert args.chunk_method == method, \
                f"--chunk-method {method} should parse to {method}"

    def test_chunk_method_rejects_invalid_value(self, opentranscode_module):
        """Invalid --chunk-method values should be rejected by argparse."""
        try:
            from opentranscode.cli import build_parser
        except ImportError:
            pytest.skip("opentranscode.cli not importable (PySide6 missing)")

        parser = build_parser()
        with pytest.raises(SystemExit):
            parser.parse_args(["--chunk-method", "invalid-method"])
