//! Shared helpers for multipart upload handlers.
//!
//! `parse_image` enforces the per-file size cap, sniffs the MIME by
//! magic bytes (not by the client-supplied Content-Type or filename),
//! and rejects anything outside the jpeg / png / webp / gif / avif
//! whitelist with 415. Filename and extension never reach the storage
//! key — we derive both from the sniffed type.

use axum::extract::multipart::Field;
use uuid::Uuid;

use crate::api::mangas::map_multipart_error;
use crate::error::{AppError, AppResult};
use crate::storage::Storage;

#[derive(Debug, Clone)]
pub struct UploadedImage {
    pub bytes: Vec<u8>,
    pub mime: &'static str,
    pub ext: &'static str,
}

/// A page image written to a temporary staging key during a chapter
/// upload, awaiting promotion to its final chapter-scoped key once the
/// chapter row (and thus its id) exists. Carries only the small metadata
/// the caller needs — never the image bytes.
#[derive(Debug, Clone)]
pub struct StagedImage {
    pub staging_key: String,
    pub mime: &'static str,
    pub ext: &'static str,
    pub size_bytes: i64,
}

/// The staging-key prefix. Blobs left here by a failed or abandoned upload
/// are orphans a future reaper can sweep; a successful upload renames every
/// staged page out of this prefix.
pub const STAGING_PREFIX: &str = "staging";

/// Read one multipart image part and write it straight to a staging key,
/// returning only its metadata. The per-file byte cap is enforced as bytes
/// arrive, so an oversized part is rejected (413) without being fully
/// buffered, and at most one page's bytes are held in memory at a time —
/// the whole chapter is never buffered, unlike the previous
/// read-all-then-persist path.
pub async fn stage_image_part(
    storage: &dyn Storage,
    mut field: Field<'_>,
    upload_id: Uuid,
    seq: usize,
    max_size: usize,
    field_name: &str,
) -> AppResult<StagedImage> {
    let mut bytes: Vec<u8> = Vec::new();
    while let Some(chunk) = field.chunk().await.map_err(map_multipart_error)? {
        if bytes.len().saturating_add(chunk.len()) > max_size {
            return Err(AppError::PayloadTooLarge(format!(
                "{field_name} exceeds {max_size}-byte cap"
            )));
        }
        bytes.extend_from_slice(&chunk);
    }
    // Reuse the shared sniff + whitelist check; it re-verifies the size cap
    // (already enforced above) and derives mime/ext from magic bytes.
    let img = parse_image(bytes, max_size, field_name)?;
    let staging_key = format!("{STAGING_PREFIX}/{}/{:04}.{}", upload_id.simple(), seq, img.ext);
    storage.put(&staging_key, &img.bytes).await?;
    Ok(StagedImage {
        staging_key,
        mime: img.mime,
        ext: img.ext,
        size_bytes: img.bytes.len() as i64,
    })
}

pub fn parse_image(bytes: Vec<u8>, max_size: usize, field_name: &str) -> AppResult<UploadedImage> {
    if bytes.len() > max_size {
        return Err(AppError::PayloadTooLarge(format!(
            "{field_name} exceeds {max_size}-byte cap"
        )));
    }
    // `infer` only looks at magic bytes in the first dozen-or-so bytes;
    // hand it a small head slice rather than walking the whole 20-MiB
    // image buffer when sniffing.
    let head = &bytes[..bytes.len().min(64)];
    let kind = infer::get(head).ok_or_else(|| {
        AppError::UnsupportedMediaType(format!("{field_name}: unrecognised image format"))
    })?;
    let (mime, ext) = match kind.mime_type() {
        "image/jpeg" => ("image/jpeg", "jpg"),
        "image/png" => ("image/png", "png"),
        "image/webp" => ("image/webp", "webp"),
        "image/gif" => ("image/gif", "gif"),
        "image/avif" => ("image/avif", "avif"),
        other => {
            return Err(AppError::UnsupportedMediaType(format!(
                "{field_name}: unsupported image type {other}"
            )));
        }
    };
    Ok(UploadedImage { bytes, mime, ext })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn png_bytes() -> Vec<u8> {
        // PNG magic + minimum padding so infer can identify it.
        vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]
    }

    fn jpeg_bytes() -> Vec<u8> {
        vec![0xff, 0xd8, 0xff, 0xe0, 0, 0x10, b'J', b'F', b'I', b'F', 0, 0]
    }

    fn pdf_bytes() -> Vec<u8> {
        b"%PDF-1.4\n%\xc4\xe5".to_vec()
    }

    fn avif_bytes() -> Vec<u8> {
        // Minimal `ftyp avif` header that `infer` recognises. A real
        // AVIF would continue with `mdat`/`meta` boxes; the magic bytes
        // alone are enough for sniffing.
        vec![
            0x00, 0x00, 0x00, 0x18, // box size = 24
            b'f', b't', b'y', b'p', // box type
            b'a', b'v', b'i', b'f', // major brand
            0x00, 0x00, 0x00, 0x00, // minor version
            b'm', b'i', b'f', b'1', // compatible brand
            b'a', b'v', b'i', b'f', // compatible brand
        ]
    }

    #[test]
    fn accepts_png() {
        let img = parse_image(png_bytes(), 1024, "cover").unwrap();
        assert_eq!(img.mime, "image/png");
        assert_eq!(img.ext, "png");
    }

    #[test]
    fn accepts_jpeg() {
        let img = parse_image(jpeg_bytes(), 1024, "cover").unwrap();
        assert_eq!(img.mime, "image/jpeg");
        assert_eq!(img.ext, "jpg");
    }

    #[test]
    fn accepts_avif() {
        let img = parse_image(avif_bytes(), 1024, "cover").unwrap();
        assert_eq!(img.mime, "image/avif");
        assert_eq!(img.ext, "avif");
    }

    #[test]
    fn rejects_non_image_with_unsupported_media_type() {
        let err = parse_image(pdf_bytes(), 1024, "cover").unwrap_err();
        assert!(matches!(err, AppError::UnsupportedMediaType(_)));
        assert_eq!(err.code(), "unsupported_media_type");
    }

    #[test]
    fn rejects_garbage_with_unsupported_media_type() {
        let err = parse_image(b"just some text".to_vec(), 1024, "cover").unwrap_err();
        assert!(matches!(err, AppError::UnsupportedMediaType(_)));
    }

    #[test]
    fn rejects_oversized() {
        let err = parse_image(png_bytes(), 4, "cover").unwrap_err();
        assert!(matches!(err, AppError::PayloadTooLarge(_)));
        assert_eq!(err.code(), "payload_too_large");
    }
}
