//! Operator-logo compositor for the iPXE menu.
//!
//! The brief: match iVentoy's polished centered-logo PXE chrome with
//! whatever raster the operator drops onto Settings → Branding. A wide
//! wordmark, a portrait stack, a square monogram — all three should
//! land in roughly the same place on the boot screen.
//!
//! Approach: decode the operator's upload, fit it into a fixed
//! 1024×768 canvas with the logo horizontally centered and pinned a
//! short margin from the top, re-encode as PNG, return the bytes. iPXE
//! built with `IMAGE_PNG` paints the result via `console --picture`.
//!
//! The 1024×768 size matches the default VESA framebuffer iPXE picks
//! on most BIOS/UEFI consoles. Operators uploading 4K logos get
//! correctly downscaled; tiny icons get drawn at their native size,
//! centered, with transparent margins.
//!
//! We deliberately don't ship `resvg` for SVG support — keeping the
//! dependency surface narrow matters more than supporting SVG-only
//! brand assets. The WebUI's logo stays SVG-native (the browser
//! rasterizes it); the PXE menu wants a raster regardless.

use image::imageops::FilterType;
use image::{DynamicImage, ImageError, ImageFormat, Rgba, RgbaImage};
use std::io::Cursor;

/// Canvas dimensions used for the composed PXE logo. Picked to match
/// the framebuffer dimensions iPXE picks on most BIOS/UEFI consoles —
/// gives a 1:1 paint with no scaling at the firmware layer.
pub const CANVAS_W: u32 = 1024;
pub const CANVAS_H: u32 = 768;

/// Maximum dimensions for the operator's logo inside the canvas. Any
/// upload larger than this in either axis is downscaled (preserving
/// aspect ratio) to fit. Smaller uploads paint at native size.
const LOGO_MAX_W: u32 = 600;
const LOGO_MAX_H: u32 = 200;

/// Top margin in pixels from the canvas's top edge to the logo's top
/// edge. Matches the visual rhythm of iVentoy's screen (logo at top,
/// menu below).
const LOGO_TOP_MARGIN: u32 = 64;

/// Compose `src_bytes` (any PNG/JPEG/WebP/GIF) into a centered-top
/// 1024×768 PNG and return the encoded bytes.
///
/// Errors when the source can't be decoded or the encoded buffer can't
/// be written (only really fires on out-of-memory; the encoder itself
/// is infallible for well-formed inputs).
pub fn compose_pxe_logo(src_bytes: &[u8]) -> Result<Vec<u8>, ImageError> {
    let logo = image::load_from_memory(src_bytes)?;
    // Resize-fit if the upload exceeds our bounding box. `Lanczos3`
    // keeps the antialiasing crisp on the framebuffer console; it's a
    // touch slower than `Triangle` but the operator hits this endpoint
    // once per boot at most.
    let logo = downscale_to_fit(logo, LOGO_MAX_W, LOGO_MAX_H);
    let logo_rgba = logo.to_rgba8();

    // Transparent canvas. iPXE 1.21+ honours alpha-channel transparency
    // on framebuffer consoles; older builds simply draw the alpha as
    // black, which still gives a sensible look.
    let mut canvas: RgbaImage = RgbaImage::from_pixel(CANVAS_W, CANVAS_H, Rgba([0, 0, 0, 0]));
    let logo_w = logo_rgba.width();
    let logo_h = logo_rgba.height();
    // Horizontal center, top-margin from the top. Saturating math
    // means a logo wider than CANVAS_W (shouldn't happen after the
    // downscale above, but defensive) just sits flush-left.
    let off_x = CANVAS_W.saturating_sub(logo_w) / 2;
    let off_y = LOGO_TOP_MARGIN.min(CANVAS_H.saturating_sub(logo_h));
    image::imageops::overlay(&mut canvas, &logo_rgba, off_x.into(), off_y.into());

    let mut out = Vec::with_capacity(64 * 1024);
    DynamicImage::ImageRgba8(canvas).write_to(&mut Cursor::new(&mut out), ImageFormat::Png)?;
    Ok(out)
}

fn downscale_to_fit(img: DynamicImage, max_w: u32, max_h: u32) -> DynamicImage {
    let (w, h) = (img.width(), img.height());
    if w <= max_w && h <= max_h {
        return img;
    }
    // Preserve aspect ratio. `resize` clamps to the smaller of the
    // two scale factors so we never overshoot the bounding box.
    img.resize(max_w, max_h, FilterType::Lanczos3)
}

#[cfg(test)]
mod tests {
    use super::*;
    use image::{ImageBuffer, Rgb};

    fn solid_png(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
        let img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(w, h, Rgb(rgb));
        let mut out = Vec::with_capacity(4096);
        DynamicImage::ImageRgb8(img)
            .write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
            .unwrap();
        out
    }

    #[test]
    fn compose_emits_canvas_sized_png() {
        let src = solid_png(120, 60, [200, 50, 50]);
        let out = compose_pxe_logo(&src).unwrap();
        // Round-trip the output and confirm dimensions.
        let img = image::load_from_memory(&out).unwrap();
        assert_eq!(img.width(), CANVAS_W);
        assert_eq!(img.height(), CANVAS_H);
    }

    #[test]
    fn small_logo_centered_at_top_margin() {
        let src = solid_png(100, 40, [10, 200, 10]);
        let out = compose_pxe_logo(&src).unwrap();
        let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
        // Pixel just inside the logo box should match the source color
        // (alpha=255). Pixel near a far corner of the canvas should be
        // the transparent background.
        let cx = (CANVAS_W - 100) / 2;
        let cy = LOGO_TOP_MARGIN;
        let inside = canvas.get_pixel(cx + 10, cy + 10);
        assert_eq!(inside.0[3], 255, "logo pixel should be opaque");
        assert!(inside.0[0] < 100 && inside.0[1] > 100 && inside.0[2] < 100, "color mismatch: {inside:?}");
        let corner = canvas.get_pixel(CANVAS_W - 1, CANVAS_H - 1);
        assert_eq!(corner.0[3], 0, "canvas corner should be transparent");
    }

    #[test]
    fn oversize_logo_is_downscaled_to_bounding_box() {
        // 4000×800 image — bigger than LOGO_MAX_W and LOGO_MAX_H in
        // both axes. After downscale the output must fit; we re-decode
        // the canvas, count non-transparent pixels, and confirm none
        // sit outside the expected band.
        let src = solid_png(4000, 800, [50, 50, 200]);
        let out = compose_pxe_logo(&src).unwrap();
        let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
        // Span row at the top margin should have non-transparent
        // pixels somewhere; rows past the LOGO_TOP_MARGIN + LOGO_MAX_H
        // should be entirely transparent.
        let bottom_band_y = LOGO_TOP_MARGIN + LOGO_MAX_H + 10;
        for x in 0..CANVAS_W {
            let p = canvas.get_pixel(x, bottom_band_y);
            assert_eq!(p.0[3], 0, "row {bottom_band_y} should be transparent at x={x}");
        }
    }

    #[test]
    fn unsupported_bytes_returns_error_not_panic() {
        let r = compose_pxe_logo(b"\xde\xad\xbe\xef not an image");
        assert!(r.is_err());
    }
}
