// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0

// Making an image a different size.
//
// Three filters, because the three answer different questions. Nearest is what
// a pixel artist wants: a pixel becomes a square, and no colour that was not in
// the original appears. Bilinear is what a photograph wants when it is being
// scaled a little: cheap, and smooth enough that the result does not crawl.
// Lanczos is what it wants when it is being scaled a lot or viewed still: six
// taps per axis, sharp edges, and a small amount of ringing that is the price of
// the sharpness.
//
// Alpha is resampled premultiplied even when the image is not stored that way.
// A transparent pixel usually has an arbitrary colour behind its alpha, and
// averaging that colour into its neighbours is how a sprite gets a dark fringe.

///|
/// How a scaler decides what an unknown pixel is worth.
pub(all) enum Sampling {
  /// The nearest pixel, and nothing else: no new colours.
  Nearest
  /// A weighted average of the four nearest, weights linear in distance.
  Bilinear
  /// A weighted average of the six nearest per axis, weights from the Lanczos
  /// window with a = 3.
  Lanczos
} derive(Eq, Debug)

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

///|
pub extend Sampling with Debug::{to_repr}

///|
/// The image at a new size.
///
/// Raises when either dimension is not positive, and when the result would be
/// larger than `Limits::default()` — a scaler is easy to point at a huge size by
/// accident.
pub fn Image::scale(
  self : Image,
  width : Int32,
  height : Int32,
  sampling? : Sampling,
) -> Image raise Refused {
  if width <= 0 || height <= 0 {
    raise Unexpected(
      at=0,
      got="a size with no pixels",
      want="a width and a height above zero",
    )
  }
  Limits::default().check(width, height)
  let sampling = sampling.unwrap_or(Bilinear)
  if width == self.width && height == self.height {
    return self
  }
  match sampling {
    Nearest => self.scale_nearest(width, height)
    _ => self.scale_weighted(width, height, sampling)
  }
}

///|
/// Nearest neighbour, copied byte by byte: no arithmetic, so a palette stays a
/// palette.
fn Image::scale_nearest(self : Image, width : Int32, height : Int32) -> Image {
  let pixel = self.format.bytes()
  let stride = width * pixel
  let buffer : FixedArray[Byte] = FixedArray::make(stride * height, b'\x00')
  for y in 0.. Image {
  let source = self.working_copy()
  let across = resample(source, self.width, self.height, width, true, sampling)
  let down = resample(across, width, self.height, height, false, sampling)
  let pixel = self.format.bytes()
  let stride = width * pixel
  let buffer : FixedArray[Byte] = FixedArray::make(stride * height, b'\x00')
  for y in 0.. FixedArray[Double] {
  let pixels : FixedArray[Double] = FixedArray::make(
    self.width * self.height * 4,
    0.0,
  )
  let bpp = self.format.bytes()
  let multiplied = self.premultiplied || !self.format.has_alpha()
  for y in 0.. FixedArray[Double] {
  let out_width = if horizontal { length } else { width }
  let out_height = if horizontal { height } else { length }
  let taps = axis_weights(
    if horizontal {
      width
    } else {
      height
    },
    length,
    sampling,
  )
  let pixels : FixedArray[Double] = FixedArray::make(
    out_width * out_height * 4,
    0.0,
  )
  for y in 0.. Double {
  if value < 0.0 {
    0.0
  } else if value > 1.0 {
    1.0
  } else {
    value
  }
}

///|
/// The taps each output position reads, and how much of each.
///
/// Positions that fall outside the image are clamped to its edge, and the weights
/// are renormalised afterwards: a filter that sums to less than one at the
/// border darkens it, which is the visible edge artefact of a naive scaler.
fn axis_weights(
  source : Int32,
  target : Int32,
  sampling : Sampling,
) -> Array[Array[(Int32, Double)]] {
  let ratio = source.to_double() / target.to_double()
  let taps : Array[Array[(Int32, Double)]] = []
  for i in 0.. Unit {
  let clamped = if index < 0 {
    0
  } else if index >= source {
    source - 1
  } else {
    index
  }
  taps.push((clamped, weight))
}

///|
fn sum_weights(taps : Array[(Int32, Double)]) -> Double {
  let mut total = 0.0
  for tap in taps {
    total = total + tap.1
  }
  total
}

///|
/// The Lanczos window with a = 3, in one dimension.
fn lanczos(x : Double) -> Double {
  let distance = if x < 0.0 { -x } else { x }
  if distance < 1.0e-9 {
    1.0
  } else if distance >= 3.0 {
    0.0
  } else {
    let pi = 3.141592653589793
    let px = pi * distance
    3.0 * @math.sin(px) * @math.sin(px / 3.0) / (px * px)
  }
}