///|
priv suberror ReadPdfError {
  Revisions(Int)
  BadRevision
}

///|
fn is_digit(c : Char) -> Bool {
  c is ('0'..='9')
}

///|
fn read_startxref(input : @pdfio.Input) -> Int raise {
  @pdfsyntax.PdfSyntax::new().find_eof(input)
  @pdfsyntax.PdfSyntax::new().backline(input)
  @pdfsyntax.PdfSyntax::new().ignoreuntil(false, is_digit, input)
  let digits = @pdfsyntax.PdfSyntax::new().getuntil_string(
    false,
    c => !is_digit(c),
    input,
  )
  if digits.length() == 0 {
    let message = @pdf.input_pdferror(input, "Could not find xref pointer")
    raise @pdf.PdfError::Msg(message)
  }
  match parse_uint_string(digits) {
    Some(value) => value
    None => {
      let message = @pdf.input_pdferror(input, "Could not parse xref pointer")
      raise @pdf.PdfError::Msg(message)
    }
  }
}

///|
fn extract_trailer_dict(obj : @pdf.PdfObject) -> @pdf.PdfObject raise {
  match obj {
    @pdf.PdfObject::Dictionary(_) => obj
    @pdf.PdfObject::Stream(r) => {
      let (dict, _) = r.val
      match dict {
        @pdf.PdfObject::Dictionary(_) => dict
        _ => raise @pdf.PdfError::Msg("Malformed trailer")
      }
    }
    _ => raise @pdf.PdfError::Msg("Malformed trailer")
  }
}

///|
fn read_trailer_dict_from_table(
  input : @pdfio.Input,
  read_stream_data : Bool,
) -> @pdf.PdfObject raise {
  let lexemes = @pdfsyntax.PdfSyntax::new().lex_object_at(
    true,
    input,
    read_stream_data,
    _ => [],
  )
  let (_, parsed) = @pdfsyntax.PdfSyntax::new().parse(lexemes)
  extract_trailer_dict(parsed)
}

///|
fn sanitize_trailerdict(
  object_count : Int,
  trailerdict : @pdf.PdfObject,
) -> @pdf.PdfObject raise {
  let mut cleaned = trailerdict
  let keys = [
    "/W", "/Type", "/Index", "/Prev", "/XRefStm", "/Filter", "/DecodeParms",
  ]
  for key in keys {
    if cleaned.lookup_immediate(key) is Some(_) {
      cleaned = cleaned.remove_entry(key)
    }
  }
  cleaned.add_entry("/Size", @pdf.PdfObject::Integer(object_count))
}

///|
fn version_from_catalog(ctx : PdfRead, pdf : @pdf.Pdf) -> (Int, Int)? {
  match pdf.lookup_direct("/Version", pdf.lookup_obj(pdf.root)) {
    Some(@pdf.PdfObject::Name(name)) => {
      let s = name.to_string_bytes()
      if s.length() < 2 {
        return None
      }
      // `/Version` is a name like `/1.7` (byte-string). Slice off the `/`.
      let version = try s[1:] catch {
        _ => return None
      } noraise {
        view => view.to_string()
      }
      let header = "%PDF-\{version}"
      try read_header(ctx, @pdfio.Input::of_string(header)) catch {
        _ => None
      } noraise {
        pair => Some(pair)
      }
    }
    _ => None
  }
}

///|
fn read_pdf_internal(
  ctx : PdfRead,
  revision : Int?,
  user_password : String?,
  owner_password : String?,
  input : @pdfio.Input,
  read_stream_data : Bool,
) -> @pdf.Pdf raise {
  let _ = user_password
  let _ = owner_password
  match revision {
    Some(r) if r < 1 && r != -1 => raise ReadPdfError::BadRevision
    _ => ()
  }
  if ctx.debug_always_treat_malformed && revision != Some(-1) {
    return read_malformed_pdf(
      ctx, revision, user_password, owner_password, input, read_stream_data,
    )
  }
  try {
    let was_linearized = ctx.is_linearized(input)
    (input.seek_in)(0)
    let (major_header, minor_header) = read_header(ctx, input)
    let mut revisions_count = 1
    let mut current_revision = 0
    let mut got_all_xref_sections = false
    let mut xref_offset = read_startxref(input)
    let mut first_trailer = true
    let mut trailerdict = @pdf.PdfObject::Dictionary([])
    let xrefs : Map[Int, XRef] = Map::new()
    let postdeletes : Map[Int, Bool] = Map::new()
    fn add_entries(
      entries : Array[(Int, XRef)],
      xrefs : Map[Int, XRef],
    ) -> Unit {
      for pair in entries {
        if xrefs.get(pair.0) is None {
          xrefs.set(pair.0, pair.1)
        }
      }
    }

    while !got_all_xref_sections {
      (input.seek_in)(xref_offset)
      current_revision = current_revision + 1
      @pdfsyntax.PdfSyntax::new().dropwhite(input)
      let skip = match revision {
        None => false
        Some(r) => r > current_revision
      }
      let mut trailerdict_current = @pdf.PdfObject::Dictionary([])
      if input.peek_char() is Some('x') {
        let entries = read_xref(input)
        if !skip {
          add_entries(entries, xrefs)
        }
        trailerdict_current = read_trailer_dict_from_table(
          input, read_stream_data,
        )
      } else {
        let (entries, xref_objnum, stream_obj) = read_xref_stream(ctx, input)
        if !skip {
          let filtered = entries.filter(pair => pair.0 != xref_objnum)
          add_entries(filtered, xrefs)
          postdeletes.set(xref_objnum, true)
        }
        trailerdict_current = extract_trailer_dict(stream_obj)
      }
      if first_trailer {
        trailerdict = trailerdict_current
        first_trailer = false
      }
      match trailerdict_current.lookup_immediate("/XRefStm") {
        Some(@pdf.PdfObject::Integer(n)) => {
          (input.seek_in)(n)
          @pdfsyntax.PdfSyntax::new().dropwhite(input)
          let (entries, xref_objnum, _stream_obj) = read_xref_stream(ctx, input)
          if !skip {
            let filtered = entries.filter(pair => pair.0 != xref_objnum)
            add_entries(filtered, xrefs)
            postdeletes.set(xref_objnum, true)
          }
        }
        Some(_) =>
          raise @pdf.PdfError::Msg(
            @pdf.input_pdferror(input, "Malformed /XRefStm"),
          )
        None => ()
      }
      match trailerdict_current.lookup_immediate("/Prev") {
        None => got_all_xref_sections = true
        Some(@pdf.PdfObject::Integer(n)) => {
          revisions_count = revisions_count + 1
          xref_offset = n
        }
        Some(_) =>
          raise @pdf.PdfError::Msg(
            @pdf.input_pdferror(input, "Malformed /Prev"),
          )
      }
    }
    if revision is Some(-1) {
      let real_revisions = if was_linearized {
        revisions_count - 1
      } else {
        revisions_count
      }
      raise ReadPdfError::Revisions(real_revisions)
    }
    if read_stream_data {
      for _key, xref in xrefs {
        match xref {
          XRef::Stream(stream_id, _) => postdeletes.set(stream_id, true)
          _ => ()
        }
      }
    }
    let root = match trailerdict.lookup_immediate("/Root") {
      Some(@pdf.PdfObject::Indirect(i)) => i
      None => {
        let message = @pdf.input_pdferror(input, "No /Root entry")
        raise @pdf.PdfError::Msg(message)
      }
      _ => {
        let message = @pdf.input_pdferror(input, "Malformed /Root entry")
        raise @pdf.PdfError::Msg(message)
      }
    }
    let objects = build_pdf_objects(
      ctx,
      input,
      xrefs.to_array(),
      read_stream_data,
      trailerdict,
      user_password,
      owner_password,
    )
    let sanitized_trailer = sanitize_trailerdict(
      objects.objects.length(),
      trailerdict,
    )
    let pdf_tmp = @pdf.Pdf::{
      major: major_header,
      minor: minor_header,
      root,
      objects,
      trailerdict: sanitized_trailer,
      was_linearized,
      saved_encryption: None,
    }
    let (major, minor) = match version_from_catalog(ctx, pdf_tmp) {
      Some(pair) => pair
      None => (major_header, minor_header)
    }
    let pdf_final = @pdf.Pdf::{
      major,
      minor,
      root,
      objects,
      trailerdict: sanitized_trailer,
      was_linearized,
      saved_encryption: None,
    }
    fn remove_postdelete(pdf : @pdf.Pdf, objnum : Int) -> Unit {
      match pdf.lookup_obj(objnum) {
        @pdf.PdfObject::Stream(r) => {
          let (dict, _) = r.val
          match pdf.lookup_direct("/Type", dict) {
            Some(@pdf.PdfObject::Name(name)) if name.equal_string_bytes("/XRef") =>
              pdf.removeobj(objnum)
            Some(@pdf.PdfObject::Name(name)) if name.equal_string_bytes(
                "/ObjStm",
              ) => pdf.removeobj(objnum)
            _ => ()
          }
        }
        _ => ()
      }
    }

    for objnum, _ in postdeletes {
      remove_postdelete(pdf_final, objnum)
    }
    pdf_final
  } catch {
    err =>
      if should_fallback_on_error(ctx, err, revision) {
        read_malformed_pdf(
          ctx, revision, user_password, owner_password, input, read_stream_data,
        )
      } else {
        raise err
      }
  }
}