//! renders text into png, forked from <https://github.com/rsheeter/embed1/blob/main/make_test_images/src/main.rs>
use crate::{
    measure::shape,
    pens::{foreground_paint, GlyphPainter, GlyphPainterError, Paint},
};
use kurbo::{Affine, BezPath, PathEl, Rect, Shape, Vec2};
use skrifa::{
    color::{ColorPainter, Extend, PaintError},
    prelude::{LocationRef, Size},
    raw::{FontRef, ReadError},
    MetadataProvider,
};
use thiserror::Error;
use tiny_skia::{
    Color, FillRule, GradientStop, LinearGradient, Mask, Paint as SkiaPaint, PathBuilder, Pixmap,
    Point as SkiaPoint, RadialGradient, Shader, SpreadMode, SweepGradient, Transform,
};

/// Errors encountered during the text-to-PNG rendering process.
#[derive(Error, Debug)]
pub enum TextToPngError {
    #[error("error reading font bytes: {0}")]
    ReadError(#[from] ReadError),
    #[error("error encoding bitmap to png: {0}")]
    PngEncodingError(#[from] png::EncodingError),
    #[error("there was no text to render")]
    NoText,
    #[error("the combination of text and font size was too small to produce anything")]
    TextTooSmall,
    #[error("Failed to build render path")]
    PathBuildError,
    #[error("{0}")]
    PaintError(PaintError),
    #[error("{0}")]
    GlyphPainterError(#[from] GlyphPainterError),
    #[error("Malformed gradient")]
    MalformedGradient,
}

// TODO: From<PaintError> can be autoderived with `#[from]` once
// `PaintError` implements `Error`.
impl From<PaintError> for TextToPngError {
    fn from(err: PaintError) -> TextToPngError {
        TextToPngError::PaintError(err)
    }
}

/// The fill rule used in tiny skia.
const FILL_RULE: FillRule = FillRule::Winding;

/// Returns a new Rect inflated by a margin proportional to the
/// smaller of its dimensions.
pub fn with_margin(rect: Rect, multiplier: f64) -> Rect {
    let margin = rect.width().min(rect.height()) * multiplier;
    rect.inflate(margin, margin)
}

/// Options for rendering text to PNG.
#[derive(Debug, Clone)]
pub struct Text2PngOptions<'a> {
    /// The raw font file data.
    pub font_bytes: &'a [u8],
    /// The size of the font in pixels.
    pub font_size: f32,
    /// The multiplier for the font size to determine line height (e.g., 1.0 means line height
    /// equals font size).
    pub line_spacing: f32,
    /// The default color for non-color glyphs.
    pub foreground: Color,
    /// The background color of the resulting PNG.
    pub background: Color,
    /// The font variations and settings.
    pub location: LocationRef<'a>,
}

impl<'a> Text2PngOptions<'a> {
    /// Creates a new set of options with default values.
    ///
    /// # Example
    /// ```
    /// # use tiny_skia::Color;
    /// # use sleipnir::text2png::Text2PngOptions;
    /// # let font_bytes = Vec::new();
    /// let options = Text2PngOptions {
    ///     foreground: Color::from_rgba8(255, 0, 0, 255),
    ///     ..Text2PngOptions::new(&font_bytes, 24.0)
    /// };
    /// ```
    pub fn new(font_bytes: &'a [u8], font_size: f32) -> Self {
        Self {
            font_bytes,
            font_size,
            line_spacing: 1.0,
            foreground: Color::BLACK,
            background: Color::TRANSPARENT,
            location: LocationRef::default(),
        }
    }
}

/// Renders a string of text into a PNG-encoded byte vector.
///
/// # Arguments
/// * `text` - The string to render.
/// * `options` - Configuration for the rendering process.
///
/// # Errors
/// Returns [`TextToPngError`] if the font is invalid, the text is empty,
/// or PNG encoding fails.
pub fn text2png(text: &str, options: &Text2PngOptions) -> Result<Vec<u8>, TextToPngError> {
    let font = FontRef::new(options.font_bytes)?;
    let color_glyphs = font.color_glyphs();
    if text.split_whitespace().count() == 0 {
        return Err(TextToPngError::NoText);
    }

    let size = Size::new(options.font_size);
    let metrics = font.metrics(size, options.location);
    let line_height = options.line_spacing as f64 * options.font_size as f64;
    let scale = size.linear_scale(metrics.units_per_em);

    let mut painter = GlyphPainter::new(&font, options.location, options.foreground, size);
    for (line_num, text) in text.lines().enumerate() {
        let glyphs = shape(text, &font, options.location);
        painter.x = 0.0;
        for (glyph_info, pos) in glyphs.glyph_infos().iter().zip(glyphs.glyph_positions()) {
            // TODO: Use positions from `shape` instead of assuming left-to-right, top-to-bottom.
            painter.y = line_num as f64 * line_height;
            let glyph_id = glyph_info.glyph_id.into();
            match color_glyphs.get(glyph_id) {
                Some(color_glyph) => color_glyph.paint(options.location, &mut painter)?,
                None => {
                    painter.fill_glyph(glyph_id, None, foreground_paint());
                }
            };
            painter.x += pos.x_advance as f64 * scale as f64;
        }
    }
    let expected_height =
        (options.line_spacing * options.font_size * text.lines().count() as f32) as f64;
    let pixmap = to_pixmap(&painter.into_fills()?, options.background, expected_height)?;
    let bytes = pixmap.encode_png()?;
    Ok(bytes)
}

/// Calculates the intersection of the bounding boxes of a collection of paths.
/// Returns None if the input is empty.
fn clip_bounds(paths: &[BezPath]) -> Option<Rect> {
    paths
        .iter()
        .map(|p| p.bounding_box())
        .reduce(|a, b| a.intersect(b))
}

/// Computes the union of bounding boxes for all provided color fills,
/// considering their respective offsets and clip paths.
fn compute_bounds(fills: &[crate::pens::ColorFill]) -> Rect {
    fills
        .iter()
        .filter_map(|fill| {
            let add_offset = |b| b + Vec2::new(fill.offset_x, fill.offset_y);
            clip_bounds(&fill.clip_paths).map(add_offset)
        })
        .reduce(|a, b| a.union(b))
        .unwrap_or_default()
}

/// Create a mask from the intersection of all `paths`. If there are
/// no paths, then `None`. is returned.
fn to_mask(
    paths: &[BezPath],
    width_height: (u32, u32),
    transform: Transform,
) -> Result<Option<Mask>, TextToPngError> {
    match paths {
        [] => Ok(None),
        [path, paths @ ..] => {
            let Some(mut mask) = Mask::new(width_height.0, width_height.1) else {
                return Ok(None);
            };
            mask.fill_path(
                &path.to_tinyskia().ok_or(TextToPngError::PathBuildError)?,
                FILL_RULE,
                true,
                transform,
            );
            for path in paths {
                mask.intersect_path(
                    &path.to_tinyskia().ok_or(TextToPngError::PathBuildError)?,
                    FILL_RULE,
                    true,
                    transform,
                );
            }
            Ok(Some(mask))
        }
    }
}

/// Creates a Pixmap from a collection of color fills, centering them
/// vertically within the given height.
///
/// The Pixmap's width is determined automatically based on the
/// bounding box of the fills.
fn to_pixmap(
    fills: &[crate::pens::ColorFill],
    background: Color,
    height: f64,
) -> Result<Pixmap, TextToPngError> {
    let bounds = compute_bounds(fills);
    let width = bounds.width();

    let mut pixmap = Pixmap::new(width.ceil() as u32, height.ceil() as u32)
        .ok_or(TextToPngError::TextTooSmall)?;
    pixmap.fill(background);
    let x_offset = -bounds.min_x();
    let y_offset_for_centering = (height - bounds.height()) / 2.0;
    let y_offset = y_offset_for_centering - bounds.min_y();
    for fill in fills {
        let transform = Transform::from_translate(
            (fill.offset_x + x_offset) as f32,
            (fill.offset_y + y_offset) as f32,
        );
        let Some(path) = fill.clip_paths.last() else {
            continue;
        };
        let mask = to_mask(
            // OK: Guaranteed to be at least length 1 in above statement.
            &fill.clip_paths[0..fill.clip_paths.len() - 1],
            (pixmap.width(), pixmap.height()),
            transform,
        )?;
        pixmap.fill_path(
            &path.to_tinyskia().ok_or(TextToPngError::PathBuildError)?,
            &fill
                .paint
                .to_tinyskia()
                .ok_or(TextToPngError::MalformedGradient)?,
            FILL_RULE,
            transform,
            mask.as_ref(),
        );
    }
    Ok(pixmap)
}

trait ToTinySkia {
    type T;
    fn to_tinyskia(&self) -> Self::T;
}

impl ToTinySkia for BezPath {
    type T = Option<tiny_skia::Path>;

    fn to_tinyskia(&self) -> Option<tiny_skia::Path> {
        let mut pb = PathBuilder::new();
        for el in self {
            match el {
                PathEl::MoveTo(p) => pb.move_to(p.x as f32, p.y as f32),
                PathEl::LineTo(p) => pb.line_to(p.x as f32, p.y as f32),
                PathEl::QuadTo(c0, p) => {
                    pb.quad_to(c0.x as f32, c0.y as f32, p.x as f32, p.y as f32)
                }
                PathEl::CurveTo(c0, c1, p) => pb.cubic_to(
                    c0.x as f32,
                    c0.y as f32,
                    c1.x as f32,
                    c1.y as f32,
                    p.x as f32,
                    p.y as f32,
                ),
                PathEl::ClosePath => pb.close(),
            }
        }
        pb.finish()
    }
}

impl ToTinySkia for Affine {
    type T = Transform;

    fn to_tinyskia(&self) -> Transform {
        let coeffs = self.as_coeffs();
        Transform {
            sx: coeffs[0] as f32,
            ky: coeffs[1] as f32,
            kx: coeffs[2] as f32,
            sy: coeffs[3] as f32,
            tx: coeffs[4] as f32,
            ty: coeffs[5] as f32,
        }
    }
}

impl ToTinySkia for Paint {
    type T = Option<SkiaPaint<'static>>;

    fn to_tinyskia(&self) -> Option<SkiaPaint<'static>> {
        let shader = match self {
            Paint::Solid(color) => Shader::SolidColor(*color),
            Paint::LinearGradient {
                p0,
                p1,
                stops,
                extend,
                transform,
            } => LinearGradient::new(
                p0.to_tinyskia(),
                p1.to_tinyskia(),
                stops.to_tinyskia(),
                extend.to_tinyskia(),
                transform.to_tinyskia(),
            )?,
            Paint::RadialGradient {
                c0,
                r0,
                c1,
                r1,
                stops,
                extend,
                transform,
            } => RadialGradient::new(
                c0.to_tinyskia(),
                *r0,
                c1.to_tinyskia(),
                *r1,
                stops.to_tinyskia(),
                extend.to_tinyskia(),
                transform.to_tinyskia(),
            )?,
            Paint::SweepGradient {
                c0,
                start_angle,
                end_angle,
                stops,
                extend,
                transform,
            } => SweepGradient::new(
                c0.to_tinyskia(),
                *start_angle,
                *end_angle,
                stops.to_tinyskia(),
                extend.to_tinyskia(),
                transform.to_tinyskia(),
            )?,
        };
        Some(SkiaPaint {
            shader,
            ..SkiaPaint::default()
        })
    }
}

impl ToTinySkia for Extend {
    type T = SpreadMode;

    fn to_tinyskia(&self) -> SpreadMode {
        match self {
            Extend::Pad => SpreadMode::Pad,
            Extend::Repeat => SpreadMode::Repeat,
            Extend::Reflect => SpreadMode::Reflect,
            // `Extend` requires non-exhaustive matching. If any new
            // variants are discovered, they should be added.
            _ => SpreadMode::Pad,
        }
    }
}

impl ToTinySkia for Vec<crate::pens::ColorStop> {
    type T = Vec<GradientStop>;

    fn to_tinyskia(&self) -> Vec<GradientStop> {
        self.iter()
            .map(|s| GradientStop::new(s.offset, s.color))
            .collect()
    }
}

impl ToTinySkia for kurbo::Point {
    type T = SkiaPoint;

    fn to_tinyskia(&self) -> SkiaPoint {
        SkiaPoint::from_xy(self.x as f32, self.y as f32)
    }
}

#[cfg(test)]
mod tests {
    use skrifa::{FontRef, MetadataProvider};
    use tiny_skia::{Color, Pixmap};

    use crate::{
        assert_file_eq, assert_matches, testdata,
        text2png::{text2png, Text2PngOptions, TextToPngError},
    };

    #[test]
    fn ligature() {
        let png_bytes = text2png(
            "fitto",
            &Text2PngOptions {
                foreground: Color::from_rgba8(255, 255, 255, 255),
                background: Color::from_rgba8(20, 20, 20, 255),
                ..Text2PngOptions::new(testdata::CAVEAT_FONT, 24.0)
            },
        )
        .expect("To draw PNG");

        assert_file_eq!(png_bytes, "render_ligature.png");
    }

    #[test]
    fn two_lines() {
        let png_bytes = text2png(
            "hello\nworld",
            &Text2PngOptions {
                foreground: Color::from_rgba8(255, 255, 255, 255),
                background: Color::from_rgba8(20, 20, 20, 255),
                ..Text2PngOptions::new(testdata::CAVEAT_FONT, 24.0)
            },
        )
        .expect("To draw PNG");

        assert_file_eq!(png_bytes, "render_two_lines.png");
    }

    #[test]
    fn colored_font() {
        let png_bytes = text2png(
            "abab\nABAB",
            &Text2PngOptions {
                background: Color::WHITE,
                ..Text2PngOptions::new(testdata::NABLA_FONT, 64.0)
            },
        )
        .unwrap();
        assert_file_eq!(png_bytes, "colored_font.png");
    }

    #[test]
    fn sweep_gradient() {
        let sweep_gradient_text ="\u{f0200}\u{f0201}\u{f0202}\u{f0203}\u{f0204}\u{f0205}\u{f0206}\u{f0207}\u{f0208}\u{f0209}\u{f020a}\u{f020b}\u{f020c}\u{f020d}\u{f020e}\u{f020f}\n\u{f0210}\u{f0211}\u{f0212}\u{f0213}\u{f0214}\u{f0215}\u{f0216}\u{f0217}\u{f0218}\u{f0219}\u{f021a}\u{f021b}\u{f021c}\u{f021d}\u{f021e}\u{f021f}\n\u{f0220}\u{f0221}\u{f0222}\u{f0223}\u{f0224}\u{f0225}\u{f0226}\u{f0227}\u{f0228}\u{f0229}\u{f022a}\u{f022b}\u{f022c}\u{f022d}\u{f022e}\u{f022f}\n\u{f0230}\u{f0231}\u{f0232}\u{f0233}\u{f0234}\u{f0235}\u{f0236}\u{f0237}\u{f0238}\u{f0239}\u{f023a}\u{f023b}\u{f023c}\u{f023d}\u{f023e}\u{f023f}\n\u{f0240}\u{f0241}\u{f0242}\u{f0243}\u{f0244}\u{f0245}\u{f0246}\u{f0247}";
        let png_bytes = text2png(
            sweep_gradient_text,
            &Text2PngOptions::new(testdata::COLR_FONT, 64.0),
        )
        .unwrap();
        assert_file_eq!(png_bytes, "sweep_gradient.png");
    }

    #[test]
    fn complex_emoji() {
        // TODO: Improve the centering algorithm.
        let png_bytes = text2png(
            "🥳",
            &Text2PngOptions {
                background: Color::WHITE,
                ..Text2PngOptions::new(testdata::NOTO_EMOJI_FONT, 64.0)
            },
        )
        .unwrap();
        assert_file_eq!(png_bytes, "complex_emoji.png");
    }

    #[test]
    fn empty_string_produces_error() {
        assert_matches!(
            text2png("", &Text2PngOptions::new(testdata::CAVEAT_FONT, 24.0)),
            Err(TextToPngError::NoText)
        );
    }

    #[test]
    fn unmapped_character_produces_error() {
        assert_matches!(
            text2png(
                // "c" is not included in our subsetted NABLA_FONT used for testing.
                "c",
                &Text2PngOptions::new(testdata::NABLA_FONT, 64.0),
            ),
            // TODO: Produce a better error.
            Err(TextToPngError::PathBuildError)
        );
    }

    #[test]
    fn whitespace_only_produces_error() {
        assert_matches!(
            text2png("\n \n", &Text2PngOptions::new(testdata::CAVEAT_FONT, 24.0)),
            Err(TextToPngError::NoText)
        );

        assert_matches!(
            text2png("\r", &Text2PngOptions::new(testdata::CAVEAT_FONT, 24.0)),
            Err(TextToPngError::NoText)
        );
        assert_matches!(
            text2png("\t", &Text2PngOptions::new(testdata::CAVEAT_FONT, 24.0)),
            Err(TextToPngError::NoText)
        );
    }

    #[test]
    fn bad_font_data_produces_error() {
        let bad_font_data = &[];
        assert_matches!(
            text2png("hello world", &Text2PngOptions::new(bad_font_data, 24.0)),
            Err(TextToPngError::ReadError(_))
        );
    }

    #[test]
    fn zero_size_font_produces_error() {
        assert_matches!(
            text2png("hello", &Text2PngOptions::new(testdata::CAVEAT_FONT, 0.0)),
            Err(TextToPngError::TextTooSmall)
        );

        assert_matches!(
            text2png(
                "hello",
                &Text2PngOptions {
                    line_spacing: 0.0,
                    ..Text2PngOptions::new(testdata::CAVEAT_FONT, 12.0)
                },
            ),
            Err(TextToPngError::TextTooSmall)
        );
    }

    fn active_pixels(pixmap: &Pixmap) -> f64 {
        let active_sum = pixmap
            .pixels()
            .iter()
            .map(|pixel| pixel.alpha() as u64)
            .sum::<u64>();
        active_sum as f64 / 255.0
    }

    #[test]
    fn variable_font() {
        let default = Pixmap::decode_png(
            &text2png(
                "AA",
                &Text2PngOptions::new(testdata::INCONSOLATA_FONT, 24.0),
            )
            .unwrap(),
        )
        .unwrap();
        let wide_heavy = Pixmap::decode_png(
            &text2png(
                "AA",
                &Text2PngOptions {
                    location: (&FontRef::new(testdata::INCONSOLATA_FONT)
                        .unwrap()
                        .axes()
                        .location([("wght", 900.0), ("wdth", 200.0)]))
                        .into(),
                    ..Text2PngOptions::new(testdata::INCONSOLATA_FONT, 24.0)
                },
            )
            .unwrap(),
        )
        .unwrap();

        assert!(
            default.width() < wide_heavy.width(),
            "{} < {}",
            default.width(),
            wide_heavy.width()
        );

        assert!(
            active_pixels(&default) < active_pixels(&wide_heavy),
            "{} < {}",
            active_pixels(&default),
            active_pixels(&wide_heavy)
        );
    }
}
