//! PXE boot-menu background compositor.
//!
//! The brief (v0.4.69): match iVentoy's polished graphical PXE screen.
//! iPXE built with `CONSOLE_FRAMEBUFFER` + `IMAGE_PNG` paints a PNG to
//! the framebuffer via `console --picture`, then draws the text menu on
//! top (the console's default background colour is rendered transparent
//! so the picture shows through the menu's blank cells). So what we
//! produce here is a **full-screen 1024×768 background**, not just a
//! floating logo:
//!
//!   - a solid dark field (matches the WebUI dark theme so the product
//!     feels consistent from browser to bare metal), with
//!   - the operator's uploaded logo composited across the top, leaving
//!     the lower ~two-thirds clear for the iPXE menu text.
//!
//! When no custom logo is uploaded we still return a designed
//! background — a dark field with a centered "rainbow-horizon" disc
//! echoing the bundled OpenPXE mark — so the boot screen is graphical
//! out of the box. This replaces the old ASCII wordmark entirely.
//!
//! iPXE does **not** scale pictures (confirmed against the decoder
//! source): the image is painted at native pixel size and the firmware
//! picks the smallest video mode that fits. 1024×768 is the universal
//! safe mode, so we pin the canvas there. Operators uploading a 4K logo
//! get it downscaled to fit the top band; tiny icons paint at native
//! size, centered.
//!
//! Input formats: anything the `image` crate decodes with our enabled
//! features — PNG, JPEG, WebP, GIF. iPXE itself only consumes PNG, so
//! we always *emit* PNG regardless of what the operator uploaded; a
//! WebP logo is transcoded here transparently.

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

/// Canvas dimensions. Pinned to 1024×768 — the universal framebuffer
/// mode every BIOS/UEFI console supports, and iPXE doesn't scale.
pub const CANVAS_W: u32 = 1024;
pub const CANVAS_H: u32 = 768;

/// Bounding box for the operator's logo across the top band. Wider than
/// the old floating-logo box because the logo now anchors a full
/// background rather than sitting alone on transparency.
const LOGO_MAX_W: u32 = 760;
const LOGO_MAX_H: u32 = 200;

/// Top margin from the canvas top to the logo's top edge.
const LOGO_TOP_MARGIN: u32 = 72;

/// Background fill — a near-black with a faint blue cast, matching the
/// WebUI's dark theme surface so the product reads as one piece from
/// browser to PXE screen.
const BG: Rgba<u8> = Rgba([11, 14, 22, 255]);

/// Compose the operator's uploaded raster (`Some`) — or the default
/// OpenPXE mark (`None`) — into a full-screen 1024×768 PNG background
/// and return the encoded bytes.
///
/// Errors only when a provided `src_bytes` can't be decoded; the
/// `None` path and the PNG encode are infallible for our fixed canvas.
pub fn compose_pxe_background(src_bytes: Option<&[u8]>) -> Result<Vec<u8>, ImageError> {
    let mut canvas: RgbaImage = RgbaImage::from_pixel(CANVAS_W, CANVAS_H, BG);

    match src_bytes {
        Some(bytes) => {
            let logo = image::load_from_memory(bytes)?;
            let logo = downscale_to_fit(logo, LOGO_MAX_W, LOGO_MAX_H);
            let logo_rgba = logo.to_rgba8();
            let off_x = CANVAS_W.saturating_sub(logo_rgba.width()) / 2;
            let off_y = LOGO_TOP_MARGIN.min(CANVAS_H.saturating_sub(logo_rgba.height()));
            // `overlay` alpha-composites, so a transparent-background
            // logo blends onto the dark field exactly as designed.
            image::imageops::overlay(&mut canvas, &logo_rgba, off_x.into(), off_y.into());
        }
        None => draw_default_mark(&mut canvas),
    }

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

/// Back-compat shim for the old name — callers that pass a raw logo and
/// want it composited get the same result as `compose_pxe_background`
/// with `Some`.
pub fn compose_pxe_logo(src_bytes: &[u8]) -> Result<Vec<u8>, ImageError> {
    compose_pxe_background(Some(src_bytes))
}

/// Paint a centered "rainbow-horizon" disc onto the dark canvas as the
/// default brand mark when no operator logo is set. Pure pixel math —
/// no font, no SVG rasterizer, no extra deps. A filled circle with a
/// left-to-right hue sweep echoes the bundled `logo.svg` motif.
// Casts here are all bounded small-range geometry (radius ≤ 90, canvas
// ≤ 1024) — precision loss / wrap is structurally impossible.
#[allow(clippy::cast_precision_loss, clippy::cast_possible_wrap)]
fn draw_default_mark(canvas: &mut RgbaImage) {
    let radius: i32 = 90;
    let cx = (CANVAS_W / 2) as i32;
    let cy = (LOGO_TOP_MARGIN + 100) as i32;
    // Four-stop horizontal sweep across the disc (teal → blue → violet
    // → magenta) — the OpenPXE palette.
    let stops = [
        [0x22u8, 0xd3, 0xaa],
        [0x3b, 0x82, 0xf6],
        [0x8b, 0x5c, 0xf6],
        [0xec, 0x48, 0x99],
    ];
    let r2 = radius * radius;
    for dy in -radius..=radius {
        for dx in -radius..=radius {
            if dx * dx + dy * dy > r2 {
                continue;
            }
            // Position across the disc in [0,1] left→right.
            let t = (f32::from(i16::try_from(dx + radius).unwrap_or(0)))
                / (f32::from(i16::try_from(2 * radius).unwrap_or(1)));
            let color = gradient_at(&stops, t);
            // Soft edge: fade alpha in the outer 3px ring.
            let dist = ((dx * dx + dy * dy) as f32).sqrt();
            let alpha = if dist > (radius as f32 - 3.0) {
                let edge = (radius as f32 - dist).clamp(0.0, 3.0) / 3.0;
                (edge * 255.0) as u8
            } else {
                255
            };
            let px = cx + dx;
            let py = cy + dy;
            if px >= 0 && py >= 0 && (px as u32) < CANVAS_W && (py as u32) < CANVAS_H {
                blend_pixel(canvas, px as u32, py as u32, color, alpha);
            }
        }
    }
}

/// Linear interpolate across an N-stop palette at position `t` in [0,1].
// `segments`/`idx` are ≤ palette length (4) — f32 cast is exact.
#[allow(clippy::cast_precision_loss)]
fn gradient_at(stops: &[[u8; 3]], t: f32) -> [u8; 3] {
    let t = t.clamp(0.0, 1.0);
    let segments = stops.len() - 1;
    let scaled = t * segments as f32;
    let idx = (scaled.floor() as usize).min(segments - 1);
    let frac = scaled - idx as f32;
    let a = stops[idx];
    let b = stops[idx + 1];
    [
        lerp(a[0], b[0], frac),
        lerp(a[1], b[1], frac),
        lerp(a[2], b[2], frac),
    ]
}

fn lerp(a: u8, b: u8, t: f32) -> u8 {
    (f32::from(a) + (f32::from(b) - f32::from(a)) * t).round() as u8
}

/// Alpha-blend `color` at `alpha` over the existing canvas pixel.
fn blend_pixel(canvas: &mut RgbaImage, x: u32, y: u32, color: [u8; 3], alpha: u8) {
    let bg = canvas.get_pixel(x, y).0;
    let a = f32::from(alpha) / 255.0;
    let out = Rgba([
        lerp(bg[0], color[0], a),
        lerp(bg[1], color[1], a),
        lerp(bg[2], color[2], a),
        255,
    ]);
    canvas.put_pixel(x, y, 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;
    }
    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 custom_logo_emits_canvas_sized_png_with_dark_field() {
        let src = solid_png(120, 60, [200, 50, 50]);
        let out = compose_pxe_background(Some(&src)).unwrap();
        let img = image::load_from_memory(&out).unwrap().to_rgba8();
        assert_eq!(img.width(), CANVAS_W);
        assert_eq!(img.height(), CANVAS_H);
        // A far corner should be the opaque dark background fill, not
        // transparent — this is a full background now, not a floating
        // logo on transparency.
        let corner = img.get_pixel(CANVAS_W - 1, CANVAS_H - 1);
        assert_eq!(corner.0, BG.0, "corner should be the dark fill");
    }

    #[test]
    fn custom_logo_painted_in_top_band() {
        let src = solid_png(100, 40, [10, 200, 10]);
        let out = compose_pxe_background(Some(&src)).unwrap();
        let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
        let cx = (CANVAS_W - 100) / 2;
        let cy = LOGO_TOP_MARGIN;
        let inside = canvas.get_pixel(cx + 10, cy + 10);
        assert!(
            inside.0[1] > 100 && inside.0[0] < 100,
            "logo pixel color mismatch: {inside:?}"
        );
    }

    #[test]
    fn default_background_is_dark_with_a_painted_mark() {
        let out = compose_pxe_background(None).unwrap();
        let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
        assert_eq!(canvas.width(), CANVAS_W);
        assert_eq!(canvas.height(), CANVAS_H);
        // Corner is dark fill.
        assert_eq!(canvas.get_pixel(2, CANVAS_H - 2).0, BG.0);
        // Center of the disc is not the background fill (something was
        // painted there).
        let center = canvas.get_pixel(CANVAS_W / 2, LOGO_TOP_MARGIN + 100);
        assert_ne!(center.0, BG.0, "default mark should paint over the field");
    }

    #[test]
    fn webp_or_jpeg_input_is_accepted_and_transcoded_to_png() {
        // Encode a JPEG and confirm the compositor decodes it and emits
        // a valid PNG (iPXE only eats PNG, so transcoding is the point).
        let img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(80, 80, Rgb([90, 90, 90]));
        let mut jpeg = Vec::new();
        DynamicImage::ImageRgb8(img)
            .write_to(&mut Cursor::new(&mut jpeg), ImageFormat::Jpeg)
            .unwrap();
        let out = compose_pxe_background(Some(&jpeg)).unwrap();
        // Output must be a PNG (magic bytes) of canvas size.
        assert_eq!(&out[..8], b"\x89PNG\r\n\x1a\n");
        let img = image::load_from_memory(&out).unwrap();
        assert_eq!(img.width(), CANVAS_W);
    }

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

    #[test]
    fn gradient_endpoints_match_stops() {
        let stops = [[0, 0, 0], [255, 255, 255]];
        assert_eq!(gradient_at(&stops, 0.0), [0, 0, 0]);
        assert_eq!(gradient_at(&stops, 1.0), [255, 255, 255]);
    }
}
