///|
pub(all) struct AssetSpec {
  file : String
  label : String?
  width : Int32?
  height : Int32?
  max_bytes : Int32
  min_bytes : Int32?
  expected_sha256 : String?
  aspect : String?
  format : String?
} derive(FromJson, ToJson)

///|
pub extend AssetSpec with @moonbitlang/core/json.FromJson::{from_json}

///|
pub extend AssetSpec with ToJson::{to_json}

///|
pub(all) struct Manifest {
  assets : Array[AssetSpec]
  max_total_bytes : Int32?
} derive(FromJson, ToJson)

///|
pub extend Manifest with @moonbitlang/core/json.FromJson::{from_json}

///|
pub extend Manifest with ToJson::{to_json}

///|
pub(all) struct CheckResult {
  file : String
  label : String?
  status : String
  observed : String
  sha256 : String
  details : String
} derive(ToJson)

///|
pub extend CheckResult with ToJson::{to_json}

///|
pub(all) struct AuditReport {
  asset_dir : String
  manifest_sha256 : String
  checked : Int32
  passed : Int32
  failed : Int32
  results : Array[CheckResult]
} derive(ToJson)

///|
pub extend AuditReport with ToJson::{to_json}

///|
pub fn png_dimensions(bytes : Bytes) -> (Int32, Int32)? {
  if bytes.length() < 33 {
    return None
  }
  if !bytes.has_prefix(b"\x89PNG\r\n\x1A\n") {
    return None
  }
  if bytes[8] != b'\x00' ||
    bytes[9] != b'\x00' ||
    bytes[10] != b'\x00' ||
    bytes[11] != b'\x0D' {
    return None
  }
  if bytes[12] != b'I' ||
    bytes[13] != b'H' ||
    bytes[14] != b'D' ||
    bytes[15] != b'R' {
    return None
  }
  if !png_ihdr_crc_is_valid(bytes) {
    return None
  }
  if !png_ihdr_fields_are_valid(
      bytes[24].to_int(),
      bytes[25].to_int(),
      bytes[26].to_int(),
      bytes[27].to_int(),
      bytes[28].to_int(),
    ) {
    return None
  }

  let width = read_png_dimension(bytes, 16)
  let height = read_png_dimension(bytes, 20)
  match (width, height) {
    (Some(w), Some(h)) if w > 0 && h > 0 => Some((w, h))
    _ => None
  }
}

///|
/// Validate the CRC for the first PNG chunk (IHDR), without decoding image data.
fn png_ihdr_crc_is_valid(bytes : Bytes) -> Bool {
  let digest = @crc32.Digest::new()
  for index in 12..<29 {
    digest.write(bytes[index])
  }
  png_crc_matches(digest.digest(), bytes, 29)
}

///|
fn png_crc_matches(crc : UInt32, bytes : Bytes, offset : Int32) -> Bool {
  bytes[offset] == (crc >> 24).to_byte() &&
  bytes[offset + 1] == (crc >> 16).to_byte() &&
  bytes[offset + 2] == (crc >> 8).to_byte() &&
  bytes[offset + 3] == crc.to_byte()
}

///|
/// Check the combinations defined by the PNG IHDR specification.
fn png_ihdr_fields_are_valid(
  bit_depth : Int32,
  color_type : Int32,
  compression_method : Int32,
  filter_method : Int32,
  interlace_method : Int32,
) -> Bool {
  let valid_bit_depth = match color_type {
    0 =>
      bit_depth == 1 ||
      bit_depth == 2 ||
      bit_depth == 4 ||
      bit_depth == 8 ||
      bit_depth == 16
    2 => bit_depth == 8 || bit_depth == 16
    3 => bit_depth == 1 || bit_depth == 2 || bit_depth == 4 || bit_depth == 8
    4 | 6 => bit_depth == 8 || bit_depth == 16
    _ => false
  }
  valid_bit_depth &&
  compression_method == 0 &&
  filter_method == 0 &&
  (interlace_method == 0 || interlace_method == 1)
}

///|
pub fn jpeg_dimensions(bytes : Bytes) -> (Int32, Int32)? {
  if bytes.length() < 4 || bytes[0] != b'\xFF' || bytes[1] != b'\xD8' {
    return None
  }

  let mut offset = 2
  let mut orientation : Int32? = None
  while offset < bytes.length() {
    if bytes[offset] != b'\xFF' {
      return None
    }
    offset += 1
    while offset < bytes.length() && bytes[offset] == b'\xFF' {
      offset += 1
    }
    if offset >= bytes.length() {
      return None
    }
    let marker = bytes[offset]
    offset += 1

    if marker == b'\x00' ||
      marker == b'\xD8' ||
      marker == b'\xD9' ||
      marker == b'\xDA' {
      return None
    }
    if is_standalone_jpeg_marker(marker) {
      continue
    }
    if offset + 2 > bytes.length() {
      return None
    }

    let segment_length = read_big_endian_u16(bytes, offset)
    if segment_length < 2 || offset + segment_length > bytes.length() {
      return None
    }
    if marker == b'\xE1' && orientation is None {
      orientation = jpeg_exif_orientation(bytes, offset + 2, segment_length - 2)
    }
    if is_jpeg_start_of_frame(marker) {
      if segment_length < 8 {
        return None
      }
      let height = read_big_endian_u16(bytes, offset + 3)
      let width = read_big_endian_u16(bytes, offset + 5)
      let component_count = bytes[offset + 7].to_int()
      if component_count == 0 || segment_length != 8 + component_count * 3 {
        return None
      }
      if width > 0 && height > 0 {
        match orientation {
          Some(value) if value >= 5 && value <= 8 =>
            return Some((height, width))
          _ => return Some((width, height))
        }
      }
      return None
    }
    offset += segment_length
  }
  None
}

///|
/// Read the EXIF Orientation tag from a JPEG APP1 payload. Invalid or
/// unsupported EXIF metadata is ignored so the SOF dimensions remain usable.
fn jpeg_exif_orientation(
  bytes : Bytes,
  payload_start : Int32,
  payload_length : Int32,
) -> Int32? {
  if payload_start < 0 ||
    payload_length < 32 ||
    payload_start > bytes.length() - payload_length {
    return None
  }
  if bytes[payload_start] != b'E' ||
    bytes[payload_start + 1] != b'x' ||
    bytes[payload_start + 2] != b'i' ||
    bytes[payload_start + 3] != b'f' ||
    bytes[payload_start + 4] != b'\x00' ||
    bytes[payload_start + 5] != b'\x00' {
    return None
  }

  let tiff_start = payload_start + 6
  let payload_end = payload_start + payload_length
  let little_endian = if bytes[tiff_start] == b'I' &&
    bytes[tiff_start + 1] == b'I' {
    true
  } else if bytes[tiff_start] == b'M' && bytes[tiff_start + 1] == b'M' {
    false
  } else {
    return None
  }
  if read_tiff_u16(bytes, tiff_start + 2, little_endian) != 42 {
    return None
  }
  let ifd_offset = match
    read_tiff_u32_bounded(bytes, tiff_start + 4, payload_end, little_endian) {
    Some(value) if value >= 8 => value
    _ => return None
  }
  let ifd_start = tiff_start + ifd_offset
  if ifd_start + 2 > payload_end {
    return None
  }
  let entry_count = read_tiff_u16(bytes, ifd_start, little_endian)
  let entries_start = ifd_start + 2
  let entry_bytes = entry_count * 12
  if entry_bytes + 4 > payload_end - entries_start {
    return None
  }
  for entry_index in 0..= 1 && value <= 8 {
        return Some(value)
      }
    }
  }
  None
}

///|
fn read_tiff_u16(bytes : Bytes, offset : Int32, little_endian : Bool) -> Int32 {
  if little_endian {
    read_little_endian_u16(bytes, offset)
  } else {
    read_big_endian_u16(bytes, offset)
  }
}

///|
/// TIFF offsets must fit within a JPEG APP1 segment (at most 64 KiB).
fn read_tiff_u32_bounded(
  bytes : Bytes,
  offset : Int32,
  end : Int32,
  little_endian : Bool,
) -> Int32? {
  if offset < 0 || offset + 4 > end {
    return None
  }
  if little_endian {
    if bytes[offset + 2] != b'\x00' || bytes[offset + 3] != b'\x00' {
      return None
    }
    Some(read_little_endian_u16(bytes, offset))
  } else {
    if bytes[offset] != b'\x00' || bytes[offset + 1] != b'\x00' {
      return None
    }
    Some(read_big_endian_u16(bytes, offset + 2))
  }
}

///|
fn tiff_u32_is_one(bytes : Bytes, offset : Int32, little_endian : Bool) -> Bool {
  if little_endian {
    bytes[offset] == b'\x01' &&
    bytes[offset + 1] == b'\x00' &&
    bytes[offset + 2] == b'\x00' &&
    bytes[offset + 3] == b'\x00'
  } else {
    bytes[offset] == b'\x00' &&
    bytes[offset + 1] == b'\x00' &&
    bytes[offset + 2] == b'\x00' &&
    bytes[offset + 3] == b'\x01'
  }
}

///|
/// Return the GIF87a/GIF89a logical screen dimensions without decoding image data.
pub fn gif_dimensions(bytes : Bytes) -> (Int32, Int32)? {
  if bytes.length() < 13 {
    return None
  }
  if !bytes.has_prefix(b"GIF87a") && !bytes.has_prefix(b"GIF89a") {
    return None
  }
  let width = read_little_endian_u16(bytes, 6)
  let height = read_little_endian_u16(bytes, 8)
  if width > 0 && height > 0 {
    Some((width, height))
  } else {
    None
  }
}

///|
/// Read the pixel dimensions from a BMP file header without decoding pixel data.
/// Supports BITMAPINFOHEADER (and later headers) plus the legacy BITMAPCOREHEADER.
/// A negative height (top-down DIB) is reported as its absolute value.
pub fn bmp_dimensions(bytes : Bytes) -> (Int32, Int32)? {
  if bytes.length() < 26 {
    return None
  }
  if bytes[0] != b'B' || bytes[1] != b'M' {
    return None
  }
  let dib_size = read_little_endian_u32(bytes, 14)
  let (width, height) = if dib_size >= 40L {
    (read_little_endian_u32(bytes, 18), read_little_endian_i32(bytes, 22))
  } else if dib_size >= 12L {
    (
      Int32::to_int64(read_little_endian_u16(bytes, 18)),
      Int32::to_int64(read_little_endian_u16(bytes, 20)),
    )
  } else {
    return None
  }
  let abs_height = if height < 0L { 0L - height } else { height }
  if width > 0L &&
    abs_height > 0L &&
    width < 2147483648L &&
    abs_height < 2147483648L {
    Some((Int64::to_int(width), Int64::to_int(abs_height)))
  } else {
    None
  }
}

///|
/// Read the pixel dimensions from a TIFF header (ImageWidth / ImageLength tags)
/// without decoding pixel data. Supports little-endian ("II") and big-endian
/// ("MM") byte order, and both SHORT and LONG tag value types.
pub fn tiff_dimensions(bytes : Bytes) -> (Int32, Int32)? {
  if bytes.length() < 8 {
    return None
  }
  let little_endian = if bytes[0] == b'I' && bytes[1] == b'I' {
    true
  } else if bytes[0] == b'M' && bytes[1] == b'M' {
    false
  } else {
    return None
  }
  if read_tiff_u16(bytes, 2, little_endian) != 42 {
    return None
  }
  let ifd_offset = read_tiff_u32(bytes, 4, little_endian)
  if ifd_offset < 0L || ifd_offset >= Int32::to_int64(bytes.length()) {
    return None
  }
  let ifd = Int64::to_int(ifd_offset)
  if ifd + 2 > bytes.length() {
    return None
  }
  let entry_count = read_tiff_u16(bytes, ifd, little_endian)
  let mut width : Int32? = None
  let mut height : Int32? = None
  for index in 0.. bytes.length() {
      return None
    }
    let tag = read_tiff_u16(bytes, entry, little_endian)
    if tag == 256 || tag == 257 {
      let field_type = read_tiff_u16(bytes, entry + 2, little_endian)
      let value = match field_type {
        3 => Int32::to_int64(read_tiff_u16(bytes, entry + 8, little_endian))
        4 => read_tiff_u32(bytes, entry + 8, little_endian)
        _ => return None
      }
      if value < 0L || value >= 2147483648L {
        return None
      }
      let int_value = Int64::to_int(value)
      if int_value <= 0 {
        return None
      }
      if tag == 256 {
        width = Some(int_value)
      } else {
        height = Some(int_value)
      }
    }
  }
  match (width, height) {
    (Some(w), Some(h)) => Some((w, h))
    _ => None
  }
}

///|
fn read_tiff_u32(bytes : Bytes, offset : Int32, little_endian : Bool) -> Int64 {
  if little_endian {
    read_little_endian_u32(bytes, offset)
  } else {
    read_big_endian_u32(bytes, offset)
  }
}

///|
fn read_big_endian_u16(bytes : Bytes, offset : Int32) -> Int32 {
  bytes[offset].to_int() * 256 + bytes[offset + 1].to_int()
}

///|
fn read_little_endian_u16(bytes : Bytes, offset : Int32) -> Int32 {
  bytes[offset].to_int() + bytes[offset + 1].to_int() * 256
}

///|
fn read_little_endian_u32(bytes : Bytes, offset : Int32) -> Int64 {
  Int32::to_int64(bytes[offset].to_int()) +
  Int32::to_int64(bytes[offset + 1].to_int()) * 256L +
  Int32::to_int64(bytes[offset + 2].to_int()) * 65536L +
  Int32::to_int64(bytes[offset + 3].to_int()) * 16777216L
}

///|
fn read_little_endian_i32(bytes : Bytes, offset : Int32) -> Int64 {
  let value = read_little_endian_u32(bytes, offset)
  if value >= 2147483648L {
    value - 4294967296L
  } else {
    value
  }
}

///|
fn read_big_endian_u32(bytes : Bytes, offset : Int32) -> Int64 {
  Int32::to_int64(bytes[offset].to_int()) * 16777216L +
  Int32::to_int64(bytes[offset + 1].to_int()) * 65536L +
  Int32::to_int64(bytes[offset + 2].to_int()) * 256L +
  Int32::to_int64(bytes[offset + 3].to_int())
}

///|
fn is_standalone_jpeg_marker(marker : Byte) -> Bool {
  match marker {
    b'\x01'
    | b'\xD0'
    | b'\xD1'
    | b'\xD2'
    | b'\xD3'
    | b'\xD4'
    | b'\xD5'
    | b'\xD6'
    | b'\xD7' => true
    _ => false
  }
}

///|
fn is_jpeg_start_of_frame(marker : Byte) -> Bool {
  match marker {
    b'\xC0'
    | b'\xC1'
    | b'\xC2'
    | b'\xC3'
    | b'\xC5'
    | b'\xC6'
    | b'\xC7'
    | b'\xC9'
    | b'\xCA'
    | b'\xCB'
    | b'\xCD'
    | b'\xCE'
    | b'\xCF' => true
    _ => false
  }
}

///|
fn read_png_dimension(bytes : Bytes, offset : Int32) -> Int32? {
  let first = bytes[offset].to_int()
  if first >= 128 {
    return None
  }
  Some(
    first * 16777216 +
    bytes[offset + 1].to_int() * 65536 +
    bytes[offset + 2].to_int() * 256 +
    bytes[offset + 3].to_int(),
  )
}

///|
async fn sha256_file(path : String, size : Int64) -> String? {
  let file = @fs.open(path, mode=@fs.ReadOnly)
  defer file.close()
  let hasher = @crypto.SHA256::new()
  let mut offset = 0L
  while offset < size {
    let remaining = size - offset
    let block_size = if remaining > 65_536L {
      65_536
    } else {
      Int64::to_int(remaining)
    }
    let block = file.read_exactly_at(block_size, position=offset)
    hasher.update(block)
    offset += Int32::to_int64(block_size)
  }
  if @fs.file_size(path) != size {
    return None
  }
  Some(@crypto.bytes_to_hex_string(hasher.finalize()))
}

///|
/// Fingerprint the exact manifest bytes so a saved report can be tied to the
/// specification file used for the audit.
pub fn sha256_bytes(bytes : Bytes) -> String {
  let hasher = @crypto.SHA256::new()
  hasher.update(bytes)
  @crypto.bytes_to_hex_string(hasher.finalize())
}

///|
fn normalize_sha256(value : String) -> String {
  let mut normalized = ""
  for character in value {
    normalized += match character {
      'A' => "a"
      'B' => "b"
      'C' => "c"
      'D' => "d"
      'E' => "e"
      'F' => "f"
      _ => character.to_string()
    }
  }
  normalized
}

///|
/// Parse an aspect ratio string "W:H" into its positive integer parts.
pub fn parse_aspect(value : String) -> (Int32, Int32)? {
  let chars = value.to_array()
  let mut colon = -1
  let mut index = 0
  while index < chars.length() {
    if chars[index] == ':' {
      colon = index
      break
    }
    index += 1
  }
  if colon <= 0 || colon == chars.length() - 1 {
    return None
  }
  match
    (
      parse_positive_int(chars, 0, colon),
      parse_positive_int(chars, colon + 1, chars.length()),
    ) {
    (Some(w), Some(h)) if w > 0 && h > 0 => Some((w, h))
    _ => None
  }
}

///|
fn parse_positive_int(
  chars : Array[Char],
  start : Int32,
  end : Int32,
) -> Int32? {
  if start >= end {
    return None
  }
  let mut value = 0
  for index in start.. 57 {
      return None
    }
    if value > 214748364 {
      return None
    }
    value = value * 10 + (code - 48)
  }
  Some(value)
}

///|
/// Check whether width × height matches the aspect ratio aw:ah exactly.
pub fn matches_aspect(
  width : Int32,
  height : Int32,
  aw : Int32,
  ah : Int32,
) -> Bool {
  Int32::to_int64(width) * Int32::to_int64(ah) ==
  Int32::to_int64(height) * Int32::to_int64(aw)
}

///|
async fn read_png_dimensions(path : String, size : Int64) -> (Int32, Int32)? {
  if size < Int32::to_int64(33) {
    return None
  }
  let file = @fs.open(path, mode=@fs.ReadOnly)
  defer file.close()
  let header = file.read_exactly_at(33, position=0L)
  match png_dimensions(header) {
    Some(dimensions) => {
      let complete = png_chunks_are_valid(file, size) catch { _ => false }
      if complete {
        Some(dimensions)
      } else {
        None
      }
    }
    None => None
  }
}

///|
/// Check PNG chunk framing and CRCs without decompressing image data.
async fn png_chunks_are_valid(file : @fs.File, size : Int64) -> Bool {
  if size < 8L {
    return false
  }
  let signature = file.read_exactly_at(8, position=0L)
  if !signature.has_prefix(b"\x89PNG\r\n\x1A\n") {
    return false
  }

  let mut offset = 8L
  let mut chunk_index = 0
  let mut has_idat = false
  let mut idat_sequence_ended = false
  while offset < size {
    if size - offset < 12L {
      return false
    }
    let header = file.read_exactly_at(8, position=offset)
    let length = read_png_chunk_length(header)
    if length > 2_147_483_647L || length > size - offset - 12L {
      return false
    }
    if !is_png_chunk_type(header, 4) {
      return false
    }

    let is_ihdr = png_chunk_type_is(header, b"IHDR")
    let is_idat = png_chunk_type_is(header, b"IDAT")
    let is_iend = png_chunk_type_is(header, b"IEND")
    if chunk_index == 0 {
      if !is_ihdr || length != 13L {
        return false
      }
    } else if is_ihdr {
      return false
    }
    if is_idat {
      if idat_sequence_ended {
        return false
      }
      has_idat = true
    } else if has_idat && !is_iend {
      idat_sequence_ended = true
    }
    if is_iend && (!has_idat || length != 0L) {
      return false
    }

    let digest = @crc32.Digest::new()
    for index in 4..<8 {
      digest.write(header[index])
    }
    let data_start = offset + 8L
    let data_end = data_start + length
    let mut data_offset = data_start
    while data_offset < data_end {
      let remaining = data_end - data_offset
      let block_size = if remaining > 65_536L {
        65_536
      } else {
        Int64::to_int(remaining)
      }
      let block = file.read_exactly_at(block_size, position=data_offset)
      for index in 0.. Int64 {
  Int32::to_int64(bytes[0].to_int()) * 16_777_216L +
  Int32::to_int64(bytes[1].to_int()) * 65_536L +
  Int32::to_int64(bytes[2].to_int()) * 256L +
  Int32::to_int64(bytes[3].to_int())
}

///|
fn is_png_chunk_type(bytes : Bytes, offset : Int32) -> Bool {
  for index in offset..<(offset + 4) {
    let value = bytes[index].to_int()
    let is_uppercase = value >= b'A'.to_int() && value <= b'Z'.to_int()
    let is_lowercase = value >= b'a'.to_int() && value <= b'z'.to_int()
    if !is_uppercase && !is_lowercase {
      return false
    }
  }
  // The reserved bit (bit 5 of the third type byte) must be zero.
  bytes[offset + 2].to_int() >= b'A'.to_int() &&
  bytes[offset + 2].to_int() <= b'Z'.to_int()
}

///|
fn png_chunk_type_is(bytes : Bytes, name : Bytes) -> Bool {
  bytes[4] == name[0] &&
  bytes[5] == name[1] &&
  bytes[6] == name[2] &&
  bytes[7] == name[3]
}

///|
async fn read_jpeg_dimensions(path : String, size : Int64) -> (Int32, Int32)? {
  if size < 4L {
    return None
  }
  // Bound memory use while allowing common EXIF, XMP, and ICC metadata blocks.
  let header_length = if size > 4_194_304L {
    4_194_304
  } else {
    Int64::to_int(size)
  }
  let file = @fs.open(path, mode=@fs.ReadOnly)
  defer file.close()
  let header = file.read_exactly_at(header_length, position=0L)
  jpeg_dimensions(header)
}

///|
async fn read_gif_dimensions(path : String, size : Int64) -> (Int32, Int32)? {
  if size < 13L {
    return None
  }
  let file = @fs.open(path, mode=@fs.ReadOnly)
  defer file.close()
  let header = file.read_exactly(13)
  gif_dimensions(header)
}

///|
async fn read_bmp_dimensions(path : String, size : Int64) -> (Int32, Int32)? {
  if size < 26L {
    return None
  }
  let file = @fs.open(path, mode=@fs.ReadOnly)
  defer file.close()
  let header = file.read_exactly(26)
  bmp_dimensions(header)
}

///|
async fn read_tiff_dimensions(path : String, size : Int64) -> (Int32, Int32)? {
  if size < 8L {
    return None
  }
  // TIFF headers, IFD entries, and the ImageWidth/ImageLength tags are normally
  // near the start of the file; bound the read to keep memory use predictable.
  let header_length = if size > 1_048_576L {
    1_048_576
  } else {
    Int64::to_int(size)
  }
  let file = @fs.open(path, mode=@fs.ReadOnly)
  defer file.close()
  let header = file.read_exactly_at(header_length, position=0L)
  tiff_dimensions(header)
}

///|
/// A detected image format. Extending this list also requires updating the
/// extension mapping in `image_format`, the reader dispatch in
/// `read_image_dimensions`, and the diagnostic in `format_error_message`.
pub(all) enum ImageFormat {
  Png
  Jpeg
  Webp
  Gif
  Bmp
  Tiff
  Svg
  Avif
  Jp2
  Ico
  Psd
} derive(Eq)

///|
pub extend ImageFormat with Eq::{not_equal, equal}

///|
pub fn image_format(name : String) -> ImageFormat? {
  let lower_name = name.to_lower()
  if lower_name.has_suffix(".png") {
    Some(ImageFormat::Png)
  } else if lower_name.has_suffix(".jpg") || lower_name.has_suffix(".jpeg") {
    Some(ImageFormat::Jpeg)
  } else if lower_name.has_suffix(".webp") {
    Some(ImageFormat::Webp)
  } else if lower_name.has_suffix(".gif") {
    Some(ImageFormat::Gif)
  } else if lower_name.has_suffix(".bmp") {
    Some(ImageFormat::Bmp)
  } else if lower_name.has_suffix(".tif") || lower_name.has_suffix(".tiff") {
    Some(ImageFormat::Tiff)
  } else if lower_name.has_suffix(".svg") {
    Some(ImageFormat::Svg)
  } else if lower_name.has_suffix(".avif") {
    Some(ImageFormat::Avif)
  } else if lower_name.has_suffix(".jp2") {
    Some(ImageFormat::Jp2)
  } else if lower_name.has_suffix(".ico") || lower_name.has_suffix(".cur") {
    Some(ImageFormat::Ico)
  } else if lower_name.has_suffix(".psd") {
    Some(ImageFormat::Psd)
  } else {
    None
  }
}

///|
/// Return the canonical lowercase format name used in reports and manifest
/// `format` assertions.
pub fn format_name(format : ImageFormat) -> String {
  match format {
    ImageFormat::Png => "png"
    ImageFormat::Jpeg => "jpeg"
    ImageFormat::Webp => "webp"
    ImageFormat::Gif => "gif"
    ImageFormat::Bmp => "bmp"
    ImageFormat::Tiff => "tiff"
    ImageFormat::Svg => "svg"
    ImageFormat::Avif => "avif"
    ImageFormat::Jp2 => "jp2"
    ImageFormat::Ico => "ico"
    ImageFormat::Psd => "psd"
  }
}

///|
/// Dispatch a file to the right dimension reader based on its detected format.
async fn read_image_dimensions(
  format : ImageFormat,
  path : String,
  size : Int64,
) -> (Int32, Int32)? {
  match format {
    ImageFormat::Png => read_png_dimensions(path, size)
    ImageFormat::Jpeg => read_jpeg_dimensions(path, size)
    ImageFormat::Webp => read_webp_dimensions(path, size)
    ImageFormat::Gif => read_gif_dimensions(path, size)
    ImageFormat::Bmp => read_bmp_dimensions(path, size)
    ImageFormat::Tiff => read_tiff_dimensions(path, size)
    ImageFormat::Svg => read_svg_dimensions(path, size)
    ImageFormat::Avif => read_avif_dimensions(path, size)
    ImageFormat::Jp2 => read_jp2_dimensions(path, size)
    ImageFormat::Ico => read_ico_dimensions(path, size)
    ImageFormat::Psd => read_psd_dimensions(path, size)
  }
}

///|
fn format_error_message(format : ImageFormat) -> String {
  match format {
    ImageFormat::Png => "PNG 结构无效、不完整或 CRC 校验失败"
    ImageFormat::Jpeg =>
      "JPEG 文件头无效、不完整或 SOF 超出 4 MiB 扫描范围"
    ImageFormat::Webp => "WebP RIFF 结构无效、不完整或尺寸头错误"
    ImageFormat::Gif => "GIF 文件头无效、不完整或尺寸为零"
    ImageFormat::Bmp => "BMP 文件头无效、不完整或尺寸为零"
    ImageFormat::Tiff => "TIFF 文件头无效、不完整或缺少尺寸标签"
    ImageFormat::Svg => "SVG 文档无效、不完整或缺少整数宽高"
    ImageFormat::Avif =>
      "AVIF 容器无效、不完整或缺少 ispe 尺寸属性"
    ImageFormat::Jp2 =>
      "JPEG 2000 容器无效、不完整或缺少 ihdr 尺寸头"
    ImageFormat::Ico => "ICO/CUR 文件头无效、不完整或尺寸为零"
    ImageFormat::Psd => "PSD 文件头无效、不完整或尺寸为零"
  }
}

///|
/// Audit a directory against typed constraints, including directly constructed
/// AssetSpec values. Invalid constraints produce a FAIL item without reading or
/// hashing that asset; they are never silently treated as omitted constraints.
pub async fn scan_assets(
  asset_dir : String,
  manifest : Manifest,
  manifest_sha256 : String,
) -> AuditReport {
  let results : Array[CheckResult] = []
  let seen_files : Array[String] = []
  let seen_digests : Map[String, String] = Map([])

  if manifest.assets.is_empty() {
    results.push({
      file: "",
      label: None,
      status: "FAIL",
      observed: "0 项",
      sha256: "—",
      details: "清单至少需要一项素材",
    })
  }

  let entries = collect_asset_files(asset_dir)
  for spec in manifest.assets {
    let mut duplicate = false
    for previous in seen_files {
      if previous == spec.file {
        duplicate = true
      }
    }
    seen_files.push(spec.file)

    if !is_safe_asset_path(spec.file) {
      results.push({
        file: spec.file,
        label: spec.label,
        status: "FAIL",
        observed: "—",
        sha256: "—",
        details: "file 必须是使用 / 分隔的安全相对路径,不能包含 ..、绝对路径或 Windows 分隔符",
      })
      continue
    }
    if duplicate {
      results.push({
        file: spec.file,
        label: spec.label,
        status: "FAIL",
        observed: "重复项",
        sha256: "—",
        details: "清单中出现了重复文件名",
      })
      continue
    }
    match asset_spec_error(spec) {
      Some(message) => {
        results.push({
          file: spec.file,
          label: spec.label,
          status: "FAIL",
          observed: "—",
          sha256: "—",
          details: message,
        })
        continue
      }
      None => ()
    }
    let path = join_asset_path(asset_dir, spec.file)
    if !contains_entry(entries, spec.file) {
      results.push({
        file: spec.file,
        label: spec.label,
        status: "FAIL",
        observed: "缺失",
        sha256: "—",
        details: "清单要求的文件名(含大小写)没有交付",
      })
      continue
    }
    let kind = @fs.kind(path, follow_symlink=false)
    if kind != @fs.FileKind::Regular {
      results.push({
        file: spec.file,
        label: spec.label,
        status: "FAIL",
        observed: "非普通文件",
        sha256: "—",
        details: "清单路径必须指向普通文件",
      })
      continue
    }

    let size = @fs.file_size(path)
    let extension_format = image_format(spec.file)
    let content_format = detect_content_format_from_file(path, size)
    let parsed_image_format = match (extension_format, content_format) {
      (Some(format), _) => Some(format)
      (None, Some(format)) => Some(format)
      (None, None) => None
    }
    let issues : Array[String] = []
    let max_bytes = Int32::to_int64(spec.max_bytes)
    if size > max_bytes {
      issues.push("文件大小 \{size} bytes 超出上限 \{max_bytes} bytes")
    }
    match spec.min_bytes {
      Some(minimum) if minimum > 0 && size < Int32::to_int64(minimum) =>
        issues.push("文件大小 \{size} bytes 低于下限 \{minimum} bytes")
      _ => ()
    }
    match (spec.format, content_format) {
      (Some(expected), Some(actual)) =>
        match canonical_format(expected) {
          Some(name) if name != format_name(actual) =>
            issues.push(
              "格式断言不匹配(清单要求 \{name},实际 \{format_name(actual)})",
            )
          _ => ()
        }
      (Some(_), None) =>
        issues.push(
          "无法识别文件内容格式,无法核对 format 断言",
        )
      _ => ()
    }
    match (extension_format, content_format) {
      (Some(expected), Some(actual)) if expected != actual =>
        issues.push(
          "扩展名与内容不符(扩展名 \{format_name(expected)},实际 \{format_name(actual)})",
        )
      _ => ()
    }
    let digest = if size <= max_bytes {
      sha256_file(path, size) catch {
        _ => None
      }
    } else {
      None
    }
    let (sha256, hash_failed) = match digest {
      Some(value) => (value, false)
      None => ("—", size <= max_bytes)
    }
    if hash_failed {
      issues.push(
        "SHA-256 计算失败或文件大小在检查期间发生变化",
      )
    }
    match spec.expected_sha256 {
      Some(_) if size > max_bytes =>
        issues.push(
          "文件大小超出 max_bytes,跳过预期 SHA-256 校验",
        )
      Some(_) if hash_failed => ()
      Some(expected) =>
        match digest {
          Some(actual) =>
            if normalize_sha256(expected) != actual {
              issues.push("SHA-256 不匹配(清单要求 \{expected})")
            }
          None =>
            issues.push("SHA-256 未能计算,无法核对清单预期值")
        }
      None => ()
    }
    let dimensions = match parsed_image_format {
      Some(Avif) =>
        match inspect_avif_file(path) {
          Ok(structure) => Some((structure.width, structure.height))
          Err(diagnostic) => {
            issues.push(avif_diagnostic_message(diagnostic))
            None
          }
        }
      Some(format) => read_image_dimensions(format, path, size)
      None => None
    }
    match (parsed_image_format, dimensions) {
      (Some(Avif), None) => ()
      (Some(format), None) => {
        let message = match (extension_format, content_format) {
          (Some(expected), Some(actual)) if expected != actual => ""
          _ => format_error_message(format)
        }
        if message != "" {
          issues.push(message)
        }
      }
      (None, None) if spec.width is Some(_) || spec.aspect is Some(_) =>
        issues.push(
          "尺寸和宽高比规则只适用于受支持的图像文件",
        )
      _ => ()
    }
    let observed = match dimensions {
      Some((width, height)) => "\{width} × \{height},\{size} bytes"
      None => "\{size} bytes"
    }
    match (spec.width, spec.height, dimensions) {
      (Some(expected_width), Some(expected_height), Some((width, height))) if width !=
        expected_width ||
        height != expected_height =>
        issues.push(
          "尺寸不匹配(清单要求 \{expected_width} × \{expected_height},实际 \{width} × \{height})",
        )
      _ => ()
    }
    match (dimensions, spec.aspect) {
      (Some((width, height)), Some(aspect)) =>
        match parse_aspect(aspect) {
          Some((aw, ah)) =>
            if !matches_aspect(width, height, aw, ah) {
              issues.push(
                "宽高比不匹配(清单要求 \{aw}:\{ah},实际 \{width} × \{height})",
              )
            }
          None => ()
        }
      _ => ()
    }
    let status = if issues.is_empty() { "PASS" } else { "FAIL" }
    let issue_details = if issues.is_empty() {
      "符合清单规格"
    } else {
      issues.join(";")
    }
    let duplicate_details = match
      register_content_digest(seen_digests, spec.file, sha256) {
      Some(previous_file) =>
        "SHA-256 与 \{previous_file} 相同,可能是重复导出,请人工确认"
      None => ""
    }
    let details = if duplicate_details == "" {
      issue_details
    } else if issues.is_empty() {
      duplicate_details
    } else {
      "\{issue_details};\{duplicate_details}"
    }
    results.push({
      file: spec.file,
      label: spec.label,
      status,
      observed,
      sha256,
      details,
    })
  }

  for name in entries {
    let mut expected = false
    for spec in manifest.assets {
      if spec.file == name {
        expected = true
      }
    }
    if !expected {
      let path = join_asset_path(asset_dir, name)
      if @fs.kind(path, follow_symlink=false) == @fs.FileKind::Regular {
        let size = @fs.file_size(path)
        let is_image = image_format(name) is Some(_)
        let digest = if is_image {
          sha256_file(path, size) catch {
            _ => None
          }
        } else {
          None
        }
        let sha256 = match digest {
          Some(value) => value
          None => "—"
        }
        let details = if is_image {
          match register_content_digest(seen_digests, name, sha256) {
            Some(previous_file) =>
              "未登记图片文件;SHA-256 与 \{previous_file} 相同,可能是重复导出,请人工确认"
            None => "未登记图片文件"
          }
        } else {
          "未登记文件"
        }
        results.push({
          file: name,
          label: None,
          status: "EXTRA",
          observed: "\{size} bytes",
          sha256,
          details,
        })
      }
    }
  }

  match manifest.max_total_bytes {
    Some(limit) => {
      let mut total_bytes = 0L
      let int64_max = 9_223_372_036_854_775_807L
      for name in entries {
        let path = join_asset_path(asset_dir, name)
        if @fs.kind(path, follow_symlink=false) == @fs.FileKind::Regular {
          let size = @fs.file_size(path)
          total_bytes = if size > int64_max - total_bytes {
            int64_max
          } else {
            total_bytes + size
          }
        }
      }
      let max_total_bytes = Int32::to_int64(limit)
      let status = if max_total_bytes > 0 && total_bytes <= max_total_bytes {
        "PASS"
      } else {
        "FAIL"
      }
      let details = if max_total_bytes <= 0 {
        "max_total_bytes 必须为正整数"
      } else if status == "PASS" {
        "素材目录总大小符合上限 \{max_total_bytes} bytes"
      } else {
        "素材目录总大小 \{total_bytes} bytes 超出上限 \{max_total_bytes} bytes"
      }
      results.push({
        file: "",
        label: None,
        status,
        observed: "\{total_bytes} bytes",
        sha256: "—",
        details,
      })
    }
    None => ()
  }

  let mut passed = 0
  let mut failed = 0
  for result in results {
    if result.status == "PASS" {
      passed += 1
    } else {
      failed += 1
    }
  }
  {
    asset_dir,
    manifest_sha256,
    checked: results.length(),
    passed,
    failed,
    results,
  }
}