use std::{
    fmt,
    os::fd::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd},
    path::{Path, PathBuf},
    str::FromStr,
};

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

use crate::portal::Stream;

const AUDIO_SAMPLE_RATE: i32 = 48_000;
const DEFAULT_H264_BITRATE: u32 = 12_000_000;
const DEFAULT_VP8_BITRATE: u32 = 8_000_000;
const RAW_VIDEO_QUEUE_MAX_BUFFERS: u32 = 2;
const RAW_AUDIO_QUEUE_MAX_TIME: u64 = 2_000_000_000;
const MUX_QUEUE_MAX_TIME: u64 = 3_000_000_000;
const MAX_VIDEO_PROCESSING_THREADS: u32 = 8;
const MAX_PORTAL_STREAMS: usize = 16;
const MAX_PORTAL_STREAM_DIMENSION: i32 = 32_768;
const MAX_PORTAL_CAPTURE_PIXELS: i64 = 268_435_456;

const NEON_FRAGMENT_SHADER: &str = r#"
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D tex;

float luma(vec3 color) {
    return dot(color, vec3(0.2126, 0.7152, 0.0722));
}

void main() {
    vec2 uv = v_texcoord;
    vec2 px = vec2(1.0 / 960.0, 1.0 / 540.0);

    vec3 color = texture2D(tex, uv).rgb;
    float gx = luma(texture2D(tex, uv + vec2(px.x, 0.0)).rgb)
             - luma(texture2D(tex, uv - vec2(px.x, 0.0)).rgb);
    float gy = luma(texture2D(tex, uv + vec2(0.0, px.y)).rgb)
             - luma(texture2D(tex, uv - vec2(0.0, px.y)).rgb);
    float edge = smoothstep(0.045, 0.22, length(vec2(gx, gy)));
    float scan = 0.86 + 0.14 * sin(uv.y * 1600.0);

    vec3 cyan = vec3(0.0, 0.92, 1.0) * edge;
    vec3 magenta = vec3(1.0, 0.05, 0.85) * edge * edge;
    vec3 glow = cyan + magenta;
    vec3 lifted = pow(color, vec3(0.82));
    vec3 final_color = mix(lifted * 0.72, lifted + glow * 1.45, clamp(edge * 1.6, 0.0, 1.0));

    gl_FragColor = vec4(clamp(final_color * scan, 0.0, 1.0), 1.0);
}
"#;

const HEATMAP_FRAGMENT_SHADER: &str = r#"
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D tex;

float luma(vec3 color) {
    return dot(color, vec3(0.2126, 0.7152, 0.0722));
}

void main() {
    vec2 uv = v_texcoord;
    vec3 color = texture2D(tex, uv).rgb;
    float t = smoothstep(0.02, 0.98, luma(color));

    vec3 deep = vec3(0.02, 0.02, 0.16);
    vec3 blue = vec3(0.0, 0.38, 1.0);
    vec3 gold = vec3(1.0, 0.74, 0.05);
    vec3 white = vec3(1.0, 0.96, 0.82);

    vec3 cold_to_mid = mix(deep, blue, smoothstep(0.00, 0.42, t));
    vec3 mid_to_hot = mix(gold, white, smoothstep(0.58, 1.00, t));
    vec3 heat = mix(cold_to_mid, mid_to_hot, smoothstep(0.34, 0.72, t));
    float vignette = smoothstep(0.82, 0.18, distance(uv, vec2(0.5)));

    gl_FragColor = vec4(clamp(heat * (0.68 + 0.44 * vignette), 0.0, 1.0), 1.0);
}
"#;

const GLITCH_FRAGMENT_SHADER: &str = r#"
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D tex;

float hash(vec2 value) {
    return fract(sin(dot(value, vec2(12.9898, 78.233))) * 43758.5453);
}

void main() {
    vec2 uv = v_texcoord;
    float band = sin(uv.y * 82.0) + 0.5 * sin(uv.y * 233.0);
    float block = step(0.965, hash(floor(uv * vec2(18.0, 10.0))));
    float shift = band * 0.0028 + block * 0.018;

    float r = texture2D(tex, uv + vec2(shift + 0.004, 0.0)).r;
    float g = texture2D(tex, uv + vec2(shift * 0.25, 0.0)).g;
    float b = texture2D(tex, uv - vec2(shift + 0.004, 0.0)).b;
    vec3 color = vec3(r, g, b);

    float scanline = step(0.88, fract(uv.y * 130.0));
    color += vec3(0.10, -0.02, 0.16) * scanline;
    color = mix(color, color.brg, block * 0.24);

    gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}
"#;

const RIPPLE_FRAGMENT_SHADER: &str = r#"
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D tex;

void main() {
    vec2 uv = v_texcoord;
    vec2 center = vec2(0.5, 0.5);
    vec2 delta = uv - center;
    float radius = length(delta);
    vec2 direction = delta / max(radius, 0.0008);

    float ring = sin(radius * 72.0);
    float falloff = exp(-radius * 3.35);
    float wave = ring * falloff;
    vec2 refract_uv = clamp(uv + direction * wave * 0.014, vec2(0.001), vec2(0.999));

    vec3 color = texture2D(tex, refract_uv).rgb;
    float caustic = pow(max(0.0, wave), 2.0) * 0.42;
    float trough = pow(max(0.0, -wave), 2.0) * 0.16;
    color += vec3(0.06, 0.20, 0.28) * caustic;
    color -= vec3(0.02, 0.04, 0.06) * trough;
    color *= 0.94 + 0.06 * smoothstep(0.62, 0.0, radius);

    gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}
"#;

const GLASS_FRAGMENT_SHADER: &str = r#"
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D tex;

float hash(vec2 value) {
    return fract(sin(dot(value, vec2(127.1, 311.7))) * 43758.5453);
}

void main() {
    vec2 uv = v_texcoord;
    vec2 cell = floor(uv * vec2(42.0, 24.0));
    float n0 = hash(cell);
    float n1 = hash(cell + vec2(7.0, 19.0));
    vec2 local = fract(uv * vec2(42.0, 24.0)) - 0.5;
    vec2 shard = (vec2(n0, n1) - 0.5) * 0.018;
    vec2 frost = vec2(
        sin((uv.y + n0) * 92.0) + sin((uv.x + n1) * 31.0),
        cos((uv.x + n1) * 86.0) + cos((uv.y + n0) * 27.0)
    ) * 0.0038;
    vec2 lens = local * (0.010 + 0.006 * n0);
    vec2 bend = shard + frost - lens;

    float r = texture2D(tex, clamp(uv + bend * 1.55, vec2(0.001), vec2(0.999))).r;
    float g = texture2D(tex, clamp(uv + bend * 0.78, vec2(0.001), vec2(0.999))).g;
    float b = texture2D(tex, clamp(uv - bend * 1.35, vec2(0.001), vec2(0.999))).b;
    vec3 color = vec3(r, g, b);

    float bevel = smoothstep(0.43, 0.50, abs(local.x))
                + smoothstep(0.43, 0.50, abs(local.y));
    float highlight = pow(max(0.0, 1.0 - length(local * vec2(1.4, 2.0))), 5.0);
    color = mix(color, vec3(dot(color, vec3(0.299, 0.587, 0.114))), 0.08);
    color += vec3(0.16, 0.22, 0.30) * clamp(bevel, 0.0, 1.0);
    color += vec3(0.22, 0.34, 0.42) * highlight * 0.38;
    color *= 0.96 + 0.06 * smoothstep(0.62, 0.0, distance(uv, vec2(0.5)));

    gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}
"#;

const CRT_FRAGMENT_SHADER: &str = r#"
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D tex;

void main() {
    vec2 centered = v_texcoord * 2.0 - 1.0;
    float r2 = dot(centered, centered);
    vec2 uv = centered * (1.0 + 0.085 * r2) * 0.5 + 0.5;

    if (uv.x < 0.0 || uv.y < 0.0 || uv.x > 1.0 || uv.y > 1.0) {
        gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
        return;
    }

    float red = texture2D(tex, clamp(uv + vec2(0.0028, 0.0), vec2(0.001), vec2(0.999))).r;
    float green = texture2D(tex, uv).g;
    float blue = texture2D(tex, clamp(uv - vec2(0.0028, 0.0), vec2(0.001), vec2(0.999))).b;
    vec3 color = vec3(red, green, blue);

    float scan = 0.72 + 0.28 * sin(uv.y * 1280.0);
    float mask = 0.88 + 0.12 * sin(uv.x * 1880.0);
    float vignette = smoothstep(1.18, 0.25, r2);
    color *= scan * mask * vignette;
    color += vec3(0.015, 0.055, 0.035);

    gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}
"#;

const HOLO_FRAGMENT_SHADER: &str = r#"
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D tex;

float luma(vec3 color) {
    return dot(color, vec3(0.2126, 0.7152, 0.0722));
}

void main() {
    vec2 uv = v_texcoord;
    vec2 px = vec2(1.0 / 960.0, 1.0 / 540.0);
    vec3 base = texture2D(tex, uv).rgb;
    float gx = luma(texture2D(tex, uv + vec2(px.x, 0.0)).rgb)
             - luma(texture2D(tex, uv - vec2(px.x, 0.0)).rgb);
    float gy = luma(texture2D(tex, uv + vec2(0.0, px.y)).rgb)
             - luma(texture2D(tex, uv - vec2(0.0, px.y)).rgb);
    float edge = smoothstep(0.025, 0.18, length(vec2(gx, gy)));
    float interference = 0.5 + 0.5 * sin((uv.x + uv.y) * 210.0 + sin(uv.y * 32.0) * 2.0);
    vec3 prism = vec3(
        texture2D(tex, clamp(uv + vec2(0.0035, 0.0), vec2(0.001), vec2(0.999))).r,
        texture2D(tex, uv).g,
        texture2D(tex, clamp(uv - vec2(0.0035, 0.0), vec2(0.001), vec2(0.999))).b
    );
    vec3 holo = mix(vec3(0.0, 0.75, 1.0), vec3(1.0, 0.15, 0.92), interference);
    vec3 color = mix(base * 0.54 + prism * 0.34, prism + holo * edge * 1.35, 0.58);
    color += holo * pow(interference, 6.0) * 0.18;
    gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}
"#;

const VAPOR_FRAGMENT_SHADER: &str = r#"
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D tex;

void main() {
    vec2 uv = v_texcoord;
    vec2 centered = uv - vec2(0.5);
    float radius = length(centered);
    float angle = atan(centered.y, centered.x);
    float swirl = sin(radius * 34.0 + angle * 5.0) * exp(-radius * 2.1);
    vec2 flow = vec2(cos(angle + swirl), sin(angle + swirl)) * swirl * 0.018;
    vec2 smoke = vec2(
        sin(uv.y * 28.0 + uv.x * 8.0),
        cos(uv.x * 24.0 - uv.y * 7.0)
    ) * 0.006;
    vec3 color = texture2D(tex, clamp(uv + flow + smoke, vec2(0.001), vec2(0.999))).rgb;
    float glow = smoothstep(0.74, 0.12, radius);
    vec3 vapor = mix(vec3(0.06, 0.02, 0.16), vec3(0.20, 0.95, 1.0), glow);
    color = mix(color * vec3(0.78, 0.74, 1.0), color + vapor * 0.42, glow);
    color += vec3(0.45, 0.0, 0.72) * pow(max(0.0, swirl), 2.0);
    gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}
"#;

const FRACTURE_FRAGMENT_SHADER: &str = r#"
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D tex;

float hash(vec2 value) {
    return fract(sin(dot(value, vec2(41.7, 289.3))) * 53127.9123);
}

void main() {
    vec2 uv = v_texcoord;
    vec2 grid = vec2(18.0, 11.0);
    vec2 cell = floor(uv * grid);
    vec2 local = fract(uv * grid) - 0.5;
    float h0 = hash(cell);
    float h1 = hash(cell + vec2(3.0, 17.0));
    vec2 fracture = (vec2(h0, h1) - 0.5) * 0.030;
    float crack = smoothstep(0.44, 0.50, abs(local.x + local.y * sign(h0 - 0.5)))
                + smoothstep(0.46, 0.50, abs(local.x - local.y * sign(h1 - 0.5)));
    float r = texture2D(tex, clamp(uv + fracture * 1.15, vec2(0.001), vec2(0.999))).r;
    float g = texture2D(tex, clamp(uv + fracture * 0.45, vec2(0.001), vec2(0.999))).g;
    float b = texture2D(tex, clamp(uv - fracture * 0.95, vec2(0.001), vec2(0.999))).b;
    vec3 color = vec3(r, g, b);
    color += vec3(0.40, 0.82, 1.0) * clamp(crack, 0.0, 1.0) * 0.32;
    color *= 0.92 + 0.12 * h0;
    gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}
"#;

#[derive(Debug)]
pub struct PipelineOptions {
    pub video_source: VideoSource,
    pub temp_path: PathBuf,
    pub output_format: OutputFormat,
    pub fps: u32,
    pub bitrate: u32,
    pub codec: VideoCodec,
    pub effect: VideoEffect,
    pub audio: AudioMode,
    pub area: Option<CaptureArea>,
    pub scale: Option<OutputSize>,
}

#[derive(Debug)]
pub enum VideoSource {
    Portal { fd: OwnedFd, streams: Vec<Stream> },
    X11 { show_pointer: bool },
}

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

impl OutputFormat {
    pub fn extension(self) -> &'static str {
        match self {
            Self::Mkv => "mkv",
            Self::Mp4 => "mp4",
            Self::Gif => "gif",
        }
    }

    pub fn muxer_element(self) -> Option<&'static str> {
        match self {
            Self::Mkv => Some("matroskamux"),
            Self::Mp4 => Some("mp4mux"),
            Self::Gif => None,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VideoCodec {
    H264(H264Encoder),
    Mjpeg,
    Vp8,
    Gif,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VideoEffect {
    None,
    Neon,
    Heatmap,
    Glitch,
    Ripple,
    Glass,
    Crt,
    Holo,
    Vapor,
    Fracture,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum H264Encoder {
    OpenH264,
    Va,
    Vaapi,
    Nv,
}

impl H264Encoder {
    pub fn element(self) -> &'static str {
        match self {
            Self::OpenH264 => "openh264enc",
            Self::Va => "vah264enc",
            Self::Vaapi => "vaapih264enc",
            Self::Nv => "nvh264enc",
        }
    }

    fn is_hardware(self) -> bool {
        !matches!(self, Self::OpenH264)
    }
}

impl VideoCodec {
    pub fn required_elements(self) -> Vec<&'static str> {
        match self {
            Self::H264(encoder) => vec![encoder.element(), "h264parse", "capsfilter"],
            Self::Mjpeg => vec!["jpegenc"],
            Self::Vp8 => vec!["vp8enc"],
            Self::Gif => vec!["gifenc"],
        }
    }
}

impl VideoEffect {
    pub fn required_elements(self) -> &'static [&'static str] {
        match self {
            Self::None => &[],
            Self::Neon
            | Self::Heatmap
            | Self::Glitch
            | Self::Ripple
            | Self::Glass
            | Self::Crt
            | Self::Holo
            | Self::Vapor
            | Self::Fracture => &[
                "glupload",
                "glshader",
                "gldownload",
                "videoconvert",
                "capsfilter",
            ],
        }
    }

    fn fragment_shader(self) -> Option<&'static str> {
        match self {
            Self::None => None,
            Self::Neon => Some(NEON_FRAGMENT_SHADER),
            Self::Heatmap => Some(HEATMAP_FRAGMENT_SHADER),
            Self::Glitch => Some(GLITCH_FRAGMENT_SHADER),
            Self::Ripple => Some(RIPPLE_FRAGMENT_SHADER),
            Self::Glass => Some(GLASS_FRAGMENT_SHADER),
            Self::Crt => Some(CRT_FRAGMENT_SHADER),
            Self::Holo => Some(HOLO_FRAGMENT_SHADER),
            Self::Vapor => Some(VAPOR_FRAGMENT_SHADER),
            Self::Fracture => Some(FRACTURE_FRAGMENT_SHADER),
        }
    }

    fn name(self) -> &'static str {
        match self {
            Self::None => "none",
            Self::Neon => "neon",
            Self::Heatmap => "heatmap",
            Self::Glitch => "glitch",
            Self::Ripple => "ripple",
            Self::Glass => "glass",
            Self::Crt => "crt",
            Self::Holo => "holo",
            Self::Vapor => "vapor",
            Self::Fracture => "fracture",
        }
    }
}

impl fmt::Display for VideoEffect {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.name())
    }
}

pub fn hardware_h264_encoder() -> Option<H264Encoder> {
    [H264Encoder::Va, H264Encoder::Vaapi, H264Encoder::Nv]
        .into_iter()
        .find(|encoder| gst::ElementFactory::find(encoder.element()).is_some())
}

pub fn aac_encoder_element() -> Option<&'static str> {
    ["fdkaacenc", "voaacenc", "avenc_aac"]
        .into_iter()
        .find(|name| gst::ElementFactory::find(name).is_some())
}

impl fmt::Display for VideoCodec {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::H264(encoder) if encoder.is_hardware() => {
                write!(f, "h264 hardware ({})", encoder.element())
            }
            Self::H264(encoder) => write!(f, "h264 ({})", encoder.element()),
            Self::Mjpeg => f.write_str("mjpeg"),
            Self::Vp8 => f.write_str("vp8"),
            Self::Gif => f.write_str("gif"),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CaptureArea {
    pub x: i32,
    pub y: i32,
    pub width: i32,
    pub height: i32,
}

impl CaptureArea {
    pub fn new(x: i32, y: i32, width: i32, height: i32) -> Result<Self> {
        ensure!(x >= 0, "selection x must be >= 0");
        ensure!(y >= 0, "selection y must be >= 0");
        ensure!(width > 0, "selection width must be > 0");
        ensure!(height > 0, "selection height must be > 0");
        ensure!(
            x <= i32::MAX - width,
            "selection right edge exceeds i32 range"
        );
        ensure!(
            y <= i32::MAX - height,
            "selection bottom edge exceeds i32 range"
        );

        Ok(Self {
            x,
            y,
            width,
            height,
        })
    }

    pub fn right(self) -> i32 {
        self.x + self.width
    }

    pub fn bottom(self) -> i32 {
        self.y + self.height
    }
}

impl FromStr for CaptureArea {
    type Err = anyhow::Error;

    fn from_str(raw: &str) -> Result<Self> {
        if let Some(area) = parse_comma_area(raw)? {
            return Ok(area);
        }

        if let Some(area) = parse_x11_geometry(raw)? {
            return Ok(area);
        }

        bail!("expected X,Y,WIDTH,HEIGHT or WIDTHxHEIGHT+X+Y")
    }
}

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

impl OutputSize {
    pub fn new(width: i32, height: i32) -> Result<Self> {
        ensure!(width > 0, "output width must be > 0");
        ensure!(height > 0, "output height must be > 0");
        let width = round_to_even(width);
        let height = round_to_even(height);
        ensure!(
            width > 0,
            "output width must be at least 2 after even rounding"
        );
        ensure!(
            height > 0,
            "output height must be at least 2 after even rounding"
        );

        Ok(Self { width, height })
    }
}

impl FromStr for OutputSize {
    type Err = anyhow::Error;

    fn from_str(raw: &str) -> Result<Self> {
        let Some((width, height)) = raw.split_once('x') else {
            bail!("expected WIDTHxHEIGHT")
        };

        Self::new(
            width.parse().context("invalid output width")?,
            height.parse().context("invalid output height")?,
        )
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioMode {
    None,
    Microphone,
    System,
    Both,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DeviceClass {
    Source,
    Sink,
}

impl DeviceClass {
    fn as_str(self) -> &'static str {
        match self {
            Self::Source => "Audio/Source",
            Self::Sink => "Audio/Sink",
        }
    }

    fn description(self) -> &'static str {
        match self {
            Self::Source => "microphone",
            Self::Sink => "system audio",
        }
    }
}

pub fn preflight_audio(mode: AudioMode) -> Result<()> {
    for (class, _) in audio_sources(mode) {
        let _pulsesrc = make_pulsesrc(class, "ocula-audio-preflight-src")
            .with_context(|| format!("failed to prepare {} capture", class.description()))?;
    }

    Ok(())
}

pub fn build(options: PipelineOptions) -> Result<gst::Pipeline> {
    let pipeline = gst::Pipeline::builder()
        .name("ocula-recording-pipeline")
        .build();

    let skip_video_convert = options.effect == VideoEffect::None
        && can_skip_video_convert(&options.video_source, options.codec);
    let video_src = make_video_source(options.video_source, options.fps, options.area)?;
    let video_queue = make_video_queue("ocula-video-queue")?;
    let scale_elements = make_scale_elements(options.scale)?;
    let effect_elements = make_video_effect_elements(options.effect)?;
    let video_convert = (!skip_video_convert).then(make_video_convert).transpose()?;
    let video_format_caps = make_video_format_caps(options.codec)?;
    let encoder_elements = make_video_encoder(options.codec, options.fps, options.bitrate)?;
    let encoded_video_queue = make_mux_queue("ocula-encoded-video-queue")?;
    let muxer = options
        .output_format
        .muxer_element()
        .map(make_muxer)
        .transpose()?;
    let filesink = gst::ElementFactory::make("filesink")
        .name("ocula-filesink")
        .property("location", path_to_str(&options.temp_path)?)
        .property("sync", false)
        .property("async", false)
        .build()?;

    pipeline.add(&video_src)?;
    pipeline.add_many([&video_queue, &encoded_video_queue, &filesink])?;
    if let Some(convert) = &video_convert {
        pipeline.add(convert)?;
    }
    if let Some(muxer) = &muxer {
        pipeline.add(muxer)?;
    }
    for element in &scale_elements {
        pipeline.add(element)?;
    }
    for element in &effect_elements {
        pipeline.add(element)?;
    }
    if let Some(capsfilter) = &video_format_caps {
        pipeline.add(capsfilter)?;
    }
    for element in &encoder_elements {
        pipeline.add(element)?;
    }

    let mut video_chain = vec![
        video_src.clone().upcast::<gst::Element>(),
        video_queue.clone(),
    ];
    video_chain.extend(scale_elements.iter().cloned());
    video_chain.extend(effect_elements.iter().cloned());
    if let Some(convert) = video_convert {
        video_chain.push(convert);
    }
    if let Some(capsfilter) = video_format_caps {
        video_chain.push(capsfilter);
    }
    video_chain.extend(encoder_elements.iter().cloned());
    video_chain.push(encoded_video_queue.clone());
    link_chain(&video_chain)?;
    if let Some(muxer) = &muxer {
        encoded_video_queue.link_pads(None, muxer, Some("video_%u"))?;
        muxer.link(&filesink)?;
    } else {
        ensure!(
            options.audio == AudioMode::None,
            "GIF output does not support audio"
        );
        encoded_video_queue.link(&filesink)?;
    }

    if options.audio != AudioMode::None {
        let muxer = muxer
            .as_ref()
            .context("selected output format does not support audio muxing")?;
        attach_audio(&pipeline, muxer, options.audio, options.output_format)?;
    }

    Ok(pipeline)
}

fn make_muxer(element: &'static str) -> Result<gst::Element> {
    match element {
        "matroskamux" => Ok(gst::ElementFactory::make(element)
            .name("ocula-muxer")
            .property("streamable", true)
            .property("min-cluster-duration", 250_000_000_i64)
            .property("max-cluster-duration", 1_000_000_000_i64)
            .property("writing-app", "Ocula")
            .build()?),
        "mp4mux" => Ok(gst::ElementFactory::make(element)
            .name("ocula-muxer")
            .build()?),
        _ => unreachable!("unsupported muxer element"),
    }
}

fn make_video_source(
    video_source: VideoSource,
    fps: u32,
    area: Option<CaptureArea>,
) -> Result<gst::Bin> {
    match video_source {
        VideoSource::Portal { fd, streams } => make_portal_video_src_bin(fd, &streams, fps, area),
        VideoSource::X11 { show_pointer } => make_x11_video_src_bin(show_pointer, fps, area),
    }
}

fn can_skip_video_convert(video_source: &VideoSource, codec: VideoCodec) -> bool {
    matches!(video_source, VideoSource::X11 { .. }) && codec == VideoCodec::Mjpeg
}

fn make_portal_video_src_bin(
    fd: OwnedFd,
    streams: &[Stream],
    fps: u32,
    area: Option<CaptureArea>,
) -> Result<gst::Bin> {
    validate_portal_streams(streams)?;

    let bin = gst::Bin::builder()
        .name("ocula-portal-video-src-bin")
        .build();
    let crop = area
        .map(|area| make_portal_crop(area, streams))
        .transpose()?;
    let videorate = make_videorate("ocula-portal-videorate", fps)?;
    let capsfilter = make_video_capsfilter(fps)?;
    bin.add_many([&videorate, &capsfilter])?;
    if let Some(crop) = &crop {
        bin.add(crop)?;
        crop.link(&videorate)?;
    }
    videorate.link(&capsfilter)?;

    match streams {
        [stream] => {
            let pipewiresrc = make_pipewiresrc(&fd, stream)?;
            let videoflip = make_videoflip()?;
            bin.add_many([&pipewiresrc, &videoflip])?;
            if let Some(crop) = &crop {
                gst::Element::link_many([&pipewiresrc, &videoflip, crop])?;
            } else {
                gst::Element::link_many([&pipewiresrc, &videoflip, &videorate])?;
            }
        }
        streams => {
            let compositor = gst::ElementFactory::make("compositor")
                .name("ocula-compositor")
                .build()?;
            bin.add(&compositor)?;
            if let Some(crop) = &crop {
                compositor.link(crop)?;
            } else {
                compositor.link(&videorate)?;
            }

            let min_x = streams
                .iter()
                .filter_map(|stream| stream.position().map(|(x, _)| x))
                .min()
                .unwrap_or(0);
            let min_y = streams
                .iter()
                .filter_map(|stream| stream.position().map(|(_, y)| y))
                .min()
                .unwrap_or(0);
            let mut next_x = 0;
            for stream in streams {
                let pipewiresrc = make_pipewiresrc(&fd, stream)?;
                let videoflip = make_videoflip()?;
                bin.add_many([&pipewiresrc, &videoflip])?;
                pipewiresrc.link(&videoflip)?;

                let sink_pad = compositor
                    .request_pad_simple("sink_%u")
                    .context("failed to request compositor sink pad")?;
                if let Some((x, y)) = stream.position() {
                    sink_pad.set_property("xpos", compositor_pad_offset(x, min_x, "x")?);
                    sink_pad.set_property("ypos", compositor_pad_offset(y, min_y, "y")?);
                } else {
                    sink_pad.set_property("xpos", next_x);
                }
                videoflip
                    .static_pad("src")
                    .context("videoflip has no src pad")?
                    .link(&sink_pad)?;

                next_x = next_compositor_x(next_x, stream)?;
            }
        }
    }

    add_src_ghost_pad(&bin, &capsfilter)?;
    Ok(bin)
}

pub fn validate_portal_streams(streams: &[Stream]) -> Result<()> {
    ensure!(!streams.is_empty(), "portal returned no screen streams");
    ensure!(
        streams.len() <= MAX_PORTAL_STREAMS,
        "portal returned {} streams, exceeding limit of {}",
        streams.len(),
        MAX_PORTAL_STREAMS
    );

    let mut total_pixels = 0_i64;
    for stream in streams {
        if let Some((width, height)) = stream.size() {
            ensure!(width > 0, "portal stream width must be > 0");
            ensure!(height > 0, "portal stream height must be > 0");
            ensure!(
                width <= MAX_PORTAL_STREAM_DIMENSION && height <= MAX_PORTAL_STREAM_DIMENSION,
                "portal stream size {}x{} exceeds supported limit {}x{}",
                width,
                height,
                MAX_PORTAL_STREAM_DIMENSION,
                MAX_PORTAL_STREAM_DIMENSION
            );
            let pixels = i64::from(width)
                .checked_mul(i64::from(height))
                .context("portal stream pixel count exceeds i64 range")?;
            total_pixels = total_pixels
                .checked_add(pixels)
                .context("portal stream pixel total exceeds i64 range")?;
        }

        if let Some((x, y)) = stream.position() {
            ensure!(
                (-MAX_PORTAL_STREAM_DIMENSION..=MAX_PORTAL_STREAM_DIMENSION).contains(&x)
                    && (-MAX_PORTAL_STREAM_DIMENSION..=MAX_PORTAL_STREAM_DIMENSION).contains(&y),
                "portal stream position {x},{y} exceeds supported coordinate limit"
            );
        }
    }

    if let Some((width, height)) = portal_stream_size(streams) {
        ensure!(
            width <= MAX_PORTAL_STREAM_DIMENSION && height <= MAX_PORTAL_STREAM_DIMENSION,
            "portal composited size {}x{} exceeds supported limit {}x{}",
            width,
            height,
            MAX_PORTAL_STREAM_DIMENSION,
            MAX_PORTAL_STREAM_DIMENSION
        );
    }

    ensure!(
        total_pixels <= MAX_PORTAL_CAPTURE_PIXELS,
        "portal stream total pixel count exceeds supported limit"
    );

    Ok(())
}

fn make_x11_video_src_bin(
    show_pointer: bool,
    fps: u32,
    area: Option<CaptureArea>,
) -> Result<gst::Bin> {
    let bin = gst::Bin::builder().name("ocula-x11-video-src-bin").build();
    let ximagesrc = gst::ElementFactory::make("ximagesrc")
        .name("ocula-ximagesrc")
        .property("show-pointer", show_pointer)
        .property("do-timestamp", true)
        .property("use-damage", false)
        .build()?;
    if let Some(area) = area {
        ximagesrc.set_property("startx", u32::try_from(area.x)?);
        ximagesrc.set_property("starty", u32::try_from(area.y)?);
        ximagesrc.set_property("endx", u32::try_from(area.right() - 1)?);
        ximagesrc.set_property("endy", u32::try_from(area.bottom() - 1)?);
    }
    let videorate = make_videorate("ocula-x11-videorate", fps)?;
    let capsfilter = make_video_capsfilter(fps)?;

    bin.add_many([&ximagesrc, &videorate, &capsfilter])?;
    gst::Element::link_many([&ximagesrc, &videorate, &capsfilter])?;
    add_src_ghost_pad(&bin, &capsfilter)?;
    Ok(bin)
}

fn make_pipewiresrc(fd: &OwnedFd, stream: &Stream) -> Result<gst::Element> {
    let src = gst::ElementFactory::make("pipewiresrc")
        .name(format!("ocula-pipewiresrc-{}", stream.node_id()))
        .property("path", stream.node_id().to_string())
        .property("do-timestamp", true)
        .property("provide-clock", false)
        .property("keepalive-time", 1000_i32)
        .property("resend-last", true)
        .property_from_str("on-disconnect", "eos")
        .build()?;

    let stream_fd = duplicate_fd(fd)?;
    src.set_property("fd", stream_fd.into_raw_fd());

    Ok(src)
}

fn duplicate_fd(fd: &OwnedFd) -> Result<OwnedFd> {
    let raw_fd = unsafe { libc::dup(fd.as_raw_fd()) };
    if raw_fd < 0 {
        return Err(std::io::Error::last_os_error()).context("failed to duplicate PipeWire FD");
    }

    Ok(unsafe { OwnedFd::from_raw_fd(raw_fd) })
}

fn make_videoflip() -> Result<gst::Element> {
    Ok(gst::ElementFactory::make("videoflip")
        .property_from_str("video-direction", "auto")
        .build()?)
}

fn make_video_capsfilter(fps: u32) -> Result<gst::Element> {
    let fps = i32::try_from(fps).context("fps does not fit in i32")?;
    Ok(gst::ElementFactory::make("capsfilter")
        .name("ocula-video-framerate-caps")
        .property(
            "caps",
            gst::Caps::builder("video/x-raw")
                .field("framerate", gst::Fraction::new(fps, 1))
                .build(),
        )
        .build()?)
}

fn make_videorate(name: &str, fps: u32) -> Result<gst::Element> {
    let fps = i32::try_from(fps).context("fps does not fit in i32")?;
    Ok(gst::ElementFactory::make("videorate")
        .name(name)
        .property("skip-to-first", true)
        .property("drop-only", true)
        .property("max-rate", fps)
        .property("silent", true)
        .build()?)
}

fn make_scale_elements(scale: Option<OutputSize>) -> Result<Vec<gst::Element>> {
    let Some(scale) = scale else {
        return Ok(Vec::new());
    };

    let videoscale = gst::ElementFactory::make("videoscale")
        .name("ocula-video-scale")
        .property("add-borders", true)
        .property("n-threads", video_processing_threads())
        .build()?;
    let capsfilter = gst::ElementFactory::make("capsfilter")
        .name("ocula-video-scale-caps")
        .property(
            "caps",
            gst::Caps::builder("video/x-raw")
                .field("width", scale.width)
                .field("height", scale.height)
                .field("pixel-aspect-ratio", gst::Fraction::new(1, 1))
                .build(),
        )
        .build()?;

    Ok(vec![videoscale, capsfilter])
}

fn make_video_effect_elements(effect: VideoEffect) -> Result<Vec<gst::Element>> {
    let Some(fragment) = effect.fragment_shader() else {
        return Ok(Vec::new());
    };

    let input_convert = make_video_convert_named("ocula-effect-input-convert")?;
    let input_caps = make_rgba_capsfilter("ocula-effect-input-rgba-caps")?;
    let upload = gst::ElementFactory::make("glupload")
        .name("ocula-effect-gl-upload")
        .build()?;
    let shader = gst::ElementFactory::make("glshader")
        .name(format!("ocula-effect-{}-shader", effect.name()))
        .property("fragment", fragment)
        .build()?;
    let download = gst::ElementFactory::make("gldownload")
        .name("ocula-effect-gl-download")
        .build()?;
    let output_caps = make_rgba_capsfilter("ocula-effect-output-rgba-caps")?;

    Ok(vec![
        input_convert,
        input_caps,
        upload,
        shader,
        download,
        output_caps,
    ])
}

fn make_rgba_capsfilter(name: &str) -> Result<gst::Element> {
    Ok(gst::ElementFactory::make("capsfilter")
        .name(name)
        .property(
            "caps",
            gst::Caps::builder("video/x-raw")
                .field("format", "RGBA")
                .build(),
        )
        .build()?)
}

fn make_video_convert() -> Result<gst::Element> {
    make_video_convert_named("ocula-video-convert")
}

fn make_video_convert_named(name: &str) -> Result<gst::Element> {
    Ok(gst::ElementFactory::make("videoconvert")
        .name(name)
        .property("n-threads", video_processing_threads())
        .property_from_str("dither", "none")
        .build()?)
}

fn make_video_format_caps(codec: VideoCodec) -> Result<Option<gst::Element>> {
    let format = match codec {
        VideoCodec::H264(H264Encoder::OpenH264) | VideoCodec::Vp8 => Some("I420"),
        VideoCodec::H264(_) | VideoCodec::Mjpeg | VideoCodec::Gif => None,
    };
    let Some(format) = format else {
        return Ok(None);
    };

    Ok(Some(
        gst::ElementFactory::make("capsfilter")
            .name("ocula-video-format-caps")
            .property(
                "caps",
                gst::Caps::builder("video/x-raw")
                    .field("format", format)
                    .build(),
            )
            .build()?,
    ))
}

fn video_processing_threads() -> u32 {
    std::thread::available_parallelism()
        .map(|threads| {
            threads
                .get()
                .clamp(1, MAX_VIDEO_PROCESSING_THREADS as usize) as u32
        })
        .unwrap_or(1)
}

fn make_portal_crop(area: CaptureArea, streams: &[Stream]) -> Result<gst::Element> {
    let (origin_x, origin_y, source_width, source_height) = validate_portal_area(area, streams)?;
    let source_right = portal_stream_edge(origin_x, source_width, "right")?;
    let source_bottom = portal_stream_edge(origin_y, source_height, "bottom")?;

    let left = portal_crop_margin(area.x, origin_x, "left")?;
    let top = portal_crop_margin(area.y, origin_y, "top")?;
    let right = portal_crop_margin(source_right, area.right(), "right")?;
    let bottom = portal_crop_margin(source_bottom, area.bottom(), "bottom")?;

    Ok(gst::ElementFactory::make("videocrop")
        .name("ocula-selection-crop")
        .property("left", left)
        .property("top", top)
        .property("right", right)
        .property("bottom", bottom)
        .build()?)
}

pub fn validate_portal_area(area: CaptureArea, streams: &[Stream]) -> Result<(i32, i32, i32, i32)> {
    let (origin_x, origin_y, source_width, source_height) = stream_bounds(streams)
        .context("portal did not return stream geometry, so --area cannot be applied")?;
    let source_right = portal_stream_edge(origin_x, source_width, "right")?;
    let source_bottom = portal_stream_edge(origin_y, source_height, "bottom")?;

    ensure!(
        area.x >= origin_x && area.y >= origin_y,
        "selection starts outside the captured portal stream"
    );
    ensure!(
        area.right() <= source_right && area.bottom() <= source_bottom,
        "selection extends outside the captured portal stream"
    );

    Ok((origin_x, origin_y, source_width, source_height))
}

pub fn scale_area_to_portal_streams(
    area: CaptureArea,
    screen_width: i32,
    screen_height: i32,
    streams: &[Stream],
) -> Result<CaptureArea> {
    ensure!(screen_width > 0, "selector screen width must be > 0");
    ensure!(screen_height > 0, "selector screen height must be > 0");
    ensure!(
        area.right() <= screen_width && area.bottom() <= screen_height,
        "selection extends outside the selector screen"
    );

    let (origin_x, origin_y, source_width, source_height) = stream_bounds(streams)
        .context("portal did not return stream geometry, so selected area cannot be scaled")?;

    let x = scaled_coordinate(origin_x, area.x, source_width, screen_width, "x")?;
    let y = scaled_coordinate(origin_y, area.y, source_height, screen_height, "y")?;
    let right = scaled_coordinate(origin_x, area.right(), source_width, screen_width, "right")?;
    let bottom = scaled_coordinate(
        origin_y,
        area.bottom(),
        source_height,
        screen_height,
        "bottom",
    )?;

    CaptureArea::new(
        x,
        y,
        right
            .checked_sub(x)
            .context("scaled selection width is invalid")?,
        bottom
            .checked_sub(y)
            .context("scaled selection height is invalid")?,
    )
}

pub fn portal_stream_size(streams: &[Stream]) -> Option<(i32, i32)> {
    let (_, _, width, height) = stream_bounds(streams)?;
    Some((width, height))
}

fn next_compositor_x(current: i32, stream: &Stream) -> Result<i32> {
    let width = match stream.size() {
        Some((width, _)) => {
            ensure!(width > 0, "portal stream width must be > 0");
            width
        }
        None => 1920,
    };

    current
        .checked_add(width)
        .context("portal stream layout exceeds i32 range")
}

fn compositor_pad_offset(value: i32, origin: i32, label: &str) -> Result<i32> {
    value
        .checked_sub(origin)
        .with_context(|| format!("portal stream {label} offset exceeds i32 range"))
}

fn portal_stream_edge(origin: i32, size: i32, label: &str) -> Result<i32> {
    origin
        .checked_add(size)
        .with_context(|| format!("portal stream {label} edge exceeds i32 range"))
}

fn portal_crop_margin(edge: i32, from: i32, label: &str) -> Result<i32> {
    let margin = edge
        .checked_sub(from)
        .with_context(|| format!("portal crop {label} margin exceeds i32 range"))?;
    ensure!(margin >= 0, "portal crop {label} margin is negative");
    Ok(round_to_even(margin))
}

fn scale_coordinate(value: i32, source: i32, screen: i32) -> i32 {
    ((f64::from(value) * f64::from(source)) / f64::from(screen)).round() as i32
}

fn scaled_coordinate(
    origin: i32,
    value: i32,
    source: i32,
    screen: i32,
    label: &str,
) -> Result<i32> {
    origin
        .checked_add(scale_coordinate(value, source, screen))
        .with_context(|| format!("scaled selection {label} exceeds i32 range"))
}

fn stream_bounds(streams: &[Stream]) -> Option<(i32, i32, i32, i32)> {
    if streams.is_empty() {
        return None;
    }

    let mut min_x = i32::MAX;
    let mut min_y = i32::MAX;
    let mut max_x = i32::MIN;
    let mut max_y = i32::MIN;
    let mut next_x = 0_i32;

    for stream in streams {
        let (width, height) = stream.size()?;
        if width <= 0 || height <= 0 {
            return None;
        }
        let (x, y) = stream.position().unwrap_or((next_x, 0));
        next_x = next_x.checked_add(width)?;
        min_x = min_x.min(x);
        min_y = min_y.min(y);
        max_x = max_x.max(x.checked_add(width)?);
        max_y = max_y.max(y.checked_add(height)?);
    }

    Some((
        min_x,
        min_y,
        max_x.checked_sub(min_x)?,
        max_y.checked_sub(min_y)?,
    ))
}

fn make_video_encoder(codec: VideoCodec, fps: u32, bitrate: u32) -> Result<Vec<gst::Element>> {
    match codec {
        VideoCodec::H264(encoder) => make_h264_encoder(encoder, fps, bitrate),
        VideoCodec::Mjpeg => make_mjpeg_encoder(),
        VideoCodec::Vp8 => make_vp8_encoder(fps, bitrate),
        VideoCodec::Gif => make_gif_encoder(),
    }
}

fn make_h264_encoder(encoder: H264Encoder, fps: u32, bitrate: u32) -> Result<Vec<gst::Element>> {
    if encoder.is_hardware() {
        return make_hardware_h264_encoder(encoder);
    }

    let threads = std::thread::available_parallelism()
        .map(|threads| threads.get().clamp(1, 16) as u32)
        .unwrap_or(4);
    let bitrate = bitrate_or_default(bitrate, DEFAULT_H264_BITRATE);
    let encoder = gst::ElementFactory::make("openh264enc")
        .name("ocula-openh264enc")
        .property("bitrate", bitrate)
        .property("max-bitrate", bitrate)
        .property("gop-size", fps.saturating_mul(2).max(1))
        .property("multi-thread", threads)
        .property("enable-frame-skip", true)
        .property_from_str("complexity", "low")
        .property_from_str("usage-type", "screen")
        .property_from_str("rate-control", "bitrate")
        .build()?;
    let parser = gst::ElementFactory::make("h264parse")
        .name("ocula-h264parse")
        .property("config-interval", -1_i32)
        .build()?;
    let caps = gst::ElementFactory::make("capsfilter")
        .name("ocula-h264-caps")
        .property(
            "caps",
            gst::Caps::builder("video/x-h264")
                .field("stream-format", "avc")
                .field("alignment", "au")
                .build(),
        )
        .build()?;

    Ok(vec![encoder, parser, caps])
}

fn make_hardware_h264_encoder(encoder: H264Encoder) -> Result<Vec<gst::Element>> {
    let encoder = gst::ElementFactory::make(encoder.element())
        .name("ocula-hardware-h264enc")
        .build()?;
    let parser = gst::ElementFactory::make("h264parse")
        .name("ocula-h264parse")
        .property("config-interval", -1_i32)
        .build()?;
    let caps = gst::ElementFactory::make("capsfilter")
        .name("ocula-h264-caps")
        .property(
            "caps",
            gst::Caps::builder("video/x-h264")
                .field("stream-format", "avc")
                .field("alignment", "au")
                .build(),
        )
        .build()?;

    Ok(vec![encoder, parser, caps])
}

fn make_mjpeg_encoder() -> Result<Vec<gst::Element>> {
    Ok(vec![
        gst::ElementFactory::make("jpegenc")
            .name("ocula-jpegenc")
            .property("quality", 85_i32)
            .build()?,
    ])
}

fn make_vp8_encoder(fps: u32, bitrate: u32) -> Result<Vec<gst::Element>> {
    let keyframe_distance = i32::try_from(fps.saturating_mul(2)).unwrap_or(240);
    let threads = std::thread::available_parallelism()
        .map(|threads| threads.get().clamp(1, 16) as i32)
        .unwrap_or(4);
    let target_bitrate = i32::try_from(bitrate_or_default(bitrate, DEFAULT_VP8_BITRATE))
        .context("VP8 bitrate does not fit in i32")?;

    Ok(vec![
        gst::ElementFactory::make("vp8enc")
            .name("ocula-vp8enc")
            .property("deadline", 1_i64)
            .property("cpu-used", 8_i32)
            .property("threads", threads)
            .property("lag-in-frames", 0_i32)
            .property("static-threshold", 100_i32)
            .property("keyframe-max-dist", keyframe_distance)
            .property("target-bitrate", target_bitrate)
            .build()?,
    ])
}

fn make_gif_encoder() -> Result<Vec<gst::Element>> {
    Ok(vec![
        gst::ElementFactory::make("gifenc")
            .name("ocula-gifenc")
            .build()?,
    ])
}

fn bitrate_or_default(bitrate: u32, default: u32) -> u32 {
    if bitrate == 0 { default } else { bitrate }
}

fn attach_audio(
    pipeline: &gst::Pipeline,
    muxer: &gst::Element,
    mode: AudioMode,
    output_format: OutputFormat,
) -> Result<()> {
    let audio_src = make_audio_src_bin(mode)?;
    let audio_queue = make_audio_queue("ocula-audio-queue")?;
    let audio_convert = gst::ElementFactory::make("audioconvert")
        .name("ocula-audio-convert")
        .build()?;
    let audio_resample = gst::ElementFactory::make("audioresample")
        .name("ocula-audio-resample")
        .build()?;
    let audio_encoder = make_audio_encoder(output_format)?;
    let encoded_audio_queue = make_mux_queue("ocula-encoded-audio-queue")?;

    pipeline.add(&audio_src)?;
    pipeline.add_many([
        &audio_queue,
        &audio_convert,
        &audio_resample,
        &audio_encoder,
        &encoded_audio_queue,
    ])?;
    gst::Element::link_many([
        audio_src.upcast_ref(),
        &audio_queue,
        &audio_convert,
        &audio_resample,
        &audio_encoder,
        &encoded_audio_queue,
    ])?;
    encoded_audio_queue.link_pads(None, muxer, Some("audio_%u"))?;

    Ok(())
}

fn make_audio_encoder(output_format: OutputFormat) -> Result<gst::Element> {
    match output_format {
        OutputFormat::Mkv => Ok(gst::ElementFactory::make("opusenc")
            .name("ocula-opusenc")
            .property("bitrate", 128_000_i32)
            .build()?),
        OutputFormat::Mp4 => {
            let encoder = aac_encoder_element()
                .context("MP4 audio requires an AAC encoder such as fdkaacenc")?;
            let builder = gst::ElementFactory::make(encoder).name("ocula-aacenc");
            if encoder == "fdkaacenc" {
                Ok(builder.property("bitrate", 128_000_i32).build()?)
            } else {
                Ok(builder.build()?)
            }
        }
        OutputFormat::Gif => bail!("GIF output does not support audio"),
    }
}

fn make_audio_src_bin(mode: AudioMode) -> Result<gst::Bin> {
    let bin = gst::Bin::builder().name("ocula-audio-src-bin").build();
    let mixer = gst::ElementFactory::make("audiomixer")
        .name("ocula-audiomixer")
        .property("latency", gst::ClockTime::from_seconds(2))
        .build()?;
    let capsfilter = gst::ElementFactory::make("capsfilter")
        .name("ocula-audio-caps")
        .property(
            "caps",
            gst::Caps::builder("audio/x-raw")
                .field("rate", AUDIO_SAMPLE_RATE)
                .field("channels", 2_i32)
                .build(),
        )
        .build()?;
    bin.add_many([&mixer, &capsfilter])?;
    mixer.link(&capsfilter)?;

    for (class, name) in audio_sources(mode) {
        let pulsesrc = make_pulsesrc(class, name)?;
        let queue = make_audio_queue(&format!("{name}-queue"))?;
        let rate = gst::ElementFactory::make("audiorate")
            .name(format!("{name}-audiorate"))
            .property("skip-to-first", true)
            .build()?;
        let convert = gst::ElementFactory::make("audioconvert")
            .name(format!("{name}-convert"))
            .build()?;
        let resample = gst::ElementFactory::make("audioresample")
            .name(format!("{name}-resample"))
            .build()?;

        bin.add_many([&pulsesrc, &queue, &rate, &convert, &resample])?;
        gst::Element::link_many([&pulsesrc, &queue, &rate, &convert, &resample])?;
        resample.link_pads(None, &mixer, Some("sink_%u"))?;
    }

    add_src_ghost_pad(&bin, &capsfilter)?;
    Ok(bin)
}

fn audio_sources(mode: AudioMode) -> Vec<(DeviceClass, &'static str)> {
    match mode {
        AudioMode::None => Vec::new(),
        AudioMode::Microphone => vec![(DeviceClass::Source, "ocula-microphone-src")],
        AudioMode::System => vec![(DeviceClass::Sink, "ocula-system-audio-src")],
        AudioMode::Both => vec![
            (DeviceClass::Sink, "ocula-system-audio-src"),
            (DeviceClass::Source, "ocula-microphone-src"),
        ],
    }
}

fn make_pulsesrc(class: DeviceClass, element_name: &str) -> Result<gst::Element> {
    let device = find_default_device(class)?;
    let pulsesrc = gst::ElementFactory::make("pulsesrc")
        .name(element_name)
        .property("provide-clock", false)
        .property("do-timestamp", true)
        .build()?;

    match class {
        DeviceClass::Sink => {
            let pulsesink = device.create_element(None)?;
            let device_name = pulsesink
                .property::<Option<String>>("device")
                .context("default audio sink has no device name")?;
            ensure!(!device_name.is_empty(), "default audio sink name is empty");
            pulsesrc.set_property("device", format!("{device_name}.monitor"));
        }
        DeviceClass::Source => {
            device.reconfigure_element(&pulsesrc)?;
            let device_name = pulsesrc
                .property::<Option<String>>("device")
                .context("default microphone has no device name")?;
            ensure!(!device_name.is_empty(), "default microphone name is empty");
        }
    }

    Ok(pulsesrc)
}

fn find_default_device(class: DeviceClass) -> Result<gst::Device> {
    let provider = gst::DeviceProviderFactory::by_name("pulsedeviceprovider")
        .context("failed to find PulseAudio device provider")?;
    provider.start().with_context(|| {
        format!(
            "failed to start PulseAudio device provider for {} capture",
            class.description()
        )
    })?;
    let devices = provider.devices();
    provider.stop();

    for device in devices {
        if validate_device(&device, class).is_ok() {
            return Ok(device);
        }
    }

    bail!(
        "failed to find default {} audio device",
        class.description()
    )
}

fn validate_device(device: &gst::Device, class: DeviceClass) -> Result<()> {
    ensure!(
        device.has_classes(class.as_str()),
        "unexpected device class `{}`",
        device.device_class()
    );

    let is_default = device
        .properties()
        .context("audio device has no properties")?
        .get::<bool>("is-default")
        .context("audio device has no is-default property")?;
    ensure!(is_default, "not the default audio device");

    Ok(())
}

fn make_video_queue(name: &str) -> Result<gst::Element> {
    Ok(gst::ElementFactory::make("queue")
        .name(name)
        .property("max-size-buffers", RAW_VIDEO_QUEUE_MAX_BUFFERS)
        .property("max-size-bytes", 0_u32)
        .property("max-size-time", 0_u64)
        .property("silent", true)
        .property_from_str("leaky", "downstream")
        .build()?)
}

fn make_audio_queue(name: &str) -> Result<gst::Element> {
    Ok(gst::ElementFactory::make("queue")
        .name(name)
        .property("max-size-buffers", 0_u32)
        .property("max-size-bytes", 0_u32)
        .property("max-size-time", RAW_AUDIO_QUEUE_MAX_TIME)
        .property("silent", true)
        .build()?)
}

fn make_mux_queue(name: &str) -> Result<gst::Element> {
    Ok(gst::ElementFactory::make("queue")
        .name(name)
        .property("max-size-buffers", 0_u32)
        .property("max-size-bytes", 0_u32)
        .property("max-size-time", MUX_QUEUE_MAX_TIME)
        .build()?)
}

fn link_chain(elements: &[gst::Element]) -> Result<()> {
    for pair in elements.windows(2) {
        pair[0].link(&pair[1]).with_context(|| {
            format!(
                "failed to link `{}` to `{}`",
                pair[0].name(),
                pair[1].name()
            )
        })?;
    }

    Ok(())
}

fn add_src_ghost_pad(bin: &gst::Bin, element: &gst::Element) -> Result<()> {
    let src_pad = element
        .static_pad("src")
        .context("element has no src pad")?;
    bin.add_pad(&gst::GhostPad::with_target(&src_pad)?)?;
    Ok(())
}

fn path_to_str(path: &Path) -> Result<&str> {
    path.to_str()
        .with_context(|| format!("path `{}` is not valid UTF-8", path.display()))
}

fn round_to_even(number: i32) -> i32 {
    number / 2 * 2
}

fn parse_comma_area(raw: &str) -> Result<Option<CaptureArea>> {
    let parts = raw.split(',').collect::<Vec<_>>();
    if parts.len() != 4 {
        return Ok(None);
    }

    let x = parts[0].parse().context("invalid selection x")?;
    let y = parts[1].parse().context("invalid selection y")?;
    let width = parts[2].parse().context("invalid selection width")?;
    let height = parts[3].parse().context("invalid selection height")?;
    CaptureArea::new(x, y, width, height).map(Some)
}

fn parse_x11_geometry(raw: &str) -> Result<Option<CaptureArea>> {
    let Some((size, position)) = raw.split_once('+') else {
        return Ok(None);
    };
    let Some((width, height)) = size.split_once('x') else {
        return Ok(None);
    };
    let Some((x, y)) = position.split_once('+') else {
        return Ok(None);
    };

    CaptureArea::new(
        x.parse().context("invalid selection x")?,
        y.parse().context("invalid selection y")?,
        width.parse().context("invalid selection width")?,
        height.parse().context("invalid selection height")?,
    )
    .map(Some)
}

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

    #[test]
    fn parse_comma_selection_area() {
        assert_eq!(
            "10,20,300,400".parse::<CaptureArea>().unwrap(),
            CaptureArea {
                x: 10,
                y: 20,
                width: 300,
                height: 400,
            }
        );
    }

    #[test]
    fn parse_x11_selection_area() {
        assert_eq!(
            "300x400+10+20".parse::<CaptureArea>().unwrap(),
            CaptureArea {
                x: 10,
                y: 20,
                width: 300,
                height: 400,
            }
        );
    }

    #[test]
    fn reject_empty_selection_area() {
        assert!("10,20,0,400".parse::<CaptureArea>().is_err());
    }

    #[test]
    fn reject_selection_area_that_overflows_edges() {
        assert!(CaptureArea::new(i32::MAX, 0, 1, 1).is_err());
        assert!(CaptureArea::new(0, i32::MAX, 1, 1).is_err());
    }

    #[test]
    fn parse_output_size_rounds_to_even() {
        assert_eq!(
            "641x361".parse::<OutputSize>().unwrap(),
            OutputSize {
                width: 640,
                height: 360,
            }
        );
    }

    #[test]
    fn reject_output_size_that_rounds_to_zero() {
        assert!("1x2".parse::<OutputSize>().is_err());
        assert!("2x1".parse::<OutputSize>().is_err());
    }

    #[test]
    fn bitrate_zero_uses_codec_default() {
        assert_eq!(bitrate_or_default(0, DEFAULT_VP8_BITRATE), 8_000_000);
        assert_eq!(
            bitrate_or_default(1_234_567, DEFAULT_VP8_BITRATE),
            1_234_567
        );
    }

    #[test]
    fn audio_sources_match_requested_mode() {
        assert!(audio_sources(AudioMode::None).is_empty());
        assert_eq!(
            audio_sources(AudioMode::Microphone),
            vec![(DeviceClass::Source, "ocula-microphone-src")]
        );
        assert_eq!(
            audio_sources(AudioMode::System),
            vec![(DeviceClass::Sink, "ocula-system-audio-src")]
        );
        assert_eq!(
            audio_sources(AudioMode::Both),
            vec![
                (DeviceClass::Sink, "ocula-system-audio-src"),
                (DeviceClass::Source, "ocula-microphone-src"),
            ]
        );
    }

    #[test]
    fn preflight_audio_none_is_noop() {
        preflight_audio(AudioMode::None).unwrap();
    }

    #[test]
    fn raw_video_queue_drops_old_frames_to_stay_realtime() {
        gst::init().unwrap();
        let queue = make_video_queue("ocula-test-video-queue").unwrap();

        assert_eq!(
            queue.property::<u32>("max-size-buffers"),
            RAW_VIDEO_QUEUE_MAX_BUFFERS
        );
        assert_eq!(queue.property::<u32>("max-size-bytes"), 0);
        assert_eq!(queue.property::<u64>("max-size-time"), 0);
    }

    #[test]
    fn raw_audio_queue_buffers_without_leaking() {
        gst::init().unwrap();
        let queue = make_audio_queue("ocula-test-audio-queue").unwrap();

        assert_eq!(queue.property::<u32>("max-size-buffers"), 0);
        assert_eq!(queue.property::<u32>("max-size-bytes"), 0);
        assert_eq!(
            queue.property::<u64>("max-size-time"),
            RAW_AUDIO_QUEUE_MAX_TIME
        );
    }

    #[test]
    fn video_convert_uses_threaded_low_overhead_settings() {
        gst::init().unwrap();
        let convert = make_video_convert().unwrap();

        assert!(convert.property::<u32>("n-threads") >= 1);
        assert!(convert.property::<u32>("n-threads") <= MAX_VIDEO_PROCESSING_THREADS);
    }

    #[test]
    fn direct_x11_mjpeg_skips_unnecessary_video_convert() {
        assert!(can_skip_video_convert(
            &VideoSource::X11 { show_pointer: true },
            VideoCodec::Mjpeg,
        ));
        assert!(!can_skip_video_convert(
            &VideoSource::X11 { show_pointer: true },
            VideoCodec::H264(H264Encoder::OpenH264),
        ));
        assert!(!can_skip_video_convert(
            &VideoSource::Portal {
                fd: test_owned_fd(),
                streams: Vec::new(),
            },
            VideoCodec::Mjpeg,
        ));
    }

    #[test]
    fn video_effects_request_gl_elements_only_when_enabled() {
        assert!(VideoEffect::None.required_elements().is_empty());
        assert_eq!(
            VideoEffect::Neon.required_elements(),
            &[
                "glupload",
                "glshader",
                "gldownload",
                "videoconvert",
                "capsfilter"
            ]
        );
        assert_eq!(
            VideoEffect::Heatmap.required_elements(),
            VideoEffect::Neon.required_elements()
        );
        assert_eq!(
            VideoEffect::Glitch.required_elements(),
            VideoEffect::Neon.required_elements()
        );
        assert_eq!(
            VideoEffect::Ripple.required_elements(),
            VideoEffect::Neon.required_elements()
        );
        assert_eq!(
            VideoEffect::Glass.required_elements(),
            VideoEffect::Neon.required_elements()
        );
        assert_eq!(
            VideoEffect::Crt.required_elements(),
            VideoEffect::Neon.required_elements()
        );
        assert_eq!(
            VideoEffect::Holo.required_elements(),
            VideoEffect::Neon.required_elements()
        );
        assert_eq!(
            VideoEffect::Vapor.required_elements(),
            VideoEffect::Neon.required_elements()
        );
        assert_eq!(
            VideoEffect::Fracture.required_elements(),
            VideoEffect::Neon.required_elements()
        );
    }

    #[test]
    fn shader_effects_include_precision_and_math() {
        for effect in [
            VideoEffect::Neon,
            VideoEffect::Heatmap,
            VideoEffect::Glitch,
            VideoEffect::Ripple,
            VideoEffect::Glass,
            VideoEffect::Crt,
            VideoEffect::Holo,
            VideoEffect::Vapor,
            VideoEffect::Fracture,
        ] {
            let shader = effect.fragment_shader().unwrap();
            assert!(shader.contains("precision mediump float"));
            assert!(shader.contains("texture2D"));
            assert!(shader.contains("gl_FragColor"));
        }
        assert!(
            VideoEffect::Neon
                .fragment_shader()
                .unwrap()
                .contains("smoothstep")
        );
        assert!(
            VideoEffect::Heatmap
                .fragment_shader()
                .unwrap()
                .contains("distance")
        );
        assert!(
            VideoEffect::Glitch
                .fragment_shader()
                .unwrap()
                .contains("hash")
        );
        assert!(
            VideoEffect::Ripple
                .fragment_shader()
                .unwrap()
                .contains("refract_uv")
        );
        assert!(
            VideoEffect::Glass
                .fragment_shader()
                .unwrap()
                .contains("shard")
        );
        assert!(VideoEffect::Crt.fragment_shader().unwrap().contains("scan"));
        assert!(
            VideoEffect::Holo
                .fragment_shader()
                .unwrap()
                .contains("interference")
        );
        assert!(
            VideoEffect::Vapor
                .fragment_shader()
                .unwrap()
                .contains("swirl")
        );
        assert!(
            VideoEffect::Fracture
                .fragment_shader()
                .unwrap()
                .contains("fracture")
        );
    }

    #[test]
    fn video_effect_builder_creates_gl_shader_chain() {
        gst::init().unwrap();
        let elements = make_video_effect_elements(VideoEffect::Glitch).unwrap();

        assert_eq!(elements.len(), 6);
        assert_eq!(elements[0].factory().unwrap().name(), "videoconvert");
        assert_eq!(elements[1].factory().unwrap().name(), "capsfilter");
        assert_eq!(elements[2].factory().unwrap().name(), "glupload");
        assert_eq!(elements[3].factory().unwrap().name(), "glshader");
        assert_eq!(elements[4].factory().unwrap().name(), "gldownload");
        assert_eq!(elements[5].factory().unwrap().name(), "capsfilter");
        assert!(elements[3].property::<String>("fragment").contains("hash"));
        assert!(
            make_video_effect_elements(VideoEffect::None)
                .unwrap()
                .is_empty()
        );
    }

    #[test]
    fn software_h264_and_vp8_request_i420_input_caps() {
        gst::init().unwrap();

        let h264_caps = make_video_format_caps(VideoCodec::H264(H264Encoder::OpenH264))
            .unwrap()
            .unwrap()
            .property::<gst::Caps>("caps");
        let vp8_caps = make_video_format_caps(VideoCodec::Vp8)
            .unwrap()
            .unwrap()
            .property::<gst::Caps>("caps");

        assert!(h264_caps.to_string().contains("I420"));
        assert!(vp8_caps.to_string().contains("I420"));
        assert!(make_video_format_caps(VideoCodec::Mjpeg).unwrap().is_none());
        assert!(
            make_video_format_caps(VideoCodec::H264(H264Encoder::Va))
                .unwrap()
                .is_none()
        );
    }

    #[test]
    fn videorate_caps_frame_rate_without_creating_duplicates() {
        gst::init().unwrap();
        let videorate = make_videorate("ocula-test-videorate", 15).unwrap();

        assert!(videorate.property::<bool>("skip-to-first"));
        assert!(videorate.property::<bool>("drop-only"));
        assert_eq!(videorate.property::<i32>("max-rate"), 15);
        assert!(videorate.property::<bool>("silent"));
    }

    #[test]
    fn mux_queue_buffers_encoded_streams_for_latency_negotiation() {
        gst::init().unwrap();
        let queue = make_mux_queue("ocula-test-mux-queue").unwrap();

        assert_eq!(queue.property::<u32>("max-size-buffers"), 0);
        assert_eq!(queue.property::<u32>("max-size-bytes"), 0);
        assert_eq!(queue.property::<u64>("max-size-time"), MUX_QUEUE_MAX_TIME);
    }

    #[test]
    fn scale_selected_area_to_larger_portal_stream() {
        let stream = stream_variant("(uint32 63, {'position': <(0, 0)>, 'size': <(3840, 2160)>})");

        assert_eq!(
            scale_area_to_portal_streams(
                CaptureArea::new(100, 50, 400, 300).unwrap(),
                1920,
                1080,
                &[stream],
            )
            .unwrap(),
            CaptureArea {
                x: 200,
                y: 100,
                width: 800,
                height: 600,
            }
        );
    }

    #[test]
    fn scale_selected_area_to_offset_portal_stream() {
        let stream =
            stream_variant("(uint32 63, {'position': <(1920, 0)>, 'size': <(1920, 1080)>})");

        assert_eq!(
            scale_area_to_portal_streams(
                CaptureArea::new(100, 50, 400, 300).unwrap(),
                1920,
                1080,
                &[stream],
            )
            .unwrap(),
            CaptureArea {
                x: 2020,
                y: 50,
                width: 400,
                height: 300,
            }
        );
    }

    #[test]
    fn scale_selected_area_rejects_area_outside_selector_screen() {
        let stream = stream_variant("(uint32 63, {'position': <(0, 0)>, 'size': <(1920, 1080)>})");
        let err = scale_area_to_portal_streams(
            CaptureArea::new(1900, 0, 100, 100).unwrap(),
            1920,
            1080,
            &[stream],
        )
        .unwrap_err()
        .to_string();

        assert!(err.contains("selector screen"));
    }

    #[test]
    fn scale_selected_area_rejects_zero_scaled_dimensions() {
        let stream = stream_variant("(uint32 63, {'position': <(0, 0)>, 'size': <(1, 1)>})");
        let err = scale_area_to_portal_streams(
            CaptureArea::new(0, 0, 1, 1).unwrap(),
            1920,
            1080,
            &[stream],
        )
        .unwrap_err()
        .to_string();

        assert!(err.contains("selection width must be > 0"));
    }

    #[test]
    fn stream_bounds_rejects_empty_and_invalid_geometry() {
        let zero_width = stream_variant("(uint32 63, {'position': <(0, 0)>, 'size': <(0, 1080)>})");
        let overflowing =
            stream_variant("(uint32 63, {'position': <(2147483647, 0)>, 'size': <(1, 1080)>})");

        assert!(stream_bounds(&[]).is_none());
        assert!(stream_bounds(&[zero_width]).is_none());
        assert!(stream_bounds(&[overflowing]).is_none());
    }

    #[test]
    fn stream_bounds_lays_out_streams_without_positions_like_compositor() {
        let left = stream_variant("(uint32 63, {'size': <(100, 100)>})");
        let right = stream_variant("(uint32 64, {'size': <(200, 50)>})");

        assert_eq!(stream_bounds(&[left, right]), Some((0, 0, 300, 100)));
    }

    #[test]
    fn portal_stream_size_uses_composited_bounds() {
        let left = stream_variant("(uint32 63, {'size': <(100, 100)>})");
        let right = stream_variant("(uint32 64, {'size': <(200, 50)>})");

        assert_eq!(portal_stream_size(&[left, right]), Some((300, 100)));
    }

    #[test]
    fn portal_stream_validation_accepts_reasonable_streams() {
        let left = stream_variant("(uint32 63, {'position': <(0, 0)>, 'size': <(1920, 1080)>})");
        let right =
            stream_variant("(uint32 64, {'position': <(1920, 0)>, 'size': <(1920, 1080)>})");

        validate_portal_streams(&[left, right]).unwrap();
    }

    #[test]
    fn portal_stream_validation_rejects_excessive_stream_count() {
        let stream = stream_variant("(uint32 63, {'size': <(100, 100)>})");
        let streams = vec![stream; MAX_PORTAL_STREAMS + 1];

        let err = validate_portal_streams(&streams).unwrap_err().to_string();

        assert!(err.contains("exceeding limit"));
    }

    #[test]
    fn portal_stream_validation_rejects_excessive_dimensions() {
        let stream = stream_variant("(uint32 63, {'size': <(32769, 100)>})");

        let err = validate_portal_streams(&[stream]).unwrap_err().to_string();

        assert!(err.contains("exceeds supported limit"));
    }

    #[test]
    fn portal_stream_validation_rejects_excessive_total_pixels() {
        let stream = stream_variant("(uint32 63, {'size': <(20000, 20000)>})");

        let err = validate_portal_streams(&[stream]).unwrap_err().to_string();

        assert!(err.contains("pixel count"));
    }

    #[test]
    fn compositor_layout_advances_by_stream_width_or_default() {
        let sized = stream_variant("(uint32 63, {'size': <(100, 100)>})");
        let missing_size = stream_variant("(uint32 64, {})");

        assert_eq!(next_compositor_x(10, &sized).unwrap(), 110);
        assert_eq!(next_compositor_x(10, &missing_size).unwrap(), 1930);
    }

    #[test]
    fn compositor_layout_rejects_invalid_or_overflowing_width() {
        let zero = stream_variant("(uint32 63, {'size': <(0, 100)>})");
        let missing_size = stream_variant("(uint32 64, {})");

        assert!(next_compositor_x(0, &zero).is_err());
        assert!(next_compositor_x(i32::MAX, &missing_size).is_err());
    }

    #[test]
    fn compositor_pad_offset_is_checked() {
        assert_eq!(compositor_pad_offset(10, -5, "x").unwrap(), 15);
        assert!(compositor_pad_offset(i32::MAX, -1, "x").is_err());
    }

    #[test]
    fn portal_stream_edge_is_checked() {
        assert_eq!(portal_stream_edge(10, 20, "right").unwrap(), 30);
        assert!(portal_stream_edge(i32::MAX, 1, "right").is_err());
    }

    #[test]
    fn portal_crop_margin_is_checked_and_even() {
        assert_eq!(portal_crop_margin(11, 2, "left").unwrap(), 8);
        assert!(portal_crop_margin(2, 11, "left").is_err());
        assert!(portal_crop_margin(i32::MIN, 1, "left").is_err());
    }

    fn stream_variant(raw: &str) -> Stream {
        glib::Variant::parse(Some(&Stream::static_variant_type()), raw)
            .unwrap()
            .get::<Stream>()
            .unwrap()
    }

    fn test_owned_fd() -> OwnedFd {
        std::fs::File::open("/dev/null").unwrap().into()
    }
}
