"""
Shared pytest fixtures and PySide6 stubs for the OpenTranscode v3 test suite.

QA item: v3-10 — pytest integration tests for ``scripts/open-transcode.v3.py``.

Why this file exists
--------------------
The v3 module (``scripts/open-transcode.v3.py``) is a single-file PySide6 GUI
that imports ``PySide6.QtWidgets`` / ``QtCore`` / ``QtGui`` at module load time.
The non-UI unit tests in this suite (smoke test, encoder pipeline, audio
loudnorm, subtitle mux, stop-button, concurrent workers) only need the
*non-Qt* logic (dataclasses, free functions, and the non-Qt methods of
``EncoderWorker``). They should run on any CI worker — even one without a
real PySide6 install.

To make that possible, this conftest installs *stub* PySide6 modules in
``sys.modules`` BEFORE the v3 module is loaded, but only when a real PySide6
package is not available. The stubs provide:

  - Real Python base classes for ``QThread``, ``QWidget``, ``QMainWindow``
    so that ``class EncoderWorker(QThread)`` and ``class OpenCodecMaster(
    QMainWindow)`` succeed at module load time. The stub ``__init__`` methods
    accept any args/kwargs so ``super().__init__()`` calls in the real
    ``__init__`` methods don't raise.
  - ``MagicMock`` for everything else (``QApplication``, ``QVBoxLayout``,
    ``QFont``, ``Qt`` enum, ``Signal``, ``Slot``, etc.) so attribute access
    and instantiation are no-ops.

If a real PySide6 IS installed, the stubs are NOT installed and the v3 module
loads against the real Qt classes. All tests in this suite work in both
modes — they either instantiate ``EncoderWorker`` via ``__new__`` (bypassing
``QThread.__init__``) or via ``__init__`` (which is safe to call because it
does not start the QThread).
"""

from __future__ import annotations

import importlib.util
import io
import os
import shutil
import subprocess
import sys
from pathlib import Path
from unittest.mock import MagicMock

import pytest


# ─────────────────────────────────────────────────────────────────────────────
#  PySide6 detection + stub installation
# ─────────────────────────────────────────────────────────────────────────────

# Detect a REAL PySide6 install BEFORE installing any stubs. We use
# importlib.util.find_spec (not "import PySide6") so that we don't trigger
# PySide6's somewhat expensive C-extension load if it IS installed.
_REAL_PYSIDE6_AVAILABLE: bool = importlib.util.find_spec("PySide6") is not None


def _install_pyside6_stubs() -> None:
    """Install stub PySide6 modules in ``sys.modules``.

    Idempotent: a second call is a no-op (detected via the ``_otc_stub``
    marker on the fake ``PySide6`` package).
    """
    if getattr(sys.modules.get("PySide6"), "_otc_stub", False):
        return  # already installed

    class _StubBase:
        """Minimal base for stubbed Qt objects.

        Accepts any args/kwargs in ``__init__`` so subclass ``__init__``
        methods that call ``super().__init__(...)`` don't fail. Auto-returns
        a ``MagicMock`` for any attribute not explicitly defined, so methods
        like ``setObjectName``, ``resize``, ``setLayout`` are no-ops.
        """

        def __init__(self, *args, **kwargs):
            pass

        def __getattr__(self, name):
            m = MagicMock()
            # Bypass __setattr__ (which would otherwise hit __getattr__ again
            # for non-existent dunder lookups during interpreter bootstrapping).
            object.__setattr__(self, name, m)
            return m

    class _StubQWidget(_StubBase):
        pass

    class _StubQMainWindow(_StubQWidget):
        pass

    class _StubQThread(_StubBase):
        # QThread class-level signals (defined as MagicMock instances so
        # ``worker.started.connect(...)`` works without raising).
        started = MagicMock()
        finished = MagicMock()

        def start(self, *args, **kwargs):
            pass

        def wait(self, *args, **kwargs):
            return True

        def terminate(self):
            pass

        def isRunning(self):
            return False

        def requestInterruption(self):
            pass

        def isInterruptionRequested(self):
            return False

    # Build the fake PySide6.QtCore module.
    qtcore = MagicMock()
    qtcore.QThread = _StubQThread
    qtcore.Qt = MagicMock()
    # Signal(str) must return something with .emit(). Use a side_effect so
    # each call returns a fresh MagicMock (matching the real Signal behavior
    # of returning a per-class-attribute signal instance).
    qtcore.Signal = MagicMock(side_effect=lambda *a, **k: MagicMock())
    # Slot is used as a decorator: @Slot() -> (fn -> fn).
    qtcore.Slot = lambda *a, **k: (lambda f: f)
    qtcore.QTimer = MagicMock()
    qtcore.QPointF = MagicMock()
    qtcore.QRectF = MagicMock()

    # Build the fake PySide6.QtWidgets module.
    qtwidgets = MagicMock()
    qtwidgets.QWidget = _StubQWidget
    qtwidgets.QMainWindow = _StubQMainWindow
    # Other names (QApplication, QVBoxLayout, QLabel, ...) auto-resolve to
    # child MagicMocks via the parent MagicMock's attribute access.

    # Build the fake PySide6.QtGui module.
    qtgui = MagicMock()

    # Assemble the fake PySide6 package.
    pyside6 = MagicMock()
    pyside6._otc_stub = True  # idempotency marker
    pyside6.QtCore = qtcore
    pyside6.QtWidgets = qtwidgets
    pyside6.QtGui = qtgui

    sys.modules["PySide6"] = pyside6
    sys.modules["PySide6.QtCore"] = qtcore
    sys.modules["PySide6.QtWidgets"] = qtwidgets
    sys.modules["PySide6.QtGui"] = qtgui


if not _REAL_PYSIDE6_AVAILABLE:
    _install_pyside6_stubs()


# ─────────────────────────────────────────────────────────────────────────────
#  v3 module loader
# ─────────────────────────────────────────────────────────────────────────────

V3_SCRIPT_PATH = Path("/home/z/my-project/scripts/open-transcode.v3.py")


@pytest.fixture(scope="session")
def v3_module():
    """Load ``scripts/open-transcode.v3.py`` as a Python module.

    The filename contains a dot and a dash (both illegal in Python
    identifiers), so we use ``importlib.util.spec_from_file_location``.
    The module is loaded once per test session (scope="session") and
    cached here.
    """
    if not V3_SCRIPT_PATH.is_file():
        pytest.skip(f"v3 script not found at {V3_SCRIPT_PATH}")
    spec = importlib.util.spec_from_file_location(
        "open_transcode_v3", str(V3_SCRIPT_PATH)
    )
    assert spec is not None and spec.loader is not None
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


# ─────────────────────────────────────────────────────────────────────────────
#  Shared fixtures
# ─────────────────────────────────────────────────────────────────────────────

@pytest.fixture
def tiny_test_video(tmp_path):
    """Create a 1-second 64x64 black video using ffmpeg.

    If ffmpeg is not installed, returns a fake path. Tests that need a real
    video file should skip themselves when this fixture returns a path that
    does not exist on disk; most tests in this suite instead mock
    ``subprocess.run`` and never touch a real video.
    """
    ffmpeg_bin = shutil.which("ffmpeg")
    if ffmpeg_bin is None:
        # ffmpeg not installed — return a fake path. Callers that need a
        # real file should check ``.exists()`` and skip / mock accordingly.
        return tmp_path / "fake_test_video.mkv"

    out = tmp_path / "tiny_test_video.mkv"
    try:
        subprocess.run(
            [
                ffmpeg_bin,
                "-f", "lavfi", "-i", "color=c=black:s=64x64:d=1:r=24",
                "-t", "1", "-pix_fmt", "yuv420p", "-an", "-y", str(out),
            ],
            capture_output=True, text=True, timeout=15,
        )
    except (OSError, subprocess.SubprocessError):
        return tmp_path / "fake_test_video.mkv"
    if not out.exists():
        return tmp_path / "fake_test_video.mkv"
    return out


@pytest.fixture
def mock_env(v3_module):
    """Return a fully-populated fake ``EnvProbe`` for testing.

    Every field is set to a plausible value so tests that read ``env.X``
    don't have to construct the whole distro/cpu topology themselves.
    """
    EnvProbe = v3_module.EnvProbe
    DistroProfile = v3_module.DistroProfile
    CpuTopology = v3_module.CpuTopology

    distro = DistroProfile(
        family="debian",
        name="Ubuntu 24.04",
        version_id="24.04",
        pkg_manager="apt",
        install_cmd_template="sudo apt install {packages}",
        binary_extra_paths=["/usr/bin", "/usr/local/bin"],
        av1an_known_encoder_names=["svt_av1", "svt-av1"],
        ffmpeg_pkg="ffmpeg",
        av1an_pkg="av1an",
        notes="test distro profile",
    )
    cpu = CpuTopology(
        physical_cores=4,
        logical_threads=8,
        threads_per_core=2,
        model_name="Test CPU @ 2.0 GHz",
    )

    env = EnvProbe()
    env.distro = distro
    env.av1an_path = "/usr/bin/av1an"
    env.ffmpeg_path = "/usr/bin/ffmpeg"
    env.ffprobe_path = "/usr/bin/ffprobe"
    env.av1an_flags = {
        "worker": "--workers",
        "video_params": "--video-params",
        "audio_params": "--audio-params",
        "concat_method": "ffmpeg",
        "chunk_method_override": "select",
        "svt_name": "svt_av1",
        "has_chunk_method": True,
    }
    env.av1an_version = "0.5.2"
    env.ffmpeg_version = "6.0"
    env.ffmpeg_libs = {
        "libsvtav1": True,
        "libaom": True,
        "libvpx": True,
        "libx265": True,
        "libopus": True,
        "libvorbis": True,
        "flac": True,
    }
    env.runtime_deps = {}
    env.missing_dep_pkgs = []
    env.vs_version = "R65"
    env.vs_script_lib = "/usr/lib/x86_64-linux-gnu/libvapoursynth-script.so"
    env.cpu = cpu
    env.errors = []
    env.warnings = []
    return env


@pytest.fixture
def mock_subprocess_run(monkeypatch):
    """Patch ``subprocess.run`` to return configurable ``CompletedProcess`` objects.

    Returns a mutable ``list`` that tests populate with the results they want
    returned (or exceptions to raise) in call order. Each entry is either a
    ``subprocess.CompletedProcess`` (returned as-is), a ``BaseException``
    (raised), or any other object (wrapped in a CompletedProcess with
    returncode=0). Once the list is exhausted, subsequent calls return a
    default rc=0 CompletedProcess.
    """
    results: list = []

    def fake_run(cmd, *args, **kwargs):
        if results:
            r = results.pop(0)
            if isinstance(r, BaseException):
                raise r
            if isinstance(r, subprocess.CompletedProcess):
                return r
            return subprocess.CompletedProcess(
                args=cmd, returncode=0, stdout=str(r), stderr="",
            )
        return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")

    monkeypatch.setattr("subprocess.run", fake_run)
    return results


@pytest.fixture
def mock_subprocess_popen(monkeypatch):
    """Patch ``subprocess.Popen`` for STOP-button tests.

    Returns a ``MagicMock`` representing the fake subprocess. Tests configure
    it (e.g. ``fake.poll.side_effect = [None, None, 0]``,
    ``fake.wait.side_effect = [...]``) before triggering the code under test.

    ``stdout`` and ``stderr`` default to empty ``StringIO`` objects so the
    v3 module's drainer threads (in ``_run_with_stop_check``) immediately
    hit EOF instead of looping forever.
    """
    fake_proc = MagicMock()
    fake_proc.pid = 12345
    fake_proc.stdout = io.StringIO("")
    fake_proc.stderr = io.StringIO("")
    monkeypatch.setattr("subprocess.Popen", lambda *a, **k: fake_proc)
    return fake_proc


# ─────────────────────────────────────────────────────────────────────────────
#  Helper functions (importable from any test module via `from conftest import ...`)
# ─────────────────────────────────────────────────────────────────────────────

def make_minimal_worker(v3_module, env=None, audio_level_db=-14.0):
    """Create an ``EncoderWorker`` without running ``__init__``.

    Uses ``EncoderWorker.__new__`` to bypass QThread construction (which
    would require a real Qt event loop in some setups), then sets only the
    attributes the unit tests need. This is the recommended pattern for
    testing the non-Qt methods of ``EncoderWorker`` (``_validate_file``,
    ``_analyze_audio_loudness``, ``_find_subtitle_stream``,
    ``_run_with_stop_check``) in isolation.
    """
    worker = v3_module.EncoderWorker.__new__(v3_module.EncoderWorker)
    worker._stop = False
    worker.log_msg = MagicMock()
    worker._file_res_map = {}
    worker.fail_count = 0
    worker._current_temps = []
    worker._sources_to_delete = []
    worker.success_count = 0
    worker.audio_level_db = audio_level_db
    worker.env = env
    worker.subtitle_lang = None
    worker.use_ffmpeg_fallback = False
    return worker
