use ab_glyph::{FontArc, PxScale};
use domain::errors::DomainError;
use domain::models::wrapup::WrapUpReport;
use image::{DynamicImage, Rgba, RgbaImage};
use imageproc::drawing::{draw_filled_rect_mut, draw_text_mut};
use imageproc::rect::Rect;

fn decode_image(bytes: &[u8]) -> Result<DynamicImage, String> {
    image::load_from_memory(bytes).or_else(|_| {
        let dir = tempfile::tempdir().map_err(|e| e.to_string())?;
        let input = dir.path().join("input");
        let output = dir.path().join("output.png");
        std::fs::write(&input, bytes).map_err(|e| e.to_string())?;
        let status = std::process::Command::new("ffmpeg")
            .args([
                "-y",
                "-i",
                &input.to_string_lossy(),
                &output.to_string_lossy(),
            ])
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .map_err(|e| e.to_string())?;
        if !status.success() {
            return Err("ffmpeg conversion failed".into());
        }
        let png_bytes = std::fs::read(&output).map_err(|e| e.to_string())?;
        image::load_from_memory(&png_bytes).map_err(|e| e.to_string())
    })
}

fn resize_cover(img: &RgbaImage, w: u32, h: u32) -> RgbaImage {
    let (iw, ih) = (img.width() as f64, img.height() as f64);
    let scale = (w as f64 / iw).max(h as f64 / ih);
    let sw = (iw * scale).ceil() as u32;
    let sh = (ih * scale).ceil() as u32;
    let scaled = image::imageops::resize(img, sw, sh, image::imageops::FilterType::CatmullRom);
    let cx = (sw.saturating_sub(w)) / 2;
    let cy = (sh.saturating_sub(h)) / 2;
    image::imageops::crop_imm(&scaled, cx, cy, w, h).to_image()
}

const BG: Rgba<u8> = Rgba([26, 26, 36, 255]);
const GOLD: Rgba<u8> = Rgba([229, 192, 52, 255]);
const WHITE: Rgba<u8> = Rgba([255, 255, 255, 255]);
const DIM: Rgba<u8> = Rgba([255, 255, 255, 140]);
const BAR_BG: Rgba<u8> = Rgba([50, 50, 65, 255]);
const GLASS: Rgba<u8> = Rgba([20, 20, 30, 180]);
const GLASS_PADDING: u32 = 30;

pub struct SlideRenderer {
    font: FontArc,
    logo: Option<RgbaImage>,
    backgrounds: Vec<RgbaImage>,
}

impl SlideRenderer {
    pub fn new(
        font_path: Option<&str>,
        logo_path: Option<&str>,
        bg_dir: Option<&str>,
    ) -> Result<Self, DomainError> {
        let font = if let Some(path) = font_path {
            let bytes = std::fs::read(path)
                .map_err(|e| DomainError::InfrastructureError(format!("font load: {e}")))?;
            FontArc::try_from_vec(bytes)
                .map_err(|e| DomainError::InfrastructureError(format!("font parse: {e}")))?
        } else {
            load_system_font()?
        };

        let logo = if let Some(path) = logo_path {
            let img = image::open(path)
                .map_err(|e| DomainError::InfrastructureError(format!("logo load: {e}")))?;
            Some(img.to_rgba8())
        } else {
            None
        };

        let mut backgrounds = Vec::new();
        if let Some(dir) = bg_dir
            && let Ok(entries) = std::fs::read_dir(dir)
        {
            for entry in entries.flatten() {
                let path = entry.path();
                let ext = path
                    .extension()
                    .and_then(|e| e.to_str())
                    .unwrap_or("")
                    .to_lowercase();
                if matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "webp") {
                    match image::open(&path) {
                        Ok(img) => backgrounds.push(img.to_rgba8()),
                        Err(e) => tracing::warn!("bg load {}: {e}", path.display()),
                    }
                }
            }
        }

        Ok(Self {
            font,
            logo,
            backgrounds,
        })
    }

    /// Pick a background for slide at `index`, resized to `w x h` with dark gradient overlay.
    fn pick_background(&self, index: usize, w: u32, h: u32) -> Option<RgbaImage> {
        if self.backgrounds.is_empty() {
            return None;
        }
        let bg = &self.backgrounds[index % self.backgrounds.len()];
        let mut out = resize_cover(bg, w, h);
        // darken top 40% and bottom 40% with gradient to ~70% black
        let top_cutoff = (h as f32 * 0.4) as u32;
        let bot_start = h - top_cutoff;
        for y in 0..h {
            let darken = if y < top_cutoff {
                // fade from 0.70 at top to 0.0 at cutoff
                0.70 * (1.0 - y as f32 / top_cutoff as f32)
            } else if y >= bot_start {
                // fade from 0.0 at bot_start to 0.70 at bottom
                0.70 * ((y - bot_start) as f32 / top_cutoff as f32)
            } else {
                0.0
            };
            if darken > 0.0 {
                let factor = 1.0 - darken;
                for x in 0..w {
                    let px = out.get_pixel_mut(x, y);
                    px[0] = (px[0] as f32 * factor) as u8;
                    px[1] = (px[1] as f32 * factor) as u8;
                    px[2] = (px[2] as f32 * factor) as u8;
                }
            }
        }
        Some(out)
    }

    /// Start a canvas: background image if available, else solid color.
    fn make_canvas(&self, slide_index: usize, w: u32, h: u32) -> RgbaImage {
        self.pick_background(slide_index, w, h)
            .unwrap_or_else(|| fill(w, h))
    }

    /// Draw a semi-transparent dark glass panel.
    fn draw_glass_panel(&self, canvas: &mut RgbaImage, x: i32, y: i32, pw: u32, ph: u32) {
        let x0 = x.max(0) as u32;
        let y0 = y.max(0) as u32;
        let x1 = (x as u32 + pw).min(canvas.width());
        let y1 = (y as u32 + ph).min(canvas.height());
        if x1 <= x0 || y1 <= y0 {
            return;
        }
        let alpha = GLASS[3] as f32 / 255.0;
        let inv = 1.0 - alpha;
        for py in y0..y1 {
            for px in x0..x1 {
                let bg = canvas.get_pixel(px, py);
                let r = (GLASS[0] as f32 * alpha + bg[0] as f32 * inv) as u8;
                let g = (GLASS[1] as f32 * alpha + bg[1] as f32 * inv) as u8;
                let b = (GLASS[2] as f32 * alpha + bg[2] as f32 * inv) as u8;
                canvas.put_pixel(px, py, Rgba([r, g, b, 255]));
            }
        }
    }

    fn stamp_logo(&self, canvas: &mut RgbaImage) {
        if let Some(ref logo) = self.logo {
            let logo_size = 64u32;
            let resized = image::imageops::resize(
                logo,
                logo_size,
                logo_size,
                image::imageops::FilterType::Triangle,
            );
            let margin = 20i64;
            let x = canvas.width() as i64 - logo_size as i64 - margin;
            let y = canvas.height() as i64 - logo_size as i64 - margin;
            image::imageops::overlay(canvas, &resized, x, y);
        }
    }

    fn draw_centered(
        &self,
        canvas: &mut RgbaImage,
        text: &str,
        y: i32,
        scale: f32,
        color: Rgba<u8>,
    ) {
        let px = PxScale::from(scale);
        let approx_w = (text.len() as f32 * scale * 0.45) as i32;
        let x = ((canvas.width() as i32 - approx_w) / 2).max(10);
        draw_text_mut(canvas, color, x, y, px, &self.font, text);
    }

    fn draw_left(
        &self,
        canvas: &mut RgbaImage,
        text: &str,
        x: i32,
        y: i32,
        scale: f32,
        color: Rgba<u8>,
    ) {
        draw_text_mut(canvas, color, x, y, PxScale::from(scale), &self.font, text);
    }

    /// Draw a small thumbnail from raw image bytes, resized to `size x size`.
    fn draw_thumbnail(canvas: &mut RgbaImage, bytes: &[u8], x: i64, y: i64, tw: u32, th: u32) {
        if let Ok(img) = decode_image(bytes) {
            let thumb = img.resize_exact(tw, th, image::imageops::FilterType::Triangle);
            image::imageops::overlay(canvas, &thumb.to_rgba8(), x, y);
        }
    }

    /// Find cast photo bytes matching `name` (case-insensitive substring).
    fn find_cast_photo<'a>(name: &str, cast_images: &'a [(String, Vec<u8>)]) -> Option<&'a [u8]> {
        let lower = name.to_lowercase();
        cast_images
            .iter()
            .find(|(n, _)| {
                let cn = n.to_lowercase();
                cn.contains(&lower) || lower.contains(&cn)
            })
            .map(|(_, b)| b.as_slice())
    }

    /// Find poster bytes matching a poster_path (compare filename stem).
    fn find_poster<'a>(
        poster_path: &str,
        poster_images: &'a [(String, Vec<u8>)],
    ) -> Option<&'a [u8]> {
        let target = std::path::Path::new(poster_path)
            .file_name()
            .and_then(|f| f.to_str())
            .unwrap_or(poster_path);
        poster_images
            .iter()
            .find(|(p, _)| {
                let fname = std::path::Path::new(p)
                    .file_name()
                    .and_then(|f| f.to_str())
                    .unwrap_or(p);
                fname == target
            })
            .map(|(_, b)| b.as_slice())
    }

    // ── Slides ──────────────────────────────────────────────

    pub fn render_hero(
        &self,
        report: &WrapUpReport,
        w: u32,
        h: u32,
    ) -> Result<Vec<u8>, DomainError> {
        let mut img = self.make_canvas(0, w, h);

        // glass panel in center area
        let panel_x = GLASS_PADDING as i32;
        let panel_y = (h / 7) as i32;
        let panel_w = w - GLASS_PADDING * 2;
        let panel_h = h * 5 / 7;
        self.draw_glass_panel(&mut img, panel_x, panel_y, panel_w, panel_h);

        let year_label = format!(
            "{} - {}",
            report.date_range.start().format("%b %Y"),
            report.date_range.end().format("%b %Y")
        );
        self.draw_centered(&mut img, &year_label, (h / 6) as i32, 48.0, DIM);
        self.draw_centered(
            &mut img,
            &report.total_movies.to_string(),
            (h / 3) as i32,
            160.0,
            GOLD,
        );
        self.draw_centered(
            &mut img,
            "movies watched",
            (h / 3 + 170) as i32,
            40.0,
            WHITE,
        );

        let hours = report.total_watch_time_minutes / 60;
        let mins = report.total_watch_time_minutes % 60;
        let time_str = format!("{}h {}m of watch time", hours, mins);
        self.draw_centered(&mut img, &time_str, (h / 2 + 60) as i32, 36.0, DIM);

        if let Some(ref month) = report.busiest_month {
            let s = format!("Busiest month: {month}");
            self.draw_centered(&mut img, &s, (h * 2 / 3) as i32, 32.0, DIM);
        }
        if let Some(ref dow) = report.busiest_day_of_week {
            let s = format!("Favorite day: {dow}");
            self.draw_centered(&mut img, &s, (h * 2 / 3 + 50) as i32, 32.0, DIM);
        }

        self.stamp_logo(&mut img);
        to_png(&img)
    }

    pub fn render_ratings(
        &self,
        report: &WrapUpReport,
        w: u32,
        h: u32,
    ) -> Result<Vec<u8>, DomainError> {
        let mut img = self.make_canvas(1, w, h);

        // glass panel covering content area
        let panel_x = (GLASS_PADDING / 2) as i32;
        let panel_y = (h / 10) as i32;
        let panel_w = w - GLASS_PADDING;
        let panel_h = h * 4 / 5;
        self.draw_glass_panel(&mut img, panel_x, panel_y, panel_w, panel_h);

        self.draw_centered(&mut img, "Ratings", (h / 8) as i32, 56.0, GOLD);

        if let Some(avg) = report.avg_rating {
            let s = format!("{:.1} / 5", avg);
            self.draw_centered(&mut img, &s, (h / 4) as i32, 80.0, WHITE);
            self.draw_centered(&mut img, "average rating", (h / 4 + 90) as i32, 32.0, DIM);
        }

        let max_count = report
            .rating_distribution
            .iter()
            .copied()
            .max()
            .unwrap_or(1)
            .max(1);
        let bar_area_top = (h / 2) as i32;
        let bar_h = 36u32;
        let bar_gap = 16u32;
        let margin_x = 120i32;
        let max_bar_w = (w as i32 - margin_x * 2) as u32;

        for row in 0..5 {
            let stars = 5 - row;
            let count = report.rating_distribution[stars - 1];
            let label = format!("{stars}\u{2605}");
            let y = bar_area_top + (row as i32) * (bar_h as i32 + bar_gap as i32);
            self.draw_left(&mut img, &label, margin_x - 60, y + 2, 28.0, GOLD);

            draw_filled_rect_mut(
                &mut img,
                Rect::at(margin_x, y).of_size(max_bar_w, bar_h),
                BAR_BG,
            );
            let fill_w = ((count as f32 / max_count as f32) * max_bar_w as f32) as u32;
            if fill_w > 0 {
                draw_filled_rect_mut(&mut img, Rect::at(margin_x, y).of_size(fill_w, bar_h), GOLD);
            }
            let count_s = count.to_string();
            self.draw_left(
                &mut img,
                &count_s,
                margin_x + fill_w as i32 + 10,
                y + 2,
                24.0,
                DIM,
            );
        }

        self.stamp_logo(&mut img);
        to_png(&img)
    }

    pub fn render_directors(
        &self,
        report: &WrapUpReport,
        cast_images: &[(String, Vec<u8>)],
        w: u32,
        h: u32,
    ) -> Result<Vec<u8>, DomainError> {
        let mut img = self.make_canvas(2, w, h);

        let margin = 80i32;
        let start_y = (h / 4) as i32;
        let row_h = 100i32;
        let panel_h = (report.top_directors.len().min(5) as u32) * row_h as u32 + GLASS_PADDING * 2;
        self.draw_glass_panel(
            &mut img,
            margin - GLASS_PADDING as i32,
            start_y - GLASS_PADDING as i32,
            w - (margin as u32 - GLASS_PADDING) * 2,
            panel_h,
        );

        self.draw_centered(&mut img, "Top Directors", (h / 8) as i32, 56.0, GOLD);

        let thumb_size = 60u32;
        // offset text right when cast photos present
        let text_offset = if cast_images.is_empty() {
            60
        } else {
            thumb_size as i32 + 20
        };

        for (i, d) in report.top_directors.iter().take(5).enumerate() {
            let y = start_y + (i as i32) * row_h;

            // cast photo thumbnail
            if let Some(photo) = Self::find_cast_photo(&d.name, cast_images) {
                Self::draw_thumbnail(
                    &mut img,
                    photo,
                    margin as i64 + 40,
                    y as i64,
                    thumb_size,
                    thumb_size,
                );
            }

            let rank = format!("{}.", i + 1);
            self.draw_left(&mut img, &rank, margin, y + 10, 36.0, GOLD);
            self.draw_left(&mut img, &d.name, margin + text_offset, y + 10, 36.0, WHITE);
            let detail = format!("{} films  avg {:.1}\u{2605}", d.count, d.avg_rating);
            self.draw_left(&mut img, &detail, margin + text_offset, y + 54, 24.0, DIM);
        }

        self.stamp_logo(&mut img);
        to_png(&img)
    }

    pub fn render_actors(
        &self,
        report: &WrapUpReport,
        cast_images: &[(String, Vec<u8>)],
        w: u32,
        h: u32,
    ) -> Result<Vec<u8>, DomainError> {
        let mut img = self.make_canvas(3, w, h);

        let margin = 80i32;
        let start_y = (h / 4) as i32;
        let row_h = 100i32;
        let panel_h = (report.top_actors.len().min(5) as u32) * row_h as u32 + GLASS_PADDING * 2;
        self.draw_glass_panel(
            &mut img,
            margin - GLASS_PADDING as i32,
            start_y - GLASS_PADDING as i32,
            w - (margin as u32 - GLASS_PADDING) * 2,
            panel_h,
        );

        self.draw_centered(&mut img, "Top Actors", (h / 8) as i32, 56.0, GOLD);

        let thumb_size = 60u32;
        let text_offset = if cast_images.is_empty() {
            60
        } else {
            thumb_size as i32 + 20
        };

        for (i, a) in report.top_actors.iter().take(5).enumerate() {
            let y = start_y + (i as i32) * row_h;

            if let Some(photo) = Self::find_cast_photo(&a.name, cast_images) {
                Self::draw_thumbnail(
                    &mut img,
                    photo,
                    margin as i64 + 40,
                    y as i64,
                    thumb_size,
                    thumb_size,
                );
            }

            let rank = format!("{}.", i + 1);
            self.draw_left(&mut img, &rank, margin, y + 10, 36.0, GOLD);
            self.draw_left(&mut img, &a.name, margin + text_offset, y + 10, 36.0, WHITE);
            let detail = format!("{} films  avg {:.1}\u{2605}", a.count, a.avg_rating);
            self.draw_left(&mut img, &detail, margin + text_offset, y + 54, 24.0, DIM);
        }

        self.stamp_logo(&mut img);
        to_png(&img)
    }

    pub fn render_genres(
        &self,
        report: &WrapUpReport,
        w: u32,
        h: u32,
    ) -> Result<Vec<u8>, DomainError> {
        let mut img = self.make_canvas(4, w, h);

        let margin = 80i32;
        let start_y = (h / 4) as i32;
        let num_genres = report.top_genres.len().min(8) as u32;
        let panel_h = num_genres * 80 + GLASS_PADDING * 2 + 80;
        self.draw_glass_panel(
            &mut img,
            margin - GLASS_PADDING as i32,
            (h / 10) as i32,
            w - (margin as u32 - GLASS_PADDING) * 2,
            panel_h + (start_y as u32 - h / 10),
        );

        self.draw_centered(&mut img, "Genre Breakdown", (h / 8) as i32, 56.0, GOLD);

        let detail = format!("{} genres explored", report.genre_diversity);
        self.draw_centered(&mut img, &detail, (h / 8) as i32 + 64, 28.0, DIM);

        let bar_area_w = (w as i32 - margin * 2 - 200) as u32;
        let max_count = report
            .top_genres
            .first()
            .map(|g| g.count)
            .unwrap_or(1)
            .max(1);

        for (i, g) in report.top_genres.iter().take(8).enumerate() {
            let y = start_y + (i as i32) * 80;
            self.draw_left(&mut img, &g.genre, margin, y, 30.0, WHITE);
            let count_str = format!("{}", g.count);
            self.draw_left(&mut img, &count_str, w as i32 - margin - 40, y, 30.0, DIM);

            let bar_y = y + 38;
            let bar_w = (g.count as f64 / max_count as f64 * bar_area_w as f64) as u32;
            draw_filled_rect_mut(
                &mut img,
                Rect::at(margin, bar_y).of_size(bar_area_w, 12),
                BAR_BG,
            );
            if bar_w > 0 {
                draw_filled_rect_mut(&mut img, Rect::at(margin, bar_y).of_size(bar_w, 12), GOLD);
            }
        }

        if let Some(ref best) = report.highest_rated_genre {
            let text = format!("Highest rated: {best}");
            self.draw_centered(&mut img, &text, h as i32 - 180, 28.0, WHITE);
        }
        if let Some(ref worst) = report.lowest_rated_genre {
            let text = format!("Lowest rated: {worst}");
            self.draw_centered(&mut img, &text, h as i32 - 140, 28.0, DIM);
        }

        self.stamp_logo(&mut img);
        to_png(&img)
    }

    pub fn render_highlights(
        &self,
        report: &WrapUpReport,
        poster_images: &[(String, Vec<u8>)],
        w: u32,
        h: u32,
    ) -> Result<Vec<u8>, DomainError> {
        let mut img = self.make_canvas(5, w, h);

        // glass panel behind highlights grid
        let panel_x = GLASS_PADDING as i32;
        let panel_y = (h / 10) as i32;
        let panel_w = w - GLASS_PADDING * 2;
        let panel_h = h * 4 / 5;
        self.draw_glass_panel(&mut img, panel_x, panel_y, panel_w, panel_h);

        self.draw_centered(&mut img, "Highlights", (h / 10) as i32 + 10, 56.0, GOLD);

        let col_w = w / 2;
        let start_y = (h / 5) as i32;
        let row_h = (h / 5) as i32;
        let left = 60i32;
        let right = col_w as i32 + 40;
        let poster_w = 100u32;
        let poster_h = 150u32;

        let items: Vec<(&str, Option<&domain::models::wrapup::MovieRef>)> = vec![
            ("Highest Rated", report.highest_rated_movie.as_ref()),
            ("Lowest Rated", report.lowest_rated_movie.as_ref()),
            ("Oldest Film", report.oldest_movie.as_ref()),
            ("Newest Film", report.newest_movie.as_ref()),
            ("Longest", report.longest_movie.as_ref()),
            ("Shortest", report.shortest_movie.as_ref()),
            ("First Watched", report.first_movie_of_period.as_ref()),
            ("Last Watched", report.last_movie_of_period.as_ref()),
        ];

        for (i, (label, movie_ref)) in items.iter().enumerate() {
            let col = i % 2;
            let row = i / 2;
            let x = if col == 0 { left } else { right };
            let y = start_y + (row as i32) * row_h;

            // poster thumbnail if available
            let text_x_offset = if let Some(m) = movie_ref {
                if let Some(ref pp) = m.poster_path {
                    if let Some(pb) = Self::find_poster(pp, poster_images) {
                        Self::draw_thumbnail(
                            &mut img,
                            pb,
                            x as i64,
                            (y + 30) as i64,
                            poster_w,
                            poster_h,
                        );
                        poster_w as i32 + 10
                    } else {
                        0
                    }
                } else {
                    0
                }
            } else {
                0
            };

            self.draw_left(&mut img, label, x, y, 28.0, GOLD);
            if let Some(m) = movie_ref {
                let title = if m.title.len() > 22 {
                    format!("{}...", &m.title[..19])
                } else {
                    m.title.clone()
                };
                self.draw_left(&mut img, &title, x + text_x_offset, y + 36, 26.0, WHITE);
                let sub = format!("({})", m.year);
                self.draw_left(&mut img, &sub, x + text_x_offset, y + 68, 22.0, DIM);
            } else {
                self.draw_left(&mut img, "-", x, y + 36, 26.0, DIM);
            }
        }

        // Rewatches
        if report.total_rewatches > 0 {
            let rewatch_y = start_y + 4 * row_h + 20;
            let s = format!("{} rewatches", report.total_rewatches);
            self.draw_centered(&mut img, &s, rewatch_y, 30.0, DIM);
            if let Some(ref m) = report.most_rewatched_movie {
                let s2 = format!("Most rewatched: {}", m.title);
                self.draw_centered(&mut img, &s2, rewatch_y + 40, 26.0, WHITE);
            }
        }

        self.stamp_logo(&mut img);
        to_png(&img)
    }

    pub fn render_mosaic(
        &self,
        posters: &[(String, Vec<u8>)],
        w: u32,
        h: u32,
    ) -> Result<Vec<u8>, DomainError> {
        let mut canvas = RgbaImage::from_pixel(w, h, Rgba([0, 0, 0, 255]));

        // poster aspect 2:3, calculate grid to fill entire frame
        // find cols that best tile the width, then rows to fill height
        let poster_ratio = 2.0_f32 / 3.0;
        // try col counts from 3..8, pick one that wastes least space
        let cols = (3..=8)
            .min_by_key(|&c| {
                let tw = w / c;
                let th = (tw as f32 / poster_ratio) as u32;
                let rows_needed = h.div_ceil(th);
                let total = rows_needed * c;
                // prefer filling screen with fewer leftover pixels
                let waste_y = (rows_needed * th).saturating_sub(h);
                let shortage = total.saturating_sub(posters.len() as u32);
                waste_y + shortage * 100
            })
            .unwrap_or(4);

        let thumb_w = w / cols;
        let thumb_h = (thumb_w as f32 / poster_ratio) as u32;
        let total_rows = h.div_ceil(thumb_h);
        let total_cells = (total_rows * cols) as usize;

        for i in 0..total_cells {
            if posters.is_empty() {
                break;
            }
            // tile/repeat if not enough posters
            let idx = i % posters.len();
            let (name, bytes) = &posters[idx];
            let col = (i as u32) % cols;
            let row = (i as u32) / cols;
            let x = col * thumb_w;
            let y = row * thumb_h;

            match decode_image(bytes) {
                Ok(poster) => {
                    let thumb = poster.resize_exact(
                        thumb_w,
                        thumb_h,
                        image::imageops::FilterType::Triangle,
                    );
                    image::imageops::overlay(&mut canvas, &thumb.to_rgba8(), x as i64, y as i64);
                }
                Err(e) => tracing::debug!("mosaic: skipped {name}: {e}"),
            }
        }

        self.stamp_logo(&mut canvas);
        to_png(&canvas)
    }
}

fn fill(w: u32, h: u32) -> RgbaImage {
    RgbaImage::from_pixel(w, h, BG)
}

fn to_png(img: &RgbaImage) -> Result<Vec<u8>, DomainError> {
    let mut buf = Vec::new();
    img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
        .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
    Ok(buf)
}

fn load_system_font() -> Result<FontArc, DomainError> {
    let candidates = [
        "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
        "/usr/share/fonts/TTF/DejaVuSans.ttf",
        "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf",
        "/usr/share/fonts/noto/NotoSans-Regular.ttf",
        "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
        "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
        "/System/Library/Fonts/Helvetica.ttc",
    ];
    for path in &candidates {
        if let Ok(bytes) = std::fs::read(path)
            && let Ok(font) = FontArc::try_from_vec(bytes)
        {
            tracing::info!("loaded system font: {path}");
            return Ok(font);
        }
    }
    Err(DomainError::InfrastructureError(
        "no system font found; set font_path in VideoRenderConfig or WRAPUP_FONT_PATH env"
            .to_string(),
    ))
}
