//! Re-encode decoded attachments that exceed [`MAX_IMAGE_BYTES`],
//! [`MAX_ENCODE_PIXELS`], or [`MAX_ENCODE_SIDE_PX`] to fit the conversation
//! caps. The primary dimension limit is the v9 pixel-area budget
//! ([`MAX_ENCODE_PIXELS`]); [`MAX_ENCODE_SIDE_PX`] is a model-agnostic side
//! clamp. Compute is amortised via
//! [`NormalizeCache`](crate::session::normalize_cache).
use crate::session::normalize_cache::{
    HarnessVariant, NormalizeCache, NormalizeError, NormalizedEntry, run_blocking,
};
use agent_client_protocol::ImageContent;
use base64::Engine as _;
use bytes::Bytes;
use kigi_tools::util::image_compress::{FilterType, ReEncodeParams, re_encode_under_limit};
use std::borrow::Cow;
/// Decoded attachment bytes above this are re-encoded to fit this cap.
///
/// Kept low so many images fit under the inference proxy's ~50 MB request-body
/// limit before byte-budget image eviction has to kick in downstream. Base64
/// inflates raw bytes by ~4/3, so a 1.5 MB image is ~2 MB on the wire and ~25
/// fit under the limit. A low per-image cost means a conversation rarely
/// reaches the eviction threshold, so the server-side KV-cache prefix is rarely
/// rewritten. (Was 5 MB, which let only ~7 images reach the limit and then
/// forced cache-busting eviction on essentially every subsequent turn.)
pub(crate) const MAX_IMAGE_BYTES: usize = 1_500_000;
const LIMIT_LABEL: &str = "1.5 MB";
/// Total pixel budget (w*h) before downscaling. Mirrors the v9 tokenizer's
/// `image_filter_max_pixels = 2_408_448` — larger images are downsampled
/// server-side anyway, so extra pixels only waste request bytes.
const MAX_ENCODE_PIXELS: u64 = 2_408_448;
/// Max width/height before downscaling. Model-agnostic side clamp because
/// images are normalized once at ingest and models can switch mid-session.
/// Not a v9 constraint — [`MAX_ENCODE_PIXELS`] is what the v9 encoder enforces.
const MAX_ENCODE_SIDE_PX: u32 = 2000;
/// External-harness image-resize path caps at 1024px before captioning.
const STRICT_MAX_ENCODE_SIDE_PX: u32 = 1024;
const MIN_ENCODE_SIDE_PX: u32 = 512;
const DOWNSCALE_FILTER: FilterType = FilterType::CatmullRom;
const JPEG_QUALITY_STEPS: &[u8] = &[88, 80, 72, 64, 56, 48, 40, 32];
/// Upper bound on decoded pixel count before refusing to decode. Matches
/// the API ceiling ([`MAX_VISION_TOTAL_PX`]) so any image the API would
/// accept can be decoded for the downscale re-encode — a 20-48 Mpx camera
/// photo must not be refused client-side (it downscales to the wire caps
/// anyway). Worst case is a transient ~716 MB RGBA bitmap inside
/// `spawn_blocking`, one image at a time.
const MAX_DECODE_PIXELS: u64 = MAX_VISION_TOTAL_PX;
/// Bounded ICO decode for load-time verification: real icons are far
/// smaller; bytes claiming more are kept un-verified rather than decoded
/// on the session-load path.
const MAX_LOAD_ICO_DECODE_PIXELS: u64 = 16_000_000;
/// Backend APIs reject images with either side < 8 px.
pub(crate) const MIN_VISION_SIDE_PX: u32 = 8;
/// Backend APIs also reject images with fewer than 512 total pixels
/// (`MIN_IMAGE_PIXELS`); e.g. a 16×16 icon is 256 px and draws a 400 that
/// poisons the conversation on every following turn.
pub(crate) const MIN_VISION_TOTAL_PX: u64 = 512;
/// Backend ceiling (`MAX_IMAGE_PIXELS`), header-checked server-side before
/// any resize. Send paths re-encode far below this; only legacy/foreign
/// history payloads can exceed it.
pub(crate) const MAX_VISION_TOTAL_PX: u64 = 178_956_970;
const NORMALIZE_PARAMS: ReEncodeParams = ReEncodeParams {
    max_bytes: MAX_IMAGE_BYTES,
    max_side_px: MAX_ENCODE_SIDE_PX,
    max_pixels: MAX_ENCODE_PIXELS,
    min_side_px: MIN_ENCODE_SIDE_PX,
    quality_steps: JPEG_QUALITY_STEPS,
    filter: DOWNSCALE_FILTER,
};
/// Resize target for the stricter image normalization path.
const STRICT_NORMALIZE_PARAMS: ReEncodeParams = ReEncodeParams {
    max_bytes: MAX_IMAGE_BYTES,
    max_side_px: STRICT_MAX_ENCODE_SIDE_PX,
    max_pixels: u64::MAX,
    min_side_px: MIN_ENCODE_SIDE_PX,
    quality_steps: JPEG_QUALITY_STEPS,
    filter: DOWNSCALE_FILTER,
};
#[derive(Debug, Clone, Copy)]
pub struct ImageCompressionInfo {
    pub index: usize,
    pub original_bytes: usize,
    pub compressed_bytes: usize,
    pub original_width: u32,
    pub original_height: u32,
    pub compressed_width: u32,
    pub compressed_height: u32,
    pub exceeded_size: bool,
    pub exceeded_dimensions: bool,
}
impl ImageCompressionInfo {
    fn reason_label(&self) -> Cow<'static, str> {
        match (self.exceeded_size, self.exceeded_dimensions) {
            (true, true) => Cow::Borrowed("was over the size and resolution limits"),
            (true, false) => Cow::Owned(format!("was over the {LIMIT_LABEL} attachment limit")),
            (false, true) => Cow::Borrowed("was over the max input resolution"),
            (false, false) => Cow::Borrowed("compressed"),
        }
    }
    /// User-facing one-liner (TUI toast, headless events). Leads with the
    /// outcome, not the limit violation, so it doesn't read as an error;
    /// the why lives in the model-facing [`render_compression_notice`],
    /// which keeps [`Self::reason_label`].
    pub fn display(&self) -> String {
        let verb = if self.compressed_width == self.original_width
            && self.compressed_height == self.original_height
        {
            "Compressed"
        } else {
            "Downscaled"
        };
        format!(
            "{verb} Image {}: {} ({}x{}) \u{2192} {} ({}x{})",
            self.index,
            format_bytes(self.original_bytes),
            self.original_width,
            self.original_height,
            format_bytes(self.compressed_bytes),
            self.compressed_width,
            self.compressed_height,
        )
    }
}
#[derive(Default)]
pub struct NormalizeResult {
    pub images: Vec<ImageContent>,
    pub compressed: Vec<ImageCompressionInfo>,
    pub re_encode_fallbacks: Vec<String>,
    /// Images dropped entirely (integrity failure, too small, etc.).
    /// Surfaced via [`render_image_dropped_notice`].
    pub dropped: Vec<String>,
}
pub async fn normalize_images(images: Vec<ImageContent>, is_cursor: bool) -> NormalizeResult {
    normalize_images_in(images, is_cursor, NormalizeCache::global()).await
}
/// [`normalize_images`] with an injected cache (tests use a fresh
/// per-case instance to avoid singleton-state leakage).
pub(crate) async fn normalize_images_in(
    images: Vec<ImageContent>,
    is_cursor: bool,
    cache: &NormalizeCache,
) -> NormalizeResult {
    let mut out = Vec::with_capacity(images.len());
    let mut compressed = Vec::new();
    let mut re_encode_fallbacks = Vec::new();
    let mut dropped = Vec::new();
    for (i, img) in images.into_iter().enumerate() {
        let one_based = i + 1;
        match normalize_one_in(img, one_based, is_cursor, cache).await {
            Outcome::Unchanged(c) => out.push(c),
            Outcome::ReEncodingOversized(c) => {
                re_encode_fallbacks
                    .push(
                        format!(
                            "Image {one_based} could not be re-encoded under the {LIMIT_LABEL} limit; the original attachment was kept."
                        ),
                    );
                out.push(c);
            }
            Outcome::Compressed { content, info } => {
                out.push(content);
                compressed.push(info);
            }
            Outcome::Failed { index, error } => {
                tracing::warn!("image {index}: normalization failed: {error}");
                dropped.push(format!("Image {index} was dropped before send: {error}."));
            }
        }
    }
    NormalizeResult {
        images: out,
        compressed,
        re_encode_fallbacks,
        dropped,
    }
}
fn params_for(harness: HarnessVariant) -> &'static ReEncodeParams {
    match harness {
        HarnessVariant::Cursor => &STRICT_NORMALIZE_PARAMS,
        HarnessVariant::Default => &NORMALIZE_PARAMS,
    }
}
/// Resolve the active reminder tag via the canonical constants in
/// `kigi_tools::reminders` (free-fn shape because this module has no
/// `SessionActor`; see `reminder_wrapper_tag`).
fn reminder_tag(is_cursor: bool) -> &'static str {
    let _ = is_cursor;
    kigi_tools::reminders::DEFAULT_REMINDER_TAG
}
fn render_notice(notes: &[String], is_cursor: bool, inner_tag: &str) -> String {
    if notes.is_empty() {
        return String::new();
    }
    let tag = reminder_tag(is_cursor);
    format!(
        "\n\n<{tag}>\n\
         <{inner_tag}>\n\
         {}\n\
         </{inner_tag}>\n\
         </{tag}>",
        notes.join("\n"),
    )
}
/// System-reminder for images dropped entirely before send.
pub fn render_image_dropped_notice(notes: &[String], is_cursor: bool) -> String {
    render_notice(notes, is_cursor, "image_dropped_notice")
}
/// Build the (system-reminder, owned-notes) pair for a
/// `NormalizeResult.dropped` list. Shared by the user-attachment flow
/// and the tool-result-extraction flow.
pub(crate) fn dropped_to_envelope(
    dropped: Vec<String>,
    is_cursor: bool,
) -> Option<(String, Vec<String>)> {
    if dropped.is_empty() {
        return None;
    }
    let notice = render_image_dropped_notice(&dropped, is_cursor);
    Some((notice, dropped))
}
/// System-reminder when oversized attachments were kept after re-encode failure.
pub fn render_re_encode_fallback_notice(notes: &[String], is_cursor: bool) -> String {
    render_notice(notes, is_cursor, "image_re_encode_fallback")
}
/// System-reminder listing images that were re-encoded under the cap.
pub fn render_compression_notice(compressed: &[ImageCompressionInfo], is_cursor: bool) -> String {
    let notes: Vec<String> = compressed
        .iter()
        .map(|c| {
            format!(
                "Image {} {} and was re-encoded from {}x{} ({}) \
                 to {}x{} ({}). Fine details may have been lost.",
                c.index,
                c.reason_label(),
                c.original_width,
                c.original_height,
                format_bytes(c.original_bytes),
                c.compressed_width,
                c.compressed_height,
                format_bytes(c.compressed_bytes),
            )
        })
        .collect();
    let tag = reminder_tag(is_cursor);
    format!(
        "\n\n<{tag}>\n\
         <image_compression_notice>\n\
         {}\n\
         </image_compression_notice>\n\
         </{tag}>",
        notes.join("\n"),
    )
}
/// Why persisted-history image bytes would be rejected by the API, or
/// `None` when sendable. Cheap (format sniff + structural walk + header
/// dimension probe; pixel decode only for ICO, bounded) — used at session
/// load to strip payloads that draw a 400 on every subsequent turn,
/// leaving the session bricked. The reason is logged when the loader
/// strips an image — the strip is re-persisted (irreversible), so the
/// evidence must reach logs.
pub(crate) fn persisted_image_reject_reason(bytes: &[u8]) -> Option<String> {
    use image::ImageFormat as F;
    use kigi_tools::util::image_validate as iv;
    let Ok(format) = image::guess_format(bytes) else {
        return Some(format!("unrecognized format ({} bytes)", bytes.len()));
    };
    match format {
        F::Ico => {
            let Ok((w, h, _)) = iv::validate_image_bytes_unrestricted(bytes, false) else {
                return Some("unreadable Ico header".to_owned());
            };
            if (w as u64) * (h as u64) > MAX_LOAD_ICO_DECODE_PIXELS {
                return None;
            }
            image::load_from_memory(bytes)
                .is_err()
                .then(|| format!("undecodable Ico ({} bytes)", bytes.len()))
        }
        F::Gif | F::Bmp | F::Tiff => Some(format!("API-rejected format {format:?}")),
        F::Jpeg | F::Png | F::WebP => {
            if !iv::format_structurally_complete(format, bytes) {
                return Some(format!(
                    "structurally incomplete {format:?} ({} bytes)",
                    bytes.len()
                ));
            }
            let Ok((w, h, _)) = iv::validate_image_bytes_unrestricted(bytes, false) else {
                return Some(format!("unreadable {format:?} header"));
            };
            let px = (w as u64) * (h as u64);
            if w < MIN_VISION_SIDE_PX || h < MIN_VISION_SIDE_PX || px < MIN_VISION_TOTAL_PX {
                return Some(format!("below dimension floor ({w}x{h})"));
            }
            (px > MAX_VISION_TOTAL_PX).then(|| format!("above pixel ceiling ({w}x{h})"))
        }
        _ => Some(format!("API-rejected format {format:?}")),
    }
}
/// Whether a `read_file` image may be attached inline to its tool result.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum InlineAttachVerdict {
    Attach,
    TooSmall,
    /// Base64 or header probe failed. Fail closed: an unvalidatable image
    /// must be withheld, not attached (the API 400s it on every turn).
    Unreadable,
}
/// Gate for attaching a `read_file` image to its tool result: enforce the
/// API dimension floors on the decoded payload.
pub(crate) fn inline_attach_verdict(data_b64: &str) -> InlineAttachVerdict {
    use base64::Engine as _;
    let Ok(raw) = base64::engine::general_purpose::STANDARD.decode(data_b64) else {
        return InlineAttachVerdict::Unreadable;
    };
    let Ok((w, h, _)) = kigi_tools::util::image_validate::validate_image_bytes_with(&raw, false)
    else {
        return InlineAttachVerdict::Unreadable;
    };
    if w < MIN_VISION_SIDE_PX
        || h < MIN_VISION_SIDE_PX
        || (w as u64) * (h as u64) < MIN_VISION_TOTAL_PX
    {
        InlineAttachVerdict::TooSmall
    } else {
        InlineAttachVerdict::Attach
    }
}
#[derive(Debug)]
enum Outcome {
    Unchanged(ImageContent),
    /// Re-encode could not meet the byte cap; original bytes are passed through.
    ReEncodingOversized(ImageContent),
    Compressed {
        content: ImageContent,
        info: ImageCompressionInfo,
    },
    Failed {
        index: usize,
        error: String,
    },
}
async fn normalize_one_in(
    img: ImageContent,
    index: usize,
    is_cursor: bool,
    cache: &NormalizeCache,
) -> Outcome {
    let raw_bytes = match base64::engine::general_purpose::STANDARD.decode(&img.data) {
        Ok(b) => b,
        Err(e) => return fail(index, format!("base64 decode: {e}")),
    };
    use kigi_tools::util::image_validate as iv;
    let (img, raw_bytes) = if iv::needs_endpoint_transcode(&raw_bytes) {
        let png = match run_blocking(move || match iv::transcode_to_endpoint_png(&raw_bytes) {
            Some(r) => r.map_err(|e| NormalizeError(format!("non-native image transcode: {e}"))),
            None => Err(NormalizeError(
                "non-native image transcode: format no longer needs conversion".to_owned(),
            )),
        })
        .await
        {
            Ok(png) => png,
            Err(e) => return fail(index, e.0),
        };
        let converted = ImageContent::new(
            base64::engine::general_purpose::STANDARD.encode(&png),
            "image/png".to_owned(),
        )
        .uri(img.uri.clone())
        .annotations(img.annotations.clone())
        .meta(img.meta.clone());
        (converted, png)
    } else {
        (img, raw_bytes)
    };
    let harness = HarnessVariant::from_is_cursor(is_cursor);
    let entry_res = cache
        .get_or_try_insert_with(raw_bytes, harness, move |bytes| {
            compute_normalized(bytes, harness, index)
        })
        .await;
    match entry_res {
        Ok(entry) => entry_to_outcome(img, entry, index),
        Err(arc_err) => fail(index, arc_err.0.clone()),
    }
}
fn entry_to_outcome(orig: ImageContent, entry: NormalizedEntry, index: usize) -> Outcome {
    match entry {
        NormalizedEntry::Compressed {
            bytes, mime, info, ..
        } => Outcome::Compressed {
            content: ImageContent::new(
                base64::engine::general_purpose::STANDARD.encode(&bytes),
                mime.into_owned(),
            )
            .uri(orig.uri)
            .annotations(orig.annotations)
            .meta(orig.meta),
            info: ImageCompressionInfo { index, ..info },
        },
        NormalizedEntry::ReEncodingOversized { .. } => Outcome::ReEncodingOversized(orig),
        NormalizedEntry::Unchanged { .. } => Outcome::Unchanged(orig),
    }
}
async fn compute_normalized(
    raw_bytes: Vec<u8>,
    harness: HarnessVariant,
    index: usize,
) -> Result<NormalizedEntry, NormalizeError> {
    let params = params_for(harness);
    run_blocking(move || compute_normalized_blocking(raw_bytes, params, index)).await
}
/// CPU-bound normalize work. `params` widens to `&'static
/// ReEncodeParams` (instead of `HarnessVariant`) so tests can inject
/// `max_bytes = 0` to drive the `ReEncodingOversized` branch.
fn compute_normalized_blocking(
    raw_bytes: Vec<u8>,
    params: &'static ReEncodeParams,
    index: usize,
) -> Result<NormalizedEntry, NormalizeError> {
    let original_bytes = raw_bytes.len();
    let (orig_w, orig_h, orig_mime) =
        kigi_tools::util::image_validate::validate_image_bytes_with(&raw_bytes, false)
            .map_err(|e| NormalizeError(format!("validate: {e}")))?;
    if !kigi_tools::util::image_validate::image_structurally_complete(&raw_bytes) {
        return Err(NormalizeError(
            "integrity check failed: image bytes are truncated".to_owned(),
        ));
    }
    if orig_w < MIN_VISION_SIDE_PX || orig_h < MIN_VISION_SIDE_PX {
        return Err(NormalizeError(format!(
            "too small ({orig_w}×{orig_h}); images must be at least {MIN_VISION_SIDE_PX}×{MIN_VISION_SIDE_PX} pixels"
        )));
    }
    let pixels = (orig_w as u64) * (orig_h as u64);
    if pixels < MIN_VISION_TOTAL_PX {
        return Err(NormalizeError(format!(
            "too small ({orig_w}×{orig_h} = {pixels} px); images must have at least {MIN_VISION_TOTAL_PX} total pixels"
        )));
    }
    let exceeded_size = original_bytes > MAX_IMAGE_BYTES;
    let exceeded_dimensions = params.exceeds_dimension_caps(orig_w, orig_h);
    if !exceeded_size && !exceeded_dimensions {
        if let Err(e) = kigi_tools::util::image_validate::validate_image_bytes(&raw_bytes) {
            return Err(NormalizeError(format!("integrity check failed: {e}")));
        }
        return Ok(NormalizedEntry::Unchanged {
            bytes: Bytes::from(raw_bytes),
            mime: Cow::Borrowed(orig_mime),
        });
    }
    if pixels > MAX_DECODE_PIXELS {
        return Err(NormalizeError(format!(
            "image {orig_w}x{orig_h} exceeds {MAX_DECODE_PIXELS} px decode limit",
        )));
    }
    let decoded =
        image::load_from_memory(&raw_bytes).map_err(|e| NormalizeError(format!("decode: {e}")))?;
    let (buf, new_w, new_h, mime) = match re_encode_under_limit(&decoded, params) {
        Ok(v) => v,
        Err(e) => {
            tracing::warn!(
                index, bytes = original_bytes, error = % e,
                "image re-encode failed; keeping original attachment"
            );
            return Ok(NormalizedEntry::ReEncodingOversized {
                bytes: Bytes::from(raw_bytes),
                mime: Cow::Borrowed(orig_mime),
            });
        }
    };
    if buf.len() >= original_bytes {
        return Ok(NormalizedEntry::Unchanged {
            bytes: Bytes::from(raw_bytes),
            mime: Cow::Borrowed(orig_mime),
        });
    }
    let compressed_bytes = buf.len();
    Ok(NormalizedEntry::Compressed {
        bytes: Bytes::from(buf),
        mime: Cow::Borrowed(mime),
        info: ImageCompressionInfo {
            index,
            original_bytes,
            compressed_bytes,
            original_width: orig_w,
            original_height: orig_h,
            compressed_width: new_w,
            compressed_height: new_h,
            exceeded_size,
            exceeded_dimensions,
        },
    })
}
fn fail(index: usize, error: String) -> Outcome {
    Outcome::Failed { index, error }
}
fn format_bytes(bytes: usize) -> String {
    if bytes >= 1_000_000 {
        format!("{:.1} MB", bytes as f64 / 1_000_000.0)
    } else {
        format!("{:.0} KB", bytes as f64 / 1_000.0)
    }
}
#[cfg(test)]
mod tests {
    use super::*;
    use image::DynamicImage;
    use image::codecs::jpeg::JpegEncoder;
    fn fresh_cache() -> NormalizeCache {
        let cache = NormalizeCache::with_capacity(64 * 1024 * 1024);
        cache.set_enabled(true);
        cache
    }
    fn make_test_png(width: u32, height: u32) -> Vec<u8> {
        use image::{ImageBuffer, Rgba};
        let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
            ImageBuffer::from_pixel(width, height, Rgba([128, 64, 32, 255]));
        let mut buf = Vec::new();
        img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
            .unwrap();
        buf
    }
    fn make_image_content(width: u32, height: u32) -> ImageContent {
        let png = make_test_png(width, height);
        ImageContent::new(
            base64::engine::general_purpose::STANDARD.encode(&png),
            "image/png",
        )
    }
    fn make_ico_content(width: u32, height: u32) -> ImageContent {
        let png = make_test_png(width, height);
        let buf = kigi_test_utils::image::ico_with_png_frame(&png, width as u8, height as u8);
        ImageContent::new(
            base64::engine::general_purpose::STANDARD.encode(&buf),
            "image/x-icon",
        )
    }
    /// An attached ICO survives as PNG instead of being dropped by the allow-list.
    #[tokio::test]
    async fn ico_attachment_transcoded_to_png() {
        let cache = fresh_cache();
        let content = match normalize_one_in(make_ico_content(16, 16), 1, false, &cache).await {
            Outcome::Unchanged(c) | Outcome::Compressed { content: c, .. } => c,
            other => panic!("expected ICO to survive as PNG, got {other:?}"),
        };
        assert_eq!(content.mime_type, "image/png");
        let decoded = base64::engine::general_purpose::STANDARD
            .decode(&content.data)
            .unwrap();
        assert_eq!(
            image::guess_format(&decoded).unwrap(),
            image::ImageFormat::Png
        );
    }
    fn make_gif_content(width: u32, height: u32) -> ImageContent {
        use image::{ImageBuffer, Rgba};
        let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
            ImageBuffer::from_pixel(width, height, Rgba([1u8, 2, 3, 255]));
        let mut buf = Vec::new();
        img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Gif)
            .unwrap();
        ImageContent::new(
            base64::engine::general_purpose::STANDARD.encode(&buf),
            "image/gif",
        )
    }
    /// GIF must be PNG'd before send — engines do not sample GIF on the wire.
    #[tokio::test]
    async fn gif_attachment_transcoded_to_png() {
        let cache = fresh_cache();
        let content = match normalize_one_in(make_gif_content(32, 24), 1, false, &cache).await {
            Outcome::Unchanged(c) | Outcome::Compressed { content: c, .. } => c,
            other => panic!("expected GIF to survive as PNG, got {other:?}"),
        };
        assert_eq!(content.mime_type, "image/png");
        let decoded = base64::engine::general_purpose::STANDARD
            .decode(&content.data)
            .unwrap();
        assert_eq!(
            image::guess_format(&decoded).unwrap(),
            image::ImageFormat::Png
        );
    }
    #[tokio::test]
    async fn small_image_unchanged() {
        let img = make_image_content(100, 80);
        let original_data = img.data.clone();
        let cache = fresh_cache();
        match normalize_one_in(img, 1, false, &cache).await {
            Outcome::Unchanged(c) => assert_eq!(c.data, original_data),
            other => panic!("expected Unchanged, got {other:?}"),
        }
    }
    #[tokio::test]
    async fn large_dimensions_resized_when_over_side_limit() {
        let img = make_image_content(3000, 2000);
        let cache = fresh_cache();
        match normalize_one_in(img, 1, false, &cache).await {
            Outcome::Compressed { content, info } => {
                assert_eq!(content.mime_type, "image/png");
                assert!(info.compressed_bytes < info.original_bytes);
                assert!(info.compressed_width <= MAX_ENCODE_SIDE_PX);
                assert!(info.compressed_height <= MAX_ENCODE_SIDE_PX);
                let area = u64::from(info.compressed_width) * u64::from(info.compressed_height);
                assert!(area <= MAX_ENCODE_PIXELS, "area {area} over v9 budget");
            }
            other => panic!("expected Compressed, got {other:?}"),
        }
    }
    /// A flat 1700x1700 attachment is under the 2000px side clamp but its
    /// 2.89 Mpx area exceeds the v9 pixel budget, so it must be downscaled —
    /// under a side-only cap this passed through unchanged at full resolution.
    #[tokio::test]
    async fn attachment_over_area_cap_is_downscaled() {
        let img = make_image_content(1700, 1700);
        let cache = fresh_cache();
        match normalize_one_in(img, 1, false, &cache).await {
            Outcome::Compressed { info, .. } => {
                assert!(
                    info.exceeded_dimensions,
                    "1700x1700 = 2.89 Mpx exceeds the {MAX_ENCODE_PIXELS} px area cap"
                );
                assert!(info.compressed_width <= MAX_ENCODE_SIDE_PX);
                assert!(info.compressed_height <= MAX_ENCODE_SIDE_PX);
                let area = u64::from(info.compressed_width) * u64::from(info.compressed_height);
                assert!(area <= MAX_ENCODE_PIXELS, "area {area} over v9 budget");
            }
            other => panic!("expected a 2.89 Mpx image to be downscaled, got {other:?}"),
        }
    }
    /// 3438x1830 flat screenshot: aspect > ~1.67, so the 2000px side clamp
    /// binds before the area cap and the long side lands exactly on 2000.
    #[tokio::test]
    async fn wide_screenshot_clamped_to_side_limit_and_area_budget() {
        let img = make_image_content(3438, 1830);
        let cache = fresh_cache();
        match normalize_one_in(img, 1, false, &cache).await {
            Outcome::Compressed { info, .. } => {
                assert!(info.exceeded_dimensions);
                assert!(!info.exceeded_size, "flat PNG must be small in bytes");
                assert_eq!(
                    info.compressed_width.max(info.compressed_height),
                    MAX_ENCODE_SIDE_PX,
                    "long side must land on the 2000px compat clamp"
                );
                let area = u64::from(info.compressed_width) * u64::from(info.compressed_height);
                assert!(area <= MAX_ENCODE_PIXELS, "area {area} over v9 budget");
            }
            other => panic!("expected Compressed, got {other:?}"),
        }
    }
    /// Near-square 1800x1700 = 3.06 Mpx: sides are within the 2000px clamp,
    /// so only the v9 area cap triggers; the result stays under 2000 per side.
    #[tokio::test]
    async fn near_square_over_area_budget_downscaled_below_side_clamp() {
        let img = make_image_content(1800, 1700);
        let cache = fresh_cache();
        match normalize_one_in(img, 1, false, &cache).await {
            Outcome::Compressed { info, .. } => {
                assert!(info.exceeded_dimensions);
                assert!(info.compressed_width < MAX_ENCODE_SIDE_PX);
                assert!(info.compressed_height < MAX_ENCODE_SIDE_PX);
                let area = u64::from(info.compressed_width) * u64::from(info.compressed_height);
                assert!(area <= MAX_ENCODE_PIXELS, "area {area} over v9 budget");
                let r_in = 1800.0 / 1700.0;
                let r_out = info.compressed_width as f64 / info.compressed_height as f64;
                assert!(
                    (r_in - r_out).abs() < 0.05,
                    "aspect ratio {r_in} -> {r_out}"
                );
            }
            other => panic!("expected Compressed, got {other:?}"),
        }
    }
    /// External-harness pin: the 1024px side-only resize with area cap
    /// disabled, so behavior matches the pre-v9-area-cap path —
    /// a 1300x900 paste still lands on a 1024px long side.
    #[tokio::test]
    async fn strict_path_still_downscales_to_1024_side_only() {
        let img = make_image_content(1300, 900);
        let cache = fresh_cache();
        match normalize_one_in(img, 1, true, &cache).await {
            Outcome::Compressed { info, .. } => {
                assert!(
                    info.exceeded_dimensions,
                    "1300px exceeds the stricter 1024px resize target"
                );
                assert_eq!(
                    info.compressed_width.max(info.compressed_height),
                    STRICT_MAX_ENCODE_SIDE_PX,
                    "external long side must land exactly on 1024"
                );
                assert!(info.compressed_height <= STRICT_MAX_ENCODE_SIDE_PX);
            }
            other => panic!("expected Compressed on the strict path, got {other:?}"),
        }
    }
    /// External-harness pin: within the 1024px side cap nothing triggers — the
    /// attachment passes through untouched.
    #[tokio::test]
    async fn strict_path_passes_through_within_1024_side() {
        let img = make_image_content(1000, 800);
        let original_data = img.data.clone();
        let cache = fresh_cache();
        match normalize_one_in(img, 1, true, &cache).await {
            Outcome::Unchanged(c) => assert_eq!(c.data, original_data),
            other => panic!("expected Unchanged on the strict path, got {other:?}"),
        }
    }
    fn jpeg_larger_than_limit() -> Vec<u8> {
        use image::{ImageBuffer, Rgb};
        let mut side = 2200u32;
        for _ in 0..64 {
            let img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_fn(side, side, |x, y| {
                Rgb([
                    (x.wrapping_mul(17).wrapping_add(y)) as u8,
                    (x.wrapping_mul(31).wrapping_add(y.wrapping_mul(7))) as u8,
                    (x.wrapping_add(y).wrapping_mul(13)) as u8,
                ])
            });
            let mut buf = Vec::new();
            let mut enc = JpegEncoder::new_with_quality(&mut buf, 98);
            enc.encode_image(&DynamicImage::ImageRgb8(img))
                .expect("encode test JPEG");
            if buf.len() > MAX_IMAGE_BYTES {
                return buf;
            }
            side += 250;
        }
        panic!("could not synthesize test JPEG above limit");
    }
    #[tokio::test]
    async fn oversize_bytes_becomes_jpeg_under_limit() {
        let raw = jpeg_larger_than_limit();
        assert!(raw.len() > MAX_IMAGE_BYTES);
        let img = ImageContent::new(
            base64::engine::general_purpose::STANDARD.encode(&raw),
            "image/jpeg",
        );
        let cache = fresh_cache();
        match normalize_one_in(img, 1, false, &cache).await {
            Outcome::Compressed { content, info } => {
                assert_eq!(content.mime_type, "image/jpeg");
                assert!(info.compressed_bytes <= MAX_IMAGE_BYTES);
                assert!(info.compressed_bytes < info.original_bytes);
                assert!(info.compressed_width <= MAX_ENCODE_SIDE_PX);
                assert!(info.compressed_height <= MAX_ENCODE_SIDE_PX);
                let area = u64::from(info.compressed_width) * u64::from(info.compressed_height);
                assert!(area <= MAX_ENCODE_PIXELS, "area {area} over v9 budget");
            }
            other => panic!("expected Compressed, got {other:?}"),
        }
    }
    #[tokio::test]
    async fn bad_base64_fails() {
        let img = ImageContent::new(String::from("!!!"), "image/png");
        let cache = fresh_cache();
        match normalize_one_in(img, 1, false, &cache).await {
            Outcome::Failed { index, error } => {
                assert_eq!(index, 1);
                assert!(
                    error.contains("base64"),
                    "expected base64 mention in error, got: {error}"
                );
            }
            other => panic!("expected Failed, got {other:?}"),
        }
    }
    #[tokio::test]
    async fn oversized_invalid_bytes_fail_decode() {
        let raw = vec![0u8; MAX_IMAGE_BYTES + 1];
        let img = ImageContent::new(
            base64::engine::general_purpose::STANDARD.encode(&raw),
            "image/png",
        );
        let cache = fresh_cache();
        match normalize_one_in(img, 1, false, &cache).await {
            Outcome::Failed { index, error } => {
                assert_eq!(index, 1);
                assert!(
                    error.contains("validate") || error.contains("decode"),
                    "expected validate/decode mention, got: {error}"
                );
            }
            other => panic!("expected Failed, got {other:?}"),
        }
    }
    #[tokio::test]
    async fn preserves_index() {
        let raw = jpeg_larger_than_limit();
        let img = ImageContent::new(
            base64::engine::general_purpose::STANDARD.encode(&raw),
            "image/jpeg",
        );
        let cache = fresh_cache();
        match normalize_one_in(img, 7, false, &cache).await {
            Outcome::Compressed { info, .. } => assert_eq!(info.index, 7),
            other => panic!("expected Compressed, got {other:?}"),
        }
    }
    #[tokio::test]
    async fn normalize_images_filters_bad_and_keeps_good() {
        let good = make_image_content(100, 100);
        let bad = ImageContent::new(String::from("!!!"), "image/png");
        let cache = fresh_cache();
        let result = normalize_images_in(vec![good, bad], false, &cache).await;
        assert_eq!(result.images.len(), 1);
        assert!(result.compressed.is_empty());
        assert!(result.re_encode_fallbacks.is_empty());
        assert_eq!(result.dropped.len(), 1, "bad image must surface as dropped");
        assert!(
            result.dropped[0].contains("Image 2"),
            "drop note must name the per-call index, got: {}",
            result.dropped[0]
        );
    }
    /// Wide raster so `resize(max_side, max_side)` must not equal a square output.
    fn wide_jpeg_above_limit() -> (Vec<u8>, u32, u32) {
        use image::{ImageBuffer, Rgb};
        let h = 500u32;
        let mut w = 3600u32;
        for _ in 0..48 {
            let img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_fn(w, h, |x, y| {
                Rgb([
                    (x.wrapping_mul(11).wrapping_add(y)) as u8,
                    (x.wrapping_mul(19)) as u8,
                    (y.wrapping_mul(7)) as u8,
                ])
            });
            let mut buf = Vec::new();
            let mut enc = JpegEncoder::new_with_quality(&mut buf, 98);
            enc.encode_image(&DynamicImage::ImageRgb8(img))
                .expect("encode wide test JPEG");
            if buf.len() > MAX_IMAGE_BYTES {
                return (buf, w, h);
            }
            w += 200;
        }
        panic!("could not synthesize wide JPEG above limit");
    }
    #[tokio::test]
    async fn oversize_wide_image_keeps_aspect_ratio() {
        let (raw, ow, oh) = wide_jpeg_above_limit();
        assert!(raw.len() > MAX_IMAGE_BYTES);
        let img = ImageContent::new(
            base64::engine::general_purpose::STANDARD.encode(&raw),
            "image/jpeg",
        );
        let cache = fresh_cache();
        match normalize_one_in(img, 1, false, &cache).await {
            Outcome::Compressed { info, .. } => {
                let r_in = ow as f64 / oh as f64;
                let r_out = info.compressed_width as f64 / info.compressed_height as f64;
                assert!(
                    (r_in - r_out).abs() < 0.2,
                    "aspect ratio {r_in} -> {r_out} ({}x{} -> {}x{})",
                    ow,
                    oh,
                    info.compressed_width,
                    info.compressed_height
                );
                assert_ne!(info.compressed_width, info.compressed_height);
                assert!(info.compressed_width <= MAX_ENCODE_SIDE_PX);
                assert!(info.compressed_height <= MAX_ENCODE_SIDE_PX);
                let area = u64::from(info.compressed_width) * u64::from(info.compressed_height);
                assert!(area <= MAX_ENCODE_PIXELS, "area {area} over v9 budget");
            }
            other => panic!("expected Compressed, got {other:?}"),
        }
    }
    #[test]
    fn compression_notice_contains_dimensions() {
        let info = ImageCompressionInfo {
            index: 1,
            original_bytes: 8_200_000,
            compressed_bytes: 1_400_000,
            original_width: 5000,
            original_height: 3000,
            compressed_width: 2000,
            compressed_height: 1200,
            exceeded_size: true,
            exceeded_dimensions: true,
        };
        let notice = render_compression_notice(std::slice::from_ref(&info), false);
        assert!(notice.contains("<system-reminder>"));
        assert!(notice.contains("</system-reminder>"));
        assert!(!notice.contains("<system_reminder>"));
        assert!(notice.contains("<image_compression_notice>"));
        assert!(notice.contains("was over the size and resolution limits"));
        assert!(notice.contains("5000x3000"));
        assert!(notice.contains("2000x1200"));
        assert!(notice.contains("8.2 MB"));
        assert!(notice.contains("1.4 MB"));
        let size_only = ImageCompressionInfo {
            exceeded_size: true,
            exceeded_dimensions: false,
            ..info
        };
        assert!(
            render_compression_notice(&[size_only], false)
                .contains("was over the 1.5 MB attachment limit")
        );
        let dims_only = ImageCompressionInfo {
            exceeded_size: false,
            exceeded_dimensions: true,
            ..info
        };
        assert!(
            render_compression_notice(&[dims_only], false)
                .contains("was over the max input resolution")
        );
    }
    #[test]
    fn re_encode_fallback_notice_picks_tag_per_harness() {
        let notes = vec!["Image 1 could not be re-encoded under the cap.".to_string()];
        let kigi = render_re_encode_fallback_notice(&notes, false);
        assert!(kigi.contains("<system-reminder>"));
        assert!(kigi.contains("</system-reminder>"));
        assert!(!kigi.contains("<system_reminder>"));
        assert!(kigi.contains("<image_re_encode_fallback>"));
    }
    #[test]
    fn display_format() {
        let info_both = ImageCompressionInfo {
            index: 2,
            original_bytes: 5_000_000,
            compressed_bytes: 800_000,
            original_width: 4000,
            original_height: 3000,
            compressed_width: 2000,
            compressed_height: 1500,
            exceeded_size: true,
            exceeded_dimensions: true,
        };
        assert_eq!(
            info_both.display(),
            "Downscaled Image 2: 5.0 MB (4000x3000) \u{2192} 800 KB (2000x1500)"
        );
        let info_size = ImageCompressionInfo {
            exceeded_size: true,
            exceeded_dimensions: false,
            ..info_both
        };
        assert_eq!(info_size.display(), info_both.display());
        let info_size_same_dims = ImageCompressionInfo {
            compressed_width: 4000,
            compressed_height: 3000,
            ..info_size
        };
        assert_eq!(
            info_size_same_dims.display(),
            "Compressed Image 2: 5.0 MB (4000x3000) \u{2192} 800 KB (4000x3000)"
        );
    }
    #[test]
    fn format_bytes_ranges() {
        assert_eq!(format_bytes(8_200_000), "8.2 MB");
        assert_eq!(format_bytes(1_000_000), "1.0 MB");
        assert_eq!(format_bytes(450_000), "450 KB");
        assert_eq!(format_bytes(1_000), "1 KB");
    }
    /// CRC-corrupt PNG must be rejected by the Unchanged integrity check.
    #[tokio::test]
    async fn crc_corrupt_png_fails_integrity_check() {
        let mut bytes = make_test_png(64, 64);
        let tag = b"IDAT";
        let pos = bytes
            .windows(4)
            .position(|w| w == tag)
            .expect("IDAT chunk present");
        bytes[pos + 12] ^= 0xFF;
        let img = ImageContent::new(
            base64::engine::general_purpose::STANDARD.encode(&bytes),
            "image/png",
        );
        let cache = fresh_cache();
        match normalize_one_in(img, 3, false, &cache).await {
            Outcome::Failed { index, error } => {
                assert_eq!(index, 3);
                assert!(
                    error.contains("integrity check failed"),
                    "expected integrity-check error, got: {error}"
                );
            }
            other => panic!("expected Failed, got {other:?}"),
        }
    }
    /// Truncated PNG is rejected by the Unchanged integrity check.
    #[tokio::test]
    async fn truncated_png_fails_integrity_check_in_normalize_one() {
        let mut bytes = make_test_png(64, 64);
        bytes.truncate(bytes.len() / 2);
        let img = ImageContent::new(
            base64::engine::general_purpose::STANDARD.encode(&bytes),
            "image/png",
        );
        let cache = fresh_cache();
        match normalize_one_in(img, 11, false, &cache).await {
            Outcome::Failed { index, error } => {
                assert_eq!(index, 11);
                assert!(
                    error.contains("integrity check failed"),
                    "expected integrity-check error, got: {error}"
                );
            }
            other => panic!("expected Failed, got {other:?}"),
        }
    }
    /// Truncated JPEG is rejected somewhere in the normalize pipeline
    /// (dimension-probe or integrity-check, depending on the codec).
    #[tokio::test]
    async fn corrupt_jpeg_rejected_by_normalize_one() {
        use image::{ImageBuffer, Rgb};
        let img_buf: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_fn(64, 64, |x, y| {
            Rgb([
                (x.wrapping_mul(13) ^ y) as u8,
                (x.wrapping_mul(7).wrapping_add(y * 3)) as u8,
                (x.wrapping_add(y).wrapping_mul(11)) as u8,
            ])
        });
        let mut bytes = Vec::new();
        JpegEncoder::new_with_quality(&mut bytes, 80)
            .encode_image(&DynamicImage::ImageRgb8(img_buf))
            .unwrap();
        let sos_pos = bytes
            .windows(2)
            .position(|w| w == [0xFF, 0xDA])
            .expect("SOS marker present");
        bytes.truncate(sos_pos + 12);
        let img = ImageContent::new(
            base64::engine::general_purpose::STANDARD.encode(&bytes),
            "image/jpeg",
        );
        let cache = fresh_cache();
        match normalize_one_in(img, 12, false, &cache).await {
            Outcome::Failed { index, error } => {
                assert_eq!(index, 12);
                assert!(
                    error.contains("validate")
                        || error.contains("decode")
                        || error.contains("truncated"),
                    "expected validate/decode/truncated error, got: {error}"
                );
            }
            other => panic!("expected Failed, got {other:?}"),
        }
    }
    /// Entropy-cut JPEG (a data URI sliced mid-payload by tool output):
    /// zune-jpeg decodes it leniently, so only the structural walk rejects it.
    #[tokio::test]
    async fn truncated_jpeg_under_size_limits_is_dropped() {
        use image::codecs::jpeg::JpegEncoder;
        use image::{ImageBuffer, Rgb};
        let img_buf: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_fn(400, 300, |x, y| {
            Rgb([(x ^ y) as u8, (x * 7) as u8, (y * 5) as u8])
        });
        let mut bytes = Vec::new();
        JpegEncoder::new_with_quality(&mut bytes, 85)
            .encode_image(&DynamicImage::ImageRgb8(img_buf))
            .unwrap();
        bytes.truncate(bytes.len() / 2);
        assert!(
            image::load_from_memory(&bytes).is_ok(),
            "precondition: the lenient decoder accepts the truncated JPEG"
        );
        let img = ImageContent::new(
            base64::engine::general_purpose::STANDARD.encode(&bytes),
            "image/jpeg",
        );
        let cache = fresh_cache();
        match normalize_one_in(img, 4, false, &cache).await {
            Outcome::Failed { index, error } => {
                assert_eq!(index, 4);
                assert!(
                    error.contains("truncated"),
                    "expected truncated error, got: {error}"
                );
            }
            other => panic!("expected Failed, got {other:?}"),
        }
    }
    /// A 16×16 icon clears the 8px-side floor but violates the API's
    /// 512-total-pixel floor.
    #[tokio::test]
    async fn below_total_pixel_floor_is_dropped() {
        let img = make_image_content(16, 16);
        let cache = fresh_cache();
        match normalize_one_in(img, 1, false, &cache).await {
            Outcome::Failed { error, .. } => {
                assert!(
                    error.contains("total pixels"),
                    "expected total-pixel floor error, got: {error}"
                );
            }
            other => panic!("expected Failed, got {other:?}"),
        }
        let ok = make_image_content(32, 16);
        match normalize_one_in(ok, 1, false, &fresh_cache()).await {
            Outcome::Unchanged(_) => {}
            other => panic!("expected Unchanged at exactly 512 px, got {other:?}"),
        }
    }
    #[test]
    fn persisted_image_reject_reason_verdicts() {
        let reason = persisted_image_reject_reason;
        assert_eq!(reason(&make_test_png(32, 32)), None);
        let mut jpeg = {
            use image::codecs::jpeg::JpegEncoder;
            use image::{ImageBuffer, Rgb};
            let img: ImageBuffer<Rgb<u8>, Vec<u8>> =
                ImageBuffer::from_fn(64, 64, |x, y| Rgb([(x ^ y) as u8, x as u8, y as u8]));
            let mut buf = Vec::new();
            JpegEncoder::new_with_quality(&mut buf, 85)
                .encode_image(&image::DynamicImage::ImageRgb8(img))
                .unwrap();
            buf
        };
        assert_eq!(reason(&jpeg), None);
        jpeg.truncate(jpeg.len() / 2);
        assert!(
            reason(&jpeg).is_some_and(|r| r.contains("structurally incomplete")),
            "truncated JPEG must be rejected"
        );
        assert!(reason(&make_test_png(16, 16)).is_some_and(|r| r.contains("dimension floor")),);
        let mut gif = Vec::new();
        image::DynamicImage::ImageRgba8(image::ImageBuffer::from_pixel(
            64,
            64,
            image::Rgba([1u8, 2, 3, 255]),
        ))
        .write_to(&mut std::io::Cursor::new(&mut gif), image::ImageFormat::Gif)
        .unwrap();
        assert!(reason(&gif).is_some_and(|r| r.contains("format")));
        let ico = kigi_test_utils::image::ico_with_png_frame(&make_test_png(16, 16), 16, 16);
        assert_eq!(reason(&ico), None);
        let mut cut_ico = ico.clone();
        cut_ico.truncate(cut_ico.len() - 8);
        assert!(
            reason(&cut_ico).is_some_and(|r| r.contains("Ico")),
            "truncated ICO must be rejected"
        );
        let mut garbage_ico = vec![0x00, 0x00, 0x01, 0x00];
        garbage_ico.extend_from_slice(&[0xAB; 64]);
        assert!(reason(&garbage_ico).is_some_and(|r| r.contains("Ico")));
        assert!(reason(b"not an image").is_some());
    }
    /// Regression: a camera-class photo above the old 16 Mpx decode cap
    /// (production shape: a 5184×3888 ≈ 20 Mpx attachment refused with
    /// "exceeds … px decode limit") must normalize via downscale, not be
    /// rejected — the API accepts up to ~178.9 Mpx.
    #[tokio::test]
    async fn camera_sized_photo_is_compressed_not_rejected() {
        use image::codecs::jpeg::JpegEncoder;
        use image::{DynamicImage, ImageBuffer, Rgb};
        let img: ImageBuffer<Rgb<u8>, Vec<u8>> =
            ImageBuffer::from_pixel(5184, 3888, Rgb([90, 120, 150]));
        let mut jpeg = Vec::new();
        JpegEncoder::new_with_quality(&mut jpeg, 85)
            .encode_image(&DynamicImage::ImageRgb8(img))
            .unwrap();
        let img = ImageContent::new(
            base64::engine::general_purpose::STANDARD.encode(&jpeg),
            "image/jpeg",
        );
        match normalize_one_in(img, 0, false, &fresh_cache()).await {
            Outcome::Compressed { content, .. } => {
                assert!(!content.data.is_empty());
            }
            other => panic!("expected Compressed, got {other:?}"),
        }
    }
    /// Above the API ceiling the decode is still refused (the API would
    /// 400 it regardless). SOF dims are patched — a real fixture that
    /// large is infeasible to encode.
    #[tokio::test]
    async fn above_api_ceiling_is_rejected_by_normalize() {
        use image::codecs::jpeg::JpegEncoder;
        use image::{DynamicImage, ImageBuffer, Rgb};
        let img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(64, 64, Rgb([1, 2, 3]));
        let mut jpeg = Vec::new();
        JpegEncoder::new_with_quality(&mut jpeg, 85)
            .encode_image(&DynamicImage::ImageRgb8(img))
            .unwrap();
        let sof = jpeg
            .windows(2)
            .position(|w| w == [0xFF, 0xC0])
            .expect("baseline SOF0 present");
        jpeg[sof + 5..sof + 9].copy_from_slice(&[0x40, 0x00, 0x40, 0x00]);
        let img = ImageContent::new(
            base64::engine::general_purpose::STANDARD.encode(&jpeg),
            "image/jpeg",
        );
        match normalize_one_in(img, 0, false, &fresh_cache()).await {
            Outcome::Failed { error, .. } => {
                assert!(error.contains("decode limit"), "got: {error}");
            }
            other => panic!("expected Failed, got {other:?}"),
        }
    }
    /// The API also 400s images whose header dims exceed its
    /// `MAX_IMAGE_PIXELS` ceiling; a kept one would brick the session the
    /// same way as a below-floor image. (SOF dims are patched because
    /// encoding a real >178 Mpx fixture is infeasible.)
    #[test]
    fn persisted_image_reject_reason_pixel_ceiling() {
        use image::codecs::jpeg::JpegEncoder;
        use image::{DynamicImage, ImageBuffer, Rgb};
        let img: ImageBuffer<Rgb<u8>, Vec<u8>> =
            ImageBuffer::from_fn(64, 64, |x, y| Rgb([(x ^ y) as u8, x as u8, y as u8]));
        let mut jpeg = Vec::new();
        JpegEncoder::new_with_quality(&mut jpeg, 85)
            .encode_image(&DynamicImage::ImageRgb8(img))
            .unwrap();
        let sof = jpeg
            .windows(2)
            .position(|w| w == [0xFF, 0xC0])
            .expect("baseline SOF0 present");
        jpeg[sof + 5..sof + 9].copy_from_slice(&[0x40, 0x00, 0x40, 0x00]);
        assert!(
            persisted_image_reject_reason(&jpeg).is_some_and(|r| r.contains("above pixel ceiling")),
        );
    }
    /// The `read_file` inline-attach gate (the below-floor icon enforcement
    /// point): floors enforced, and unvalidatable payloads fail closed.
    #[test]
    fn inline_attach_verdict_gates_floors_and_fails_closed() {
        use base64::Engine as _;
        let b64 = |bytes: &[u8]| base64::engine::general_purpose::STANDARD.encode(bytes);
        assert_eq!(
            inline_attach_verdict(&b64(&make_test_png(16, 16))),
            InlineAttachVerdict::TooSmall
        );
        assert_eq!(
            inline_attach_verdict(&b64(&make_test_png(32, 16))),
            InlineAttachVerdict::Attach
        );
        assert_eq!(
            inline_attach_verdict(&b64(&make_test_png(64, 64))),
            InlineAttachVerdict::Attach
        );
        assert_eq!(
            inline_attach_verdict(&b64(&make_test_png(4, 200))),
            InlineAttachVerdict::TooSmall
        );
        assert_eq!(
            inline_attach_verdict("!!!not base64!!!"),
            InlineAttachVerdict::Unreadable
        );
        assert_eq!(
            inline_attach_verdict(&b64(b"not an image")),
            InlineAttachVerdict::Unreadable
        );
    }
    /// Oversized truncated JPEG must be dropped too — not silently healed
    /// into a mostly-grey re-encode.
    #[tokio::test]
    async fn truncated_oversized_jpeg_is_dropped() {
        let mut raw = jpeg_larger_than_limit();
        raw.truncate(raw.len() / 2);
        let img = ImageContent::new(
            base64::engine::general_purpose::STANDARD.encode(&raw),
            "image/jpeg",
        );
        let cache = fresh_cache();
        match normalize_one_in(img, 2, false, &cache).await {
            Outcome::Failed { error, .. } => {
                assert!(
                    error.contains("truncated"),
                    "expected truncated error, got: {error}"
                );
            }
            other => panic!("expected Failed, got {other:?}"),
        }
    }
    #[tokio::test]
    async fn small_well_formed_png_unchanged_after_integrity_check() {
        let img = make_image_content(50, 40);
        let original_data = img.data.clone();
        let cache = fresh_cache();
        match normalize_one_in(img, 1, false, &cache).await {
            Outcome::Unchanged(c) => assert_eq!(c.data, original_data),
            other => panic!("expected Unchanged, got {other:?}"),
        }
    }
    /// Dropped notes propagate to the top-level `NormalizeResult`.
    #[tokio::test]
    async fn normalize_images_collects_dropped_notes() {
        let mut bytes = make_test_png(32, 32);
        let tag = b"IDAT";
        let pos = bytes.windows(4).position(|w| w == tag).unwrap();
        bytes[pos + 12] ^= 0xFF;
        let bad = ImageContent::new(
            base64::engine::general_purpose::STANDARD.encode(&bytes),
            "image/png",
        );
        let good = make_image_content(60, 40);
        let cache = fresh_cache();
        let result = normalize_images_in(vec![good, bad], false, &cache).await;
        assert_eq!(result.images.len(), 1, "good image preserved");
        assert_eq!(result.dropped.len(), 1, "one drop note");
        assert!(result.dropped[0].contains("Image 2"), "drop names index");
    }
    #[test]
    fn dropped_to_envelope_returns_none_for_empty() {
        assert!(dropped_to_envelope(Vec::new(), false).is_none());
        assert!(dropped_to_envelope(Vec::new(), true).is_none());
    }
    #[test]
    fn dropped_to_envelope_emits_notice_and_notes() {
        let notes = vec!["Image 1 was dropped before send: corrupt.".to_string()];
        let (notice, returned) = dropped_to_envelope(notes.clone(), false).unwrap();
        assert!(notice.contains("<system-reminder>"));
        assert!(notice.contains("<image_dropped_notice>"));
        assert!(notice.contains(&notes[0]));
        assert_eq!(returned, notes);
    }
    #[test]
    fn image_dropped_notice_picks_tag_per_harness() {
        let notes = vec!["Image 5 was dropped before send: corrupt".to_string()];
        let kigi = render_image_dropped_notice(&notes, false);
        assert!(kigi.contains("<system-reminder>"));
        assert!(kigi.contains("</system-reminder>"));
        assert!(!kigi.contains("<system_reminder>"));
        assert!(kigi.contains("<image_dropped_notice>"));
        assert!(kigi.contains("Image 5"));
        assert_eq!(render_image_dropped_notice(&[], false), "");
    }
    /// Large flat-color images compress better as PNG than JPEG; the
    /// normalizer must pick PNG when it wins.
    #[tokio::test]
    async fn flat_color_oversized_picks_png() {
        use image::{ImageBuffer, Rgb};
        let side = 4000u32;
        let img: ImageBuffer<Rgb<u8>, Vec<u8>> =
            ImageBuffer::from_pixel(side, side, Rgb([40, 80, 120]));
        let mut png_buf = Vec::new();
        img.write_to(
            &mut std::io::Cursor::new(&mut png_buf),
            image::ImageFormat::Png,
        )
        .unwrap();
        let padding = MAX_IMAGE_BYTES + 1 - png_buf.len();
        png_buf.resize(png_buf.len() + padding, 0xAA);
        assert!(png_buf.len() > MAX_IMAGE_BYTES);
        let content = ImageContent::new(
            base64::engine::general_purpose::STANDARD.encode(&png_buf),
            "image/png",
        );
        let cache = fresh_cache();
        match normalize_one_in(content, 1, false, &cache).await {
            Outcome::Compressed { content, info } => {
                assert_eq!(
                    content.mime_type, "image/png",
                    "flat-color image should pick PNG over JPEG"
                );
                assert!(info.compressed_bytes <= MAX_IMAGE_BYTES);
                assert!(info.compressed_bytes < info.original_bytes);
            }
            other => panic!("expected Compressed, got {other:?}"),
        }
    }
    /// `Bytes::as_ptr` identity + re-stamped per-call `index` together
    /// prove the second call is a cache hit through `entry_to_outcome`.
    #[tokio::test]
    async fn normalize_one_in_uses_cache_for_repeat_input() {
        let cache = fresh_cache();
        let img = make_image_content(48, 48);
        let raw_decoded = base64::engine::general_purpose::STANDARD
            .decode(&img.data)
            .expect("test base64 decode");
        let dup = img.clone();
        let first_data = match normalize_one_in(img, 1, false, &cache).await {
            Outcome::Unchanged(c) => c.data,
            other => panic!("expected Unchanged on first call, got {other:?}"),
        };
        let cached_first = cache
            .get_for_tests(&raw_decoded, HarnessVariant::Default)
            .await
            .expect("first call must populate the cache");
        let p1 = match &cached_first {
            NormalizedEntry::Unchanged { bytes, .. } => bytes.as_ptr(),
            other => panic!("expected Unchanged in cache, got {other:?}"),
        };
        let second_data = match normalize_one_in(dup, 9, false, &cache).await {
            Outcome::Unchanged(c) => c.data,
            other => panic!("expected Unchanged on second call, got {other:?}"),
        };
        assert_eq!(
            first_data, second_data,
            "cache-served output must match the first compute"
        );
        let cached_second = cache
            .get_for_tests(&raw_decoded, HarnessVariant::Default)
            .await
            .expect("cache entry survived");
        let p2 = match &cached_second {
            NormalizedEntry::Unchanged { bytes, .. } => bytes.as_ptr(),
            _ => unreachable!("invariant: variant pinned above"),
        };
        assert_eq!(p1, p2, "`Bytes::as_ptr` identity proves cache hit");
    }
    /// Forces `re_encode_under_limit` to exhaust every step (drives
    /// the `ReEncodingOversized` path).
    static UNSATISFIABLE_PARAMS: ReEncodeParams = ReEncodeParams {
        max_bytes: 0,
        max_side_px: MAX_ENCODE_SIDE_PX,
        max_pixels: MAX_ENCODE_PIXELS,
        min_side_px: MIN_ENCODE_SIDE_PX,
        quality_steps: JPEG_QUALITY_STEPS,
        filter: DOWNSCALE_FILTER,
    };
    fn unsatisfiable_params() -> &'static ReEncodeParams {
        &UNSATISFIABLE_PARAMS
    }
    #[tokio::test]
    async fn re_encoding_oversized_routing() {
        let raw = jpeg_larger_than_limit();
        let entry = compute_normalized_blocking(raw.clone(), unsatisfiable_params(), 5)
            .expect("blocking compute ok");
        match &entry {
            NormalizedEntry::ReEncodingOversized { bytes, mime } => {
                assert_eq!(bytes.as_ref(), raw.as_slice(), "original bytes preserved");
                assert_eq!(mime.as_ref(), "image/jpeg");
            }
            other => panic!("expected ReEncodingOversized, got {other:?}"),
        }
        let orig = ImageContent::new(
            base64::engine::general_purpose::STANDARD.encode(&raw),
            "image/jpeg",
        );
        match entry_to_outcome(orig.clone(), entry, 5) {
            Outcome::ReEncodingOversized(c) => {
                assert_eq!(c.data, orig.data, "original ImageContent passed through");
                assert_eq!(c.mime_type, "image/jpeg");
            }
            other => panic!("expected ReEncodingOversized, got {other:?}"),
        }
    }
    #[tokio::test]
    async fn re_encoding_oversized_round_trips_through_cache() {
        let raw = jpeg_larger_than_limit();
        let cache = fresh_cache();
        let params = unsatisfiable_params();
        cache
            .get_or_try_insert_with(
                raw.clone(),
                HarnessVariant::Default,
                move |bytes| async move { compute_normalized_blocking(bytes, params, 1) },
            )
            .await
            .expect("seed cache");
        let cached = cache
            .get_for_tests(&raw, HarnessVariant::Default)
            .await
            .expect("seed succeeded");
        let p1 = match &cached {
            NormalizedEntry::ReEncodingOversized { bytes, .. } => bytes.as_ptr(),
            other => panic!("expected ReEncodingOversized in cache, got {other:?}"),
        };
        let input = ImageContent::new(
            base64::engine::general_purpose::STANDARD.encode(&raw),
            "image/jpeg",
        );
        match normalize_one_in(input, 3, false, &cache).await {
            Outcome::ReEncodingOversized(c) => assert_eq!(c.mime_type, "image/jpeg"),
            other => panic!("expected ReEncodingOversized from cache hit, got {other:?}"),
        }
        let cached_after = cache
            .get_for_tests(&raw, HarnessVariant::Default)
            .await
            .expect("entry survived");
        let p2 = match &cached_after {
            NormalizedEntry::ReEncodingOversized { bytes, .. } => bytes.as_ptr(),
            _ => unreachable!(),
        };
        assert_eq!(
            p1, p2,
            "ReEncodingOversized cache hit must share backing buffer"
        );
    }
    #[tokio::test]
    async fn normalize_images_in_collects_re_encode_fallback_note() {
        let raw = jpeg_larger_than_limit();
        let cache = fresh_cache();
        let params = unsatisfiable_params();
        cache
            .get_or_try_insert_with(
                raw.clone(),
                HarnessVariant::Default,
                move |bytes| async move { compute_normalized_blocking(bytes, params, 1) },
            )
            .await
            .expect("seed cache");
        let img = ImageContent::new(
            base64::engine::general_purpose::STANDARD.encode(&raw),
            "image/jpeg",
        );
        let result = normalize_images_in(vec![img], false, &cache).await;
        assert_eq!(result.images.len(), 1, "image preserved despite oversize");
        assert_eq!(
            result.re_encode_fallbacks.len(),
            1,
            "one fallback note per oversized image"
        );
        assert!(
            result.re_encode_fallbacks[0].contains("Image 1"),
            "fallback note must name the per-call index, got: {}",
            result.re_encode_fallbacks[0],
        );
    }
    #[tokio::test]
    async fn sub_8x8_image_is_rejected() {
        let tiny = make_image_content(4, 3);
        let ok = make_image_content(30, 30);
        let cache = fresh_cache();
        let result = normalize_images_in(vec![tiny, ok], false, &cache).await;
        assert_eq!(result.images.len(), 1, "only the >=8x8 image proceeds");
        assert_eq!(result.dropped.len(), 1);
        assert!(
            result.dropped[0].contains("4×3") && result.dropped[0].contains("8×8"),
            "dropped note must mention the offending dims and the min: {}",
            result.dropped[0]
        );
        assert!(
            result.dropped[0].contains("too small"),
            "dropped: {}",
            result.dropped[0]
        );
    }
    /// Boundary: 8×8 clears the per-side floor but not the API's
    /// 512-total-pixel floor (64 px would 400 server-side).
    #[tokio::test]
    async fn exactly_8x8_is_rejected_by_total_pixel_floor() {
        let img = make_image_content(8, 8);
        let cache = fresh_cache();
        let result = normalize_images_in(vec![img], false, &cache).await;
        assert!(result.images.is_empty());
        assert_eq!(result.dropped.len(), 1);
        assert!(
            result.dropped[0].contains("total pixels"),
            "dropped: {}",
            result.dropped[0]
        );
    }
    /// One dimension below threshold.
    #[tokio::test]
    async fn seven_by_eight_is_rejected() {
        let img = make_image_content(7, 8);
        let cache = fresh_cache();
        let result = normalize_images_in(vec![img], false, &cache).await;
        assert!(result.images.is_empty());
        assert_eq!(result.dropped.len(), 1);
        assert!(result.dropped[0].contains("7×8"));
    }
}
