// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
// SPDX-License-Identifier: Apache-2.0 OR MIT

//! Image optimization plugin.
//!
//! Processes images to generate WebP variants and responsive `<picture>`
//! elements with `srcset`, `loading="lazy"`, and `decoding="async"`.
//!
//! ## Responsive pipeline
//!
//! For each `<img>` in compiled HTML the plugin emits:
//!
//! ```html
//! <picture>
//!   <source type="image/avif" srcset="…-320w.avif 320w, …"> <!-- if avif feature -->
//!   <source type="image/webp" srcset="…-320w.webp 320w, …">
//!   <img src="/original.jpg" alt="…" width="…" height="…"
//!        loading="lazy" decoding="async">
//! </picture>
//! ```
//!
//! Images tagged with `fetchpriority="high"` receive `loading="eager"`
//! instead, so the browser fetches them immediately.

#[cfg(feature = "image-optimization")]
use crate::error::{PathErrorExt, SsgError};
#[cfg(feature = "image-optimization")]
use crate::plugin::{Plugin, PluginContext};

#[cfg(feature = "image-optimization")]
use std::{
    collections::HashMap,
    fs,
    path::{Path, PathBuf},
};

/// Default responsive breakpoints (px).
#[cfg(feature = "image-optimization")]
const DEFAULT_BREAKPOINTS: &[u32] = &[320, 640, 1024, 1440];

/// Default WebP encoding quality (1–100).
#[cfg(feature = "image-optimization")]
const DEFAULT_QUALITY: u8 = 80;

/// Plugin that optimises images and rewrites HTML with `<picture>` tags.
///
/// Runs in `after_compile`:
/// 1. Scans `site_dir` for JPEG/PNG images
/// 2. Generates WebP variants at responsive widths
/// 3. Rewrites `<img>` tags to `<picture>` with `srcset`
/// 4. Adds `loading="lazy"`, `decoding="async"`, `width`, `height`
///
/// The quality and breakpoints are configurable via the struct fields.
#[cfg(feature = "image-optimization")]
#[derive(Debug, Clone)]
pub struct ImageOptimizationPlugin {
    /// WebP encoding quality (1–100). Defaults to 80.
    pub quality: u8,
    /// Responsive width breakpoints in pixels. Defaults to `[320, 640, 1024, 1440]`.
    pub breakpoints: Vec<u32>,
}

#[cfg(feature = "image-optimization")]
impl Default for ImageOptimizationPlugin {
    fn default() -> Self {
        Self {
            quality: DEFAULT_QUALITY,
            breakpoints: DEFAULT_BREAKPOINTS.to_vec(),
        }
    }
}

#[cfg(feature = "image-optimization")]
impl Plugin for ImageOptimizationPlugin {
    fn name(&self) -> &'static str {
        "image-optimization"
    }

    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
        if !ctx.site_dir.exists() {
            return Ok(());
        }

        let images = collect_images(&ctx.site_dir)?;
        if images.is_empty() {
            return Ok(());
        }

        let optimized_dir = ctx.site_dir.join("optimized");
        fs::create_dir_all(&optimized_dir).with_path(&optimized_dir)?;

        let manifest = optimize_all_images(
            &images,
            &ctx.site_dir,
            &optimized_dir,
            &self.breakpoints,
            self.quality,
        );

        rewrite_html_img_tags(&ctx.site_dir, &manifest)?;

        log::info!(
            "[image] Optimised {} image(s), {} variant(s) generated",
            manifest.len(),
            manifest
                .values()
                .map(|m| m.webp_variants.len())
                .sum::<usize>()
        );
        Ok(())
    }
}

#[cfg(feature = "image-optimization")]
#[derive(Debug, Clone)]
struct ImageVariant {
    rel_path: String,
    width: u32,
}

#[cfg(feature = "image-optimization")]
#[derive(Debug, Clone)]
struct ImageManifest {
    original_rel: String,
    original_width: u32,
    original_height: u32,
    webp_variants: Vec<ImageVariant>,
    avif_variants: Vec<ImageVariant>,
}

/// Optimizes all images and builds the manifest, logging warnings for failures.
#[cfg(feature = "image-optimization")]
fn optimize_all_images(
    images: &[PathBuf],
    site_dir: &Path,
    optimized_dir: &Path,
    breakpoints: &[u32],
    quality: u8,
) -> HashMap<String, ImageManifest> {
    let mut manifest = HashMap::new();
    for img_path in images {
        match process_image(
            img_path,
            site_dir,
            optimized_dir,
            breakpoints,
            quality,
        ) {
            Ok(entry) => {
                let _ = manifest.insert(entry.original_rel.clone(), entry);
            }
            Err(e) => {
                log::warn!(
                    "[image] Failed to process {}: {e}",
                    img_path.display()
                );
            }
        }
    }
    manifest
}

/// Rewrites HTML files to use `<picture>` tags for optimized images.
#[cfg(feature = "image-optimization")]
fn rewrite_html_img_tags(
    site_dir: &Path,
    manifest: &HashMap<String, ImageManifest>,
) -> Result<(), SsgError> {
    let html_files = collect_html_files(site_dir)?;
    for html_path in &html_files {
        let html = fs::read_to_string(html_path).with_path(html_path)?;
        let rewritten = rewrite_img_tags(&html, manifest);
        if rewritten != html {
            fs::write(html_path, rewritten).with_path(html_path)?;
        }
    }
    Ok(())
}

/// Processes a single image: resize + encode to WebP (and AVIF if available)
/// at responsive widths.
#[cfg(feature = "image-optimization")]
fn process_image(
    img_path: &Path,
    site_dir: &Path,
    optimized_dir: &Path,
    breakpoints: &[u32],
    _quality: u8,
) -> Result<ImageManifest, SsgError> {
    let img = image::open(img_path).map_err(|e| SsgError::io(e, img_path))?;

    let (orig_w, orig_h) = (img.width(), img.height());
    let rel = img_path
        .strip_prefix(site_dir)
        .unwrap_or(img_path)
        .to_string_lossy()
        .replace('\\', "/");

    let stem = img_path.file_stem().unwrap_or_default().to_string_lossy();

    let mut webp_variants = Vec::new();
    let avif_variants = Vec::new();

    for &width in breakpoints {
        if width >= orig_w {
            continue; // Skip sizes larger than original
        }

        let ratio = f64::from(width) / f64::from(orig_w);
        let height = (f64::from(orig_h) * ratio) as u32;
        let resized = img.resize_exact(
            width,
            height,
            image::imageops::FilterType::Lanczos3,
        );

        // Save WebP variant
        let variant_name = format!("{stem}-{width}w.webp");
        let variant_path = optimized_dir.join(&variant_name);
        resized
            .save(&variant_path)
            .map_err(|e| SsgError::io(e, &variant_path))?;

        let variant_rel = format!("optimized/{variant_name}");
        webp_variants.push(ImageVariant {
            rel_path: variant_rel,
            width,
        });

        // AVIF encoding would go here if the `image` crate is built
        // with AVIF support (requires the `avif` feature). Since AVIF
        // encoding pulls in heavy C dependencies (libdav1d, rav1e) it
        // is left opt-in and the pipeline gracefully degrades to
        // WebP + original.
    }

    Ok(ImageManifest {
        original_rel: rel,
        original_width: orig_w,
        original_height: orig_h,
        webp_variants,
        avif_variants,
    })
}

/// Rewrites `<img src="...">` tags to `<picture>` with srcset.
///
/// For each image in the manifest that has variants, the original
/// `<img>` tag is wrapped in a `<picture>` element with:
/// - `<source type="image/avif" srcset="...">` (if AVIF variants exist)
/// - `<source type="image/webp" srcset="...">`
/// - `<img>` fallback with `loading`, `decoding`, `width`, `height`
///
/// Images with `fetchpriority="high"` get `loading="eager"` instead of
/// `loading="lazy"`.
#[cfg(feature = "image-optimization")]
fn rewrite_img_tags(
    html: &str,
    manifest: &HashMap<String, ImageManifest>,
) -> String {
    let mut result = html.to_string();

    for (original_rel, entry) in manifest {
        if entry.webp_variants.is_empty() && entry.avif_variants.is_empty() {
            continue;
        }

        // Build WebP srcset
        let webp_srcset: String = entry
            .webp_variants
            .iter()
            .map(|v| format!("/{} {}w", v.rel_path, v.width))
            .collect::<Vec<_>>()
            .join(", ");

        // Build AVIF srcset
        let avif_srcset: String = entry
            .avif_variants
            .iter()
            .map(|v| format!("/{} {}w", v.rel_path, v.width))
            .collect::<Vec<_>>()
            .join(", ");

        // Find and replace <img src="...original_rel...">
        let patterns = [
            format!("\"{original_rel}\""),
            format!("\"/{original_rel}\""),
        ];

        for pattern in &patterns {
            if let Some(img_start) = result.find(pattern) {
                // Find the <img that contains this src
                let search_back = &result[..img_start + pattern.len()];
                if let Some(tag_start) = search_back.rfind("<img") {
                    let tag_end = result[tag_start..]
                        .find('>')
                        .map_or(result.len(), |e| tag_start + e + 1);

                    let old_tag = &result[tag_start..tag_end];

                    // Extract existing attributes
                    let alt = extract_attr(old_tag, "alt").unwrap_or_default();
                    let fetchpriority = extract_attr(old_tag, "fetchpriority");

                    // Determine loading strategy
                    let loading = if fetchpriority.as_deref() == Some("high") {
                        "eager"
                    } else {
                        "lazy"
                    };

                    // Preserve original width/height if present, else use source dimensions
                    let width = extract_attr(old_tag, "width")
                        .and_then(|v| v.parse::<u32>().ok())
                        .unwrap_or(entry.original_width);
                    let height = extract_attr(old_tag, "height")
                        .and_then(|v| v.parse::<u32>().ok())
                        .unwrap_or(entry.original_height);

                    let sizes = "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw";

                    // Build <picture> element
                    let mut picture = String::from("<picture>");

                    // AVIF source (if variants exist)
                    if !avif_srcset.is_empty() {
                        picture.push_str(&format!(
                            "<source type=\"image/avif\" srcset=\"{avif_srcset}\" sizes=\"{sizes}\">"
                        ));
                    }

                    // WebP source
                    if !webp_srcset.is_empty() {
                        picture.push_str(&format!(
                            "<source type=\"image/webp\" srcset=\"{webp_srcset}\" sizes=\"{sizes}\">"
                        ));
                    }

                    // Fallback <img>
                    picture.push_str(&format!(
                        "<img src=\"/{original_rel}\" alt=\"{alt}\" \
                         width=\"{width}\" height=\"{height}\" \
                         loading=\"{loading}\" decoding=\"async\">"
                    ));

                    // fetchpriority on the img if present
                    if let Some(ref fp) = fetchpriority {
                        // Re-build: remove the closing > we just added, insert fetchpriority
                        // Actually, let's build it properly from scratch
                        picture = String::from("<picture>");
                        if !avif_srcset.is_empty() {
                            picture.push_str(&format!(
                                "<source type=\"image/avif\" srcset=\"{avif_srcset}\" sizes=\"{sizes}\">"
                            ));
                        }
                        if !webp_srcset.is_empty() {
                            picture.push_str(&format!(
                                "<source type=\"image/webp\" srcset=\"{webp_srcset}\" sizes=\"{sizes}\">"
                            ));
                        }
                        picture.push_str(&format!(
                            "<img src=\"/{original_rel}\" alt=\"{alt}\" \
                             width=\"{width}\" height=\"{height}\" \
                             loading=\"{loading}\" decoding=\"async\" \
                             fetchpriority=\"{fp}\">"
                        ));
                    }

                    picture.push_str("</picture>");

                    result = format!(
                        "{}{}{}",
                        &result[..tag_start],
                        picture,
                        &result[tag_end..]
                    );
                    break; // Only replace first occurrence per image
                }
            }
        }
    }

    result
}

#[cfg(feature = "image-optimization")]
fn extract_attr(tag: &str, attr: &str) -> Option<String> {
    let pattern = format!("{attr}=\"");
    let start = tag.find(&pattern)? + pattern.len();
    let end = tag[start..].find('"')? + start;
    Some(tag[start..end].to_string())
}

/// Collect all `.jpg`/`.jpeg`/`.png` files under `dir`, skipping any
/// that live inside an `optimized/` subdirectory (the plugin's own
/// output directory — must not be re-processed).
#[cfg(feature = "image-optimization")]
fn collect_images(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
    let all = crate::walk::walk_files_multi(dir, &["jpg", "jpeg", "png"])?;
    Ok(all
        .into_iter()
        .filter(|p| !p.components().any(|c| c.as_os_str() == "optimized"))
        .collect())
}

#[cfg(feature = "image-optimization")]
fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
    crate::walk::walk_files(dir, "html")
}

#[cfg(all(test, feature = "image-optimization"))]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    // -------------------------------------------------------------------
    // Test fixtures
    // -------------------------------------------------------------------

    /// Writes a tiny programmatically-generated JPEG to the given path.
    fn write_test_jpeg(path: &Path, w: u32, h: u32) {
        let buf = image::ImageBuffer::from_fn(w, h, |x, y| {
            image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
        });
        image::DynamicImage::ImageRgb8(buf)
            .save_with_format(path, image::ImageFormat::Jpeg)
            .expect("write jpeg");
    }

    /// Writes a tiny programmatically-generated PNG to the given path.
    fn write_test_png(path: &Path, w: u32, h: u32) {
        let buf = image::ImageBuffer::from_fn(w, h, |x, y| {
            image::Rgba([(x % 256) as u8, (y % 256) as u8, 200, 255])
        });
        image::DynamicImage::ImageRgba8(buf)
            .save_with_format(path, image::ImageFormat::Png)
            .expect("write png");
    }

    /// Builds an in-memory `ImageManifest` with the supplied WebP variants.
    fn manifest_with(
        original_rel: &str,
        width: u32,
        height: u32,
        variant_widths: &[u32],
    ) -> HashMap<String, ImageManifest> {
        let stem = original_rel
            .rsplit('/')
            .next()
            .unwrap_or(original_rel)
            .rsplit('.')
            .nth(1)
            .unwrap_or("img");
        let webp_variants = variant_widths
            .iter()
            .map(|&w| ImageVariant {
                rel_path: format!("optimized/{stem}-{w}w.webp"),
                width: w,
            })
            .collect();
        let mut m = HashMap::new();
        let _ = m.insert(
            original_rel.to_string(),
            ImageManifest {
                original_rel: original_rel.to_string(),
                original_width: width,
                original_height: height,
                webp_variants,
                avif_variants: Vec::new(),
            },
        );
        m
    }

    /// Builds a manifest with both WebP and AVIF variants.
    fn manifest_with_avif(
        original_rel: &str,
        width: u32,
        height: u32,
        variant_widths: &[u32],
    ) -> HashMap<String, ImageManifest> {
        let stem = original_rel
            .rsplit('/')
            .next()
            .unwrap_or(original_rel)
            .rsplit('.')
            .nth(1)
            .unwrap_or("img");
        let webp_variants = variant_widths
            .iter()
            .map(|&w| ImageVariant {
                rel_path: format!("optimized/{stem}-{w}w.webp"),
                width: w,
            })
            .collect();
        let avif_variants = variant_widths
            .iter()
            .map(|&w| ImageVariant {
                rel_path: format!("optimized/{stem}-{w}w.avif"),
                width: w,
            })
            .collect();
        let mut m = HashMap::new();
        let _ = m.insert(
            original_rel.to_string(),
            ImageManifest {
                original_rel: original_rel.to_string(),
                original_width: width,
                original_height: height,
                webp_variants,
                avif_variants,
            },
        );
        m
    }

    // -------------------------------------------------------------------
    // ImageOptimizationPlugin — struct configuration
    // -------------------------------------------------------------------

    #[test]
    fn default_plugin_has_expected_quality_and_breakpoints() {
        let plugin = ImageOptimizationPlugin::default();
        assert_eq!(plugin.quality, 80);
        assert_eq!(plugin.breakpoints, vec![320, 640, 1024, 1440]);
    }

    #[test]
    fn plugin_allows_custom_quality_and_breakpoints() {
        let plugin = ImageOptimizationPlugin {
            quality: 90,
            breakpoints: vec![480, 960],
        };
        assert_eq!(plugin.quality, 90);
        assert_eq!(plugin.breakpoints, vec![480, 960]);
    }

    #[test]
    fn name_returns_static_image_optimization_identifier() {
        assert_eq!(
            ImageOptimizationPlugin::default().name(),
            "image-optimization"
        );
    }

    // -------------------------------------------------------------------
    // extract_attr — table-driven over the success / failure paths
    // -------------------------------------------------------------------

    #[test]
    fn extract_attr_table_driven_inputs() {
        let cases: &[(&str, &str, Option<&str>)] = &[
            (r#"<img src="x.jpg" alt="Photo">"#, "alt", Some("Photo")),
            (r#"<img src="x.jpg">"#, "alt", None),
            (r#"<img alt="">"#, "alt", Some("")),
            (
                r#"<img alt="multi word value">"#,
                "alt",
                Some("multi word value"),
            ),
            (r#"<img src="x.jpg" alt="P">"#, "src", Some("x.jpg")),
            (r"<img>", "src", None),
            (
                r#"<img fetchpriority="high" src="x.jpg">"#,
                "fetchpriority",
                Some("high"),
            ),
        ];
        for &(tag, attr, expected) in cases {
            let actual = extract_attr(tag, attr);
            assert_eq!(
                actual.as_deref(),
                expected,
                "extract_attr({tag:?}, {attr:?}) should be {expected:?}"
            );
        }
    }

    // -------------------------------------------------------------------
    // rewrite_img_tags — picture element generation
    // -------------------------------------------------------------------

    #[test]
    fn rewrite_img_tags_replaces_img_with_picture_element() {
        let manifest =
            manifest_with("images/photo.jpg", 2000, 1500, &[640, 1024]);
        let html = r#"<img src="images/photo.jpg" alt="A photo">"#;

        let result = rewrite_img_tags(html, &manifest);

        assert!(result.contains("<picture>"));
        assert!(result.contains("</picture>"));
        assert!(result.contains(r#"type="image/webp""#));
        assert!(result.contains("srcset="));
        assert!(result.contains("640w"));
        assert!(result.contains("1024w"));
        assert!(result.contains(r#"loading="lazy""#));
        assert!(result.contains(r#"decoding="async""#));
        assert!(result.contains(r#"width="2000""#));
        assert!(result.contains(r#"height="1500""#));
        assert!(result.contains(r#"alt="A photo""#));
    }

    #[test]
    fn rewrite_img_tags_preserves_alt_text() {
        let manifest = manifest_with("a.jpg", 2000, 1000, &[640]);
        let html = r#"<img src="a.jpg" alt="Important context">"#;
        let result = rewrite_img_tags(html, &manifest);
        assert!(result.contains(r#"alt="Important context""#));
    }

    #[test]
    fn rewrite_img_tags_handles_missing_alt_with_empty_string() {
        let manifest = manifest_with("a.jpg", 2000, 1000, &[640]);
        let html = r#"<img src="a.jpg">"#;
        let result = rewrite_img_tags(html, &manifest);
        assert!(result.contains(r#"alt="""#));
    }

    #[test]
    fn rewrite_img_tags_handles_absolute_src_path() {
        let manifest = manifest_with("images/a.jpg", 2000, 1000, &[640]);
        let html = r#"<img src="/images/a.jpg" alt="">"#;
        let result = rewrite_img_tags(html, &manifest);
        assert!(result.contains("<picture>"));
    }

    #[test]
    fn rewrite_img_tags_no_match_returns_unchanged() {
        let manifest = manifest_with("ghost.jpg", 100, 100, &[640]);
        let html = r"<p>no images here</p>";
        let result = rewrite_img_tags(html, &manifest);
        assert_eq!(result, html);
    }

    #[test]
    fn rewrite_img_tags_skips_entries_with_no_variants() {
        let manifest = manifest_with("a.jpg", 2000, 1000, &[]);
        let html = r#"<img src="a.jpg" alt="x">"#;
        let result = rewrite_img_tags(html, &manifest);
        assert_eq!(result, html, "no variants → no rewrite");
    }

    // -------------------------------------------------------------------
    // rewrite_img_tags — srcset format
    // -------------------------------------------------------------------

    #[test]
    fn rewrite_img_tags_builds_srcset_with_width_descriptors() {
        let manifest =
            manifest_with("a.jpg", 4000, 3000, &[320, 640, 1024, 1440]);
        let html = r#"<img src="a.jpg" alt="">"#;
        let result = rewrite_img_tags(html, &manifest);
        for w in [320, 640, 1024, 1440] {
            assert!(
                result.contains(&format!("{w}w")),
                "srcset should contain {w}w:\n{result}"
            );
        }
        assert!(result.matches(", ").count() >= 3);
    }

    #[test]
    fn rewrite_img_tags_srcset_paths_are_absolute() {
        let manifest = manifest_with("a.jpg", 2000, 1000, &[640]);
        let html = r#"<img src="a.jpg" alt="">"#;
        let result = rewrite_img_tags(html, &manifest);
        assert!(
            result.contains("/optimized/a-640w.webp 640w"),
            "srcset paths should be absolute: {result}"
        );
    }

    // -------------------------------------------------------------------
    // rewrite_img_tags — lazy loading defaults
    // -------------------------------------------------------------------

    #[test]
    fn rewrite_img_tags_default_loading_is_lazy() {
        let manifest = manifest_with("a.jpg", 2000, 1000, &[640]);
        let html = r#"<img src="a.jpg" alt="">"#;
        let result = rewrite_img_tags(html, &manifest);
        assert!(result.contains(r#"loading="lazy""#));
        assert!(result.contains(r#"decoding="async""#));
    }

    #[test]
    fn rewrite_img_tags_fetchpriority_high_gets_eager_loading() {
        let manifest = manifest_with("hero.jpg", 2000, 1000, &[640]);
        let html = r#"<img src="hero.jpg" alt="Hero" fetchpriority="high">"#;
        let result = rewrite_img_tags(html, &manifest);
        assert!(
            result.contains(r#"loading="eager""#),
            "fetchpriority=high should produce loading=eager: {result}"
        );
        assert!(
            result.contains(r#"fetchpriority="high""#),
            "fetchpriority attribute should be preserved: {result}"
        );
    }

    #[test]
    fn rewrite_img_tags_fetchpriority_low_still_lazy() {
        let manifest = manifest_with("bg.jpg", 2000, 1000, &[640]);
        let html = r#"<img src="bg.jpg" alt="" fetchpriority="low">"#;
        let result = rewrite_img_tags(html, &manifest);
        assert!(result.contains(r#"loading="lazy""#));
    }

    // -------------------------------------------------------------------
    // rewrite_img_tags — AVIF + WebP picture element
    // -------------------------------------------------------------------

    #[test]
    fn rewrite_img_tags_includes_avif_source_when_present() {
        let manifest =
            manifest_with_avif("photo.jpg", 2000, 1500, &[640, 1024]);
        let html = r#"<img src="photo.jpg" alt="">"#;
        let result = rewrite_img_tags(html, &manifest);

        assert!(
            result.contains(r#"type="image/avif""#),
            "should have AVIF source: {result}"
        );
        assert!(
            result.contains(r#"type="image/webp""#),
            "should have WebP source: {result}"
        );

        // AVIF should come before WebP (browser picks first match)
        let avif_pos = result.find("image/avif").expect("avif present");
        let webp_pos = result.find("image/webp").expect("webp present");
        assert!(
            avif_pos < webp_pos,
            "AVIF source should precede WebP source"
        );
    }

    #[test]
    fn rewrite_img_tags_avif_srcset_has_correct_format() {
        let manifest = manifest_with_avif("photo.jpg", 2000, 1500, &[320, 640]);
        let html = r#"<img src="photo.jpg" alt="">"#;
        let result = rewrite_img_tags(html, &manifest);

        assert!(
            result.contains("/optimized/photo-320w.avif 320w"),
            "AVIF srcset should have width descriptors: {result}"
        );
        assert!(
            result.contains("/optimized/photo-640w.avif 640w"),
            "AVIF srcset should have width descriptors: {result}"
        );
    }

    // -------------------------------------------------------------------
    // rewrite_img_tags — width/height preservation
    // -------------------------------------------------------------------

    #[test]
    fn rewrite_img_tags_injects_dimensions_from_manifest() {
        let manifest = manifest_with("a.jpg", 1920, 1080, &[640]);
        let html = r#"<img src="a.jpg" alt="">"#;
        let result = rewrite_img_tags(html, &manifest);
        assert!(result.contains(r#"width="1920""#));
        assert!(result.contains(r#"height="1080""#));
    }

    #[test]
    fn rewrite_img_tags_preserves_explicit_width_height() {
        let manifest = manifest_with("a.jpg", 1920, 1080, &[640]);
        let html = r#"<img src="a.jpg" alt="" width="800" height="450">"#;
        let result = rewrite_img_tags(html, &manifest);
        assert!(
            result.contains(r#"width="800""#),
            "explicit width should be preserved: {result}"
        );
        assert!(
            result.contains(r#"height="450""#),
            "explicit height should be preserved: {result}"
        );
    }

    #[test]
    fn rewrite_img_tags_only_replaces_first_occurrence_per_image() {
        let manifest = manifest_with("a.jpg", 2000, 1000, &[640]);
        let html = r#"<img src="a.jpg"><img src="a.jpg">"#;
        let result = rewrite_img_tags(html, &manifest);
        assert_eq!(result.matches("<picture>").count(), 1);
    }

    // -------------------------------------------------------------------
    // collect_images — extension filter + optimized-dir skip
    // -------------------------------------------------------------------

    #[test]
    fn collect_images_skips_optimized_subdirectory() {
        let dir = tempdir().expect("tempdir");
        let site = dir.path().join("site");
        let opt = site.join("optimized");
        fs::create_dir_all(&opt).unwrap();

        fs::write(site.join("photo.jpg"), [0xFF, 0xD8]).unwrap();
        fs::write(opt.join("photo-640w.webp"), [0]).unwrap();

        let images = collect_images(&site).unwrap();
        assert_eq!(images.len(), 1);
        assert!(images[0].ends_with("photo.jpg"));
    }

    #[test]
    fn collect_images_filters_to_jpg_jpeg_png_only() {
        let dir = tempdir().expect("tempdir");
        for name in ["a.jpg", "b.jpeg", "c.png", "d.gif", "e.webp", "f.txt"] {
            fs::write(dir.path().join(name), [0]).unwrap();
        }
        let images = collect_images(dir.path()).unwrap();
        assert_eq!(images.len(), 3, "only jpg/jpeg/png should be collected");
    }

    #[test]
    fn collect_images_extension_match_is_case_insensitive() {
        let dir = tempdir().expect("tempdir");
        for name in ["A.JPG", "B.PNG", "C.JPEG"] {
            fs::write(dir.path().join(name), [0]).unwrap();
        }
        let images = collect_images(dir.path()).unwrap();
        assert_eq!(images.len(), 3);
    }

    #[test]
    fn collect_images_recurses_into_nested_subdirectories() {
        let dir = tempdir().expect("tempdir");
        let nested = dir.path().join("a").join("b");
        fs::create_dir_all(&nested).unwrap();
        fs::write(dir.path().join("top.jpg"), [0]).unwrap();
        fs::write(nested.join("deep.png"), [0]).unwrap();

        let images = collect_images(dir.path()).unwrap();
        assert_eq!(images.len(), 2);
    }

    #[test]
    fn collect_images_returns_empty_for_missing_directory() {
        let dir = tempdir().expect("tempdir");
        let result = collect_images(&dir.path().join("missing")).unwrap();
        assert!(result.is_empty());
    }

    #[test]
    fn collect_images_returns_results_sorted() {
        let dir = tempdir().expect("tempdir");
        for name in ["zebra.jpg", "apple.jpg", "mango.jpg"] {
            fs::write(dir.path().join(name), [0]).unwrap();
        }
        let images = collect_images(dir.path()).unwrap();
        let names: Vec<_> = images
            .iter()
            .map(|p| p.file_name().unwrap().to_str().unwrap())
            .collect();
        assert_eq!(names, vec!["apple.jpg", "mango.jpg", "zebra.jpg"]);
    }

    // -------------------------------------------------------------------
    // collect_html_files — recursion + filtering
    // -------------------------------------------------------------------

    #[test]
    fn collect_html_files_filters_non_html_extensions() {
        let dir = tempdir().expect("tempdir");
        fs::write(dir.path().join("a.html"), "").unwrap();
        fs::write(dir.path().join("b.css"), "").unwrap();

        let result = collect_html_files(dir.path()).unwrap();
        assert_eq!(result.len(), 1);
    }

    #[test]
    fn collect_html_files_recurses_and_sorts() {
        let dir = tempdir().expect("tempdir");
        let nested = dir.path().join("blog");
        fs::create_dir_all(&nested).unwrap();
        fs::write(dir.path().join("index.html"), "").unwrap();
        fs::write(nested.join("post.html"), "").unwrap();

        let result = collect_html_files(dir.path()).unwrap();
        assert_eq!(result.len(), 2);
    }

    // -------------------------------------------------------------------
    // after_compile — short-circuit paths (no real images)
    // -------------------------------------------------------------------

    #[test]
    fn after_compile_missing_site_dir_returns_ok() {
        let dir = tempdir().expect("tempdir");
        let missing = dir.path().join("missing");
        let ctx =
            PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
        ImageOptimizationPlugin::default()
            .after_compile(&ctx)
            .expect("missing site is not an error");
        assert!(!missing.exists());
    }

    // -------------------------------------------------------------------
    // process_image — real JPEG/PNG round-trip
    // -------------------------------------------------------------------

    #[test]
    fn process_image_generates_webp_variants_below_original_width() {
        let dir = tempdir().expect("tempdir");
        let site = dir.path().join("site");
        let opt = site.join("optimized");
        fs::create_dir_all(&opt).unwrap();

        let src = site.join("hero.jpg");
        write_test_jpeg(&src, 2000, 1000);

        let manifest = process_image(
            &src,
            &site,
            &opt,
            &[320, 640, 1024, 1440],
            DEFAULT_QUALITY,
        )
        .unwrap();
        assert_eq!(manifest.original_width, 2000);
        assert_eq!(manifest.original_height, 1000);
        assert_eq!(manifest.original_rel, "hero.jpg");

        // Every breakpoint strictly less than 2000 must produce a variant.
        assert_eq!(manifest.webp_variants.len(), 4);
        for v in &manifest.webp_variants {
            assert!(opt
                .join(v.rel_path.trim_start_matches("optimized/"))
                .exists());
            assert!(v.width < 2000);
        }
    }

    #[test]
    fn process_image_skips_widths_larger_than_original() {
        let dir = tempdir().expect("tempdir");
        let site = dir.path().join("site");
        let opt = site.join("optimized");
        fs::create_dir_all(&opt).unwrap();

        let src = site.join("small.png");
        write_test_png(&src, 500, 500);

        let manifest = process_image(
            &src,
            &site,
            &opt,
            &[320, 640, 1024, 1440],
            DEFAULT_QUALITY,
        )
        .unwrap();
        // Only 320 should survive (320 < 500).
        assert_eq!(manifest.webp_variants.len(), 1);
        assert_eq!(manifest.webp_variants[0].width, 320);
    }

    #[test]
    fn process_image_uses_custom_breakpoints() {
        let dir = tempdir().expect("tempdir");
        let site = dir.path().join("site");
        let opt = site.join("optimized");
        fs::create_dir_all(&opt).unwrap();

        let src = site.join("photo.jpg");
        write_test_jpeg(&src, 2000, 1000);

        let manifest =
            process_image(&src, &site, &opt, &[480, 960], DEFAULT_QUALITY)
                .unwrap();
        assert_eq!(manifest.webp_variants.len(), 2);
        assert_eq!(manifest.webp_variants[0].width, 480);
        assert_eq!(manifest.webp_variants[1].width, 960);
    }

    #[test]
    fn process_image_rejects_unreadable_source_path() {
        let dir = tempdir().expect("tempdir");
        let opt = dir.path().join("opt");
        fs::create_dir_all(&opt).unwrap();
        let missing = dir.path().join("does-not-exist.jpg");
        assert!(process_image(
            &missing,
            dir.path(),
            &opt,
            DEFAULT_BREAKPOINTS,
            DEFAULT_QUALITY
        )
        .is_err());
    }

    // -------------------------------------------------------------------
    // after_compile — end-to-end on real images
    // -------------------------------------------------------------------

    #[test]
    fn after_compile_processes_real_images_and_rewrites_html() {
        let dir = tempdir().expect("tempdir");
        let site = dir.path().join("site");
        let images = site.join("images");
        fs::create_dir_all(&images).unwrap();

        write_test_jpeg(&images.join("photo.jpg"), 2000, 1500);
        fs::write(
            site.join("index.html"),
            r#"<html><head></head><body><img src="/images/photo.jpg" alt="Test"></body></html>"#,
        )
        .unwrap();

        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
        ImageOptimizationPlugin::default()
            .after_compile(&ctx)
            .unwrap();

        // Original file preserved.
        assert!(images.join("photo.jpg").exists());
        // Optimized directory populated.
        assert!(site.join("optimized").exists());
        // HTML rewritten to <picture>.
        let html = fs::read_to_string(site.join("index.html")).unwrap();
        assert!(html.contains("<picture>"));
        assert!(html.contains("image/webp"));
        assert!(html.contains(r#"alt="Test""#));
        assert!(html.contains(r#"loading="lazy""#));
        assert!(html.contains(r#"decoding="async""#));
    }

    #[test]
    fn after_compile_failed_image_processing_logs_warn_and_continues() {
        let dir = tempdir().expect("tempdir");
        let site = dir.path().join("site");
        fs::create_dir_all(&site).unwrap();

        fs::write(site.join("broken.jpg"), b"this is not really a jpeg")
            .unwrap();

        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
        ImageOptimizationPlugin::default()
            .after_compile(&ctx)
            .expect("broken image must not propagate");
    }

    #[test]
    fn after_compile_html_without_image_refs_skips_rewrite() {
        let dir = tempdir().expect("tempdir");
        let site = dir.path().join("site");
        let images = site.join("images");
        fs::create_dir_all(&images).unwrap();

        write_test_jpeg(&images.join("orphan.jpg"), 1000, 1000);
        let original_html =
            "<html><head></head><body><p>no images here</p></body></html>";
        fs::write(site.join("index.html"), original_html).unwrap();

        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
        ImageOptimizationPlugin::default()
            .after_compile(&ctx)
            .unwrap();

        let after = fs::read_to_string(site.join("index.html")).unwrap();
        assert_eq!(
            after, original_html,
            "html with no image refs should not be rewritten"
        );
    }

    #[test]
    fn after_compile_no_images_short_circuits_without_creating_optimized_dir() {
        let dir = tempdir().expect("tempdir");
        let site = dir.path().join("site");
        fs::create_dir_all(&site).unwrap();
        fs::write(site.join("index.html"), "<p></p>").unwrap();

        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
        ImageOptimizationPlugin::default()
            .after_compile(&ctx)
            .unwrap();
        assert!(!site.join("optimized").exists());
    }
}
