///|
fn pdf_addtext_bytes_match_at(
  bytes : BytesView,
  index : Int,
  pattern : BytesView,
) -> Bool {
  if pattern.length() == 0 || index + pattern.length() > bytes.length() {
    false
  } else {
    for offset in 0.. Int {
  let mut position = position
  for byte in view {
    output[position] = byte
    position += 1
  }
  position
}

///|
fn pdf_addtext_replace_all_lazy(
  text : String,
  search : String,
  replacement : () -> String,
) -> String {
  let search_bytes = @utf8.encode(search)
  if search_bytes.length() == 0 {
    text
  } else {
    let text_bytes = @utf8.encode(text)
    let replacements : Array[(Int, Int, @core.PdfBytes)] = Array::new(
      capacity=text_bytes.length() / search_bytes.length(),
    )
    let mut index = 0
    let mut length = text_bytes.length()
    while index < text_bytes.length() {
      if pdf_addtext_bytes_match_at(text_bytes, index, search_bytes) {
        let replacement_bytes = @utf8.encode(replacement())
        replacements.push(
          (index, index + search_bytes.length(), replacement_bytes),
        )
        length = length - search_bytes.length() + replacement_bytes.length()
        index += search_bytes.length()
      } else {
        index += 1
      }
    }
    guard replacements.length() > 0 else { return text }
    let output = Array::make(length, b'\x00')
    let mut source_index = 0
    let mut position = 0
    for entry in replacements {
      position = pdf_addtext_copy_view(
        output,
        position,
        text_bytes[source_index:entry.0],
      )
      position = pdf_addtext_copy_view(output, position, entry.2)
      source_index = entry.1
    }
    let _ = pdf_addtext_copy_view(output, position, text_bytes[source_index:])
    @utf8.decode_lossy(Bytes::from_array(output))
  }
}

///|
/// Apply cpdf add-text replacements, then run the cpdf strftime pass.
///
/// Replacement callbacks are only evaluated for markers that occur in `text`,
/// matching cpdf's `string_replace_all_lazy` helper.
pub fn pdf_addtext_process_text_with_time(
  time : @date.PdfStrftimeTime,
  text : String,
  replacements : ArrayView[(String, () -> String)],
) -> String {
  let mut output = text
  for replacement in replacements {
    output = pdf_addtext_replace_all_lazy(output, replacement.0, replacement.1)
  }
  @date.pdf_strftime_with_time(time, output)
}

///|
/// Compatibility wrapper matching cpdfaddtext's `process_text`.
pub fn pdf_process_text(
  time : @date.PdfStrftimeTime,
  text : String,
  replacements : ArrayView[(String, () -> String)],
) -> String {
  pdf_addtext_process_text_with_time(time, text, replacements)
}

///|
fn pdf_addtext_decode_lossy_view(bytes : BytesView) -> String {
  let output = Array::make(bytes.length(), b'\x00')
  for index, byte in bytes {
    output[index] = byte
  }
  @utf8.decode_lossy(Bytes::from_array(output))
}

///|
/// Split add-text input at cpdf's literal `\n` line marker.
///
/// A doubled backslash before `n` is preserved as text, matching cpdf's
/// `split_at_newline` helper.
pub fn pdf_addtext_split_at_newline(text : String) -> Array[String] {
  let bytes = @utf8.encode(text)
  let lines : Array[String] = Array::new(capacity=bytes.length())
  let mut line_start = 0
  let mut index = 0
  while index < bytes.length() {
    if index + 2 < bytes.length() &&
      bytes[index].to_int() == 92 &&
      bytes[index + 1].to_int() == 92 &&
      bytes[index + 2].to_int() == 110 {
      index += 3
    } else if index + 1 < bytes.length() &&
      bytes[index].to_int() == 92 &&
      bytes[index + 1].to_int() == 110 {
      lines.push(pdf_addtext_decode_lossy_view(bytes[line_start:index]))
      index += 2
      line_start = index
    } else {
      index += 1
    }
  }
  lines.push(pdf_addtext_decode_lossy_view(bytes[line_start:]))
  lines
}

///|
fn pdf_addtext_is_octal_digit(byte : Byte) -> Bool {
  let value = byte.to_int()
  value >= 48 && value <= 55
}

///|
fn pdf_addtext_octal_value(a : Byte, b : Byte, c : Byte) -> Int {
  (a.to_int() - 48) * 64 + (b.to_int() - 48) * 8 + c.to_int() - 48
}

///|
fn pdf_addtext_unescaped_length(bytes : BytesView) -> Int raise @core.PdfError {
  let mut index = 0
  let mut length = 0
  while index < bytes.length() {
    if bytes[index].to_int() == 92 {
      if index + 3 < bytes.length() &&
        pdf_addtext_is_octal_digit(bytes[index + 1]) &&
        pdf_addtext_is_octal_digit(bytes[index + 2]) &&
        pdf_addtext_is_octal_digit(bytes[index + 3]) {
        let value = pdf_addtext_octal_value(
          bytes[index + 1],
          bytes[index + 2],
          bytes[index + 3],
        )
        if value > 255 {
          raise SoftError("bad add-text octal escape")
        }
        length += 1
        index += 4
      } else if index + 1 < bytes.length() && bytes[index + 1].to_int() == 92 {
        length += 1
        index += 2
      } else if index + 1 < bytes.length() && bytes[index + 1].to_int() != 110 {
        length += 1
        index += 2
      } else {
        length += 1
        index += 1
      }
    } else {
      length += 1
      index += 1
    }
  }
  length
}

///|
/// Decode cpdf add-text backslash escapes after line splitting.
///
/// Three-digit octal escapes emit one byte, doubled backslashes emit one
/// backslash, and other non-newline escapes drop the backslash.
pub fn pdf_addtext_unescape_string(
  text : String,
) -> String raise @core.PdfError {
  let bytes = @utf8.encode(text)
  let output = Array::make(pdf_addtext_unescaped_length(bytes), b'\x00')
  let mut index = 0
  let mut position = 0
  while index < bytes.length() {
    if bytes[index].to_int() == 92 {
      if index + 3 < bytes.length() &&
        pdf_addtext_is_octal_digit(bytes[index + 1]) &&
        pdf_addtext_is_octal_digit(bytes[index + 2]) &&
        pdf_addtext_is_octal_digit(bytes[index + 3]) {
        let value = pdf_addtext_octal_value(
          bytes[index + 1],
          bytes[index + 2],
          bytes[index + 3],
        )
        if value > 255 {
          raise SoftError("bad add-text octal escape")
        }
        output[position] = value.to_byte()
        position += 1
        index += 4
      } else if index + 1 < bytes.length() && bytes[index + 1].to_int() == 92 {
        output[position] = (92).to_byte()
        position += 1
        index += 2
      } else if index + 1 < bytes.length() && bytes[index + 1].to_int() != 110 {
        output[position] = bytes[index + 1]
        position += 1
        index += 2
      } else {
        output[position] = bytes[index]
        position += 1
        index += 1
      }
    } else {
      output[position] = bytes[index]
      position += 1
      index += 1
    }
  }
  @utf8.decode_lossy(Bytes::from_array(output))
}

///|
/// Split and unescape add-text lines, reversing them for upside-down rotations.
pub fn pdf_addtext_lines_for_rotation(
  text : String,
  rotation : @addtext.PdfAddTextRotation,
) -> Array[String] raise @core.PdfError {
  let lines = pdf_addtext_split_at_newline(text).map(line => {
    pdf_addtext_unescape_string(line)
  })
  match rotation {
    PdfAddTextRot180 | PdfAddTextRot270 => lines.rev()
    PdfAddTextRot0 | PdfAddTextRot90 => lines
  }
}