///|
fn stream_char(source : String, offset : Int) -> Char? {
  source.get_char(offset)
}

///|
fn stream_is_space(character : Char) -> Bool {
  character == ' ' ||
  character == '\n' ||
  character == '\r' ||
  character == '\t' ||
  character == '\u{000c}'
}

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

///|
fn stream_is_identifier(character : Char) -> Bool {
  character is ('A'..='Z') || character is ('a'..='z') || character == '_'
}

///|
fn stream_skip_space(source : String, offset : Int) -> Int {
  let mut cursor = offset
  for ;; {
    match stream_char(source, cursor) {
      Some(character) if stream_is_space(character) => cursor += 1
      Some('/') if stream_char(source, cursor + 1) == Some('/') => {
        cursor += 2
        for ;; {
          match stream_char(source, cursor) {
            Some('\n') | None => break
            Some(_) => cursor += 1
          }
        }
      }
      Some('-') if stream_char(source, cursor + 1) == Some('-') => {
        cursor += 2
        for ;; {
          match stream_char(source, cursor) {
            Some('\n') | None => break
            Some(_) => cursor += 1
          }
        }
      }
      _ => return cursor
    }
  }
}

///|
fn stream_read_digits(source : String, offset : Int) -> (Int, Int) {
  let mut cursor = offset
  for ;; {
    match stream_char(source, cursor) {
      Some(character) if stream_is_digit(character) => cursor += 1
      _ => break
    }
  }
  (offset, cursor)
}

///|
fn stream_parse_int(source : String, start : Int, end : Int) -> Int {
  let text = source[start:end].to_owned()
  @strconv.from_str(text) catch {
    _ => 0
  }
}

///|
fn stream_read_name(source : String, offset : Int) -> (String, Int) {
  let mut cursor = offset
  for ;; {
    match stream_char(source, cursor) {
      Some(character) if stream_is_identifier(character) ||
        stream_is_digit(character) => cursor += 1
      _ => break
    }
  }
  (source[offset:cursor].to_owned(), cursor)
}

///|
fn stream_record_end(source : String, start : Int) -> (Int, Int) {
  let mut cursor = start
  let mut depth = 0
  let mut line = 1
  let mut in_string = false
  for ;; {
    match stream_char(source, cursor) {
      None => return (source.length(), line)
      Some('\\') if in_string => cursor += 2
      Some('\'') =>
        if stream_char(source, cursor + 1) == Some('\'') && in_string {
          cursor += 2
        } else {
          in_string = !in_string
          cursor += 1
        }
      Some('(') if !in_string => {
        depth += 1
        cursor += 1
      }
      Some(')') if !in_string => {
        if depth > 0 {
          depth -= 1
        }
        cursor += 1
      }
      Some(';') if !in_string && depth == 0 => return (cursor + 1, line)
      Some('\n') => {
        line += 1
        cursor += 1
      }
      Some(_) => cursor += 1
    }
  }
}

///|
fn stream_header(source : String, start : Int) -> (Int, String)? {
  let mut cursor = start
  match stream_char(source, cursor) {
    Some('#') => cursor += 1
    _ => return None
  }
  let (id_start, id_end) = stream_read_digits(source, cursor)
  if id_start == id_end {
    return None
  }
  cursor = id_end
  match stream_char(source, cursor) {
    Some('=') => cursor += 1
    _ => return None
  }
  cursor = stream_skip_space(source, cursor)
  let (name, _) = stream_read_name(source, cursor)
  Some((stream_parse_int(source, id_start, id_end), name))
}

///|
fn count_character_outside_string(text : String, wanted : Char) -> Int {
  let mut count = 0
  let mut cursor = 0
  let mut in_string = false
  for ;; {
    match stream_char(text, cursor) {
      None => break
      Some('\\') if in_string => cursor += 2
      Some('\'') =>
        if stream_char(text, cursor + 1) == Some('\'') && in_string {
          cursor += 2
        } else {
          in_string = !in_string
          cursor += 1
        }
      Some(character) => {
        if !in_string && character == wanted {
          count += 1
        }
        cursor += 1
      }
    }
  }
  count
}

///|
fn stream_find(text : String, needle : String) -> Int? {
  if needle.length() > text.length() {
    return None
  }
  let last = text.length() - needle.length()
  for index in 0..<=last {
    if text[index:].has_prefix(needle) {
      return Some(index)
    }
  }
  None
}

///|
fn stream_count_character(text : String, wanted : Char) -> Int {
  let mut count = 0
  for index in 0.. Array[StepRecord] {
  let records : Array[StepRecord] = []
  let mut cursor = 0
  let mut line = 1
  for ;; {
    cursor = stream_skip_space(source, cursor)
    match stream_char(source, cursor) {
      None => break
      Some('#') => {
        let start = cursor
        let (end, relative_lines) = stream_record_end(source, start)
        match stream_header(source, start) {
          Some((id, name)) => {
            let raw = source[start:end].to_owned()
            records.push({
              id,
              name,
              start_offset: start,
              end_offset: end,
              line,
              raw,
            })
          }
          None => ()
        }
        line += relative_lines - 1
        cursor = end
      }
      Some(character) => {
        if character == '\n' {
          line += 1
        }
        cursor += 1
      }
    }
  }
  records
}

///|
pub fn stream_entities(source : String) -> Array[StepRecord] {
  scan_step_records(source)
}

///|
fn stream_schema(source : String) -> String? {
  let upper = source.to_upper()
  match stream_find(upper, "FILE_SCHEMA") {
    None => None
    Some(start) => {
      let tail = source[start:]
      match stream_find(tail.to_owned(), "'") {
        Some(first) => {
          let remaining = tail[first + 1:]
          stream_find(remaining.to_owned(), "'").map(second => {
            remaining[:second].to_owned()
          })
        }
        None => None
      }
    }
  }
}

///|
/// Calculate source and record statistics with a single record scan.
pub fn stream_statistics(source : String) -> StreamStats {
  let records = scan_step_records(source)
  let mut max_record_length = 0
  let mut reference_count = 0
  for record in records {
    if record.raw.length() > max_record_length {
      max_record_length = record.raw.length()
    }
    reference_count += count_character_outside_string(record.raw, '#')
  }
  {
    source_length: source.length(),
    record_count: records.length(),
    reference_count,
    max_record_length,
    comment_count: count_character_outside_string(source, '/') / 2,
    line_count: stream_count_character(source, '\n') + 1,
    schema: stream_schema(source),
  }
}

///|
/// Parse one scanned record through the normal lossless parser.
pub fn parse_step_record(record : StepRecord) -> Entity raise ParseError {
  let source = "ISO-10303-21;HEADER;FILE_SCHEMA(('IFC4'));ENDSEC;DATA;" +
    record.raw +
    "ENDSEC;END-ISO-10303-21;"
  parse(source).entities[0]
}

///|
pub fn records_named(source : String, name : String) -> Array[StepRecord] {
  let wanted = name.to_upper()
  scan_step_records(source).filter(record => record.name.to_upper() == wanted)
}

///|
pub fn records_in_range(
  source : String,
  start_offset : Int,
  end_offset : Int,
) -> Array[StepRecord] {
  scan_step_records(source).filter(record => {
    record.start_offset >= start_offset && record.end_offset <= end_offset
  })
}

///|
pub fn StepRecord::length(self : StepRecord) -> Int {
  self.end_offset - self.start_offset
}

///|
pub fn StepRecord::is_relationship(self : StepRecord) -> Bool {
  self.name.to_upper().has_prefix("IFCREL")
}

///|
pub fn StepRecord::reference_count(self : StepRecord) -> Int {
  count_character_outside_string(self.raw, '#')
}

///|
pub fn StreamStats::density(self : StreamStats) -> Double {
  if self.source_length == 0 {
    0.0
  } else {
    self.record_count.to_double() / self.source_length.to_double()
  }
}

///|
pub fn StreamStats::headline(self : StreamStats) -> String {
  "records=" +
  self.record_count.to_string() +
  " refs=" +
  self.reference_count.to_string() +
  " bytes=" +
  self.source_length.to_string() +
  " max_record=" +
  self.max_record_length.to_string()
}