use std::io::Cursor;

use bone_text::{FontFace, FontWeight, ShapeRequest, ShapedLine, Shaper, load_font};
use swash::{
    FontRef,
    scale::{Render, ScaleContext, Source, image::Image},
    zeno::Format,
};

use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
use crate::strings::StringTable;
use crate::theme::{Border, Color, SurfaceLevel, Theme};
use crate::widgets::{ConvexPoly, HorizontalAlign, PaintPrim, PolyPath, WidgetPaint, lower_paint};

const VECTOR_AA_PX: f32 = 0.5;

const MARK_FONT_SCALE: f32 = 0.7;

#[derive(Debug, thiserror::Error)]
pub enum PngError {
    #[error("png encode failed: {0}")]
    Encode(String),
    #[error("png decode failed: {0}")]
    Decode(String),
    #[error("png dimensions {got_w}x{got_h} differ from expected {want_w}x{want_h}")]
    Dimensions {
        got_w: CanvasPx,
        got_h: CanvasPx,
        want_w: CanvasPx,
        want_h: CanvasPx,
    },
    #[error("png decoded buffer size {got} differs from expected {want}")]
    Length { got: usize, want: usize },
}

#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CanvasPx(u32);

impl CanvasPx {
    #[must_use]
    pub const fn new(value: u32) -> Self {
        Self(value)
    }

    #[must_use]
    pub const fn value(self) -> u32 {
        self.0
    }
}

impl core::fmt::Display for CanvasPx {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        self.0.fmt(f)
    }
}

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

impl CanvasSize {
    #[must_use]
    pub const fn new(width: CanvasPx, height: CanvasPx) -> Self {
        Self { width, height }
    }

    #[must_use]
    pub const fn pixel_count(self) -> usize {
        (self.width.value() as usize) * (self.height.value() as usize)
    }
}

#[derive(Clone, Debug)]
struct Canvas {
    size: CanvasSize,
    pixels: Vec<[f32; 4]>,
}

impl Canvas {
    fn new(size: CanvasSize, clear: Color) -> Self {
        let fill = clear.linear_rgba_premul();
        Self {
            size,
            pixels: vec![fill; size.pixel_count()],
        }
    }

    fn fill_rect(&mut self, rect: LayoutRect, color: Color) {
        let bounds = self.bounds(rect);
        let src = color.linear_rgba_premul();
        composite_band(&mut self.pixels, self.size.width.value(), bounds, src);
    }

    fn outline_rect(&mut self, rect: LayoutRect, color: Color, thickness: f32) {
        let outer = self.bounds(rect);
        if outer.is_empty() {
            return;
        }
        #[allow(
            clippy::cast_possible_truncation,
            clippy::cast_sign_loss,
            reason = "stroke widths are small non-negative pixel counts"
        )]
        let t = thickness.max(1.0).round() as u32;
        let h = outer.y1 - outer.y0;
        let w = outer.x1 - outer.x0;
        let top_h = t.min(h);
        let bot_h = t.min(h - top_h);
        let left_w = t.min(w);
        let right_w = t.min(w - left_w);
        let mid_y0 = outer.y0 + top_h;
        let mid_y1 = outer.y1 - bot_h;
        let src = color.linear_rgba_premul();
        let bands = [
            PixelBounds {
                x0: outer.x0,
                y0: outer.y0,
                x1: outer.x1,
                y1: mid_y0,
            },
            PixelBounds {
                x0: outer.x0,
                y0: mid_y1,
                x1: outer.x1,
                y1: outer.y1,
            },
            PixelBounds {
                x0: outer.x0,
                y0: mid_y0,
                x1: outer.x0 + left_w,
                y1: mid_y1,
            },
            PixelBounds {
                x0: outer.x1 - right_w,
                y0: mid_y0,
                x1: outer.x1,
                y1: mid_y1,
            },
        ];
        bands.iter().for_each(|band| {
            composite_band(&mut self.pixels, self.size.width.value(), *band, src);
        });
    }

    fn bounds(&self, rect: LayoutRect) -> PixelBounds {
        let x0 = clamp_axis(rect.min_x(), self.size.width);
        let y0 = clamp_axis(rect.min_y(), self.size.height);
        let x1 = clamp_axis(rect.max_x(), self.size.width);
        let y1 = clamp_axis(rect.max_y(), self.size.height);
        PixelBounds {
            x0,
            y0,
            x1: x1.max(x0),
            y1: y1.max(y0),
        }
    }

    fn expanded_bounds(&self, rect: LayoutRect, pad: f32) -> PixelBounds {
        let grown = LayoutRect::new(
            LayoutPos::new(
                LayoutPx::saturating(rect.min_x().value() - pad),
                LayoutPx::saturating(rect.min_y().value() - pad),
            ),
            LayoutSize::new(
                LayoutPx::saturating_nonneg(rect.size.width.value() + 2.0 * pad),
                LayoutPx::saturating_nonneg(rect.size.height.value() + 2.0 * pad),
            ),
        );
        self.bounds(grown)
    }

    fn fill_convex(&mut self, poly: &ConvexPoly, fill: Color, border: Option<Border>) {
        let planes = poly.edge_planes();
        let bounds = self.expanded_bounds(poly.bounds(), VECTOR_AA_PX);
        if bounds.is_empty() {
            return;
        }
        let fill_premul = fill.linear_rgba_premul();
        let (border_premul, border_width) = match border {
            Some(b) => (b.color.linear_rgba_premul(), b.width.value_px()),
            None => ([0.0; 4], 0.0),
        };
        let stride = self.size.width.value();
        (bounds.y0..bounds.y1).for_each(|y| {
            let py = pixel_center(y);
            (bounds.x0..bounds.x1).for_each(|x| {
                let sd = convex_signed_distance(&planes, pixel_center(x), py);
                let cov_outer = coverage(sd);
                if cov_outer <= 0.0 {
                    return;
                }
                let cov_inner = if border_width > 0.0 {
                    coverage(sd + border_width)
                } else {
                    cov_outer
                };
                let src = blend_fill_border(
                    fill_premul,
                    cov_inner,
                    border_premul,
                    (cov_outer - cov_inner).max(0.0),
                );
                composite_pixel(&mut self.pixels, stride, x, y, src);
            });
        });
    }

    fn stroke_path(&mut self, path: &PolyPath, width: f32, color: Color) {
        let half = width.max(1.0) * 0.5;
        let bounds = self.expanded_bounds(path.bounds(), half + VECTOR_AA_PX);
        if bounds.is_empty() {
            return;
        }
        let span_w = (bounds.x1 - bounds.x0) as usize;
        let span_h = (bounds.y1 - bounds.y0) as usize;
        let mut mask = vec![0.0_f32; span_w * span_h];
        path.segments().for_each(|(a, b)| {
            accumulate_segment(&mut mask, bounds, span_w, a, b, half);
        });
        let premul = color.linear_rgba_premul();
        let stride = self.size.width.value();
        (bounds.y0..bounds.y1).for_each(|y| {
            let row = (y - bounds.y0) as usize;
            (bounds.x0..bounds.x1).for_each(|x| {
                let cov = mask[row * span_w + (x - bounds.x0) as usize];
                if cov <= 0.0 {
                    return;
                }
                let src = [
                    premul[0] * cov,
                    premul[1] * cov,
                    premul[2] * cov,
                    premul[3] * cov,
                ];
                composite_pixel(&mut self.pixels, stride, x, y, src);
            });
        });
    }

    fn into_srgb_rgba8(self) -> Vec<u8> {
        self.pixels
            .iter()
            .flat_map(|p| pixel_to_srgb_u8(*p))
            .collect()
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
struct PixelBounds {
    x0: u32,
    y0: u32,
    x1: u32,
    y1: u32,
}

impl PixelBounds {
    fn is_empty(self) -> bool {
        self.x0 >= self.x1 || self.y0 >= self.y1
    }
}

fn clamp_axis(value: LayoutPx, limit: CanvasPx) -> u32 {
    let raw = value.value();
    #[allow(
        clippy::cast_precision_loss,
        reason = "canvas dimensions far below f32 precision boundary"
    )]
    let bounded = raw.clamp(0.0, limit.value() as f32);
    #[allow(
        clippy::cast_possible_truncation,
        clippy::cast_sign_loss,
        reason = "value clamped to [0, limit] before cast"
    )]
    let pixel = bounded.round() as u32;
    pixel
}

fn composite_band(pixels: &mut [[f32; 4]], stride: u32, bounds: PixelBounds, src: [f32; 4]) {
    if bounds.is_empty() {
        return;
    }
    let inv_alpha = 1.0 - src[3];
    (bounds.y0..bounds.y1).for_each(|y| {
        let row_offset = (y as usize) * (stride as usize);
        (bounds.x0..bounds.x1).for_each(|x| {
            let idx = row_offset + (x as usize);
            let dst = pixels[idx];
            pixels[idx] = [
                src[0] + dst[0] * inv_alpha,
                src[1] + dst[1] * inv_alpha,
                src[2] + dst[2] * inv_alpha,
                src[3] + dst[3] * inv_alpha,
            ];
        });
    });
}

#[allow(
    clippy::cast_precision_loss,
    reason = "pixel coordinates stay far below the f32 mantissa limit"
)]
fn pixel_center(coord: u32) -> f32 {
    coord as f32 + 0.5
}

fn coverage(signed_distance: f32) -> f32 {
    (0.5 - signed_distance / VECTOR_AA_PX).clamp(0.0, 1.0)
}

fn convex_signed_distance(planes: &[[f32; 3]], x: f32, y: f32) -> f32 {
    planes
        .iter()
        .map(|[nx, ny, d]| nx * x + ny * y - d)
        .fold(f32::NEG_INFINITY, f32::max)
}

fn blend_fill_border(
    fill_premul: [f32; 4],
    fill_cov: f32,
    border_premul: [f32; 4],
    border_cov: f32,
) -> [f32; 4] {
    let border = [
        border_premul[0] * border_cov,
        border_premul[1] * border_cov,
        border_premul[2] * border_cov,
        border_premul[3] * border_cov,
    ];
    let inv = 1.0 - border[3];
    [
        border[0] + fill_premul[0] * fill_cov * inv,
        border[1] + fill_premul[1] * fill_cov * inv,
        border[2] + fill_premul[2] * fill_cov * inv,
        border[3] + fill_premul[3] * fill_cov * inv,
    ]
}

fn composite_pixel(pixels: &mut [[f32; 4]], stride: u32, x: u32, y: u32, src: [f32; 4]) {
    let idx = (y as usize) * (stride as usize) + (x as usize);
    let dst = pixels[idx];
    let inv_alpha = 1.0 - src[3];
    pixels[idx] = [
        src[0] + dst[0] * inv_alpha,
        src[1] + dst[1] * inv_alpha,
        src[2] + dst[2] * inv_alpha,
        src[3] + dst[3] * inv_alpha,
    ];
}

fn accumulate_segment(
    mask: &mut [f32],
    bounds: PixelBounds,
    span_w: usize,
    a: LayoutPos,
    b: LayoutPos,
    half: f32,
) {
    let (ax, ay) = (a.x.value(), a.y.value());
    let (bx, by) = (b.x.value(), b.y.value());
    (bounds.y0..bounds.y1).for_each(|y| {
        let row = (y - bounds.y0) as usize;
        let py = pixel_center(y);
        (bounds.x0..bounds.x1).for_each(|x| {
            let cov = coverage(dist_point_segment(pixel_center(x), py, ax, ay, bx, by) - half);
            if cov > 0.0 {
                let idx = row * span_w + (x - bounds.x0) as usize;
                mask[idx] = mask[idx].max(cov);
            }
        });
    });
}

fn dist_point_segment(px: f32, py: f32, ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
    let (dx, dy) = (bx - ax, by - ay);
    let len2 = dx * dx + dy * dy;
    let t = if len2 <= f32::EPSILON {
        0.0
    } else {
        (((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0)
    };
    (px - (ax + t * dx)).hypot(py - (ay + t * dy))
}

fn pixel_to_srgb_u8(premul: [f32; 4]) -> [u8; 4] {
    let alpha = premul[3].clamp(0.0, 1.0);
    if alpha <= 0.0 {
        return [0, 0, 0, 0];
    }
    let inv = 1.0 / alpha;
    let to_u8 = |c: f32| {
        let unpremul = (c * inv).clamp(0.0, 1.0);
        unit_to_byte(linear_to_srgb(unpremul))
    };
    [
        to_u8(premul[0]),
        to_u8(premul[1]),
        to_u8(premul[2]),
        unit_to_byte(alpha),
    ]
}

fn unit_to_byte(unit: f32) -> u8 {
    #[allow(
        clippy::cast_possible_truncation,
        clippy::cast_sign_loss,
        reason = "value clamped to [0, 255] before cast"
    )]
    let byte = (unit * 255.0).round().clamp(0.0, 255.0) as u8;
    byte
}

fn linear_to_srgb(linear: f32) -> f32 {
    if linear <= 0.003_130_8 {
        12.92 * linear
    } else {
        1.055 * linear.powf(1.0 / 2.4) - 0.055
    }
}

#[must_use]
pub fn rasterize(
    theme: &Theme,
    paint: &[WidgetPaint],
    size: CanvasSize,
    strings: &StringTable,
) -> Vec<u8> {
    let mut canvas = Canvas::new(size, theme.colors.surface(SurfaceLevel::L0));
    let mut painter = GlyphPainter::new();
    paint
        .iter()
        .for_each(|p| draw(&mut canvas, p, theme, strings, &mut painter));
    canvas.into_srgb_rgba8()
}

fn draw(
    canvas: &mut Canvas,
    paint: &WidgetPaint,
    theme: &Theme,
    strings: &StringTable,
    painter: &mut GlyphPainter,
) {
    match paint {
        WidgetPaint::Label {
            rect,
            text,
            color,
            role,
        } => {
            painter.paint_text(
                canvas,
                *rect,
                text.resolve(strings),
                TextStyle {
                    font_size_px: role.size.as_px_f32(),
                    face: role.face,
                    weight: role.weight,
                    color: *color,
                    align: HorizontalAlign::Center,
                },
            );
        }
        WidgetPaint::AlignedLabel {
            rect,
            text,
            color,
            role,
            align,
        } => {
            painter.paint_text(
                canvas,
                *rect,
                text.resolve(strings),
                TextStyle {
                    font_size_px: role.size.as_px_f32(),
                    face: role.face,
                    weight: role.weight,
                    color: *color,
                    align: *align,
                },
            );
        }
        WidgetPaint::Mark { rect, kind, color } => {
            painter.paint_text(
                canvas,
                *rect,
                kind.glyph(),
                TextStyle {
                    font_size_px: rect.size.height.value() * MARK_FONT_SCALE,
                    face: FontFace::Sans,
                    weight: FontWeight::Regular,
                    color: *color,
                    align: HorizontalAlign::Center,
                },
            );
        }
        WidgetPaint::ConvexFill { poly, fill, border } => {
            canvas.fill_convex(poly, *fill, *border);
        }
        WidgetPaint::Stroke { path, width, color } => {
            canvas.stroke_path(path, width.value_px(), *color);
        }
        WidgetPaint::Popup { paints } => {
            paints
                .iter()
                .for_each(|inner| draw(canvas, inner, theme, strings, painter));
        }
        other => {
            let prim = lower_paint(theme, other);
            draw_prim(canvas, &prim);
        }
    }
}

fn draw_prim(canvas: &mut Canvas, prim: &PaintPrim) {
    if prim.fill.alpha() > 0.0 {
        canvas.fill_rect(prim.rect, prim.fill);
    }
    if let Some(b) = prim.border {
        canvas.outline_rect(prim.rect, b.color, b.width.value_px());
    }
}

struct GlyphPainter {
    shaper: Shaper,
    scale_ctx: ScaleContext,
    sans: FontRef<'static>,
    mono: FontRef<'static>,
}

impl GlyphPainter {
    fn new() -> Self {
        Self {
            shaper: Shaper::new(),
            scale_ctx: ScaleContext::new(),
            sans: load_font(FontFace::Sans),
            mono: load_font(FontFace::Mono),
        }
    }

    fn font_for(&self, face: FontFace) -> &FontRef<'static> {
        match face {
            FontFace::Sans => &self.sans,
            FontFace::Mono => &self.mono,
        }
    }

    fn paint_text(&mut self, canvas: &mut Canvas, rect: LayoutRect, text: &str, style: TextStyle) {
        if text.is_empty() || style.font_size_px <= 0.0 {
            return;
        }
        let layout = self.shaper.shape(
            text,
            ShapeRequest {
                face: style.face,
                size_px: style.font_size_px,
                weight: style.weight,
                line_height_px: 0.0,
                letter_spacing_px: 0.0,
                max_width: None,
            },
        );
        let metrics = self
            .font_for(style.face)
            .metrics(&[])
            .scale(style.font_size_px);
        let visible_advance = layout
            .lines
            .first()
            .map_or(0.0, ShapedLine::visible_advance_px);
        let rx = rect.origin.x.value();
        let ry = rect.origin.y.value();
        let rw = rect.size.width.value();
        let rh = rect.size.height.value();
        let start_x = match style.align {
            HorizontalAlign::Center => rx + ((rw - visible_advance) * 0.5).max(0.0),
            HorizontalAlign::Start => rx,
            HorizontalAlign::End => rx + (rw - visible_advance).max(0.0),
        };
        let baseline_y = ry + (rh + metrics.cap_height) * 0.5;
        let font = *self.font_for(style.face);
        let mut scaler = self
            .scale_ctx
            .builder(font)
            .size(style.font_size_px)
            .hint(false)
            .build();
        layout
            .lines
            .iter()
            .flat_map(|l| l.runs.iter())
            .for_each(|run| {
                run.glyphs.iter().for_each(|g| {
                    let Ok(glyph_id_u16) = u16::try_from(g.id.raw()) else {
                        return;
                    };
                    let mut image = Image::new();
                    let rendered = Render::new(&[Source::Outline])
                        .format(Format::Alpha)
                        .render_into(&mut scaler, glyph_id_u16, &mut image);
                    if !rendered {
                        return;
                    }
                    composite_alpha_mask(
                        canvas,
                        &image,
                        start_x + run.origin_x_px + g.x_px,
                        baseline_y,
                        style.color,
                    );
                });
            });
    }
}

#[derive(Copy, Clone)]
struct TextStyle {
    font_size_px: f32,
    face: FontFace,
    weight: FontWeight,
    color: Color,
    align: HorizontalAlign,
}

fn composite_alpha_mask(
    canvas: &mut Canvas,
    image: &Image,
    origin_x_px: f32,
    baseline_y_px: f32,
    color: Color,
) {
    let placement = image.placement;
    let mask_w = placement.width;
    let mask_h = placement.height;
    if mask_w == 0 || mask_h == 0 || image.data.len() < (mask_w as usize) * (mask_h as usize) {
        return;
    }
    #[allow(
        clippy::cast_precision_loss,
        reason = "glyph placement offsets stay well within f32 mantissa precision"
    )]
    let base_horiz = origin_x_px + placement.left as f32;
    #[allow(
        clippy::cast_precision_loss,
        reason = "glyph placement offsets stay well within f32 mantissa precision"
    )]
    let base_vert = baseline_y_px - placement.top as f32;
    let canvas_w = canvas.size.width.value();
    let canvas_h = canvas.size.height.value();
    let stride = canvas_w as usize;
    let color_premul = color.linear_rgba_premul();
    (0..mask_h).for_each(|py| {
        let Some(dst_y) = pixel_index(base_vert, py, canvas_h) else {
            return;
        };
        let row = (py as usize) * (mask_w as usize);
        (0..mask_w).for_each(|px| {
            let Some(dst_x) = pixel_index(base_horiz, px, canvas_w) else {
                return;
            };
            let mask = f32::from(image.data[row + (px as usize)]) / 255.0;
            if mask <= 0.0 {
                return;
            }
            let src = [
                color_premul[0] * mask,
                color_premul[1] * mask,
                color_premul[2] * mask,
                color_premul[3] * mask,
            ];
            let inv_alpha = 1.0 - src[3];
            let idx = (dst_y as usize) * stride + (dst_x as usize);
            let dst = canvas.pixels[idx];
            canvas.pixels[idx] = [
                src[0] + dst[0] * inv_alpha,
                src[1] + dst[1] * inv_alpha,
                src[2] + dst[2] * inv_alpha,
                src[3] + dst[3] * inv_alpha,
            ];
        });
    });
}

#[allow(
    clippy::cast_precision_loss,
    clippy::cast_possible_truncation,
    clippy::cast_sign_loss,
    reason = "glyph mask offsets stay within f32 mantissa precision and the rounded coordinate is guarded against negatives and the canvas limit"
)]
fn pixel_index(base: f32, offset: u32, limit: u32) -> Option<u32> {
    let pos = (base + offset as f32).round();
    if !pos.is_finite() || pos < 0.0 {
        return None;
    }
    let value = pos as u32;
    (value < limit).then_some(value)
}

pub fn encode_png(rgba: &[u8], size: CanvasSize) -> Result<Vec<u8>, PngError> {
    let expected = size.pixel_count() * 4;
    if rgba.len() != expected {
        return Err(PngError::Length {
            got: rgba.len(),
            want: expected,
        });
    }
    let mut out = Vec::new();
    {
        let mut encoder = png::Encoder::new(&mut out, size.width.value(), size.height.value());
        encoder.set_color(png::ColorType::Rgba);
        encoder.set_depth(png::BitDepth::Eight);
        encoder.set_compression(png::Compression::Best);
        let mut writer = encoder
            .write_header()
            .map_err(|e| PngError::Encode(e.to_string()))?;
        writer
            .write_image_data(rgba)
            .map_err(|e| PngError::Encode(e.to_string()))?;
    }
    Ok(out)
}

pub fn decode_png(bytes: &[u8], size: CanvasSize) -> Result<Vec<u8>, PngError> {
    let decoder = png::Decoder::new(Cursor::new(bytes));
    let mut reader = decoder
        .read_info()
        .map_err(|e| PngError::Decode(e.to_string()))?;
    let info = reader.info();
    if info.width != size.width.value() || info.height != size.height.value() {
        return Err(PngError::Dimensions {
            got_w: CanvasPx::new(info.width),
            got_h: CanvasPx::new(info.height),
            want_w: size.width,
            want_h: size.height,
        });
    }
    let mut buf = vec![0_u8; reader.output_buffer_size()];
    let frame = reader
        .next_frame(&mut buf)
        .map_err(|e| PngError::Decode(e.to_string()))?;
    if frame.color_type != png::ColorType::Rgba {
        return Err(PngError::Decode(format!(
            "expected rgba color type, got {:?}",
            frame.color_type,
        )));
    }
    let rgba = buf[..frame.buffer_size()].to_vec();
    let expected = size.pixel_count() * 4;
    if rgba.len() != expected {
        return Err(PngError::Length {
            got: rgba.len(),
            want: expected,
        });
    }
    Ok(rgba)
}

#[cfg(test)]
mod tests {
    use super::{Canvas, CanvasPx, CanvasSize, rasterize};
    use crate::gallery::{GALLERY_CANVAS, GalleryState, render};
    use crate::strings::StringTable;
    use crate::theme::Theme;
    use std::sync::Arc;

    #[test]
    fn rasterizer_emits_canvas_sized_buffer() {
        let mut state = GalleryState::new();
        let paint = render(Arc::new(Theme::light()), &mut state);
        let pixels = rasterize(
            &Theme::light(),
            &paint,
            GALLERY_CANVAS,
            StringTable::empty(),
        );
        assert_eq!(pixels.len(), GALLERY_CANVAS.pixel_count() * 4);
    }

    #[test]
    fn light_and_dark_buffers_differ() {
        let render_for = |theme: Theme| {
            let mut state = GalleryState::new();
            let paint = render(Arc::new(theme.clone()), &mut state);
            rasterize(&theme, &paint, GALLERY_CANVAS, StringTable::empty())
        };
        let light = render_for(Theme::light());
        let dark = render_for(Theme::dark());
        assert_ne!(light, dark, "themes must produce distinct rasterizations");
    }

    #[test]
    fn outline_rect_corners_render_once_under_translucent_color() {
        use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
        use crate::theme::Color;
        let size = CanvasSize::new(CanvasPx::new(8), CanvasPx::new(8));
        let bg = Color::TRANSPARENT.with_alpha(1.0);
        let stroke = Theme::light().colors.focus_ring().with_alpha(0.5);
        let mut canvas = Canvas::new(size, bg);
        canvas.outline_rect(
            LayoutRect::new(
                LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
                LayoutSize::new(LayoutPx::new(8.0), LayoutPx::new(8.0)),
            ),
            stroke,
            1.0,
        );
        let bytes = canvas.into_srgb_rgba8();
        let pixel = |x: usize, y: usize| {
            let i = (y * size.width.value() as usize + x) * 4;
            [bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]
        };
        assert_eq!(
            pixel(0, 0),
            pixel(3, 0),
            "corner pixel must match a non-corner top-edge pixel; double-paint detected",
        );
    }

    #[test]
    fn convex_fill_and_stroke_paint_inside_their_shapes() {
        use crate::layout::{LayoutPos, LayoutPx};
        use crate::theme::{Step12, StrokeWidth};
        use crate::widgets::{ConvexPoly, PolyPath, WidgetPaint};
        let theme = Theme::light();
        let size = CanvasSize::new(CanvasPx::new(32), CanvasPx::new(32));
        let ink = theme.colors.accent.step(Step12::SOLID);
        let bg = rasterize(&theme, &[], size, StringTable::empty());
        let rgb = |buf: &[u8], x: usize, y: usize| {
            let i = (y * 32 + x) * 4;
            [buf[i], buf[i + 1], buf[i + 2]]
        };
        let Some(poly) = ConvexPoly::new(vec![
            LayoutPos::new(LayoutPx::new(8.0), LayoutPx::new(8.0)),
            LayoutPos::new(LayoutPx::new(24.0), LayoutPx::new(8.0)),
            LayoutPos::new(LayoutPx::new(24.0), LayoutPx::new(24.0)),
            LayoutPos::new(LayoutPx::new(8.0), LayoutPx::new(24.0)),
        ]) else {
            panic!("the square is convex");
        };
        let filled = rasterize(
            &theme,
            &[WidgetPaint::ConvexFill {
                poly,
                fill: ink,
                border: None,
            }],
            size,
            StringTable::empty(),
        );
        assert_ne!(
            rgb(&filled, 16, 16),
            rgb(&bg, 16, 16),
            "interior is painted"
        );
        assert_eq!(
            rgb(&filled, 1, 1),
            rgb(&bg, 1, 1),
            "far corner is untouched"
        );

        let Some(path) = PolyPath::open(vec![
            LayoutPos::new(LayoutPx::new(4.0), LayoutPx::new(16.0)),
            LayoutPos::new(LayoutPx::new(28.0), LayoutPx::new(16.0)),
        ]) else {
            panic!("two points form a path");
        };
        let stroked = rasterize(
            &theme,
            &[WidgetPaint::Stroke {
                path,
                width: StrokeWidth::px(3.0),
                color: ink,
            }],
            size,
            StringTable::empty(),
        );
        assert_ne!(
            rgb(&stroked, 16, 16),
            rgb(&bg, 16, 16),
            "stroke covers the line"
        );
        assert_eq!(
            rgb(&stroked, 16, 3),
            rgb(&bg, 16, 3),
            "stroke stays off the line"
        );
    }
}
