///|
fn Vm::encoding_builtin(
  self : Vm,
  name : String,
  value : Value,
  loc : Location,
) -> Value raise {
  match name {
    "base64" => {
      let bytes = match value {
        Str(s) => {
          for c in s.iter() {
            self.tick(loc)
            if c.to_int() > 255 {
              raise problem(
                Runtime,
                loc,
                "base64 encountered invalid codepoint value in the array (must be 0 <= X <= 255), got " +
                c.to_int().to_string(),
              )
            }
          }
          // Go validates Latin-1 code points but encodes their UTF-8 bytes.
          @utf8.encode(s)
        }
        ArrayValue(_) => self.byte_array(value, true, loc)
        _ =>
          raise problem(
            Runtime,
            loc,
            "base64 can only base64 encode strings / arrays of single bytes, got " +
            value_type(value),
          )
      }
      Str(@base64.encode(bytes))
    }
    "encodeUTF8" => self.bytes_value(@utf8.encode(text_value(value, loc)), loc)
    "decodeUTF8" => Str(decode_go_utf8(self.byte_array(value, false, loc)))
    _ => {
      guard value is Str(s) else {
        raise problem(
          Runtime,
          loc,
          "base64DecodeBytes requires a string, got " + value_type(value),
        )
      }
      let bytes = self.decode_base64(s, loc)
      if name == "base64DecodeBytes" {
        self.bytes_value(bytes, loc)
      } else {
        Str(decode_go_utf8(bytes))
      }
    }
  }
}

///|
fn Vm::byte_array(
  self : Vm,
  value : Value,
  base64 : Bool,
  loc : Location,
) -> Bytes raise {
  let items = array_value(value, loc)
  Bytes::makei(items.length(), fn(i) raise {
    let value = self.force(items[i], loc)
    let n = integer(value, loc) catch {
      e =>
        if base64 {
          raise problem(
            Runtime,
            loc,
            "base64 encountered a non-integer value in the array, got " +
            value_type(value),
          )
        } else {
          raise e
        }
    }
    if n < 0 || n > 255 {
      raise problem(
        Runtime,
        loc,
        (if base64 {
          "base64 encountered invalid codepoint value in the array (must be 0 <= X <= 255), got "
        } else {
          "Bytes must be integers in range [0, 255], got "
        }) +
        n.to_string(),
      )
    }
    n.to_byte()
  })
}

///|
fn Vm::bytes_value(self : Vm, bytes : Bytes, loc : Location) -> Value raise {
  let items = []
  for b in bytes.iter() {
    self.tick(loc)
    items.push(self.store(Ready(Num(b.to_int().to_double()))))
  }
  ArrayValue(items)
}

///|
fn base64_digit(c : Byte) -> Int {
  let n = c.to_int()
  if n >= 65 && n <= 90 {
    n - 65
  } else if n >= 97 && n <= 122 {
    n - 97 + 26
  } else if n >= 48 && n <= 57 {
    n - 48 + 52
  } else if n == 43 {
    62
  } else if n == 47 {
    63
  } else {
    -1
  }
}

///|
fn Vm::decode_base64(self : Vm, s : String, loc : Location) -> Bytes raise {
  let raw = @utf8.encode(s)
  if raw.length() % 4 != 0 {
    raise problem(
      Runtime,
      loc,
      "input string appears not to be a base64 encoded string. Wrong length found (" +
      raw.length().to_string() +
      ")",
    )
  }
  // Keep the original UTF-8 offsets while skipping CR/LF. Go's decoder reports
  // consumed byte positions, including skipped newlines and incomplete padding.
  let bytes : Array[Byte] = []
  let mut position = 0
  while position < raw.length() {
    self.tick(loc)
    let digits = FixedArray::make(4, 0)
    let mut count = 0
    while count < 4 {
      if position == raw.length() {
        if count != 0 {
          raise base64_corrupt(loc, position - count)
        }
        break
      }
      let byte = raw[position]
      position += 1
      let digit = base64_digit(byte)
      if digit >= 0 {
        digits[count] = digit
        count += 1
        continue
      }
      if byte == 13 || byte == 10 {
        continue
      }
      if byte != 61 || count < 2 {
        raise base64_corrupt(loc, position - 1)
      }
      if count == 2 {
        while position < raw.length() &&
              (raw[position] == 13 || raw[position] == 10) {
          position += 1
        }
        if position == raw.length() {
          raise base64_corrupt(loc, position)
        }
        if raw[position] != 61 {
          raise base64_corrupt(loc, position - 1)
        }
        position += 1
      }
      while position < raw.length() &&
            (raw[position] == 13 || raw[position] == 10) {
        position += 1
      }
      if position < raw.length() {
        raise base64_corrupt(loc, position)
      }
      break
    }
    if count >= 2 {
      bytes.push(((digits[0] << 2) | (digits[1] >> 4)).to_byte())
    }
    if count >= 3 {
      bytes.push((((digits[1] & 15) << 4) | (digits[2] >> 2)).to_byte())
    }
    if count == 4 {
      bytes.push((((digits[2] & 3) << 6) | digits[3]).to_byte())
    }
  }
  Bytes::from_array(bytes)
}

///|
fn base64_corrupt(loc : Location, offset : Int) -> JsonnetError {
  problem(
    Runtime,
    loc,
    "failed to decode: illegal base64 data at input byte " + offset.to_string(),
  )
}

///|
// Go's []rune(string(bytes)) consumes one byte for each malformed sequence,
// unlike decoders that replace an entire maximal invalid subpart at once.
fn decode_go_utf8(bytes : Bytes) -> String {
  let output = StringBuilder()
  let mut i = 0
  while i < bytes.length() {
    let first = bytes[i].to_int()
    if first < 128 {
      output.write_char(first.to_char().unwrap())
      i += 1
      continue
    }
    let (width, minimum, mask) = if first >= 194 && first <= 223 {
      (2, 128, 31)
    } else if first >= 224 && first <= 239 {
      (3, 2048, 15)
    } else if first >= 240 && first <= 244 {
      (4, 65536, 7)
    } else {
      (0, 0, 0)
    }
    let mut valid = width > 0 && width <= bytes.length() - i
    let mut point = first & mask
    if valid {
      for j in 1.. 191 {
          valid = false
          break
        }
        point = (point << 6) | (next & 63)
      }
      valid = valid &&
        point >= minimum &&
        point <= 0x10ffff &&
        !(point >= 0xd800 && point <= 0xdfff)
    }
    if valid {
      output.write_char(point.to_char().unwrap())
      i += width
    } else {
      output.write_char('\uFFFD')
      i += 1
    }
  }
  output.to_string()
}