///|
/// Portable Netpbm encodings supported by the CLI and test fixtures.
pub(all) enum NetpbmFormat {
  PpmRgb
  PpmRgba
  PgmGray
  PamRgba
} derive(Eq, Debug)

///|
pub(all) struct NetpbmOptions {
  format : NetpbmFormat
  max_value : Int
  include_comments : Bool
} derive(Debug)

///|
pub fn NetpbmOptions::default() -> NetpbmOptions {
  { format: PamRgba, max_value: 255, include_comments: false }
}

///|
pub fn NetpbmOptions::format(
  self : NetpbmOptions,
  format : NetpbmFormat,
) -> NetpbmOptions {
  { ..self, format, }
}

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

///|
pub fn NetpbmOptions::comments(
  self : NetpbmOptions,
  enabled : Bool,
) -> NetpbmOptions {
  { ..self, include_comments: enabled }
}

///|
struct NetpbmBuffer {
  bytes : Array[Byte]
} derive(Debug)

///|
fn NetpbmBuffer::new() -> NetpbmBuffer {
  { bytes: [] }
}

///|
fn NetpbmBuffer::text(self : NetpbmBuffer, value : String) -> Unit {
  let encoded = @utf8.encode(value)
  for byte in encoded {
    self.bytes.push(byte)
  }
}

///|
fn NetpbmBuffer::byte(self : NetpbmBuffer, value : Int) -> Unit {
  self.bytes.push(value.to_byte())
}

///|
fn NetpbmBuffer::u16(self : NetpbmBuffer, value : Int) -> Unit {
  self.byte(value >> 8)
  self.byte(value)
}

///|
fn NetpbmBuffer::finish(self : NetpbmBuffer) -> Bytes {
  Bytes::from_array(self.bytes[:])
}

///|
fn append_ascii_int(buffer : NetpbmBuffer, value : Int) -> Unit {
  buffer.text("\{value}")
}

///|
fn netpbm_channel(value : Byte, max_value : Int) -> Int {
  value.to_int() * max_value / 255
}

///|
fn byte_from_netpbm(value : Int, max_value : Int) -> Byte {
  if max_value <= 0 {
    0
  } else {
    clamp_color_int(value * 255 / max_value).to_byte()
  }
}

///|
/// Encode a canvas as binary PPM, PGM, or PAM.
pub fn encode_netpbm(canvas : Canvas, options : NetpbmOptions) -> Bytes {
  let max_value = if options.max_value < 1 {
    1
  } else if options.max_value > 65535 {
    65535
  } else {
    options.max_value
  }
  let buffer = NetpbmBuffer::new()
  match options.format {
    PpmRgb => {
      buffer.text("P6\n")
      append_ascii_int(buffer, canvas.width)
      buffer.text(" ")
      append_ascii_int(buffer, canvas.height)
      buffer.text("\n")
      append_ascii_int(buffer, max_value)
      buffer.text("\n")
      for color in canvas.pixels {
        append_netpbm_sample(buffer, color.red(), max_value)
        append_netpbm_sample(buffer, color.green(), max_value)
        append_netpbm_sample(buffer, color.blue(), max_value)
      }
    }
    PpmRgba => {
      buffer.text("P7\nWIDTH ")
      append_ascii_int(buffer, canvas.width)
      buffer.text("\nHEIGHT ")
      append_ascii_int(buffer, canvas.height)
      buffer.text("\nDEPTH 4\nMAXVAL ")
      append_ascii_int(buffer, max_value)
      buffer.text("\nTUPLTYPE RGB_ALPHA\nENDHDR\n")
      for color in canvas.pixels {
        append_netpbm_sample(buffer, color.red(), max_value)
        append_netpbm_sample(buffer, color.green(), max_value)
        append_netpbm_sample(buffer, color.blue(), max_value)
        append_netpbm_sample(buffer, color.alpha(), max_value)
      }
    }
    PgmGray => {
      buffer.text("P5\n")
      append_ascii_int(buffer, canvas.width)
      buffer.text(" ")
      append_ascii_int(buffer, canvas.height)
      buffer.text("\n")
      append_ascii_int(buffer, max_value)
      buffer.text("\n")
      for color in canvas.pixels {
        append_netpbm_sample(buffer, color.luma(), max_value)
      }
    }
    PamRgba => {
      buffer.text("P7\nWIDTH ")
      append_ascii_int(buffer, canvas.width)
      buffer.text("\nHEIGHT ")
      append_ascii_int(buffer, canvas.height)
      buffer.text("\nDEPTH 4\nMAXVAL ")
      append_ascii_int(buffer, max_value)
      buffer.text("\nTUPLTYPE RGB_ALPHA\nENDHDR\n")
      for color in canvas.pixels {
        append_netpbm_sample(buffer, color.red(), max_value)
        append_netpbm_sample(buffer, color.green(), max_value)
        append_netpbm_sample(buffer, color.blue(), max_value)
        append_netpbm_sample(buffer, color.alpha(), max_value)
      }
    }
  }
  buffer.finish()
}

///|
fn append_netpbm_sample(
  buffer : NetpbmBuffer,
  value : Byte,
  max_value : Int,
) -> Unit {
  if max_value <= 255 {
    buffer.byte(netpbm_channel(value, max_value))
  } else {
    buffer.u16(netpbm_channel(value, max_value))
  }
}

///|
struct NetpbmCursor {
  data : Bytes
  mut position : Int
} derive(Debug)

///|
fn NetpbmCursor::new(data : Bytes) -> NetpbmCursor {
  { data, position: 0 }
}

///|
fn NetpbmCursor::byte(self : NetpbmCursor) -> Byte raise GifError {
  if self.position >= self.data.length() {
    raise UnexpectedEnd(context="Netpbm data")
  }
  let result = self.data[self.position]
  self.position += 1
  result
}

///|
fn NetpbmCursor::skip_space(self : NetpbmCursor) -> Unit {
  let mut continue_scanning = true
  while continue_scanning && self.position < self.data.length() {
    let current = self.data[self.position]
    if current == b'#' {
      while self.position < self.data.length() &&
            self.data[self.position] != b'\n' {
        self.position += 1
      }
    } else if current == b' ' ||
      current == b'\n' ||
      current == b'\r' ||
      current == b'\t' {
      self.position += 1
    } else {
      continue_scanning = false
    }
  }
}

///|
fn NetpbmCursor::token(self : NetpbmCursor) -> String raise GifError {
  self.skip_space()
  let start = self.position
  while self.position < self.data.length() {
    let current = self.data[self.position]
    if current == b' ' ||
      current == b'\n' ||
      current == b'\r' ||
      current == b'\t' ||
      current == b'#' {
      break
    }
    self.position += 1
  }
  if self.position == start {
    raise UnexpectedEnd(context="Netpbm token")
  }
  let token_bytes : Array[Byte] = []
  for i in start.. raise InvalidBlock(context="invalid UTF-8 Netpbm token")
  }
}

///|
fn parse_netpbm_int(value : String) -> Int raise GifError {
  let mut result = 0
  let mut seen = false
  for byte in @utf8.encode(value) {
    if byte >= b'0' && byte <= b'9' {
      result = result * 10 + (byte - b'0').to_int()
      seen = true
    } else if byte == b'-' && !seen && result == 0 {
      result = -1
    } else {
      raise InvalidBlock(context="invalid Netpbm integer")
    }
  }
  if !seen || result < 0 {
    raise InvalidBlock(context="invalid Netpbm integer")
  }
  result
}

///|
fn read_netpbm_sample(
  cursor : NetpbmCursor,
  max_value : Int,
) -> Int raise GifError {
  if max_value <= 255 {
    cursor.byte().to_int()
  } else {
    let high = cursor.byte().to_int()
    let low = cursor.byte().to_int()
    high * 256 + low
  }
}

///|
/// Decode binary PPM/PAM/PGM data into an RGBA canvas.
pub fn decode_netpbm(data : Bytes) -> Canvas raise GifError {
  let cursor = NetpbmCursor::new(data)
  let magic = cursor.token()
  if magic == "P6" || magic == "P5" {
    let width = parse_netpbm_int(cursor.token())
    let height = parse_netpbm_int(cursor.token())
    let max_value = parse_netpbm_int(cursor.token())
    if width <= 0 || height <= 0 || max_value <= 0 || max_value > 65535 {
      raise InvalidDimensions
    }
    cursor.skip_space()
    let channel_count = if magic == "P6" { 3 } else { 1 }
    let result = Canvas::new(width~, height~, fill=rgba(0, 0, 0, 255))
    for i in 0..<(width * height) {
      let red = read_netpbm_sample(cursor, max_value)
      let green = if channel_count == 3 {
        read_netpbm_sample(cursor, max_value)
      } else {
        red
      }
      let blue = if channel_count == 3 {
        read_netpbm_sample(cursor, max_value)
      } else {
        red
      }
      result.pixels[i] = rgba(
        byte_from_netpbm(red, max_value),
        byte_from_netpbm(green, max_value),
        byte_from_netpbm(blue, max_value),
        255,
      )
    }
    result
  } else if magic == "P7" {
    decode_pam(cursor)
  } else {
    raise UnsupportedFeature(
      feature="only binary P5, P6, and P7 Netpbm are supported",
    )
  }
}

///|
fn decode_pam(cursor : NetpbmCursor) -> Canvas raise GifError {
  let mut width = 0
  let mut height = 0
  let mut depth = 0
  let mut max_value = 255
  let mut header_done = false
  while !header_done {
    let key = cursor.token()
    if key == "ENDHDR" {
      header_done = true
    } else if key == "WIDTH" {
      width = parse_netpbm_int(cursor.token())
    } else if key == "HEIGHT" {
      height = parse_netpbm_int(cursor.token())
    } else if key == "DEPTH" {
      depth = parse_netpbm_int(cursor.token())
    } else if key == "MAXVAL" {
      max_value = parse_netpbm_int(cursor.token())
    } else {
      ignore(cursor.token())
    }
  }
  cursor.skip_space()
  if width <= 0 ||
    height <= 0 ||
    depth != 4 ||
    max_value <= 0 ||
    max_value > 65535 {
    raise InvalidDimensions
  }
  let result = Canvas::new(width~, height~, fill=rgba(0, 0, 0, 0))
  for i in 0..<(width * height) {
    let red = read_netpbm_sample(cursor, max_value)
    let green = read_netpbm_sample(cursor, max_value)
    let blue = read_netpbm_sample(cursor, max_value)
    let alpha = read_netpbm_sample(cursor, max_value)
    result.pixels[i] = rgba(
      byte_from_netpbm(red, max_value),
      byte_from_netpbm(green, max_value),
      byte_from_netpbm(blue, max_value),
      byte_from_netpbm(alpha, max_value),
    )
  }
  result
}

///|
/// Return the format marker without decoding pixel data.
pub fn netpbm_format(data : Bytes) -> NetpbmFormat raise GifError {
  let cursor = NetpbmCursor::new(data)
  match cursor.token() {
    "P5" => PgmGray
    "P6" => PpmRgb
    "P7" => PamRgba
    _ => raise UnsupportedFeature(feature="unknown Netpbm marker")
  }
}

///|
/// Crop a canvas to a rectangular PPM-friendly RGB view.
pub fn Canvas::to_rgb(self : Canvas) -> Canvas {
  let result = self.copy()
  for i in 0.. Canvas {
  let result = self.copy()
  for i in 0..