///|
/// Apply a per-channel window reducer over the RGB channels (alpha copied).
/// Uses a preallocated buffer to avoid per-pixel allocation.
fn window_map(img : Image, size : Int, reduce : (Array[Byte]) -> Byte) -> Image {
  let r = size / 2
  let h = img.h
  let w = img.w
  let stride = w * 4
  let out = Image::new(h, w)
  let hmax = h - 1
  let wmax = w - 1
  let win_n = (2 * r + 1) * (2 * r + 1)
  let buf = Array::make(win_n, (0 : Byte))
  for y = 0; y < h; y = y + 1 {
    let out_base = y * stride
    for x = 0; x < w; x = x + 1 {
      let oo = out_base + x * 4
      for c = 0; c < 3; c = c + 1 {
        let mut bi = 0
        for dy = -r; dy <= r; dy = dy + 1 {
          let cy = y + dy
          let clamped_y = if cy < 0 { 0 } else if cy > hmax { hmax } else { cy }
          let row_base = clamped_y * stride
          for dx = -r; dx <= r; dx = dx + 1 {
            let cx = x + dx
            let clamped_x = if cx < 0 {
              0
            } else if cx > wmax {
              wmax
            } else {
              cx
            }
            buf[bi] = img.data[row_base + clamped_x * 4 + c]
            bi = bi + 1
          }
        }
        out.data[oo + c] = reduce(buf)
      }
      out.data[oo + 3] = img.data[oo + 3]
    }
  }
  out
}

///|
/// Median filter with an odd window `size`. Reduces salt-and-pepper noise
/// while preserving edges. Uses replicate borders; alpha is copied unchanged.
pub fn median_filter(img : Image, size : Int) -> Image {
  window_map(img, size, fn(vals) {
    vals.sort()
    vals[vals.length() / 2]
  })
}

///|
/// Maximum (dilation-like) filter with an odd window `size`. Each output
/// pixel is the max over a `(2r+1)²` window with replicate borders; alpha
/// is copied unchanged.
pub fn max_filter(img : Image, size : Int) -> Image {
  window_map(img, size, fn(vals) {
    let mut m : Byte = 0
    for i = 0; i < vals.length(); i = i + 1 {
      if vals[i] > m {
        m = vals[i]
      }
    }
    m
  })
}

///|
/// Minimum (erosion-like) filter with an odd window `size`. Each output
/// pixel is the min over a `(2r+1)²` window with replicate borders; alpha
/// is copied unchanged.
pub fn min_filter(img : Image, size : Int) -> Image {
  window_map(img, size, fn(vals) {
    let mut m : Byte = 255
    for i = 0; i < vals.length(); i = i + 1 {
      if vals[i] < m {
        m = vals[i]
      }
    }
    m
  })
}

///|
/// Laplacian-based sharpening. `strength` scales the high-frequency boost;
/// `0.0` returns the input unchanged.
pub fn sharpen(img : Image, strength : Double) -> Image {
  let s = strength
  let kernel = [[0.0, -s, 0.0], [-s, 1.0 + 4.0 * s, -s], [0.0, -s, 0.0]]
  convolve(img, kernel, false)
}

///|
/// Unsharp masking: add a scaled high-pass component above a threshold.
///
/// - `radius`: Gaussian blur radius used to derive the low-pass component.
/// - `amount`: scaling factor applied to the high-pass residual.
/// - `threshold`: only pixels whose `|orig - blurred|` ≥ `threshold` are
///   boosted; smaller differences are passed through unchanged.
pub fn unsharp_mask(
  img : Image,
  radius : Double,
  amount : Double,
  threshold : Byte,
) -> Image {
  let blurred = gaussian_blur(img, radius)
  let t = threshold.to_int()
  let out = Image::new(img.h, img.w)
  let n = img.h * img.w
  for i = 0; i < n; i = i + 1 {
    let o = i * 4
    for c = 0; c < 3; c = c + 1 {
      let orig = img.data[o + c].to_int()
      let diff = orig - blurred.data[o + c].to_int()
      let d = if diff < 0 { -diff } else { diff }
      out.data[o + c] = if d >= t {
        round_byte(orig.to_double() + amount * diff.to_double())
      } else {
        img.data[o + c]
      }
    }
    out.data[o + 3] = img.data[o + 3]
  }
  out
}