///| Parses INI text using default tolerant options.
pub fn parse_ini(name : String, text : String) -> ParseResult {
  parse_ini_with_options(name, text, default_parse_options())
}

///| Parses Java-style properties text using defaults close to INI mode.
pub fn parse_properties(name : String, text : String) -> ParseResult {
  let options = default_parse_options()
  parse_properties_with_options(name, text, options)
}

///| Parses INI text with explicit options.
pub fn parse_ini_with_options(name : String, text : String, options : ParseOptions) -> ParseResult {
  parse_lines(name, split_lines(normalize_newlines(text)), options, false)
}

///| Parses properties text with explicit options.
pub fn parse_properties_with_options(name : String, text : String, options : ParseOptions) -> ParseResult {
  parse_lines(name, join_property_continuations(split_lines(normalize_newlines(text))), options, true)
}

fn parse_lines(name : String, lines : Array[String], options : ParseOptions, properties_mode : Bool) -> ParseResult {
  let entries : Array[ConfigEntry] = []
  let diagnostics : Array[Diagnostic] = []
  let mut section = ""
  let mut pending_comments : Array[String] = []
  let seen : Array[String] = []
  let mut line_number = 1
  for raw_line in lines {
    let trimmed = raw_line.trim()
    if trimmed == "" {
      if !options.preserve_comments {
        pending_comments = []
      }
    } else if is_comment(trimmed.to_owned()) {
      if options.preserve_comments {
        pending_comments.push(strip_comment_marker(trimmed.to_owned()))
      }
    } else if !properties_mode && is_section_line(trimmed.to_owned()) {
      let parsed = parse_section_name(trimmed.to_owned())
      if parsed == "" && options.strict_sections {
        diagnostics.push(error("empty-section", "empty section names are not allowed", span=Some(span(name, line_number, 1))))
      }
      section = parsed
      pending_comments = []
    } else {
      let sep = find_separator(raw_line, options.allow_colon_separator)
      match sep {
        None => {
          if options.allow_empty_value {
            let key = trimmed.to_owned()
            add_entry(
              entries,
              diagnostics,
              seen,
              section,
              key,
              "",
              name,
              line_number,
              pending_comments,
            )
            pending_comments = []
          } else {
            diagnostics.push(error("missing-separator", "expected '=' or ':' between key and value", span=Some(span(name, line_number, 1))))
          }
        }
        Some(index) => {
          let key = raw_line[0:index].to_owned().trim().to_owned()
          let value = raw_line[index + 1:raw_line.length()].to_owned().trim().to_owned()
          if key == "" {
            diagnostics.push(error("empty-key", "configuration keys must not be empty", span=Some(span(name, line_number, 1))))
          } else {
            add_entry(
              entries,
              diagnostics,
              seen,
              section,
              key,
              unquote_value(value),
              name,
              line_number,
              pending_comments,
            )
          }
          pending_comments = []
        }
      }
    }
    line_number = line_number + 1
  }
  let document = { name, entries, diagnostics }
  { document, ok: diagnostics_ok(diagnostics) }
}

fn add_entry(
  entries : Array[ConfigEntry],
  diagnostics : Array[Diagnostic],
  seen : Array[String],
  section : String,
  key : String,
  value : String,
  source : String,
  line_number : Int,
  comments : Array[String],
) -> Unit {
  let path = entry_path(section, key)
  if contains_string(seen, path) {
    diagnostics.push(warning("duplicate-key", "duplicate key '" + path + "' overrides an earlier value", span=Some(span(source, line_number, 1))))
  }
  seen.push(path)
  entries.push({
    section,
    key,
    value,
    source,
    span: span(source, line_number, 1),
    comments: comments.copy(),
  })
}

fn normalize_newlines(text : String) -> String {
  text.replace(old="\r\n", new="\n").replace(old="\r", new="\n")
}

fn split_lines(text : String) -> Array[String] {
  text.split("\n").map(line => line.to_owned()).collect()
}

fn is_comment(text : String) -> Bool {
  text.has_prefix(";") || text.has_prefix("#")
}

fn strip_comment_marker(text : String) -> String {
  if text.length() <= 1 {
    ""
  } else {
    text[1:text.length()].to_owned().trim().to_owned()
  }
}

fn is_section_line(text : String) -> Bool {
  text.has_prefix("[") && text.has_suffix("]")
}

fn parse_section_name(text : String) -> String {
  if text.length() <= 2 {
    ""
  } else {
    text[1:text.length() - 1].to_owned().trim().to_owned()
  }
}

fn find_separator(line : String, allow_colon : Bool) -> Int? {
  let mut index = 0
  let mut escaped = false
  while index < line.length() {
    let ch = line[index]
    if escaped {
      escaped = false
    } else if ch == '\\' {
      escaped = true
    } else if ch == '=' || (allow_colon && ch == ':') {
      return Some(index)
    }
    index = index + 1
  }
  None
}

fn unquote_value(value : String) -> String {
  if value.length() >= 2 {
    if (value.has_prefix("\"") && value.has_suffix("\"")) || (value.has_prefix("'") && value.has_suffix("'")) {
      value[1:value.length() - 1].to_owned()
    } else {
      value
    }
  } else {
    value
  }
}

fn contains_string(values : Array[String], target : String) -> Bool {
  for value in values {
    if value == target {
      return true
    }
  }
  false
}

fn join_property_continuations(lines : Array[String]) -> Array[String] {
  let joined : Array[String] = []
  let mut current = ""
  let mut active = false
  for line in lines {
    if active {
      current = current + line.trim().to_owned()
    } else {
      current = line
      active = true
    }
    if ends_with_unescaped_backslash(current) {
      current = current[0:current.length() - 1].to_owned()
    } else {
      joined.push(current)
      current = ""
      active = false
    }
  }
  if active {
    joined.push(current)
  }
  joined
}

fn ends_with_unescaped_backslash(text : String) -> Bool {
  if text.length() == 0 || !text.has_suffix("\\") {
    false
  } else {
    let mut count = 0
    let mut index = text.length() - 1
    while index >= 0 && text[index] == '\\' {
      count = count + 1
      index = index - 1
    }
    count % 2 == 1
  }
}