///|
/// Pixelmatch - Fast pixel-level image comparison library
/// Port of mapbox/pixelmatch to MoonBit

///|
/// RGBA color representation
pub(all) struct Color {
  r : Int
  g : Int
  b : Int
  a : Int
} derive(Debug, Eq)

///|
pub impl Show for Color with output(self, logger) {
  logger.write_string("Color(r=")
  logger.write_string(self.r.to_string())
  logger.write_string(", g=")
  logger.write_string(self.g.to_string())
  logger.write_string(", b=")
  logger.write_string(self.b.to_string())
  logger.write_string(", a=")
  logger.write_string(self.a.to_string())
  logger.write_string(")")
}

///|
pub fn Color::rgba(r : Int, g : Int, b : Int, a : Int) -> Color {
  { r, g, b, a }
}

///|
pub fn Color::rgb(r : Int, g : Int, b : Int) -> Color {
  { r, g, b, a: 255 }
}

///|
/// Image data (RGBA pixels in row-major order)
pub(all) struct Image {
  width : Int
  height : Int
  data : FixedArray[Int] // [r, g, b, a, r, g, b, a, ...]
}

///|
pub fn Image::new(width : Int, height : Int) -> Image {
  if width < 0 || height < 0 {
    abort("Image dimensions must be non-negative")
  }
  // Guard against integer overflow: width * height * 4 must fit in Int
  let pixels = width.to_int64() * height.to_int64()
  if pixels * 4L > 2147483647L {
    abort("Image too large: dimensions would overflow")
  }
  let size = width * height * 4
  { width, height, data: FixedArray::make(size, 0) }
}

///|
pub fn Image::from_pixels(
  width : Int,
  height : Int,
  pixels : Array[Color],
) -> Image {
  let expected = width * height
  if pixels.length() != expected {
    abort(
      "Image::from_pixels: expected " +
      expected.to_string() +
      " pixels, got " +
      pixels.length().to_string(),
    )
  }
  let data = FixedArray::make(expected * 4, 0)
  for i, pixel in pixels {
    let base = i * 4
    data[base] = pixel.r
    data[base + 1] = pixel.g
    data[base + 2] = pixel.b
    data[base + 3] = pixel.a
  }
  { width, height, data }
}

///|
pub fn Image::get_pixel(self : Image, x : Int, y : Int) -> Color {
  let idx = (y * self.width + x) * 4
  {
    r: self.data[idx],
    g: self.data[idx + 1],
    b: self.data[idx + 2],
    a: self.data[idx + 3],
  }
}

///|
pub fn Image::set_pixel(self : Image, x : Int, y : Int, c : Color) -> Unit {
  let idx = (y * self.width + x) * 4
  self.data[idx] = c.r
  self.data[idx + 1] = c.g
  self.data[idx + 2] = c.b
  self.data[idx + 3] = c.a
}

///|
/// Pixelmatch options
pub(all) struct Options {
  /// Matching threshold (0 to 1). Smaller = more sensitive.
  threshold : Double
  /// Include anti-aliased pixels in diff
  include_aa : Bool
  /// Blending factor of unchanged pixels (0 to 1)
  alpha : Double
  /// Color of anti-aliased pixels in diff
  aa_color : Color
  /// Color of different pixels in diff
  diff_color : Color
  /// Detect dark on light differences
  diff_color_alt : Color?
  /// Mask mode - draw only changed pixels
  diff_mask : Bool
  detect_shift : Bool
}

///|
pub fn Options::default() -> Options {
  {
    threshold: 0.1,
    include_aa: false,
    alpha: 0.1,
    aa_color: Color::rgba(255, 255, 0, 255), // Yellow
    diff_color: Color::rgba(255, 0, 0, 255), // Red
    diff_color_alt: None,
    diff_mask: false,
    detect_shift: false,
  }
}

///|
/// Compare two images and return the number of different pixels
/// Optionally writes diff image to output
pub fn pixelmatch(
  img1 : Image,
  img2 : Image,
  output : Image?,
  options : Options,
) -> Int {
  if img1.width != img2.width || img1.height != img2.height {
    abort("Image dimensions must match")
  }
  match output {
    Some(out) =>
      if out.width != img1.width || out.height != img1.height {
        abort("Output image dimensions must match input images")
      }
    None => ()
  }
  let width = img1.width
  let height = img1.height
  // Maximum delta based on threshold
  // 35215 is the maximum possible delta (see YIQ calculation)
  let max_delta = 35215.0 * options.threshold * options.threshold
  let mut diff_count = 0
  for y in 0.. max_delta {
        // Check for anti-aliasing
        if !options.include_aa &&
          (is_antialiased(img1, img2, x, y) || is_antialiased(img2, img1, x, y)) {
          // Anti-aliased pixel
          match output {
            Some(out) =>
              if !options.diff_mask {
                out.set_pixel(x, y, options.aa_color)
              }
            None => ()
          }
        } else {
          // Different pixel
          diff_count += 1
          match output {
            Some(out) => {
              let color = if delta < 0.0 {
                options.diff_color_alt.unwrap_or(options.diff_color)
              } else {
                options.diff_color
              }
              out.set_pixel(x, y, color)
            }
            None => ()
          }
        }
      } else {
        // Similar pixel - draw blended grayscale
        match output {
          Some(out) =>
            if !options.diff_mask {
              let gray = blend_gray(img1, x, y, options.alpha)
              out.set_pixel(x, y, gray)
            }
          None => ()
        }
      }
    }
  }
  diff_count
}

///|
/// Calculate color delta between two pixels using YIQ color space
/// Optimized: inline alpha blending
fn color_delta(
  r1 : Int,
  g1 : Int,
  b1 : Int,
  a1 : Int,
  r2 : Int,
  g2 : Int,
  b2 : Int,
  a2 : Int,
  y_only : Bool,
  pos~ : Int = 0,
) -> Double {
  let mut dr = (r1 - r2).to_double()
  let mut dg = (g1 - g2).to_double()
  let mut db = (b1 - b2).to_double()
  let da = (a1 - a2).to_double()
  // Early exit for identical pixels
  if dr == 0.0 && dg == 0.0 && db == 0.0 && da == 0.0 {
    return 0.0
  }
  // Alpha blending against a checkerboard background (matching mapbox/pixelmatch)
  if a1 < 255 || a2 < 255 {
    let rb = 48.0 + 159.0 * (pos % 2).to_double()
    let gb = 48.0 + 159.0 * ((pos.to_double() / 1.618033988749895).to_int() % 2).to_double()
    let bb = 48.0 + 159.0 * ((pos.to_double() / 2.618033988749895).to_int() % 2).to_double()
    dr = (r1.to_double() * a1.to_double() - r2.to_double() * a2.to_double() - rb * da) / 255.0
    dg = (g1.to_double() * a1.to_double() - g2.to_double() * a2.to_double() - gb * da) / 255.0
    db = (b1.to_double() * a1.to_double() - b2.to_double() * a2.to_double() - bb * da) / 255.0
  }
  // Calculate YIQ components
  // Y (luminance)
  let y = 0.29889531 * dr + 0.58662247 * dg + 0.11448223 * db
  if y_only {
    return y
  }
  // I and Q (chrominance)
  let i = 0.59597799 * dr - 0.27417610 * dg - 0.32180189 * db
  let q = 0.21147017 * dr - 0.52261711 * dg + 0.31114694 * db
  // Weighted delta (sign indicates lightening/darkening)
  let delta = 0.5053 * y * y + 0.299 * i * i + 0.1957 * q * q
  if y > 0.0 {
    -delta
  } else {
    delta
  }
}

///|
fn color_delta_at(
  img1 : Image,
  img2 : Image,
  x : Int,
  y : Int,
  y_only : Bool,
) -> Double {
  let idx = (y * img1.width + x) * 4
  color_delta(
    img1.data[idx],
    img1.data[idx + 1],
    img1.data[idx + 2],
    img1.data[idx + 3],
    img2.data[idx],
    img2.data[idx + 1],
    img2.data[idx + 2],
    img2.data[idx + 3],
    y_only,
    pos=idx,
  )
}

///|
/// Compute luminance delta between two positions in the same image (Y only)
fn luminance_delta_between(
  data : FixedArray[Int],
  width : Int,
  x1 : Int,
  y1 : Int,
  x2 : Int,
  y2 : Int,
) -> Double {
  let idx1 = (y1 * width + x1) * 4
  let idx2 = (y2 * width + x2) * 4
  color_delta(
    data[idx1],
    data[idx1 + 1],
    data[idx1 + 2],
    data[idx1 + 3],
    data[idx2],
    data[idx2 + 1],
    data[idx2 + 2],
    data[idx2 + 3],
    true,
    pos=idx1,
  )
}

///|
/// Check if a pixel is anti-aliased (matching mapbox/pixelmatch algorithm)
fn is_antialiased(img1 : Image, img2 : Image, x : Int, y : Int) -> Bool {
  let width = img1.width
  let height = img1.height
  let x_lo = if x > 0 { x - 1 } else { 0 }
  let x_hi = if x < width - 1 { x + 1 } else { width - 1 }
  let y_lo = if y > 0 { y - 1 } else { 0 }
  let y_hi = if y < height - 1 { y + 1 } else { height - 1 }
  // Boundary pixels get a head start for zeroes count
  let mut zeroes = if x == x_lo || x == x_hi || y == y_lo || y == y_hi { 1 } else { 0 }
  let mut min_delta = 0.0
  let mut max_delta = 0.0
  let mut min_x = x
  let mut min_y = y
  let mut max_x = x
  let mut max_y = y
  let data1 = img1.data
  let mut ny = y_lo
  while ny <= y_hi {
    let mut nx = x_lo
    while nx <= x_hi {
      if nx != x || ny != y {
        let delta = luminance_delta_between(data1, width, x, y, nx, ny)
        if delta == 0.0 {
          zeroes += 1
          // If more than 2 identical neighbors, it's definitely not anti-aliasing
          if zeroes > 2 {
            return false
          }
        } else if delta < min_delta {
          min_delta = delta
          min_x = nx
          min_y = ny
        } else if delta > max_delta {
          max_delta = delta
          max_x = nx
          max_y = ny
        }
      }
      nx += 1
    }
    ny += 1
  }
  // No contrast difference
  if min_delta == 0.0 || max_delta == 0.0 {
    return false
  }
  // Check if darkest/brightest neighbor has 3+ equal siblings in both images
  (
    has_many_siblings(img1, min_x, min_y) &&
    has_many_siblings(img2, min_x, min_y)
  ) ||
  (
    has_many_siblings(img1, max_x, max_y) &&
    has_many_siblings(img2, max_x, max_y)
  )
}

///|
/// Check if a pixel has 3+ adjacent pixels of the same color.
/// Uses exact RGBA match (matching mapbox/pixelmatch Uint32 comparison).
/// Boundary pixels start with count=1 (they have fewer neighbors).
fn has_many_siblings(img : Image, x : Int, y : Int) -> Bool {
  let width = img.width
  let height = img.height
  let data = img.data
  let idx = (y * width + x) * 4
  let r = data[idx]
  let g = data[idx + 1]
  let b = data[idx + 2]
  let a = data[idx + 3]
  let x_lo = if x > 0 { x - 1 } else { 0 }
  let x_hi = if x < width - 1 { x + 1 } else { width - 1 }
  let y_lo = if y > 0 { y - 1 } else { 0 }
  let y_hi = if y < height - 1 { y + 1 } else { height - 1 }
  // Boundary pixels get a head start (matching original: zeroes starts at 1 on boundary)
  let mut count = if x == x_lo || x == x_hi || y == y_lo || y == y_hi { 1 } else { 0 }
  let mut ny = y_lo
  while ny <= y_hi {
    let mut nx = x_lo
    while nx <= x_hi {
      if nx != x || ny != y {
        let n = (ny * width + nx) * 4
        if data[n] == r && data[n + 1] == g && data[n + 2] == b && data[n + 3] == a {
          count += 1
          if count > 2 {
            return true
          }
        }
      }
      nx += 1
    }
    ny += 1
  }
  false
}

///|
/// Create a grayscale pixel blended with alpha
fn blend_gray(img : Image, x : Int, y : Int, alpha : Double) -> Color {
  let idx = (y * img.width + x) * 4
  let r = img.data[idx].to_double()
  let g = img.data[idx + 1].to_double()
  let b = img.data[idx + 2].to_double()
  let a = img.data[idx + 3].to_double()
  // Luminance (matching mapbox/pixelmatch coefficients)
  let y_val = r * 0.29889531 + g * 0.58662247 + b * 0.11448223
  // Blend: 255 + (Y - 255) * alpha * a / 255
  let val = (255.0 + (y_val - 255.0) * alpha * a / 255.0).to_int()
  Color::rgba(val, val, val, 255)
}

///|
/// Inline color delta calculation for maximum performance
/// Returns absolute delta value (always positive)
fn color_delta_inline(
  data1 : FixedArray[Int],
  data2 : FixedArray[Int],
  base : Int,
) -> Double {
  let r1 = data1[base]
  let g1 = data1[base + 1]
  let b1 = data1[base + 2]
  let a1 = data1[base + 3]
  let r2 = data2[base]
  let g2 = data2[base + 1]
  let b2 = data2[base + 2]
  let a2 = data2[base + 3]
  // Early exit for identical pixels
  if r1 == r2 && g1 == g2 && b1 == b2 && a1 == a2 {
    return 0.0
  }
  let mut dr = (r1 - r2).to_double()
  let mut dg = (g1 - g2).to_double()
  let mut db = (b1 - b2).to_double()
  let da = (a1 - a2).to_double()
  if a1 < 255 || a2 < 255 {
    let rb = 48.0 + 159.0 * (base % 2).to_double()
    let gb = 48.0 + 159.0 * ((base.to_double() / 1.618033988749895).to_int() % 2).to_double()
    let bb = 48.0 + 159.0 * ((base.to_double() / 2.618033988749895).to_int() % 2).to_double()
    dr = (r1.to_double() * a1.to_double() - r2.to_double() * a2.to_double() - rb * da) / 255.0
    dg = (g1.to_double() * a1.to_double() - g2.to_double() * a2.to_double() - gb * da) / 255.0
    db = (b1.to_double() * a1.to_double() - b2.to_double() * a2.to_double() - bb * da) / 255.0
  }
  let y = 0.29889531 * dr + 0.58662247 * dg + 0.11448223 * db
  let i = 0.59597799 * dr - 0.27417610 * dg - 0.32180189 * db
  let q = 0.21147017 * dr - 0.52261711 * dg + 0.31114694 * db
  0.5053 * y * y + 0.299 * i * i + 0.1957 * q * q
}

///|
/// Simple comparison without anti-aliasing detection
/// Returns the number of different pixels
/// Optimized: inline delta calculation, minimize function calls
pub fn pixelmatch_simple(img1 : Image, img2 : Image, threshold : Double) -> Int {
  if img1.width != img2.width || img1.height != img2.height {
    abort("Image dimensions must match")
  }
  let max_delta = 35215.0 * threshold * threshold
  let mut diff_count = 0
  let len = img1.data.length() / 4
  let data1 = img1.data
  let data2 = img2.data
  for i in 0.. max_delta {
      diff_count += 1
    }
  }
  diff_count
}

///|
/// Simple comparison with row-level prefilter optimization.
/// Skips entire rows where all pixels are identical (common in VRT screenshots).
/// Falls back to per-pixel delta for rows with differences.
pub fn pixelmatch_simple_prefilter(
  img1 : Image,
  img2 : Image,
  threshold : Double,
) -> Int {
  if img1.width != img2.width || img1.height != img2.height {
    abort("Image dimensions must match")
  }
  let max_delta = 35215.0 * threshold * threshold
  let width = img1.width
  let height = img1.height
  let data1 = img1.data
  let data2 = img2.data
  let mut diff_count = 0
  let row_ints = width * 4
  for y in 0.. max_delta {
        diff_count += 1
      }
    }
  }
  diff_count
}

///|
/// Calculate match ratio (0.0 = completely different, 1.0 = identical)
pub fn match_ratio(img1 : Image, img2 : Image, options : Options) -> Double {
  let total = img1.width * img1.height
  if total == 0 {
    return 1.0
  }
  let diff = pixelmatch(img1, img2, None, options)
  1.0 - diff.to_double() / total.to_double()
}

// ============================================================================
// AI-friendly Diff Report
// ============================================================================

///|
/// Bounding box for a diff region
pub(all) struct DiffRegion {
  x : Int
  y : Int
  width : Int
  height : Int
  diff_pixels : Int
  region_type : String // "shift" | "content" | "edge"
}

///|
/// Comprehensive diff report for AI consumption
pub(all) struct DiffReport {
  // Basic statistics
  width : Int
  height : Int
  total_pixels : Int
  diff_count : Int
  aa_count : Int
  match_ratio : Double
  // Grid heatmap (cell values = diff count in that cell)
  grid : Array[Array[Int]]
  grid_cols : Int
  grid_rows : Int
  // Diff regions (bounding boxes of clustered differences)
  regions : Array[DiffRegion]
  // Classification summary
  shift_only : Bool
  content_change_count : Int
  // Shift compensation
  global_shift : Int
  shift_regions : Array[ShiftRegion]
  compensated_diff_count : Int
}

///|
pub(all) struct ShiftRegion {
  y_start : Int
  y_end : Int
  shift : Int
}

///|
/// Compute average luminance (Y channel) per row
pub fn luminance_profile(img : Image) -> FixedArray[Double] {
  let profile : FixedArray[Double] = FixedArray::make(img.height, 0.0)
  let data = img.data
  let width = img.width
  let inv_width = 1.0 / width.to_double()
  for y in 0.. Double {
  let mut sum_xy = 0.0
  let mut sum_xx = 0.0
  let mut sum_yy = 0.0
  let start = if offset > 0 { offset } else { 0 }
  let n = if len1 < len2 { len1 } else { len2 }
  let end = if offset > 0 { n } else { n + offset }
  for i in start.. 0.0 { sum_xy / denom } else { 0.0 }
}

///|
/// Detect global vertical shift via two-phase cross-correlation
/// Phase 1: coarse search with stride, Phase 2: refine around best
/// Returns offset (positive = img2 shifted down relative to img1)
pub fn detect_global_shift(
  profile1 : FixedArray[Double],
  profile2 : FixedArray[Double],
  max_shift : Int,
) -> Int {
  let n = profile1.length()
  if n != profile2.length() || n == 0 {
    return 0
  }
  let limit = if max_shift < n / 4 { max_shift } else { n / 4 }
  // For small ranges, do a direct scan
  if limit <= 16 {
    let mut best_corr = -1.0
    let mut best_offset = 0
    let mut offset = -limit
    while offset <= limit {
      let corr = cross_correlate_at(profile1, 0, profile2, 0, n, n, offset)
      if corr > best_corr {
        best_corr = corr
        best_offset = offset
      }
      offset += 1
    }
    return best_offset
  }
  // Phase 1: coarse search with stride
  let stride = if limit > 64 { limit / 16 } else { 4 }
  let mut best_corr = -1.0
  let mut coarse_best = 0
  let mut offset = -limit
  while offset <= limit {
    let corr = cross_correlate_at(profile1, 0, profile2, 0, n, n, offset)
    if corr > best_corr {
      best_corr = corr
      coarse_best = offset
    }
    offset += stride
  }
  // Phase 2: refine within ±stride of coarse best
  let refine_lo = if coarse_best - stride > -limit {
    coarse_best - stride
  } else {
    -limit
  }
  let refine_hi = if coarse_best + stride < limit {
    coarse_best + stride
  } else {
    limit
  }
  let mut best_offset = coarse_best
  offset = refine_lo
  while offset <= refine_hi {
    let corr = cross_correlate_at(profile1, 0, profile2, 0, n, n, offset)
    if corr > best_corr {
      best_corr = corr
      best_offset = offset
    }
    offset += 1
  }
  best_offset
}

///|
/// Detect shift for a window slice of the profiles (no array copy)
fn detect_window_shift(
  profile1 : FixedArray[Double],
  profile2 : FixedArray[Double],
  offset : Int,
  len : Int,
  max_shift : Int,
) -> Int {
  let limit = if max_shift < len / 4 { max_shift } else { len / 4 }
  let mut best_corr = -1.0
  let mut best_offset = 0
  // For windows, limit is usually small enough for direct scan
  if limit <= 16 {
    let mut off = -limit
    while off <= limit {
      let corr = cross_correlate_at(profile1, offset, profile2, offset, len, len, off)
      if corr > best_corr {
        best_corr = corr
        best_offset = off
      }
      off += 1
    }
    return best_offset
  }
  // Two-phase for larger limits
  let stride = if limit > 64 { limit / 16 } else { 4 }
  let mut coarse_best = 0
  let mut off = -limit
  while off <= limit {
    let corr = cross_correlate_at(profile1, offset, profile2, offset, len, len, off)
    if corr > best_corr {
      best_corr = corr
      coarse_best = off
    }
    off += stride
  }
  let refine_lo = if coarse_best - stride > -limit { coarse_best - stride } else { -limit }
  let refine_hi = if coarse_best + stride < limit { coarse_best + stride } else { limit }
  best_offset = coarse_best
  off = refine_lo
  while off <= refine_hi {
    let corr = cross_correlate_at(profile1, offset, profile2, offset, len, len, off)
    if corr > best_corr {
      best_corr = corr
      best_offset = off
    }
    off += 1
  }
  best_offset
}

///|
/// Detect piecewise vertical shifts using sliding window cross-correlation
pub fn detect_piecewise_shift(
  profile1 : FixedArray[Double],
  profile2 : FixedArray[Double],
  max_shift : Int,
  window_size~ : Int = 100,
) -> Array[ShiftRegion] {
  let n = profile1.length()
  if n == 0 {
    return []
  }
  let step = window_size / 2
  let step = if step < 1 { 1 } else { step }
  let window_shifts : Array[(Int, Int, Int)] = []
  let mut y = 0
  while y < n {
    let end = if y + window_size > n { n } else { y + window_size }
    let len = end - y
    if len < 4 {
      break
    }
    let shift = detect_window_shift(profile1, profile2, y, len, max_shift)
    window_shifts.push((y, end, shift))
    y += step
  }
  if window_shifts.length() == 0 {
    return [{ y_start: 0, y_end: n, shift: 0 }]
  }
  let regions : Array[ShiftRegion] = []
  let (first_start, first_end, first_shift) = window_shifts[0]
  let mut current_start = first_start
  let mut current_end = first_end
  let mut current_shift = first_shift
  for i in 1.. Double {
  let r1 = data1[base1]
  let g1 = data1[base1 + 1]
  let b1 = data1[base1 + 2]
  let a1 = data1[base1 + 3]
  let r2 = data2[base2]
  let g2 = data2[base2 + 1]
  let b2 = data2[base2 + 2]
  let a2 = data2[base2 + 3]
  if r1 == r2 && g1 == g2 && b1 == b2 && a1 == a2 {
    return 0.0
  }
  let mut dr = (r1 - r2).to_double()
  let mut dg = (g1 - g2).to_double()
  let mut db = (b1 - b2).to_double()
  let da = (a1 - a2).to_double()
  if a1 < 255 || a2 < 255 {
    let rb = 48.0 + 159.0 * (base1 % 2).to_double()
    let gb = 48.0 + 159.0 * ((base1.to_double() / 1.618033988749895).to_int() % 2).to_double()
    let bb = 48.0 + 159.0 * ((base1.to_double() / 2.618033988749895).to_int() % 2).to_double()
    dr = (r1.to_double() * a1.to_double() - r2.to_double() * a2.to_double() - rb * da) / 255.0
    dg = (g1.to_double() * a1.to_double() - g2.to_double() * a2.to_double() - gb * da) / 255.0
    db = (b1.to_double() * a1.to_double() - b2.to_double() * a2.to_double() - bb * da) / 255.0
  }
  let y = 0.29889531 * dr + 0.58662247 * dg + 0.11448223 * db
  let i = 0.59597799 * dr - 0.27417610 * dg - 0.32180189 * db
  let q = 0.21147017 * dr - 0.52261711 * dg + 0.31114694 * db
  0.5053 * y * y + 0.299 * i * i + 0.1957 * q * q
}

///|
pub fn compensated_diff(
  img1 : Image,
  img2 : Image,
  shift_regions : Array[ShiftRegion],
  threshold : Double,
) -> Int {
  if img1.width != img2.width || img1.height != img2.height {
    abort("Image dimensions must match")
  }
  let width = img1.width
  let height = img1.height
  let max_delta = 35215.0 * threshold * threshold
  let data1 = img1.data
  let data2 = img2.data
  let mut count = 0
  for region in shift_regions {
    for y in region.y_start..= height {
        count += width
        continue
      }
      let row1 = src_y * width * 4
      let row2 = y * width * 4
      // Row prefilter: skip if rows are identical after shift
      let row_len = width * 4
      let mut row_identical = true
      for i in 0.. max_delta {
          count += 1
        }
      }
    }
  }
  count
}

///|
/// Generate a comprehensive diff report
/// Optimized: pre-allocate arrays, cache references
pub fn diff_report(
  img1 : Image,
  img2 : Image,
  options : Options,
  grid_size? : Int = 10,
) -> DiffReport {
  if img1.width != img2.width || img1.height != img2.height {
    abort("Image dimensions must match")
  }
  let width = img1.width
  let height = img1.height
  let total_pixels = width * height
  let max_delta = 35215.0 *
    options.threshold *
    options.threshold *
    options.threshold *
    options.threshold
  // Initialize grid - pre-allocate
  let grid_cols = if width < grid_size { 1 } else { grid_size }
  let grid_rows = if height < grid_size { 1 } else { grid_size }
  let cell_w = width / grid_cols
  let cell_h = height / grid_rows
  let grid : Array[Array[Int]] = Array::make(grid_rows, [])
  for i in 0.. max_delta {
        if !options.include_aa &&
          (is_antialiased(img1, img2, x, y) || is_antialiased(img2, img1, x, y)) {
          aa_count += 1
        } else {
          diff_count += 1
          diff_map[row_offset + x] = true
          // Update grid
          let gx = if cell_w > 0 { x / cell_w } else { 0 }
          let gy = if cell_h > 0 { y / cell_h } else { 0 }
          let gx = if gx >= grid_cols { grid_cols - 1 } else { gx }
          let gy = if gy >= grid_rows { grid_rows - 1 } else { gy }
          grid[gy][gx] += 1
        }
      }
    }
  }
  // Find connected regions using simple bounding box detection
  let regions = find_diff_regions_flat(diff_map, width, height)
  let match_ratio = if total_pixels > 0 {
    1.0 - diff_count.to_double() / total_pixels.to_double()
  } else {
    1.0
  }
  // Compute classification summary
  let mut content_change_count = 0
  let mut has_non_shift = false
  for region in regions {
    if region.region_type == "content" {
      content_change_count += 1
      has_non_shift = true
    } else if region.region_type == "edge" {
      has_non_shift = true
    }
  }
  let shift_only = regions.length() > 0 && !has_non_shift
  let (global_shift, shift_regions, compensated_diff_count) = if options.detect_shift &&
    height > 4 {
    let p1 = luminance_profile(img1)
    let p2 = luminance_profile(img2)
    let max_shift_val = if height / 4 < 500 { height / 4 } else { 500 }
    let gs = detect_global_shift(p1, p2, max_shift_val)
    let sr = detect_piecewise_shift(p1, p2, max_shift_val, window_size=100)
    let cd = compensated_diff(img1, img2, sr, options.threshold)
    (gs, sr, cd)
  } else {
    (0, ([] : Array[ShiftRegion]), 0)
  }
  {
    width,
    height,
    total_pixels,
    diff_count,
    aa_count,
    match_ratio,
    grid,
    grid_cols,
    grid_rows,
    regions,
    shift_only,
    content_change_count,
    global_shift,
    shift_regions,
    compensated_diff_count,
  }
}

///|
/// Classify a diff region as "shift", "content", or "edge"
fn classify_region(region : DiffRegion, image_width : Int) -> String {
  if region.height <= 2 || region.width <= 2 {
    "edge"
  } else if region.width.to_double() / region.height.to_double() > 3.0 &&
    region.width > image_width * 80 / 100 {
    "shift"
  } else {
    "content"
  }
}

///|
/// Find bounding boxes of diff regions using connected component labeling
/// Optimized: use flat arrays, check bounds before push
fn find_diff_regions_flat(
  diff_map : FixedArray[Bool],
  width : Int,
  height : Int,
) -> Array[DiffRegion] {
  let size = width * height
  let visited : FixedArray[Bool] = FixedArray::make(size, false)
  let regions : Array[DiffRegion] = []
  for y in 0.. 0 {
          let cidx = stack.pop().unwrap()
          if visited[cidx] || !diff_map[cidx] {
            continue
          }
          visited[cidx] = true
          pixel_count += 1
          let cx = cidx % width
          let cy = cidx / width
          if cx < min_x {
            min_x = cx
          }
          if cx > max_x {
            max_x = cx
          }
          if cy < min_y {
            min_y = cy
          }
          if cy > max_y {
            max_y = cy
          }
          // Add neighbors: check visited and diff_map before push to avoid
          // pushing the same pixel multiple times (up to 4x per pixel)
          if cx > 0 {
            let n = cidx - 1
            if !visited[n] && diff_map[n] {
              stack.push(n)
            }
          }
          if cx < width - 1 {
            let n = cidx + 1
            if !visited[n] && diff_map[n] {
              stack.push(n)
            }
          }
          if cy > 0 {
            let n = cidx - width
            if !visited[n] && diff_map[n] {
              stack.push(n)
            }
          }
          if cy < height - 1 {
            let n = cidx + width
            if !visited[n] && diff_map[n] {
              stack.push(n)
            }
          }
        }
        if pixel_count > 0 {
          let region : DiffRegion = {
            x: min_x,
            y: min_y,
            width: max_x - min_x + 1,
            height: max_y - min_y + 1,
            diff_pixels: pixel_count,
            region_type: "",
          }
          regions.push(
            { ..region, region_type: classify_region(region, width) },
          )
        }
      }
    }
  }
  regions
}

///|
/// Convert DiffReport to AI-readable text format
pub fn DiffReport::to_text(self : DiffReport) -> String {
  let mut s = "=== Diff Report ===\n"
  // Summary
  s += "Summary:\n"
  s += "  Image size: " +
    self.width.to_string() +
    "x" +
    self.height.to_string() +
    "\n"
  s += "  Total pixels: " + self.total_pixels.to_string() + "\n"
  s += "  Different pixels: " + self.diff_count.to_string() + "\n"
  s += "  Anti-aliased pixels: " + self.aa_count.to_string() + "\n"
  let pct = (self.match_ratio * 100.0).to_int()
  s += "  Match ratio: " + pct.to_string() + "%\n"
  // Verdict
  s += "\nVerdict: "
  if self.diff_count == 0 {
    s += "IDENTICAL\n"
  } else if self.match_ratio > 0.99 {
    s += "NEARLY_IDENTICAL (minor differences)\n"
  } else if self.match_ratio > 0.95 {
    s += "SIMILAR (small differences)\n"
  } else if self.match_ratio > 0.8 {
    s += "DIFFERENT (moderate differences)\n"
  } else {
    s += "VERY_DIFFERENT (major differences)\n"
  }
  // Grid heatmap
  s += "\nHeatmap (" +
    self.grid_cols.to_string() +
    "x" +
    self.grid_rows.to_string() +
    " grid):\n"
  // Find max for normalization
  let mut max_val = 1
  for row in self.grid {
    for val in row {
      if val > max_val {
        max_val = val
      }
    }
  }
  // Header
  s += "  "
  for i in 0.. 0 {
    s += "\nDiff Regions (" + self.regions.length().to_string() + "):\n"
    for i, region in self.regions {
      s += "  [" + i.to_string() + "] "
      s += "type=" + region.region_type + " "
      s += "pos=(" + region.x.to_string() + "," + region.y.to_string() + ") "
      s += "size=" +
        region.width.to_string() +
        "x" +
        region.height.to_string() +
        " "
      s += "pixels=" + region.diff_pixels.to_string() + "\n"
    }
  }
  // Classification
  s += "\nClassification:\n"
  let shift_only_str = if self.shift_only { "true" } else { "false" }
  s += "  Shift only: " + shift_only_str + "\n"
  s += "  Content changes: " + self.content_change_count.to_string() + "\n"
  if self.global_shift != 0 || self.shift_regions.length() > 0 {
    s += "\nShift Analysis:\n"
    s += "  Global shift: " + self.global_shift.to_string() + "px\n"
    s += "  Compensated diff: " + self.compensated_diff_count.to_string() + " pixels\n"
    if self.shift_regions.length() > 0 {
      s += "  Shift regions:\n"
      for sr in self.shift_regions {
        s += "    y=[" + sr.y_start.to_string() + ".." + sr.y_end.to_string() + "] shift=" + sr.shift.to_string() + "px\n"
      }
    }
  }
  s
}

///|
/// Convert DiffReport to compact AI format
/// Minimal tokens, maximum spatial information
pub fn DiffReport::to_compact(self : DiffReport) -> String {
  let mut s = ""
  // One-line summary
  let pct = (self.match_ratio * 100.0).to_int()
  s += "diff:" +
    self.diff_count.to_string() +
    "/" +
    self.total_pixels.to_string() +
    "(" +
    pct.to_string() +
    "%match)\n"
  // Binary heatmap - single chars, no spaces
  for row in self.grid {
    for val in row {
      s += if val == 0 { "." } else { "X" }
    }
    s += "\n"
  }
  // Compact regions: type:x,y,w,h format
  if self.regions.length() > 0 {
    s += "regions:"
    for i, r in self.regions {
      if i > 0 {
        s += ";"
      }
      s += r.region_type +
        ":" +
        r.x.to_string() +
        "," +
        r.y.to_string() +
        "," +
        r.width.to_string() +
        "x" +
        r.height.to_string()
    }
    s += "\n"
  }
  s
}

///|
/// Analyze grid pattern to detect shape hints
fn detect_shape_hints(
  grid : Array[Array[Int]],
  regions : Array[DiffRegion],
) -> Array[String] {
  let hints : Array[String] = []
  let rows = grid.length()
  let cols = if rows > 0 { grid[0].length() } else { 0 }
  if rows == 0 || cols == 0 {
    return hints
  }
  // Check for hole (ring/donut): diff on edges but gap in middle
  // Check inner gap (center area has significantly less diff than edges)
  // Use smaller center region (middle 20%) for better hole detection
  let center_start_r = rows * 2 / 5
  let center_end_r = rows * 3 / 5
  let center_start_c = cols * 2 / 5
  let center_end_c = cols * 3 / 5
  let mut center_weight = 0
  let mut edge_weight = 0
  let mut total_weight = 0
  for r in 0..= center_start_r &&
        r < center_end_r &&
        c >= center_start_c &&
        c < center_end_c {
        center_weight += v
      } else {
        edge_weight += v
      }
    }
  }
  // If edges have significantly more diff than center, likely has a hole
  // Use ratio < 0.2 for smaller center region
  let has_hole = edge_weight > 0 &&
    center_weight.to_double() / total_weight.to_double() < 0.05
  if has_hole && regions.length() == 1 {
    hints.push("HAS_HOLE: shape may have empty center (ring/donut/frame)")
  }
  // Check for border/frame pattern
  let mut is_border = true
  let rows_inner = rows - 1
  let cols_inner = cols - 1
  for r in 1.. 0 {
        // Check if it's only on edges
        let on_edge = r < 2 || r >= rows - 2 || c < 2 || c >= cols - 2
        if !on_edge {
          is_border = false
        }
      }
    }
  }
  if is_border && (grid[0][0] > 0 || grid[0][cols - 1] > 0) {
    hints.push("IS_BORDER: changes only on edges (frame pattern)")
  }
  // Check for directional shape (asymmetric)
  let mut top_weight = 0
  let mut bottom_weight = 0
  let mut left_weight = 0
  let mut right_weight = 0
  for r in 0.. 0 {
    top_weight.to_double() / bottom_weight.to_double()
  } else {
    0.0
  }
  let horizontal_ratio = if right_weight > 0 {
    left_weight.to_double() / right_weight.to_double()
  } else {
    0.0
  }
  // Only add directional hints if there's a significant imbalance
  // Use 1.08 threshold (8% asymmetry), but only show the most prominent direction
  // Prefer vertical direction when asymmetries are equal (arrows typically point up/down)
  let v_asymmetry = if vertical_ratio > 1.0 {
    vertical_ratio
  } else {
    1.0 / vertical_ratio
  }
  let h_asymmetry = if horizontal_ratio > 1.0 {
    horizontal_ratio
  } else {
    1.0 / horizontal_ratio
  }
  // Only show directional hint for the dominant axis, and only if significant
  if v_asymmetry >= 1.08 &&
    v_asymmetry >= h_asymmetry &&
    top_weight + bottom_weight > 0 {
    let dir = if vertical_ratio >= 1.08 {
      "top-heavy (pointing up?)"
    } else {
      "bottom-heavy (pointing down?)"
    }
    hints.push("DIRECTIONAL: " + dir)
  } else if h_asymmetry >= 1.08 && left_weight + right_weight > 0 {
    let dir = if horizontal_ratio >= 1.08 {
      "left-heavy (pointing left?)"
    } else {
      "right-heavy (pointing right?)"
    }
    hints.push("DIRECTIONAL: " + dir)
  }
  // Check for multiple separate regions
  if regions.length() > 3 {
    hints.push(
      "MULTI_REGION: " +
      regions.length().to_string() +
      " separate areas (scattered or pattern)",
    )
  }
  // Check for repeating pattern (checkerboard-like)
  if regions.length() > 5 {
    let first = regions[0]
    let mut same_size = true
    for i in 1.. 2 ||
        (r.height - first.height).abs() > 2 {
        same_size = false
      }
    }
    if same_size {
      hints.push("REPEATING: similar-sized regions (grid/checkerboard pattern)")
    }
  }
  hints
}

///|
/// Convert DiffReport to compact format with shape hints
/// Adds contextual hints to help AI interpretation
pub fn DiffReport::to_compact_with_hints(self : DiffReport) -> String {
  let mut s = self.to_compact()
  let hints = detect_shape_hints(self.grid, self.regions)
  if hints.length() > 0 {
    s += "hints:"
    for i, hint in hints {
      if i > 0 {
        s += ";"
      }
      s += hint
    }
    s += "\n"
  }
  s
}

///|
/// Convert DiffReport to JSON string
pub fn DiffReport::to_json(self : DiffReport) -> String {
  let mut s = "{\n"
  s += "  \"width\": " + self.width.to_string() + ",\n"
  s += "  \"height\": " + self.height.to_string() + ",\n"
  s += "  \"total_pixels\": " + self.total_pixels.to_string() + ",\n"
  s += "  \"diff_count\": " + self.diff_count.to_string() + ",\n"
  s += "  \"aa_count\": " + self.aa_count.to_string() + ",\n"
  s += "  \"match_ratio\": " + self.match_ratio.to_string() + ",\n"
  // Grid
  s += "  \"grid\": [\n"
  for row_idx, row in self.grid {
    s += "    ["
    for col_idx, val in row {
      s += val.to_string()
      if col_idx < row.length() - 1 {
        s += ", "
      }
    }
    s += "]"
    if row_idx < self.grid.length() - 1 {
      s += ","
    }
    s += "\n"
  }
  s += "  ],\n"
  // Regions
  s += "  \"regions\": [\n"
  for i, region in self.regions {
    s += "    {\"x\": " + region.x.to_string()
    s += ", \"y\": " + region.y.to_string()
    s += ", \"width\": " + region.width.to_string()
    s += ", \"height\": " + region.height.to_string()
    s += ", \"diff_pixels\": " + region.diff_pixels.to_string()
    s += ", \"region_type\": \"" + region.region_type + "\"}"
    if i < self.regions.length() - 1 {
      s += ","
    }
    s += "\n"
  }
  s += "  ],\n"
  let shift_only_str = if self.shift_only { "true" } else { "false" }
  s += "  \"shift_only\": " + shift_only_str + ",\n"
  s += "  \"content_change_count\": " +
    self.content_change_count.to_string() +
    ",\n"
  s += "  \"global_shift\": " + self.global_shift.to_string() + ",\n"
  s += "  \"compensated_diff_count\": " + self.compensated_diff_count.to_string() + ",\n"
  s += "  \"shift_regions\": [\n"
  for i, sr in self.shift_regions {
    s += "    {\"y_start\": " + sr.y_start.to_string()
    s += ", \"y_end\": " + sr.y_end.to_string()
    s += ", \"shift\": " + sr.shift.to_string() + "}"
    if i < self.shift_regions.length() - 1 {
      s += ","
    }
    s += "\n"
  }
  s += "  ]\n"
  s += "}"
  s
}