///|
pub fn parse_yaml_stream(input : String) -> Array[Yaml] raise ParseError {
  let normalized = strip_bom(input)
  split_documents(normalized).map(doc => parse_document(doc))
}

///|
pub fn parse_yaml_stream_raw(input : String) -> Array[Yaml] raise ParseError {
  let normalized = strip_bom(input)
  split_documents(normalized).map(doc => parse_document_raw(doc))
}

///|
pub fn parse_yaml(input : String) -> Yaml raise ParseError {
  let docs = parse_yaml_stream(input)
  if docs.length() == 0 {
    Yaml::Null
  } else if docs.length() == 1 {
    docs[0]
  } else {
    raise ParseError::MultipleDocuments(count=docs.length())
  }
}

///|
fn parse_document(input : String) -> Yaml raise ParseError {
  let (ctx, content) = extract_directives(input, true)
  let trimmed = content.trim().to_string()
  if trimmed is "" {
    return Yaml::Null
  }
  if is_flow_document(content) {
    let scanner = Scanner::new(content)
    scanner.skip_ws_and_comments()
    let value = parse_flow_node(ctx, scanner)
    scanner.skip_ws_and_comments()
    match scanner.peek() {
      None => resolve_aliases(value)
      Some(c) =>
        raise ParseError::UnexpectedChar(pos=scanner.position(), found=c)
    }
  } else {
    resolve_aliases(parse_block_document(ctx, content))
  }
}

///|
fn parse_document_raw(input : String) -> Yaml raise ParseError {
  let (ctx, content) = extract_directives(input, false)
  let trimmed = content.trim().to_string()
  if trimmed is "" {
    return Yaml::Null
  }
  if is_flow_document(content) {
    let scanner = Scanner::new(content)
    scanner.skip_ws_and_comments()
    let value = parse_flow_node(ctx, scanner)
    scanner.skip_ws_and_comments()
    match scanner.peek() {
      None => value
      Some(c) =>
        raise ParseError::UnexpectedChar(pos=scanner.position(), found=c)
    }
  } else {
    parse_block_document(ctx, content)
  }
}

///|
fn strip_bom(input : String) -> String {
  if input.has_prefix("\u{feff}") {
    input.view(start_offset=1).to_string()
  } else {
    input
  }
}

///|
fn extract_directives(
  input : String,
  coerce_binary : Bool,
) -> (TagContext, String) raise ParseError {
  let ctx = if coerce_binary {
    TagContext::new()
  } else {
    TagContext::new_raw()
  }
  let normalized = input
    .replace_all(old="\r\n", new="\n")
    .replace_all(old="\r", new="\n")
  let sb = StringBuilder::new()
  let mut in_directives = true
  let mut wrote_line = false
  for line_view in normalized.split("\n") {
    let line = line_view.to_string()
    let trimmed = line.trim().to_string()
    if in_directives {
      if trimmed is "" || trimmed.has_prefix("#") {
        continue
      }
      if trimmed.has_prefix("%") {
        parse_directive(ctx, trimmed)
        continue
      }
      in_directives = false
    }
    if wrote_line {
      sb.write_char('\n')
    }
    sb.write_string(line)
    wrote_line = true
  }
  (ctx, sb.to_string())
}

///|
fn parse_directive(ctx : TagContext, line : String) -> Unit raise ParseError {
  let parts = split_ws(strip_inline_comment(line))
  if parts.length() == 0 {
    return
  }
  let name = parts[0]
  if name is "%TAG" {
    if parts.length() != 3 {
      raise ParseError::InvalidDirective(detail="TAG directive expects 2 args")
    }
    let handle = parts[1]
    let prefix = parts[2]
    ctx.set_handle(handle, prefix)
  } else if name is "%YAML" {
    if parts.length() != 2 {
      raise ParseError::InvalidDirective(
        detail="YAML directive expects version",
      )
    }
    if ctx.seen_yaml {
      raise ParseError::InvalidDirective(detail="duplicate YAML directive")
    }
    ctx.seen_yaml = true
    let version = parts[1]
    if !is_valid_yaml_version(version) {
      raise ParseError::InvalidDirective(detail="unsupported YAML version")
    }
  } else {
    // Ignore unknown or reserved directives per YAML 1.2.
    ()
  }
}

///|
fn is_valid_yaml_version(version : String) -> Bool {
  if !version.has_prefix("1.") {
    return false
  }
  let chars = version.to_array()
  let mut i = 2
  let mut saw_digit = false
  let mut prev_dot = false
  while i < chars.length() {
    let c = chars[i]
    if c.is_ascii_digit() {
      saw_digit = true
      prev_dot = false
    } else if c is '.' {
      if prev_dot {
        return false
      }
      prev_dot = true
    } else {
      return false
    }
    i = i + 1
  }
  saw_digit && !prev_dot
}

///|
fn split_ws(line : String) -> Array[String] {
  let parts : Array[String] = []
  let sb = StringBuilder::new()
  let mut in_token = false
  for c in line {
    if c.is_whitespace() {
      if in_token {
        parts.push(sb.to_string())
        sb.reset()
        in_token = false
      }
    } else {
      sb.write_char(c)
      in_token = true
    }
  }
  if in_token {
    parts.push(sb.to_string())
  }
  parts
}

///|
fn split_documents(input : String) -> Array[String] raise ParseError {
  let normalized = input
    .replace_all(old="\r\n", new="\n")
    .replace_all(old="\r", new="\n")
  let docs : Array[String] = []
  let sb = StringBuilder::new()
  let mut in_doc = false
  let mut in_directives = false
  let mut saw_directive = false
  let mut explicit_start = false
  let mut content_seen = false
  let mut wrote_line = false
  fn append_line(line : String) -> Unit {
    if wrote_line {
      sb.write_char('\n')
    }
    sb.write_string(line)
    wrote_line = true
  }
  fn validate_directives(
    saw_directive : Bool,
    explicit_start : Bool,
    content_seen : Bool,
  ) -> Unit raise ParseError {
    if saw_directive && !explicit_start {
      if content_seen {
        raise ParseError::InvalidDirective(
          detail="directive without document start",
        )
      } else {
        raise ParseError::InvalidDirective(detail="directive without document")
      }
    }
  }
  for line_view in normalized.split("\n") {
    let line = line_view.to_string()
    match doc_start_rest(line) {
      Some(rest) => {
        if in_doc {
          if !(in_directives && !content_seen) {
            validate_directives(saw_directive, explicit_start, content_seen)
            docs.push(sb.to_string())
            sb.reset()
            wrote_line = false
            saw_directive = false
          }
        }
        in_doc = true
        in_directives = false
        explicit_start = true
        content_seen = false
        if rest != "" {
          if split_key_value_opt(rest) is Some(_) {
            raise ParseError::InvalidDirective(
              detail="mapping on document start line",
            )
          }
          append_line(rest)
          if !is_non_content_line(rest) {
            content_seen = true
          }
        }
        continue
      }
      None => ()
    }
    if is_doc_end_marker(line) {
      if in_doc || wrote_line {
        validate_directives(saw_directive, explicit_start, content_seen)
        docs.push(sb.to_string())
        sb.reset()
        wrote_line = false
      }
      in_doc = false
      in_directives = false
      saw_directive = false
      explicit_start = false
      content_seen = false
      continue
    }
    let trimmed = line.trim().to_string()
    if !in_doc {
      if trimmed is "" || trimmed.has_prefix("#") {
        continue
      }
      if trimmed.has_prefix("%") {
        in_doc = true
        in_directives = true
        saw_directive = true
        append_line(line)
        continue
      }
      in_doc = true
      in_directives = false
      explicit_start = false
      if !is_non_content_line(line) {
        content_seen = true
      }
      append_line(line)
      continue
    }
    if in_directives {
      if trimmed is "" || trimmed.has_prefix("#") {
        continue
      }
      if trimmed.has_prefix("%") {
        saw_directive = true
        append_line(line)
        continue
      }
      validate_directives(saw_directive, explicit_start, content_seen)
      in_directives = false
      if !is_non_content_line(line) {
        content_seen = true
      }
      append_line(line)
      continue
    }
    if !is_non_content_line(line) {
      content_seen = true
    }
    append_line(line)
  }
  if in_doc || wrote_line {
    validate_directives(saw_directive, explicit_start, content_seen)
    docs.push(sb.to_string())
  }
  docs
}

///|
fn doc_start_rest(line : String) -> String? {
  let mut indent = 0
  let chars = line.to_array()
  while indent < chars.length() &&
        (chars[indent] is ' ' || chars[indent] is '\t') {
    indent = indent + 1
  }
  if indent > 0 {
    return None
  }
  let stripped = strip_inline_comment(line).trim().to_string()
  if stripped.has_prefix("---") {
    if stripped.length() == 3 {
      return Some("")
    }
    let next = stripped.get_char(3)
    match next {
      Some(c) if c.is_whitespace() =>
        Some(stripped.view(start_offset=3).trim_start().to_string())
      _ => None
    }
  } else {
    None
  }
}

///|
fn is_flow_document(text : String) -> Bool {
  let trimmed = text.trim_start().to_string()
  if !(trimmed.has_prefix("[") || trimmed.has_prefix("{")) {
    return false
  }
  let chars = trimmed.to_array()
  let mut depth = 0
  let mut in_single = false
  let mut in_double = false
  let mut i = 0
  while i < chars.length() {
    let c = chars[i]
    if in_double {
      if c is '\\' && i + 1 < chars.length() {
        i = i + 2
        continue
      }
      if c is '"' {
        in_double = false
      }
      i = i + 1
      continue
    }
    if in_single {
      if c is '\'' {
        in_single = false
      }
      i = i + 1
      continue
    }
    if c is '"' {
      in_double = true
      i = i + 1
      continue
    }
    if c is '\'' {
      in_single = true
      i = i + 1
      continue
    }
    if c is '[' || c is '{' {
      depth = depth + 1
    } else if c is ']' || c is '}' {
      if depth > 0 {
        depth = depth - 1
      }
      if depth == 0 {
        let rest = trimmed.view(start_offset=i + 1).to_string()
        let rest_trimmed = strip_inline_comment(rest).trim().to_string()
        return rest_trimmed is ""
      }
    }
    i = i + 1
  }
  false
}

///|
fn is_doc_end_marker(line : String) -> Bool {
  let mut indent = 0
  let chars = line.to_array()
  while indent < chars.length() &&
        (chars[indent] is ' ' || chars[indent] is '\t') {
    indent = indent + 1
  }
  if indent > 0 {
    return false
  }
  let stripped = strip_inline_comment(line).trim().to_string()
  stripped is "..."
}

///|
fn is_non_content_line(line : String) -> Bool {
  let stripped = strip_inline_comment(line).trim().to_string()
  stripped is "" || stripped.has_prefix("#") || stripped.has_prefix("%")
}