///|
/// Safe boundary helpers for applications that prefer explicit status values.
pub(all) struct EncodeOutcome {
  bytes : Bytes?
  error_code : String?
  error_message : String?
} derive(Debug)

///|
pub fn EncodeOutcome::bytes(self : EncodeOutcome) -> Bytes? {
  self.bytes
}

///|
pub fn EncodeOutcome::error_code(self : EncodeOutcome) -> String? {
  self.error_code
}

///|
pub fn EncodeOutcome::error_message(self : EncodeOutcome) -> String? {
  self.error_message
}

///|
pub fn EncodeOutcome::ok(self : EncodeOutcome) -> Bool {
  self.bytes is Some(_)
}

///|
fn gif_error_code(error : GifError) -> (String, String) {
  match error {
    InvalidSignature => ("INVALID_SIGNATURE", "input is not a GIF stream")
    UnexpectedEnd(_) => ("UNEXPECTED_END", "input ends before a complete block")
    InvalidDimensions =>
      ("INVALID_DIMENSIONS", "image dimensions are outside the GIF range")
    InvalidBlock(_) => ("INVALID_BLOCK", "GIF block structure is malformed")
    UnsupportedFeature(_) =>
      ("UNSUPPORTED_FEATURE", "GIF feature is not supported")
    InvalidLzw(_) => ("INVALID_LZW", "LZW payload cannot be decoded")
    InvalidPalette(_) => ("INVALID_PALETTE", "palette entry is invalid")
  }
}

///|
/// Encode without throwing across a foreign-function boundary.
pub fn encode_checked(
  animation : Animation,
  options : EncodeOptions,
) -> EncodeOutcome {
  try
    EncodeOutcome::{
      bytes: Some(encode_with_options(animation, options)),
      error_code: None,
      error_message: None,
    }
  catch {
    error => {
      let (code, message) = gif_error_code(error)
      EncodeOutcome::{
        bytes: None,
        error_code: Some(code),
        error_message: Some(message),
      }
    }
  } noraise {
    _ =>
      EncodeOutcome::{
        bytes: None,
        error_code: Some("UNKNOWN"),
        error_message: Some("unknown GIF error"),
      }
  }
}

///|
/// Decode without throwing across an application callback boundary.
pub fn decode_checked(data : Bytes) -> Animation? {
  try Some(decode(data)) catch {
    _ => None
  } noraise {
    _ => None
  }
}

///|
/// Return a one-frame animation for a canvas with validated timing.
pub fn single_frame_animation(canvas : Canvas, delay_cs : Int) -> Animation {
  Animation::new(width=canvas.width, height=canvas.height).add_frame(
    Frame::new(canvas~, delay_cs=if delay_cs < 0 { 0 } else { delay_cs }),
  )
}

///|
/// Encode a canvas with a single call and a safe palette budget.
pub fn encode_canvas_checked(
  canvas : Canvas,
  colors : Int,
  delay_cs : Int,
) -> EncodeOutcome {
  let options = EncodeOptions::new(
    max_colors=clamp_palette_size(colors),
    default_delay_cs=delay_cs,
  )
  encode_checked(single_frame_animation(canvas, delay_cs), options)
}

///|
/// Decode the first visible frame, if a stream contains one.
pub fn decode_first_frame(data : Bytes) -> Frame? {
  match decode_checked(data) {
    None => None
    Some(animation) =>
      if animation.frame_count() == 0 {
        None
      } else {
        Some(animation.frames[0])
      }
  }
}

///|
/// Return a human-readable message for an invalid buffer.
pub fn explain_gif_error(data : Bytes) -> String {
  match validate_gif_bytes(data).first_error() {
    None => "valid GIF stream"
    Some(code) => "GIF validation failed: \{code}"
  }
}

///|
/// Test whether a decoded animation can be re-encoded losslessly in shape.
pub fn can_roundtrip(data : Bytes) -> Bool {
  match decode_checked(data) {
    None => false
    Some(animation) => encode_checked(animation, EncodeOptions::default()).ok()
  }
}

///|
/// Return a transparent placeholder for an empty input canvas.
pub fn normalize_empty_canvas(canvas : Canvas) -> Canvas {
  if canvas.width == 0 || canvas.height == 0 {
    Canvas::new(width=1, height=1, fill=rgba(0, 0, 0, 0))
  } else {
    canvas
  }
}