///|
/// Apply a per-pixel RGBA transform, producing a new same-size image.
fn map_rgba(
  img : Image,
  f : (Byte, Byte, Byte, Byte) -> (Byte, Byte, Byte, Byte),
) -> Image {
  let data = Array::make(img.data.length(), (0 : Byte))
  let n = img.h * img.w
  for i = 0; i < n; i = i + 1 {
    let o = i * 4
    let (r, g, b, a) = f(
      img.data[o],
      img.data[o + 1],
      img.data[o + 2],
      img.data[o + 3],
    )
    data[o] = r
    data[o + 1] = g
    data[o + 2] = b
    data[o + 3] = a
  }
  { data, h: img.h, w: img.w }
}

///|
/// ITU-R BT.601 grayscale: `(R*77 + G*150 + B*29) >> 8`, alpha preserved.
pub fn to_grayscale(img : Image) -> Image {
  map_rgba(img, fn(r, g, b, a) {
    let y = (r.to_int() * 77 + g.to_int() * 150 + b.to_int() * 29) >> 8
    let yb = clamp_byte(y)
    (yb, yb, yb, a)
  })
}

///|
/// Grayscale with custom linear weights, alpha preserved.
///
/// - `r_w`, `g_w`, `b_w`: per-channel weights (typically summing to 1.0).
pub fn to_grayscale_weighted(
  img : Image,
  r_w : Double,
  g_w : Double,
  b_w : Double,
) -> Image {
  map_rgba(img, fn(r, g, b, a) {
    let y = r_w * r.to_double() + g_w * g.to_double() + b_w * b.to_double()
    let yb = round_byte(y)
    (yb, yb, yb, a)
  })
}

///|
/// Swap the red and blue channels.
pub fn to_bgr(img : Image) -> Image {
  map_rgba(img, fn(r, g, b, a) { (b, g, r, a) })
}

///|
/// Invert the RGB channels (`255 - value`), alpha preserved.
pub fn invert(img : Image) -> Image {
  map_rgba(img, fn(r, g, b, a) {
    (
      clamp_byte(255 - r.to_int()),
      clamp_byte(255 - g.to_int()),
      clamp_byte(255 - b.to_int()),
      a,
    )
  })
}

///|
/// Multiplicative color tint: each channel scaled by `color / 255`.
///
/// - `r`, `g`, `b`: tint color; `(255, 255, 255)` leaves the image unchanged.
pub fn tint(img : Image, r : Byte, g : Byte, b : Byte) -> Image {
  map_rgba(img, fn(pr, pg, pb, a) {
    (
      clamp_byte(pr.to_int() * r.to_int() / 255),
      clamp_byte(pg.to_int() * g.to_int() / 255),
      clamp_byte(pb.to_int() * b.to_int() / 255),
      a,
    )
  })
}

///|
/// Composite the image over a solid background color, dropping transparency.
///
/// - `r`, `g`, `b`: the solid background color; output alpha is forced to 255.
pub fn flatten_alpha(img : Image, r : Byte, g : Byte, b : Byte) -> Image {
  map_rgba(img, fn(pr, pg, pb, a) {
    let alpha = a.to_double() / 255.0
    (
      round_byte(pr.to_double() * alpha + r.to_double() * (1.0 - alpha)),
      round_byte(pg.to_double() * alpha + g.to_double() * (1.0 - alpha)),
      round_byte(pb.to_double() * alpha + b.to_double() * (1.0 - alpha)),
      255,
    )
  })
}

///|
/// Alpha "over" compositing of `src` onto `dst`; result matches `dst` size.
/// Source pixels outside its bounds are treated as fully transparent.
pub fn composite_over(src : Image, dst : Image) -> Image {
  let out = Image::new(dst.h, dst.w)
  for y = 0; y < dst.h; y = y + 1 {
    for x = 0; x < dst.w; x = x + 1 {
      let doff = dst.offset(y, x)
      let da = dst.data[doff + 3].to_double() / 255.0
      let (sr, sg, sb, sa) = match src.pixel_at(y, x) {
        Some(p) => p
        None => ((0 : Byte), (0 : Byte), (0 : Byte), (0 : Byte))
      }
      let saf = sa.to_double() / 255.0
      let oa = saf + da * (1.0 - saf)
      let ooff = out.offset(y, x)
      if oa <= 0.0 {
        out.data[ooff + 3] = 0
      } else {
        let mix = fn(sc : Byte, dc : Byte) -> Byte {
          let v = (sc.to_double() * saf + dc.to_double() * da * (1.0 - saf)) /
            oa
          round_byte(v)
        }
        out.data[ooff] = mix(sr, dst.data[doff])
        out.data[ooff + 1] = mix(sg, dst.data[doff + 1])
        out.data[ooff + 2] = mix(sb, dst.data[doff + 2])
        out.data[ooff + 3] = round_byte(oa * 255.0)
      }
    }
  }
  out
}

///|
/// Convert a grayscale image (R==G==B) to RGB by forcing alpha to 255.
pub fn grayscale_to_rgb(img : Image) -> Image {
  map_rgba(img, fn(r, g, b, _a) { (r, g, b, 255) })
}

///|
/// Convert a grayscale image to RGBA with fully opaque alpha.
pub fn grayscale_to_rgba(img : Image) -> Image {
  map_rgba(img, fn(r, g, b, _a) { (r, g, b, 255) })
}

///|
/// Drop the alpha channel, returning an RGB-only image (A=255 forced).
pub fn to_rgb(img : Image) -> Image {
  map_rgba(img, fn(r, g, b, _a) { (r, g, b, 255) })
}

///|
/// Ensure the image has an alpha channel (A=255 if missing).
/// Since all millow images are RGBA8, this is a no-op that forces A=255.
pub fn to_rgba(img : Image) -> Image {
  map_rgba(img, fn(r, g, b, _a) { (r, g, b, 255) })
}

///|
/// RGB → HSV. Returns three `h × w` arrays: H in `[0, 360)`, S in `[0, 1]`,
/// V in `[0, 1]`.
pub fn to_hsv(
  img : Image,
) -> (Array[Array[Double]], Array[Array[Double]], Array[Array[Double]]) {
  let hh = Array::makei(img.h, fn(_i) { Array::make(img.w, 0.0) })
  let ss = Array::makei(img.h, fn(_i) { Array::make(img.w, 0.0) })
  let vv = Array::makei(img.h, fn(_i) { Array::make(img.w, 0.0) })
  for y = 0; y < img.h; y = y + 1 {
    for x = 0; x < img.w; x = x + 1 {
      let o = img.offset(y, x)
      let ri = img.data[o].to_double() / 255.0
      let gi = img.data[o + 1].to_double() / 255.0
      let bi = img.data[o + 2].to_double() / 255.0
      let mx = ri.max(gi).max(bi)
      let mn = ri.min(gi).min(bi)
      let delta = mx - mn
      let v = mx
      let s = if mx <= 0.0 { 0.0 } else { delta / mx }
      let h = if delta <= 0.0 {
        0.0
      } else if mx == ri {
        60.0 * ((gi - bi) / delta % 6.0)
      } else if mx == gi {
        60.0 * ((bi - ri) / delta + 2.0)
      } else {
        60.0 * ((ri - gi) / delta + 4.0)
      }
      let h_clamped = if h < 0.0 { h + 360.0 } else { h }
      hh[y][x] = h_clamped
      ss[y][x] = s
      vv[y][x] = v
    }
  }
  (hh, ss, vv)
}

///|
/// HSV → RGBA8. H in `[0, 360)`, S in `[0, 1]`, V in `[0, 1]`.
///
/// - `h`, `s`, `v`: equally-shaped `rows × cols` arrays; output alpha is 255.
pub fn from_hsv(
  h : Array[Array[Double]],
  s : Array[Array[Double]],
  v : Array[Array[Double]],
) -> Image {
  let rows = h.length()
  let cols = if rows == 0 { 0 } else { h[0].length() }
  let img = Image::new(rows, cols)
  for y = 0; y < rows; y = y + 1 {
    for x = 0; x < cols; x = x + 1 {
      let hi = h[y][x]
      let si = s[y][x]
      let vi = v[y][x]
      let c = vi * si
      let hp = hi / 60.0
      let x_val = c * (1.0 - (hp % 2.0 - 1.0).abs())
      let (r1, g1, b1) = if hp < 1.0 {
        (c, x_val, 0.0)
      } else if hp < 2.0 {
        (x_val, c, 0.0)
      } else if hp < 3.0 {
        (0.0, c, x_val)
      } else if hp < 4.0 {
        (0.0, x_val, c)
      } else if hp < 5.0 {
        (x_val, 0.0, c)
      } else {
        (c, 0.0, x_val)
      }
      let m = vi - c
      let o = img.offset(y, x)
      img.data[o] = round_byte((r1 + m) * 255.0)
      img.data[o + 1] = round_byte((g1 + m) * 255.0)
      img.data[o + 2] = round_byte((b1 + m) * 255.0)
      img.data[o + 3] = 255
    }
  }
  img
}

///|
/// RGB → YCbCr (ITU-R BT.601). Returns three `h × w` arrays `(Y, Cb, Cr)`.
/// `Y` is in `[0, 255]`; `Cb` and `Cr` are centred at 128.
pub fn to_ycbcr(
  img : Image,
) -> (Array[Array[Double]], Array[Array[Double]], Array[Array[Double]]) {
  let yy = Array::makei(img.h, fn(_i) { Array::make(img.w, 0.0) })
  let cb = Array::makei(img.h, fn(_i) { Array::make(img.w, 0.0) })
  let cr = Array::makei(img.h, fn(_i) { Array::make(img.w, 0.0) })
  for y = 0; y < img.h; y = y + 1 {
    for x = 0; x < img.w; x = x + 1 {
      let o = img.offset(y, x)
      let ri = img.data[o].to_double()
      let gi = img.data[o + 1].to_double()
      let bi = img.data[o + 2].to_double()
      yy[y][x] = 0.299 * ri + 0.587 * gi + 0.114 * bi
      cb[y][x] = -0.168736 * ri - 0.331264 * gi + 0.5 * bi + 128.0
      cr[y][x] = 0.5 * ri - 0.418688 * gi - 0.081312 * bi + 128.0
    }
  }
  (yy, cb, cr)
}

///|
/// YCbCr → RGBA8. Expects `Y` in `[0, 255]`, `Cb`/`Cr` centred at 128.
/// Output alpha is 255.
pub fn from_ycbcr(
  y_arr : Array[Array[Double]],
  cb_arr : Array[Array[Double]],
  cr_arr : Array[Array[Double]],
) -> Image {
  let rows = y_arr.length()
  let cols = if rows == 0 { 0 } else { y_arr[0].length() }
  let img = Image::new(rows, cols)
  for y = 0; y < rows; y = y + 1 {
    for x = 0; x < cols; x = x + 1 {
      let yi = y_arr[y][x]
      let cbi = cb_arr[y][x] - 128.0
      let cri = cr_arr[y][x] - 128.0
      let o = img.offset(y, x)
      img.data[o] = round_byte(yi + 1.402 * cri)
      img.data[o + 1] = round_byte(yi - 0.344136 * cbi - 0.714136 * cri)
      img.data[o + 2] = round_byte(yi + 1.772 * cbi)
      img.data[o + 3] = 255
    }
  }
  img
}

///|
/// Composite a solid color onto `img` using `mask` as the alpha source.
/// Where `mask` is bright, the color is applied more strongly.
///
/// - `mask`: its red channel (normalized to `[0, 1]`) is used as the blend weight.
/// - `r`, `g`, `b`: the color to composite.
pub fn composite_color(
  img : Image,
  mask : Image,
  r : Byte,
  g : Byte,
  b : Byte,
) -> Image {
  let out = img.clone()
  for y = 0; y < out.h; y = y + 1 {
    for x = 0; x < out.w; x = x + 1 {
      let ma = match mask.pixel_at(y, x) {
        Some((mr, _mg, _mb, _ma)) => mr.to_double() / 255.0
        None => 0.0
      }
      if ma <= 0.0 {
        continue
      }
      let o = out.offset(y, x)
      let inv = 1.0 - ma
      out.data[o] = round_byte(
        out.data[o].to_double() * inv + r.to_double() * ma,
      )
      out.data[o + 1] = round_byte(
        out.data[o + 1].to_double() * inv + g.to_double() * ma,
      )
      out.data[o + 2] = round_byte(
        out.data[o + 2].to_double() * inv + b.to_double() * ma,
      )
    }
  }
  out
}