///|
/// A logical, significant YAML line: its indentation (leading-space count) and
/// its content with the indentation and any trailing `# comment` removed. Blank
/// and comment-only lines are dropped during tokenisation, so every `YamlLine`
/// carries structure.
priv struct YamlLine {
  indent : Int
  text : String
}

///|
/// Whether `c` is an ASCII space or tab.
fn is_space(c : Int) -> Bool {
  c == ' '.to_int() || c == '\t'.to_int()
}

///|
/// Strip leading and trailing ASCII whitespace from `s`.
fn yaml_trim(s : String) -> String {
  let mut start = 0
  let mut end = s.length()
  while start < end && is_space(s[start].to_int()) {
    start = start + 1
  }
  while end > start && is_space(s[end - 1].to_int()) {
    end = end - 1
  }
  s[start:end].to_owned()
}

///|
/// Remove an unquoted trailing comment: a `#` at the start of the content or
/// preceded by whitespace begins a comment. A `#` inside a quoted scalar or
/// glued to a non-space character is kept (minimal-subset rule, documented on
/// `yaml_parse`).
fn strip_comment(s : String) -> String {
  let mut quote = 0
  for i = 0; i < s.length(); i = i + 1 {
    let c = s[i].to_int()
    if quote != 0 {
      if c == quote {
        quote = 0
      }
    } else if c == '"'.to_int() || c == '\''.to_int() {
      quote = c
    } else if c == '#'.to_int() && (i == 0 || is_space(s[i - 1].to_int())) {
      return s[0:i].to_owned()
    }
  }
  s
}

///|
/// Split raw source into significant `YamlLine`s: strip comments, drop blank
/// lines, and record each surviving line's indentation.
fn yaml_tokenize(src : String) -> Array[YamlLine] {
  let out : Array[YamlLine] = []
  let sb = StringBuilder::new()
  let raw : Array[String] = []
  for i = 0; i < src.length(); i = i + 1 {
    let c = src[i].to_int()
    if c == '\n'.to_int() {
      raw.push(sb.to_string())
      sb.reset()
    } else if c != '\r'.to_int() {
      sb.write_char(src[i].unsafe_to_char())
    }
  }
  raw.push(sb.to_string())
  for line in raw {
    let mut indent = 0
    while indent < line.length() && line[indent].to_int() == ' '.to_int() {
      indent = indent + 1
    }
    let content = yaml_trim(strip_comment(line[indent:].to_owned()))
    if content.length() > 0 {
      out.push({ indent, text: content })
    }
  }
  out
}

///|
/// Whether `s` begins a block sequence item (`-` alone or `- ...`).
fn is_seq_item(s : String) -> Bool {
  s.length() > 0 &&
  s[0].to_int() == '-'.to_int() &&
  (s.length() == 1 || is_space(s[1].to_int()))
}

///|
/// The index of the `:` separating a mapping key from its value: the first `:`
/// at end-of-line or followed by whitespace, outside quotes. `None` if the line
/// is not a mapping entry.
fn key_sep(s : String) -> Int? {
  let mut quote = 0
  for i = 0; i < s.length(); i = i + 1 {
    let c = s[i].to_int()
    if quote != 0 {
      if c == quote {
        quote = 0
      }
    } else if c == '"'.to_int() || c == '\''.to_int() {
      quote = c
    } else if c == ':'.to_int() &&
      (i + 1 == s.length() || is_space(s[i + 1].to_int())) {
      return Some(i)
    }
  }
  None
}

///|
/// Parse a scalar token to JSON: quoted strings verbatim, `~`/`null`/empty to
/// null, `true`/`false` to booleans, integer/float literals to numbers, and
/// anything else to a plain string.
fn yaml_scalar(raw : String) -> Json {
  let s = yaml_trim(raw)
  if s.length() == 0 || s == "~" || s == "null" {
    return Json::null()
  }
  if s == "true" {
    return Json::boolean(true)
  }
  if s == "false" {
    return Json::boolean(false)
  }
  let first = s[0].to_int()
  if (first == '"'.to_int() || first == '\''.to_int()) &&
    s.length() >= 2 &&
    s[s.length() - 1].to_int() == first {
    return Json::string(s[1:s.length() - 1].to_owned())
  }
  match parse_number(s) {
    Some(n) => Json::number(n)
    None => Json::string(s)
  }
}

///|
/// Parse a decimal integer or float literal (optional leading `-`, optional
/// single `.`) to a `Double`; `None` for anything that is not wholly numeric, so
/// `yaml_scalar` falls back to a string. Hand-written: core's `strconv` is not
/// imported here and YAML's numeric grammar is this narrow subset.
fn parse_number(s : String) -> Double? {
  if s.length() == 0 {
    return None
  }
  let mut i = 0
  let mut neg = false
  if s[0].to_int() == '-'.to_int() {
    neg = true
    i = 1
  }
  if i >= s.length() {
    return None
  }
  let mut int_part = 0.0
  let mut seen_digit = false
  while i < s.length() &&
        s[i].to_int() >= '0'.to_int() &&
        s[i].to_int() <= '9'.to_int() {
    int_part = int_part * 10.0 + (s[i].to_int() - '0'.to_int()).to_double()
    seen_digit = true
    i = i + 1
  }
  let mut value = int_part
  if i < s.length() && s[i].to_int() == '.'.to_int() {
    i = i + 1
    let mut frac = 0.0
    let mut scale = 1.0
    while i < s.length() &&
          s[i].to_int() >= '0'.to_int() &&
          s[i].to_int() <= '9'.to_int() {
      frac = frac * 10.0 + (s[i].to_int() - '0'.to_int()).to_double()
      scale = scale * 10.0
      seen_digit = true
      i = i + 1
    }
    value = value + frac / scale
  }
  if !seen_digit || i != s.length() {
    return None
  }
  Some(if neg { -value } else { value })
}

///|
/// Parse a mapping at indentation `ind`, consuming consecutive entries at that
/// level (a value-less `key:` opens a nested block at deeper indent). `cur`
/// tracks the cursor into `lines`.
fn yaml_mapping(
  lines : Array[YamlLine],
  cur : Ref[Int],
  ind : Int,
) -> Json raise ConfigError {
  let m : Map[String, Json] = Map([])
  while cur.val < lines.length() &&
        lines[cur.val].indent == ind &&
        !is_seq_item(lines[cur.val].text) {
    let line = lines[cur.val]
    let sep = match key_sep(line.text) {
      Some(p) => p
      None => raise ConfigError("YAML: expected 'key: value', got " + line.text)
    }
    let key = unquote_key(yaml_trim(line.text[0:sep].to_owned()))
    let rest = yaml_trim(line.text[sep + 1:].to_owned())
    cur.val = cur.val + 1
    if rest.length() == 0 {
      m[key] = yaml_block(lines, cur, ind)
    } else {
      m[key] = yaml_scalar(rest)
    }
  }
  Json::object(m)
}

///|
/// Strip surrounding quotes from a mapping key, if present.
fn unquote_key(s : String) -> String {
  if s.length() >= 2 {
    let q = s[0].to_int()
    if (q == '"'.to_int() || q == '\''.to_int()) &&
      s[s.length() - 1].to_int() == q {
      return s[1:s.length() - 1].to_owned()
    }
  }
  s
}

///|
/// Parse a sequence at indentation `ind`, consuming consecutive `-` items. An
/// item may be a scalar, a nested block, or an inline mapping (`- key: value`,
/// possibly extended by aligned continuation lines).
fn yaml_sequence(
  lines : Array[YamlLine],
  cur : Ref[Int],
  ind : Int,
) -> Json raise ConfigError {
  let arr : Array[Json] = []
  while cur.val < lines.length() &&
        lines[cur.val].indent == ind &&
        is_seq_item(lines[cur.val].text) {
    let text = lines[cur.val].text
    // content after the leading '-', and the column that content starts at
    let mut j = 1
    while j < text.length() && is_space(text[j].to_int()) {
      j = j + 1
    }
    let content = text[j:].to_owned()
    let key_col = ind + j
    if content.length() == 0 {
      cur.val = cur.val + 1
      arr.push(yaml_block(lines, cur, ind + 1))
    } else if key_sep(content) is Some(_) {
      // rewrite this line as a mapping entry at the key column, then fold in
      // any aligned continuation lines belonging to the same item
      lines[cur.val] = { indent: key_col, text: content }
      arr.push(yaml_mapping(lines, cur, key_col))
    } else {
      cur.val = cur.val + 1
      arr.push(yaml_scalar(content))
    }
  }
  Json::array(arr)
}

///|
/// Parse the block starting at the cursor: dispatch to a sequence, a mapping, or
/// (for a lone deeper-indented scalar) a scalar, using the first line's shape.
fn yaml_block(
  lines : Array[YamlLine],
  cur : Ref[Int],
  parent_ind : Int,
) -> Json raise ConfigError {
  if cur.val >= lines.length() || lines[cur.val].indent <= parent_ind {
    return Json::null()
  }
  let ind = lines[cur.val].indent
  if is_seq_item(lines[cur.val].text) {
    yaml_sequence(lines, cur, ind)
  } else if key_sep(lines[cur.val].text) is Some(_) {
    yaml_mapping(lines, cur, ind)
  } else {
    let v = yaml_scalar(lines[cur.val].text)
    cur.val = cur.val + 1
    v
  }
}

///|
/// Parse a **minimal YAML subset** into a `Json` value: block mappings
/// (`key: value`), arbitrary indentation-based nesting, block sequences (`-
/// item`, including `- key: value` maps in a list), scalars (quoted/plain
/// strings, integers, floats, `true`/`false`, `~`/`null`), and `#` line
/// comments. Enough of YAML 1.1 to load go-zero-style service config. Flow style
/// (`{a: 1}`, `[1, 2]`), anchors/aliases, multi-document streams, and block
/// scalars (`|`/`>`) are **not** supported — use JSON for those. Raises
/// `ConfigError` on a line that is neither a mapping entry nor a sequence item.
pub fn yaml_parse(src : String) -> Json raise ConfigError {
  let lines = yaml_tokenize(src)
  if lines.length() == 0 {
    return Json::object(Map([]))
  }
  let cur : Ref[Int] = { val: 0 }
  let root_ind = lines[0].indent
  let doc = if is_seq_item(lines[0].text) {
    yaml_sequence(lines, cur, root_ind)
  } else if key_sep(lines[0].text) is Some(_) {
    yaml_mapping(lines, cur, root_ind)
  } else {
    let v = yaml_scalar(lines[0].text)
    cur.val = cur.val + 1
    v
  }
  if cur.val != lines.length() {
    raise ConfigError("YAML: unexpected line " + lines[cur.val].text)
  }
  doc
}