use eframe::egui;
use image::{DynamicImage, ImageReader};
use std::collections::{HashMap, HashSet, VecDeque};
use std::hash::{Hash, Hasher};
use std::io::{Cursor, Read};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::{self, Receiver, SyncSender};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

const MAX_ACTIVE_WORKERS: usize = 3;
const MAX_CACHE_ENTRIES: usize = 420;
const MAX_QUEUED_WORK: usize = 256;
const THUMBNAIL_RESULT_CAPACITY: usize = MAX_ACTIVE_WORKERS * 2;
const SOURCE_IMAGE_BYTES_LIMIT: u64 = 128 * 1024 * 1024;
const SOURCE_IMAGE_PIXELS_LIMIT: u64 = 64_000_000;
const FFMPEG_THUMBNAIL_BYTES_LIMIT: usize = 32 * 1024 * 1024;
const FFMPEG_STDERR_BYTES_LIMIT: usize = 16 * 1024;
const FFMPEG_TIMEOUT_SECONDS: u64 = 8;
const IMAGE_MAX_EDGE: u32 = 360;
const VIDEO_POSTER_MAX_EDGE: u32 = 360;
const VIDEO_STRIP_WIDTH: u32 = 180;
const VIDEO_STRIP_FRAMES: usize = 6;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MediaClass {
    Folder,
    Image,
    Video,
    Audio,
    Archive,
    Code,
    Pdf,
    Text,
    Symlink,
    Special,
    File,
}

impl MediaClass {
    pub fn label(self) -> &'static str {
        match self {
            Self::Folder => "DIR",
            Self::Image => "IMG",
            Self::Video => "VID",
            Self::Audio => "AUD",
            Self::Archive => "ARC",
            Self::Code => "SRC",
            Self::Pdf => "PDF",
            Self::Text => "TXT",
            Self::Symlink => "LNK",
            Self::Special => "SYS",
            Self::File => "FIL",
        }
    }

    fn supports_thumbnail(self) -> bool {
        matches!(self, Self::Image | Self::Video)
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum ThumbnailMode {
    Poster,
    VideoStrip,
}

#[derive(Clone, Debug, Eq)]
struct ThumbnailKey {
    path: PathBuf,
    mode: ThumbnailMode,
    len: u64,
    modified_ms: u128,
}

impl PartialEq for ThumbnailKey {
    fn eq(&self, other: &Self) -> bool {
        self.path == other.path
            && self.mode == other.mode
            && self.len == other.len
            && self.modified_ms == other.modified_ms
    }
}

impl Hash for ThumbnailKey {
    fn hash<H: Hasher>(&self, state: &mut H) {
        self.path.hash(state);
        self.mode.hash(state);
        self.len.hash(state);
        self.modified_ms.hash(state);
    }
}

#[derive(Clone)]
struct ThumbnailWork {
    key: ThumbnailKey,
    media_class: MediaClass,
}

struct RawFrame {
    width: usize,
    height: usize,
    rgba: Vec<u8>,
}

struct GeneratedThumbnail {
    key: ThumbnailKey,
    frames: Result<Vec<RawFrame>, String>,
}

enum CachedThumbnail {
    Ready {
        textures: Vec<egui::TextureHandle>,
        aspect: f32,
        last_used: u64,
    },
    Failed {
        last_used: u64,
    },
}

#[derive(Clone, Copy, Debug)]
pub struct ThumbnailView {
    pub texture_id: egui::TextureId,
    pub aspect: f32,
    pub animated: bool,
}

#[derive(Clone, Copy, Debug)]
pub struct ThumbnailStats {
    pub cache_entries: usize,
    pub cache_limit: usize,
    pub pending: usize,
    pub queued: usize,
    pub queue_limit: usize,
    pub active_workers: usize,
    pub worker_limit: usize,
}

pub struct ThumbnailManager {
    cache: HashMap<ThumbnailKey, CachedThumbnail>,
    pending: HashSet<ThumbnailKey>,
    queue: VecDeque<ThumbnailWork>,
    tx: SyncSender<GeneratedThumbnail>,
    rx: Receiver<GeneratedThumbnail>,
    active_workers: usize,
    touch_clock: u64,
}

impl ThumbnailManager {
    pub fn new() -> Self {
        let (tx, rx) = mpsc::sync_channel(THUMBNAIL_RESULT_CAPACITY);
        Self {
            cache: HashMap::new(),
            pending: HashSet::new(),
            queue: VecDeque::new(),
            tx,
            rx,
            active_workers: 0,
            touch_clock: 0,
        }
    }

    pub fn poll(&mut self, ctx: &egui::Context) {
        while let Ok(generated) = self.rx.try_recv() {
            self.active_workers = self.active_workers.saturating_sub(1);
            self.pending.remove(&generated.key);
            self.touch_clock = self.touch_clock.saturating_add(1);

            match generated.frames {
                Ok(frames) if !frames.is_empty() => {
                    let mode = generated.key.mode;
                    let path_label = generated.key.path.display().to_string();
                    let mut textures = Vec::with_capacity(frames.len());
                    let mut aspect = 1.0;
                    for (index, frame) in frames.into_iter().enumerate() {
                        aspect = frame.width as f32 / frame.height.max(1) as f32;
                        let image = egui::ColorImage::from_rgba_unmultiplied(
                            [frame.width, frame.height],
                            &frame.rgba,
                        );
                        textures.push(ctx.load_texture(
                            format!("thumb:{mode:?}:{path_label}:{index}"),
                            image,
                            egui::TextureOptions::LINEAR,
                        ));
                    }
                    self.cache.insert(
                        generated.key,
                        CachedThumbnail::Ready {
                            textures,
                            aspect,
                            last_used: self.touch_clock,
                        },
                    );
                }
                _ => {
                    self.cache.insert(
                        generated.key,
                        CachedThumbnail::Failed {
                            last_used: self.touch_clock,
                        },
                    );
                }
            }
        }

        self.start_queued_work();
        self.evict_cold_entries();
    }

    pub fn request_poster(&mut self, path: &Path, len: u64, modified_ms: u128) {
        let media_class = media_class_for_extension(path);
        if !media_class.supports_thumbnail() {
            return;
        }
        self.request(path, ThumbnailMode::Poster, media_class, len, modified_ms);
    }

    pub fn request_hover_strip(&mut self, path: &Path, len: u64, modified_ms: u128) {
        if media_class_for_extension(path) == MediaClass::Video {
            self.request(
                path,
                ThumbnailMode::VideoStrip,
                MediaClass::Video,
                len,
                modified_ms,
            );
        }
    }

    pub fn thumbnail_for(
        &mut self,
        path: &Path,
        len: u64,
        modified_ms: u128,
        hovered: bool,
        phase: f32,
    ) -> Option<ThumbnailView> {
        if hovered {
            if let Some(view) =
                self.cached_view(path, ThumbnailMode::VideoStrip, len, modified_ms, phase)
            {
                return Some(ThumbnailView {
                    animated: true,
                    ..view
                });
            }
        }

        self.cached_view(path, ThumbnailMode::Poster, len, modified_ms, phase)
    }

    pub fn is_loading(&self, path: &Path) -> bool {
        self.pending.iter().any(|key| key.path == path)
    }

    pub fn has_work(&self) -> bool {
        self.active_workers > 0 || !self.queue.is_empty() || !self.pending.is_empty()
    }

    pub fn stats(&self) -> ThumbnailStats {
        ThumbnailStats {
            cache_entries: self.cache.len(),
            cache_limit: MAX_CACHE_ENTRIES,
            pending: self.pending.len(),
            queued: self.queue.len(),
            queue_limit: MAX_QUEUED_WORK,
            active_workers: self.active_workers,
            worker_limit: MAX_ACTIVE_WORKERS,
        }
    }

    fn request(
        &mut self,
        path: &Path,
        mode: ThumbnailMode,
        media_class: MediaClass,
        len: u64,
        modified_ms: u128,
    ) {
        let key = thumbnail_key_from_parts(path, mode, len, modified_ms);

        if self.cache.contains_key(&key) || self.pending.contains(&key) {
            return;
        }

        self.make_room_for_queued_work();
        self.pending.insert(key.clone());
        self.queue.push_back(ThumbnailWork { key, media_class });
        self.start_queued_work();
    }

    fn cached_view(
        &mut self,
        path: &Path,
        mode: ThumbnailMode,
        len: u64,
        modified_ms: u128,
        phase: f32,
    ) -> Option<ThumbnailView> {
        let key = thumbnail_key_from_parts(path, mode, len, modified_ms);
        let entry = self.cache.get_mut(&key)?;
        self.touch_clock = self.touch_clock.saturating_add(1);
        match entry {
            CachedThumbnail::Ready {
                textures,
                aspect,
                last_used,
            } => {
                *last_used = self.touch_clock;
                if textures.is_empty() {
                    return None;
                }
                let index = if mode == ThumbnailMode::VideoStrip && textures.len() > 1 {
                    ((phase * 8.0) as usize) % textures.len()
                } else {
                    0
                };
                Some(ThumbnailView {
                    texture_id: textures[index].id(),
                    aspect: *aspect,
                    animated: mode == ThumbnailMode::VideoStrip,
                })
            }
            CachedThumbnail::Failed { last_used } => {
                *last_used = self.touch_clock;
                None
            }
        }
    }

    fn start_queued_work(&mut self) {
        while self.active_workers < MAX_ACTIVE_WORKERS {
            let Some(work) = self.queue.pop_front() else {
                break;
            };
            self.active_workers += 1;
            let tx = self.tx.clone();
            let key = work.key.clone();
            if thread::Builder::new()
                .name("guth-thumbnail-worker".to_string())
                .spawn(move || {
                    let frames =
                        guard_thumbnail_work(|| generate_thumbnail(&work.key, work.media_class));
                    let _ = tx.send(GeneratedThumbnail {
                        key: work.key,
                        frames,
                    });
                })
                .is_err()
            {
                self.active_workers = self.active_workers.saturating_sub(1);
                self.pending.remove(&key);
                self.touch_clock = self.touch_clock.saturating_add(1);
                self.cache.insert(
                    key,
                    CachedThumbnail::Failed {
                        last_used: self.touch_clock,
                    },
                );
            }
        }
    }

    fn make_room_for_queued_work(&mut self) {
        while self.queue.len() >= MAX_QUEUED_WORK {
            let Some(work) = self.queue.pop_front() else {
                break;
            };
            self.pending.remove(&work.key);
        }
    }

    fn evict_cold_entries(&mut self) {
        if self.cache.len() <= MAX_CACHE_ENTRIES {
            return;
        }
        let mut entries: Vec<(ThumbnailKey, u64)> = self
            .cache
            .iter()
            .map(|(key, value)| {
                let last_used = match value {
                    CachedThumbnail::Ready { last_used, .. }
                    | CachedThumbnail::Failed { last_used } => *last_used,
                };
                (key.clone(), last_used)
            })
            .collect();
        entries.sort_by_key(|(_, last_used)| *last_used);
        let remove_count = self.cache.len().saturating_sub(MAX_CACHE_ENTRIES);
        for (key, _) in entries.into_iter().take(remove_count) {
            self.cache.remove(&key);
        }
    }
}

impl Default for ThumbnailManager {
    fn default() -> Self {
        Self::new()
    }
}

pub fn media_class_for_path(path: &Path, is_dir: bool, is_symlink: bool) -> MediaClass {
    if is_symlink {
        return MediaClass::Symlink;
    }
    if is_dir {
        return MediaClass::Folder;
    }
    media_class_for_extension(path)
}

pub fn media_class_for_extension(path: &Path) -> MediaClass {
    match path
        .extension()
        .and_then(|extension| extension.to_str())
        .unwrap_or_default()
        .to_ascii_lowercase()
        .as_str()
    {
        "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => MediaClass::Image,
        "mp4" | "mkv" | "mov" | "webm" | "avi" | "m4v" | "mpeg" | "mpg" => MediaClass::Video,
        "mp3" | "flac" | "wav" | "ogg" | "m4a" | "aac" | "opus" => MediaClass::Audio,
        "zip" | "tar" | "gz" | "xz" | "zst" | "7z" | "rar" | "bz2" => MediaClass::Archive,
        "rs" | "c" | "h" | "cpp" | "hpp" | "py" | "js" | "ts" | "tsx" | "jsx" | "go" | "java"
        | "kt" | "swift" | "sh" | "toml" | "json" | "yaml" | "yml" => MediaClass::Code,
        "pdf" => MediaClass::Pdf,
        "md" | "txt" | "rst" | "log" | "csv" => MediaClass::Text,
        _ => MediaClass::File,
    }
}

pub fn modified_ms(time: Option<SystemTime>) -> u128 {
    time.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
        .map(|duration| duration.as_millis())
        .unwrap_or_default()
}

fn thumbnail_key_from_parts(
    path: &Path,
    mode: ThumbnailMode,
    len: u64,
    modified_ms: u128,
) -> ThumbnailKey {
    ThumbnailKey {
        path: path.to_path_buf(),
        mode,
        len,
        modified_ms,
    }
}

fn guard_thumbnail_work(
    generate: impl FnOnce() -> Result<Vec<RawFrame>, String>,
) -> Result<Vec<RawFrame>, String> {
    std::panic::catch_unwind(std::panic::AssertUnwindSafe(generate))
        .unwrap_or_else(|_| Err("Thumbnail worker stopped unexpectedly".to_string()))
}

fn generate_thumbnail(
    key: &ThumbnailKey,
    media_class: MediaClass,
) -> Result<Vec<RawFrame>, String> {
    match (media_class, key.mode) {
        (MediaClass::Image, ThumbnailMode::Poster) => {
            decode_image_thumbnail(&key.path, IMAGE_MAX_EDGE).map(|frame| vec![frame])
        }
        (MediaClass::Video, ThumbnailMode::Poster) => {
            decode_video_poster(&key.path).map(|frame| vec![frame])
        }
        (MediaClass::Video, ThumbnailMode::VideoStrip) => decode_video_strip(&key.path),
        _ => Err("unsupported thumbnail request".to_string()),
    }
}

fn decode_image_thumbnail(path: &Path, max_edge: u32) -> Result<RawFrame, String> {
    let metadata =
        std::fs::metadata(path).map_err(|error| format!("image metadata failed: {error}"))?;
    if metadata.len() > SOURCE_IMAGE_BYTES_LIMIT {
        return Err(format!(
            "image too large for thumbnail: {} bytes limit",
            SOURCE_IMAGE_BYTES_LIMIT
        ));
    }
    let dimensions_reader = ImageReader::open(path)
        .map_err(|error| format!("image open failed: {error}"))?
        .with_guessed_format()
        .map_err(|error| format!("image format failed: {error}"))?;
    let (width, height) = dimensions_reader
        .into_dimensions()
        .map_err(|error| format!("image dimensions failed: {error}"))?;
    let pixels = u64::from(width).saturating_mul(u64::from(height));
    if pixels > SOURCE_IMAGE_PIXELS_LIMIT {
        return Err(format!(
            "image dimensions too large for thumbnail: {} pixels limit",
            SOURCE_IMAGE_PIXELS_LIMIT
        ));
    }
    let image = ImageReader::open(path)
        .map_err(|error| format!("image open failed: {error}"))?
        .with_guessed_format()
        .map_err(|error| format!("image format failed: {error}"))?
        .decode()
        .map_err(|error| format!("image decode failed: {error}"))?;
    Ok(dynamic_to_frame(image, max_edge))
}

fn decode_video_poster(path: &Path) -> Result<RawFrame, String> {
    let output = run_ffmpeg_png(
        path,
        &[
            "-ss",
            "00:00:01",
            "-i",
            path.to_string_lossy().as_ref(),
            "-vf",
            &format!("scale={VIDEO_POSTER_MAX_EDGE}:-2:force_original_aspect_ratio=decrease"),
            "-frames:v",
            "1",
            "-f",
            "image2pipe",
            "-vcodec",
            "png",
            "-",
        ],
    )
    .or_else(|_| {
        run_ffmpeg_png(
            path,
            &[
                "-i",
                path.to_string_lossy().as_ref(),
                "-vf",
                &format!("scale={VIDEO_POSTER_MAX_EDGE}:-2:force_original_aspect_ratio=decrease"),
                "-frames:v",
                "1",
                "-f",
                "image2pipe",
                "-vcodec",
                "png",
                "-",
            ],
        )
    })?;
    decode_png_bytes(&output)
}

fn decode_video_strip(path: &Path) -> Result<Vec<RawFrame>, String> {
    let vf = format!(
        "fps=2,scale={VIDEO_STRIP_WIDTH}:-2:force_original_aspect_ratio=decrease,tile={}x1",
        VIDEO_STRIP_FRAMES
    );
    let output = run_ffmpeg_png(
        path,
        &[
            "-i",
            path.to_string_lossy().as_ref(),
            "-vf",
            &vf,
            "-frames:v",
            "1",
            "-f",
            "image2pipe",
            "-vcodec",
            "png",
            "-",
        ],
    )?;
    split_strip_frame(decode_png_bytes(&output)?, VIDEO_STRIP_FRAMES)
}

fn run_ffmpeg_png(_path: &Path, args: &[&str]) -> Result<Vec<u8>, String> {
    let mut child = Command::new("ffmpeg")
        .arg("-hide_banner")
        .arg("-loglevel")
        .arg("error")
        .arg("-nostdin")
        .args(args)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|error| format!("ffmpeg unavailable: {error}"))?;

    let Some(stdout) = child.stdout.take() else {
        terminate_child(&mut child);
        return Err("ffmpeg stdout was not captured".to_string());
    };
    let Some(stderr) = child.stderr.take() else {
        terminate_child(&mut child);
        return Err("ffmpeg stderr was not captured".to_string());
    };

    let stdout_reader =
        match spawn_pipe_reader("guth-ffmpeg-stdout", stdout, FFMPEG_THUMBNAIL_BYTES_LIMIT) {
            Ok(reader) => reader,
            Err(error) => {
                terminate_child(&mut child);
                return Err(error);
            }
        };
    let stderr_reader =
        match spawn_pipe_reader("guth-ffmpeg-stderr", stderr, FFMPEG_STDERR_BYTES_LIMIT) {
            Ok(reader) => reader,
            Err(error) => {
                terminate_child(&mut child);
                let _ = join_pipe_reader(stdout_reader, "stdout");
                return Err(error);
            }
        };

    let timeout = Duration::from_secs(FFMPEG_TIMEOUT_SECONDS);
    let start = Instant::now();
    let status = loop {
        match child.try_wait() {
            Ok(Some(status)) => break status,
            Ok(None) if start.elapsed() >= timeout => {
                terminate_child(&mut child);
                let _ = join_pipe_reader(stdout_reader, "stdout");
                let stderr = join_pipe_reader(stderr_reader, "stderr").unwrap_or_default();
                return Err(format!(
                    "ffmpeg thumbnail timed out after {}s{}",
                    FFMPEG_TIMEOUT_SECONDS,
                    stderr_suffix(&stderr)
                ));
            }
            Ok(None) => thread::sleep(Duration::from_millis(20)),
            Err(error) => {
                terminate_child(&mut child);
                let _ = join_pipe_reader(stdout_reader, "stdout");
                let _ = join_pipe_reader(stderr_reader, "stderr");
                return Err(format!("ffmpeg wait failed: {error}"));
            }
        }
    };

    let stdout = join_pipe_reader(stdout_reader, "stdout")?;
    let stderr = join_pipe_reader(stderr_reader, "stderr")?;

    if status.success() && !stdout.is_empty() {
        Ok(stdout)
    } else {
        Err(format!(
            "ffmpeg thumbnail failed with {status}{}",
            stderr_suffix(&stderr)
        ))
    }
}

fn spawn_pipe_reader<R>(
    name: &str,
    reader: R,
    limit: usize,
) -> Result<thread::JoinHandle<Result<Vec<u8>, String>>, String>
where
    R: Read + Send + 'static,
{
    thread::Builder::new()
        .name(name.to_string())
        .spawn(move || read_bounded_pipe(reader, limit))
        .map_err(|error| format!("could not start {name} reader: {error}"))
}

fn read_bounded_pipe<R: Read>(mut reader: R, limit: usize) -> Result<Vec<u8>, String> {
    let mut output = Vec::new();
    let mut buffer = [0_u8; 8 * 1024];
    loop {
        let read = reader
            .read(&mut buffer)
            .map_err(|error| format!("ffmpeg pipe read failed: {error}"))?;
        if read == 0 {
            return Ok(output);
        }
        if output.len().saturating_add(read) > limit {
            return Err(format!("ffmpeg pipe output exceeded {limit} bytes"));
        }
        output.extend_from_slice(&buffer[..read]);
    }
}

fn join_pipe_reader(
    reader: thread::JoinHandle<Result<Vec<u8>, String>>,
    name: &str,
) -> Result<Vec<u8>, String> {
    reader
        .join()
        .map_err(|_| format!("ffmpeg {name} reader panicked"))?
}

fn terminate_child(child: &mut Child) {
    let _ = child.kill();
    let _ = child.wait();
}

fn stderr_suffix(stderr: &[u8]) -> String {
    let text = String::from_utf8_lossy(stderr);
    let trimmed = text.trim();
    if trimmed.is_empty() {
        String::new()
    } else {
        format!(": {trimmed}")
    }
}

fn decode_png_bytes(bytes: &[u8]) -> Result<RawFrame, String> {
    let image = image::load(Cursor::new(bytes), image::ImageFormat::Png)
        .map_err(|error| format!("png decode failed: {error}"))?;
    Ok(dynamic_to_frame(image, u32::MAX))
}

fn dynamic_to_frame(image: DynamicImage, max_edge: u32) -> RawFrame {
    let image = if max_edge == u32::MAX {
        image
    } else {
        image.thumbnail(max_edge, max_edge)
    };
    let rgba = image.to_rgba8();
    let (width, height) = rgba.dimensions();
    RawFrame {
        width: width as usize,
        height: height as usize,
        rgba: rgba.into_raw(),
    }
}

fn split_strip_frame(frame: RawFrame, columns: usize) -> Result<Vec<RawFrame>, String> {
    if columns == 0 || frame.width < columns {
        return Ok(vec![frame]);
    }

    let frame_width = frame.width / columns;
    if frame_width == 0 {
        return Ok(vec![frame]);
    }

    let mut frames = Vec::with_capacity(columns);
    for column in 0..columns {
        let start_x = column * frame_width;
        let mut rgba = vec![0; frame_width * frame.height * 4];
        for y in 0..frame.height {
            let src_start = (y * frame.width + start_x) * 4;
            let src_end = src_start + frame_width * 4;
            let dst_start = y * frame_width * 4;
            rgba[dst_start..dst_start + frame_width * 4]
                .copy_from_slice(&frame.rgba[src_start..src_end]);
        }
        frames.push(RawFrame {
            width: frame_width,
            height: frame.height,
            rgba,
        });
    }

    Ok(frames)
}

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

    #[test]
    fn classifies_common_media_extensions() {
        assert_eq!(
            media_class_for_extension(Path::new("a.PNG")),
            MediaClass::Image
        );
        assert_eq!(
            media_class_for_extension(Path::new("a.mp4")),
            MediaClass::Video
        );
        assert_eq!(
            media_class_for_extension(Path::new("a.rs")),
            MediaClass::Code
        );
        assert_eq!(
            media_class_for_extension(Path::new("a.pdf")),
            MediaClass::Pdf
        );
    }

    #[test]
    fn splits_horizontal_strip() {
        let frame = RawFrame {
            width: 6,
            height: 1,
            rgba: vec![
                1, 0, 0, 255, 2, 0, 0, 255, 3, 0, 0, 255, 4, 0, 0, 255, 5, 0, 0, 255, 6, 0, 0, 255,
            ],
        };
        let frames = split_strip_frame(frame, 3).unwrap();
        assert_eq!(frames.len(), 3);
        assert_eq!(frames[0].rgba[0], 1);
        assert_eq!(frames[1].rgba[0], 3);
        assert_eq!(frames[2].rgba[0], 5);
    }

    #[test]
    fn bounded_pipe_reader_accepts_output_within_limit() {
        let output = read_bounded_pipe(Cursor::new(b"small"), 8).unwrap();
        assert_eq!(output, b"small");
    }

    #[test]
    fn bounded_pipe_reader_rejects_output_over_limit() {
        let error = read_bounded_pipe(Cursor::new(b"too-large"), 4).unwrap_err();
        assert!(error.contains("exceeded"));
    }

    #[test]
    fn stderr_suffix_omits_empty_text() {
        assert_eq!(stderr_suffix(b"\n\t "), "");
        assert_eq!(stderr_suffix(b"bad input"), ": bad input");
    }

    #[test]
    fn thumbnail_worker_converts_panics_to_failures() {
        let result = guard_thumbnail_work(|| panic!("decoder panic"));
        assert!(matches!(
            result,
            Err(message) if message == "Thumbnail worker stopped unexpectedly"
        ));
    }
}
