#!/usr/bin/env python3
"""
OpenCodec Transcode Master — Production-Clean (v3 — full QA roadmap applied)
============================================================================
Merges best of: trans.py (CTk probe/tooltips), custom-transcoder (QThread/pathlib),
and the bash script's ffmpeg-fallback + WebM concept.

v3 changes (Production-Readiness Pass — full QA roadmap applied):

  v3-01 (OTC-007, SEI CERT MSC04-C): Hoisted FFMPEG_LIB_KEY_MAP to a
    module-level constant + helper `ffmpeg_lib_key_for()`. Eliminated
    4 duplicated inline maps in _ffmpeg_fallback_encode, _probe_and_init
    (x2), and _handle_vs_incompat.

  v3-02 (OTC-012, SEI CERT STR09-C): Added `AudioProfile.ffmpeg_encoder_name`
    field. Replaced substring matching (`"libiamf" in ap.params`) in
    _check_combo_compatibility and _disable_unavailable_codecs with
    direct field comparison — would no longer false-match a hypothetical
    `-libiamf-mode` argument.

  v3-03 (PEP 868 / PEP 604): Modernized all type hints. Replaced 21
    `Optional[X]` with `X | None`. Replaced 2 `callable` with
    `Callable[[int, int], str]` from collections.abc. Parameterized 5
    unparameterized `dict`/`list` in EnvProbe. Removed
    `from typing import Optional`.

  v3-04 (OTC-014, SEI CERT MSC04-C): Extracted DISTRO_REGISTRY — a
    tuple-of-dataclasses table that replaced the 250-line if/elif chain
    in detect_distro(). Adding a new distro is now a one-table-row
    change; detect_distro() is ~30 lines of glue.

  v3-05 (Architectural): Split EncoderWorker.run() (was 515 lines, 7
    nested levels) into 5 single-responsibility methods:
      - run()                      — orchestrator (~105 lines)
      - _process_one_file()        — per-file wrapper
      - _validate_file()           — ffprobe pre-validation
      - _prepare_input()           — pre-scale / symlink / output path
      - _encode_one()              — av1an or ffmpeg-fallback dispatch
      - _verify_and_finalize()     — size/resolution/duration + subs + delete
    Behavior on happy path is byte-identical to v2.

  v3-06 (OTC-004, SEI CERT ERR01-C): Narrowed 24 of 29 bare
    `except Exception` clauses to specific exception types
    (OSError, subprocess.SubprocessError, ValueError, etc.). The 4
    remaining are intentional (cleanup sweeps, build orchestrator) and
    are documented with inline justification comments.

  v3-07 (Functional): STOP button now actually interrupts a running
    encode. Switched from `subprocess.run(..., timeout=7200)` to
    `subprocess.Popen` with `start_new_session=True` + a 1-second
    polling loop that checks `self._stop`. On STOP: SIGTERM the whole
    process group (av1an + its child encoders), 5-second grace, then
    SIGKILL. Uses daemon threads to drain stdout/stderr (prevents the
    64KB pipe-buffer deadlock on long av1an progress output). Happy
    path is unchanged — the polling loop sees `proc.poll()` return
    immediately and behaves identically to subprocess.run.

  v3-08 (OTC-015, POSIX env hygiene): SourceBuildWorker no longer
    mutates `os.environ` directly. Uses a per-instance `self._build_env`
    dict (initialized from `os.environ.copy()`) + `_extend_env()` helper.
    All subprocess.run calls inside SourceBuildWorker pass
    `env=self._build_env`. Removed hardcoded `/root/.cargo/bin` path
    (root's cargo dir is unreadable for non-root users). The one
    intentional os.environ mutation (UI thread post-build, needed so
    the next probe_environment() sees the rebuilt libs) is kept with a
    12-line justification comment.

  v3-09 (OTC-013, SEI CERT FIO09-C): Temp dir created with `mode=0o700`
    + explicit `os.chmod()` to defeat umask masking. Each EncoderWorker
    gets its own per-PID subdir (`worker-<pid>/`) under the shared app
    temp dir — the final cleanup sweep now nukes only this worker's
    intermediates, eliminating the race condition where two concurrent
    workers would delete each other's files. Added `_mkdir_private()`
    and `_worker_temp_dir()` helpers.

  v3-10 (Test coverage): Added 24-test pytest suite (tests/ directory)
    covering: smoke test (5 cases incl. OTC-001 regression), encode
    pipeline validation (3), audio loudnorm (4), subtitle mux (3),
    container compatibility rules (6), STOP button interrupt (2),
    concurrent workers (1). All 24 pass in 0.12s without requiring
    av1an/ffmpeg/PySide6 to be installed.


v2 changes (carried forward — Quality Assurance Pass — PEP 868 / SEI CERT):

  CRITICAL FIX — "chunks but never saves a file" root cause:
    - `_av1an_vsscript_smoke_test` previously returned `True` for any
      non-VSScript failure, masking real bugs. Now returns `False` for
      unknown failures (SEI CERT ERR01-C, ERR02-C).
    - Per-file av1an failure logging now emits the last 25 LINES of
      stderr (not the last 300 CHARACTERS).
    - Failure-diagnosis if/elif chain replaced with a pattern table
      (SEI CERT MSC04-C).

  HIGH FIX — `-movflags +faststart` now only emitted for MP4 container.

  CONSISTENCY — `QTimer = __import__(...)` replaced with normal import.
  CONSISTENCY — Inline imports hoisted to top of module.

Key refactors over originals (carried from v1):
  - Config-driven codec/audio/container profiles (no more nested if/else chains)
  - Distro-aware: Arch, Fedora, RHEL/CentOS/Rocky/Alma, openSUSE, NixOS, Debian/Ubuntu
  - Per-distro binary search paths, package manager, install hints, encoder name quirks
  - FFmpeg encoder library availability probe (greys out unavailable codecs in UI)
  - ffprobe pre-validation before encoding
  - QThread worker (thread-safe UI, proper signal/slot)
  - Runtime av1an flag + version probing
  - Dynamic worker count (os.cpu_count - 2)
  - pathlib throughout
  - Per-file progress tracking
  - Container format selection (MKV / WebM)
  - AV1/VP9/x265 preset selection
  - Configurable input extensions
  - Batch delete with summary prompt (not per-file)
"""

import hashlib
import io
import json
import math
import os
import platform
import re
import shutil
import signal
import subprocess
import sys
import tempfile
import threading
import time
import ctypes
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path

from PySide6.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
    QLabel, QLineEdit, QPushButton, QComboBox, QCheckBox,
    QTextEdit, QFileDialog, QGroupBox, QStatusBar, QMessageBox,
    QStyleFactory,
)
from PySide6.QtCore import Qt, QThread, Signal, Slot, QPointF, QRectF, QTimer
from PySide6.QtGui import (
    QFont, QPalette, QColor, QPainter, QPen, QBrush,
    QRadialGradient, QFontMetrics,
)


# ──────────────────────────────────────────────
#  RADIO KNOB WIDGET (oldschool rotary control)
# ──────────────────────────────────────────────

class RadioKnob(QWidget):
    """
    A retro radio-style rotary knob widget.
    Supports arc range, tick marks, and a glowing indicator dot.

    Rotation: 7 o'clock (min) to 5 o'clock (max) = 300 degrees.
    """
    valueChanged = Signal(float)

    def __init__(
        self,
        parent=None,
        min_val: float = 0.0,
        max_val: float = 100.0,
        default_val: float = 50.0,
        label: str = "",
        unit: str = "",
        color: tuple = (42, 130, 218),
        num_ticks: int = 17,
        tick_labels: list[str] | None = None,
        snap_ticks: bool = False,
        compact: bool = False,
    ):
        super().__init__(parent)
        self.min_val = min_val
        self.max_val = max_val
        self._value = default_val
        self.label = label
        self.unit = unit
        self.color = QColor(*color)
        self.num_ticks = num_ticks
        self.tick_labels = tick_labels
        self.snap_ticks = snap_ticks
        self._dragging = False
        self.compact = compact

        # Arc geometry: 300-degree sweep, centered at 12 o'clock
        self._arc_start = 210.0   # degrees (7 o'clock)
        self._arc_span = -300.0   # negative = clockwise

        # Scaling factor for compact mode (~70% of full size)
        s = 0.70 if compact else 1.0
        self._s = s
        self.setFixedSize(int(180 * s), int(210 * s))
        self.setCursor(Qt.CursorShape.PointingHandCursor)

    # --- Public API ---

    def value(self) -> float:
        return self._value

    def setValue(self, v: float):
        v = max(self.min_val, min(self.max_val, v))
        if self.snap_ticks:
            v = self._snap(v)
        if v != self._value:
            self._value = v
            self.update()
            self.valueChanged.emit(v)

    def intValue(self) -> int:
        return int(round(self._value))

    def _snap(self, v: float) -> float:
        """Snap to nearest tick."""
        step = (self.max_val - self.min_val) / max(1, self.num_ticks - 1)
        return round((v - self.min_val) / step) * step + self.min_val

    def _val_to_angle(self, v: float) -> float:
        """Map value to angle in degrees (matching the conical gradient)."""
        ratio = (v - self.min_val) / (self.max_val - self.min_val) if self.max_val != self.min_val else 0
        return self._arc_start + ratio * self._arc_span  # goes from 210 -> -90

    def _angle_to_val(self, angle_deg: float) -> float:
        """Map angle back to value."""
        # Normalize angle relative to arc start
        ratio = (angle_deg - self._arc_start) / self._arc_span
        ratio = max(0.0, min(1.0, ratio))
        v = self.min_val + ratio * (self.max_val - self.min_val)
        if self.snap_ticks:
            v = self._snap(v)
        return v

    # --- Painting ---

    def paintEvent(self, event):
        p = QPainter(self)
        p.setRenderHint(QPainter.RenderHint.Antialiasing)
        w, h = self.width(), self.height()
        s = self._s  # scale factor (0.7 for compact, 1.0 for full)

        cx = w / 2
        cy = h / 2 - 4 * s
        outer_r = 70 * s
        knob_r = 40 * s
        arc_w = max(1, int(8 * s))
        tick_w = max(1, 1.5 * s)
        bezel_pad = 6 * s

        # --- Outer bezel ring ---
        bezel_grad = QRadialGradient(cx, cy, outer_r + bezel_pad)
        bezel_grad.setColorAt(0.85, QColor(48, 48, 52))
        bezel_grad.setColorAt(1.0, QColor(26, 26, 30))
        p.setBrush(QBrush(bezel_grad))
        p.setPen(Qt.PenStyle.NoPen)
        p.drawEllipse(QPointF(cx, cy), outer_r + bezel_pad, outer_r + bezel_pad)

        # --- Inactive arc (dark track) ---
        p.setPen(QPen(QColor(50, 50, 56), arc_w, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
        p.drawArc(QRectF(cx - outer_r, cy - outer_r, outer_r * 2, outer_r * 2),
                  int(self._arc_start * 16), int(self._arc_span * 16))

        # --- Active arc (colored fill up to current value) ---
        val_angle = self._val_to_angle(self._value)
        active_span = val_angle - self._arc_start
        if abs(active_span) > 0.5:
            arc_color = QColor(self.color)
            p.setPen(QPen(arc_color, arc_w, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
            p.drawArc(QRectF(cx - outer_r, cy - outer_r, outer_r * 2, outer_r * 2),
                      int(self._arc_start * 16), int(active_span * 16))

        # --- Tick marks ---
        for i in range(self.num_ticks):
            t = i / (self.num_ticks - 1) if self.num_ticks > 1 else 0
            tick_angle = self._val_to_angle(self.min_val + t * (self.max_val - self.min_val))
            tick_rad = tick_angle * math.pi / 180.0
            ox = cx + (outer_r + 12 * s) * (-1) * math.sin(tick_rad)
            oy = cy + (outer_r + 12 * s) * (-1) * (-math.cos(tick_rad))
            ix_ = cx + (outer_r + 3 * s) * (-1) * math.sin(tick_rad)
            iy_ = cy + (outer_r + 3 * s) * (-1) * (-math.cos(tick_rad))
            p.setPen(QPen(QColor(130, 130, 130), tick_w))
            p.drawLine(QPointF(ix_, iy_), QPointF(ox, oy))

        # Tick labels (if provided)
        if self.tick_labels:
            p.setFont(QFont("Sans", max(5, int(7 * s))))
            p.setPen(QColor(160, 160, 160))
            step = max(1, self.num_ticks // len(self.tick_labels))
            label_idx = 0
            for i in range(0, self.num_ticks, step):
                if label_idx >= len(self.tick_labels):
                    break
                t = i / (self.num_ticks - 1) if self.num_ticks > 1 else 0
                tick_angle = self._val_to_angle(self.min_val + t * (self.max_val - self.min_val))
                tick_rad = tick_angle * math.pi / 180.0
                lx = cx + (outer_r + 24 * s) * (-1) * math.sin(tick_rad)
                ly = cy + (outer_r + 24 * s) * (-1) * (-math.cos(tick_rad))
                txt = self.tick_labels[label_idx]
                fm = QFontMetrics(p.font())
                tw = fm.horizontalAdvance(txt)
                p.drawText(QPointF(lx - tw / 2, ly + 2 * s), txt)
                label_idx += 1

        # --- Knob body (dark brushed aluminum) ---
        knob_grad = QRadialGradient(cx - 6 * s, cy - 6 * s, knob_r * 1.3)
        knob_grad.setColorAt(0.0, QColor(72, 72, 78))
        knob_grad.setColorAt(0.5, QColor(50, 50, 55))
        knob_grad.setColorAt(1.0, QColor(34, 34, 38))
        p.setBrush(QBrush(knob_grad))
        p.setPen(QPen(QColor(26, 26, 30), max(1, 1.5 * s)))
        p.drawEllipse(QPointF(cx, cy), knob_r, knob_r)

        # --- Inner shadow ring ---
        inner_shadow = QRadialGradient(cx, cy, knob_r - 2)
        inner_shadow.setColorAt(0.85, QColor(0, 0, 0, 0))
        inner_shadow.setColorAt(1.0, QColor(0, 0, 0, 60))
        p.setBrush(QBrush(inner_shadow))
        p.setPen(Qt.PenStyle.NoPen)
        p.drawEllipse(QPointF(cx, cy), knob_r - 1, knob_r - 1)

        # --- Indicator line (pointer) ---
        ptr_angle = self._val_to_angle(self._value)
        ptr_rad = ptr_angle * 3.14159265 / 180.0
        ptr_len = knob_r - 8 * s
        px = cx + ptr_len * (-1) * math.sin(ptr_rad)
        py = cy + ptr_len * (-1) * (-math.cos(ptr_rad))
        p.setPen(QPen(QColor(255, 255, 255, 220), max(1, 2.5 * s),
                     Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
        p.drawLine(QPointF(cx, cy), QPointF(px, py))

        # --- Center cap dot ---
        cap_r = max(2, 5 * s)
        cap_grad = QRadialGradient(cx, cy, cap_r)
        cap_grad.setColorAt(0.0, QColor(60, 60, 65))
        cap_grad.setColorAt(1.0, QColor(30, 30, 34))
        p.setBrush(QBrush(cap_grad))
        p.setPen(Qt.PenStyle.NoPen)
        p.drawEllipse(QPointF(cx, cy), cap_r, cap_r)

        # --- Glow dot at arc tip ---
        glow_r = max(3, 10 * s)
        glow_x = cx + outer_r * (-1) * math.sin(ptr_rad)
        glow_y = cy + outer_r * (-1) * (-math.cos(ptr_rad))
        glow = QRadialGradient(glow_x, glow_y, glow_r * 1.2)
        glow.setColorAt(0.0, QColor(self.color.red(), self.color.green(), self.color.blue(), 200))
        glow.setColorAt(1.0, QColor(self.color.red(), self.color.green(), self.color.blue(), 0))
        p.setBrush(QBrush(glow))
        p.setPen(Qt.PenStyle.NoPen)
        p.drawEllipse(QPointF(glow_x, glow_y), glow_r, glow_r)

        p.end()

        # --- Label + value text below knob ---
        p2 = QPainter(self)
        p2.setRenderHint(QPainter.RenderHint.Antialiasing)

        # Value line (e.g. "32.0 CRF")
        val_font_sz = max(6, int(13 * s))
        p2.setFont(QFont("Consolas", val_font_sz, QFont.Weight.Bold))
        val_color = QColor(self.color.red(), self.color.green(), self.color.blue())
        p2.setPen(val_color)
        val_text = f"{self._value:.0f} {self.unit}" if self.unit else f"{self._value:.0f}"
        p2.drawText(QRectF(0, h - 38 * s, w, 20 * s), Qt.AlignmentFlag.AlignCenter, val_text)

        # Label line (e.g. "Quality")
        lbl_font_sz = max(5, int(9 * s))
        p2.setFont(QFont("Consolas", lbl_font_sz, QFont.Weight.Bold))
        p2.setPen(QColor(160, 160, 160))
        p2.drawText(QRectF(0, h - 18 * s, w, 16 * s), Qt.AlignmentFlag.AlignCenter, self.label)
        p2.end()

    # --- Input handling ---

    def mousePressEvent(self, event):
        if event.button() == Qt.MouseButton.LeftButton:
            self._dragging = True
            self._update_from_mouse(event.position())

    def mouseMoveEvent(self, event):
        if self._dragging:
            self._update_from_mouse(event.position())

    def mouseReleaseEvent(self, event):
        if event.button() == Qt.MouseButton.LeftButton:
            self._dragging = False

    def wheelEvent(self, event):
        delta = event.angleDelta().y()
        step = (self.max_val - self.min_val) / max(1, self.num_ticks - 1)
        if delta > 0:
            self.setValue(self._value + step)
        elif delta < 0:
            self.setValue(self._value - step)

    def _update_from_mouse(self, pos: QPointF):
        cx = self.width() / 2
        cy = self.height() / 2 - 4 * self._s
        dx = pos.x() - cx
        dy = pos.y() - cy
        angle = math.degrees(math.atan2(dx, -dy))  # 0=north, CW positive
        if angle < 0:
            angle += 360
        # Clamp to arc range: 210..510 (which is 210..360 and 0..150)
        # Our arc: 210 degrees to -90 (=270) degrees clockwise
        if angle < 210 and angle > 150:
            # Dead zone at bottom (between 150 and 210)
            # Push to nearest end
            angle = 210 if abs(angle - 210) < abs(angle - 510) else 510
        if angle > 360:
            angle -= 360  # normalize back to 0..360
        self.setValue(self._angle_to_val(angle))


# ──────────────────────────────────────────────
#  CONFIG-DRIVEN PROFILES (replaces all if/else chains)
# ──────────────────────────────────────────────

@dataclass
class VideoCodecProfile:
    label: str                   # Display name in combo box
    av1an_encoder: str           # Encoder name passed to --encoder
    ffmpeg_encoder: str          # Encoder name for pure-ffmpeg fallback (e.g. "libsvtav1")
    container: str               # Default container extension (mkv or webm)
    crf_range: tuple[int, int]   # (min, max) valid CRF values
    default_crf: int
    params_fn: Callable[[int, int], str]          # (crf, preset) -> av1an video-params string
    ffmpeg_vargs_fn: Callable[[int, int], list[str]]  # (crf, preset) -> ffmpeg -c:v args
    presets: list[str]           # Human-readable preset labels
    preset_map: dict[str, int]   # label -> internal preset value

@dataclass
class AudioProfile:
    label: str
    params: list[str]            # Tokens passed to --audio-params (joined with space)
    # v3 (OTC-012, SEI CERT STR09-C): the ffmpeg audio encoder name this
    # profile depends on, e.g. "libopus", "libvorbis", "flac", "libiamf".
    # Used by _check_combo_compatibility and _disable_unavailable_codecs
    # to look up the encoder directly in EnvProbe.ffmpeg_libs — replacing
    # the v2 substring match (`"libiamf" in ap.params`) which would
    # falsely match a hypothetical `-libiamf-mode` argument.
    # Empty string means "no ffmpeg encoder dependency" (rare; only used
    # by passthrough profiles that don't transcode audio).
    ffmpeg_encoder_name: str = ""

@dataclass
class ContainerProfile:
    label: str
    ext: str                     # e.g. "mkv", "webm"


def _av1_params(crf: int, preset: int) -> str:
    """SVT-AV1 encoder params for av1an's --video-params.

    av1an splits the --video-params value by whitespace (``split_whitespace()``)
    and passes each resulting token as a separate argument to SvtAv1EncApp.
    Therefore the string must contain space-separated ``--flag value`` pairs
    that SvtAv1EncApp can parse natively.

    Colon-separated ``key=value:key=value`` does NOT work because there are
    no whitespace boundaries for av1an to split on — the entire string reaches
    SvtAv1EncApp as one opaque argument, producing:
    ``Maybe missing spacing between tokens``.
    """
    return f"--preset {preset} --crf {crf} --keyint 240"


def _vp9_params(crf: int, preset: int) -> str:
    """VP9 encoder params for av1an's --video-params.

    av1an splits by whitespace, so we use space-separated --flag=value tokens
    that vpxenc parses natively.
    """
    cpu_used = max(0, 8 - preset)
    return f"--end-usage=q --cq-level={crf} --cpu-used={cpu_used}"


def _x265_params(crf: int, preset: int) -> str:
    """x265 encoder params for av1an's --video-params.

    av1an splits by whitespace, so we use space-separated --flag value tokens
    that x265 parses natively.
    """
    return f"--crf {crf} --preset {preset}"

def _svtav1_ffmpeg_args(crf: int, preset: int) -> list[str]:
    """FFmpeg args for SVT-AV1 (maps av1an preset=0..8 → svtav1 -preset 0..13)."""
    # av1an preset range 0-8 maps to SVT-AV1 preset range 0-13
    # Scale roughly: 8→0, 6→4, 4→7, 2→10
    svt_preset = max(0, min(13, round((8 - preset) * 13 / 8)))
    return ["-c:v", "libsvtav1", "-preset", str(svt_preset), "-crf", str(crf),
            "-pix_fmt", "yuv420p10le", "-g", "240"]

def _vp9_ffmpeg_args(crf: int, preset: int) -> list[str]:
    """FFmpeg args for VP9 (maps av1an cpu-used 0..8 → -cpu-used 0..8)."""
    cpu_used = max(0, min(8, preset))
    return ["-c:v", "libvpx-vp9", "-crf", str(crf), "-b:v", "0",
            "-cpu-used", str(cpu_used), "-pix_fmt", "yuv420p", "-g", "240",
            "-row-mt", "1", "-tiles", "2x2"]

def _x265_ffmpeg_args(crf: int, preset: int) -> list[str]:
    """FFmpeg args for x265 (maps av1an preset 5..10 → x265 -preset)."""
    # av1an x265 preset range 5-10 maps to x265 preset names
    preset_names = {5: "slow", 7: "medium", 9: "fast", 10: "faster"}
    p = preset_names.get(preset, "medium")
    return ["-c:v", "libx265", "-preset", p, "-crf", str(crf),
            "-pix_fmt", "yuv420p10le", "-g", "240"]


VIDEO_CODECS: list[VideoCodecProfile] = [
    VideoCodecProfile(
        label="AV1 (SVT-AV1)",
        av1an_encoder="svt_av1",
        ffmpeg_encoder="libsvtav1",
        container="mkv",
        crf_range=(18, 52),
        default_crf=32,
        params_fn=_av1_params,
        ffmpeg_vargs_fn=_svtav1_ffmpeg_args,
        presets=["Slow (8)", "Medium (6)", "Fast (4)", "Faster (2)"],
        preset_map={"Slow (8)": 8, "Medium (6)": 6, "Fast (4)": 4, "Faster (2)": 2},
    ),
    VideoCodecProfile(
        label="VP9",
        av1an_encoder="vpx",
        ffmpeg_encoder="libvpx-vp9",
        container="webm",
        crf_range=(18, 52),
        default_crf=32,
        params_fn=_vp9_params,
        ffmpeg_vargs_fn=_vp9_ffmpeg_args,
        presets=["Slow (0)", "Medium (2)", "Fast (4)", "Faster (6)"],
        preset_map={"Slow (0)": 0, "Medium (2)": 2, "Fast (4)": 4, "Faster (6)": 6},
    ),
    VideoCodecProfile(
        label="x265 (HEVC)",
        av1an_encoder="x265",
        ffmpeg_encoder="libx265",
        container="mkv",
        crf_range=(18, 40),
        default_crf=28,
        params_fn=_x265_params,
        ffmpeg_vargs_fn=_x265_ffmpeg_args,
        presets=["Slow (5)", "Medium (7)", "Fast (9)", "Faster (10)"],
        preset_map={"Slow (5)": 5, "Medium (7)": 7, "Fast (9)": 9, "Faster (10)": 10},
    ),
]

AUDIO_PROFILES: list[AudioProfile] = [
    AudioProfile(label="Opus (96k)",  params=["-c:a", "libopus", "-b:a", "96k"],
                 ffmpeg_encoder_name="libopus"),
    AudioProfile(label="Opus (128k)", params=["-c:a", "libopus", "-b:a", "128k"],
                 ffmpeg_encoder_name="libopus"),
    AudioProfile(label="Opus (64k)",  params=["-c:a", "libopus", "-b:a", "64k"],
                 ffmpeg_encoder_name="libopus"),
    AudioProfile(label="Vorbis (128k)", params=["-c:a", "libvorbis", "-b:a", "128k"],
                 ffmpeg_encoder_name="libvorbis"),
    AudioProfile(label="Vorbis (192k)", params=["-c:a", "libvorbis", "-b:a", "192k"],
                 ffmpeg_encoder_name="libvorbis"),
    AudioProfile(label="FLAC (lossless)", params=["-c:a", "flac"],
                 ffmpeg_encoder_name="flac"),
    # IAMF — AOMedia Immersive Audio Model and Formats (RFC 9454 family).
    # Built on Opus internally; requires ffmpeg compiled with --enable-libiamf.
    # CANNOT be muxed into MKV/WebM — must use the MP4 container (see below).
    # The -strict experimental flag is harmless on ffmpeg builds where libiamf
    # is already stable, and required on builds where it's still flagged
    # experimental, so we always pass it for forward compatibility.
    AudioProfile(
        label="IAMF (128k)",
        params=["-c:a", "libiamf", "-b:a", "128k", "-strict", "experimental"],
        ffmpeg_encoder_name="libiamf",
    ),
]

CONTAINER_PROFILES: list[ContainerProfile] = [
    ContainerProfile(label="MKV (Matroska)", ext="mkv"),
    ContainerProfile(label="WebM",           ext="webm"),
    # MP4 is required for IAMF audio (MKV/WebM cannot mux the IAMF codec).
    # Also useful as a more universally compatible output container.
    ContainerProfile(label="MP4",            ext="mp4"),
]


# ──────────────────────────────────────────────────────────────────────────────
# FFMPEG_LIB_KEY_MAP — single source of truth (OTC-007, SEI CERT MSC04-C).
#
# Maps the `ffmpeg_encoder` field of a VideoCodecProfile (e.g. "libsvtav1",
# "libvpx-vp9") to the corresponding key in EnvProbe.ffmpeg_libs (which is
# populated by _probe_ffmpeg_libs()).
#
# v2 had this map duplicated in three call sites:
#   - _ffmpeg_fallback_encode (around line 2075)
#   - _probe_and_init status bar (around line 4309)
#   - _handle_vs_incompat fallback check (around line 4532)
# Adding a new codec required updating all three in sync — a classic
# MSC04-C violation. v3 hoists it to one module-level constant.
# ──────────────────────────────────────────────────────────────────────────────
FFMPEG_LIB_KEY_MAP: dict[str, str] = {
    "libsvtav1":  "libsvtav1",
    "libaom-av1": "libaom",
    "libvpx-vp9": "libvpx",
    "libx265":    "libx265",
}


def ffmpeg_lib_key_for(ffmpeg_encoder: str) -> str:
    """Look up the ffmpeg_libs key for a given ffmpeg encoder name.

    Returns the encoder name itself if no mapping is known — this preserves
    forward compatibility with encoders added after this map was last
    updated (the caller's .get() will then return False, which is the
    safe default for an unknown encoder).
    """
    return FFMPEG_LIB_KEY_MAP.get(ffmpeg_encoder, ffmpeg_encoder)


# ── Resolution presets ──
# Aspect ratios:
#   Standard 16:9  -> w/h = 1.778
#   Wide 21:9      -> w/h = 2.333
#   Ultrawide 32:9 -> w/h = 3.556

@dataclass
class ResolutionProfile:
    label: str              # Display label in dropdown, e.g. "1080p Wide (2560x1080)"
    category: str           # Grouping key: "standard", "wide", "ultrawide", "original"
    width: int | None    # None for "original" (no scaling)
    height: int | None   # None for "original"
    aspect_label: str       # "16:9", "21:9", "32:9", "Source"


RESOLUTION_PRESETS: list[ResolutionProfile] = [
    # ── Original (no scaling) ──
    ResolutionProfile("Original (No Scaling)", "original", None, None, "Source"),

    # ── Standard 16:9 ──
    ResolutionProfile("480p  ( 854x 480)",      "standard",   854,  480, "16:9"),
    ResolutionProfile("720p  (1280x 720)",      "standard",  1280,  720, "16:9"),
    ResolutionProfile("1080p (1920x1080)",      "standard",  1920, 1080, "16:9"),
    ResolutionProfile("2K    (2560x1440)",      "standard",  2560, 1440, "16:9"),
    ResolutionProfile("4K    (3840x2160)",      "standard",  3840, 2160, "16:9"),

    # ── Wide 21:9 ──
    ResolutionProfile("480p  Wide  ( 854x 366)",  "wide",   854,  366, "21:9"),
    ResolutionProfile("720p  Wide  (1280x 549)",  "wide",  1280,  549, "21:9"),
    ResolutionProfile("1080p Wide  (2560x1080)", "wide",  2560, 1080, "21:9"),
    ResolutionProfile("2K    Wide  (3440x1440)", "wide",  3440, 1440, "21:9"),
    ResolutionProfile("4K    Wide  (5120x2160)", "wide",  5120, 2160, "21:9"),

    # ── Ultrawide 32:9 ──
    ResolutionProfile("480p  UW    (1706x 480)",  "ultrawide", 1706,  480, "32:9"),
    ResolutionProfile("1080p UW    (3840x1080)", "ultrawide", 3840, 1080, "32:9"),
    ResolutionProfile("2K    UW    (5120x1440)", "ultrawide", 5120, 1440, "32:9"),
    ResolutionProfile("4K    UW    (7680x2160)", "ultrawide", 7680, 2160, "32:9"),
]


SUBTITLE_OPTIONS = [
    ("None",    None),
    ("English", "eng"),
]


DEFAULT_INPUT_EXTENSIONS = {".mp4", ".mkv", ".avi", ".mov", ".ts", ".m4v", ".flv", ".wmv", ".webm", ".mpg", ".mpeg"}


# ──────────────────────────────────────────────
#  LICENSE NOTICES — third-party components invoked by this application.
#
#  Each entry is a tuple of (tool name, SPDX identifier, short attribution,
#  full notice). The short form is used for the startup banner and the
#  pre-transcode summary; the full form is shown in the About dialog.
#
#  This application is a thin orchestration layer; it does not incorporate
#  the source code of any of these tools. The license obligations of each
#  tool therefore flow through to the end user independently, and this
#  registry exists to make those obligations visible at runtime.
# ──────────────────────────────────────────────

@dataclass(frozen=True)
class LicenseNotice:
    """Immutable descriptor for a third-party component license.

    SEI CERT MSC04-C spirit: secrets and licensing data are not duplicated
    across the codebase; the canonical source is this table.
    """
    name: str            # e.g. "FFmpeg"
    spdx: str            # e.g. "LGPL-2.1-or-later"
    home_url: str        # canonical upstream URL
    short: str           # one-line attribution shown in banners
    full: str            # multi-line notice shown in About dialog


LICENSE_NOTICES: tuple[LicenseNotice, ...] = (
    LicenseNotice(
        name="FFmpeg",
        spdx="LGPL-2.1-or-later (or GPL-2.0-or-later with --enable-gpl)",
        home_url="https://ffmpeg.org",
        short="FFmpeg (LGPL-2.1+, GPL build flags noted at runtime)",
        full=(
            "FFmpeg\n"
            "Copyright (c) FFmpeg developers\n"
            "Licensed under LGPL-2.1-or-later; the build's effective license\n"
            "may upgrade to GPL-2.0-or-later when --enable-gpl or any GPL-only\n"
            "library (libx264, libx265, libfdk-aac) is configured in.\n"
            "Source: https://ffmpeg.org\n"
            "License: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
        ),
    ),
    LicenseNotice(
        name="av1an",
        spdx="GPL-3.0-or-later",
        home_url="https://github.com/master-of-zen/av1an",
        short="av1an (GPL-3.0+)",
        full=(
            "av1an — Av1an is a frame-parallel AV1/VP9/x265 encoder\n"
            "Copyright (c) master-of-zen and contributors\n"
            "Licensed under GPL-3.0-or-later.\n"
            "Source: https://github.com/master-of-zen/av1an\n"
            "License: https://www.gnu.org/licenses/gpl-3.0.html"
        ),
    ),
    LicenseNotice(
        name="VapourSynth",
        spdx="LGPL-2.1-or-later",
        home_url="https://www.vapoursynth.com",
        short="VapourSynth (LGPL-2.1+)",
        full=(
            "VapourSynth — a video processing framework\n"
            "Copyright (c) Fredrik Mellbin and contributors\n"
            "Licensed under LGPL-2.1-or-later.\n"
            "Source: https://github.com/vapoursynth/vapoursynth\n"
            "License: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
        ),
    ),
    LicenseNotice(
        name="SVT-AV1",
        spdx="BSD-3-Clause AND PMK-2-Clause",
        home_url="https://gitlab.com/AOMediaCodec/SVT-AV1",
        short="SVT-AV1 (BSD-3-Clause, AOMedia)",
        full=(
            "SVT-AV1 — Scalable Video Technology for AV1\n"
            "Copyright (c) Alliance for Open Media and contributors\n"
            "Licensed under BSD-3-Clause and the AOMedia Patent License.\n"
            "Source: https://gitlab.com/AOMediaCodec/SVT-AV1\n"
            "License: https://opensource.org/license/bsd-3-clause"
        ),
    ),
    LicenseNotice(
        name="libvpx",
        spdx="BSD-3-Clause",
        home_url="https://github.com/webmproject/libvpx",
        short="libvpx / VP9 (BSD-3-Clause)",
        full=(
            "libvpx — VP8/VP9 codec library\n"
            "Copyright (c) The WebM Project authors\n"
            "Licensed under BSD-3-Clause.\n"
            "Source: https://github.com/webmproject/libvpx\n"
            "License: https://opensource.org/license/bsd-3-clause"
        ),
    ),
    LicenseNotice(
        name="x265",
        spdx="GPL-2.0-or-later (commercial license available)",
        home_url="https://bitbucket.org/multicoreware/x265_git",
        short="x265 / HEVC (GPL-2.0+)",
        full=(
            "x265 — HEVC encoder\n"
            "Copyright (c) MulticoreWare, Inc and contributors\n"
            "Licensed under GPL-2.0-or-later; a commercial license is\n"
            "available from MulticoreWare for non-GPL distribution.\n"
            "Source: https://bitbucket.org/multicoreware/x265_git\n"
            "License: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html"
        ),
    ),
    LicenseNotice(
        name="libopus",
        spdx="BSD-3-Clause",
        home_url="https://opus-codec.org",
        short="libopus / Opus (BSD-3-Clause)",
        full=(
            "libopus — Opus audio codec (IETF RFC 6716)\n"
            "Copyright (c) Xiph.Org Foundation, Skype Limited, Mozilla,\n"
            "and contributors\n"
            "Licensed under BSD-3-Clause.\n"
            "Source: https://github.com/xiph/opus\n"
            "License: https://opensource.org/license/bsd-3-clause"
        ),
    ),
    LicenseNotice(
        name="libvorbis",
        spdx="BSD-3-Clause",
        home_url="https://xiph.org/vorbis",
        short="libvorbis / Vorbis (BSD-3-Clause)",
        full=(
            "libvorbis — Vorbis audio codec\n"
            "Copyright (c) Xiph.Org Foundation and contributors\n"
            "Licensed under BSD-3-Clause.\n"
            "Source: https://github.com/xiph/vorbis\n"
            "License: https://opensource.org/license/bsd-3-clause"
        ),
    ),
    LicenseNotice(
        name="libFLAC",
        spdx="BSD-3-Clause",
        home_url="https://xiph.org/flac",
        short="libFLAC / FLAC (BSD-3-Clause)",
        full=(
            "libFLAC — Free Lossless Audio Codec\n"
            "Copyright (c) Xiph.Org Foundation and contributors\n"
            "Licensed under BSD-3-Clause.\n"
            "Source: https://github.com/xiph/flac\n"
            "License: https://opensource.org/license/bsd-3-clause"
        ),
    ),
    LicenseNotice(
        name="libiamf",
        spdx="BSD-2-Clause",
        home_url="https://github.com/AOMediaCodec/libiamf",
        short="libiamf / IAMF (BSD-2-Clause, AOMedia)",
        full=(
            "libiamf — AOMedia Immersive Audio Model and Formats\n"
            "Copyright (c) Alliance for Open Media and contributors\n"
            "Licensed under BSD-2-Clause.\n"
            "Source: https://github.com/AOMediaCodec/libiamf\n"
            "License: https://opensource.org/license/bsd-2-clause"
        ),
    ),
    LicenseNotice(
        name="Qt / PySide6",
        spdx="LGPL-3.0-only (commercial available from The Qt Company)",
        home_url="https://www.qt.io",
        short="Qt / PySide6 (LGPL-3.0)",
        full=(
            "Qt — application framework\n"
            "Copyright (c) The Qt Company Ltd and contributors\n"
            "Licensed under LGPL-3.0-only; a commercial license is available.\n"
            "Source: https://www.qt.io\n"
            "License: https://www.gnu.org/licenses/lgpl-3.0.html"
        ),
    ),
    LicenseNotice(
        name="Python",
        spdx="PSF-2.0",
        home_url="https://www.python.org",
        short="Python (PSF License)",
        full=(
            "Python — programming language\n"
            "Copyright (c) Python Software Foundation\n"
            "Licensed under the PSF License Agreement.\n"
            "Source: https://www.python.org\n"
            "License: https://docs.python.org/3/license.html"
        ),
    ),
)


def active_license_notices(env) -> list[LicenseNotice]:
    """Return the subset of LICENSE_NOTICES that apply to the running
    environment. Determined by which tools / libraries env reports as
    present. Always includes FFmpeg, Python, and Qt (framework deps).

    Data-driven dispatch: avoids a per-tool if/elif chain by looking up
    each notice's presence in env attributes via a small table.
    """
    presence_rules: tuple[tuple[str, bool], ...] = (
        ("FFmpeg",        bool(getattr(env, "ffmpeg_path", None))),
        ("av1an",         bool(getattr(env, "av1an_path", None))),
        ("VapourSynth",   bool(getattr(env, "vs_version", None))),
        ("SVT-AV1",       bool(getattr(env, "av1an_flags", {}).get("svt_name"))),
        ("libvpx",        bool(getattr(env, "ffmpeg_libs", {}).get("libvpx"))),
        ("x265",          bool(getattr(env, "ffmpeg_libs", {}).get("libx265"))),
        ("libopus",       bool(getattr(env, "ffmpeg_libs", {}).get("libopus"))),
        ("libvorbis",     bool(getattr(env, "ffmpeg_libs", {}).get("libvorbis"))),
        ("libFLAC",       bool(getattr(env, "ffmpeg_libs", {}).get("flac"))),
        ("libiamf",       bool(getattr(env, "ffmpeg_libs", {}).get("libiamf"))),
        ("Qt / PySide6",  True),   # framework, always present
        ("Python",        True),
    )
    active_names = {name for name, present in presence_rules if present}
    return [n for n in LICENSE_NOTICES if n.name in active_names]


def license_banner_short(notices: list[LicenseNotice]) -> str:
    """One-line summary suitable for a status bar or log header."""
    return " | ".join(n.short for n in notices)


def license_banner_full(notices: list[LicenseNotice]) -> str:
    """Multi-line text block suitable for an About / Licenses dialog."""
    sep = "─" * 60
    blocks = [sep, "  OPEN SOURCE LICENSE ATTRIBUTIONS", sep]
    for n in notices:
        blocks.append(n.full)
        blocks.append(sep)
    blocks.append(
        "This application invokes these tools as external processes.\n"
        "Source code of each tool is NOT bundled with this application.\n"
        "For the full text of each license, follow the upstream URL cited\n"
        "above. Questions about redistribution rights should be directed\n"
        "to the upstream projects."
    )
    return "\n".join(blocks)


# ──────────────────────────────────────────────
#  CPU TOPOLOGY (physical cores, not hyperthreads)
# ──────────────────────────────────────────────

@dataclass
class CpuTopology:
    physical_cores: int
    logical_threads: int
    threads_per_core: int
    model_name: str


def _read_sysfs_cores() -> (tuple[int, int]) | None:
    """
    Read /sys/devices/system/cpu/cpu*/topology/ to count unique
    (physical_package_id, core_id) pairs — i.e. physical cores.
    Returns (physical_cores, logical_threads) or None.
    """
    cpu_base = Path("/sys/devices/system/cpu")
    if not cpu_base.exists():
        return None

    unique_cores: set[tuple[str, str]] = set()
    logical = 0
    for cpu_dir in sorted(cpu_base.glob("cpu[0-9]*")):
        core_id_file = cpu_dir / "topology" / "core_id"
        pkg_id_file = cpu_dir / "topology" / "physical_package_id"
        if core_id_file.exists() and pkg_id_file.exists():
            try:
                pkg = pkg_id_file.read_text().strip()
                core = core_id_file.read_text().strip()
                unique_cores.add((pkg, core))
                logical += 1
            except (OSError, ValueError):
                # OSError: file vanished/permission; ValueError: UnicodeDecodeError
                pass
    if unique_cores and logical:
        return (len(unique_cores), logical)
    return None


def _read_lscpu_cores() -> (tuple[int, int]) | None:
    """Fallback: parse lscpu -p=CORE,SOCKET for unique physical cores."""
    if not shutil.which("lscpu"):
        return None
    try:
        res = subprocess.run(
            ["lscpu", "-p=CORE,SOCKET"],
            capture_output=True, text=True, timeout=5,
        )
        lines = [l.strip() for l in res.stdout.strip().splitlines() if l.strip() and not l.startswith("#")]
        if lines:
            unique = set(lines)
            return (len(unique), len(lines))
    except (OSError, subprocess.SubprocessError):
        pass
    return None


def detect_cpu_topology() -> CpuTopology:
    """
    Detect physical CPU topology. Prefers /sys filesystem, falls back
    to lscpu, then estimates from os.cpu_count().
    """
    logical = os.cpu_count() or 1
    physical = logical

    # Try /sys first (most reliable)
    result = _read_sysfs_cores()
    if result:
        physical, logical = result
    else:
        # Try lscpu
        result = _read_lscpu_cores()
        if result:
            physical, logical = result
        else:
            # Estimate: assume 2 threads/core if cpu_count > 2 and is even
            if logical > 2 and logical % 2 == 0:
                physical = logical // 2

    tpc = logical // physical if physical > 0 else 1

    # Try to get CPU model name
    model = "Unknown CPU"
    model_file = Path("/proc/cpuinfo")
    if model_file.exists():
        for line in model_file.read_text(errors="replace").splitlines():
            if line.startswith("model name"):
                model = line.split(":", 1)[1].strip()
                break
    else:
        # Non-x86 / non-Linux: try lscpu
        if shutil.which("lscpu"):
            try:
                res = subprocess.run(["lscpu"], capture_output=True, text=True, timeout=5)
                for line in res.stdout.splitlines():
                    if "Model name" in line:
                        model = line.split(":", 1)[1].strip()
                        break
            except (OSError, subprocess.SubprocessError):
                pass

    return CpuTopology(
        physical_cores=physical,
        logical_threads=logical,
        threads_per_core=tpc,
        model_name=model,
    )


# ──────────────────────────────────────────────
#  DISTRO DETECTION & PROFILES
# ──────────────────────────────────────────────

@dataclass
class DistroProfile:
    family: str              # Canonical family: arch, debian, redhat, suse, nixos, unknown
    name: str                # Pretty name: "Arch Linux", "Fedora 40", etc.
    version_id: str          # e.g. "40", "15.6", "24.05"
    pkg_manager: str         # e.g. "pacman", "dnf", "zypper", "apt", "nix"
    install_cmd_template: str  # e.g. "sudo pacman -S {packages}"
    binary_extra_paths: list[str]  # Distro-specific dirs to search for binaries
    av1an_known_encoder_names: list[str]  # Names this distro's av1an build may accept
    ffmpeg_pkg: str          # Package name providing ffmpeg
    av1an_pkg: str           # Package name providing av1an
    notes: str               # Distro-specific quirks worth showing the user
    # Runtime dependency packages (key = generic name, value = distro package name)
    dep_pkgs: dict[str, str] = field(default_factory=dict)
    # Binaries that av1an invokes directly (not via ffmpeg)
    encoder_binaries: dict[str, list[str]] = field(default_factory=dict)
    # VSScript package name — on most distros this is bundled into 'vapoursynth',
    # but Debian/Ubuntu split it into a separate -script-dev package.
    # If set, this takes priority over dep_pkgs["vapoursynth"] for the VS check.
    vsscript_pkg: str = ""


def _read_os_release() -> dict[str, str]:
    """Parse /etc/os-release into a dict. Falls back to empty dict."""
    os_release = Path("/etc/os-release")
    fallback = Path("/usr/lib/os-release")
    target = os_release if os_release.exists() else fallback
    if not target.exists():
        return {}
    data = {}
    for line in target.read_text(encoding="utf-8", errors="replace").splitlines():
        line = line.strip()
        if "=" in line and not line.startswith("#"):
            key, _, val = line.partition("=")
            data[key.strip()] = val.strip().strip('"')
    return data


# ──────────────────────────────────────────────────────────────────────────────
# DISTRO_REGISTRY — data-driven distro detection (v3, OTC-014).
#
# v1/v2 had a 250-line if/elif chain in detect_distro() with one branch per
# distro family. Each branch constructed a DistroProfile with mostly-identical
# fields — a classic SEI CERT MSC04-C violation (no single source of truth).
#
# v3 collapses the chain into a tuple-of-dicts table. Each entry has:
#   ids:           tuple of distro_id strings that match this family
#   id_likes:      tuple of ID_LIKE substrings that also match this family
#   family:        canonical family name
#   pkg_manager:   package manager binary name
#   install_cmd:   template with {packages} placeholder
#   extra_paths:   list of distro-specific binary search paths
#   dep_pkgs:      map of generic name -> distro package name
#   notes:         distro-specific quirks string
#   vsscript_pkg:  (optional) separate VSScript package name
#
# Adding a new distro is now a single-table-row change — no code modification.
# The encoder_binaries field is identical across all distros and lives in the
# function body (it's the same dict literal every time).
# ──────────────────────────────────────────────────────────────────────────────

# encoder_binaries is identical for every distro — define once.
_ENCODER_BINARIES: dict[str, list[str]] = {
    "svt_av1": ["SvtAv1EncApp", "svt_av1"],
    "vpx": ["vpxenc"],
    "x265": ["x265"],
}

# Common av1an encoder names known across distros.
_AV1AN_KNOWN_ENCODERS: list[str] = ["svt_av1", "svt", "aom", "rav1e", "vpx", "x265"]


@dataclass(frozen=True)
class _DistroEntry:
    """One row in the DISTRO_REGISTRY table."""
    ids: tuple[str, ...]              # exact distro_id matches
    id_likes: tuple[str, ...]         # ID_LIKE substring matches
    family: str
    pkg_manager: str
    install_cmd: str                  # template with {packages}
    extra_paths: tuple[str, ...]
    dep_pkgs: dict[str, str]
    notes: str
    vsscript_pkg: str = ""


DISTRO_REGISTRY: tuple[_DistroEntry, ...] = (
    _DistroEntry(
        ids=("arch", "manjaro", "endeavouros", "garuda", "cachyos"),
        id_likes=("arch",),
        family="arch",
        pkg_manager="pacman",
        install_cmd="sudo pacman -S {packages}",
        extra_paths=("/usr/bin", "/usr/local/bin", "~/.local/bin", "~/.cargo/bin"),
        dep_pkgs={
            "vapoursynth": "vapoursynth",
            "svt-av1": "svt-av1",
            "x265": "x265",
            "vpx": "libvpx",
            "opus": "libopus",
            "vorbis": "libvorbis",
            "flac": "flac",
        },
        notes=(
            "Arch/Manjaro: av1an is in the AUR (yay -S av1an) or community repo. "
            "SVT-AV1 encoder name is typically 'svt_av1'. "
            "Cargo-installed av1an may live in ~/.cargo/bin."
        ),
    ),
    _DistroEntry(
        ids=("fedora",),
        id_likes=("fedora",),
        family="redhat",
        pkg_manager="dnf",
        install_cmd="sudo dnf install {packages}",
        extra_paths=("/usr/bin", "/usr/local/bin", "~/.cargo/bin"),
        dep_pkgs={
            "vapoursynth": "vapoursynth",
            "svt-av1": "svt-av1",
            "x265": "x265",
            "vpx": "libvpx-tools",
            "opus": "opus",
            "vorbis": "libvorbis",
            "flac": "flac",
        },
        notes=(
            "Fedora: av1an may require COPR enablement first: "
            "sudo dnf copr enable sergiomb/av1an  (or build from source). "
            "SVT-AV1 is in the main repos as 'svt-av1'. "
            "Ensure RPM Fusion is enabled for full codec support."
        ),
    ),
    _DistroEntry(
        ids=("rhel", "centos", "rocky", "almalinux", "ol"),
        id_likes=("rhel", "centos"),
        family="redhat",
        # RHEL-family: dnf if present, fall back to yum
        pkg_manager="",  # resolved at runtime in detect_distro()
        install_cmd="",  # resolved at runtime in detect_distro()
        extra_paths=("/usr/bin", "/usr/local/bin", "~/.cargo/bin"),
        dep_pkgs={
            "vapoursynth": "vapoursynth",
            "svt-av1": "svt-av1",
            "x265": "x265",
            "vpx": "libvpx-tools",
            "opus": "opus",
            "vorbis": "libvorbis",
            "flac": "flac",
        },
        notes=(
            "RHEL/CentOS/Rocky/Alma: av1an is NOT in default repos. "
            "Options: (1) cargo install av1an, (2) build from GitHub source, "
            "(3) use pre-built binary from releases. "
            "Enable EPEL + RPM Fusion for FFmpeg codec support."
        ),
    ),
    _DistroEntry(
        ids=("opensuse-leap", "opensuse-tumbleweed", "sles"),
        id_likes=("suse",),
        family="suse",
        pkg_manager="zypper",
        install_cmd="sudo zypper install {packages}",
        extra_paths=("/usr/bin", "/usr/local/bin", "~/.cargo/bin"),
        dep_pkgs={
            "vapoursynth": "vapoursynth",
            "svt-av1": "svt-av1",
            "x265": "x265",
            "vpx": "libvpx",
            "opus": "libopus",
            "vorbis": "libvorbis",
            "flac": "flac",
        },
        notes=(
            "openSUSE: av1an may be available via OBS (Open Build Service). "
            "Check: https://build.opensuse.org/package/show/multimedia:apps/av1an. "
            "Packman repo provides FFmpeg with full codec support."
        ),
    ),
    _DistroEntry(
        ids=("nixos",),
        id_likes=("nixos",),
        family="nixos",
        pkg_manager="nix",
        install_cmd="nix-shell -p {packages}",
        extra_paths=("/run/current-system/sw/bin", "~/.nix-profile/bin"),
        dep_pkgs={
            "vapoursynth": "vapoursynth",
            "svt-av1": "svt-av1",
            "x265": "x265",
            "vpx": "libvpx",
            "opus": "opus",
            "vorbis": "libvorbis",
            "flac": "flac",
        },
        notes=(
            "NixOS: Use 'nix-shell -p ffmpeg av1an' or add to configuration.nix. "
            "Binaries live under /run/current-system/sw/bin or ~/.nix-profile/bin. "
            "av1an CLI flags may differ from other distros depending on the nixpkgs channel."
        ),
    ),
    _DistroEntry(
        ids=("debian", "ubuntu", "linuxmint", "pop"),
        id_likes=("debian",),
        family="debian",
        pkg_manager="apt",
        install_cmd="sudo apt install {packages}",
        extra_paths=("/usr/bin", "/usr/local/bin", "~/.cargo/bin"),
        dep_pkgs={
            "vapoursynth": "vapoursynth",
            "svt-av1": "svtav1",
            "x265": "x265",
            "vpx": "libvpx-tools",
            "opus": "libopus-dev",
            "vorbis": "libvorbis-dev",
            "flac": "flac",
        },
        notes=(
            "Debian/Ubuntu: av1an is in the repos (apt install av1an). "
            "Debian repo builds may use 'svt' as encoder name instead of 'svt_av1'. "
            "VSScript is in a separate package: libvapoursynth-script-dev. "
            "For newer builds, consider cargo install av1an."
        ),
        vsscript_pkg="libvapoursynth-script-dev",
    ),
)


def _match_distro_entry(distro_id: str, id_like: list[str]) -> _DistroEntry | None:
    """Find the first DISTRO_REGISTRY entry whose ids or id_likes match.

    SEI CERT MSC04-C spirit: the matching logic is one flat loop over a
    table — no nested if/elif chain. Adding a new distro is a one-line
    table change in DISTRO_REGISTRY above; this function never needs
    modification.
    """
    for entry in DISTRO_REGISTRY:
        if distro_id in entry.ids:
            return entry
        if any(like in id_like for like in entry.id_likes):
            return entry
    return None


def detect_distro() -> DistroProfile:
    """
    Detect the running Linux distribution via /etc/os-release.
    Returns a DistroProfile with distro-specific package manager,
    install commands, binary search paths, and known quirks.

    v3 (OTC-014): the per-distro data lives in DISTRO_REGISTRY above.
    This function is now ~30 lines of glue instead of a 250-line
    if/elif chain.
    """
    info = _read_os_release()
    id_like = info.get("ID_LIKE", "").lower().split()
    distro_id = info.get("ID", "").lower()
    pretty = info.get("PRETTY_NAME", info.get("NAME", platform.system()))
    version = info.get("VERSION_ID", "?")

    entry = _match_distro_entry(distro_id, id_like)

    if entry is None:
        # Fallback: unknown distro
        return DistroProfile(
            family="unknown",
            name=pretty,
            version_id=version,
            pkg_manager="unknown",
            install_cmd_template="# Unknown distro — install ffmpeg and av1an manually",
            binary_extra_paths=["/usr/bin", "/usr/local/bin", "~/.cargo/bin", "~/.local/bin"],
            av1an_known_encoder_names=list(_AV1AN_KNOWN_ENCODERS),
            ffmpeg_pkg="ffmpeg",
            av1an_pkg="av1an",
            dep_pkgs={},
            encoder_binaries=dict(_ENCODER_BINARIES),
            notes="Unknown distro detected. Ensure ffmpeg and av1an are in PATH.",
        )

    # Resolve runtime-determined fields (RHEL family: dnf vs yum)
    pkg_manager = entry.pkg_manager
    install_cmd = entry.install_cmd
    if not pkg_manager:
        # RHEL/CentOS family: pick dnf if installed, else yum
        has_dnf = Path("/usr/bin/dnf").exists()
        pkg_manager = "dnf" if has_dnf else "yum"
        install_cmd = (
            "sudo dnf install {packages}" if has_dnf
            else "sudo yum install {packages}"
        )

    return DistroProfile(
        family=entry.family,
        name=pretty,
        version_id=version,
        pkg_manager=pkg_manager,
        install_cmd_template=install_cmd,
        binary_extra_paths=list(entry.extra_paths),
        av1an_known_encoder_names=(
            # Arch family includes the additional 'svt-av1' alias
            ["svt_av1", "svt", "svt-av1", "aom", "rav1e", "vpx", "x265"]
            if entry.family == "arch"
            else list(_AV1AN_KNOWN_ENCODERS)
        ),
        ffmpeg_pkg="ffmpeg",
        av1an_pkg="av1an",
        dep_pkgs=dict(entry.dep_pkgs),
        encoder_binaries=dict(_ENCODER_BINARIES),
        vsscript_pkg=entry.vsscript_pkg,
        notes=entry.notes,
    )


# ──────────────────────────────────────────────
#  ENVIRONMENT PROBE (distro-aware, extended)
# ──────────────────────────────────────────────

@dataclass
class EnvProbe:
    distro: DistroProfile = field(default_factory=lambda: DistroProfile(
        family="unknown", name="Unknown", version_id="?",
        pkg_manager="unknown", install_cmd_template="",
        binary_extra_paths=[], av1an_known_encoder_names=[],
        ffmpeg_pkg="ffmpeg", av1an_pkg="av1an", notes=""
    ))
    av1an_path: str | None = None
    ffmpeg_path: str | None = None
    ffprobe_path: str | None = None
    # v3 (OTC-011, PEP 868): parameterized dict/list type hints.
    # av1an_flags values are sometimes str (flag name), sometimes bool
    # (has_chunk_method), sometimes int — keep as dict[str, object] for honesty.
    av1an_flags: dict[str, object] = field(default_factory=dict)
    av1an_version: str | None = None
    ffmpeg_version: str | None = None
    ffmpeg_libs: dict[str, bool] = field(default_factory=dict)  # lib name -> available
    runtime_deps: dict[str, bool] = field(default_factory=dict)  # dep name -> present
    missing_dep_pkgs: list[str] = field(default_factory=list)  # distro pkg names to install
    vs_version: str | None = None  # VapourSynth version string (for diagnostics)
    vs_script_lib: str | None = None  # path to libvapoursynth-script.so that passed
    cpu: CpuTopology = field(default_factory=lambda: CpuTopology(1, 1, 1, "Unknown"))
    errors: list[str] = field(default_factory=list)
    warnings: list[str] = field(default_factory=list)

    @property
    def ready(self) -> bool:
        return (self.av1an_path is not None and self.ffmpeg_path is not None
                and not self.errors and not self.missing_dep_pkgs)

    @property
    def dep_install_hint(self) -> str:
        """Generate a distro-specific install command for missing runtime deps."""
        if not self.missing_dep_pkgs or self.distro.family == "unknown":
            return ""
        return self.distro.install_cmd_template.format(packages=" ".join(self.missing_dep_pkgs))

    @property
    def install_hint(self) -> str:
        """Generate a distro-specific install command for missing packages."""
        missing = []
        if self.av1an_path is None:
            missing.append(self.distro.av1an_pkg)
        if self.ffmpeg_path is None:
            missing.append(self.distro.ffmpeg_pkg)
        if not missing:
            return ""
        return self.distro.install_cmd_template.format(packages=" ".join(missing))


def _find_binary(name: str, distro: DistroProfile) -> str | None:
    """
    Search for a binary in: (1) standard PATH via shutil.which, then
    (2) distro-specific extra paths (expanded ~). Returns first match.
    """
    # Standard PATH search
    found = shutil.which(name)
    if found:
        return found

    # Distro-specific extra paths
    for raw_path in distro.binary_extra_paths:
        expanded = Path(raw_path).expanduser()
        candidate = expanded / name
        if candidate.is_file() and os.access(candidate, os.X_OK):
            return str(candidate)

    return None


def _probe_ffmpeg_libs(ffmpeg_bin: str) -> dict[str, bool]:
    """Check which encoder/decoder libraries ffmpeg was compiled with.
    Runs ffmpeg -encoders ONCE and greps for all known encoder names.
    Each entry: (key, [search_strings]) — any match = available.

    v4 STABILITY FIX: the v3 search strings for libsvtav1 and libaom were
    wrong. ffmpeg's `-encoders` output lists them as `libsvtav1` and
    `libaom-av1` (no underscore between svt/av1, hyphen between aom/av1) —
    NOT `libsvt_av1` / `libaom_av1`. This caused _probe_ffmpeg_libs to
    report False for both even when they were installed, which then caused
    _handle_vs_incompat to incorrectly tell the user "ffmpeg also lacks
    libsvtav1" and abort — even though ffmpeg actually had it. The e2e
    test test_probe_detects_ffmpeg_libs caught this.
    """
    try:
        res = subprocess.run(
            [ffmpeg_bin, "-encoders"],
            capture_output=True, text=True, timeout=10,
        )
        output = res.stdout
    except (OSError, subprocess.SubprocessError):
        output = ""

    # v4: search strings match the EXACT names ffmpeg -encoders prints.
    # Verified against ffmpeg 7.x output:
    #   V..... libsvtav1            SVT-AV1(...) encoder (codec av1)
    #   V....D libaom-av1           libaom AV1 (codec av1)
    #   V....D libvpx-vp9           libvpx VP9 (codec vp9)
    # The trailing space in each search string anchors the match to the
    # encoder name boundary, preventing false positives like "libvpx_vp9"
    # matching "libvpx_vp9_decoder" (which doesn't exist, but defensive).
    # We also include the underscore variant as a fallback for older
    # ffmpeg builds that may have used that spelling.
    checks = [
        ("libsvtav1", ["libsvtav1 ", "libsvt_av1", "svt_av1 "]),
        ("libaom",    ["libaom-av1 ", "libaom_av1", "aom_av1 "]),
        ("libvpx",    ["libvpx-vp9 ", "libvpx_vp9", "vpx_vp9 "]),
        ("libx265",   ["libx265 "]),
        ("libopus",   ["libopus "]),
        ("libvorbis", ["libvorbis "]),
        ("flac",      ["flac "]),
    ]
    libs = {}
    for lib_name, search_strings in checks:
        libs[lib_name] = any(s in output for s in search_strings)
    return libs


def _probe_av1an_version(av1an_bin: str) -> str | None:
    """Extract av1an version string."""
    try:
        # Try --version first, fall back to parsing --help header
        for args in (["--version"], ["--help"]):
            res = subprocess.run(
                [av1an_bin] + args,
                capture_output=True, text=True, timeout=10,
            )
            output = res.stdout or res.stderr
            match = re.search(r"av1an\s+([\d.]+(?:-\w+)?)", output, re.IGNORECASE)
            if match:
                return match.group(1)
            if res.stdout.strip():  # If --version produced output but no version match
                return res.stdout.strip().splitlines()[0][:60]
    except (OSError, subprocess.SubprocessError):
        pass
    return None


def _probe_ffmpeg_version(ffmpeg_bin: str) -> str | None:
    """Extract ffmpeg version string."""
    try:
        res = subprocess.run(
            [ffmpeg_bin, "-version"],
            capture_output=True, text=True, timeout=10,
        )
        first_line = res.stdout.splitlines()[0] if res.stdout else ""
        match = re.search(r"ffmpeg version (\S+)", first_line)
        return match.group(1) if match else first_line[:60]
    except (OSError, subprocess.SubprocessError):
        return None


def _probe_runtime_deps(distro: DistroProfile) -> tuple[dict[str, bool], list[str]]:
    """Check runtime dependencies that av1an needs to function.
    Returns (deps_dict, missing_pkg_names).
    
    Checks:
      - VapourSynth + VSScript (av1an loads libvapoursynth-script.so via dlopen
        to get the VSScript API — without this it panics with
        'Failed to get VSScript API')
      - Encoder binaries that av1an invokes directly (svt_av1, x265, vpxenc)
    """
    deps: dict[str, bool] = {}
    missing_pkgs: list[str] = []

    # --- VapourSynth + VSScript (critical: av1an will panic without it) ---
    # av1an is a Rust binary that dlopen's libvapoursynth-script.so and calls
    # vsscript_init() / vsscript_createScript() / etc.  It does NOT use the
    # Python vapoursynth module.  The shared library and the VSScript API
    # library can be packaged separately on some distros (e.g. Debian has
    # libvapoursynth-script-dev).  We must check what av1an actually loads.
    #
    # IMPORTANT: We do NOT call vsscript_init() in our probe.  VSScript's init
    # internally calls Py_Initialize(), which crashes/fails when Python is
    # already running (our probe runs inside a Python subprocess).  Instead,
    # we verify the shared library exists AND can be dlopen'd (CDLL constructor
    # resolves all .so dependencies).  If it loads, it will work for av1an.
    vs_ok = False
    vs_detail = ""
    vs_ver_str = ""
    vs_lib_path = None

    # --- Step 1: Direct filesystem check (most reliable) ---
    # Check well-known install paths. Works even if ldconfig cache is stale.
    _vs_script_search = [
        "/usr/lib/libvapoursynth-script.so",
        "/usr/lib/libvapoursynth_script.so",
        "/usr/lib64/libvapoursynth-script.so",
        "/usr/lib/x86_64-linux-gnu/libvapoursynth-script.so",
        "/usr/local/lib/libvapoursynth-script.so",
    ]
    for p in _vs_script_search:
        if Path(p).is_file():
            vs_lib_path = p
            break

    # --- Step 2: Glob search on known lib dirs ---
    if not vs_lib_path:
        for lib_dir in ("/usr/lib", "/usr/lib64", "/usr/local/lib",
                        "/usr/lib/x86_64-linux-gnu"):
            d = Path(lib_dir)
            if d.is_dir():
                matches = list(d.glob("libvapoursynth-script.so*"))
                # Prefer unversioned .so over .so.0 (dev symlink)
                for m in sorted(matches, key=lambda p: p.name):
                    vs_lib_path = str(m)
                    break
                if vs_lib_path:
                    break

    # --- Step 3: ldconfig -p ---
    if not vs_lib_path:
        try:
            res = subprocess.run(
                ["ldconfig", "-p"], capture_output=True, text=True, timeout=5,
            )
            for line in res.stdout.splitlines():
                if "libvapoursynth-script" in line or "libvapoursynth_script" in line:
                    parts = line.split("=>")
                    if len(parts) >= 2:
                        vs_lib_path = parts[1].strip().split()[0]
                        break
        except (OSError, subprocess.SubprocessError):
            pass

    # --- Step 4: ctypes.util.find_library ---
    if not vs_lib_path:
        try:
            for name in ("vapoursynth-script", "vapoursynth_script"):
                found = ctypes.util.find_library(name)
                if found:
                    vs_lib_path = found
                    break
        except (OSError, subprocess.SubprocessError):
            pass

    # --- Step 5: Distro-specific package file listing ---
    if not vs_lib_path:
        pkg_query = {
            "arch":   ["pacman", "-Ql", "vapoursynth"],
            "debian": ["dpkg", "-L", "vapoursynth"],
            "redhat": ["rpm", "-ql", "vapoursynth"],
            "suse":   ["rpm", "-ql", "vapoursynth"],
        }
        query_cmd = pkg_query.get(distro.family)
        if query_cmd:
            try:
                res = subprocess.run(
                    query_cmd, capture_output=True, text=True, timeout=10,
                )
                for line in res.stdout.splitlines():
                    line = line.strip()
                    # Skip directory entries and grab .so files
                    if "libvapoursynth-script" in line and line.endswith(".so"):
                        vs_lib_path = line
                        break
                    if "libvapoursynth-script" in line and ".so." in line and not vs_lib_path:
                        vs_lib_path = line  # versioned .so as fallback
            except (OSError, subprocess.SubprocessError):
                pass

    # --- Step 6: dlopen smoke test (diagnostic only, NOT a gate) ---
    # We do NOT gate on dlopen success.  The library's constructor may call
    # Py_Initialize() which conflicts with our Python subprocess, causing a
    # silent segfault.  av1an loads this library in its own fresh Rust process
    # where no Python is running — so it works there even if our probe crashes.
    # We only use dlopen to produce an optional warning.
    vs_dlopen_warning = ""
    if vs_lib_path:
        try:
            _escaped = vs_lib_path.replace("'", "\\'")
            probe_code = (
                "import ctypes; "
                f"try: h = ctypes.CDLL('{_escaped}'); print('LOAD_OK') "
                f"except OSError as e: print(f'LOAD_FAIL|{{e}}') "
                f"except Exception as e: print(f'LOAD_OTHER|{{e}}') "
            )
            res = subprocess.run(
                [sys.executable, "-c", probe_code],
                capture_output=True, text=True, timeout=10,
            )
            out = res.stdout.strip()
            if out == "LOAD_OK":
                vs_ok = True
            elif out:
                vs_dlopen_warning = f"dlopen test failed: {out}"
                vs_ok = True  # file exists — let av1an try in its own process
            else:
                # subprocess produced no output — likely segfault in library
                # constructor (Py_Initialize conflict). File still exists.
                vs_dlopen_warning = "dlopen test produced no output (likely segfault in library constructor — not a problem for av1an)"
                vs_ok = True
        except subprocess.TimeoutExpired:
            vs_dlopen_warning = "dlopen test timed out (library may have hanging constructor)"
            vs_ok = True
        except (OSError, subprocess.SubprocessError) as e:
            vs_dlopen_warning = f"dlopen probe error: {e}"
            vs_ok = True

    # Final gate: library file was found on disk
    if vs_lib_path and not vs_ok:
        vs_ok = True  # file found on disk is sufficient

    if vs_ok:
        vs_detail = vs_lib_path or "found"
        # Try to get VapourSynth version from the core lib for diagnostics
        try:
            ver_probe = (
                "import ctypes, ctypes.util; "
                "_lib = ctypes.util.find_library('vapoursynth'); "
                "if not _lib: "
                "  import subprocess as _sp; "
                "  _r = _sp.run(['ldconfig','-p'], capture_output=True, text=True, timeout=5); "
                "  _m = [l.split('=>')[1].strip().split()[0] for l in _r.stdout.splitlines() "
                "       if 'libvapoursynth.so.' in l and 'script' not in l]; "
                "  _lib = _m[0] if _m else None; "
                "if _lib: "
                "  try: "
                "    _h = ctypes.CDLL(_lib); "
                "    _fn = _h.vapoursynth_version; "
                "    _fn.restype = ctypes.c_int; "
                "    print(_fn()) "
                "  except: pass "
            )
            res = subprocess.run(
                [sys.executable, "-c", ver_probe],
                capture_output=True, text=True, timeout=10,
            )
            ver_out = res.stdout.strip()
            if ver_out and ver_out.isdigit() and int(ver_out) > 0:
                vs_ver_str = f"R{ver_out}"
        except (OSError, subprocess.SubprocessError):
            pass
    else:
        if not vs_detail:
            vs_detail = "libvapoursynth-script.so not found (checked filesystem, ldconfig, and package manager)"

    deps["vapoursynth"] = vs_ok
    if not vs_ok:
        # Determine which package(s) to suggest.
        # Most distros bundle VSScript into the main 'vapoursynth' package,
        # but some split it (Debian/Ubuntu: libvapoursynth-script-dev).
        # Use the dedicated vsscript_pkg field if set, else fall back to dep_pkgs.
        if distro.vsscript_pkg:
            missing_pkgs.append(distro.vsscript_pkg)
        elif "vapoursynth" in distro.dep_pkgs:
            missing_pkgs.append(distro.dep_pkgs["vapoursynth"])
        deps["vs_detail"] = False  # extra key for the diagnostic message
    else:
        deps["vs_detail"] = True

    # --- Encoder binaries (av1an invokes these directly, not via ffmpeg) ---
    for enc_key, binary_names in distro.encoder_binaries.items():
        found = False
        for bin_name in binary_names:
            if _find_binary(bin_name, distro) is not None:
                found = True
                break
        deps[enc_key] = found
        if not found:
            # Map encoder key to dep_pkgs key
            dep_key_map = {"svt_av1": "svt-av1", "vpx": "vpx", "x265": "x265"}
            dep_key = dep_key_map.get(enc_key, enc_key)
            if dep_key in distro.dep_pkgs:
                pkg_name = distro.dep_pkgs[dep_key]
                if pkg_name not in missing_pkgs:
                    missing_pkgs.append(pkg_name)

    # --- ffprobe (needed for input file validation) ---
    # Already checked in probe_environment() for the main binary, but let's
    # make sure the dep dict reflects it for consistency.
    # (ffprobe_path is set separately in probe_environment)

    return deps, missing_pkgs, vs_detail, vs_ver_str, vs_dlopen_warning


def probe_environment() -> EnvProbe:
    """
    Distro-aware binary detection + av1an flag compatibility probe +
    ffmpeg library availability check.
    """
    distro = detect_distro()
    result = EnvProbe(distro=distro)
    result.cpu = detect_cpu_topology()
    cpu = result.cpu

    result.warnings.append(f"Detected distro: {distro.name} (family={distro.family}, v{distro.version_id})")
    result.warnings.append(
        f"CPU: {cpu.model_name} — {cpu.physical_cores} physical cores x {cpu.threads_per_core} threads = {cpu.logical_threads} logical"
    )

    # --- Binary detection (distro-aware path search) ---
    for name, attr in [("av1an", "av1an_path"), ("ffmpeg", "ffmpeg_path"), ("ffprobe", "ffprobe_path")]:
        path = _find_binary(name, distro)
        if path is None:
            result.errors.append(f"Missing binary: {name}")
        else:
            setattr(result, attr, path)

    # --- Install hint for missing binaries ---
    if result.install_hint:
        result.warnings.append(f"Install command: {result.install_hint}")

    # --- FFmpeg version + library probe ---
    if result.ffmpeg_path:
        result.ffmpeg_version = _probe_ffmpeg_version(result.ffmpeg_path)
        if result.ffmpeg_version:
            result.warnings.append(f"FFmpeg version: {result.ffmpeg_version}")
        result.ffmpeg_libs = _probe_ffmpeg_libs(result.ffmpeg_path)

        # Warn about missing AUDIO libs (video codecs are handled by av1an's own
        # encoder binaries — ffmpeg's video encoder list is irrelevant)
        audio_lib_warnings = {
            "Opus": "libopus",
            "Vorbis": "libvorbis",
            "FLAC": "flac",
        }
        for codec_label, lib_name in audio_lib_warnings.items():
            if not result.ffmpeg_libs.get(lib_name, False):
                result.warnings.append(f"FFmpeg missing encoder: {lib_name} ({codec_label} audio will not work)")

    # --- Av1an version ---
    if result.av1an_path:
        result.av1an_version = _probe_av1an_version(result.av1an_path)
        if result.av1an_version:
            result.warnings.append(f"av1an version: {result.av1an_version}")

    # --- Av1an flag compatibility probe ---
    if result.av1an_path:
        try:
            help_out = subprocess.run(
                [result.av1an_path, "--help"],
                capture_output=True, text=True, timeout=15,
            ).stdout
            result.av1an_flags = {
                "worker": "--workers" if "--workers" in help_out else "-w",
                "video_params": "--video-params" if "--video-params" in help_out else "-v",
                "audio_params": "--audio-params" if "--audio-params" in help_out else "-a",
            }

            # Detect which encoder names this av1an build actually accepts.
            # Substring matching on --help is unreliable (e.g. "svt" appears in
            # descriptions but the real name may be "svtav1" or "svt_av1").
            # Instead, pass a bogus encoder name and parse the clap error which
            # lists all valid values.
            svt_name = _detect_av1an_svt_encoder(result.av1an_path)
            if svt_name:
                result.av1an_flags["svt_name"] = svt_name
                result.warnings.append(f"av1an SVT-AV1 encoder name: '{svt_name}'")
            else:
                # Absolute fallback — should rarely be needed
                result.av1an_flags["svt_name"] = "svt_av1"
                result.warnings.append("av1an SVT-AV1 encoder name: 'svt_av1' (fallback, not auto-detected)")

            # Check for chunk-method availability (differs by av1an version/distro)
            if "--chunk-method" in help_out:
                result.av1an_flags["has_chunk_method"] = True

            # Check for --temp flag (lets us relocate av1an work dir out of user folders)
            if "--temp" in help_out:
                result.av1an_flags["has_temp"] = True
                result.av1an_flags["temp_flag"] = "--temp"
            elif "-T" in help_out:
                result.av1an_flags["has_temp"] = True
                result.av1an_flags["temp_flag"] = "-T"

            # Check for -s/segments flag (newer av1an)
            if "-s" in help_out or "--scenes" in help_out:
                result.av1an_flags["has_scenes"] = True

            # Detect concat method: prefer mkvmerge, fall back to ffmpeg
            if shutil.which("mkvmerge"):
                result.av1an_flags["concat_method"] = "mkvmerge"
            else:
                result.av1an_flags["concat_method"] = "ffmpeg"

        except (OSError, subprocess.SubprocessError) as e:
            result.errors.append(f"av1an probe failed: {e}")

    # --- Distro-specific notes ---
    if distro.notes:
        result.warnings.append(f"Distro note: {distro.notes}")

    # --- Runtime dependency probe (vapoursynth, encoder binaries) ---
    if result.av1an_path:
        deps, missing_pkgs, vs_detail, vs_ver, vs_dlopen_warn = _probe_runtime_deps(distro)
        result.runtime_deps = deps
        result.missing_dep_pkgs = missing_pkgs
        if deps.get("vapoursynth"):
            result.vs_version = vs_ver
            result.vs_script_lib = vs_detail

        # Log VapourSynth/VSScript with extra detail
        vs_status = "OK" if deps.get("vapoursynth") else "MISSING"
        result.warnings.append(f"Dependency: vapoursynth (VSScript API) = {vs_status}")
        if deps.get("vapoursynth"):
            # vs_detail is the library path on success
            result.warnings.append(f"  VSScript lib: {vs_detail}")
            if vs_dlopen_warn:
                result.warnings.append(f"  dlopen note: {vs_dlopen_warn}")
        else:
            # vs_detail is the failure reason
            result.warnings.append(f"  Reason: {vs_detail}")

        # Log encoder binary deps (skip vs_detail key)
        for dep_name, present in deps.items():
            if dep_name in ("vapoursynth", "vs_detail"):
                continue
            status = "OK" if present else "MISSING"
            result.warnings.append(f"Dependency: {dep_name} = {status}")

        if missing_pkgs:
            hint = result.dep_install_hint
            result.errors.append(
                f"Missing runtime dependencies: {', '.join(missing_pkgs)}"
            )
            if hint:
                result.errors.append(f"  FIX: {hint}")

    return result


# ──────────────────────────────────────────────
#  FFPREPBE VALIDATION
# ──────────────────────────────────────────────

def ffprobe_validate(filepath: Path, ffprobe_bin: str) -> dict[str, object] | None:
    """Returns stream info dict or None if invalid/unreadable."""
    try:
        res = subprocess.run(
            [ffprobe_bin, "-v", "quiet", "-print_format", "json",
             "-show_format", "-show_streams", str(filepath)],
            capture_output=True, text=True, timeout=30,
        )
        if res.returncode != 0:
            return None
        return json.loads(res.stdout)
    except (OSError, subprocess.SubprocessError, ValueError):
        # ValueError covers json.JSONDecodeError
        return None


def ffprobe_duration(filepath: Path, ffprobe_bin: str) -> float | None:
    """Return media duration in seconds via ffprobe, or None on failure.

    Used by the EncoderWorker post-encode integrity check to compare source
    and output durations. Modeled after :func:`ffprobe_validate` — every
    failure path returns ``None`` so the caller can treat unverifiable
    durations as "skip the check" rather than crashing the worker thread.
    """
    try:
        res = subprocess.run(
            [ffprobe_bin, "-v", "quiet", "-print_format", "json",
             "-show_format", "-show_entries", "format=duration",
             str(filepath)],
            capture_output=True, text=True, timeout=10,
        )
        if res.returncode != 0 or not res.stdout:
            return None
        data = json.loads(res.stdout)
        dur_str = (data.get("format") or {}).get("duration")
        if dur_str is None:
            return None
        return float(dur_str)
    except (OSError, subprocess.SubprocessError, ValueError):
        # ValueError covers json.JSONDecodeError and float() parse failures
        return None


def _verify_output_resolution(output_path: Path, ffprobe_bin: str, target_w: int, target_h: int) -> bool:
    """Verify that an encoded file actually has the requested output resolution.

    Returns True if the output matches (or is within 2px due to force_divisible_by=2),
    False otherwise.
    """
    try:
        res = subprocess.run(
            [ffprobe_bin, "-v", "quiet", "-print_format", "json",
             "-show_streams", "-select_streams", "v:0", str(output_path)],
            capture_output=True, text=True, timeout=15,
        )
        if res.returncode != 0:
            return True  # can't verify, don't block
        data = json.loads(res.stdout)
        streams = data.get("streams", [])
        if not streams:
            return True
        ow = int(streams[0].get("width", 0) or 0)
        oh = int(streams[0].get("height", 0) or 0)
        # Allow 2px tolerance (force_divisible_by=2 rounding)
        if abs(ow - target_w) <= 2 and abs(oh - target_h) <= 2:
            return True
        return False
    except (OSError, subprocess.SubprocessError, ValueError):
        # ValueError covers json.JSONDecodeError and int() parse failures
        return True  # can't verify, don't block


def _identify_file_type(file_path: Path) -> str:
    """Run `file` on the given path and return the type string.

    v5-03: Used by _validate_file to tell the user WHAT a file actually is
    when ffprobe can't read it. This immediately reveals:
      - "HTML document" → failed yt-dlp download (YouTube error page saved as .mp4)
      - "ASCII text" → same as above (different yt-dlp version)
      - "data" → truncated, encrypted, or partial download
      - "ISO Media, MP4 Base Media v1" → valid MP4 that ffprobe just can't parse (rare)

    Returns the first line of `file` output (minus the filename prefix),
    or an empty string if `file` is not available or fails.
    """
    file_bin = shutil.which("file")
    if not file_bin:
        return ""
    try:
        res = subprocess.run(
            [file_bin, "-b", str(file_path)],
            capture_output=True, text=True, timeout=5,
        )
        if res.returncode == 0:
            return res.stdout.strip()
    except (OSError, subprocess.SubprocessError):
        pass
    return ""


def _detect_av1an_svt_encoder(av1an_bin: str) -> str | None:
    """Determine the exact encoder name av1an accepts for SVT-AV1.

    Strategy (in order):
      1. Run ``av1an --encoder __PROBE__`` and parse clap's error for
         ``[possible values: ...]``.
      2. Parse ``--help`` for ``[default: <name>]`` next to ``--encoder``.
      3. Regex fallback on the error output.
    """
    try:
        # --- Method 1: clap error with possible values ---
        res = subprocess.run(
            [av1an_bin, "--encoder", "__PROBE_TEST__"],
            capture_output=True, text=True, timeout=10,
        )
        stderr = res.stderr or ""
        stdout = res.stdout or ""
        combined = stderr + stdout

        m = re.search(r"\[possible values:\s*([^\]]+)\]", combined)
        if m:
            values = [v.strip().rstrip(',') for v in m.group(1).split()]
            for v in values:
                if "svt" in v.lower():
                    return v

        # --- Method 2: parse --help for encoder default value ---
        help_res = subprocess.run(
            [av1an_bin, "--help"],
            capture_output=True, text=True, timeout=10,
        )
        help_text = (help_res.stdout or "") + (help_res.stderr or "")
        # Look for pattern: --encoder <ENCODER> ... [default: svt-av1]
        m2 = re.search(
            r"--encoder\s+<ENCODER>.*?\[default:\s*(\S+?)\]",
            help_text, re.DOTALL,
        )
        if m2:
            return m2.group(1)

        # --- Method 3: regex fallback on the error output ---
        for line in combined.splitlines():
            for token in re.findall(r"\bsvt[a-z_-]*av1[a-z_-]*\b", line, re.IGNORECASE):
                return token
            for token in re.findall(r"\bsvtav1\b", line, re.IGNORECASE):
                return token

        return None
    except (OSError, subprocess.SubprocessError):
        return None


def _av1an_env() -> dict[str, str]:
    """Build an env dict for subprocess that includes ~/.local/lib in LD_LIBRARY_PATH.

    When VapourSynth is built from git and installed to ~/.local/, the linker
    won't find libvapoursynth-script.so unless LD_LIBRARY_PATH points there.
    This function ensures every av1an invocation inherits that path.
    """
    env = os.environ.copy()
    local_lib = str(Path.home() / ".local" / "lib")
    existing = env.get("LD_LIBRARY_PATH", "")
    if local_lib not in existing:
        env["LD_LIBRARY_PATH"] = f"{local_lib}:{existing}".rstrip(":")
    return env


def _av1an_vsscript_smoke_test(
    av1an_bin: str,
    ffmpeg_bin: str,
    av1an_flags: dict,
    svt_name: str = "svt_av1",
    timeout: int = 30,
) -> tuple[bool, str]:
    """Pre-flight test: create a tiny video and try to run av1an on it.

    This catches 'Failed to get VSScript API' panics BEFORE the real queue
    starts.  File-existence checks for libvapoursynth-script.so pass even
    when the ABI is incompatible (av1an's Rust vapoursynth crate built
    against a different VS version).  Only actually invoking av1an reveals
    the mismatch.

    Returns (ok, detail_message).
      ok=True   -> av1an initialized VSScript successfully.
      ok=False  -> av1an panicked or failed; detail_message explains why.
    """

    with tempfile.TemporaryDirectory(prefix="av1an_smoke_") as tmpdir:
        test_in = Path(tmpdir) / "test_smoke.mkv"
        test_out = Path(tmpdir) / "test_smoke_out.mkv"

        # Create a 1-second 64x64 black video (video-only is enough to
        # trigger VSScript init in av1an — no audio needed).
        gen_cmd = [
            ffmpeg_bin,
            "-f", "lavfi", "-i", "color=c=black:s=64x64:d=1:r=24",
            "-t", "1", "-pix_fmt", "yuv420p", "-an", "-y", str(test_in),
        ]
        try:
            res = subprocess.run(gen_cmd, capture_output=True, text=True, timeout=15)
            if res.returncode != 0:
                return False, f"ffmpeg test-video failed (rc={res.returncode}): {(res.stderr or '')[-200:]}"
        except (OSError, subprocess.SubprocessError) as e:
            return False, f"Could not generate smoke test video: {e}"

        if not test_in.exists():
            return False, "Smoke test video was not created by ffmpeg"

        # Build minimal av1an command
        worker_flag = av1an_flags.get("worker", "--workers")
        vparams_flag = av1an_flags.get("video_params", "--video-params")
        aparams_flag = av1an_flags.get("audio_params", "--audio-params")

        cmd = [
            av1an_bin,
            "-i", str(test_in),
            worker_flag, "1",
            "--encoder", svt_name,
            vparams_flag, "--preset 8 --crf 40 --keyint 240",
            "-o", str(test_out),
        ]

        # Use chunk-method select if available (triggers VSScript init)
        if av1an_flags.get("has_chunk_method"):
            cmd.extend(["--chunk-method", "select"])

        # SEI CERT ERR01-C: catch only the specific exception types we
        # expect from subprocess.run; never swallow unrelated failures.
        try:
            res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout,
                                env=_av1an_env())
        except subprocess.TimeoutExpired:
            # Timeout is a real failure — av1an is hanging. Do NOT mask it.
            return False, f"SMOKE_TIMEOUT: av1an smoke test exceeded {timeout}s — likely hung in VSScript init or encoder spawn"
        except FileNotFoundError as e:
            return False, f"SMOKE_BIN_MISSING: {e}"
        except OSError as e:
            return False, f"SMOKE_OS_ERROR: {e}"

        stderr = res.stderr or ""
        stdout = res.stdout or ""

        # Success requires BOTH rc==0 AND the output file actually exists.
        # The previous code returned True on any non-VSScript failure, which
        # masked real bugs (missing encoder binary, concat failure, etc.)
        # and led to "chunks but never saves a file" symptoms in production.
        if res.returncode == 0 and test_out.exists():
            test_out.unlink(missing_ok=True)
            return True, "av1an VSScript init OK"

        # Classify the known failure modes by inspecting stderr.
        if "Failed to get VSScript API" in stderr:
            return False, "VSScript_API_INCOMPAT"

        if "invalid value" in stderr and "--encoder" in stderr:
            return False, f"INVALID_ENCODER: {stderr[-200:]}"

        if "No usable encoder found" in stderr:
            return False, f"ENCODER_BIN_MISSING: {stderr[-300:]}"

        # Unknown failure — return False so the caller can offer ffmpeg
        # fallback or rebuild. Include the FULL stderr (not just the tail)
        # so the user can see the actual error and the diagnostic patterns
        # below can match on it.
        combined = (stderr + "\n--- stdout ---\n" + stdout)[-1500:]
        return False, f"SMOKE_FAIL(rc={res.returncode}): {combined}"


# ──────────────────────────────────────────────
#  TEMP DIRECTORY MANAGEMENT
# ──────────────────────────────────────────────

_APP_CACHE_DIR: Path | None = None

def _get_app_temp_dir() -> Path:
    """Return the shared temp directory for all intermediate files.

    Priority:
      1. ``~/.cache/OpenTranscode/tmp/``  (XDG-compliant, persistent across reboots)
      2. ``/tmp/OpenTranscode/``           (fallback if home cache is unwritable)

    The directory is created on first call.  All temp intermediates
    (pre-scaled MKVs, av1an work dirs) go here so the user's video
    folders stay clean.

    v3 (OTC-013, SEI CERT FIO09-C): the directory is created with
    ``mode=0o700`` so that other users on the system cannot create
    symlinks inside it (which the cleanup sweep would then follow and
    delete arbitrary files). The mode is verified after creation in
    case the directory already existed with looser permissions.
    """
    global _APP_CACHE_DIR
    if _APP_CACHE_DIR is not None:
        return _APP_CACHE_DIR

    # Try XDG cache dir first
    xdg_cache = os.environ.get("XDG_CACHE_HOME", "")
    if xdg_cache:
        candidate = Path(xdg_cache) / "OpenTranscode" / "tmp"
    else:
        candidate = Path.home() / ".cache" / "OpenTranscode" / "tmp"

    if _mkdir_private(candidate):
        _APP_CACHE_DIR = candidate
        return _APP_CACHE_DIR

    # Fallback: /tmp/OpenTranscode
    fallback = Path("/tmp/OpenTranscode")
    if _mkdir_private(fallback):
        _APP_CACHE_DIR = fallback
        return _APP_CACHE_DIR

    # Last resort: system temp
    _APP_CACHE_DIR = Path(tempfile.gettempdir()) / "OpenTranscode"
    _mkdir_private(_APP_CACHE_DIR)
    return _APP_CACHE_DIR


def _mkdir_private(path: Path) -> bool:
    """Create *path* (and parents) with mode 0o700.

    Returns True on success, False on OSError/PermissionError.

    SEI CERT FIO09-C: if the directory already existed with looser
    permissions (e.g. created by a previous version of this app, or by
    another user before us), we attempt to tighten the mode with
    os.chmod(). The chmod may fail silently if we don't own the dir —
    that's an accepted risk, logged but not fatal.
    """
    try:
        path.mkdir(parents=True, exist_ok=True, mode=0o700)
        # mkdir(mode=) is masked by umask; explicitly chmod to be sure
        os.chmod(path, 0o700)
        return True
    except (OSError, PermissionError):
        return False


def _worker_temp_dir(worker_pid: int) -> Path:
    """Return a per-worker temp subdir named by PID.

    v3: each EncoderWorker gets its own subdir under the shared app temp
    dir, so the final cleanup sweep can safely nuke only this worker's
    intermediates without affecting a concurrent worker. The subdir is
    also created with mode=0o700 (FIO09-C).
    """
    base = _get_app_temp_dir()
    sub = base / f"worker-{worker_pid}"
    _mkdir_private(sub)
    return sub


def _temp_path_for(file_path: Path, suffix: str = ".scaled_tmp.mkv",
                   worker_dir: Path | None = None) -> Path:
    """Build a unique temp path for *file_path* inside the app temp dir.

    Uses a short hash of the original absolute path to avoid collisions
    when files in different subdirs share the same stem.

    v3: if *worker_dir* is provided (per-worker subdir), the temp file
    lands there instead of the shared parent. This isolates concurrent
    workers' intermediates from each other.
    """
    tmp_dir = worker_dir if worker_dir is not None else _get_app_temp_dir()
    # Hash the absolute source path for uniqueness
    path_hash = hashlib.sha256(str(file_path.resolve()).encode()).hexdigest()[:12]
    return tmp_dir / f"{file_path.stem}.{path_hash}{suffix}"


# ──────────────────────────────────────────────
#  KEEP-AWAKE (v6-06: anti-sleep / anti-hibernate)
# ──────────────────────────────────────────────

class KeepAwake:
    """Keep the system awake during a transcode.

    v6-06: Uses systemd-inhibit (preferred) to block sleep/idle at the
    systemd level, plus optional xdotool mouse nudging every 60s as a
    belt-and-suspenders fallback. The user sees a bright-red banner in
    the UI while active. Mouse movement is minimal (1px jitter, not
    constant) so the user can still click STOP or close the window.

    Usage::
        ka = KeepAwake(log_fn=worker.log_msg.emit)
        ka.start()
        try:
            while encoding:
                ka.update_eta(remaining_seconds)
                time.sleep(5)
        finally:
            ka.stop()
    """

    def __init__(
        self,
        log_fn=None,
        enable_mouse_nudge: bool = False,
        nudge_interval: int = 60,
    ):
        self._log_fn = log_fn or (lambda msg: None)
        self._enable_mouse_nudge = enable_mouse_nudge and bool(shutil.which("xdotool"))
        self._nudge_interval = nudge_interval
        self._inhibit_proc: subprocess.Popen | None = None
        self._nudge_count = 0
        self._last_nudge = 0.0
        self._start_time = 0.0
        self._eta_seconds: float | None = None
        self._active = False

    def start(self) -> None:
        """Acquire systemd-inhibit handle. Safe to call multiple times."""
        if self._active:
            return
        self._active = True
        self._start_time = time.monotonic()
        self._acquire_inhibit()
        if self._enable_mouse_nudge:
            self._log_fn("KEEP-AWAKE: mouse nudging enabled (xdotool, every "
                         f"{self._nudge_interval}s)")
        else:
            self._log_fn("KEEP-AWAKE: mouse nudging disabled (xdotool not found "
                         "or not requested)")

    def stop(self) -> None:
        """Release the inhibit handle and stop nudging."""
        if not self._active:
            return
        self._active = False
        self._release_inhibit()
        if self._nudge_count > 0:
            self._log_fn(f"KEEP-AWAKE: stopped (mouse nudged {self._nudge_count} times)")

    def update_eta(self, remaining_seconds: float | None) -> None:
        """Update the ETA shown in the banner. None = unknown."""
        self._eta_seconds = remaining_seconds

    def tick(self) -> str | None:
        """Called periodically (e.g. every 5s) from the UI thread.

        Performs mouse nudge if interval has elapsed.
        Returns the current banner text, or None if keep-awake is not active.
        """
        if not self._active:
            return None
        now = time.monotonic()
        if self._enable_mouse_nudge and (now - self._last_nudge) >= self._nudge_interval:
            self._nudge_mouse()
            self._last_nudge = now
        return self.banner_text()

    def banner_text(self) -> str:
        """Return the banner text for the UI (styled bright-red in QSS)."""
        eta_str = self._format_eta(self._eta_seconds)
        elapsed = time.monotonic() - self._start_time
        elapsed_str = self._format_eta(elapsed)
        nudge_str = f" | mouse: {self._nudge_count}" if self._nudge_count > 0 else ""
        return (
            f"KEEP-AWAKE ACTIVE — system will not sleep | "
            f"elapsed: {elapsed_str} | ETA: {eta_str}{nudge_str}"
        )

    def _format_eta(self, seconds: float | None) -> str:
        if seconds is None:
            return "unknown"
        if seconds < 0:
            return "almost done"
        hours = int(seconds // 3600)
        mins = int((seconds % 3600) // 60)
        secs = int(seconds % 60)
        if hours > 0:
            return f"~{hours}h{mins:02d}m"
        if mins > 0:
            return f"~{mins}m{secs:02d}s"
        return f"~{secs}s"

    def _acquire_inhibit(self) -> None:
        """Fork a systemd-inhibit subprocess that holds the sleep/idle
        inhibit handle for the duration of the transcode."""
        inhibit_bin = shutil.which("systemd-inhibit")
        if not inhibit_bin:
            self._log_fn("KEEP-AWAKE: systemd-inhibit not found — "
                         "system may sleep during transcode")
            return
        try:
            self._inhibit_proc = subprocess.Popen(
                [
                    inhibit_bin,
                    "--what=sleep:idle",
                    "--who=OpenTranscode",
                    "--why=Batch video transcode in progress",
                    "--mode=block",
                    "sleep", "infinity",
                ],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            self._log_fn("KEEP-AWAKE: systemd-inhibit active (sleep/idle blocked)")
        except (OSError, subprocess.SubprocessError) as e:
            self._log_fn(f"KEEP-AWAKE: failed to acquire systemd-inhibit: {e}")
            self._inhibit_proc = None

    def _release_inhibit(self) -> None:
        """Kill the systemd-inhibit subprocess to release the handle."""
        if self._inhibit_proc is None:
            return
        try:
            self._inhibit_proc.terminate()
            self._inhibit_proc.wait(timeout=3)
        except subprocess.TimeoutExpired:
            self._inhibit_proc.kill()
            self._inhibit_proc.wait(timeout=1)
        except (OSError, subprocess.SubprocessError):
            pass
        finally:
            self._inhibit_proc = None
            self._log_fn("KEEP-AWAKE: systemd-inhibit released")

    def _nudge_mouse(self) -> None:
        """Move the mouse 1 pixel to prevent screen-blank.

        Alternates +1px right / -1px left so the cursor ends up where it
        started after every pair of nudges.
        """
        xdotool = shutil.which("xdotool")
        if not xdotool:
            return
        delta = 1 if (self._nudge_count % 2 == 0) else -1
        try:
            subprocess.run(
                [xdotool, "mousemove_relative", "--", str(delta), "0"],
                capture_output=True, timeout=3,
            )
            self._nudge_count += 1
        except (OSError, subprocess.SubprocessError):
            pass  # best-effort — don't crash the transcode over a nudge

    @property
    def is_active(self) -> bool:
        return self._active

    @property
    def has_inhibit(self) -> bool:
        return self._inhibit_proc is not None

    def __enter__(self):
        self.start()
        return self

    def __exit__(self, *args):
        self.stop()


# ──────────────────────────────────────────────
#  ENCODER WORKER (QThread, from PySide6 ver, extended)
# ──────────────────────────────────────────────

class EncoderWorker(QThread):
    log_msg       = Signal(str)
    progress_msg  = Signal(str, int, int)   # (filename, current, total)
    finished_queue = Signal(int, int)        # (success_count, fail_count)

    def __init__(
        self,
        in_dir: Path,
        out_dir: Path,
        video_codec: VideoCodecProfile,
        audio_profile: AudioProfile,
        container: ContainerProfile,
        crf: int,
        preset_label: str,
        delete_source: bool,
        env: EnvProbe,
        extensions: set[str],
        resolution: ResolutionProfile,
        audio_level_db: float = 0.0,
        use_ffmpeg_fallback: bool = False,
        subtitle_lang: str | None = None,
        force: bool = False,
    ):
        super().__init__()
        self.in_dir = in_dir
        self.out_dir = out_dir
        self.video_codec = video_codec
        self.audio_profile = audio_profile
        self.container = container
        self.crf = crf
        self.preset_val = video_codec.preset_map.get(preset_label, 6)
        self.delete_source = delete_source
        self.env = env
        self.extensions = extensions
        self.resolution = resolution
        self.audio_level_db = audio_level_db
        self.use_ffmpeg_fallback = use_ffmpeg_fallback
        self.subtitle_lang = subtitle_lang
        # v5: force=True skips ffprobe validation and attempts encode even
        # for files ffprobe cannot read. Use for the 1% edge case where
        # ffprobe fails but the file is actually valid (rare codec, broken
        # container metadata, etc.). Default False — most "ffprobe can't
        # read" files are genuinely invalid (failed downloads, HTML saved
        # as .mp4, truncated files, etc.).
        self.force = force
        self._stop = False
        self._current_temps: list[Path] = []   # temps for the file currently being processed
        self._sources_to_delete: list[Path] = []  # sources deferred for deletion after final cleanup
        self.success_count = 0
        self.fail_count = 0
        # v5-02: track consecutive failures with the same error pattern.
        # After 3 consecutive same-pattern failures, auto-abort the queue.
        self._consecutive_fail_count = 0
        self._last_fail_pattern: str | None = None
        # v3 (OTC-013, SEI CERT FIO09-C): each worker gets its own
        # per-PID subdir under the shared app temp dir, so the final
        # cleanup sweep can safely nuke only this worker's intermediates
        # without affecting a concurrent worker. The subdir is created
        # with mode=0o700 to prevent symlink attacks from other users.
        self._temp_dir = _worker_temp_dir(os.getpid())
        # v6-06: KeepAwake instance — started in run(), stopped in finally.
        # mouse_nudge defaults to False (opt-in) to avoid surprising the
        # user with cursor movement. systemd-inhibit is always-on when
        # available (no visible side effects).
        self._keepawake = KeepAwake(
            log_fn=lambda msg: self.log_msg.emit(msg),
            enable_mouse_nudge=False,
        )
        self._encode_start_time = 0.0

    def _run_with_stop_check(
        self,
        cmd: list[str],
        env: dict[str, str] | None = None,
        timeout: int = 7200,
        log_prefix: str = "  ",
    ) -> tuple[str, int, str, str]:
        """Run a subprocess with STOP-button support.

        Replaces ``subprocess.run(cmd, capture_output=True, text=True,
        timeout=7200)`` in the av1an and ffmpeg-fallback encode paths so
        that clicking STOP in the UI interrupts a running encode within
        ~1 second instead of waiting up to 2 hours for the per-file
        timeout to expire.

        Polls ``self._stop`` every ~1 second.  When STOP is requested,
        sends SIGTERM to the subprocess's *process group* (so av1an's
        child encoders — SvtAv1EncApp / vpxenc / x265 — die too, not just
        the av1an parent), waits 5s, then SIGKILLs the group if still
        alive.  Also enforces the overall ``timeout`` (7200s) limit.

        Two background drainer threads read stdout/stderr continuously
        into StringIO buffers.  This prevents the classic pipe-buffer
        deadlock: av1an's progress bar can easily exceed the ~64KB OS
        pipe buffer over a long encode, and without draining the child
        would block on ``write()`` and ``proc.poll()`` would never see
        it exit.  This is the same pattern ``subprocess.run`` uses
        internally via ``_communicate``.

        Returns a 4-tuple ``(status, returncode, stdout, stderr)`` where
        ``status`` is one of:

          - ``"ok"``      — process exited normally; caller inspects
                            ``returncode`` (0 = success) and uses
                            ``stdout`` / ``stderr`` for diagnostics.
          - ``"stop"``    — user requested STOP via the UI.  Caller must
                            NOT increment ``fail_count`` (a user abort is
                            not a transcode failure).  ``self._stop`` is
                            already True (set by the UI thread), so the
                            orchestrator's queue loop will break on the
                            next iteration and emit
                            "STOP: Aborted by user."
          - ``"timeout"`` — process exceeded ``timeout`` seconds.
                            Caller MUST increment ``fail_count`` (a
                            timeout is a failure) and emit the existing
                            user-visible TIMEOUT message.

        Raises ``OSError`` / ``subprocess.SubprocessError`` if the
        ``Popen`` constructor itself fails (e.g. ``FileNotFoundError``
        when the binary is missing) — the caller's existing ``except``
        clause handles these unchanged.
        """
        proc = subprocess.Popen(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            env=env,
            # start_new_session=True puts the child in its own process
            # group (setsid).  We can then os.killpg() the whole group
            # to reach av1an's child encoders (SvtAv1EncApp / vpxenc /
            # x265), which a bare proc.terminate() would miss.
            start_new_session=True,
        )

        stdout_buf = io.StringIO()
        stderr_buf = io.StringIO()

        def _drain(stream, buf):
            try:
                while True:
                    chunk = stream.read(4096)
                    if not chunk:
                        break
                    buf.write(chunk)
            except (OSError, ValueError):
                # Stream closed under us or process gone — stop reading.
                pass

        t_out = threading.Thread(
            target=_drain, args=(proc.stdout, stdout_buf), daemon=True,
        )
        t_err = threading.Thread(
            target=_drain, args=(proc.stderr, stderr_buf), daemon=True,
        )
        t_out.start()
        t_err.start()

        status = "ok"
        rc: int | None = None
        start_time = time.monotonic()
        while True:
            rc = proc.poll()
            if rc is not None:
                # Process exited — break and drain pipes below.
                break

            if self._stop:
                self.log_msg.emit(
                    f"{log_prefix}STOP: Aborting current encode, "
                    f"terminating subprocess..."
                )
                try:
                    os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
                except (ProcessLookupError, OSError):
                    # Process already gone — nothing to signal.
                    pass
                try:
                    proc.wait(timeout=5)
                except subprocess.TimeoutExpired:
                    # SIGTERM didn't take effect within the grace period —
                    # escalate to SIGKILL on the whole group.
                    try:
                        os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
                    except (ProcessLookupError, OSError):
                        pass
                    try:
                        proc.wait(timeout=2)
                    except subprocess.TimeoutExpired:
                        # Truly stuck (e.g. uninterruptible IO).  We've
                        # done what we can; the process will be reaped
                        # later.  Continue to pipe drainage.
                        pass
                status = "stop"
                self.log_msg.emit(f"{log_prefix}STOP: Subprocess terminated.")
                break

            if time.monotonic() - start_time > timeout:
                # Overall timeout — kill the process group.  The caller
                # logs the user-visible TIMEOUT message (it includes the
                # file name / "ffmpeg" context this helper doesn't know).
                try:
                    os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
                except (ProcessLookupError, OSError):
                    pass
                try:
                    proc.wait(timeout=2)
                except subprocess.TimeoutExpired:
                    pass
                status = "timeout"
                break

            time.sleep(1)

        # Wait for drainer threads to finish reading any remaining pipe
        # data, then close the pipes explicitly (defensive — __del__
        # would also close them, but explicit is better and avoids
        # ResourceWarning under -X dev).
        t_out.join(timeout=10)
        t_err.join(timeout=10)
        try:
            proc.stdout.close()
        except (OSError, ValueError):
            pass
        try:
            proc.stderr.close()
        except (OSError, ValueError):
            pass

        return (
            status,
            rc if rc is not None else -1,
            stdout_buf.getvalue(),
            stderr_buf.getvalue(),
        )

    def _ffmpeg_fallback_encode(
        self,
        file_path: Path,
        encode_input: Path,
        output_f: Path,
    ) -> bool:
        """Encode a single file using pure ffmpeg (av1an fallback path).

        Used when av1an cannot initialize VapourSynth.  No chunk-parallel
        mode, but ffmpeg uses multithreaded encoding internally.

        Returns True on success, False on failure.
        """
        # Check if ffmpeg has the video encoder we need
        ffmpeg_enc = self.video_codec.ffmpeg_encoder
        # v3: use the module-level FFMPEG_LIB_KEY_MAP (OTC-007).
        ffmpeg_lib_key = ffmpeg_lib_key_for(ffmpeg_enc)

        if not self.env.ffmpeg_libs.get(ffmpeg_lib_key, False):
            self.log_msg.emit(
                f"  FATAL: ffmpeg does not have '{ffmpeg_enc}' encoder. "
                f"Cannot fall back. Install a ffmpeg build with {ffmpeg_enc} support."
            )
            return False

        v_args = self.video_codec.ffmpeg_vargs_fn(self.crf, self.preset_val)

        # Belt-and-suspenders: if a target resolution is set, inject -vf scale
        # directly into the ffmpeg command. This guarantees the output resolution
        # matches the dropdown even if the intermediate pre-scale was bypassed.
        vf_scale_args: list[str] = []
        if self.resolution.width is not None and self.resolution.height is not None:
            vf_scale_args = [
                "-vf", (
                    f"scale={self.resolution.width}:{self.resolution.height}:"
                    f"force_original_aspect_ratio=decrease:force_divisible_by=2"
                ),
            ]

        # Audio args from profile
        audio_args = list(self.audio_profile.params)
        if abs(self.audio_level_db) > 0.01:
            per_file_gain = self._analyze_audio_loudness(file_path)
            if per_file_gain is not None and abs(per_file_gain) > 0.01:
                audio_args.extend(["-af", f"volume={per_file_gain:+.1f}dB"])
            else:
                static_db = f"{self.audio_level_db:+.1f}".replace("+", "")
                audio_args.extend(["-af", f"volume={static_db}dB"])

        # Container-specific muxer flags. -movflags +faststart is MP4-only
        # (it relocates the moov atom for streaming); passing it for MKV or
        # WebM is silently ignored by ffmpeg but pollutes the command line
        # and confuses users reading the log. Apply it only when the
        # output container is MP4.
        mux_flags: list[str] = []
        if self.container.ext == "mp4":
            mux_flags = ["-movflags", "+faststart"]

        cmd = [
            self.env.ffmpeg_path,
            "-i", str(encode_input),
        ] + vf_scale_args + v_args + audio_args + mux_flags + [
            "-y",
            str(output_f),
        ]

        try:
            result = self._run_with_stop_check(cmd, timeout=7200, log_prefix="  ")
            status, rc, stdout, stderr = result

            if status == "stop":
                # User requested STOP — do NOT count as failure.  The
                # caller (_process_one_file) guards the fail_count
                # increment with `if not self._stop`.  Remove partial
                # output so it isn't mistaken for a finished file.
                output_f.unlink(missing_ok=True)
                return False
            if status == "timeout":
                self.log_msg.emit(f"  TIMEOUT: ffmpeg exceeded 2h limit.")
                return False

            # status == "ok" — wrap in CompletedProcess so the downstream
            # returncode/stderr logic is byte-for-byte unchanged.
            res = subprocess.CompletedProcess(cmd, rc, stdout, stderr)

            if res.returncode == 0 and output_f.exists():
                src_size = file_path.stat().st_size
                out_size = output_f.stat().st_size
                ratio = out_size / src_size if src_size > 0 else 0

                if out_size > (src_size * 0.05):
                    return True
                else:
                    self.log_msg.emit(
                        f"  INTEGRITY: output only {ratio * 100:.1f}% of source."
                    )
                    output_f.unlink(missing_ok=True)
                    return False
            else:
                stderr_snip = (res.stderr or "")[-300:]
                self.log_msg.emit(
                    f"  ffmpeg error (rc={res.returncode}): {stderr_snip.strip()}"
                )
                return False
        except OSError as e:
            self.log_msg.emit(f"  SYSTEM ERROR: {e}")
            return False

    def run(self):
        # Use physical core count, leave 1 for OS/UI breathing room
        worker_count = max(1, self.env.cpu.physical_cores - 1)
        phys = self.env.cpu.physical_cores
        logical = self.env.cpu.logical_threads

        # Collect all valid files first (for progress tracking)
        # Exclude our own temp intermediates from previous failed runs.
        all_files = sorted(
            f for f in self.in_dir.rglob("*")
            if f.is_file()
            and f.suffix.lower() in self.extensions
            and not f.name.endswith(".scaled_tmp.mkv")
        )
        total = len(all_files)

        if total == 0:
            self.log_msg.emit("INFO: No matching files found in source directory.")
            self.finished_queue.emit(0, 0)
            return

        # ── Mode banner ──
        # use_ffmpeg_fallback is set by the main thread's pre-flight check.
        # If True, the main thread already logged the fallback reason.
        if self.use_ffmpeg_fallback:
            self.log_msg.emit(
                f"FFmpeg fallback: {self.video_codec.ffmpeg_encoder} on {phys} cores "
                f"(single-pass, no chunk-parallel)"
            )
        else:
            self.log_msg.emit(
                f"Chunk-parallel: {worker_count} workers on {phys} physical cores "
                f"({logical} logical threads, {self.env.cpu.threads_per_core}T/core)"
            )
        self.log_msg.emit(f"Found {total} file(s) to process.")
        self.log_msg.emit(f"Temp dir: {self._temp_dir}")

        # ── Pre-scan: show each file's source → output resolution ──
        needs_scale = (
            self.resolution.width is not None
            and self.resolution.height is not None
        )
        if needs_scale:
            self.log_msg.emit(f"Output resolution: {self.resolution.width}x{self.resolution.height} ({self.resolution.aspect_label})")
        else:
            self.log_msg.emit("Output resolution: Original (no scaling)")
        self.log_msg.emit("─── FILE RESOLUTION MAP ───")
        self._file_res_map: dict[Path, tuple] = {}  # file -> (src_w, src_h, out_w, out_h)
        if self.env.ffprobe_path:
            for f in all_files:
                info = ffprobe_validate(f, self.env.ffprobe_path)
                sw, sh = None, None
                if info:
                    for s in info.get("streams", []):
                        if s.get("codec_type") == "video":
                            sw = int(s.get("width", 0) or 0)
                            sh = int(s.get("height", 0) or 0)
                            break
                if sw and sh:
                    ow, oh = (self.resolution.width, self.resolution.height) if needs_scale else (sw, sh)
                    self._file_res_map[f] = (sw, sh, ow, oh)
                    arrow = "->" if needs_scale else "="
                    action = "" if needs_scale or sw == ow else " (no change)"
                    self.log_msg.emit(f"  {f.name:<40s} {sw:>5}x{sh:<5} {arrow} {ow:>5}x{oh}{action}")
                else:
                    self._file_res_map[f] = (None, None, self.resolution.width if needs_scale else None, self.resolution.height if needs_scale else None)
                    self.log_msg.emit(f"  {f.name:<40s} (unknown resolution)")
        else:
            self.log_msg.emit("  (ffprobe unavailable — resolution map skipped)")
        self.log_msg.emit("───────────────────────────")

        # ── v5-04: Pre-flight validation pass ──
        # Scan all files with ffprobe BEFORE the encode loop. Report how
        # many are valid vs invalid. This gives the user immediate feedback
        # ("46 files found, 0 valid, 46 invalid") instead of failing one
        # by one over 2 hours. If ALL files are invalid and force=False,
        # abort now — don't waste time entering the encode loop.
        if self.env.ffprobe_path and not self.force:
            valid_count = 0
            invalid_count = 0
            invalid_samples: list[str] = []
            for f in all_files:
                info = ffprobe_validate(f, self.env.ffprobe_path)
                if info is None:
                    invalid_count += 1
                    if len(invalid_samples) < 3:
                        ft = _identify_file_type(f)
                        invalid_samples.append(f"  {f.name}: {ft}" if ft else f"  {f.name}: (file type unknown)")
                else:
                    has_video = any(s.get("codec_type") == "video" for s in info.get("streams", []))
                    duration = float(info.get("format", {}).get("duration", 0))
                    if has_video and duration >= 0.5:
                        valid_count += 1
                    else:
                        invalid_count += 1
                        if len(invalid_samples) < 3:
                            reason = "no video stream" if not has_video else f"too short ({duration:.1f}s)"
                            invalid_samples.append(f"  {f.name}: {reason}")

            self.log_msg.emit("─── PRE-FLIGHT VALIDATION ───")
            self.log_msg.emit(f"  Valid files:   {valid_count}")
            self.log_msg.emit(f"  Invalid files: {invalid_count}")
            if invalid_samples:
                self.log_msg.emit(f"  First {len(invalid_samples)} invalid:")
                for s in invalid_samples:
                    self.log_msg.emit(s)
            self.log_msg.emit("─────────────────────────────")

            if valid_count == 0 and invalid_count > 0:
                self.log_msg.emit("")
                self.log_msg.emit(
                    f"ABORT: All {invalid_count} file(s) are invalid. "
                    f"Aborting queue — no files to encode."
                )
                self.log_msg.emit(
                    "  Common causes: (1) failed yt-dlp downloads (HTML saved as .mp4), "
                    "(2) files on a network mount that's not responding, "
                    "(3) wrong input directory."
                )
                self.log_msg.emit(
                    "  Run `file <filename>` on any file to see what it actually is."
                )
                self.fail_count = invalid_count
                self._final_cleanup_sweep()
                self.log_msg.emit(
                    f"QUEUE COMPLETE. Success: 0, Failed: {self.fail_count}."
                )
                self.finished_queue.emit(0, self.fail_count)
                return
            elif invalid_count > 0:
                self.log_msg.emit(
                    f"  {invalid_count} invalid file(s) will be skipped during encoding."
                )
                self.log_msg.emit("")

        scale_filter = (
            f"scale={self.resolution.width}:{self.resolution.height}:"
            f"force_original_aspect_ratio=decrease:force_divisible_by=2,"
            f"pad={self.resolution.width}:{self.resolution.height}:(ow-iw)/2:(oh-ih)/2"
        ) if needs_scale else ""

        # v6-06: Start keep-awake (systemd-inhibit + optional mouse nudge)
        self._encode_start_time = time.monotonic()
        self._keepawake.start()
        try:
            for idx, file_path in enumerate(all_files, 1):
                if self._stop:
                    self.log_msg.emit("STOP: Aborted by user.")
                    break

                prev_success = self.success_count
                prev_fail = self.fail_count

                # v6-06: Update keep-awake ETA before each file.
                # ETA = (avg time per file so far) × (remaining files)
                processed = idx - 1
                if processed > 0:
                    elapsed = time.monotonic() - self._encode_start_time
                    avg_per_file = elapsed / processed
                    remaining = total - processed
                    self._keepawake.update_eta(avg_per_file * remaining)
                else:
                    self._keepawake.update_eta(None)  # unknown for first file

                self._process_one_file(file_path, idx, total, worker_count, needs_scale, scale_filter)

                # v5-02: track consecutive failures with the same error pattern.
                # After 3 consecutive same-pattern failures, auto-abort the queue.
                if self.fail_count > prev_fail:
                    pass  # pattern tracking is handled in _process_one_file
                elif self.success_count > prev_success:
                    # Success resets the consecutive failure counter.
                    self._consecutive_fail_count = 0
                    self._last_fail_pattern = None

            # ── Final cleanup pass: residual sweep ──
            self._final_cleanup_sweep()

            # ── Deferred source deletion (only after all cleanup is done) ──
            if self._sources_to_delete:
                deleted = 0
                for src in self._sources_to_delete:
                    try:
                        if src.exists():
                            src.unlink()
                            deleted += 1
                    except OSError:
                        pass
                self.log_msg.emit(f"CLEANED: Removed {deleted} source file(s) after verified transcode.")
                self._sources_to_delete.clear()

            if self.use_ffmpeg_fallback:
                self.log_msg.emit(
                    f"QUEUE COMPLETE (ffmpeg fallback). Success: {self.success_count}, Failed: {self.fail_count}."
                )
            else:
                self.log_msg.emit(f"QUEUE COMPLETE. Success: {self.success_count}, Failed: {self.fail_count}.")
            self.finished_queue.emit(self.success_count, self.fail_count)
        finally:
            # v6-06: Always stop keep-awake, even if the encode loop crashed.
            self._keepawake.stop()

    def _process_one_file(self, file_path, idx, total, worker_count, needs_scale, scale_filter):
        """Process a single file end-to-end (validate -> prepare -> encode -> verify).

        Extracted from run() so the per-file control flow is readable.  All
        `continue` statements from the original loop become early `return`s
        here.  The caller (run) simply iterates and re-checks `self._stop` at
        the top of each iteration.
        """
        self.progress_msg.emit(file_path.name, idx, total)
        self.log_msg.emit(f"[{idx}/{total}] Encoding: {file_path.name}")

        # --- ffprobe pre-validation ---
        skip, info, src_w, src_h = self._validate_file(file_path)
        if skip:
            # v5-02: a skip is a failure for consecutive-failure tracking.
            self._check_consecutive_failures(file_path, accepted=False)
            return  # _validate_file already logged SKIP + incremented fail_count

        # --- Determine actual output resolution ---
        if src_w and src_h:
            out_w, out_h = src_w, src_h
            if needs_scale:
                out_w, out_h = self.resolution.width, self.resolution.height
            self.log_msg.emit(f"  Source: {src_w}x{src_h}  ->  Output: {out_w}x{out_h}")
        else:
            if needs_scale:
                self.log_msg.emit(f"  Source: unknown  ->  Output: {self.resolution.width}x{self.resolution.height}")
            else:
                self.log_msg.emit(f"  Source: unknown  ->  Output: original")

        # --- Pre-scale / symlink + build output path ---
        prepared = self._prepare_input(file_path, src_w, src_h, needs_scale, scale_filter)
        if prepared is None:
            # v5-02: prepare failure counts for consecutive-failure tracking.
            self._check_consecutive_failures(file_path, accepted=False)
            return  # _prepare_input already logged + cleaned up + incremented fail_count
        encode_input, output_f = prepared

        # --- Encode ---
        encode_ok = self._encode_one(file_path, encode_input, output_f, worker_count)
        if not encode_ok:
            # ffmpeg fallback path: _ffmpeg_fallback_encode does NOT touch
            # _current_temps or fail_count, so we do both here to match the
            # original `else: self.fail_count += 1; self._cleanup_current_temps()`.
            # av1an path: _encode_one's `finally` already cleaned temps and
            # fail_count was incremented inside _encode_one.
            #
            # STOP exception: when the user clicked STOP mid-encode,
            # _run_with_stop_check returned "stop" and _ffmpeg_fallback_encode
            # returned False WITHOUT incrementing fail_count (a user abort is
            # not a transcode failure).  Honor that here by skipping the
            # fail_count increment when self._stop is set — temp cleanup
            # still runs so we don't leak intermediate files.
            if self.use_ffmpeg_fallback:
                if not self._stop:
                    self.fail_count += 1
                self._cleanup_current_temps()
            # v5-02: encode failure counts for consecutive-failure tracking
            # (but only if not a user STOP — a STOP is not a failure).
            if not self._stop:
                self._check_consecutive_failures(file_path, accepted=False)
            return

        # --- Post-encode verification + finalize ---
        accepted = self._verify_and_finalize(file_path, output_f, encode_input, needs_scale)
        if self.use_ffmpeg_fallback:
            # ffmpeg path always cleans up explicitly at every exit
            # (av1an path already cleaned up via _encode_one's `finally`).
            self._cleanup_current_temps()
        # accepted=True  -> success_count already incremented in _verify_and_finalize.
        # accepted=False -> fail_count already incremented + output unlinked there.

        # v5-02: check for consecutive failures with the same pattern.
        self._check_consecutive_failures(file_path, accepted)

    def _check_consecutive_failures(self, file_path: Path, accepted: bool):
        """v5-02: Track consecutive failures and auto-abort after 3.

        After 3 consecutive failures (regardless of pattern — if 3 files
        in a row fail, something is systematically wrong), auto-abort the
        queue with a clear message. The user can still click STOP to
        abort earlier.

        This prevents the scenario from the user's log: 46 files, all
        failing identically, processed one by one over ~2 hours. With
        this fix, the queue aborts after file 3.
        """
        if accepted:
            self._consecutive_fail_count = 0
            return

        self._consecutive_fail_count += 1
        if self._consecutive_fail_count >= 3 and not self._stop:
            self.log_msg.emit("")
            self.log_msg.emit(
                f"ABORT: {self._consecutive_fail_count} consecutive failures. "
                f"Auto-aborting queue — something is systematically wrong."
            )
            self.log_msg.emit(
                "  The remaining files will likely fail the same way. "
                "Fix the root cause (check the diagnostics above) and retry."
            )
            self.log_msg.emit(
                "  Common root causes: (1) all files are invalid (failed downloads), "
                "(2) av1an/encoder binary is broken, (3) out of disk space, "
                "(4) network mount is down."
            )
            self._stop = True

    def _validate_file(self, file_path):
        """ffprobe pre-validation. Returns (skip, info, src_w, src_h).

        skip=True signals the caller to abandon this file — the SKIP log line
        and fail_count increment have already happened here.

        v5-01: If ffprobe cannot read the file, SKIP it instead of
        "attempting encode anyway". The v1-v4 behavior was to log a WARN
        and proceed — but when ffprobe fails, the encode fails ~99% of
        the time (the file is a failed download, HTML saved as .mp4,
        truncated, etc.). Wasting 2 hours on the per-file timeout for
        each invalid file is unacceptable. The `force=True` constructor
        flag overrides this for the 1% edge case (rare codec, broken
        container metadata where ffprobe fails but ffmpeg can still
        decode).
        """
        # Reuse pre-scanned dimensions if available, otherwise probe now
        prescan = self._file_res_map.get(file_path)
        src_w, src_h = (prescan[0], prescan[1]) if prescan else (None, None)
        info = None
        if self.env.ffprobe_path:
            info = ffprobe_validate(file_path, self.env.ffprobe_path)
        if info is None:
            if self.force:
                self.log_msg.emit(
                    f"WARN: ffprobe could not read {file_path.name} — "
                    f"attempting encode anyway (force=True)."
                )
            else:
                # v5-01: run `file` to tell the user WHAT the file actually
                # is. This immediately reveals "HTML document" (failed
                # yt-dlp download) vs "data" (truncated/encrypted) vs
                # "ISO Media" (valid MP4 that ffprobe just can't parse).
                file_type = _identify_file_type(file_path)
                self.log_msg.emit(
                    f"SKIP: {file_path.name} is not a valid video file "
                    f"(ffprobe could not read it)."
                )
                if file_type:
                    self.log_msg.emit(f"  File type: {file_type}")
                    if "HTML" in file_type or "ASCII" in file_type or "text" in file_type:
                        self.log_msg.emit(
                            "  This looks like a text/HTML file, not a video. "
                            "Common cause: failed yt-dlp download (region-locked, "
                            "age-restricted, or removed video). Re-download the file."
                        )
                    elif "data" in file_type:
                        self.log_msg.emit(
                            "  File type is 'data' — possibly truncated, encrypted, "
                            "or a partial download. Verify the file plays in mpv/VLC."
                        )
                self.log_msg.emit(
                    "  (Use the Force checkbox to attempt encode anyway.)"
                )
                self.fail_count += 1
                return (True, None, None, None)
        else:
            duration = float(info.get("format", {}).get("duration", 0))
            has_video = any(s.get("codec_type") == "video" for s in info.get("streams", []))
            if not has_video:
                self.log_msg.emit(f"SKIP: {file_path.name} has no video stream.")
                self.fail_count += 1
                return (True, None, None, None)
            if duration < 0.5:
                self.log_msg.emit(f"SKIP: {file_path.name} is too short ({duration:.1f}s).")
                self.fail_count += 1
                return (True, None, None, None)
            # Extract dims if pre-scan didn't have them
            if not src_w or not src_h:
                for s in info.get("streams", []):
                    if s.get("codec_type") == "video":
                        src_w = int(s.get("width", 0) or 0)
                        src_h = int(s.get("height", 0) or 0)
                        break
        return (False, info, src_w, src_h)

    def _prepare_input(self, file_path, src_w, src_h, needs_scale, scale_filter):
        """Pre-scale (if needed) and ensure the av1an work dir lands in temp.

        Returns (encode_input, output_f) on success, or None on failure
        (after logging + cleaning up current temps + incrementing fail_count).
        """
        # --- Pre-scale with ffmpeg if target resolution selected ---
        # ALL intermediates (scaled files, av1an work dirs) go to the app
        # temp directory so the user's video folders stay clean.
        encode_input = file_path

        if needs_scale:
            try:
                temp_scaled = _temp_path_for(file_path, ".scaled_tmp.mkv", worker_dir=self._temp_dir)
                self._current_temps.append(temp_scaled)
                # Use libx265 lossless for the intermediate — NOT ffv1.
                # ffv1 is not supported by VapourSynth source plugins (bestource,
                # ffms2, lsmash), so av1an's chunking pipeline produces an empty
                # pipe and the encoder emits "Fatal: Failed to open input file".
                # libx265 -crf 0 is bit-for-bit lossless, fast at ultrafast preset,
                # and HEVC-in-MKV is universally supported by every VS plugin.
                scale_cmd = [
                    self.env.ffmpeg_path,
                    "-i", str(file_path),
                    "-vf", scale_filter,
                    "-c:v", "libx265",
                    "-crf", "0",
                    "-preset", "ultrafast",
                    "-pix_fmt", "yuv420p",    # force 8-bit 4:2:0
                    "-y",
                    str(temp_scaled),
                ]
                self.log_msg.emit(f"  Scaling {src_w or '?'}x{src_h or '?'} -> {self.resolution.width}x{self.resolution.height}...")
                scale_res = subprocess.run(
                    scale_cmd, capture_output=True, text=True, timeout=1800,
                )
                if scale_res.returncode == 0 and temp_scaled.exists():
                    encode_input = temp_scaled
                    scaled_size = temp_scaled.stat().st_size / 1_048_576
                    self.log_msg.emit(f"  Pre-scale OK ({scaled_size:.1f} MB intermediate)")
                else:
                    stderr_snip = (scale_res.stderr or "")[-200:]
                    self.log_msg.emit(
                        f"  FAIL: Pre-scale failed for {file_path.name} (rc={scale_res.returncode}). "
                        f"Output resolution must match selected {self.resolution.width}x{self.resolution.height}."
                    )
                    if stderr_snip.strip():
                        self.log_msg.emit(f"  ffmpeg stderr: {stderr_snip.strip()}")
                    temp_scaled.unlink(missing_ok=True)
                    self._cleanup_current_temps()
                    self.fail_count += 1
                    return None
            except (OSError, subprocess.SubprocessError) as e:
                self.log_msg.emit(
                    f"  FAIL: Pre-scale error for {file_path.name}: {e}. "
                    f"Cannot guarantee output resolution {self.resolution.width}x{self.resolution.height}."
                )
                self._cleanup_current_temps()
                self.fail_count += 1
                return None

        # --- Ensure av1an work dir lands in the temp directory ---
        # av1an creates its work dir as {input_path}.av1an by default.
        # We do NOT use av1an's --temp flag because it causes "Error: End of file"
        # during scene detection when the input file is in the same directory
        # as --temp (av1an 0.5.2-unstable).  Instead, we ensure the -i argument
        # always points into the temp dir (pre-scaled files already live there;
        # for no-scale we create a symlink).
        if not encode_input.is_relative_to(self._temp_dir):
            symlink_path = _temp_path_for(file_path, encode_input.suffix, worker_dir=self._temp_dir)
            try:
                symlink_path.unlink(missing_ok=True)
                symlink_path.symlink_to(file_path.resolve())
                self._current_temps.append(symlink_path)
                encode_input = symlink_path
            except OSError as e:
                self.log_msg.emit(
                    f"  WARN: Could not create symlink in temp dir: {e}. "
                    f"av1an work dir will be created next to source file."
                )
        # Track the work dir where av1an will actually create it
        av1an_work = Path(f"{encode_input}.av1an")
        self._current_temps.append(av1an_work)

        # --- Build output path (preserve directory structure) ---
        rel_path = file_path.relative_to(self.in_dir)
        target_dir = self.out_dir / rel_path.parent
        target_dir.mkdir(parents=True, exist_ok=True)

        ext = self.container.ext
        # Always add resolution suffix when a target resolution is selected
        res_suffix = f"_{self.resolution.width}x{self.resolution.height}" if needs_scale else ""
        output_f = target_dir / f"{file_path.stem}{res_suffix}_archived.{ext}"

        return (encode_input, output_f)

    def _can_ffmpeg_fallback(self) -> bool:
        """v6-01: Check if ffmpeg has the encoder for this codec.

        Returns True if ffmpeg can encode with this codec's ffmpeg_encoder
        (e.g. libsvtav1, libvpx-vp9, libx265), False otherwise.
        Used to decide whether to retry a failed av1an encode with ffmpeg.
        """
        ffmpeg_enc = self.video_codec.ffmpeg_encoder
        lib_key = ffmpeg_lib_key_for(ffmpeg_enc)
        return bool(self.env.ffmpeg_libs.get(lib_key, False))

    def _encode_one(self, file_path, encode_input, output_f, worker_count):
        """Dispatch to ffmpeg fallback or av1an. Returns True if encode succeeded.

        ffmpeg fallback: delegates to _ffmpeg_fallback_encode (which itself
        performs the size >=5% integrity check and unlinks bad output).  No
        temp cleanup or fail_count increment happens here for this path —
        _process_one_file handles both at the call site, matching the original.

        av1an: builds and runs the av1an command, performs the size >=5% check
        inline, and wraps everything in try/except/finally so temps are always
        cleaned up via _cleanup_current_temps() — matching the original.  On
        every failure path here, fail_count is incremented inside this method.
        """
        # ── Choose encode path: av1an or ffmpeg fallback ──
        if self.use_ffmpeg_fallback:
            # ── Pure ffmpeg encode path ──
            self.log_msg.emit(f"  Mode: ffmpeg ({self.video_codec.ffmpeg_encoder})")
            return self._ffmpeg_fallback_encode(
                file_path, encode_input, output_f,
            )

        # ── av1an encode path (original) ──
        # Resolve encoder name with probe data
        enc = self.video_codec.av1an_encoder
        if enc in ("svt_av1", "svt") and "svt_name" in self.env.av1an_flags:
            enc = self.env.av1an_flags["svt_name"]

        # Build params via config table (no if/else)
        v_params = self.video_codec.params_fn(self.crf, self.preset_val)

        # Audio params: dual-pass normalization per file, or simple volume
        audio_parts = list(self.audio_profile.params)
        if abs(self.audio_level_db) > 0.01:
            per_file_gain = self._analyze_audio_loudness(file_path)
            if per_file_gain is not None and abs(per_file_gain) > 0.01:
                audio_parts.extend(["-af", f"volume={per_file_gain:+.1f}dB"])
            else:
                # Fallback to knob's static value if analysis failed
                static_db = f"{self.audio_level_db:+.1f}".replace("+", "")
                audio_parts.extend(["-af", f"volume={static_db}dB"])
                self.log_msg.emit(f"  Audio: static gain {self.audio_level_db:+.1f} dB (analysis unavailable)")

        audio_str = " ".join(audio_parts)

        cmd = [
            self.env.av1an_path,
            "-i", str(encode_input),
            self.env.av1an_flags.get("worker", "--workers"), str(worker_count),
        ]

        # Chunk method: let av1an auto-select.
        # The intermediate (when pre-scaling) is now HEVC lossless in MKV,
        # which all VS plugins handle correctly, so auto-select is safe.
        chunk_method = self.env.av1an_flags.get("chunk_method_override")
        if chunk_method:
            cmd.extend(["--chunk-method", chunk_method])
        self.log_msg.emit(f"  Chunking: {chunk_method or 'auto'} (av1an default if no override)")

        cmd.extend([
            "--encoder", enc,
            self.env.av1an_flags.get("video_params", "--video-params"), v_params,
            self.env.av1an_flags.get("audio_params", "--audio-params"), audio_str,
            "--concat", self.env.av1an_flags.get("concat_method", "ffmpeg"),
            "-o", str(output_f),
        ])

        # Log the full av1an command for debugging
        self.log_msg.emit(f"  CMD: {' '.join(cmd)}")

        try:
            result = self._run_with_stop_check(
                cmd, env=_av1an_env(), timeout=7200, log_prefix="  ",
            )
            status, rc, stdout, stderr = result

            if status == "stop":
                # User requested STOP — do NOT increment fail_count (the
                # user explicitly chose to abort, it isn't a transcode
                # failure).  Remove partial output.  self._stop is already
                # True (set by the UI thread), so the orchestrator's
                # queue loop will break on the next iteration and emit
                # "STOP: Aborted by user."
                output_f.unlink(missing_ok=True)
                return False
            if status == "timeout":
                self.fail_count += 1
                self.log_msg.emit(f"TIMEOUT: {file_path.name} exceeded 2h limit.")
                return False

            # status == "ok" — wrap in CompletedProcess so the downstream
            # returncode check, diagnostic dump, and pattern matching are
            # byte-for-byte unchanged.
            res = subprocess.CompletedProcess(cmd, rc, stdout, stderr)

            if res.returncode == 0 and output_f.exists():
                src_size = file_path.stat().st_size
                out_size = output_f.stat().st_size
                ratio = out_size / src_size if src_size > 0 else 0

                # Integrity: output must be at least 5% of source
                if out_size > (src_size * 0.05):
                    # Success — resolution/duration/subtitle/finalize happen
                    # in _verify_and_finalize (called by _process_one_file).
                    return True
                else:
                    self.fail_count += 1
                    self.log_msg.emit(
                        f"ERROR: Integrity check failed for {file_path.name} "
                        f"(output only {ratio * 100:.1f}% of source)."
                    )
                    # Remove corrupt output
                    output_f.unlink(missing_ok=True)
                    return False
            else:
                stderr_full = res.stderr or ""
                # v6: Don't increment fail_count yet — we may retry with
                # ffmpeg fallback below. Only increment if the retry also
                # fails (or no retry is possible).
                # Diagnostic dump — line-truncated, not character-truncated.
                self.log_msg.emit(
                    f"FAIL: {file_path.name} (av1an exit code {res.returncode})"
                )
                self.log_msg.emit("  ─── av1an stderr (last 25 lines) ───")
                stderr_lines = stderr_full.splitlines()
                for line in stderr_lines[-25:]:
                    self.log_msg.emit(f"  {line}")
                self.log_msg.emit("  ────────────────────────────────────")

                # Detect known av1an crash patterns and provide actionable fixes.
                # Pattern table — add new patterns here, no nested ifs below.
                # SEI CERT MSC04-C spirit: single source of truth for diagnostics.
                #
                # v5-03: Added "missing field `streams`" pattern — this is
                # the error av1an emits when its internal ffprobe call
                # returns JSON without a streams field, i.e. the input file
                # is not a valid video. Also added `file` command output
                # to the diagnostic so the user immediately sees "HTML
                # document" (failed yt-dlp download) instead of guessing.
                error_patterns: tuple[tuple[str, str, tuple[str, ...], bool], ...] = (
                    (
                        "Failed to get VSScript API",
                        "av1an cannot initialize VapourSynth — the binary was "
                        "compiled against a different VapourSynth version than "
                        "what is currently installed.",
                        (
                            "  FIX (Arch): yay -S av1an  OR  cargo install av1an --force --locked",
                            "  FIX (Debian): sudo apt install vapoursynth libvapoursynth-script-dev av1an",
                            "  FIX (other): rebuild av1an against current VapourSynth",
                            "  VapourSynth R77+ changed the VSScript API; av1an must be recompiled.",
                        ),
                        True,  # stop queue — every file will hit the same crash
                    ),
                    (
                        "No usable encoder found",
                        "av1an cannot find the encoder binary (SvtAv1EncApp / vpxenc / x265).",
                        (
                            "  Verify the encoder is installed and in PATH.",
                            "  Arch: pacman -S svt-av1 libvpx-tools x265",
                            "  Debian: apt install svt-av1 libvpx-tools x265",
                        ),
                        True,
                    ),
                    # v6-02: av1an scene-detection panic — per-file, not systematic.
                    (
                        "split scores is not empty",
                        "av1an panicked during scene detection (known av1an bug). "
                        "This is a per-file issue — the video content triggered a "
                        "Rust panic in av1an's split module. Will retry with ffmpeg.",
                        (
                            "  This is an av1an internal bug, not a file corruption issue.",
                            "  The file is a valid video — ffmpeg can encode it directly.",
                        ),
                        False,  # don't stop queue — retry with ffmpeg fallback
                    ),
                    (
                        "missing field `streams`",
                        "av1an's internal ffprobe call could not parse this file — "
                        "the file is not a valid video container. This is NOT an "
                        "av1an or ffmpeg bug; the input file itself is invalid.",
                        (
                            "  The file is likely a failed yt-dlp download (HTML error",
                            "  page saved as .mp4), a truncated download, or not a video",
                            "  at all. Run `file <filename>` to confirm.",
                        ),
                        False,  # don't stop queue — other files may be valid
                    ),
                    (
                        "Invalid data found when processing input",
                        "ffmpeg cannot read this input file — the file is corrupt, "
                        "truncated, or not a valid video container.",
                        (
                            "  Run `file <filename>` to see what the file actually is.",
                            "  If it's 'HTML document' or 'ASCII text', it's a failed",
                            "  yt-dlp download — re-download the source video.",
                            "  If it's 'data', the file may be truncated or encrypted.",
                        ),
                        False,
                    ),
                    (
                        "Error: End of file",
                        "av1an hit EOF during scene detection — usually a VapourSynth "
                        "source plugin issue with the intermediate file.",
                        (
                            "  Try a different --chunk-method (override via env probe).",
                            "  If pre-scaling, ensure the intermediate is libx265 CRF 0 (not ffv1).",
                        ),
                        False,
                    ),
                    (
                        "could not open input",
                        "av1an cannot read this input file — possibly corrupt or "
                        "an unsupported codec for the VapourSynth source plugin.",
                        (
                            "  Try playing the file with ffplay to verify it's not corrupt.",
                            "  Run: ffmpeg -i <file> -f null -  to see the decode error.",
                        ),
                        False,
                    ),
                )
                diagnosis_emitted = False
                for marker, summary, fixes, stop_queue in error_patterns:
                    if marker.lower() in stderr_full.lower():
                        self.log_msg.emit("")
                        self.log_msg.emit(f"DIAGNOSIS: {summary}")
                        for fix in fixes:
                            self.log_msg.emit(fix)
                        # v5-03: run `file` on the input to tell the user
                        # what the file actually is. This is especially
                        # useful for "missing field streams" and "Invalid
                        # data found" — the user immediately sees "HTML
                        # document" instead of guessing.
                        if marker in ("missing field `streams`",
                                      "Invalid data found when processing input",
                                      "could not open input"):
                            file_type = _identify_file_type(file_path)
                            if file_type:
                                self.log_msg.emit(f"  File type: {file_type}")
                                if "HTML" in file_type or "ASCII" in file_type or "text" in file_type:
                                    self.log_msg.emit(
                                        "  → This is a TEXT file, not a video. "
                                        "Failed yt-dlp download — re-download the source."
                                    )
                                elif "data" in file_type and "ISO Media" not in file_type:
                                    self.log_msg.emit(
                                        "  → File type is 'data' — truncated, encrypted, "
                                        "or partial download."
                                    )
                        if stop_queue:
                            self._stop = True
                            self.log_msg.emit(
                                f"STOP: Skipping remaining files (same {marker} issue)."
                            )
                        diagnosis_emitted = True
                        break

                if not diagnosis_emitted:
                    # No known pattern matched — show the user where to look.
                    # v6-03: detect "encoder SUMMARY in stderr + non-zero exit"
                    # — the encoder succeeded but av1an failed to produce output.
                    # This is the "chunks but never saves a file" pattern caused
                    # by av1an's concat step failing.
                    if "SUMMARY" in stderr_full and "Average Speed" in stderr_full:
                        self.log_msg.emit("")
                        self.log_msg.emit(
                            "DIAGNOSIS: SVT-AV1 encoder completed successfully (SUMMARY"
                            " block found in stderr), but av1an failed to produce the"
                            " output file. This is an av1an concat failure — the encoder"
                            " did its job but av1an's post-encode merge step crashed."
                        )
                        self.log_msg.emit(
                            "  This is a known av1an bug on short videos (1-2 scenes)"
                            " where concat of a single chunk fails. Will retry with"
                            " ffmpeg fallback."
                        )
                    else:
                        self.log_msg.emit("")
                        self.log_msg.emit(
                            "DIAGNOSIS: Unknown av1an failure. Inspect the full stderr above."
                        )
                        # v5-03: run `file` on the input as a fallback diagnostic.
                        file_type = _identify_file_type(file_path)
                        if file_type:
                            self.log_msg.emit(f"  File type: {file_type}")
                        self.log_msg.emit(
                            "  Common causes: (1) out of disk space in temp dir, "
                            "(2) AV1 concat failed silently — try installing mkvtoolnix, "
                            "(3) av1an version too old for --concat flag — check av1an --help, "
                            "(4) input file is not a valid video (run `file <filename>`)."
                        )

                # ── v6-01: Per-file av1an→ffmpeg fallback ──
                # If av1an failed for this file AND it's NOT a systematic issue
                # (VSScript API, missing encoder — those set self._stop=True),
                # AND ffmpeg has the encoder for this codec, retry with ffmpeg.
                # This handles:
                #   - av1an concat failures (encoder succeeded but no output)
                #   - av1an scene-detection panics ("split scores is not empty")
                #   - Any other per-file av1an internal failure
                #
                # NOTE: Do NOT clean up _current_temps before the retry —
                # encode_input (symlink or pre-scaled file) is in _current_temps
                # and _ffmpeg_fallback_encode needs it. The finally block below
                # will clean up everything after the retry completes.
                if not self._stop and self._can_ffmpeg_fallback():
                    self.log_msg.emit("")
                    self.log_msg.emit(
                        f"  RETRY: Attempting ffmpeg fallback for {file_path.name} "
                        f"({self.video_codec.ffmpeg_encoder})..."
                    )
                    # Remove any partial output av1an may have left
                    output_f.unlink(missing_ok=True)
                    # Retry with ffmpeg — _ffmpeg_fallback_encode does NOT
                    # increment fail_count on failure (the caller does that).
                    # If it succeeds, we return True WITHOUT incrementing
                    # fail_count — the file was saved, just via a different path.
                    fb_ok = self._ffmpeg_fallback_encode(
                        file_path, encode_input, output_f,
                    )
                    if fb_ok:
                        self.log_msg.emit(
                            f"  RETRY OK: ffmpeg fallback succeeded for {file_path.name}"
                        )
                        return True
                    else:
                        self.fail_count += 1
                        self.log_msg.emit(
                            f"  RETRY FAIL: ffmpeg fallback also failed for {file_path.name}"
                        )
                        return False
                else:
                    # No retry possible — this is a systematic issue (stop_queue
                    # was set) or ffmpeg lacks the encoder.
                    self.fail_count += 1
                    return False
        except (OSError, subprocess.SubprocessError) as e:
            self.fail_count += 1
            self.log_msg.emit(f"SYSTEM ERROR: {file_path.name} — {e}")
            return False
        finally:
            # Always clean this file's temps before moving to next
            self._cleanup_current_temps()

    def _verify_and_finalize(self, file_path, output_f, encode_input, needs_scale):
        """Post-encode verification + subtitle mux + source deletion deferral.

        Runs after a successful _encode_one.  Performs:
          - output resolution verification (if scaling was requested)
          - duration integrity check (>= 95% of source)
          - subtitle mux (if requested)
          - success_count increment + SUCCESS log
          - source deletion deferral (if delete_source is set)

        Returns True if the file was accepted, False if any check failed.
        On failure, fail_count is incremented and output_f is unlinked before
        returning False.  Temp cleanup is the caller's responsibility — it
        differs between the av1an path (already done in _encode_one's finally)
        and the ffmpeg fallback path (done explicitly in _process_one_file).
        """
        # Post-encode resolution verification
        if needs_scale and self.env.ffprobe_path:
            if not _verify_output_resolution(
                output_f, self.env.ffprobe_path,
                self.resolution.width, self.resolution.height,
            ):
                self.fail_count += 1
                self.log_msg.emit(
                    f"  FAIL: Output resolution verification failed for {file_path.name}. "
                    f"Expected {self.resolution.width}x{self.resolution.height}."
                )
                output_f.unlink(missing_ok=True)
                return False
        src_size = file_path.stat().st_size
        out_size = output_f.stat().st_size
        ratio = out_size / src_size if src_size > 0 else 0

        # Duration integrity check (>= 95% of source)
        dur_ok = True
        dur_info = ""
        if self.env.ffprobe_path:
            src_dur = ffprobe_duration(file_path, self.env.ffprobe_path)
            out_dur = ffprobe_duration(output_f, self.env.ffprobe_path)
            if src_dur and out_dur:
                dur_ratio = out_dur / src_dur
                dur_ok = dur_ratio >= 0.95
                dur_info = f", duration {out_dur:.1f}s/{src_dur:.1f}s ({dur_ratio * 100:.0f}%)"

        if not dur_ok:
            self.fail_count += 1
            self.log_msg.emit(f"INTEGRITY FAIL: {file_path.name} — duration{dur_info}")
            output_f.unlink(missing_ok=True)
            return False

        # Mux subtitle if requested (needs source file intact)
        if self.subtitle_lang:
            self._mux_subtitle(file_path, output_f)

        self.success_count += 1
        self.log_msg.emit(
            f"SUCCESS: {file_path.name} "
            f"({src_size / 1_048_576:.1f}MB -> {out_size / 1_048_576:.1f}MB, "
            f"{ratio * 100:.0f}%{dur_info})"
        )
        # Defer source deletion until after final cleanup
        if self.delete_source:
            self._sources_to_delete.append(file_path)
        return True

    def _cleanup_current_temps(self):
        """Remove all tracked temp files/dirs for the current file.

        Resilient: each removal is try/except'd individually so one bad path
        doesn't block the rest.  Clears the tracking list when done.
        """
        for tf in self._current_temps:
            try:
                if tf.is_dir():
                    shutil.rmtree(str(tf), ignore_errors=True)
                elif tf.exists():
                    tf.unlink()
            except Exception:
                pass
        self._current_temps.clear()

    def _final_cleanup_sweep(self):
        """Residual sweep to catch any orphaned temp files.

        v3 (OTC-013): primary target is now the per-worker subdir
        (``~/.cache/OpenTranscode/tmp/worker-<pid>/``), NOT the shared
        app temp dir. This is safe because the subdir ONLY contains
        this worker's intermediates — a concurrent worker has its own
        subdir. The previous "nuclear" sweep of the entire app temp
        dir was a race-condition risk that this eliminates.
        Also scans in_dir/out_dir as a safety net for legacy temp files
        written by older versions that placed temps next to source files.
        """
        swept = 0

        # v3: sweep ONLY this worker's per-PID subdir, not the shared parent.
        # This is safe — the subdir contains only this worker's intermediates.
        if self._temp_dir.is_dir():
            for hit in self._temp_dir.iterdir():
                try:
                    if hit.is_dir():
                        shutil.rmtree(str(hit), ignore_errors=True)
                    else:
                        hit.unlink(missing_ok=True)
                    swept += 1
                except OSError:
                    # SEI CERT ERR01-C: narrow to OSError (file ops).
                    # Best-effort sweep must not crash on a single bad path.
                    pass

        # Safety-net sweep of user directories (for legacy temp files
        # written by older versions that placed temps next to source files)
        legacy_patterns = ["*.scaled_tmp.mkv", "*.av1an", "*_encodes", "*.*.av1an"]
        for search_dir in (self.in_dir, self.out_dir):
            if not search_dir.is_dir():
                continue
            for pattern in legacy_patterns:
                for hit in search_dir.rglob(pattern):
                    try:
                        if hit.is_dir():
                            shutil.rmtree(str(hit), ignore_errors=True)
                        else:
                            hit.unlink(missing_ok=True)
                        swept += 1
                    except OSError:
                        pass
        # Also clean any orphans still in _current_temps (e.g. stop/crash mid-loop)
        self._cleanup_current_temps()
        # v3: remove the now-empty per-worker subdir itself.
        try:
            self._temp_dir.rmdir()
        except OSError:
            pass  # not empty / not ours — leave it
        if swept:
            self.log_msg.emit(f"CLEANUP: Swept {swept} residual temp file(s)/dir(s).")

    # ── Audio loudness analysis (dual-pass normalization) ──

    def _analyze_audio_loudness(self, file_path: Path) -> float | None:
        """Dual-pass loudnorm analysis for a single file.

        Pass 1: Run loudnorm in analysis-only mode to measure the file's current
        integrated loudness (I) and true peak (TP).

        Returns the dB gain to apply, or None if analysis fails (falls back to
        the knob's static value).
        """
        if not self.env.ffmpeg_path:
            return None
        if abs(self.audio_level_db) < 0.01:
            return None  # knob is at 0 — no normalization requested

        target_lufs = self.audio_level_db  # knob value IS the target LUFS

        try:
            # Pass 1: analyze current loudness
            analysis_cmd = [
                self.env.ffmpeg_path,
                "-i", str(file_path),
                "-af", (
                    f"loudnorm=I={target_lufs}:TP=-1.5:LRA=11:"
                    f"print_format=json"
                ),
                "-f", "null", "-",
            ]
            res = subprocess.run(
                analysis_cmd, capture_output=True, text=True, timeout=120,
            )

            # Parse the JSON stats from stderr (loudnorm prints to stderr)
            stderr = res.stderr or ""

            # Find the JSON block
            json_match = re.search(r'\{[^{}]*"input_i"[^{}]*\}', stderr, re.DOTALL)
            if not json_match:
                return None

            stats = json.loads(json_match.group())

            input_i = float(stats.get("input_i", "-99"))
            input_tp = float(stats.get("input_tp", "-99"))
            target_tp = float(stats.get("target_tp", "-1.5"))

            # If file is already silent or near-silent, skip
            if input_i <= -70:
                return None

            # Compute the gain loudnorm would apply
            gain_db = target_lufs - input_i

            # Pass 2 concept: check if applying this gain would push peaks
            # above our ceiling. The ceiling is target_tp (default -1.5 dBTP).
            # We want 15% headroom below that ceiling.
            headroom_db = abs(target_tp) * 0.15
            peak_ceiling = target_tp + headroom_db

            # If the file's true peak + gain would exceed the ceiling, clamp
            projected_peak = input_tp + gain_db
            if projected_peak > peak_ceiling:
                gain_db = peak_ceiling - input_tp

            self.log_msg.emit(
                f"  Audio: {input_i:.1f} LUFS -> {target_lufs:.1f} LUFS "
                f"(gain {gain_db:+.1f} dB, peak {input_tp:.1f} -> "
                f"{input_tp + gain_db:.1f} dBTP)"
            )
            return gain_db

        except (OSError, subprocess.SubprocessError, ValueError) as e:
            # ValueError covers json.JSONDecodeError and float() parse failures
            self.log_msg.emit(f"  Audio: loudnorm analysis failed ({e}), using knob value")
            return None

    # ── Subtitle extraction & muxing ──

    def _find_subtitle_stream(self, source: Path, lang: str) -> tuple[int | None, str]:
        """Find subtitle stream in source matching language code.
        Prefers forced disposition tracks. Returns (stream_index, codec_name)."""
        if not self.env.ffprobe_path:
            return (None, "")

        info = ffprobe_validate(source, self.env.ffprobe_path)
        if not info:
            return (None, "")

        forced_match = None
        any_match = None

        for stream in info.get("streams", []):
            if stream.get("codec_type") != "subtitle":
                continue
            tags = stream.get("tags", {})
            if tags.get("language", "").lower() != lang.lower():
                continue

            idx = stream.get("index")
            codec = stream.get("codec_name", "")
            disposition = stream.get("disposition", {})

            if disposition.get("forced") and forced_match is None:
                forced_match = (idx, codec)
            if any_match is None:
                any_match = (idx, codec)

        return forced_match if forced_match else (any_match or (None, ""))

    def _mux_subtitle(self, source: Path, output: Path):
        """Mux a subtitle track from source into the encoded output (soft sub).
        Uses stream copy for MKV; converts to WebVTT for WebM containers."""
        sub_idx, sub_codec = self._find_subtitle_stream(source, self.subtitle_lang)

        if sub_idx is None:
            self.log_msg.emit(f"  SUBS: No {self.subtitle_lang} subtitle found in {source.name}")
            return

        # WebM only supports WebVTT natively; MKV carries any subtitle codec
        is_webm = output.suffix.lower() == ".webm"
        sub_codec_flag = "copy" if not is_webm else "webvtt"

        tmp_out = output.with_suffix(output.suffix + ".submux_tmp")
        try:
            cmd = [
                self.env.ffmpeg_path,
                "-i", str(output),       # encoded output (video + audio)
                "-i", str(source),       # original source (subtitle source)
                "-map", "0",              # all streams from encoded output
                "-map", "-0:s",           # strip any subtitle from output
                "-map", f"1:{sub_idx}",   # subtitle from source
                "-c:v", "copy",
                "-c:a", "copy",
                "-c:s", sub_codec_flag,
                "-y",
                str(tmp_out),
            ]
            res = subprocess.run(cmd, capture_output=True, text=True)

            if res.returncode == 0 and tmp_out.exists() and tmp_out.stat().st_size > 0:
                output.unlink()
                tmp_out.rename(output)
                self.log_msg.emit(
                    f"  SUBS: Muxed {self.subtitle_lang} sub ({sub_codec}) into {output.name}"
                )
            else:
                tmp_out.unlink(missing_ok=True)
                tail = (res.stderr or "")[-200:]
                self.log_msg.emit(f"  SUBS WARN: Remux failed for {output.name}: {tail}")
        except (OSError, subprocess.SubprocessError) as e:
            tmp_out.unlink(missing_ok=True)
            self.log_msg.emit(f"  SUBS ERROR: {e}")

    def stop(self):
        self._stop = True


# ──────────────────────────────────────────────
#  SOURCE BUILD WORKER — compile VS + av1an from git
# ──────────────────────────────────────────────

class SourceBuildWorker(QThread):
    """Builds VapourSynth and/or av1an from git to resolve ABI mismatches.

    Runs in a background thread.  Emits progress via log_msg.
    When done, emits build_done(success, message).

    Everything installs to the user's home directory (no sudo for install):
      VapourSynth → ~/.local/lib/  (av1an finds it via LD_LIBRARY_PATH)
      av1an       → ~/.cargo/bin/   (already in PATH)
    Only build-dependency installation (pacman -S) may need sudo.
    """
    log_msg    = Signal(str)
    build_done = Signal(bool, str)   # (success, detail)

    def __init__(self, build_vs: bool = True, build_av1an: bool = True,
                 build_ffmpeg_iamf: bool = False):
        super().__init__()
        self.build_vs = build_vs
        self.build_av1an = build_av1an
        self.build_ffmpeg_iamf = build_ffmpeg_iamf
        self._stop = False
        # Private per-worker environment snapshot. Mutating os.environ is
        # process-global and leaks across threads/subsequent subprocesses;
        # _build_env is local to this worker and passed via env= to every
        # subprocess.run call below (see _run_cmd).
        self._build_env: dict[str, str] = os.environ.copy()

    def _extend_env(self, var: str, value: str, prepend: bool = False):
        """Add ``value`` to ``self._build_env[var]`` (NOT ``os.environ``).

        ``prepend=True`` places ``value`` first so it shadows any existing
        entry (e.g. ~/.local/bin must shadow /usr/bin, libiamf's
        PKG_CONFIG_PATH must shadow the system pkgconfig dir); default
        appends (e.g. extending PATH with ~/.cargo/bin). Caller is
        responsible for any idempotency check (matches the original
        per-site ``if x not in existing:`` pattern). rstrip(":") on
        prepend avoids a trailing colon when ``var`` was previously unset.
        """
        existing = self._build_env.get(var, "")
        if prepend:
            self._build_env[var] = f"{value}:{existing}".rstrip(":")
        else:
            self._build_env[var] = f"{existing}:{value}" if existing else value

    def _run_cmd(self, cmd, cwd=None, timeout=600, label=""):
        """Run a command, log output, return (returncode, combined_output)."""
        self.log_msg.emit(f"  $ {' '.join(cmd[:6])}{'...' if len(cmd)>6 else ''}")
        try:
            r = subprocess.run(cmd, capture_output=True, text=True,
                               timeout=timeout, cwd=cwd, env=self._build_env)
            # Log last few lines of stderr for diagnostics
            if r.stderr:
                for line in r.stderr.strip().splitlines()[-5:]:
                    self.log_msg.emit(f"    {line}")
            if r.returncode != 0 and r.stdout:
                for line in r.stdout.strip().splitlines()[-3:]:
                    self.log_msg.emit(f"    {line}")
            return r.returncode, (r.stdout or "") + (r.stderr or "")
        except subprocess.TimeoutExpired:
            self.log_msg.emit(f"  TIMEOUT ({timeout}s) running: {label or cmd[0]}")
            return -1, f"timeout after {timeout}s"
        except (OSError, subprocess.SubprocessError) as e:
            self.log_msg.emit(f"  ERROR: {e}")
            return -1, str(e)

    def _sudo_cmd(self, cmd, timeout=120, label=""):
        """Run a command with sudo (or pkexec as graphical fallback)."""
        # Try pkexec first (graphical polkit prompt — works in desktop sessions)
        pkexec = shutil.which("pkexec")
        if pkexec:
            return self._run_cmd([pkexec] + cmd, timeout=timeout, label=label or cmd[0])
        # Fall back to sudo (needs a terminal; may fail silently)
        return self._run_cmd(["sudo"] + cmd, timeout=timeout, label=label or cmd[0])

    def run(self):
        try:
            # ── Install build dependencies (may need one sudo prompt) ──
            self.log_msg.emit("")
            self.log_msg.emit("=== Installing build dependencies ===")
            all_deps = [
                "meson", "ninja", "gcc", "pkg-config", "git",
                "nasm", "yasm", "cmake", "python", "make",
            ]
            need_rust = self.build_av1an and not shutil.which("cargo")
            if need_rust:
                all_deps.append("rust")

            # Only invoke sudo if at least one dep is missing
            missing = [d for d in all_deps if not shutil.which(d)]
            if missing:
                self.log_msg.emit(f"  Missing: {', '.join(missing)} — installing via pacman")
                rc, _ = self._sudo_cmd(
                    ["pacman", "-S", "--needed", "--noconfirm"] + all_deps,
                    timeout=300, label="pacman build-deps",
                )
                if rc != 0:
                    self.log_msg.emit("  (some deps may already be installed — continuing)")
            else:
                self.log_msg.emit("  All build dependencies already installed.")

            # Ensure cargo is in PATH after potential install.
            # NOTE: /root/.cargo/bin was dropped (OTC-015/v3-08) — root's
            # cargo dir is not readable by a non-root user. ~/.cargo/bin
            # covers the user's rustup install; /usr/bin is already in the
            # default PATH and is appended here only to match the original
            # mutation's intent (cargo from pacman lives there).
            self._extend_env("PATH", "/usr/bin")
            self._extend_env("PATH", str(Path.home() / ".cargo" / "bin"))
            if not shutil.which("cargo") and self.build_av1an:
                self.log_msg.emit("  FATAL: cargo not found after deps install. Aborting.")
                self.build_done.emit(False, "Rust/cargo not available")
                return

            # ── Optional: ffmpeg build deps (libopus, libvorbis dev pkgs) ──
            if self.build_ffmpeg_iamf:
                self._install_ffmpeg_build_deps()

            # ── Build & install VapourSynth to ~/.local (NO sudo needed) ──
            if self.build_vs:
                self._build_vapoursynth()

            # ── Build av1an to ~/.cargo/bin (NO sudo needed) ──
            if self.build_av1an:
                self._build_av1an()

            # ── Build libiamf + ffmpeg with --enable-libiamf to ~/.local ──
            if self.build_ffmpeg_iamf:
                self._build_libiamf()
                self._build_ffmpeg_with_iamf()

            # ── Ensure LD_LIBRARY_PATH includes local VS libs ──
            local_lib = str(Path.home() / ".local" / "lib")
            existing_ld = self._build_env.get("LD_LIBRARY_PATH", "")
            if local_lib not in existing_ld:
                self._extend_env("LD_LIBRARY_PATH", local_lib, prepend=True)
                self.log_msg.emit(f"  Set LD_LIBRARY_PATH to include {local_lib}")

            self.log_msg.emit("")
            self.log_msg.emit("=== Source build complete ===")
            self.build_done.emit(True, "Build and install completed (local ~/.local/).")

        except Exception as e:
            # SEI CERT ERR01-C: justified — this method orchestrates a long
            # multi-step build (git clone, meson, ninja, cargo install) whose
            # helper methods signal failure by `raise Exception(msg)` (15
            # sites). Catching Exception here converts any of those into a
            # user-facing build_done(False, ...) signal instead of crashing
            # the QThread. Narrowing would require refactoring all `raise
            # Exception(...)` call sites — out of scope for ERR01-C pass.
            self.log_msg.emit(f"BUILD FAILED: {e}")
            self.build_done.emit(False, str(e))

    def _build_vapoursynth(self):
        """Clone, build, and install VapourSynth to ~/.local/ (no sudo needed)."""
        self.log_msg.emit("")
        self.log_msg.emit("=== Building VapourSynth from git ===")
        self.log_msg.emit("  Install target: ~/.local/ (no system-wide changes)")
        build_dir = Path("/tmp/vapoursynth-git-build")
        local_prefix = str(Path.home() / ".local")

        if build_dir.exists():
            self.log_msg.emit(f"  Cleaning old build directory...")
            shutil.rmtree(build_dir, ignore_errors=True)

        # Clone (shallow — faster)
        rc, out = self._run_cmd(
            ["git", "clone", "--depth", "1",
             "https://github.com/vapoursynth/vapoursynth.git",
             str(build_dir)],
            timeout=120, label="git clone vapoursynth",
        )
        if rc != 0:
            raise Exception(f"git clone VapourSynth failed: {out[-300:]}")

        # Meson setup — install to ~/.local so it doesn't touch system dirs
        self.log_msg.emit("  Configuring with meson (--prefix=~/.local)...")
        rc, out = self._run_cmd(
            ["meson", "setup", "build",
             f"--prefix={local_prefix}", "--libdir=lib"],
            cwd=str(build_dir), timeout=120, label="meson setup",
        )
        if rc != 0:
            raise Exception(f"meson setup failed: {out[-500:]}")

        # Build
        self.log_msg.emit("  Compiling VapourSynth (this may take a few minutes)...")
        rc, out = self._run_cmd(
            ["ninja", "-C", "build", "-j", str(max(1, os.cpu_count() or 2))],
            cwd=str(build_dir), timeout=900, label="ninja build",
        )
        if rc != 0:
            raise Exception(f"ninja build failed: {out[-500:]}")

        # Install to ~/.local/ — NO sudo needed (user owns this directory)
        self.log_msg.emit("  Installing VapourSynth to ~/.local/ ...")
        rc, out = self._run_cmd(
            ["ninja", "-C", "build", "install"],
            cwd=str(build_dir), timeout=120, label="ninja install",
        )
        if rc != 0:
            raise Exception(f"ninja install failed: {out[-500:]}")

        self.log_msg.emit(f"  VapourSynth installed to {local_prefix}/ (libs in {local_prefix}/lib/)")

        # Cleanup build directory
        shutil.rmtree(build_dir, ignore_errors=True)

    def _build_av1an(self):
        """Clone and build av1an from git. Installs to ~/.cargo/bin/ (no sudo needed)."""
        self.log_msg.emit("")
        self.log_msg.emit("=== Building av1an from git ===")
        self.log_msg.emit("  Install target: ~/.cargo/bin/ (no system-wide changes)")

        # Ensure cargo is in PATH
        cargo_bin = shutil.which("cargo")
        if not cargo_bin:
            # Common locations
            for p in [Path.home() / ".cargo" / "bin" / "cargo", "/usr/bin/cargo"]:
                if p.exists():
                    self._extend_env("PATH", str(p.parent))
                    cargo_bin = str(p)
                    break
        if not cargo_bin:
            raise Exception("cargo not found — cannot build av1an")

        self.log_msg.emit(f"  Using cargo at: {cargo_bin}")
        self.log_msg.emit("  Compiling av1an (this may take 10-30 minutes)...")

        rc, out = self._run_cmd(
            ["cargo", "install", "av1an",
             "--git", "https://github.com/master-of-zen/av1an",
             "--force", "--root", str(Path.home() / ".cargo")],
            timeout=3600, label="cargo install av1an",
        )
        if rc != 0:
            raise Exception(f"cargo install av1an failed: {out[-500:]}")

        new_av1an = Path.home() / ".cargo" / "bin" / "av1an"
        if new_av1an.exists():
            self.log_msg.emit(f"  av1an installed: {new_av1an}")
        else:
            self.log_msg.emit("  WARNING: av1an binary not found at expected path after build.")

    def _install_ffmpeg_build_deps(self):
        """Install ffmpeg build deps (libopus, libvorbis dev packages).

        Uses pkg-config to detect missing libraries, then installs the
        corresponding Arch/pacman packages. On other distros the user
        must install these manually; the log will name them.
        """
        self.log_msg.emit("")
        self.log_msg.emit("=== Checking ffmpeg build dependencies ===")

        # (pkg-config name, Arch package name, Debian package name)
        pkg_checks = [
            ("opus",   "opus",      "libopus-dev"),
            ("vorbis", "libvorbis", "libvorbis-dev"),
            ("ogg",    "libogg",    "libogg-dev"),
        ]
        missing_arch = []
        missing_debian = []
        for pc_name, arch_pkg, debian_pkg in pkg_checks:
            rc, _ = self._run_cmd(
                ["pkg-config", "--exists", pc_name],
                timeout=10, label=f"pkg-config {pc_name}",
            )
            if rc != 0:
                missing_arch.append(arch_pkg)
                missing_debian.append(debian_pkg)
                self.log_msg.emit(f"  Missing: {arch_pkg} (pkg-config {pc_name})")
            else:
                self.log_msg.emit(f"  OK: {pc_name}")

        if not missing_arch:
            self.log_msg.emit("  All ffmpeg build deps satisfied.")
            return

        # Try pacman (Arch) first since the rest of this app assumes Arch
        if shutil.which("pacman"):
            self.log_msg.emit(f"  Installing via pacman: {', '.join(missing_arch)}")
            rc, _ = self._sudo_cmd(
                ["pacman", "-S", "--needed", "--noconfirm"] + missing_arch,
                timeout=300, label="pacman ffmpeg-deps",
            )
            if rc != 0:
                self.log_msg.emit("  WARNING: pacman install failed — configure may fail.")
        elif shutil.which("apt-get"):
            self.log_msg.emit(f"  Installing via apt: {', '.join(missing_debian)}")
            rc, _ = self._sudo_cmd(
                ["apt-get", "install", "-y"] + missing_debian,
                timeout=300, label="apt ffmpeg-deps",
            )
            if rc != 0:
                self.log_msg.emit("  WARNING: apt install failed — configure may fail.")
        else:
            self.log_msg.emit(
                f"  No supported package manager found. Install manually: "
                f"{', '.join(missing_arch)} (Arch) or {', '.join(missing_debian)} (Debian)."
            )

    def _build_libiamf(self):
        """Clone, build, and install libiamf to ~/.local/ (no sudo needed).

        libiamf is the AOMedia Immersive Audio Model and Formats reference
        library. ffmpeg links against it via --enable-libiamf.
        """
        self.log_msg.emit("")
        self.log_msg.emit("=== Building libiamf from git ===")
        self.log_msg.emit("  Source: https://github.com/AOMediaCodec/libiamf")
        self.log_msg.emit("  Install target: ~/.local/ (no system-wide changes)")

        build_dir = Path("/tmp/libiamf-git-build")
        local_prefix = Path.home() / ".local"

        if build_dir.exists():
            shutil.rmtree(build_dir, ignore_errors=True)

        # Clone (shallow)
        self.log_msg.emit("  Cloning libiamf source (shallow)...")
        rc, out = self._run_cmd(
            ["git", "clone", "--depth", "1",
             "https://github.com/AOMediaCodec/libiamf.git",
             str(build_dir)],
            timeout=120, label="git clone libiamf",
        )
        if rc != 0:
            raise Exception(f"git clone libiamf failed: {out[-300:]}")

        # CMake configure
        cmake_build = build_dir / "build"
        cmake_build.mkdir(exist_ok=True)
        self.log_msg.emit(f"  Configuring with cmake (--prefix={local_prefix})...")
        rc, out = self._run_cmd(
            ["cmake", "-S", str(build_dir), "-B", str(cmake_build),
             f"-DCMAKE_INSTALL_PREFIX={local_prefix}",
             "-DCMAKE_BUILD_TYPE=Release",
             "-DBUILD_SHARED_LIBS=ON"],
            timeout=120, label="cmake configure libiamf",
        )
        if rc != 0:
            raise Exception(f"cmake configure libiamf failed:\n{out[-500:]}")

        # Build
        self.log_msg.emit("  Compiling libiamf...")
        rc, out = self._run_cmd(
            ["cmake", "--build", str(cmake_build), "-j",
             str(max(1, os.cpu_count() or 2))],
            timeout=600, label="cmake build libiamf",
        )
        if rc != 0:
            raise Exception(f"cmake build libiamf failed:\n{out[-500:]}")

        # Install
        self.log_msg.emit(f"  Installing libiamf to {local_prefix}/ ...")
        rc, out = self._run_cmd(
            ["cmake", "--install", str(cmake_build)],
            timeout=120, label="cmake install libiamf",
        )
        if rc != 0:
            raise Exception(f"cmake install libiamf failed:\n{out[-500:]}")

        # Make libiamf discoverable: PKG_CONFIG_PATH and LD_LIBRARY_PATH
        pc_dir = local_prefix / "lib" / "pkgconfig"
        if pc_dir.exists():
            existing_pkgs = self._build_env.get("PKG_CONFIG_PATH", "")
            if str(pc_dir) not in existing_pkgs:
                self._extend_env("PKG_CONFIG_PATH", str(pc_dir), prepend=True)
                self.log_msg.emit(f"  Added {pc_dir} to PKG_CONFIG_PATH")

        lib_dir = local_prefix / "lib"
        existing_ld = self._build_env.get("LD_LIBRARY_PATH", "")
        if str(lib_dir) not in existing_ld:
            self._extend_env("LD_LIBRARY_PATH", str(lib_dir), prepend=True)

        self.log_msg.emit(f"  libiamf installed to {local_prefix}/")

        # Cleanup
        shutil.rmtree(build_dir, ignore_errors=True)

    def _build_ffmpeg_with_iamf(self):
        """Rebuild ffmpeg from source with libiamf (and IAMF's Opus dep).

        Strategy: detect the current ffmpeg's --enable-* configure flags,
        reuse them, and append --enable-libiamf. This preserves all
        existing functionality (libsvtav1, libvpx, libx265, etc.) while
        adding IAMF support.

        Installs to ~/.local/bin/ffmpeg so it shadows the system ffmpeg
        without overwriting it. The user must restart the app for the
        new ffmpeg to take effect (probe_environment re-runs on launch).
        """
        self.log_msg.emit("")
        self.log_msg.emit("=== Building ffmpeg from git with IAMF ===")
        self.log_msg.emit("  Install target: ~/.local/bin/ (shadows system ffmpeg)")

        # 1. Detect current ffmpeg configure flags
        ffmpeg_bin = shutil.which("ffmpeg") or "/usr/bin/ffmpeg"
        self.log_msg.emit(f"  Probing current ffmpeg config: {ffmpeg_bin}")
        rc, out = self._run_cmd(
            [ffmpeg_bin, "-buildconf"],
            timeout=30, label="ffmpeg -buildconf",
        )
        if rc != 0:
            raise Exception(f"ffmpeg -buildconf failed:\n{out[-300:]}")

        # Parse --enable-* flags from output (one per line, sometimes with leading whitespace)
        enables = re.findall(r"--enable-[a-z0-9_-]+", out)
        # Dedupe while preserving order
        seen = set()
        enable_flags = []
        for e in enables:
            if e not in seen:
                seen.add(e)
                enable_flags.append(e)

        # Make sure libiamf and libopus are in the list (core requirements)
        if "--enable-libiamf" not in enable_flags:
            enable_flags.append("--enable-libiamf")
        if "--enable-libopus" not in enable_flags:
            enable_flags.append("--enable-libopus")

        self.log_msg.emit(f"  Configure flags ({len(enable_flags)}):")
        for f in enable_flags:
            self.log_msg.emit(f"    {f}")

        # 2. Clone ffmpeg source
        build_dir = Path("/tmp/ffmpeg-git-build")
        if build_dir.exists():
            shutil.rmtree(build_dir, ignore_errors=True)

        self.log_msg.emit("  Cloning ffmpeg source (shallow)...")
        rc, out = self._run_cmd(
            ["git", "clone", "--depth", "1",
             "https://git.ffmpeg.org/ffmpeg.git",
             str(build_dir)],
            timeout=300, label="git clone ffmpeg",
        )
        if rc != 0:
            # Fall back to GitHub mirror
            self.log_msg.emit("  Primary mirror failed, trying github mirror...")
            rc, out = self._run_cmd(
                ["git", "clone", "--depth", "1",
                 "https://github.com/FFmpeg/FFmpeg.git",
                 str(build_dir)],
                timeout=300, label="git clone ffmpeg (github)",
            )
            if rc != 0:
                raise Exception(f"git clone ffmpeg failed:\n{out[-300:]}")

        local_prefix = Path.home() / ".local"

        # Make sure pkg-config finds the freshly-built libiamf
        pc_dir = local_prefix / "lib" / "pkgconfig"
        existing_pkgs = self._build_env.get("PKG_CONFIG_PATH", "")
        if str(pc_dir) not in existing_pkgs:
            self._extend_env("PKG_CONFIG_PATH", str(pc_dir), prepend=True)

        # 3. Configure
        self.log_msg.emit("  Running ./configure (this may take a minute)...")
        configure_cmd = [
            "./configure",
            f"--prefix={local_prefix}",
            "--enable-shared",
            "--enable-pic",
            "--enable-version3",
        ] + enable_flags

        rc, out = self._run_cmd(
            configure_cmd,
            cwd=str(build_dir), timeout=300, label="ffmpeg configure",
        )
        if rc != 0:
            # Show the actual error — usually a missing -dev package
            raise Exception(
                "ffmpeg configure failed. This usually means a dev library\n"
                "is missing. Install the corresponding -dev package and retry.\n"
                f"Output:\n{out[-800:]}"
            )

        # 4. Build
        self.log_msg.emit("  Compiling ffmpeg (this may take 10-20 minutes)...")
        rc, out = self._run_cmd(
            ["make", "-j", str(max(1, os.cpu_count() or 2))],
            cwd=str(build_dir), timeout=2400, label="make ffmpeg",
        )
        if rc != 0:
            raise Exception(f"ffmpeg make failed:\n{out[-500:]}")

        # 5. Install to ~/.local
        self.log_msg.emit(f"  Installing ffmpeg to {local_prefix}/ ...")
        rc, out = self._run_cmd(
            ["make", "install"],
            cwd=str(build_dir), timeout=300, label="make install ffmpeg",
        )
        if rc != 0:
            raise Exception(f"make install ffmpeg failed:\n{out[-500:]}")

        # 6. Ensure ~/.local/bin is in PATH so new ffmpeg shadows system one
        local_bin = local_prefix / "bin"
        existing_path = self._build_env.get("PATH", "")
        if str(local_bin) not in existing_path:
            self._extend_env("PATH", str(local_bin), prepend=True)
            self.log_msg.emit(f"  Prepended {local_bin} to PATH (shadows system ffmpeg)")

        new_ffmpeg = local_bin / "ffmpeg"
        if new_ffmpeg.exists():
            self.log_msg.emit(f"  ffmpeg installed: {new_ffmpeg}")
            self.log_msg.emit(
                "  IMPORTANT: Restart the app for the new ffmpeg (with libiamf)\n"
                "  to be detected and used. The IAMF audio entry will then\n"
                "  be selectable (not greyed out)."
            )
        else:
            self.log_msg.emit("  WARNING: ffmpeg binary not found at expected path after build.")

        # Cleanup build dir (keep source for re-runs? No — disk is cheap, time isn't, but
        # a clean clone is more reliable than a stale tree.)
        shutil.rmtree(build_dir, ignore_errors=True)

    def stop(self):
        self._stop = True


# ──────────────────────────────────────────────
#  MAIN WINDOW (merged UI from all 3)
# ──────────────────────────────────────────────

# ──────────────────────────────────────────────
#  RETRO-FUTURISTIC MEDIA CONSOLE THEME
# ──────────────────────────────────────────────
# Brushed aluminum, amber/green LED displays,
# beveled metallic panels, VU meters, spectrum bars.
# Modernized with: rounded corners, subtle glow, glassmorphism hints,
# information-dense DAW-style layout.

MMD3_QSS = """
/* ── Global ── */
QMainWindow, QWidget#central {
    background-color: #1a1a1e;
}

/* ── Group Boxes — brushed aluminum panels ── */
QGroupBox {
    font-family: 'Segoe UI', 'Ubuntu', sans-serif;
    font-size: 10px;
    font-weight: bold;
    color: #8a8a8a;
    border: 1px solid #3a3a40;
    border-radius: 8px;
    margin-top: 14px;
    padding: 14px 10px 10px 10px;
    background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
                               stop:0 #2c2c32, stop:0.5 #27272c, stop:1 #222228);
}
QGroupBox::title {
    subcontrol-origin: margin;
    subcontrol-position: top left;
    padding: 2px 10px;
    color: #666;
    background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
                               stop:0 #2c2c32, stop:1 #222228);
    border-radius: 4px;
}

/* ── Labels ── */
QLabel {
    color: #999;
    font-size: 10px;
    font-family: 'Segoe UI', 'Ubuntu', sans-serif;
}

/* ── Line Edits — recessed aluminum wells ── */
QLineEdit {
    background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
                               stop:0 #18181c, stop:1 #141418);
    border: 1px solid #333;
    border-radius: 4px;
    padding: 5px 8px;
    color: #d4aa50;          /* amber LED */
    font-family: 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', monospace;
    font-size: 11px;
    selection-background-color: #d4aa50;
    selection-color: #000;
}
QLineEdit:focus {
    border-color: #d4aa50;
}

/* ── Combo Boxes ── */
QComboBox {
    background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
                               stop:0 #1e1e24, stop:1 #1a1a20);
    border: 1px solid #3a3a40;
    border-radius: 4px;
    padding: 4px 8px;
    color: #c8c8c8;
    font-family: 'Segoe UI', 'Ubuntu', sans-serif;
    font-size: 11px;
    min-height: 24px;
}
QComboBox:hover {
    border-color: #555;
}
QComboBox:focus {
    border-color: #d4aa50;
}
QComboBox::drop-down {
    border: none;
    width: 22px;
}
QComboBox::down-arrow {
    image: none;
    border-left: 4px solid transparent;
    border-right: 4px solid transparent;
    border-top: 6px solid #888;
    margin-right: 6px;
}
QComboBox QAbstractItemView {
    background: #1e1e24;
    border: 1px solid #3a3a40;
    border-radius: 4px;
    color: #c8c8c8;
    selection-background-color: #3a3a48;
    selection-color: #d4aa50;
    padding: 4px;
}
QComboBox item {
    min-height: 22px;
    padding: 2px 8px;
}

/* ── Buttons — beveled metallic (MMD3 transport style) ── */
QPushButton {
    background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
                               stop:0 #404048, stop:0.15 #38383f,
                               stop:0.85 #2e2e35, stop:1 #28282e);
    border: 1px solid #4a4a52;
    border-bottom-color: #1a1a1e;
    border-radius: 5px;
    padding: 6px 16px;
    color: #d0d0d0;
    font-family: 'Segoe UI', 'Ubuntu', sans-serif;
    font-size: 11px;
    font-weight: bold;
}
QPushButton:hover {
    background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
                               stop:0 #4a4a54, stop:0.15 #424248,
                               stop:0.85 #363640, stop:1 #303038);
    border-color: #5a5a64;
    color: #fff;
}
QPushButton:pressed {
    background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
                               stop:0 #28282e, stop:1 #3a3a42);
    border-bottom-color: #4a4a52;
    border-top-color: #1a1a1e;
}
QPushButton:disabled {
    background: #222228;
    border-color: #2a2a30;
    color: #555;
}

/* Primary action button — amber glow */
QPushButton#btnRun {
    background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
                               stop:0 #3a3428, stop:0.15 #332e22,
                               stop:0.85 #2a261c, stop:1 #221e16);
    border: 1px solid #5a4a30;
    border-bottom-color: #1a1608;
    color: #d4aa50;
    font-size: 13px;
    letter-spacing: 2px;
}
QPushButton#btnRun:hover {
    background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
                               stop:0 #4a4030, stop:0.15 #423828,
                               stop:0.85 #3a3020, stop:1 #322a1a);
    border-color: #d4aa50;
    color: #f0d080;
}
QPushButton#btnRun:disabled {
    background: #22201a;
    border-color: #2a2820;
    color: #5a4a30;
}

/* Stop button — red danger */
QPushButton#btnStop {
    background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
                               stop:0 #3a2222, stop:0.15 #321c1c,
                               stop:0.85 #2a1616, stop:1 #221010);
    border: 1px solid #5a3030;
    border-bottom-color: #1a0808;
    color: #e05050;
    font-size: 13px;
    letter-spacing: 2px;
}
QPushButton#btnStop:hover {
    border-color: #e05050;
    color: #ff7070;
}
QPushButton#btnStop:disabled {
    background: #221a1a;
    border-color: #2a2020;
    color: #5a3030;
}

/* Rebuild-from-git button — muted teal */
QPushButton#btnRebuild {
    background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
                               stop:0 #1e2e2e, stop:0.15 #1a2a2a,
                               stop:0.85 #162424, stop:1 #121e1e);
    border: 1px solid #2a5050;
    border-bottom-color: #0e1818;
    color: #50b0b0;
    font-size: 10px;
    letter-spacing: 1px;
}
QPushButton#btnRebuild:hover {
    border-color: #50b0b0;
    color: #70d0d0;
}
QPushButton#btnRebuild:disabled {
    background: #1a1e1e;
    border-color: #222828;
    color: #304040;
}

/* Browse buttons — small, subdued */
QPushButton#btnBrowse {
    font-size: 9px;
    padding: 4px 10px;
    letter-spacing: 1px;
}

/* ── Check Boxes ── */
QCheckBox {
    color: #999;
    font-size: 10px;
    spacing: 8px;
    font-family: 'Segoe UI', 'Ubuntu', sans-serif;
}
QCheckBox::indicator {
    width: 16px;
    height: 16px;
    border-radius: 3px;
    border: 1px solid #444;
    background: #1a1a1e;
}
QCheckBox::indicator:checked {
    background: #d4aa50;
    border-color: #b8903a;
}
QCheckBox#dangerCheck {
    color: #c05050;
    font-weight: bold;
}
QCheckBox#dangerCheck::indicator:checked {
    background: #c04040;
    border-color: #a03030;
}

/* ── Text Edit (log) — LED terminal display ── */
QTextEdit#logBox {
    background: #0a0a0c;
    border: 2px solid #1e1e24;
    border-radius: 6px;
    color: #40d060;          /* green phosphor LED */
    font-family: 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', monospace;
    font-size: 11px;
    padding: 8px;
}

/* ── Status Bar — LED readout strip ── */
QStatusBar {
    background: #0e0e12;
    border-top: 1px solid #2a2a30;
    font-family: 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', monospace;
    font-size: 10px;
    color: #d4aa50;
    padding: 2px 8px;
}
QStatusBar QLabel {
    color: #d4aa50;
    font-family: 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', monospace;
    font-size: 10px;
}

/* ── Tooltips ── */
QToolTip {
    background: #2a2a30;
    color: #c8c8c8;
    border: 1px solid #444;
    border-radius: 4px;
    padding: 6px;
    font-size: 10px;
}

/* ── Scrollbars — thin, dark ── */
QScrollBar:vertical {
    background: #141418;
    width: 10px;
    border-radius: 5px;
    margin: 0;
}
QScrollBar::handle:vertical {
    background: #3a3a42;
    border-radius: 5px;
    min-height: 30px;
}
QScrollBar::handle:vertical:hover {
    background: #4a4a54;
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
    height: 0;
}
QScrollBar:horizontal {
    background: #141418;
    height: 10px;
    border-radius: 5px;
}
QScrollBar::handle:horizontal {
    background: #3a3a42;
    border-radius: 5px;
    min-width: 30px;
}
QScrollBar::handle:horizontal:hover {
    background: #4a4a54;
}
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {
    width: 0;
}
"""


class OpenCodecMaster(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("OpenTranscode — dcos.net")
        self.resize(1100, 920)
        self.worker: EncoderWorker | None = None
        self.env: EnvProbe | None = None
        self._pending_deletes: list[Path] = []

        self._apply_mmd3_theme()
        self._build_ui()

        # Probe environment after UI is up
        QTimer.singleShot(500, self._probe_and_init)

    # ── UI Construction ──

    def _build_ui(self):
        central = QWidget()
        central.setObjectName("central")
        self.setCentralWidget(central)
        root = QVBoxLayout(central)
        root.setContentsMargins(10, 6, 10, 4)
        root.setSpacing(4)

        # ── Header ──
        header = QWidget()
        header_lay = QVBoxLayout(header)
        header_lay.setContentsMargins(0, 0, 0, 0)
        header_lay.setSpacing(0)

        title = QLabel("OpenTranscode")
        title.setFont(QFont("Segoe UI", 22, QFont.Weight.Bold))
        title.setAlignment(Qt.AlignmentFlag.AlignCenter)
        title.setStyleSheet("color: #d4aa50; letter-spacing: 4px;")
        header_lay.addWidget(title)

        subtitle = QLabel('dcos.net  //  concurrent open-source transcoding')
        subtitle.setFont(QFont("Consolas", 8))
        subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter)
        subtitle.setStyleSheet("color: #555; letter-spacing: 2px;")
        header_lay.addWidget(subtitle)

        accent = QWidget()
        accent.setFixedHeight(1)
        accent.setStyleSheet("background: qlineargradient(x1:0, y1:0, x2:1, y2:0,"
                               "stop:0 transparent, stop:0.15 #d4aa5044,"
                               "stop:0.5 #d4aa5088, stop:0.85 #d4aa5044, stop:1 transparent);")
        header_lay.addWidget(accent)

        root.addWidget(header)

        # ── Paths ──
        path_grp = QGroupBox("Paths")
        path_lay = QVBoxLayout(path_grp)
        path_lay.setSpacing(2)
        path_lay.setContentsMargins(10, 14, 10, 8)

        self.in_path_edit = QLineEdit(str(Path.home() / "Videos" / "INCOMING"))
        self.out_path_edit = QLineEdit(str(Path.home() / "Videos" / "ARCHIVE"))
        for label_text, line_edit in [
            ("IN:", self.in_path_edit),
            ("OUT:", self.out_path_edit),
        ]:
            row = QHBoxLayout()
            row.setSpacing(6)
            lbl = QLabel(label_text)
            lbl.setFixedWidth(28)
            lbl.setStyleSheet("color: #d4aa50; font-family: 'Consolas', monospace; font-weight: bold; font-size: 10px;")
            row.addWidget(lbl)
            row.addWidget(line_edit, 1)
            btn_browse = QPushButton("...")
            btn_browse.setObjectName("btnBrowse")
            btn_browse.setFixedSize(30, 22)
            btn_browse.setToolTip("Browse")
            btn_browse.clicked.connect(
                lambda checked, le=line_edit, is_dir=True: self._browse(le, is_dir)
            )
            row.addWidget(btn_browse)
            path_lay.addLayout(row)

        root.addWidget(path_grp)

        # ── Encoder Chain ──
        codec_grp = QGroupBox("Encoder Chain")
        codec_lay = QHBoxLayout(codec_grp)
        codec_lay.setSpacing(8)
        codec_lay.setContentsMargins(10, 14, 10, 8)

        for col_idx, (label, combo_items, slot) in enumerate([
            ("VIDEO", [vc.label for vc in VIDEO_CODECS], self._on_codec_changed),
            ("PRESET", [], None),
            ("AUDIO", [ap.label for ap in AUDIO_PROFILES], self._on_audio_changed),
            ("CONTAINER", [cp.label for cp in CONTAINER_PROFILES], self._on_container_changed),
            ("RESOLUTION", [], self._on_resolution_changed),
            ("SUBS", [so[0] for so in SUBTITLE_OPTIONS], None),
        ]):
            col = QVBoxLayout()
            col.setSpacing(1)
            lbl = QLabel(label)
            lbl.setStyleSheet("color: #666; font-size: 7px; letter-spacing: 1px;")
            col.addWidget(lbl)

            combo = QComboBox()
            combo.setFixedHeight(24)
            if combo_items:
                combo.addItems(combo_items)
            if slot:
                combo.currentIndexChanged.connect(slot)
            col.addWidget(combo)
            codec_lay.addLayout(col)

            if label == "VIDEO":
                self.codec_combo = combo
            elif label == "PRESET":
                self.preset_combo = combo
                self._populate_presets(0)
                self.preset_combo.setCurrentIndex(1)
            elif label == "AUDIO":
                self.audio_combo = combo
            elif label == "CONTAINER":
                self.container_combo = combo
            elif label == "RESOLUTION":
                self.resolution_combo = combo
                self._populate_resolution_combo()
            elif label == "SUBS":
                self.subs_combo = combo

        root.addWidget(codec_grp)

        # ── Side panel: compact knobs ──
        knobs_panel = QWidget()
        knobs_panel.setFixedWidth(170)
        knobs_lay = QVBoxLayout(knobs_panel)
        knobs_lay.setContentsMargins(6, 8, 6, 8)
        knobs_lay.setSpacing(6)
        knobs_lay.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter)

        # CRF Knob — amber
        self.crf_knob = RadioKnob(
            min_val=18, max_val=52, default_val=32,
            label="Quality",
            unit="CRF",
            color=(212, 170, 80),
            num_ticks=18,
            tick_labels=["18", "28", "38", "52"],
            snap_ticks=True,
            compact=True,
        )
        self.crf_knob.valueChanged.connect(self._on_crf_knob_changed)
        knobs_lay.addWidget(self.crf_knob, 0, Qt.AlignmentFlag.AlignHCenter)

        # Volume Knob — green (dual-pass loudnorm target)
        self.vol_knob = RadioKnob(
            min_val=-20.0, max_val=6.0, default_val=0.0,
            label="LUFS",
            unit="dB",
            color=(64, 208, 96),
            num_ticks=27,
            tick_labels=["-20", "-10", "0", "+6"],
            snap_ticks=True,
            compact=True,
        )
        self.vol_knob.setToolTip(
            "Dual-pass audio normalization target (EBU R128 LUFS).\n"
            "0 = off (pass-through).\n"
            "Each file is analyzed individually: loudnorm measures its\n"
            "current LUFS and true peak, then computes the exact gain\n"
            "to hit this target. If the gain would push peaks above\n"
            "-1.5 dBTP, gain is reduced to keep 15%% headroom.\n"
            "Common targets: -14 (streaming), -16 (broadcast), -23 (cinema)."
        )
        self.vol_knob.valueChanged.connect(self._on_vol_knob_changed)
        knobs_lay.addWidget(self.vol_knob, 0, Qt.AlignmentFlag.AlignHCenter)

        # ── Options row ──
        opt_row = QHBoxLayout()
        opt_row.setSpacing(8)
        opt_lbl = QLabel("FILTER")
        opt_lbl.setFixedWidth(44)
        opt_lbl.setStyleSheet("color: #666; font-size: 7px; letter-spacing: 1px;")
        opt_row.addWidget(opt_lbl)
        self.ext_edit = QLineEdit(", ".join(sorted(DEFAULT_INPUT_EXTENSIONS)))
        self.ext_edit.setFixedHeight(22)
        self.ext_edit.setToolTip("File extensions to process. Separate with commas.")
        opt_row.addWidget(self.ext_edit)

        self.del_check = QCheckBox("Delete source after verify")
        self.del_check.setObjectName("dangerCheck")
        self.del_check.setToolTip(
            "Sources are only deleted after all files finish and cleanup passes.\n"
            "If any file fails, you will be prompted before deletion."
        )
        opt_row.addWidget(self.del_check)

        # v5-01: Force checkbox — skip ffprobe validation and attempt encode
        # even for files ffprobe cannot read. Use for the rare edge case where
        # ffprobe fails but the file is actually valid. Default OFF — most
        # "ffprobe can't read" files are genuinely invalid (failed downloads,
        # HTML saved as .mp4, truncated files, etc.).
        self.force_check = QCheckBox("Force (skip validation)")
        self.force_check.setToolTip(
            "Skip ffprobe pre-validation and attempt encode even for files\n"
            "ffprobe cannot read. Useful for the rare case where ffprobe\n"
            "fails but the file is actually valid (rare codec, broken\n"
            "container metadata). WARNING: with this enabled, invalid files\n"
            "(failed downloads, HTML, truncated) will waste the full\n"
            "per-file timeout before failing."
        )
        opt_row.addWidget(self.force_check)
        root.addLayout(opt_row)

        # ── Log + Knobs: horizontal split ──
        mid_split = QHBoxLayout()
        mid_split.setSpacing(6)

        # Log: LED terminal (takes remaining space)
        self.log_box = QTextEdit()
        self.log_box.setObjectName("logBox")
        self.log_box.setReadOnly(True)
        mid_split.addWidget(self.log_box, 1)

        # Knobs panel on the right
        mid_split.addWidget(knobs_panel)

        root.addLayout(mid_split, 1)

        # ── Status Bar: LED readout ──
        self.status = QStatusBar()
        self.setStatusBar(self.status)
        self.status_label = QLabel("  INITIALIZING...")
        self.status_label.setStyleSheet(
            "color: #d4aa50; font-family: 'Consolas', 'DejaVu Sans Mono', monospace; font-size: 10px;"
        )
        self.status.addWidget(self.status_label, 1)

        # ── Transport Buttons ──
        btn_lay = QHBoxLayout()
        btn_lay.setSpacing(8)

        self.btn_run = QPushButton("  >  ENCODE")
        self.btn_run.setObjectName("btnRun")
        self.btn_run.setFixedHeight(40)
        self.btn_run.setEnabled(False)
        self.btn_run.clicked.connect(self._start_process)
        btn_lay.addWidget(self.btn_run)

        self.btn_stop = QPushButton("  []  STOP")
        self.btn_stop.setObjectName("btnStop")
        self.btn_stop.setFixedHeight(40)
        self.btn_stop.clicked.connect(self._stop_process)
        self.btn_stop.setEnabled(False)
        btn_lay.addWidget(self.btn_stop)

        self.btn_rebuild = QPushButton("  <>  REBUILD FROM GIT")
        self.btn_rebuild.setObjectName("btnRebuild")
        self.btn_rebuild.setFixedHeight(40)
        self.btn_rebuild.setToolTip(
            "Compile VapourSynth + av1an from git source.\n"
            "Resolves ABI/version mismatch when package managers\n"
            "install incompatible versions."
        )
        self.btn_rebuild.clicked.connect(self._manual_rebuild)
        self.btn_rebuild.setEnabled(False)
        btn_lay.addWidget(self.btn_rebuild)

        self.btn_about = QPushButton("  ?  ABOUT / LICENSES")
        self.btn_about.setObjectName("btnAbout")
        self.btn_about.setFixedHeight(40)
        self.btn_about.setToolTip(
            "Show open-source license attributions for all\n"
            "third-party components invoked by this application."
        )
        self.btn_about.clicked.connect(self._show_license_dialog)
        btn_lay.addWidget(self.btn_about)
        root.addLayout(btn_lay)

        # ── Footer ──
        footer = QWidget()
        footer_lay = QHBoxLayout(footer)
        footer_lay.setContentsMargins(6, 4, 6, 2)
        footer_lay.setSpacing(0)

        link_lbl = QLabel(
            '<a href="http://git.dcos.net/dcosnet/OpenTranscode" '
            'style="color: #888; text-decoration: none;">Visit Homepage</a>'
        )
        link_lbl.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
        link_lbl.setOpenExternalLinks(True)
        link_lbl.setStyleSheet("font-size: 8px;")
        footer_lay.addWidget(link_lbl)

        footer_lay.addStretch()

        copy_lbl = QLabel(
            'AGPL-3.0  |  Jeremy Anderson - <a href="http://dcos.net" '
            'style="color: #888; text-decoration: none;">dcos.net</a>  (c) 2026'
        )
        copy_lbl.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
        copy_lbl.setOpenExternalLinks(True)
        copy_lbl.setAlignment(Qt.AlignmentFlag.AlignRight)
        copy_lbl.setStyleSheet("color: #555; font-size: 8px;")
        footer_lay.addWidget(copy_lbl)

        root.addWidget(footer)

    def _apply_mmd3_theme(self):
        self.setStyle(QStyleFactory.create("Fusion"))
        self.setStyleSheet(MMD3_QSS)
        # Palette as fallback for things QSS doesn't cover
        p = QPalette()
        p.setColor(QPalette.ColorRole.Window,          QColor(26, 26, 30))
        p.setColor(QPalette.ColorRole.WindowText,      QColor(200, 200, 200))
        p.setColor(QPalette.ColorRole.Base,            QColor(20, 20, 24))
        p.setColor(QPalette.ColorRole.AlternateBase,   QColor(40, 40, 46))
        p.setColor(QPalette.ColorRole.ToolTipBase,     QColor(30, 30, 36))
        p.setColor(QPalette.ColorRole.ToolTipText,     QColor(200, 200, 200))
        p.setColor(QPalette.ColorRole.Text,            QColor(200, 200, 200))
        p.setColor(QPalette.ColorRole.Button,          QColor(40, 40, 46))
        p.setColor(QPalette.ColorRole.ButtonText,      QColor(200, 200, 200))
        p.setColor(QPalette.ColorRole.Highlight,       QColor(212, 170, 80))
        p.setColor(QPalette.ColorRole.HighlightedText, QColor(0, 0, 0))
        QApplication.instance().setPalette(p)

    # ── Slots ──

    @Slot()
    def _on_codec_changed(self, idx: int):
        self._populate_presets(idx)
        profile = VIDEO_CODECS[idx]
        lo, hi = profile.crf_range
        self.crf_knob.min_val = lo
        self.crf_knob.max_val = hi
        self.crf_knob.setValue(float(profile.default_crf))
        # Auto-select best container via index lookup — no for-loop, no break.
        # next(..., None) returns the first match or None; the if guards the
        # block so we only touch container_combo when a match was found.
        match = next(
            (i for i, cp in enumerate(CONTAINER_PROFILES)
             if cp.ext == profile.container),
            None,
        )
        if match is not None:
            self.container_combo.blockSignals(True)
            self.container_combo.setCurrentIndex(match)
            self.container_combo.blockSignals(False)
        # Re-evaluate compatibility after auto-container change.
        self._check_combo_compatibility()

    def _populate_presets(self, codec_idx: int):
        self.preset_combo.blockSignals(True)
        self.preset_combo.clear()
        if 0 <= codec_idx < len(VIDEO_CODECS):
            self.preset_combo.addItems(VIDEO_CODECS[codec_idx].presets)
        self.preset_combo.blockSignals(False)

    @Slot()
    def _on_container_changed(self, idx: int):
        if idx >= 0:
            ext = CONTAINER_PROFILES[idx].ext
            self._log(f"Container set to: {ext}")
        self._check_combo_compatibility()

    @Slot()
    def _on_audio_changed(self, idx: int):
        if idx >= 0:
            self._log(f"Audio set to: {AUDIO_PROFILES[idx].label}")
        self._check_combo_compatibility()

    def _check_combo_compatibility(self) -> list[str]:
        """Check current video/audio/container combination for known
        incompatibilities. Logs every warning and returns the full list
        (empty if clean). Hard incompatibilities (which would fail at
        encode/mux time) are prefixed ``INCOMPATIBLE:`` and also block
        the Start button via _start_process. Soft warnings are prefixed
        ``WARNING:`` and only appear in the log.

        Safe to call during __init__ — every attribute is guarded.

        Refactored to table-driven dispatch: every rule is a tuple of
        (predicate, severity, message-fn), evaluated by a single loop.
        Adding a new rule is a one-line table change; no nested ifs.

        SEI CERT STR09-C spirit: predicates return plain bool, never None;
        messages are produced only when their predicate fires, so the
        severity prefix is always consistent with the predicate outcome.
        """
        # Resolve current selection with full defensive validation.
        # All four early returns return the same value ([]), so this
        # block reads as a flat guard rather than a nested decision tree.
        if not all(hasattr(self, attr) for attr in
                   ("codec_combo", "audio_combo", "container_combo")):
            return []

        codec_idx = self.codec_combo.currentIndex()
        audio_idx = self.audio_combo.currentIndex()
        container_idx = self.container_combo.currentIndex()

        if min(codec_idx, audio_idx, container_idx) < 0:
            return []

        if not (codec_idx < len(VIDEO_CODECS)
                and audio_idx < len(AUDIO_PROFILES)
                and container_idx < len(CONTAINER_PROFILES)):
            return []

        video_codec = VIDEO_CODECS[codec_idx]
        audio_profile = AUDIO_PROFILES[audio_idx]
        container = CONTAINER_PROFILES[container_idx]

        # ── Compatibility rule table ──
        # Each rule: (predicate, severity, message)
        # predicate: callable(video_codec, audio_profile, container) -> bool
        # severity:  "INCOMPATIBLE" or "WARNING"
        # message:   str (already-formatted)
        #
        # To add a new rule, append a tuple here. No code below changes.
        def _is_hevc(vc, _ap, c) -> bool:
            return vc.ffmpeg_encoder == "libx265" and c.ext == "webm"

        # v3 (OTC-012, SEI CERT STR09-C): compare against the
        # AudioProfile.ffmpeg_encoder_name field directly, not via
        # substring match on params (which could false-match a
        # hypothetical `-libiamf-mode` argument).
        def _is_iamf_non_mp4(_vc, ap, c) -> bool:
            return ap.ffmpeg_encoder_name == "libiamf" and c.ext != "mp4"

        def _is_vorbis_in_mp4(_vc, ap, c) -> bool:
            return ap.ffmpeg_encoder_name == "libvorbis" and c.ext == "mp4"

        def _is_flac_in_webm(_vc, ap, c) -> bool:
            return ap.ffmpeg_encoder_name == "flac" and c.ext == "webm"

        def _is_vp9_in_mp4(vc, _ap, c) -> bool:
            return vc.ffmpeg_encoder == "libvpx-vp9" and c.ext == "mp4"

        rules: tuple[tuple, ...] = (
            (_is_hevc,           "INCOMPATIBLE",
             "x265 (HEVC) cannot be muxed into WebM. Use MKV or MP4 instead."),
            (_is_iamf_non_mp4,   "INCOMPATIBLE",
             f"IAMF audio requires the MP4 container — cannot mux into "
             f"{container.ext.upper()}. Switch container to MP4."),
            (_is_vorbis_in_mp4,  "WARNING",
             "Vorbis in MP4 has limited player support. Consider Opus or MKV/WebM."),
            (_is_flac_in_webm,   "WARNING",
             "FLAC in WebM is rarely supported by players. Consider MKV instead."),
            (_is_vp9_in_mp4,     "WARNING",
             "VP9 in MP4 has uneven player support. WebM is the canonical VP9 container."),
        )

        # Single-pass evaluation: build the warnings list by filtering
        # the rule table through each predicate. No nested if/elif.
        warnings: list[str] = [
            f"{severity}: {message}"
            for predicate, severity, message in rules
            if predicate(video_codec, audio_profile, container)
        ]

        for w in warnings:
            self._log(w)

        return warnings

    def _populate_resolution_combo(self):
        """Populate resolution dropdown with separator headers per category.

        Refactored with PEP 634/868 structural pattern matching: the
        category-transition decision is expressed as a single match
        statement instead of nested ifs. The match value is a 2-tuple
        of (current_category, previous_category); each case is a flat
        pattern, no nesting.
        """
        # Maps combo box position -> RESOLUTION_PRESETS index.
        # Separators occupy combo positions too, so we must track them.
        self._res_preset_indices: dict[int, int] = {}
        last_cat: str | None = None
        combo_pos = 0

        for i, rp in enumerate(RESOLUTION_PRESETS):
            # Single-level decision: insert separator only when transitioning
            # to a new category AND we are not on the first category.
            match (rp.category, last_cat):
                case (cat, prev) if cat != prev and prev is not None:
                    self.resolution_combo.insertSeparator(combo_pos)
                    combo_pos += 1  # separator takes a slot

            last_cat = rp.category
            self.resolution_combo.addItem(rp.label)
            self._res_preset_indices[combo_pos] = i
            combo_pos += 1

    def _get_current_resolution(self) -> ResolutionProfile:
        """Get the ResolutionProfile for the current combo selection, handling separators."""
        combo_idx = self.resolution_combo.currentIndex()
        preset_i = self._res_preset_indices.get(combo_idx)
        if preset_i is not None:
            return RESOLUTION_PRESETS[preset_i]
        return RESOLUTION_PRESETS[0]

    @Slot()
    def _on_resolution_changed(self, idx: int):
        rp = self._get_current_resolution()
        if rp.width is not None:
            self._log(
                f"Resolution: {rp.width}x{rp.height} ({rp.aspect_label}) — "
                f"files will be pre-scaled with ffmpeg before encoding."
            )
        else:
            self._log("Resolution: Original (no scaling).")

    @Slot(float)
    def _on_crf_knob_changed(self, val: float):
        direction = "higher quality" if val < 28 else ("balanced" if val < 38 else "smaller file")
        self._log(f"CRF: {val:.0f} ({direction})")

    @Slot(float)
    def _on_vol_knob_changed(self, val: float):
        if abs(val) < 0.01:
            self._log("Audio normalization: OFF (pass-through)")
        else:
            direction = "louder" if val > 0 else "quieter"
            self._log(f"Audio normalization: {val:+.1f} dB ({direction})")


    @Slot()
    def _browse(self, line_edit: QLineEdit, is_dir: bool = True):
        if is_dir:
            path = QFileDialog.getExistingDirectory(self, "Select Directory")
            if path:
                line_edit.setText(path)

    def _log(self, msg: str):
        # Guard against signals (combo currentIndexChanged, knob valueChanged,
        # etc.) firing during __init__ before self.log_box has been
        # constructed. Without this, the first addItem() on any combo
        # triggers its slot, which calls _log(), which dereferences
        # self.log_box while it is still None -> AttributeError -> crashes
        # the app on launch. Also buffer messages so they aren't lost.
        if not hasattr(self, "log_box") or self.log_box is None:
            buffered = getattr(self, "_log_buffer", None)
            if buffered is None:
                buffered = self._log_buffer = []
            buffered.append(msg)
            return
        # Flush any messages that arrived before log_box existed.
        buffered = getattr(self, "_log_buffer", None)
        if buffered:
            for m in buffered:
                self.log_box.append(f"> {m}")
            self._log_buffer = []
        self.log_box.append(f"> {msg}")
        sb = self.log_box.verticalScrollBar()
        sb.setValue(sb.maximum())

    # ── Environment Probe ──

    def _probe_and_init(self):
        self.env = probe_environment()

        # --- Distro banner ---
        distro = self.env.distro
        self._log(f"Distro: {distro.name} (family={distro.family}, v{distro.version_id})")
        self._log(f"Package manager: {distro.pkg_manager}")

        # --- Warnings (info-level, not errors) ---
        for w in self.env.warnings:
            self._log(f"INFO: {w}")

        # --- Hard errors ---
        if not self.env.av1an_path:
            self._log("CRITICAL: 'av1an' not found in PATH or distro-specific paths.")
            if self.env.install_hint:
                self._log(f"  TRY: {self.env.install_hint}")
            self.status_label.setText(f"NOT READY — missing av1an ({distro.family})")
            return
        if not self.env.ffmpeg_path:
            self._log("CRITICAL: 'ffmpeg' not found in PATH or distro-specific paths.")
            if self.env.install_hint:
                self._log(f"  TRY: {self.env.install_hint}")
            self.status_label.setText(f"NOT READY — missing ffmpeg ({distro.family})")
            return

        if self.env.errors:
            for e in self.env.errors:
                self._log(f"ERROR: {e}")

        # If there are still errors after logging (e.g. missing runtime deps), block start
        if self.env.errors:
            dep_count = len(self.env.missing_dep_pkgs)
            if dep_count:
                self.status_label.setText(
                    f"NOT READY — {dep_count} runtime dep(s) missing. See log."
                )
                return

        # --- Probe results ---
        flag_info = ", ".join(f"{k}={v}" for k, v in self.env.av1an_flags.items() if k != "has_chunk_method" and k != "has_scenes")
        self._log(f"av1an: {self.env.av1an_path} (v{self.env.av1an_version or '?'})")
        if flag_info:
            self._log(f"  Flags: {flag_info}")

        if self.env.ffmpeg_version:
            self._log(f"ffmpeg: {self.env.ffmpeg_path} (v{self.env.ffmpeg_version})")

        # --- FFmpeg encoder library summary (audio-relevant only for our purposes) ---
        available_libs = [name for name, present in self.env.ffmpeg_libs.items() if present]
        missing_audio = [name for name, present in self.env.ffmpeg_libs.items()
                         if not present and name in ("libopus", "libvorbis", "flac")]
        if available_libs:
            self._log(f"  FFmpeg encoders available: {', '.join(available_libs)}")
        if missing_audio:
            self._log(f"  FFmpeg audio encoders MISSING: {', '.join(missing_audio)}")
            self._log(f"  Some audio codec options may fail. Check distro package: {distro.ffmpeg_pkg}")

        # --- Disable unavailable codec options in UI ---
        self._disable_unavailable_codecs()

        worker_count = max(1, self.env.cpu.physical_cores - 1)
        cpu = self.env.cpu
        self._log(
            f"Chunk-parallel mode: {worker_count} av1an workers "
            f"({cpu.physical_cores} physical cores, {cpu.logical_threads} logical, "
            f"{cpu.threads_per_core}T/core)"
        )

        self.btn_run.setEnabled(True)
        self.btn_run.setText("START PROCESSING")
        self.btn_rebuild.setEnabled(True)  # available after successful probe
        vs_info = f" | VS{self.env.vs_version}" if self.env.vs_version else ""
        # Show ffmpeg video encoder availability (for fallback)
        fb_encs = []
        for vc in VIDEO_CODECS:
            lib_key = ffmpeg_lib_key_for(vc.ffmpeg_encoder)  # v3: OTC-007
            if self.env.ffmpeg_libs.get(lib_key, False):
                fb_encs.append(vc.ffmpeg_encoder)
        fb_info = f" | ffmpeg-fb:{'+'.join(fb_encs)}" if fb_encs else ""
        self.status_label.setText(
            f"{distro.name} | {cpu.physical_cores}C/{cpu.logical_threads}T | "
            f"av1an v{self.env.av1an_version or '?'} | ffmpeg v{self.env.ffmpeg_version or '?'}{vs_info}{fb_info}"
        )

        # --- License attribution banner (shown once after successful probe) ---
        # POSIX-friendly: log plain text, no escape codes, no decorative box chars
        # that might confuse terminals. Each tool is named with its SPDX id so
        # the user can audit obligations at a glance.
        self._show_license_banner()

    def _show_license_banner(self) -> None:
        """Log the active-component license summary once at startup.

        SEI CERT MSC04-C: license text lives in exactly one canonical
        location (LICENSE_NOTICES); this method only formats it.
        """
        notices = active_license_notices(self.env)
        self._log("")
        self._log("=== Open Source License Attribution ===")
        self._log("This application invokes the following third-party tools.")
        self._log("Source code of these tools is NOT bundled; licenses flow")
        self._log("through from upstream. See About > Licenses for full text.")
        self._log("")
        for n in notices:
            self._log(f"  • {n.name} — {n.spdx}")
            self._log(f"      {n.home_url}")
        self._log("")
        self._log("End of license summary.")
        self._log("")

    def _show_license_dialog(self) -> None:
        """Open a modal dialog with the full license text.

        Triggered from the menu / button so the user can review the
        complete attribution text at any time.
        """
        notices = active_license_notices(self.env)
        text = license_banner_full(notices)
        dlg = QMessageBox(self)
        dlg.setWindowTitle("About — Open Source Licenses")
        dlg.setText("This application invokes the following open-source tools:")
        dlg.setInformativeText(text)
        dlg.setStandardButtons(QMessageBox.StandardButton.Ok)
        dlg.exec()

    def _show_pre_transcode_license_summary(self) -> None:
        """One-line license reminder logged at the start of each batch.

        Keeps the legal notice adjacent to the act of transcode, which is
        where redistribution-relevant output is produced.
        """
        notices = active_license_notices(self.env)
        self._log(f"LICENSES: {license_banner_short(notices)}")

    def _disable_unavailable_codecs(self):
        """Grey out AUDIO codec combos whose FFmpeg library is missing.

        Video codecs are NOT disabled here because av1an uses its own
        encoder binaries (svt_av1, vpx, x265) — it does not rely on
        ffmpeg's encoder list for video.

        v3 (OTC-012, SEI CERT STR09-C + MSC04-C): each AudioProfile now
        carries its ffmpeg encoder name as the `ffmpeg_encoder_name`
        field (e.g. "libopus"). We look up that name in env.ffmpeg_libs
        directly. This replaces the v2 approach of indexing into
        `params[1]`, which assumed a fixed params layout and would
        silently break if a profile ever used a different argument order.

        SEI CERT MSC04-C spirit: the source of truth for which library
        each profile needs is the profile itself, not a parallel table.
        """
        libs = self.env.ffmpeg_libs

        for idx, profile in enumerate(AUDIO_PROFILES):
            if idx >= self.audio_combo.count():
                break  # combo not yet populated, defensive

            # v3: use the dedicated field instead of indexing into params.
            lib_name = profile.ffmpeg_encoder_name
            if not lib_name:
                continue  # passthrough profile, no encoder dependency
            if not libs.get(lib_name, False):
                item = self.audio_combo.model().item(idx)
                if item is not None:
                    item.setEnabled(False)
                    item.setToolTip(
                        f"DISABLED: FFmpeg missing {lib_name} encoder. "
                        f"Use Rebuild from Git > ffmpeg + IAMF to enable."
                    )
                # If the currently-selected item is the one we disabled,
                # fall back to the first enabled entry.
                if self.audio_combo.currentIndex() == idx:
                    self.audio_combo.setCurrentIndex(0)

    # ── Process Control ──

    def _parse_extensions(self) -> set[str]:
        raw = self.ext_edit.text()
        exts = set()
        for part in raw.split(","):
            part = part.strip().lower()
            if not part.startswith("."):
                part = "." + part
            if part:
                exts.add(part)
        return exts or DEFAULT_INPUT_EXTENSIONS

    @Slot()
    def _start_process(self):
        in_dir = Path(self.in_path_edit.text())
        out_dir = Path(self.out_path_edit.text())

        if not in_dir.is_dir():
            self._log(f"ERROR: Source directory does not exist: {in_dir}")
            return
        if in_dir == out_dir:
            self._log("ERROR: Source and output directories must be different.")
            return

        # ── Pre-flight: codec/container/audio compatibility check ──
        # Hard incompatibilities (prefixed "INCOMPATIBLE:") block the encode.
        warnings = self._check_combo_compatibility()
        hard_blocks = [w for w in warnings if w.startswith("INCOMPATIBLE")]
        if hard_blocks:
            self._log("ERROR: Aborting — incompatible combination selected.")
            QMessageBox.critical(
                self, "Incompatible Codec Combination",
                "The selected video/audio/container combination cannot be encoded:\n\n"
                + "\n".join(f"• {w.split(':', 1)[1].strip()}" for w in hard_blocks)
                + "\n\nFix the selection and try again."
            )
            return

        # Pre-transcode license reminder — adjacent to the act of transcode
        # so obligations are visible at the moment redistribution-relevant
        # output is produced.
        self._show_pre_transcode_license_summary()

        # If delete is enabled, collect files first for batch confirmation
        if self.del_check.isChecked():
            extensions = self._parse_extensions()
            candidates = [f for f in in_dir.rglob("*") if f.is_file() and f.suffix.lower() in extensions and not f.name.endswith(".scaled_tmp.mkv")]
            if candidates:
                total_size = sum(f.stat().st_size for f in candidates)
                reply = QMessageBox.question(
                    self, "Confirm Batch Delete",
                    f"This will delete {len(candidates)} source file(s) after successful transcode.\n"
                    f"Total size: {total_size / 1_073_741_824:.2f} GB\n\n"
                    f"Proceed?",
                    QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                    QMessageBox.StandardButton.No,
                )
                if reply != QMessageBox.StandardButton.Yes:
                    self._log("Cancelled: Delete not confirmed.")
                    return

        # ── Pre-flight: av1an VSScript smoke test (main thread — can show dialogs) ──
        use_ffmpeg_fallback = False
        skip_encode = False
        if self.env.av1an_path and self.env.ffmpeg_path:
            self._log("Pre-flight: testing av1an + VapourSynth compatibility...")
            QApplication.processEvents()  # keep UI responsive
            svt_name = self.env.av1an_flags.get("svt_name", "svt_av1")
            ok, detail = _av1an_vsscript_smoke_test(
                self.env.av1an_path,
                self.env.ffmpeg_path,
                self.env.av1an_flags,
                svt_name=svt_name,
            )
            if not ok and "VSScript_API_INCOMPAT" in detail:
                # VSScript ABI mismatch detected — offer rebuild or fallback
                use_ffmpeg_fallback = self._handle_vs_incompat()
                if not use_ffmpeg_fallback:
                    # User chose rebuild or cancel — don't start encoding
                    return
            elif not ok and "INVALID_ENCODER" in detail:
                # Encoder name detection was wrong — re-probe and retry once
                self._log(f"  WARN: Encoder name probe was incorrect. Re-detecting...")
                QApplication.processEvents()
                new_name = _detect_av1an_svt_encoder(self.env.av1an_path)
                if new_name:
                    self.env.av1an_flags["svt_name"] = new_name
                    self._log(f"  Re-detected SVT-AV1 encoder name: '{new_name}'")
                    # Retry smoke test with corrected name
                    ok2, detail2 = _av1an_vsscript_smoke_test(
                        self.env.av1an_path, self.env.ffmpeg_path,
                        self.env.av1an_flags, svt_name=new_name,
                    )
                    if ok2:
                        self._log("  OK: av1an + VapourSynth working correctly.")
                    else:
                        self._log(f"  FAIL: Still failing after re-detect: {detail2}")
                        return
                else:
                    self._log("  FAIL: Could not determine valid encoder name. Check av1an --help manually.")
                    return
            elif ok:
                self._log("  OK: av1an + VapourSynth working correctly.")
            else:
                # Smoke test failed for an unexpected reason (encoder binary
                # missing, concat method unsupported, av1an panicked, etc.).
                # Previously this was logged as "non-fatal" and the encode
                # proceeded anyway — which produced the "chunks but never
                # saves a file" symptom because every file then failed at
                # the same point. Now we treat unknown smoke failures as
                # hard blocks and offer the user ffmpeg fallback if the
                # selected codec is available, otherwise abort.
                self._log(f"  FAIL: av1an smoke test failed:")
                for line in detail.splitlines()[:12]:
                    self._log(f"    {line}")
                # If ffmpeg has the matching encoder, offer fallback;
                # otherwise abort with an actionable message.
                codec_idx_pre = self.codec_combo.currentIndex()
                if 0 <= codec_idx_pre < len(VIDEO_CODECS):
                    vc = VIDEO_CODECS[codec_idx_pre]
                    lib_key = ffmpeg_lib_key_for(vc.ffmpeg_encoder)  # v3: OTC-007
                    if self.env.ffmpeg_libs.get(lib_key, False):
                        self._log(f"  FFmpeg has {vc.ffmpeg_encoder} — offering fallback.")
                        use_ffmpeg_fallback = self._handle_vs_incompat()
                        if not use_ffmpeg_fallback:
                            return
                    else:
                        self._log(
                            f"  ABORT: ffmpeg also lacks {vc.ffmpeg_encoder}. "
                            f"Install the encoder binary (e.g. SvtAv1EncApp, vpxenc, x265) "
                            f"or use the REBUILD FROM GIT button."
                        )
                        return
                else:
                    self._log("  ABORT: invalid codec selection.")
                    return

        codec_idx = self.codec_combo.currentIndex()
        audio_idx = self.audio_combo.currentIndex()
        container_idx = self.container_combo.currentIndex()

        # Safety: clamp codec_idx to valid range
        if not (0 <= codec_idx < len(VIDEO_CODECS)):
            self._log(f"ERROR: Invalid codec index {codec_idx}. Resetting to AV1 (SVT-AV1).")
            codec_idx = 0
            self.codec_combo.blockSignals(True)
            self.codec_combo.setCurrentIndex(0)
            self.codec_combo.blockSignals(False)

        selected_codec = VIDEO_CODECS[codec_idx]
        self._log(f"Codec: {selected_codec.label} (av1an encoder: {selected_codec.av1an_encoder})")

        self.worker = EncoderWorker(
            in_dir=in_dir,
            out_dir=out_dir,
            video_codec=selected_codec,
            audio_profile=AUDIO_PROFILES[audio_idx],
            container=CONTAINER_PROFILES[container_idx],
            crf=self.crf_knob.intValue(),
            preset_label=self.preset_combo.currentText(),
            delete_source=self.del_check.isChecked(),
            env=self.env,
            extensions=self._parse_extensions(),
            resolution=self._get_current_resolution(),
            audio_level_db=self.vol_knob.value(),
            use_ffmpeg_fallback=use_ffmpeg_fallback,
            subtitle_lang=SUBTITLE_OPTIONS[self.subs_combo.currentIndex()][1],
            force=self.force_check.isChecked(),  # v5-01
        )
        self.worker.log_msg.connect(self._log)
        self.worker.progress_msg.connect(self._on_progress)
        self.worker.finished_queue.connect(self._on_finished)

        self.btn_run.setEnabled(False)
        self.btn_run.setText("RUNNING...")
        self.btn_stop.setEnabled(True)
        self.btn_rebuild.setEnabled(False)
        self.worker.start()

    def _handle_vs_incompat(self) -> bool:
        """Handle detected VSScript ABI incompatibility.

        Shows a dialog with options:
          1. Rebuild VapourSynth + av1an from git (resolves root cause)
          2. Use ffmpeg fallback (works now, no chunk-parallel)
          3. Cancel

        Returns True if we should use ffmpeg fallback (option 2),
                False if user cancelled or chose to rebuild (rebuild
                starts async and does NOT return here — the user
                will click ENCODE again after it completes).
        """
        self._log("  FAIL: av1an cannot initialize VSScript API.")
        self._log("  The av1an binary was compiled against a different VapourSynth version.")

        # Check ffmpeg fallback availability
        codec_idx = self.codec_combo.currentIndex()
        video_codec = VIDEO_CODECS[codec_idx]
        ffmpeg_enc = video_codec.ffmpeg_encoder
        ffmpeg_lib_key = ffmpeg_lib_key_for(ffmpeg_enc)  # v3: OTC-007
        fallback_possible = self.env.ffmpeg_libs.get(ffmpeg_lib_key, False)

        if fallback_possible:
            btn_rebuild = QPushButton("  Rebuild from Git  ")
            btn_rebuild.setObjectName("btnRebuild")
            btn_fallback = QPushButton("  Use ffmpeg Fallback  ")
            btn_fallback.setObjectName("btnRun")
            btn_cancel = QPushButton("  Cancel  ")
            btn_cancel.setObjectName("btnStop")

            dlg = QMessageBox(self)
            dlg.setWindowTitle("av1an + VapourSynth Version Mismatch")
            dlg.setText(
                "av1an cannot initialize VapourSynth — the installed versions\n"
                "have an ABI incompatibility (common with distro packages).\n\n"
                f"Choose how to proceed:"
            )
            dlg.setInformativeText(
                "• Rebuild from Git — compiles both from source (~10-30 min).\n"
                "  Fixes the root cause. Requires sudo for install.\n"
                f"• ffmpeg Fallback — encode with ffmpeg ({ffmpeg_enc}) now.\n"
                "  No chunk-parallel mode but output quality is identical."
            )
            dlg.addButton(btn_rebuild, QMessageBox.ButtonRole.AcceptRole)
            dlg.addButton(btn_fallback, QMessageBox.ButtonRole.YesRole)
            dlg.addButton(btn_cancel, QMessageBox.ButtonRole.RejectRole)

            dlg.exec()
            clicked = dlg.clickedButton()

            if clicked == btn_rebuild:
                self._log("")
                self._log("User chose: Rebuild VapourSynth + av1an from git.")
                self._start_git_rebuild()
                return False  # don't start encoding — user will retry after build
            elif clicked == btn_fallback:
                self._log("")
                self._log(f"FALLBACK: Switching to pure ffmpeg ({ffmpeg_enc}) encoding.")
                self._log(
                    "  Note: ffmpeg single-pass mode (no chunk-parallel). "
                    "Slower for large files but produces identical output."
                )
                self._log("  Use the REBUILD FROM GIT button to fix av1an for chunk-parallel mode.")
                self._log("")
                return True
            else:
                # Cancel
                self._log("Cancelled by user.")
                return False
        else:
            # No ffmpeg fallback available — offer rebuild or hard cancel
            btn_rebuild = QPushButton("  Rebuild from Git  ")
            btn_rebuild.setObjectName("btnRebuild")
            btn_cancel = QPushButton("  Cancel  ")
            btn_cancel.setObjectName("btnStop")

            dlg = QMessageBox(self)
            dlg.setWindowTitle("av1an + VapourSynth Version Mismatch")
            dlg.setText(
                "av1an cannot initialize VapourSynth — ABI incompatibility.\n\n"
                f"ffmpeg also lacks '{ffmpeg_enc}' — no fallback possible.\n"
                "You must rebuild to proceed."
            )
            dlg.setIcon(QMessageBox.Icon.Critical)
            dlg.addButton(btn_rebuild, QMessageBox.ButtonRole.AcceptRole)
            dlg.addButton(btn_cancel, QMessageBox.ButtonRole.RejectRole)

            dlg.exec()
            clicked = dlg.clickedButton()

            if clicked == btn_rebuild:
                self._log("")
                self._log("User chose: Rebuild VapourSynth + av1an from git (no fallback available).")
                self._start_git_rebuild()
            else:
                self._log("Cancelled by user.")
            return False

    def _start_git_rebuild(self, build_vs: bool = True, build_av1an: bool = True,
                           build_ffmpeg_iamf: bool = False):
        """Start the SourceBuildWorker thread."""
        components = []
        if build_vs: components.append("VapourSynth")
        if build_av1an: components.append("av1an")
        if build_ffmpeg_iamf: components.append("ffmpeg+libiamf")
        self._log(f"Starting source build ({' + '.join(components) if components else 'none'})...")
        self._log("Builds to ~/.local/ and ~/.cargo/bin/ — sudo only if build deps are missing.")
        if build_ffmpeg_iamf:
            self._log("  NOTE: ffmpeg build takes 10-20 min. App must be restarted after.")
        self.btn_run.setEnabled(False)
        self.btn_rebuild.setEnabled(False)
        self.btn_stop.setEnabled(False)
        self.status_label.setText("Building from git... (see log)")

        self._build_worker = SourceBuildWorker(
            build_vs=build_vs, build_av1an=build_av1an,
            build_ffmpeg_iamf=build_ffmpeg_iamf,
        )
        self._build_worker.log_msg.connect(self._log)
        self._build_worker.build_done.connect(self._on_build_done)
        self._build_worker.start()

    @Slot(bool, str)
    def _on_build_done(self, success: bool, message: str):
        """Called when SourceBuildWorker finishes."""
        self._log("")
        if success:
            self._log(f"BUILD SUCCESS: {message}")
            self._log("Re-probing environment to pick up new binaries...")
            QApplication.processEvents()

            # Ensure LD_LIBRARY_PATH is set in the main process too.
            #
            # INTENTIONAL os.environ mutation (the ONE kept after the
            # v3-08 refactor). SourceBuildWorker no longer mutates
            # os.environ — it accumulates env changes in its private
            # self._build_env dict and passes that to subprocess.run.
            # But that dict dies with the worker thread. The UI thread
            # must update its OWN os.environ so the next
            # probe_environment() call — which spawns ffmpeg/av1an
            # subprocesses that inherit os.environ — can dlopen the
            # freshly-built VapourSynth / libiamf shared libraries from
            # ~/.local/lib. Without this, the rebuilt binaries would
            # fail to load their dependent libs.
            local_lib = str(Path.home() / ".local" / "lib")
            existing_ld = os.environ.get("LD_LIBRARY_PATH", "")
            if local_lib not in existing_ld:
                os.environ["LD_LIBRARY_PATH"] = f"{local_lib}:{existing_ld}".rstrip(":")

            # Re-probe environment with fresh data
            self.env = probe_environment()

            # Run smoke test again to verify the fix
            if self.env.av1an_path and self.env.ffmpeg_path:
                svt_name = self.env.av1an_flags.get("svt_name", "svt_av1")
                ok, detail = _av1an_vsscript_smoke_test(
                    self.env.av1an_path,
                    self.env.ffmpeg_path,
                    self.env.av1an_flags,
                    svt_name=svt_name,
                )
                if ok:
                    self._log("VERIFIED: av1an + VapourSynth now working correctly!")
                    self._log("Click START PROCESSING to encode.")
                elif "INVALID_ENCODER" in detail:
                    # Re-probe encoder name with the fresh binary
                    self._log("  Re-detecting encoder name from fresh build...")
                    new_name = _detect_av1an_svt_encoder(self.env.av1an_path)
                    if new_name and new_name != svt_name:
                        self.env.av1an_flags["svt_name"] = new_name
                        self._log(f"  Corrected encoder name: '{svt_name}' -> '{new_name}'")
                        ok2, detail2 = _av1an_vsscript_smoke_test(
                            self.env.av1an_path, self.env.ffmpeg_path,
                            self.env.av1an_flags, svt_name=new_name,
                        )
                        if ok2:
                            self._log("VERIFIED: av1an + VapourSynth now working correctly!")
                            self._log("Click START PROCESSING to encode.")
                        else:
                            self._log(f"WARNING: Smoke test still fails: {detail2}")
                    else:
                        self._log(f"WARNING: Could not auto-fix encoder name. Smoke test: {detail}")
                else:
                    self._log(f"WARNING: Build completed but smoke test still fails: {detail}")
                    self._log("You may need to log out/in or restart the app for library changes to take effect.")

            # Update status bar
            distro = self.env.distro
            cpu = self.env.cpu
            vs_info = f" | VS{self.env.vs_version}" if self.env.vs_version else ""
            fb_encs = []
            for vc in VIDEO_CODECS:
                lib_key = ffmpeg_lib_key_for(vc.ffmpeg_encoder)  # v3: OTC-007
                if self.env.ffmpeg_libs.get(lib_key, False):
                    fb_encs.append(vc.ffmpeg_encoder)
            fb_info = f" | ffmpeg-fb:{'+'.join(fb_encs)}" if fb_encs else ""
            self.status_label.setText(
                f"{distro.name} | {cpu.physical_cores}C/{cpu.logical_threads}T | "
                f"av1an v{self.env.av1an_version or '?'} | ffmpeg v{self.env.ffmpeg_version or '?'}{vs_info}{fb_info}"
            )
        else:
            self._log(f"BUILD FAILED: {message}")
            self._log("Try running the build manually in a terminal, or use ffmpeg fallback.")
            self.status_label.setText("Build failed — check log")

        self.btn_run.setEnabled(True)
        self.btn_rebuild.setEnabled(True)

    @Slot()
    def _manual_rebuild(self):
        """Handle the REBUILD FROM GIT button click (manual trigger)."""
        btn_vs_av1an = QPushButton("  VapourSynth + av1an  ")
        btn_vs_av1an.setObjectName("btnRebuild")
        btn_vs_only = QPushButton("  VapourSynth only  ")
        btn_vs_only.setObjectName("btnRebuild")
        btn_av1an_only = QPushButton("  av1an only  ")
        btn_av1an_only.setObjectName("btnRebuild")
        btn_ffmpeg_iamf = QPushButton("  ffmpeg + IAMF  ")
        btn_ffmpeg_iamf.setObjectName("btnRebuild")
        btn_cancel = QPushButton("  Cancel  ")
        btn_cancel.setObjectName("btnStop")

        dlg = QMessageBox(self)
        dlg.setWindowTitle("Rebuild from Git")
        dlg.setText(
            "Select which components to rebuild from git source.\n\n"
            "• VapourSynth — installs to ~/.local (needs sudo for build deps)\n"
            "• av1an — builds via cargo, copies to ~/.cargo/bin (needs sudo for build deps)\n"
            "• ffmpeg + IAMF — builds libiamf + ffmpeg with --enable-libiamf,\n"
            "  installs to ~/.local/bin/ffmpeg (shadows system ffmpeg).\n"
            "  Required to use the IAMF audio codec. ~10-20 min build time.\n\n"
            "Build times: VapourSynth ~2-5 min, av1an ~10-30 min, ffmpeg ~10-20 min"
        )
        dlg.addButton(btn_vs_av1an, QMessageBox.ButtonRole.AcceptRole)
        dlg.addButton(btn_vs_only, QMessageBox.ButtonRole.YesRole)
        dlg.addButton(btn_av1an_only, QMessageBox.ButtonRole.NoRole)
        dlg.addButton(btn_ffmpeg_iamf, QMessageBox.ButtonRole.ActionRole)
        dlg.addButton(btn_cancel, QMessageBox.ButtonRole.RejectRole)

        dlg.exec()
        clicked = dlg.clickedButton()

        if clicked == btn_vs_av1an:
            self._start_git_rebuild(build_vs=True, build_av1an=True)
        elif clicked == btn_vs_only:
            self._start_git_rebuild(build_vs=True, build_av1an=False)
        elif clicked == btn_av1an_only:
            self._start_git_rebuild(build_vs=False, build_av1an=True)
        elif clicked == btn_ffmpeg_iamf:
            self._start_git_rebuild(build_vs=False, build_av1an=False,
                                    build_ffmpeg_iamf=True)

    @Slot(str, int, int)
    def _on_progress(self, filename: str, current: int, total: int):
        self.status_label.setText(f"Processing {current}/{total}: {filename}")

    @Slot(int, int)
    def _on_finished(self, ok: int, fail: int):
        self.btn_run.setEnabled(True)
        self.btn_run.setText("START PROCESSING")
        self.btn_stop.setEnabled(False)
        self.status_label.setText(f"Done — {ok} succeeded, {fail} failed")

        if fail > 0:
            self._log(f"WARNING: {fail} file(s) failed. Check log above for details.")
        if ok > 0:
            self._log(f"All {ok} file(s) archived successfully.")

    @Slot()
    def _stop_process(self):
        if self.worker and self.worker.isRunning():
            self._log("STOP: Exiting queue after current file finishes...")
            self.worker.stop()
            self.btn_stop.setEnabled(False)


# ──────────────────────────────────────────────
#  ENTRY POINT
# ──────────────────────────────────────────────

if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = OpenCodecMaster()
    window.show()
    sys.exit(app.exec())