///|
fn value_stats_visit(
  value : Value,
  depth : Int,
  output : ValueStats,
) -> ValueStats {
  let next_depth = depth + 1
  let maximum_depth = if next_depth > output.maximum_depth {
    next_depth
  } else {
    output.maximum_depth
  }
  match value {
    Null => { ..output, node_count: output.node_count + 1, maximum_depth }
    Omitted =>
      {
        ..output,
        node_count: output.node_count + 1,
        maximum_depth,
        omitted_count: output.omitted_count + 1,
      }
    Integer(_) =>
      {
        ..output,
        node_count: output.node_count + 1,
        maximum_depth,
        number_count: output.number_count + 1,
      }
    Real(_) =>
      {
        ..output,
        node_count: output.node_count + 1,
        maximum_depth,
        number_count: output.number_count + 1,
      }
    String(_) =>
      {
        ..output,
        node_count: output.node_count + 1,
        maximum_depth,
        string_count: output.string_count + 1,
      }
    Enumeration(_) =>
      { ..output, node_count: output.node_count + 1, maximum_depth }
    Binary(_) => { ..output, node_count: output.node_count + 1, maximum_depth }
    Reference(_) =>
      {
        ..output,
        node_count: output.node_count + 1,
        maximum_depth,
        reference_count: output.reference_count + 1,
      }
    Typed(name~, value~) => {
      ignore(name)
      value_stats_visit(value, depth, {
        ..output,
        node_count: output.node_count + 1,
        maximum_depth,
      })
    }
    List(values) => {
      let result = {
        ..output,
        node_count: output.node_count + 1,
        maximum_depth,
        list_count: output.list_count + 1,
      }
      values.fold(init=result, (current, item) => {
        value_stats_visit(item, next_depth, current)
      })
    }
  }
}

///|
pub fn value_stats(value : Value) -> ValueStats {
  value_stats_visit(value, 0, {
    node_count: 0,
    maximum_depth: 0,
    reference_count: 0,
    string_count: 0,
    number_count: 0,
    list_count: 0,
    omitted_count: 0,
  })
}

///|
pub fn model_value_stats(model : Model) -> ValueStats {
  model.entities.fold(
    init={
      node_count: 0,
      maximum_depth: 0,
      reference_count: 0,
      string_count: 0,
      number_count: 0,
      list_count: 0,
      omitted_count: 0,
    },
    (result, entity) => {
      entity.arguments.fold(init=result, (current, value) => {
        let item = value_stats(value)
        {
          node_count: current.node_count + item.node_count,
          maximum_depth: if current.maximum_depth > item.maximum_depth {
            current.maximum_depth
          } else {
            item.maximum_depth
          },
          reference_count: current.reference_count + item.reference_count,
          string_count: current.string_count + item.string_count,
          number_count: current.number_count + item.number_count,
          list_count: current.list_count + item.list_count,
          omitted_count: current.omitted_count + item.omitted_count,
        }
      })
    },
  )
}

///|
pub fn ValueStats::summary(self : ValueStats) -> String {
  "nodes=" +
  self.node_count.to_string() +
  ", depth=" +
  self.maximum_depth.to_string() +
  ", refs=" +
  self.reference_count.to_string() +
  ", strings=" +
  self.string_count.to_string() +
  ", numbers=" +
  self.number_count.to_string() +
  ", lists=" +
  self.list_count.to_string() +
  ", omitted=" +
  self.omitted_count.to_string()
}

///|
pub fn ValueStats::is_reasonable(
  self : ValueStats,
  maximum_depth : Int,
) -> Bool {
  self.maximum_depth <= maximum_depth && self.node_count >= 0
}

///|
pub fn value_references(value : Value) -> Array[Int] {
  match value {
    Reference(id) => [id]
    Typed(name~, value~) => {
      ignore(name)
      value_references(value)
    }
    List(values) =>
      values.fold(init=Array::new(), (result, item) => {
        result.append(value_references(item))
        result
      })
    _ => []
  }
}

///|
pub fn value_contains_reference(value : Value, target : Int) -> Bool {
  value_references(value).contains(target)
}

///|
pub fn value_maximum_depth(value : Value) -> Int {
  value_stats(value).maximum_depth
}

///|
fn source_parentheses(source : String) -> (Int, Bool, Bool) {
  let mut depth = 0
  let mut balanced = true
  let mut inside_string = false
  for character in source {
    if character == '\'' {
      inside_string = !inside_string
    } else if !inside_string {
      if character == '(' {
        depth += 1
      } else if character == ')' {
        depth -= 1
        if depth < 0 {
          balanced = false
          depth = 0
        }
      }
    }
  }
  (depth, balanced, !inside_string)
}

///|
pub fn source_health(source : String) -> SourceHealth {
  let stats = stream_statistics(source)
  let (depth, balanced, strings_balanced) = source_parentheses(source)
  {
    byte_length: source.length(),
    line_count: stats.line_count,
    record_count: stats.record_count,
    maximum_record_length: stats.max_record_length,
    reference_count: stats.reference_count,
    balanced_parentheses: balanced && depth == 0,
    balanced_strings: strings_balanced,
    has_iso_header: source.to_upper().has_prefix("ISO-10303-21"),
    has_end_marker: source.to_upper().contains("END-ISO-10303-21"),
  }
}

///|
pub fn SourceHealth::is_well_formed(self : SourceHealth) -> Bool {
  self.balanced_parentheses &&
  self.balanced_strings &&
  self.has_iso_header &&
  self.has_end_marker
}

///|
pub fn SourceHealth::summary(self : SourceHealth) -> String {
  "chars=" +
  self.byte_length.to_string() +
  ", lines=" +
  self.line_count.to_string() +
  ", records=" +
  self.record_count.to_string() +
  ", max_record=" +
  self.maximum_record_length.to_string() +
  ", refs=" +
  self.reference_count.to_string() +
  ", balanced=" +
  self.balanced_parentheses.to_string() +
  ", header=" +
  self.has_iso_header.to_string() +
  ", end=" +
  self.has_end_marker.to_string()
}

///|
pub fn source_window(source : String, start : Int, end : Int) -> SourceWindow {
  let length = source.length()
  let safe_start = if start < 0 {
    0
  } else if start > length {
    length
  } else {
    start
  }
  let safe_end = if end < safe_start {
    safe_start
  } else if end > length {
    length
  } else {
    end
  }
  let line = source[:safe_start].split("\n").length()
  {
    start: safe_start,
    end: safe_end,
    text: source[safe_start:safe_end].to_owned(),
    line,
  }
}

///|
pub fn source_line(source : String, line : Int) -> SourceWindow? {
  if line < 1 {
    return None
  }
  let lines = source.split("\n").to_array()
  match lines.get(line - 1) {
    Some(text) => {
      let start = lines[:line - 1].fold(init=0, (offset, item) => {
        offset + item.length() + 1
      })
      Some({ start, end: start + text.length(), text: text.to_owned(), line })
    }
    None => None
  }
}

///|
pub fn entity_range(model : Model) -> EntityRange {
  if model.entities.length() == 0 {
    return { first_id: 0, last_id: 0, entity_count: 0, contiguous: true }
  }
  let mut first = model.entities[0].id
  let mut last = first
  let ids : Map[Int, Bool] = Map([])
  for entity in model.entities {
    ids[entity.id] = true
    if entity.id < first {
      first = entity.id
    }
    if entity.id > last {
      last = entity.id
    }
  }
  let mut contiguous = true
  let end_id = last + 1
  for id in first.. String {
  "#" +
  self.first_id.to_string() +
  "..#" +
  self.last_id.to_string() +
  " (" +
  self.entity_count.to_string() +
  ", contiguous=" +
  self.contiguous.to_string() +
  ")"
}

///|
pub fn entity_ids_in_range(
  model : Model,
  first : Int,
  last : Int,
) -> Array[Int] {
  model.entities
  .filter(entity => entity.id >= first && entity.id <= last)
  .map(entity => entity.id)
}

///|
pub fn entity_names_case_insensitive(
  model : Model,
  name : String,
) -> Array[String] {
  let wanted = name.to_upper()
  model.entities
  .filter(entity => entity.name.to_upper() == wanted)
  .map(entity => entity.name)
}

///|
pub fn entity_shape_signature(entity : Entity) -> String {
  entity.name.to_upper() +
  "(" +
  entity.arguments.length().to_string() +
  ") refs=" +
  entity_references(entity).length().to_string()
}

///|
pub fn model_shape_signatures(model : Model) -> Array[String] {
  model.entities.map(entity_shape_signature)
}

///|
pub fn source_entity_names(source : String) -> Array[String] {
  scan_step_records(source).map(record => record.name)
}

///|
pub fn source_record_at(source : String, index : Int) -> StepRecord? {
  scan_step_records(source).get(index)
}

///|
pub fn source_range(source : String) -> EntityRange {
  let records = scan_step_records(source)
  if records.length() == 0 {
    return { first_id: 0, last_id: 0, entity_count: 0, contiguous: true }
  }
  let mut first = records[0].id
  let mut last = first
  let ids : Map[Int, Bool] = Map([])
  for record in records {
    ids[record.id] = true
    if record.id < first {
      first = record.id
    }
    if record.id > last {
      last = record.id
    }
  }
  let mut contiguous = true
  let end_id = last + 1
  for id in first.. Array[String] {
  let health = source_health(source)
  let result : Array[String] = []
  if !health.has_iso_header {
    result.push("missing ISO-10303-21 header")
  }
  if !health.has_end_marker {
    result.push("missing END-ISO-10303-21 marker")
  }
  if !health.balanced_parentheses {
    result.push("unbalanced parentheses outside strings")
  }
  if !health.balanced_strings {
    result.push("unterminated STEP string")
  }
  if health.record_count == 0 {
    result.push("no physical entity records found")
  }
  result
}

///|
pub fn source_is_safe_for_full_parse(
  source : String,
  maximum_chars : Int,
  maximum_records : Int,
) -> Bool {
  let health = source_health(source)
  (maximum_chars <= 0 || health.byte_length <= maximum_chars) &&
  (maximum_records <= 0 || health.record_count <= maximum_records) &&
  health.is_well_formed()
}

///|
pub fn normalize_step_newlines(source : String) -> String {
  source.replace(old="\r\n", new="\n").replace(old="\r", new="\n")
}

///|
pub fn source_line_count(source : String) -> Int {
  normalize_step_newlines(source).split("\n").length()
}

///|
pub fn source_reference_density(source : String) -> Double {
  let health = source_health(source)
  if health.byte_length == 0 {
    0.0
  } else {
    health.reference_count.to_double() / health.byte_length.to_double()
  }
}

///|
pub fn record_length_histogram(source : String) -> Map[Int, Int] {
  let result : Map[Int, Int] = Map([])
  for record in scan_step_records(source) {
    let bucket = record.length() / 10 * 10
    result.update_or_default(bucket, 1, count => count + 1)
  }
  result
}

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

///|
pub fn source_health_score(source : String) -> Int {
  let health = source_health(source)
  let mut score = 0
  if health.has_iso_header {
    score += 20
  }
  if health.has_end_marker {
    score += 20
  }
  if health.balanced_parentheses {
    score += 20
  }
  if health.balanced_strings {
    score += 20
  }
  if health.record_count > 0 {
    score += 20
  }
  score
}

///|
pub fn source_health_grade(source : String) -> String {
  let score = source_health_score(source)
  if score >= 100 {
    "A"
  } else if score >= 80 {
    "B"
  } else if score >= 60 {
    "C"
  } else {
    "D"
  }
}

///|
pub fn model_reference_targets(model : Model) -> Array[Int] {
  let result : Array[Int] = []
  for entity in model.entities {
    result.append(entity_references(entity))
  }
  result
}

///|
pub fn model_orphan_reference_ids(model : Model) -> Array[Int] {
  let index = model.index()
  let result : Array[Int] = []
  for target in model_reference_targets(model) {
    if !index.has(target) && !result.contains(target) {
      result.push(target)
    }
  }
  result
}

///|
pub fn source_window_for_record(
  source : String,
  record : StepRecord,
) -> SourceWindow {
  source_window(source, record.start_offset, record.end_offset)
}

///|
pub fn source_records_cover_document(source : String) -> Bool {
  let records = scan_step_records(source)
  for record in records {
    if record.start_offset < 0 ||
      record.end_offset > source.length() ||
      record.start_offset >= record.end_offset {
      return false
    }
  }
  true
}