///|
/// Sampling policies for contact sheets and preview generation.
pub(all) enum AnimationSamplingPolicy {
  EveryFrame
  Evenly(count~ : Int)
  Keyframes(threshold~ : Int)
  FirstAndLast
} derive(Eq, Debug)

///|
pub(all) struct ContactSheetOptions {
  columns : Int
  cell_width : Int
  cell_height : Int
  gap : Int
  background : Rgba
  labels : Bool
} derive(Debug)

///|
pub fn ContactSheetOptions::default() -> ContactSheetOptions {
  {
    columns: 4,
    cell_width: 160,
    cell_height: 120,
    gap: 8,
    background: rgb(32, 32, 36),
    labels: false,
  }
}

///|
pub fn ContactSheetOptions::columns(
  self : ContactSheetOptions,
  value : Int,
) -> ContactSheetOptions {
  { ..self, columns: if value < 1 { 1 } else { value } }
}

///|
pub fn ContactSheetOptions::cell(
  self : ContactSheetOptions,
  width : Int,
  height : Int,
) -> ContactSheetOptions {
  {
    ..self,
    cell_width: if width < 1 {
      1
    } else {
      width
    },
    cell_height: if height < 1 {
      1
    } else {
      height
    },
  }
}

///|
pub fn ContactSheetOptions::gap(
  self : ContactSheetOptions,
  value : Int,
) -> ContactSheetOptions {
  { ..self, gap: if value < 0 { 0 } else { value } }
}

///|
pub fn ContactSheetOptions::background(
  self : ContactSheetOptions,
  color : Rgba,
) -> ContactSheetOptions {
  { ..self, background: color }
}

///|
pub fn ContactSheetOptions::with_labels(
  self : ContactSheetOptions,
  enabled : Bool,
) -> ContactSheetOptions {
  { ..self, labels: enabled }
}

///|
fn sampled_indices(
  animation : Animation,
  policy : AnimationSamplingPolicy,
) -> Array[Int] {
  let result : Array[Int] = []
  let count = animation.frames.length()
  match policy {
    EveryFrame =>
      for i in 0..
      if count > 0 && count > 0 {
        let safe_count = if count < 1 { 1 } else { count }
        for i in 0.. {
      for index in animation.keyframe_indices(threshold~) {
        result.push(index)
      }
      if result.length() == 0 && count > 0 {
        result.push(0)
      }
    }
    FirstAndLast => {
      if count > 0 {
        result.push(0)
      }
      if count > 1 {
        result.push(count - 1)
      }
    }
  }
  result
}

///|
/// Extract a deterministic preview animation according to a sampling policy.
pub fn Animation::sample_with_policy(
  self : Animation,
  policy : AnimationSamplingPolicy,
) -> Animation {
  let frames : Array[Frame] = []
  for index in sampled_indices(self, policy) {
    frames.push(self.frames[index])
  }
  Animation::{ ..self, frames, }
}

///|
/// Create a tiled contact sheet for review, documentation, and CI artifacts.
pub fn Animation::contact_sheet(
  self : Animation,
  options? : ContactSheetOptions = ContactSheetOptions::default(),
  policy? : AnimationSamplingPolicy = EveryFrame,
) -> Canvas {
  let indices = sampled_indices(self, policy)
  let columns = if options.columns < 1 { 1 } else { options.columns }
  let rows = if indices.length() == 0 {
    1
  } else {
    (indices.length() + columns - 1) / columns
  }
  let width = columns * options.cell_width + (columns + 1) * options.gap
  let height = rows * options.cell_height + (rows + 1) * options.gap
  let sheet = Canvas::new(width~, height~, fill=options.background)
  for position, index in indices {
    let frame = self.frames[index]
    let preview = frame
      .canvas()
      .contain(
        width=options.cell_width,
        height=options.cell_height,
        fill=options.background,
      )
    let column = position % columns
    let row = position / columns
    let left = options.gap + column * (options.cell_width + options.gap)
    let top = options.gap + row * (options.cell_height + options.gap)
    ignore(sheet.blit(preview, destination_x=left, destination_y=top))
    if options.labels {
      let marker = frame_marker(index, options.cell_width, options.cell_height)
      ignore(sheet.blit(marker, destination_x=left, destination_y=top))
    }
  }
  sheet
}

///|
fn frame_marker(index : Int, width : Int, height : Int) -> Canvas {
  let marker = Canvas::new(width~, height~, fill=rgba(0, 0, 0, 0))
  let shade = (index * 37 % 180 + 50).to_byte()
  ignore(
    marker.draw_rect(
      x=2,
      y=2,
      width=width - 4,
      height=height - 4,
      color=rgba(shade, shade, shade, 80),
    ),
  )
  marker
}

///|
/// Produce thumbnails in one horizontal strip, preserving frame order.
pub fn Animation::thumbnail_strip(
  self : Animation,
  max_edge : Int,
  gap? : Int = 4,
) -> Canvas {
  let safe_gap = if gap < 0 { 0 } else { gap }
  let thumbnails : Array[Canvas] = []
  let mut width = 0
  let mut height = 0
  for frame in self.frames {
    let thumbnail = frame.canvas().thumbnail(max_edge)
    thumbnails.push(thumbnail)
    width += thumbnail.width + safe_gap
    if thumbnail.height > height {
      height = thumbnail.height
    }
  }
  let result = Canvas::new(
    width=if width > 0 { width - safe_gap } else { 0 },
    height~,
    fill=rgba(0, 0, 0, 0),
  )
  let mut left = 0
  for thumbnail in thumbnails {
    ignore(result.blit(thumbnail, destination_x=left, destination_y=0))
    left += thumbnail.width + safe_gap
  }
  result
}

///|
/// Build a heatmap of changes between two equally sized canvases.
pub fn difference_heatmap(left : Canvas, right : Canvas) -> Canvas {
  let width = if left.width < right.width { left.width } else { right.width }
  let height = if left.height < right.height {
    left.height
  } else {
    right.height
  }
  let result = Canvas::new(width~, height~, fill=rgba(0, 0, 0, 0))
  for y in 0.. 255 { 255 } else { difference }
      let blue = if difference > 255 { 255 } else { 255 - difference }
      result.pixels[x + y * width] = rgba(red.to_byte(), 0, blue.to_byte(), 255)
    }
  }
  result
}

///|
/// Generate one difference heatmap per adjacent frame pair.
pub fn Animation::difference_frames(self : Animation) -> Array[Canvas] {
  let result : Array[Canvas] = []
  if self.frames.length() > 1 {
    for i in 0..<(self.frames.length() - 1) {
      result.push(
        difference_heatmap(self.frames[i].canvas(), self.frames[i + 1].canvas()),
      )
    }
  }
  result
}

///|
/// Return cumulative timeline positions for storyboard labels and scrubbing.
pub fn Animation::timecodes(self : Animation) -> Array[Int] {
  let result : Array[Int] = []
  let mut elapsed = 0
  for frame in self.frames {
    result.push(elapsed)
    elapsed += frame.delay_cs()
  }
  result
}

///|
/// Return frames whose timeline position is closest to each requested time.
pub fn Animation::frames_at_times(
  self : Animation,
  times : ArrayView[Int],
) -> Array[Frame] {
  let result : Array[Frame] = []
  for time in times {
    match self.frame_at(time) {
      None => ()
      Some(frame) => result.push(frame)
    }
  }
  result
}

///|
/// Create a poster canvas by placing sampled thumbnails on a checkerboard.
pub fn Animation::poster(
  self : Animation,
  max_edge : Int,
  columns : Int,
) -> Canvas {
  let options = ContactSheetOptions::default()
    .cell(max_edge, max_edge)
    .columns(columns)
  self.contact_sheet(
    options~,
    policy=Evenly(
      count=if self.frames.length() < 12 { self.frames.length() } else { 12 },
    ),
  )
}

///|
/// Return the average frame color sequence for lightweight visual analytics.
pub fn Animation::average_colors(self : Animation) -> Array[Rgba] {
  let result : Array[Rgba] = []
  for frame in self.frames {
    result.push(frame.canvas().average_color())
  }
  result
}

///|
/// Compute a compact temporal activity score from adjacent frame differences.
pub fn Animation::activity_scores(self : Animation) -> Array[Int] {
  let result : Array[Int] = []
  if self.frames.length() == 0 {
    return result
  }
  result.push(0)
  for i in 1..