use std::{
    collections::HashMap,
    env,
    ffi::OsStr,
    io::{self, IsTerminal, Write},
    os::fd::{FromRawFd, OwnedFd},
    path::{Path, PathBuf},
    str::FromStr,
    sync::{
        Arc,
        atomic::{AtomicBool, Ordering},
        mpsc,
    },
    time::{Duration, Instant},
};

use anyhow::{Context, Result, anyhow, bail, ensure};
use clap::{Parser, ValueEnum};
use gst::prelude::*;

use ocula::{
    monitor,
    output::{self, FinalizedRecording, OutputPaths},
    pipeline::{
        self, AudioMode, CaptureArea, H264Encoder, OutputFormat, OutputSize, PipelineOptions,
        VideoCodec, VideoEffect, VideoSource,
    },
    portal, selector,
};

const MAX_VIDEO_BITRATE: u32 = i32::MAX as u32;
const DEFAULT_GIF_FPS: u32 = 15;
const ADAPTIVE_H264_MIN_BITRATE: u32 = 2_000_000;
const ADAPTIVE_H264_MAX_BITRATE: u32 = 12_000_000;
const ADAPTIVE_VP8_MIN_BITRATE: u32 = 1_500_000;
const ADAPTIVE_VP8_MAX_BITRATE: u32 = 8_000_000;
const CONTROL_CHANNEL_CAPACITY: usize = 2;
const WARNING_COUNT_CAPACITY: usize = 64;

#[derive(Debug, Parser)]
#[command(name = "oculo", author, version, about)]
struct Args {
    /// Final recording path. Defaults to Ocula-YYYY-MM-DD-HH-MM-SS.mp4.
    #[arg(short, long)]
    output: Option<PathBuf>,

    /// Directory used when --output is not provided.
    #[arg(long, default_value = ".")]
    dir: PathBuf,

    /// Capture backend.
    #[arg(long, value_enum, default_value_t = Backend::Auto)]
    backend: Backend,

    /// What to record. Defaults to an interactive chooser in a terminal.
    #[arg(long, value_enum)]
    mode: Option<RecordingMode>,

    /// Frames per second.
    #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u32).range(1..=120))]
    fps: u32,

    /// Video bitrate in bits/sec. 0 chooses a codec-specific default.
    #[arg(long, default_value_t = 0, value_parser = parse_bitrate)]
    bitrate: u32,

    /// Video codec. Auto prefers reliable realtime MJPEG for MKV, H.264 for MP4, and GIF for GIF.
    #[arg(long, value_enum, default_value_t = CliVideoCodec::Auto)]
    codec: CliVideoCodec,

    /// H.264 encoder preference when H.264 is selected.
    #[arg(long, value_enum, default_value_t = CliH264Encoder::Auto)]
    h264_encoder: CliH264Encoder,

    /// Optional GLSL video effect. Requires GStreamer OpenGL elements when not none.
    #[arg(long, value_enum, default_value_t = CliVideoEffect::None)]
    effect: CliVideoEffect,

    /// Output container. Defaults to .mp4, or infers .mkv/.gif from --output.
    #[arg(long, value_enum)]
    format: Option<CliOutputFormat>,

    /// Optional audio capture.
    #[arg(long, value_enum, default_value_t = CliAudioMode::None)]
    audio: CliAudioMode,

    /// Crop to X,Y,WIDTH,HEIGHT or WIDTHxHEIGHT+X+Y.
    #[arg(long, value_parser = parse_area, conflicts_with = "select")]
    area: Option<CaptureArea>,

    /// Screen dimensions used when --area came from an external selector.
    #[arg(long, hide = true, value_parser = parse_selection_screen, requires = "area")]
    area_screen: Option<SelectionScreen>,

    /// Already-approved portal PipeWire remote FD inherited from the GUI.
    #[arg(long, hide = true, value_parser = parse_inherited_fd)]
    portal_fd: Option<i32>,

    /// Portal streams for an inherited PipeWire remote.
    #[arg(long, hide = true, value_parser = parse_portal_streams, requires = "portal_fd")]
    portal_streams: Option<Vec<portal::Stream>>,

    /// Record one X11 monitor by 1-based index. Use --list-monitors to inspect.
    #[arg(long, value_parser = parse_monitor_index, conflicts_with_all = ["area", "select"])]
    monitor: Option<usize>,

    /// List X11 monitors and exit.
    #[arg(long)]
    list_monitors: bool,

    /// Select an area with Ocula's X11/XWayland overlay.
    #[arg(long)]
    select: bool,

    /// Do not open the terminal chooser when no recording mode is provided.
    #[arg(long)]
    no_prompt: bool,

    /// Scale output to WIDTHxHEIGHT. Defaults to 1920x1080 for full-screen capture.
    #[arg(long, value_parser = parse_size, conflicts_with = "no_scale")]
    scale: Option<OutputSize>,

    /// Disable default full-screen downscaling.
    #[arg(long)]
    no_scale: bool,

    /// Hide the mouse pointer in the recording.
    #[arg(long)]
    hide_pointer: bool,

    /// Stop automatically after this many seconds.
    #[arg(long, value_parser = clap::value_parser!(u64).range(1..))]
    max_duration: Option<u64>,

    /// Seconds to wait for EOS finalization before saving the partial file.
    #[arg(long, default_value_t = 15, value_parser = clap::value_parser!(u64).range(1..))]
    eos_timeout: u64,

    /// Allow replacing --output if it already exists.
    #[arg(long)]
    overwrite: bool,

    /// Permit selecting multiple monitor streams through the portal.
    #[arg(long)]
    multiple: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum Backend {
    Auto,
    Portal,
    X11,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum RecordingMode {
    Screen,
    Window,
    Area,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum CliAudioMode {
    None,
    Mic,
    System,
    Both,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum CliVideoCodec {
    Auto,
    H264,
    Mjpeg,
    Vp8,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum CliH264Encoder {
    Auto,
    Hardware,
    Software,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum CliVideoEffect {
    None,
    Neon,
    Heatmap,
    Glitch,
    Ripple,
    Glass,
    Crt,
    Holo,
    Vapor,
    Fracture,
    Prism,
    Aurora,
    Kaleido,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum CliOutputFormat {
    Mkv,
    Mp4,
    Gif,
}

impl From<CliOutputFormat> for OutputFormat {
    fn from(value: CliOutputFormat) -> Self {
        match value {
            CliOutputFormat::Mkv => Self::Mkv,
            CliOutputFormat::Mp4 => Self::Mp4,
            CliOutputFormat::Gif => Self::Gif,
        }
    }
}

impl From<CliAudioMode> for AudioMode {
    fn from(value: CliAudioMode) -> Self {
        match value {
            CliAudioMode::None => Self::None,
            CliAudioMode::Mic => Self::Microphone,
            CliAudioMode::System => Self::System,
            CliAudioMode::Both => Self::Both,
        }
    }
}

impl From<CliVideoEffect> for VideoEffect {
    fn from(value: CliVideoEffect) -> Self {
        match value {
            CliVideoEffect::None => Self::None,
            CliVideoEffect::Neon => Self::Neon,
            CliVideoEffect::Heatmap => Self::Heatmap,
            CliVideoEffect::Glitch => Self::Glitch,
            CliVideoEffect::Ripple => Self::Ripple,
            CliVideoEffect::Glass => Self::Glass,
            CliVideoEffect::Crt => Self::Crt,
            CliVideoEffect::Holo => Self::Holo,
            CliVideoEffect::Vapor => Self::Vapor,
            CliVideoEffect::Fracture => Self::Fracture,
            CliVideoEffect::Prism => Self::Prism,
            CliVideoEffect::Aurora => Self::Aurora,
            CliVideoEffect::Kaleido => Self::Kaleido,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ControlMessage {
    Stop,
    Abort,
}

#[derive(Debug)]
struct CaptureSetup {
    video_source: VideoSource,
    portal_session: Option<portal::Session>,
    backend: Backend,
    area: Option<CaptureArea>,
    source_size: Option<(i32, i32)>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct SelectionScreen {
    width: i32,
    height: i32,
}

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct ExplicitPromptArgs {
    audio: bool,
    output_or_dir: bool,
    hide_pointer: bool,
}

#[derive(Debug)]
struct RecordingRun {
    clean_eos: bool,
    duration: Option<gst::ClockTime>,
}

#[derive(Debug, Default)]
struct WarningCounts {
    counts: HashMap<(String, String), usize>,
    overflow_reported: bool,
}

impl WarningCounts {
    fn new() -> Self {
        Self {
            counts: HashMap::with_capacity(WARNING_COUNT_CAPACITY),
            overflow_reported: false,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WarningOccurrence {
    First,
    Second,
    Suppressed,
    CapacityReached,
}

fn main() {
    if let Err(err) = real_main() {
        eprintln!("error: {err:#}");
        std::process::exit(1);
    }
}

fn real_main() -> Result<()> {
    let mut args = Args::parse();
    let explicit_fps = explicit_fps_arg();
    let mut selection_screen = args.area_screen;

    if args.list_monitors {
        print_x11_monitors()?;
        return Ok(());
    }

    maybe_prompt_recording_mode(&mut args)?;
    normalize_recording_request(&mut args)?;
    let output_format = selected_output_format(&args)?;
    validate_format_options(&args, output_format)?;
    output::validate_request(args.output.as_deref(), &args.dir, output_format)?;
    resolve_monitor_area(&mut args)?;

    gst::init().context("failed to initialize GStreamer")?;
    let codec = choose_codec(args.codec, args.h264_encoder, output_format)?;
    let effect = VideoEffect::from(args.effect);
    let audio = args.audio.into();
    require_common_plugins(audio, codec, effect, output_format)?;
    pipeline::preflight_audio(audio)?;

    if args.select {
        let selection = selector::select_area_once_with_effect(effect)?;
        args.area = Some(selection.area);
        selection_screen = Some(SelectionScreen {
            width: selection.screen_width,
            height: selection.screen_height,
        });
    }

    let CaptureSetup {
        video_source,
        mut portal_session,
        backend,
        area,
        source_size,
    } = setup_capture(&args, selection_screen)?;
    let area = normalize_selected_area_for_codec(area, codec)?;
    let source_size = area.map(|area| (area.width, area.height)).or(source_size);
    if let Some(area) = area {
        eprintln!(
            "selection area: {},{},{},{}",
            area.x, area.y, area.width, area.height
        );
    }
    let scale = match selected_scale(
        recording_mode(&args),
        source_size,
        args.scale,
        args.no_scale,
    ) {
        Ok(scale) => scale,
        Err(err) => {
            close_portal_session(portal_session.take());
            return Err(err);
        }
    };
    let fps = selected_fps(output_format, args.fps, explicit_fps);
    if output_format == OutputFormat::Gif && !explicit_fps && args.fps != fps {
        eprintln!("GIF output defaults to {fps} fps; pass --fps to override");
    }
    let bitrate = selected_bitrate(codec, args.bitrate, source_size, scale, fps);
    let control_rx = match install_signal_handler() {
        Ok(control_rx) => control_rx,
        Err(err) => {
            close_portal_session(portal_session.take());
            return Err(err);
        }
    };
    let output_paths = match output::prepare(
        args.output.as_deref(),
        &args.dir,
        args.overwrite,
        output_format,
    ) {
        Ok(output_paths) => output_paths,
        Err(err) => {
            close_portal_session(portal_session.take());
            return Err(err);
        }
    };

    let pipeline = match pipeline::build(PipelineOptions {
        video_source,
        temp_path: output_paths.temp_path.clone(),
        output_format,
        fps,
        bitrate,
        codec,
        effect,
        audio,
        area,
        scale,
    }) {
        Ok(pipeline) => pipeline,
        Err(err) => {
            close_portal_session(portal_session.take());
            if let Err(cleanup_err) = output::discard(output_paths) {
                eprintln!("warning: failed to discard reserved output: {cleanup_err:#}");
            }
            return Err(err);
        }
    };

    eprintln!(
        "recording with {:?} backend, {} codec, {} fps -> {}",
        backend,
        codec,
        fps,
        output_paths.final_path.display()
    );
    if let Some(scale) = scale {
        eprintln!("output scale: {}x{}", scale.width, scale.height);
    }
    if args.bitrate == 0 && bitrate > 0 {
        eprintln!("default bitrate: {} Mbps", bitrate / 1_000_000);
    }
    if effect != VideoEffect::None {
        eprintln!("video effect: {effect}");
    }
    eprintln!("press Ctrl-C to stop; press Ctrl-C again to abort");

    let run_result = run_pipeline(
        &pipeline,
        control_rx,
        args.max_duration.map(Duration::from_secs),
        Duration::from_secs(args.eos_timeout),
    );

    let stop_result = stop_pipeline(&pipeline);
    close_portal_session(portal_session.take());

    let run = match (run_result, stop_result) {
        (Ok(run), Ok(())) => run,
        (Err(err), Ok(())) => {
            match save_partial(output_paths) {
                Ok(saved) => print_partial_recording(saved),
                Err(save_err) => {
                    eprintln!("warning: failed to save partial recording: {save_err:#}")
                }
            }
            return Err(err);
        }
        (Ok(_), Err(err)) => {
            print_temporary_recording_left(&output_paths);
            return Err(err)
                .context("pipeline did not fully stop, so the recording was not finalized");
        }
        (Err(err), Err(stop_err)) => {
            eprintln!("warning: pipeline also failed to stop: {stop_err:#}");
            print_temporary_recording_left(&output_paths);
            return Err(err).context(
                "recording failed and pipeline did not fully stop; temporary file was left in place",
            );
        }
    };

    if run.clean_eos {
        let saved = output::finalize(output_paths)?;
        match run.duration {
            Some(duration) => eprintln!(
                "saved recording: {} ({} bytes, {})",
                saved.path.display(),
                saved.bytes,
                format_duration(duration)
            ),
            None => eprintln!(
                "saved recording: {} ({} bytes)",
                saved.path.display(),
                saved.bytes
            ),
        }
    } else {
        print_partial_recording(save_partial(output_paths)?);
    }

    Ok(())
}

fn save_partial(output_paths: OutputPaths) -> Result<Option<FinalizedRecording>> {
    output::finalize_nonempty(output_paths)
        .context("non-empty temporary recording could not be promoted to the final output")
}

fn print_partial_recording(saved: Option<FinalizedRecording>) {
    match saved {
        Some(saved) => eprintln!(
            "saved partial recording: {} ({} bytes)",
            saved.path.display(),
            saved.bytes
        ),
        None => eprintln!("discarded empty partial recording"),
    }
}

fn print_temporary_recording_left(output_paths: &OutputPaths) {
    eprintln!(
        "left temporary recording for manual recovery: {}",
        output_paths.temp_path.display()
    );
}

fn maybe_prompt_recording_mode(args: &mut Args) -> Result<()> {
    if !should_prompt_recording_mode(args, io::stdin().is_terminal()) {
        return Ok(());
    }

    eprintln!("What do you want to record?");
    eprintln!("  1) Screen or monitor");
    eprintln!("  2) Window");
    eprintln!("  3) Area");
    match prompt_choice("Choose recording mode", 1, 3)? {
        1 => {
            args.mode = Some(RecordingMode::Screen);
            maybe_prompt_x11_monitor(args)?;
        }
        2 => args.mode = Some(RecordingMode::Window),
        3 => {
            args.mode = Some(RecordingMode::Area);
            args.select = true;
        }
        _ => unreachable!(),
    }

    maybe_prompt_recording_options(args, explicit_prompt_args())?;

    Ok(())
}

fn maybe_prompt_recording_options(args: &mut Args, explicit: ExplicitPromptArgs) -> Result<()> {
    if !explicit.audio {
        eprintln!("Audio capture?");
        eprintln!("  1) None");
        eprintln!("  2) Microphone");
        eprintln!("  3) System audio");
        eprintln!("  4) Microphone + system audio");
        args.audio = match prompt_choice("Choose audio", 1, 4)? {
            1 => CliAudioMode::None,
            2 => CliAudioMode::Mic,
            3 => CliAudioMode::System,
            4 => CliAudioMode::Both,
            _ => unreachable!(),
        };
    }

    if !explicit.hide_pointer {
        eprintln!("Mouse pointer?");
        eprintln!("  1) Show pointer");
        eprintln!("  2) Hide pointer");
        args.hide_pointer = match prompt_choice("Choose pointer mode", 1, 2)? {
            1 => false,
            2 => true,
            _ => unreachable!(),
        };
    }

    if !explicit.output_or_dir
        && let Some(output) = prompt_optional_path("Output file")?
    {
        args.output = Some(output);
    }

    Ok(())
}

fn maybe_prompt_x11_monitor(args: &mut Args) -> Result<()> {
    if !should_offer_x11_monitor_prompt(args) {
        return Ok(());
    }

    let monitors = monitor::list_x11_monitors()?;
    if monitors.len() <= 1 {
        return Ok(());
    }

    eprintln!("Available X11 monitors:");
    eprintln!("  0) All monitors");
    for monitor in &monitors {
        eprintln!("  {}", monitor::format_monitor(monitor));
    }

    let choice = prompt_choice("Choose monitor", 0, monitors.len())?;
    if choice != 0 {
        monitor::validate_monitor_index(choice, monitors.len())?;
        args.monitor = Some(choice);
    }

    Ok(())
}

fn should_prompt_recording_mode(args: &Args, stdin_is_terminal: bool) -> bool {
    stdin_is_terminal
        && !args.no_prompt
        && !args.list_monitors
        && args.mode.is_none()
        && args.area.is_none()
        && args.monitor.is_none()
        && !args.select
}

fn should_offer_x11_monitor_prompt(args: &Args) -> bool {
    should_offer_x11_monitor_prompt_for(
        args.backend,
        env::var_os("DISPLAY").is_some(),
        env::var_os("WAYLAND_DISPLAY").is_some(),
    )
}

fn should_offer_x11_monitor_prompt_for(
    backend: Backend,
    has_display: bool,
    has_wayland: bool,
) -> bool {
    match backend {
        Backend::X11 => has_display,
        Backend::Auto => has_display && !has_wayland,
        Backend::Portal => false,
    }
}

fn prompt_choice(prompt: &str, min: usize, max: usize) -> Result<usize> {
    loop {
        print!("{prompt} [{min}]: ");
        io::stdout().flush().context("failed to flush prompt")?;

        let mut line = String::new();
        io::stdin()
            .read_line(&mut line)
            .context("failed to read prompt response")?;
        let line = line.trim();
        if line.is_empty() {
            return Ok(min);
        }
        match line.parse::<usize>() {
            Ok(choice) if (min..=max).contains(&choice) => return Ok(choice),
            _ => eprintln!("enter a number from {min} to {max}"),
        }
    }
}

fn prompt_optional_path(prompt: &str) -> Result<Option<PathBuf>> {
    print!("{prompt} [auto]: ");
    io::stdout().flush().context("failed to flush prompt")?;

    let mut line = String::new();
    io::stdin()
        .read_line(&mut line)
        .context("failed to read prompt response")?;
    let home = env::var_os("HOME").map(PathBuf::from);
    Ok(prompt_path_from_input(&line, home.as_deref()))
}

fn prompt_path_from_input(input: &str, home: Option<&Path>) -> Option<PathBuf> {
    let path = strip_matching_quotes(input.trim()).trim();
    if path.is_empty() {
        return None;
    }

    Some(expand_prompt_path(path, home))
}

fn strip_matching_quotes(input: &str) -> &str {
    if input.len() < 2 {
        return input;
    }

    let quoted = (input.starts_with('"') && input.ends_with('"'))
        || (input.starts_with('\'') && input.ends_with('\''));
    if quoted {
        &input[1..input.len() - 1]
    } else {
        input
    }
}

fn expand_prompt_path(path: &str, home: Option<&Path>) -> PathBuf {
    match (path, home) {
        ("~", Some(home)) => home.to_path_buf(),
        (path, Some(home)) if path.starts_with("~/") => home.join(&path[2..]),
        _ => PathBuf::from(path),
    }
}

fn explicit_prompt_args() -> ExplicitPromptArgs {
    explicit_prompt_args_from(env::args_os().skip(1))
}

fn explicit_fps_arg() -> bool {
    explicit_fps_arg_from(env::args_os().skip(1))
}

fn explicit_fps_arg_from<I, S>(args: I) -> bool
where
    I: IntoIterator<Item = S>,
    S: AsRef<OsStr>,
{
    args.into_iter()
        .any(|arg| is_long_option(&arg.as_ref().to_string_lossy(), "--fps"))
}

fn explicit_prompt_args_from<I, S>(args: I) -> ExplicitPromptArgs
where
    I: IntoIterator<Item = S>,
    S: AsRef<OsStr>,
{
    let mut explicit = ExplicitPromptArgs::default();
    for arg in args {
        let arg = arg.as_ref().to_string_lossy();
        if is_long_option(&arg, "--audio") {
            explicit.audio = true;
        }
        if is_long_option(&arg, "--output")
            || is_short_output_option(&arg)
            || is_long_option(&arg, "--dir")
        {
            explicit.output_or_dir = true;
        }
        if arg == "--hide-pointer" {
            explicit.hide_pointer = true;
        }
    }

    explicit
}

fn is_long_option(raw: &str, name: &str) -> bool {
    raw == name
        || raw
            .strip_prefix(name)
            .is_some_and(|suffix| suffix.starts_with('='))
}

fn is_short_output_option(raw: &str) -> bool {
    raw == "-o"
        || raw
            .strip_prefix("-o")
            .is_some_and(|suffix| !suffix.is_empty())
}

fn normalize_recording_request(args: &mut Args) -> Result<()> {
    validate_recording_request(args)?;

    if args.monitor.is_some() && args.backend == Backend::Portal {
        bail!(
            "--monitor is only available with the X11 backend; omit it to use the portal chooser"
        );
    }
    if args.monitor.is_some() && args.backend == Backend::Auto {
        args.backend = Backend::X11;
    }
    if args.multiple && args.backend == Backend::Auto {
        args.backend = Backend::Portal;
    }

    if matches!(args.mode, Some(RecordingMode::Area)) && args.area.is_none() && !args.select {
        args.select = true;
    }
    let has_wayland = env::var_os("WAYLAND_DISPLAY").is_some();
    let x11_available = args.select && has_wayland && monitor::x11_available();
    normalize_selection_backend(args, has_wayland, x11_available)?;

    Ok(())
}

fn normalize_selection_backend(
    args: &mut Args,
    has_wayland: bool,
    x11_available: bool,
) -> Result<()> {
    validate_selection_environment(args.select, has_wayland, x11_available)
}

fn validate_selection_environment(
    select: bool,
    has_wayland: bool,
    x11_available: bool,
) -> Result<()> {
    if select && has_wayland && !x11_available {
        bail!(
            "--select needs a reachable X11/XWayland DISPLAY for the crop overlay; pass --area X,Y,WIDTH,HEIGHT instead"
        );
    }

    Ok(())
}

fn resolve_monitor_area(args: &mut Args) -> Result<()> {
    if let Some(index) = args.monitor {
        args.area = Some(monitor::x11_monitor_area(index)?);
    }

    Ok(())
}

fn validate_recording_request(args: &Args) -> Result<()> {
    if args.mode == Some(RecordingMode::Window)
        && (args.area.is_some() || args.select || args.monitor.is_some())
    {
        bail!("--mode window cannot be combined with --area, --select, or --monitor");
    }
    if args.mode == Some(RecordingMode::Window) && args.backend == Backend::X11 {
        bail!("--mode window requires the portal backend");
    }
    if args.mode == Some(RecordingMode::Screen) && (args.area.is_some() || args.select) {
        bail!("--mode screen cannot be combined with --area or --select");
    }
    if args.mode == Some(RecordingMode::Area) && args.monitor.is_some() {
        bail!("--mode area cannot be combined with --monitor");
    }
    if args.multiple && args.backend == Backend::X11 {
        bail!("--multiple requires the portal backend");
    }
    if args.multiple && args.monitor.is_some() {
        bail!("--multiple cannot be combined with --monitor");
    }

    Ok(())
}

fn recording_mode(args: &Args) -> RecordingMode {
    if args.monitor.is_some() {
        RecordingMode::Screen
    } else if args.area.is_some() || args.select {
        RecordingMode::Area
    } else {
        args.mode.unwrap_or(RecordingMode::Screen)
    }
}

fn normalize_selected_area_for_codec(
    area: Option<CaptureArea>,
    codec: VideoCodec,
) -> Result<Option<CaptureArea>> {
    if !codec_requires_even_dimensions(codec) {
        return Ok(area);
    }
    let Some(area) = area else {
        return Ok(None);
    };

    even_sized_area(area).map(Some)
}

fn codec_requires_even_dimensions(codec: VideoCodec) -> bool {
    matches!(codec, VideoCodec::H264(_) | VideoCodec::Vp8)
}

fn even_sized_area(area: CaptureArea) -> Result<CaptureArea> {
    let width = area.width - area.width.rem_euclid(2);
    let height = area.height - area.height.rem_euclid(2);
    ensure!(
        width > 0 && height > 0,
        "selected area must be at least 2x2 for the selected video codec"
    );

    CaptureArea::new(area.x, area.y, width, height)
}

fn portal_source_type(mode: RecordingMode) -> portal::SourceType {
    match mode {
        RecordingMode::Screen | RecordingMode::Area => portal::SourceType::MONITOR,
        RecordingMode::Window => portal::SourceType::WINDOW,
    }
}

fn print_x11_monitors() -> Result<()> {
    for monitor in monitor::list_x11_monitors()? {
        println!("{}", monitor::format_monitor(&monitor));
    }

    Ok(())
}

fn setup_capture(args: &Args, selection_screen: Option<SelectionScreen>) -> Result<CaptureSetup> {
    if args.portal_fd.is_some() {
        return setup_inherited_portal_capture(args, selection_screen);
    }

    let mut last_error = None;

    for backend in backend_order(args) {
        match setup_backend(backend, args, selection_screen) {
            Ok(capture) => return Ok(capture),
            Err(err) if is_user_cancel(&err) => return Err(err),
            Err(err) => {
                eprintln!("{backend:?} backend unavailable: {err:#}");
                last_error = Some(err);
            }
        }
    }

    Err(last_error.unwrap_or_else(|| anyhow!("no capture backend could be selected")))
}

fn backend_order(args: &Args) -> Vec<Backend> {
    let has_display = if args.backend == Backend::Auto {
        monitor::x11_available()
    } else {
        env::var_os("DISPLAY").is_some()
    };

    backend_order_for(
        args.backend,
        recording_mode(args),
        env::var_os("WAYLAND_DISPLAY").is_some(),
        has_display,
    )
}

fn backend_order_for(
    backend: Backend,
    mode: RecordingMode,
    has_wayland: bool,
    has_display: bool,
) -> Vec<Backend> {
    match backend {
        Backend::Portal => vec![Backend::Portal],
        Backend::X11 => vec![Backend::X11],
        Backend::Auto if mode == RecordingMode::Window => vec![Backend::Portal],
        Backend::Auto if has_wayland => vec![Backend::Portal],
        Backend::Auto if mode == RecordingMode::Area && has_display => {
            vec![Backend::X11, Backend::Portal]
        }
        Backend::Auto if has_display => vec![Backend::X11, Backend::Portal],
        Backend::Auto => vec![Backend::Portal],
    }
}

fn setup_backend(
    backend: Backend,
    args: &Args,
    selection_screen: Option<SelectionScreen>,
) -> Result<CaptureSetup> {
    match backend {
        Backend::Auto => unreachable!("auto is expanded before setup"),
        Backend::Portal => {
            require_portal_plugins(args.multiple)?;
            let context = glib::MainContext::default();
            let capture = context.block_on(portal::start_screencast(portal::StartOptions {
                cursor_mode: if args.hide_pointer {
                    portal::CursorMode::HIDDEN
                } else {
                    portal::CursorMode::EMBEDDED
                },
                source_type: portal_source_type(recording_mode(args)),
                multiple: args.multiple,
            }))?;
            if let Err(err) = pipeline::validate_portal_streams(&capture.streams) {
                close_unusable_portal_session(&context, capture.session);
                return Err(err).context("portal capture returned unsupported stream geometry");
            }
            if capture.streams.len() > 1
                && let Err(err) = require_element("compositor")
            {
                close_unusable_portal_session(&context, capture.session);
                return Err(err).context("portal capture returned multiple streams");
            }

            let area = match (args.area, selection_screen) {
                (Some(area), Some(screen)) => {
                    match map_selected_area_to_portal_streams(area, screen, &capture.streams) {
                        Ok(area) => Some(area),
                        Err(err) => {
                            close_unusable_portal_session(&context, capture.session);
                            return Err(err).context("portal capture cannot scale selected area");
                        }
                    }
                }
                (area, _) => area,
            };

            if let Some(area) = area
                && let Err(err) = pipeline::validate_portal_area(area, &capture.streams)
            {
                close_unusable_portal_session(&context, capture.session);
                return Err(err).context("portal capture cannot use selected area");
            }

            let source_size = area
                .map(|area| (area.width, area.height))
                .or_else(|| pipeline::portal_stream_size(&capture.streams));
            Ok(CaptureSetup {
                video_source: VideoSource::Portal {
                    fd: capture.fd,
                    streams: capture.streams,
                },
                portal_session: Some(capture.session),
                backend,
                area,
                source_size,
            })
        }
        Backend::X11 => {
            if recording_mode(args) == RecordingMode::Window {
                bail!("window recording requires the portal backend");
            }
            require_element("ximagesrc")?;
            if let Some(area) = args.area {
                monitor::validate_x11_capture_area(area)?;
            }
            let source_size = match args.area {
                Some(area) => Some((area.width, area.height)),
                None => Some(monitor::x11_screen_size()?),
            };

            Ok(CaptureSetup {
                video_source: VideoSource::X11 {
                    show_pointer: !args.hide_pointer,
                },
                portal_session: None,
                backend,
                area: args.area,
                source_size,
            })
        }
    }
}

fn setup_inherited_portal_capture(
    args: &Args,
    selection_screen: Option<SelectionScreen>,
) -> Result<CaptureSetup> {
    ensure!(
        args.backend != Backend::X11,
        "inherited portal capture cannot be used with --backend x11"
    );
    require_portal_plugins(args.multiple)?;
    let fd = args
        .portal_fd
        .context("inherited portal capture is missing its PipeWire FD")?;
    let streams = args
        .portal_streams
        .clone()
        .context("inherited portal capture is missing stream metadata")?;
    pipeline::validate_portal_streams(&streams)
        .context("inherited portal capture returned unsupported stream geometry")?;

    let area = match (args.area, selection_screen) {
        (Some(area), Some(screen)) => Some(
            map_selected_area_to_portal_streams(area, screen, &streams)
                .context("inherited portal capture cannot map selected area")?,
        ),
        (area, _) => area,
    };
    if let Some(area) = area {
        pipeline::validate_portal_area(area, &streams)
            .context("inherited portal capture cannot use selected area")?;
    }

    let source_size = area
        .map(|area| (area.width, area.height))
        .or_else(|| pipeline::portal_stream_size(&streams));

    Ok(CaptureSetup {
        video_source: VideoSource::Portal {
            fd: unsafe { OwnedFd::from_raw_fd(fd) },
            streams,
        },
        portal_session: None,
        backend: Backend::Portal,
        area,
        source_size,
    })
}

fn map_selected_area_to_portal_streams(
    area: CaptureArea,
    screen: SelectionScreen,
    streams: &[portal::Stream],
) -> Result<CaptureArea> {
    map_selected_area_to_portal_streams_with_localized(
        area,
        screen,
        streams,
        localize_selection_to_x11_monitor(area),
    )
}

fn map_selected_area_to_portal_streams_with_localized(
    area: CaptureArea,
    screen: SelectionScreen,
    streams: &[portal::Stream],
    localized: Option<(CaptureArea, SelectionScreen)>,
) -> Result<CaptureArea> {
    if pipeline::validate_portal_area(area, streams).is_ok() {
        return Ok(area);
    }

    if let Some((local_area, local_screen)) = localized {
        return pipeline::map_area_to_portal_streams(
            local_area,
            local_screen.width,
            local_screen.height,
            streams,
        );
    }

    pipeline::map_area_to_portal_streams(area, screen.width, screen.height, streams)
}

fn localize_selection_to_x11_monitor(area: CaptureArea) -> Option<(CaptureArea, SelectionScreen)> {
    localize_selection_to_monitor(area, &monitor::list_x11_monitors().ok()?)
}

fn localize_selection_to_monitor(
    area: CaptureArea,
    monitors: &[monitor::Monitor],
) -> Option<(CaptureArea, SelectionScreen)> {
    let monitor = monitors.iter().find(|monitor| {
        area.x >= monitor.x
            && area.y >= monitor.y
            && area.right() <= monitor.x.saturating_add(monitor.width)
            && area.bottom() <= monitor.y.saturating_add(monitor.height)
            && monitor.width > 0
            && monitor.height > 0
    })?;
    let local_area = CaptureArea::new(
        area.x.saturating_sub(monitor.x),
        area.y.saturating_sub(monitor.y),
        area.width,
        area.height,
    )
    .ok()?;

    Some((
        local_area,
        SelectionScreen {
            width: monitor.width,
            height: monitor.height,
        },
    ))
}

fn close_portal_session(session: Option<portal::Session>) {
    if let Some(session) = session {
        let context = glib::MainContext::default();
        if let Err(err) = context.block_on(session.close()) {
            eprintln!("warning: failed to close portal session: {err:#}");
        }
    }
}

fn close_unusable_portal_session(context: &glib::MainContext, session: portal::Session) {
    if let Err(close_err) = context.block_on(session.close()) {
        eprintln!("warning: failed to close unusable portal session: {close_err:#}");
    }
}

fn install_signal_handler() -> Result<mpsc::Receiver<ControlMessage>> {
    let (tx, rx) = mpsc::sync_channel(CONTROL_CHANNEL_CAPACITY);
    let seen_stop = Arc::new(AtomicBool::new(false));
    let seen_stop_for_handler = Arc::clone(&seen_stop);

    ctrlc::set_handler(move || {
        let message = if seen_stop_for_handler.swap(true, Ordering::SeqCst) {
            ControlMessage::Abort
        } else {
            ControlMessage::Stop
        };
        let _ = tx.try_send(message);
    })
    .context("failed to install Ctrl-C/SIGTERM handler")?;

    Ok(rx)
}

fn run_pipeline(
    pipeline: &gst::Pipeline,
    control_rx: mpsc::Receiver<ControlMessage>,
    max_duration: Option<Duration>,
    eos_timeout: Duration,
) -> Result<RecordingRun> {
    let bus = pipeline.bus().context("pipeline has no bus")?;
    pipeline
        .set_state(gst::State::Playing)
        .context("failed to start recording pipeline")?;

    wait_until_playing(pipeline, &bus, &control_rx, Duration::from_secs(15))
        .context("pipeline did not reach Playing state")?;

    let started_at = Instant::now();
    let mut stopping_since = None;
    let mut warning_counts = WarningCounts::new();

    loop {
        while let Ok(message) = control_rx.try_recv() {
            match message {
                ControlMessage::Stop if stopping_since.is_none() => {
                    eprintln!("stopping cleanly, waiting for muxer finalization...");
                    request_eos(pipeline)?;
                    stopping_since = Some(Instant::now());
                }
                ControlMessage::Stop => {}
                ControlMessage::Abort => {
                    bail!("recording aborted before clean finalization completed");
                }
            }
        }

        if stopping_since.is_none()
            && max_duration.is_some_and(|duration| started_at.elapsed() >= duration)
        {
            eprintln!("max duration reached, stopping cleanly...");
            request_eos(pipeline)?;
            stopping_since = Some(Instant::now());
        }

        if let Some(stopped_at) = stopping_since
            && stopped_at.elapsed() >= eos_timeout
        {
            return Ok(RecordingRun {
                clean_eos: false,
                duration: pipeline.query_position::<gst::ClockTime>(),
            });
        }

        let Some(message) = bus.timed_pop(gst::ClockTime::from_mseconds(200)) else {
            continue;
        };

        use gst::MessageView;
        match message.view() {
            MessageView::Eos(..) if stopping_since.is_some() => {
                return Ok(RecordingRun {
                    clean_eos: true,
                    duration: pipeline.query_position::<gst::ClockTime>(),
                });
            }
            MessageView::Eos(..) => bail!("capture stream ended unexpectedly"),
            MessageView::Error(err) => {
                let source = err
                    .src()
                    .map(|src| src.path_string())
                    .unwrap_or_else(|| "unknown".into());
                let debug = err.debug().unwrap_or_else(|| "no debug info".into());
                bail!("GStreamer error from {source}: {}; {debug}", err.error());
            }
            MessageView::Warning(warning) => {
                print_gstreamer_warning(warning, &mut warning_counts);
            }
            _ => {}
        }
    }
}

fn wait_until_playing(
    pipeline: &gst::Pipeline,
    bus: &gst::Bus,
    control_rx: &mpsc::Receiver<ControlMessage>,
    timeout: Duration,
) -> Result<()> {
    let started_at = Instant::now();
    let mut warning_counts = WarningCounts::new();

    loop {
        if let Ok(message) = control_rx.try_recv() {
            match message {
                ControlMessage::Stop => bail!("recording stopped before pipeline reached Playing"),
                ControlMessage::Abort => {
                    bail!("recording aborted before pipeline reached Playing")
                }
            }
        }

        let (_, current, pending) = pipeline.state(Some(gst::ClockTime::from_mseconds(0)));
        if current == gst::State::Playing {
            return Ok(());
        }
        if started_at.elapsed() >= timeout {
            bail!(
                "pipeline reached {:?}, expected {:?}; pending {:?}",
                current,
                gst::State::Playing,
                pending
            );
        }

        let Some(message) = bus.timed_pop(gst::ClockTime::from_mseconds(100)) else {
            continue;
        };

        use gst::MessageView;
        match message.view() {
            MessageView::Error(err) => {
                let source = err
                    .src()
                    .map(|src| src.path_string())
                    .unwrap_or_else(|| "unknown".into());
                let debug = err.debug().unwrap_or_else(|| "no debug info".into());
                bail!("GStreamer error from {source}: {}; {debug}", err.error());
            }
            MessageView::Warning(warning) => {
                print_gstreamer_warning(warning, &mut warning_counts);
            }
            MessageView::Eos(..) => bail!("pipeline reached EOS before Playing"),
            _ => {}
        }
    }
}

fn print_gstreamer_warning(warning: &gst::message::Warning, counts: &mut WarningCounts) {
    let source = warning
        .src()
        .map(|src| src.path_string())
        .unwrap_or_else(|| "unknown".into());
    let error = warning.error().to_string();

    match warning_occurrence(counts, &source, &error) {
        WarningOccurrence::First => {
            let debug = warning.debug().map(|debug| format!("; {debug}"));
            eprintln!(
                "warning from {source}: {error}{}",
                debug.unwrap_or_default()
            );
        }
        WarningOccurrence::Second => eprintln!(
            "warning from {source}: {error} (repeated; suppressing further warnings with same source and message)"
        ),
        WarningOccurrence::CapacityReached => {
            eprintln!("warning limit reached; suppressing additional distinct GStreamer warnings")
        }
        WarningOccurrence::Suppressed => {}
    }
}

fn warning_occurrence(counts: &mut WarningCounts, source: &str, error: &str) -> WarningOccurrence {
    let key = (source.to_owned(), error.to_owned());

    if let Some(count) = counts.counts.get_mut(&key) {
        *count = count.saturating_add(1);
        return if *count == 2 {
            WarningOccurrence::Second
        } else {
            WarningOccurrence::Suppressed
        };
    }

    if counts.counts.len() >= WARNING_COUNT_CAPACITY {
        if counts.overflow_reported {
            WarningOccurrence::Suppressed
        } else {
            counts.overflow_reported = true;
            WarningOccurrence::CapacityReached
        }
    } else {
        counts.counts.insert(key, 1);
        WarningOccurrence::First
    }
}

fn request_eos(pipeline: &gst::Pipeline) -> Result<()> {
    if pipeline.send_event(gst::event::Eos::new()) {
        Ok(())
    } else {
        bail!("pipeline refused EOS event")
    }
}

fn stop_pipeline(pipeline: &gst::Pipeline) -> Result<()> {
    pipeline
        .set_state(gst::State::Null)
        .context("failed to request pipeline stop")?;

    let (state_result, current, pending) = pipeline.state(Some(gst::ClockTime::from_seconds(15)));
    state_result.with_context(|| {
        format!(
            "pipeline stop state is {:?} with pending state {:?}",
            current, pending
        )
    })?;
    if current == gst::State::Null {
        return Ok(());
    }

    bail!(
        "pipeline reached {:?}, expected {:?}; pending {:?}",
        current,
        gst::State::Null,
        pending
    )
}

fn require_common_plugins(
    audio: AudioMode,
    codec: VideoCodec,
    effect: VideoEffect,
    output_format: OutputFormat,
) -> Result<()> {
    for element in [
        "videorate",
        "videoscale",
        "videoconvert",
        "videocrop",
        "queue",
        "filesink",
    ] {
        require_element(element)?;
    }
    if let Some(element) = output_format.muxer_element() {
        require_element(element)?;
    }

    for element in codec.required_elements() {
        require_element(element)?;
    }

    for element in effect.required_elements() {
        require_element(element)?;
    }

    if audio != AudioMode::None {
        for element in [
            "pulsesrc",
            "audiomixer",
            "audiorate",
            "audioconvert",
            "audioresample",
        ] {
            require_element(element)?;
        }
        match output_format {
            OutputFormat::Mkv => require_element("opusenc")?,
            OutputFormat::Mp4 => {
                pipeline::aac_encoder_element()
                    .with_context(|| "MP4 audio requires an AAC encoder such as fdkaacenc")?;
            }
            OutputFormat::Gif => bail!("GIF output does not support audio"),
        }
    }

    Ok(())
}

fn require_portal_plugins(multiple: bool) -> Result<()> {
    for element in portal_required_elements(multiple) {
        require_element(element)?;
    }

    Ok(())
}

fn portal_required_elements(multiple: bool) -> &'static [&'static str] {
    if multiple {
        &["pipewiresrc", "videoflip", "compositor"]
    } else {
        &["pipewiresrc", "videoflip"]
    }
}

fn require_element(name: &str) -> Result<()> {
    gst::ElementFactory::find(name)
        .map(|_| ())
        .with_context(|| format!("missing GStreamer element `{name}`"))
}

fn has_element(name: &str) -> bool {
    gst::ElementFactory::find(name).is_some()
}

fn selected_output_format(args: &Args) -> Result<OutputFormat> {
    let inferred = args
        .output
        .as_deref()
        .and_then(|path| path.extension())
        .and_then(|extension| extension.to_str())
        .and_then(|extension| {
            if extension.eq_ignore_ascii_case("mp4") {
                Some(OutputFormat::Mp4)
            } else if extension.eq_ignore_ascii_case("mkv") {
                Some(OutputFormat::Mkv)
            } else if extension.eq_ignore_ascii_case("gif") {
                Some(OutputFormat::Gif)
            } else {
                None
            }
        });

    let selected = args
        .format
        .map(OutputFormat::from)
        .or(inferred)
        .unwrap_or(OutputFormat::Mp4);
    if let (Some(explicit), Some(inferred)) = (args.format.map(OutputFormat::from), inferred) {
        ensure!(
            explicit == inferred,
            "--format does not match --output extension .{}",
            inferred.extension()
        );
    }

    Ok(selected)
}

fn validate_format_options(args: &Args, output_format: OutputFormat) -> Result<()> {
    if output_format == OutputFormat::Gif {
        ensure!(
            args.audio == CliAudioMode::None,
            "GIF output does not support audio; choose MKV or MP4 for audio"
        );
        ensure!(
            !matches!(
                args.codec,
                CliVideoCodec::H264 | CliVideoCodec::Mjpeg | CliVideoCodec::Vp8
            ),
            "GIF output requires --codec auto"
        );
    }

    Ok(())
}

fn choose_codec(
    codec: CliVideoCodec,
    h264_encoder: CliH264Encoder,
    output_format: OutputFormat,
) -> Result<VideoCodec> {
    if output_format == OutputFormat::Gif {
        return if has_element("gifenc") {
            Ok(VideoCodec::Gif)
        } else {
            bail!("GIF output requires GStreamer element `gifenc`")
        };
    }

    if output_format == OutputFormat::Mp4 {
        match codec {
            CliVideoCodec::Auto | CliVideoCodec::H264 => {
                return Ok(VideoCodec::H264(select_h264_encoder(h264_encoder)?));
            }
            CliVideoCodec::Mjpeg | CliVideoCodec::Vp8 => bail!("MP4 output requires H.264 video"),
        }
    }

    match codec {
        CliVideoCodec::H264 => Ok(VideoCodec::H264(select_h264_encoder(h264_encoder)?)),
        CliVideoCodec::Mjpeg => Ok(VideoCodec::Mjpeg),
        CliVideoCodec::Vp8 => Ok(VideoCodec::Vp8),
        CliVideoCodec::Auto if has_element("jpegenc") => Ok(VideoCodec::Mjpeg),
        CliVideoCodec::Auto if h264_available(h264_encoder) => {
            Ok(VideoCodec::H264(select_h264_encoder(h264_encoder)?))
        }
        CliVideoCodec::Auto if has_element("vp8enc") => Ok(VideoCodec::Vp8),
        CliVideoCodec::Auto => bail!("no supported video encoder found"),
    }
}

fn h264_available(preference: CliH264Encoder) -> bool {
    select_h264_encoder(preference).is_ok()
}

fn select_h264_encoder(preference: CliH264Encoder) -> Result<H264Encoder> {
    match preference {
        CliH264Encoder::Auto => pipeline::hardware_h264_encoder()
            .or_else(|| has_element("openh264enc").then_some(H264Encoder::OpenH264))
            .filter(|_| has_element("h264parse"))
            .context(
                "H.264 output requires a hardware H.264 encoder or openh264enc plus h264parse",
            ),
        CliH264Encoder::Hardware => pipeline::hardware_h264_encoder()
            .filter(|_| has_element("h264parse"))
            .context("hardware H.264 requested but no supported hardware encoder was found"),
        CliH264Encoder::Software => {
            ensure!(
                has_element("openh264enc") && has_element("h264parse"),
                "software H.264 requires openh264enc and h264parse"
            );
            Ok(H264Encoder::OpenH264)
        }
    }
}

fn parse_area(raw: &str) -> Result<CaptureArea, String> {
    CaptureArea::from_str(raw).map_err(|err| err.to_string())
}

fn parse_bitrate(raw: &str) -> Result<u32, String> {
    let bitrate = raw
        .parse::<u32>()
        .map_err(|err| format!("invalid bitrate: {err}"))?;
    if bitrate > MAX_VIDEO_BITRATE {
        return Err(format!("bitrate must be <= {MAX_VIDEO_BITRATE}"));
    }

    Ok(bitrate)
}

fn parse_monitor_index(raw: &str) -> Result<usize, String> {
    let index = raw
        .parse::<usize>()
        .map_err(|err| format!("invalid monitor index: {err}"))?;
    if index == 0 {
        return Err("monitor index must be >= 1".to_string());
    }

    Ok(index)
}

fn parse_size(raw: &str) -> Result<OutputSize, String> {
    OutputSize::from_str(raw).map_err(|err| err.to_string())
}

fn parse_selection_screen(raw: &str) -> Result<SelectionScreen, String> {
    let Some((width, height)) = raw.split_once('x') else {
        return Err("expected WIDTHxHEIGHT".to_string());
    };
    let width = width
        .parse::<i32>()
        .map_err(|err| format!("invalid selector screen width: {err}"))?;
    let height = height
        .parse::<i32>()
        .map_err(|err| format!("invalid selector screen height: {err}"))?;
    if width <= 0 || height <= 0 {
        return Err("selector screen dimensions must be > 0".to_string());
    }

    Ok(SelectionScreen { width, height })
}

fn parse_inherited_fd(raw: &str) -> Result<i32, String> {
    let fd = raw
        .parse::<i32>()
        .map_err(|err| format!("invalid inherited portal fd: {err}"))?;
    if fd < 0 {
        return Err("inherited portal fd must be >= 0".to_string());
    }

    Ok(fd)
}

fn parse_portal_streams(raw: &str) -> Result<Vec<portal::Stream>, String> {
    if raw.trim().is_empty() {
        return Err("expected at least one portal stream".to_string());
    }

    raw.split(';').map(parse_portal_stream).collect()
}

fn parse_portal_stream(raw: &str) -> Result<portal::Stream, String> {
    let fields: Vec<_> = raw.split(',').collect();
    if fields.len() != 5 {
        return Err("expected NODE_ID,X,Y,WIDTH,HEIGHT".to_string());
    }
    let node_id = fields[0]
        .parse::<u32>()
        .map_err(|err| format!("invalid portal stream node id: {err}"))?;
    let position = parse_optional_pair(fields[1], fields[2], "position")?;
    let size = parse_optional_pair(fields[3], fields[4], "size")?;

    Ok(portal::Stream::from_parts(node_id, position, size))
}

fn parse_optional_pair(left: &str, right: &str, label: &str) -> Result<Option<(i32, i32)>, String> {
    match (left.is_empty(), right.is_empty()) {
        (true, true) => Ok(None),
        (false, false) => Ok(Some((
            left.parse::<i32>()
                .map_err(|err| format!("invalid portal stream {label} value: {err}"))?,
            right
                .parse::<i32>()
                .map_err(|err| format!("invalid portal stream {label} value: {err}"))?,
        ))),
        _ => Err(format!("portal stream {label} must include both values")),
    }
}

fn selected_scale(
    mode: RecordingMode,
    source_size: Option<(i32, i32)>,
    requested: Option<OutputSize>,
    no_scale: bool,
) -> Result<Option<OutputSize>> {
    if no_scale {
        return Ok(None);
    }

    if requested.is_some() {
        return Ok(requested);
    }

    if mode != RecordingMode::Screen {
        return Ok(None);
    }

    let default = OutputSize::new(1920, 1080)?;
    if source_size.is_some_and(|(width, height)| width <= default.width && height <= default.height)
    {
        return Ok(None);
    }

    Ok(Some(default))
}

fn selected_fps(format: OutputFormat, requested_fps: u32, explicit_fps: bool) -> u32 {
    if format == OutputFormat::Gif && !explicit_fps {
        DEFAULT_GIF_FPS
    } else {
        requested_fps
    }
}

fn selected_bitrate(
    codec: VideoCodec,
    requested_bitrate: u32,
    source_size: Option<(i32, i32)>,
    scale: Option<OutputSize>,
    fps: u32,
) -> u32 {
    if requested_bitrate != 0 {
        return requested_bitrate;
    }

    let Some((width, height)) = output_dimensions(source_size, scale) else {
        return 0;
    };
    match codec {
        VideoCodec::H264(_) => adaptive_bitrate(
            width,
            height,
            fps,
            14,
            ADAPTIVE_H264_MIN_BITRATE,
            ADAPTIVE_H264_MAX_BITRATE,
        ),
        VideoCodec::Vp8 => adaptive_bitrate(
            width,
            height,
            fps,
            10,
            ADAPTIVE_VP8_MIN_BITRATE,
            ADAPTIVE_VP8_MAX_BITRATE,
        ),
        VideoCodec::Mjpeg | VideoCodec::Gif => 0,
    }
}

fn output_dimensions(
    source_size: Option<(i32, i32)>,
    scale: Option<OutputSize>,
) -> Option<(i32, i32)> {
    if let Some(scale) = scale {
        return Some((scale.width, scale.height));
    }

    source_size
}

fn adaptive_bitrate(
    width: i32,
    height: i32,
    fps: u32,
    centibits_per_pixel_frame: u64,
    min: u32,
    max: u32,
) -> u32 {
    if width <= 0 || height <= 0 || fps == 0 {
        return min;
    }

    let pixels = u64::try_from(width)
        .unwrap_or(u64::MAX)
        .saturating_mul(u64::try_from(height).unwrap_or(u64::MAX));
    let bitrate = pixels
        .saturating_mul(u64::from(fps))
        .saturating_mul(centibits_per_pixel_frame)
        / 100;
    bitrate.clamp(u64::from(min), u64::from(max)) as u32
}

fn is_user_cancel(err: &anyhow::Error) -> bool {
    err.chain()
        .any(|cause| cause.to_string().contains("cancelled by the user"))
}

fn format_duration(duration: gst::ClockTime) -> String {
    let seconds = duration.seconds();
    let mins = seconds / 60;
    let secs = seconds % 60;
    format!("{mins:02}:{secs:02}")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn auto_backend_prefers_portal_on_wayland_without_selection() {
        assert_eq!(
            backend_order_for(Backend::Auto, RecordingMode::Screen, true, true),
            vec![Backend::Portal]
        );
    }

    #[test]
    fn auto_backend_does_not_fall_back_to_x11_on_wayland() {
        assert_eq!(
            backend_order_for(Backend::Auto, RecordingMode::Area, true, true),
            vec![Backend::Portal]
        );
    }

    #[test]
    fn auto_backend_prefers_x11_for_selection_without_wayland() {
        assert_eq!(
            backend_order_for(Backend::Auto, RecordingMode::Area, false, true),
            vec![Backend::X11, Backend::Portal]
        );
    }

    #[test]
    fn auto_backend_uses_portal_for_window_recording() {
        assert_eq!(
            backend_order_for(Backend::Auto, RecordingMode::Window, false, true),
            vec![Backend::Portal]
        );
    }

    #[test]
    fn explicit_backend_is_preserved_for_selection() {
        assert_eq!(
            backend_order_for(Backend::Portal, RecordingMode::Area, true, true),
            vec![Backend::Portal]
        );
        assert_eq!(
            backend_order_for(Backend::X11, RecordingMode::Area, true, true),
            vec![Backend::X11]
        );
    }

    #[test]
    fn recording_mode_infers_area_from_area_or_select() {
        let mut args = Args::try_parse_from(["oculo"]).unwrap();
        assert_eq!(recording_mode(&args), RecordingMode::Screen);

        args.area = Some(CaptureArea::new(1, 2, 3, 4).unwrap());
        assert_eq!(recording_mode(&args), RecordingMode::Area);

        args.area = None;
        args.select = true;
        assert_eq!(recording_mode(&args), RecordingMode::Area);
    }

    #[test]
    fn recording_mode_keeps_monitor_capture_as_screen() {
        let mut args = Args::try_parse_from(["oculo", "--monitor", "1"]).unwrap();
        args.area = Some(CaptureArea::new(0, 0, 640, 480).unwrap());

        assert_eq!(recording_mode(&args), RecordingMode::Screen);
        assert_eq!(
            selected_scale(
                recording_mode(&args),
                Some((640, 480)),
                args.scale,
                args.no_scale
            )
            .unwrap(),
            None
        );
    }

    #[test]
    fn prompt_only_when_no_recording_choice_was_given() {
        let mut args = Args::try_parse_from(["oculo"]).unwrap();
        assert!(should_prompt_recording_mode(&args, true));
        assert!(!should_prompt_recording_mode(&args, false));

        args.mode = Some(RecordingMode::Screen);
        assert!(!should_prompt_recording_mode(&args, true));
    }

    #[test]
    fn x11_monitor_prompt_requires_matching_backend_and_display() {
        assert!(should_offer_x11_monitor_prompt_for(
            Backend::Auto,
            true,
            false
        ));
        assert!(!should_offer_x11_monitor_prompt_for(
            Backend::Auto,
            true,
            true
        ));
        assert!(should_offer_x11_monitor_prompt_for(
            Backend::X11,
            true,
            true
        ));
        assert!(!should_offer_x11_monitor_prompt_for(
            Backend::X11,
            false,
            false
        ));
        assert!(!should_offer_x11_monitor_prompt_for(
            Backend::Portal,
            true,
            false
        ));
    }

    #[test]
    fn explicit_prompt_args_detect_long_and_short_options() {
        let flags = explicit_prompt_args_from(["--audio=mic", "-orecording.mkv", "--hide-pointer"]);

        assert!(flags.audio);
        assert!(flags.output_or_dir);
        assert!(flags.hide_pointer);
    }

    #[test]
    fn explicit_prompt_args_treat_dir_as_output_choice() {
        let flags = explicit_prompt_args_from(["--dir", "Videos"]);

        assert!(flags.output_or_dir);
    }

    #[test]
    fn explicit_fps_args_detect_long_options() {
        assert!(explicit_fps_arg_from(["--fps", "60"]));
        assert!(explicit_fps_arg_from(["--fps=60"]));
        assert!(!explicit_fps_arg_from(["--format", "gif"]));
    }

    #[test]
    fn prompted_output_path_trims_and_expands_home() {
        let home = Path::new("/home/tester");

        assert_eq!(
            prompt_path_from_input(" ~/Videos/capture ", Some(home)).unwrap(),
            PathBuf::from("/home/tester/Videos/capture")
        );
        assert_eq!(
            prompt_path_from_input("~", Some(home)).unwrap(),
            PathBuf::from("/home/tester")
        );
        assert_eq!(
            prompt_path_from_input("~other/capture", Some(home)).unwrap(),
            PathBuf::from("~other/capture")
        );
        assert_eq!(
            prompt_path_from_input("\"~/Videos/capture with spaces\"", Some(home)).unwrap(),
            PathBuf::from("/home/tester/Videos/capture with spaces")
        );
        assert_eq!(
            prompt_path_from_input("'/tmp/capture with spaces'", Some(home)).unwrap(),
            PathBuf::from("/tmp/capture with spaces")
        );
        assert!(prompt_path_from_input("  ", Some(home)).is_none());
    }

    #[test]
    fn startup_wait_honors_stop_signal() {
        gst::init().unwrap();
        let pipeline = gst::Pipeline::builder().build();
        let bus = pipeline.bus().unwrap();
        let (tx, rx) = mpsc::channel();
        tx.send(ControlMessage::Stop).unwrap();

        let err = wait_until_playing(&pipeline, &bus, &rx, Duration::from_secs(1)).unwrap_err();

        assert!(
            err.to_string()
                .contains("stopped before pipeline reached Playing")
        );
    }

    #[test]
    fn warning_occurrence_counts_by_source_and_message() {
        let mut counts = WarningCounts::new();

        assert_eq!(
            warning_occurrence(&mut counts, "source", "warning"),
            WarningOccurrence::First
        );
        assert_eq!(
            warning_occurrence(&mut counts, "source", "warning"),
            WarningOccurrence::Second
        );
        assert_eq!(
            warning_occurrence(&mut counts, "source", "warning"),
            WarningOccurrence::Suppressed
        );
        assert_eq!(
            warning_occurrence(&mut counts, "other", "warning"),
            WarningOccurrence::First
        );
        assert_eq!(
            warning_occurrence(&mut counts, "source", "other"),
            WarningOccurrence::First
        );
    }

    #[test]
    fn warning_occurrence_caps_distinct_messages() {
        let mut counts = WarningCounts::new();

        for index in 0..WARNING_COUNT_CAPACITY {
            assert_eq!(
                warning_occurrence(&mut counts, &format!("source-{index}"), "warning"),
                WarningOccurrence::First
            );
        }

        assert_eq!(counts.counts.len(), WARNING_COUNT_CAPACITY);
        assert_eq!(
            warning_occurrence(&mut counts, "extra-source", "warning"),
            WarningOccurrence::CapacityReached
        );
        assert_eq!(
            warning_occurrence(&mut counts, "another-extra-source", "warning"),
            WarningOccurrence::Suppressed
        );
        assert_eq!(counts.counts.len(), WARNING_COUNT_CAPACITY);
    }

    #[test]
    fn mode_area_enables_selector_when_no_area_was_given() {
        let mut args =
            Args::try_parse_from(["oculo", "--mode", "area", "--backend", "x11"]).unwrap();
        normalize_recording_request(&mut args).unwrap();

        assert!(args.select);
    }

    #[test]
    fn wayland_selection_requires_reachable_x11_overlay() {
        assert!(validate_selection_environment(true, true, false).is_err());
        assert!(validate_selection_environment(true, true, true).is_ok());
        assert!(validate_selection_environment(true, false, false).is_ok());
        assert!(validate_selection_environment(false, true, false).is_ok());
    }

    #[test]
    fn wayland_auto_selection_keeps_portal_recording_when_x11_overlay_is_available() {
        let mut args = Args::try_parse_from(["oculo", "--select"]).unwrap();

        normalize_selection_backend(&mut args, true, true).unwrap();

        assert_eq!(args.backend, Backend::Auto);
    }

    #[test]
    fn wayland_auto_selection_still_errors_without_x11() {
        let mut args = Args::try_parse_from(["oculo", "--select"]).unwrap();

        assert!(normalize_selection_backend(&mut args, true, false).is_err());
        assert_eq!(args.backend, Backend::Auto);
    }

    #[test]
    fn wayland_portal_selection_is_valid_with_x11_overlay() {
        let mut args = Args::try_parse_from(["oculo", "--select", "--backend", "portal"]).unwrap();

        assert!(normalize_selection_backend(&mut args, true, true).is_ok());
        assert_eq!(args.backend, Backend::Portal);
    }

    #[test]
    fn multiple_forces_portal_backend_when_auto() {
        let mut args = Args::try_parse_from(["oculo", "--multiple"]).unwrap();
        normalize_recording_request(&mut args).unwrap();

        assert_eq!(args.backend, Backend::Portal);
    }

    #[test]
    fn monitor_normalization_does_not_resolve_x11_area() {
        let mut args = Args::try_parse_from(["oculo", "--monitor", "1"]).unwrap();
        normalize_recording_request(&mut args).unwrap();

        assert_eq!(args.backend, Backend::X11);
        assert_eq!(recording_mode(&args), RecordingMode::Screen);
        assert!(args.area.is_none());
    }

    #[test]
    fn reject_contradictory_recording_modes() {
        let args = Args::try_parse_from(["oculo", "--mode", "window", "--select"]).unwrap();

        assert!(validate_recording_request(&args).is_err());
    }

    #[test]
    fn reject_multiple_with_x11_backend() {
        let args = Args::try_parse_from(["oculo", "--multiple", "--backend", "x11"]).unwrap();

        assert!(validate_recording_request(&args).is_err());
    }

    #[test]
    fn reject_multiple_with_monitor() {
        let args = Args::try_parse_from(["oculo", "--multiple", "--monitor", "1"]).unwrap();

        assert!(validate_recording_request(&args).is_err());
    }

    #[test]
    fn reject_window_mode_with_x11_backend() {
        let args = Args::try_parse_from(["oculo", "--mode", "window", "--backend", "x11"]).unwrap();

        assert!(validate_recording_request(&args).is_err());
    }

    #[test]
    fn portal_source_type_tracks_recording_mode() {
        assert_eq!(
            portal_source_type(RecordingMode::Screen),
            portal::SourceType::MONITOR
        );
        assert_eq!(
            portal_source_type(RecordingMode::Area),
            portal::SourceType::MONITOR
        );
        assert_eq!(
            portal_source_type(RecordingMode::Window),
            portal::SourceType::WINDOW
        );
    }

    #[test]
    fn selected_area_is_trimmed_to_even_dimensions_for_subsampled_codecs() {
        let area = CaptureArea::new(11, 13, 101, 99).unwrap();
        let area =
            normalize_selected_area_for_codec(Some(area), VideoCodec::H264(H264Encoder::OpenH264))
                .unwrap()
                .unwrap();

        assert_eq!(area, CaptureArea::new(11, 13, 100, 98).unwrap());

        let area = normalize_selected_area_for_codec(
            Some(CaptureArea::new(11, 13, 101, 99).unwrap()),
            VideoCodec::Mjpeg,
        )
        .unwrap();

        assert_eq!(area, Some(CaptureArea::new(11, 13, 101, 99).unwrap()));
    }

    #[test]
    fn selected_area_too_small_for_subsampled_codec_is_rejected() {
        let err = normalize_selected_area_for_codec(
            Some(CaptureArea::new(0, 0, 1, 4).unwrap()),
            VideoCodec::Vp8,
        )
        .unwrap_err()
        .to_string();

        assert!(err.contains("at least 2x2"));
    }

    #[test]
    fn default_scale_only_applies_to_full_screen_recording() {
        assert_eq!(
            selected_scale(RecordingMode::Screen, None, None, false).unwrap(),
            Some(OutputSize {
                width: 1920,
                height: 1080
            })
        );
        assert_eq!(
            selected_scale(RecordingMode::Screen, Some((3840, 2160)), None, false).unwrap(),
            Some(OutputSize {
                width: 1920,
                height: 1080
            })
        );
        assert_eq!(
            selected_scale(RecordingMode::Screen, Some((1280, 720)), None, false).unwrap(),
            None
        );
        assert_eq!(
            selected_scale(RecordingMode::Window, None, None, false).unwrap(),
            None
        );
        assert_eq!(
            selected_scale(RecordingMode::Area, None, None, false).unwrap(),
            None
        );
    }

    #[test]
    fn gif_defaults_to_lower_fps_unless_explicit() {
        assert_eq!(selected_fps(OutputFormat::Gif, 30, false), DEFAULT_GIF_FPS);
        assert_eq!(selected_fps(OutputFormat::Gif, 30, true), 30);
        assert_eq!(selected_fps(OutputFormat::Mp4, 30, false), 30);
    }

    #[test]
    fn selected_bitrate_preserves_explicit_user_value() {
        assert_eq!(
            selected_bitrate(
                VideoCodec::H264(H264Encoder::OpenH264),
                4_200_000,
                Some((1920, 1080)),
                None,
                30,
            ),
            4_200_000
        );
    }

    #[test]
    fn selected_bitrate_adapts_to_effective_output_size() {
        assert_eq!(
            selected_bitrate(
                VideoCodec::H264(H264Encoder::OpenH264),
                0,
                Some((160, 120)),
                None,
                30,
            ),
            ADAPTIVE_H264_MIN_BITRATE
        );
        assert_eq!(
            selected_bitrate(
                VideoCodec::H264(H264Encoder::OpenH264),
                0,
                Some((3840, 2160)),
                Some(OutputSize {
                    width: 1920,
                    height: 1080,
                }),
                30,
            ),
            8_709_120
        );
        assert_eq!(
            selected_bitrate(VideoCodec::Vp8, 0, Some((3840, 2160)), None, 60,),
            ADAPTIVE_VP8_MAX_BITRATE
        );
        assert_eq!(
            selected_bitrate(VideoCodec::Mjpeg, 0, Some((1920, 1080)), None, 30),
            0
        );
    }

    #[test]
    fn portal_required_elements_include_multistream_compositor() {
        assert_eq!(
            portal_required_elements(false),
            ["pipewiresrc", "videoflip"]
        );
        assert_eq!(
            portal_required_elements(true),
            ["pipewiresrc", "videoflip", "compositor"]
        );
    }

    #[test]
    fn effect_cli_choice_maps_to_pipeline_effect() {
        let args = Args::try_parse_from(["oculo", "--effect", "neon"]).unwrap();
        assert_eq!(VideoEffect::from(args.effect), VideoEffect::Neon);

        let args = Args::try_parse_from(["oculo", "--effect", "heatmap"]).unwrap();
        assert_eq!(VideoEffect::from(args.effect), VideoEffect::Heatmap);

        let args = Args::try_parse_from(["oculo", "--effect", "glitch"]).unwrap();
        assert_eq!(VideoEffect::from(args.effect), VideoEffect::Glitch);

        let args = Args::try_parse_from(["oculo", "--effect", "ripple"]).unwrap();
        assert_eq!(VideoEffect::from(args.effect), VideoEffect::Ripple);

        let args = Args::try_parse_from(["oculo", "--effect", "glass"]).unwrap();
        assert_eq!(VideoEffect::from(args.effect), VideoEffect::Glass);

        let args = Args::try_parse_from(["oculo", "--effect", "crt"]).unwrap();
        assert_eq!(VideoEffect::from(args.effect), VideoEffect::Crt);

        let args = Args::try_parse_from(["oculo", "--effect", "holo"]).unwrap();
        assert_eq!(VideoEffect::from(args.effect), VideoEffect::Holo);

        let args = Args::try_parse_from(["oculo", "--effect", "vapor"]).unwrap();
        assert_eq!(VideoEffect::from(args.effect), VideoEffect::Vapor);

        let args = Args::try_parse_from(["oculo", "--effect", "fracture"]).unwrap();
        assert_eq!(VideoEffect::from(args.effect), VideoEffect::Fracture);

        let args = Args::try_parse_from(["oculo", "--effect", "prism"]).unwrap();
        assert_eq!(VideoEffect::from(args.effect), VideoEffect::Prism);

        let args = Args::try_parse_from(["oculo", "--effect", "aurora"]).unwrap();
        assert_eq!(VideoEffect::from(args.effect), VideoEffect::Aurora);

        let args = Args::try_parse_from(["oculo", "--effect", "kaleido"]).unwrap();
        assert_eq!(VideoEffect::from(args.effect), VideoEffect::Kaleido);

        let args = Args::try_parse_from(["oculo"]).unwrap();
        assert_eq!(VideoEffect::from(args.effect), VideoEffect::None);
    }

    #[test]
    fn glsl_effects_advertise_required_plugins() {
        assert!(VideoEffect::None.required_elements().is_empty());
        assert_eq!(
            VideoEffect::Neon.required_elements(),
            [
                "glupload",
                "glshader",
                "gldownload",
                "videoconvert",
                "capsfilter"
            ]
        );
    }

    #[test]
    fn parse_monitor_index_rejects_zero() {
        assert_eq!(parse_monitor_index("1").unwrap(), 1);
        assert!(parse_monitor_index("0").is_err());
    }

    #[test]
    fn parse_selection_screen_rejects_invalid_dimensions() {
        assert_eq!(
            parse_selection_screen("1920x1080").unwrap(),
            SelectionScreen {
                width: 1920,
                height: 1080,
            }
        );
        assert!(parse_selection_screen("1920").is_err());
        assert!(parse_selection_screen("0x1080").is_err());
        assert!(parse_selection_screen("1920x0").is_err());
    }

    #[test]
    fn parse_portal_streams_accepts_gui_handoff_metadata() {
        let streams = parse_portal_streams("63,1920,0,1920,1080").unwrap();

        assert_eq!(streams.len(), 1);
        assert_eq!(streams[0].node_id(), 63);
        assert_eq!(streams[0].position(), Some((1920, 0)));
        assert_eq!(streams[0].size(), Some((1920, 1080)));
    }

    #[test]
    fn selected_area_keeps_global_portal_coordinates_when_stream_contains_selection() {
        let area = CaptureArea::new(2200, 120, 400, 300).unwrap();
        let localized = Some((
            CaptureArea::new(280, 120, 400, 300).unwrap(),
            SelectionScreen {
                width: 1920,
                height: 1080,
            },
        ));
        let stream = portal::Stream::from_parts(63, Some((0, 0)), Some((3840, 1080)));

        assert_eq!(
            map_selected_area_to_portal_streams_with_localized(
                area,
                SelectionScreen {
                    width: 3840,
                    height: 1080,
                },
                &[stream],
                localized,
            )
            .unwrap(),
            area,
        );
    }

    #[test]
    fn selected_area_uses_localized_coordinates_when_global_area_is_outside_stream() {
        let area = CaptureArea::new(2200, 120, 400, 300).unwrap();
        let local_area = CaptureArea::new(280, 120, 400, 300).unwrap();
        let localized = Some((
            local_area,
            SelectionScreen {
                width: 1920,
                height: 1080,
            },
        ));
        let stream = portal::Stream::from_parts(63, Some((0, 0)), Some((1920, 1080)));

        assert_eq!(
            map_selected_area_to_portal_streams_with_localized(
                area,
                SelectionScreen {
                    width: 3840,
                    height: 1080,
                },
                &[stream],
                localized,
            )
            .unwrap(),
            local_area,
        );
    }

    #[test]
    fn selected_area_is_localized_to_containing_monitor() {
        let monitors = vec![
            monitor::Monitor {
                index: 1,
                name: "left".to_string(),
                x: 0,
                y: 0,
                width: 1920,
                height: 1080,
                primary: true,
            },
            monitor::Monitor {
                index: 2,
                name: "right".to_string(),
                x: 1920,
                y: 0,
                width: 1920,
                height: 1080,
                primary: false,
            },
        ];
        let area = CaptureArea::new(2200, 120, 400, 300).unwrap();
        let (local_area, local_screen) = localize_selection_to_monitor(area, &monitors).unwrap();

        assert_eq!(local_area, CaptureArea::new(280, 120, 400, 300).unwrap());
        assert_eq!(local_screen.width, 1920);
        assert_eq!(local_screen.height, 1080);
    }

    #[test]
    fn selected_area_crossing_monitors_is_not_localized() {
        let monitors = vec![
            monitor::Monitor {
                index: 1,
                name: "left".to_string(),
                x: 0,
                y: 0,
                width: 1920,
                height: 1080,
                primary: true,
            },
            monitor::Monitor {
                index: 2,
                name: "right".to_string(),
                x: 1920,
                y: 0,
                width: 1920,
                height: 1080,
                primary: false,
            },
        ];
        let area = CaptureArea::new(1800, 100, 300, 200).unwrap();

        assert!(localize_selection_to_monitor(area, &monitors).is_none());
    }

    #[test]
    fn output_format_defaults_to_mp4_and_infers_output_extension() {
        let args = Args::try_parse_from(["oculo"]).unwrap();
        assert_eq!(selected_output_format(&args).unwrap(), OutputFormat::Mp4);

        let args = Args::try_parse_from(["oculo", "--output", "capture.mp4"]).unwrap();
        assert_eq!(selected_output_format(&args).unwrap(), OutputFormat::Mp4);

        let args = Args::try_parse_from(["oculo", "--output", "capture.gif"]).unwrap();
        assert_eq!(selected_output_format(&args).unwrap(), OutputFormat::Gif);

        let args =
            Args::try_parse_from(["oculo", "--format", "mp4", "--output", "capture.mkv"]).unwrap();
        assert!(selected_output_format(&args).is_err());
    }

    #[test]
    fn mp4_rejects_non_h264_codecs() {
        assert!(
            choose_codec(
                CliVideoCodec::Mjpeg,
                CliH264Encoder::Auto,
                OutputFormat::Mp4,
            )
            .is_err()
        );
        assert!(
            choose_codec(CliVideoCodec::Vp8, CliH264Encoder::Auto, OutputFormat::Mp4,).is_err()
        );
    }

    #[test]
    fn gif_rejects_audio_and_explicit_video_codecs() {
        let args = Args::try_parse_from(["oculo", "--format", "gif", "--audio", "mic"]).unwrap();
        assert!(validate_format_options(&args, OutputFormat::Gif).is_err());

        let args = Args::try_parse_from(["oculo", "--format", "gif", "--codec", "h264"]).unwrap();
        assert!(validate_format_options(&args, OutputFormat::Gif).is_err());
    }

    #[test]
    fn reject_zero_max_duration_at_parse_time() {
        assert!(Args::try_parse_from(["oculo", "--max-duration", "0"]).is_err());
    }

    #[test]
    fn reject_zero_eos_timeout_at_parse_time() {
        assert!(Args::try_parse_from(["oculo", "--eos-timeout", "0"]).is_err());
    }

    #[test]
    fn accept_zero_bitrate_as_codec_default() {
        let args = Args::try_parse_from(["oculo", "--bitrate", "0"]).unwrap();

        assert_eq!(args.bitrate, 0);
    }

    #[test]
    fn reject_bitrate_that_cannot_fit_all_encoders() {
        let too_large = (u64::from(MAX_VIDEO_BITRATE) + 1).to_string();

        assert!(Args::try_parse_from(["oculo", "--bitrate", &too_large]).is_err());
    }
}
