///| A dependency-free TOML reader.
///
/// pm3 reads configuration, not arbitrary data, so this implements the parts of
/// TOML 1.0 that configuration files actually use: comments, bare, quoted and
/// dotted keys, basic, literal and multiline strings, integers in every base,
/// floats, booleans, arrays, inline tables, `[tables]` and `[[arrays of
/// tables]]`. The result is a `Json` tree, which means TOML and JSON configs
/// converge immediately and only one loader has to be maintained downstream.

///|
/// Raised while reading a TOML document. It stays private: `parse_toml`
/// converts it to a `Result` so callers can report the failure with the file
/// name in context.
priv suberror TomlError {
  TomlError(String)
}

///|
/// A cursor over the source text plus the table that bare keys land in.
/// `root` and `table` are both `Map`s, and MoonBit `Map`s are reference types,
/// so a nested table written into the tree stays linked to the tree.
priv struct TomlParser {
  src : String
  len : Int
  mut pos : Int
  mut line : Int
  root : Map[String, Json]
  mut table : Map[String, Json]
}

///|
/// Parse a TOML document into the same `Json` shape that the JSON loader
/// produces, so both config formats share one downstream path.
///
/// The error string carries the offending line number.
pub fn parse_toml(source : String) -> Result[Json, String] {
  Ok(TomlParser::new(source).parse()) catch {
    TomlError(msg) => Err("toml: " + msg)
  }
}

///|
fn TomlParser::new(src : String) -> TomlParser {
  let root : Map[String, Json] = Map([])
  { src, len: src.length(), pos: 0, line: 1, root, table: root, }
}

// ---------------------------------------------------------------------------
// Cursor helpers
// ---------------------------------------------------------------------------

///|
fn TomlParser::peek(self : TomlParser) -> Char? {
  self.src.get_char(self.pos)
}

///|
fn TomlParser::peek_at(self : TomlParser, offset : Int) -> Char? {
  self.src.get_char(self.pos + offset)
}

///|
fn TomlParser::eof(self : TomlParser) -> Bool {
  self.pos >= self.len
}

///|
/// Consume one code unit, keeping the line counter honest.
fn TomlParser::bump(self : TomlParser) -> Unit {
  match self.src.get_char(self.pos) {
    Some('\n') => {
      self.line += 1
      self.pos += 1
    }
    Some(_) => self.pos += 1
    None => ()
  }
}

///|
/// Report a syntax error. The return type is generic so the call can stand in
/// for any expected value; it never actually returns.
fn[T] TomlParser::fail(self : TomlParser, msg : String) -> T raise TomlError {
  raise TomlError("line " + self.line.to_string() + ": " + msg)
}

// ---------------------------------------------------------------------------
// Whitespace and comments
// ---------------------------------------------------------------------------

///|
fn TomlParser::skip_spaces(self : TomlParser) -> Unit {
  while true {
    match self.peek() {
      Some(' ') | Some('\t') => self.bump()
      _ => break
    }
  }
}

///|
fn TomlParser::skip_comment(self : TomlParser) -> Unit {
  if self.peek() == Some('#') {
    while true {
      match self.peek() {
        Some('\n') | None => break
        Some(_) => self.bump()
      }
    }
  }
}

///|
/// Skip whitespace, comments and blank lines, as allowed between top-level
/// constructs and inside arrays.
fn TomlParser::skip_blank(self : TomlParser) -> Unit {
  while true {
    self.skip_spaces()
    match self.peek() {
      Some('#') => self.skip_comment()
      Some('\n') | Some('\r') => self.bump()
      _ => break
    }
  }
}

///|
/// Consume the remainder of a key/value line, rejecting trailing junk.
fn TomlParser::finish_line(self : TomlParser) -> Unit raise TomlError {
  self.skip_spaces()
  self.skip_comment()
  match self.peek() {
    None => ()
    Some('\n') | Some('\r') => self.bump()
    Some(c) => self.fail("unexpected '" + c.to_string() + "' after a value")
  }
}

// ---------------------------------------------------------------------------
// Keys
// ---------------------------------------------------------------------------

///|
/// A bare key is `A-Za-z0-9_-`; anything else has to be quoted.
fn is_bare_key_char(c : Char) -> Bool {
  let n = c.to_int()
  (n >= 97 && n <= 122) ||
  (n >= 65 && n <= 90) ||
  (n >= 48 && n <= 57) ||
  n == 95 ||
  n == 45
}

///|
fn TomlParser::parse_key(self : TomlParser) -> String raise TomlError {
  match self.peek() {
    Some('"') => self.parse_basic_string()
    Some('\'') => self.parse_literal_string()
    None => self.fail("unexpected end of file while reading a key")
    Some(c) =>
      if is_bare_key_char(c) {
        let buf = StringBuilder()
        while true {
          match self.peek() {
            Some(ch) =>
              if is_bare_key_char(ch) {
                buf.write_char(ch)
                self.bump()
              } else {
                break
              }
            None => break
          }
        }
        buf.to_string()
      } else {
        self.fail("unexpected '" + c.to_string() + "' in a key")
      }
  }
}

///|
/// Read a dotted key such as `logrotate.max_size`.
fn TomlParser::parse_key_path(
  self : TomlParser,
) -> Array[String] raise TomlError {
  let path : Array[String] = []
  while true {
    self.skip_spaces()
    path.push(self.parse_key())
    self.skip_spaces()
    if self.peek() == Some('.') {
      self.bump()
      continue
    }
    break
  }
  path
}

// ---------------------------------------------------------------------------
// Strings
// ---------------------------------------------------------------------------

///|
fn hex_value(c : Char) -> Int? {
  let n = c.to_int()
  if n >= 48 && n <= 57 {
    Some(n - 48)
  } else if n >= 97 && n <= 102 {
    Some(n - 87)
  } else if n >= 65 && n <= 70 {
    Some(n - 55)
  } else {
    None
  }
}

///|
/// Write the character named by `\uXXXX` or `\UXXXXXXXX`. Code points outside
/// the BMP are emitted as a UTF-16 surrogate pair.
fn TomlParser::write_unicode_escape(
  self : TomlParser,
  buf : StringBuilder,
  digits : Int,
) -> Unit raise TomlError {
  self.bump() // 'u' or 'U'
  let mut code = 0
  for _i in 0.. c
      None => self.fail("unterminated unicode escape")
    }
    let v = match hex_value(c) {
      Some(v) => v
      None =>
        self.fail("bad hex digit '" + c.to_string() + "' in a unicode escape")
    }
    code = code * 16 + v
    self.bump()
  }
  if code > 0x10FFFF {
    self.fail("unicode escape out of range")
  }
  if code > 0xFFFF {
    let v = code - 0x10000
    buf.write_char((0xD800 + (v >> 10)).unsafe_to_char())
    buf.write_char((0xDC00 + (v & 0x3FF)).unsafe_to_char())
  } else {
    buf.write_char(code.unsafe_to_char())
  }
}

///|
fn TomlParser::write_escape(
  self : TomlParser,
  buf : StringBuilder,
) -> Unit raise TomlError {
  self.bump() // the backslash
  match self.peek() {
    None => self.fail("unterminated escape sequence")
    Some('b') => {
      buf.write_char('\u{8}')
      self.bump()
    }
    Some('t') => {
      buf.write_char('\t')
      self.bump()
    }
    Some('n') => {
      buf.write_char('\n')
      self.bump()
    }
    Some('f') => {
      buf.write_char('\u{c}')
      self.bump()
    }
    Some('r') => {
      buf.write_char('\r')
      self.bump()
    }
    Some('"') => {
      buf.write_char('"')
      self.bump()
    }
    Some('\\') => {
      buf.write_char('\\')
      self.bump()
    }
    Some('u') => self.write_unicode_escape(buf, 4)
    Some('U') => self.write_unicode_escape(buf, 8)
    Some(c) => self.fail("unknown escape '\\" + c.to_string() + "'")
  }
}

///|
/// Read a basic string, `"..."` or `"""..."""`. The caller has already looked
/// at the opening quote.
fn TomlParser::parse_basic_string(self : TomlParser) -> String raise TomlError {
  self.bump()
  let multiline = self.peek() == Some('"') && self.peek_at(1) == Some('"')
  if multiline {
    self.bump()
    self.bump()
    // A newline immediately after the opening delimiter is not part of the value.
    if self.peek() == Some('\r') {
      self.bump()
    }
    if self.peek() == Some('\n') {
      self.bump()
    }
  }
  let buf = StringBuilder()
  while true {
    match self.peek() {
      None => self.fail("unterminated string")
      Some('"') =>
        if multiline {
          if self.peek_at(1) == Some('"') && self.peek_at(2) == Some('"') {
            self.bump()
            self.bump()
            self.bump()
            break
          }
          buf.write_char('"')
          self.bump()
        } else {
          self.bump()
          break
        }
      Some('\\') => self.write_escape(buf)
      Some('\n') =>
        if multiline {
          buf.write_char('\n')
          self.bump()
        } else {
          self.fail("unterminated string")
        }
      Some(c) => {
        buf.write_char(c)
        self.bump()
      }
    }
  }
  buf.to_string()
}

///|
/// Read a literal string, `'...'` or `'''...'''`. Literal strings take no
/// escapes at all, which makes them the right choice for Windows paths.
fn TomlParser::parse_literal_string(
  self : TomlParser,
) -> String raise TomlError {
  self.bump()
  let multiline = self.peek() == Some('\'') && self.peek_at(1) == Some('\'')
  if multiline {
    self.bump()
    self.bump()
    if self.peek() == Some('\r') {
      self.bump()
    }
    if self.peek() == Some('\n') {
      self.bump()
    }
  }
  let buf = StringBuilder()
  while true {
    match self.peek() {
      None => self.fail("unterminated string")
      Some('\'') =>
        if multiline {
          if self.peek_at(1) == Some('\'') && self.peek_at(2) == Some('\'') {
            self.bump()
            self.bump()
            self.bump()
            break
          }
          buf.write_char('\'')
          self.bump()
        } else {
          self.bump()
          break
        }
      Some('\n') =>
        if multiline {
          buf.write_char('\n')
          self.bump()
        } else {
          self.fail("unterminated string")
        }
      Some(c) => {
        buf.write_char(c)
        self.bump()
      }
    }
  }
  buf.to_string()
}

// ---------------------------------------------------------------------------
// Values
// ---------------------------------------------------------------------------

///|
fn TomlParser::parse_value(self : TomlParser) -> Json raise TomlError {
  match self.peek() {
    None => self.fail("expected a value")
    Some('"') => Json::string(self.parse_basic_string())
    Some('\'') => Json::string(self.parse_literal_string())
    Some('[') => self.parse_array()
    Some('{') => self.parse_inline_table()
    Some(_) => self.parse_scalar()
  }
}

///|
/// An array, possibly spanning several lines with comments between elements.
fn TomlParser::parse_array(self : TomlParser) -> Json raise TomlError {
  self.bump() // '['
  let items : Array[Json] = []
  while true {
    self.skip_blank()
    if self.eof() {
      self.fail("unterminated array")
    }
    if self.peek() == Some(']') {
      self.bump()
      break
    }
    items.push(self.parse_value())
    self.skip_blank()
    match self.peek() {
      Some(',') => {
        self.bump()
        continue
      }
      Some(']') => {
        self.bump()
        break
      }
      Some(c) =>
        self.fail(
          "expected ',' or ']' in an array, found '" + c.to_string() + "'",
        )
      None => self.fail("unterminated array")
    }
  }
  Json::array(items)
}

///|
fn TomlParser::parse_inline_table(self : TomlParser) -> Json raise TomlError {
  self.bump() // '{'
  let map : Map[String, Json] = Map([])
  self.skip_spaces()
  if self.peek() == Some('}') {
    self.bump()
    return Json::object(map)
  }
  while true {
    self.skip_spaces()
    let path = self.parse_key_path()
    self.skip_spaces()
    if self.peek() != Some('=') {
      self.fail("expected '=' in an inline table")
    }
    self.bump()
    self.skip_spaces()
    let value = self.parse_value()
    self.assign_into(map, path, value)
    self.skip_spaces()
    match self.peek() {
      Some(',') => {
        self.bump()
        continue
      }
      Some('}') => {
        self.bump()
        break
      }
      Some(c) =>
        self.fail(
          "expected ',' or '}' in an inline table, found '" +
          c.to_string() +
          "'",
        )
      None => self.fail("unterminated inline table")
    }
  }
  Json::object(map)
}

///|
/// Remove the `_` separators TOML allows between digits.
fn strip_underscores(s : String) -> String {
  let buf = StringBuilder()
  for i in 0.. ()
      Some(c) => buf.write_char(c)
      None => ()
    }
  }
  buf.to_string()
}

///|
/// Accumulate digits in the given radix. `None` means the token is not a
/// well-formed number in that base.
fn parse_digits(s : String, radix : Int64) -> Int64? {
  let mut acc : Int64 = 0L
  let mut any = false
  for i in 0.. ()
      Some(c) => {
        let d = match hex_value(c) {
          Some(d) => d
          None => return None
        }
        if d.to_int64() >= radix {
          return None
        }
        acc = acc * radix + d.to_int64()
        any = true
      }
      None => ()
    }
  }
  if any {
    Some(acc)
  } else {
    None
  }
}

///|
/// A TOML date or time literal always carries a separator that a number
/// cannot: a `:` in a time, or the `-`s of a date. pm3 keeps them as strings,
/// since configuration never does arithmetic on a timestamp.
fn looks_like_datetime(t : String) -> Bool {
  if t.contains(":") {
    return true
  }
  t.length() >= 10 && t.get_char(4) == Some('-') && t.get_char(7) == Some('-')
}

///|
/// Read an unquoted token: a boolean, a number or a date/time literal.
fn TomlParser::parse_scalar(self : TomlParser) -> Json raise TomlError {
  let start = self.pos
  while true {
    match self.peek() {
      None => break
      Some(c) =>
        if c == ',' ||
          c == ']' ||
          c == '}' ||
          c == '#' ||
          c == '\n' ||
          c == '\r' ||
          c == ' ' ||
          c == '\t' {
          break
        } else {
          self.bump()
        }
    }
  }
  let token = self.src[start:self.pos].to_owned()
  if token.is_empty() {
    self.fail("expected a value")
  }
  if token == "true" {
    return Json::boolean(true)
  }
  if token == "false" {
    return Json::boolean(false)
  }
  if looks_like_datetime(token) {
    return Json::string(token)
  }
  self.parse_number(token)
}

///|
fn TomlParser::parse_number(
  self : TomlParser,
  token : String,
) -> Json raise TomlError {
  let t = strip_underscores(token)
  let negative = t.has_prefix("-")
  let unsigned = if negative || t.has_prefix("+") {
    t[1:].to_owned()
  } else {
    t
  }
  if unsigned.is_empty() {
    self.fail("'" + token + "' is not a number")
  }
  let radix = if unsigned.has_prefix("0x") || unsigned.has_prefix("0X") {
    16L
  } else if unsigned.has_prefix("0o") || unsigned.has_prefix("0O") {
    8L
  } else if unsigned.has_prefix("0b") || unsigned.has_prefix("0B") {
    2L
  } else {
    0L
  }
  if radix != 0L {
    let digits = unsigned[2:].to_owned()
    match parse_digits(digits, radix) {
      Some(v) =>
        if negative {
          Json::number(-v.to_double())
        } else {
          Json::number(v.to_double())
        }
      None =>
        self.fail(
          "'" +
          token +
          "' is not a valid base-" +
          radix.to_string() +
          " integer",
        )
    }
  } else if unsigned == "inf" {
    if negative {
      Json::number(@double.neg_infinity)
    } else {
      Json::number(@double.infinity)
    }
  } else if unsigned == "nan" {
    Json::number(@double.not_a_number)
  } else {
    let cleaned = if negative { "-" + unsigned } else { unsigned }
    let parsed : Double? = Some(@string.parse_double(cleaned)) catch {
      _ => None
    }
    match parsed {
      Some(v) => Json::number(v)
      None => self.fail("'" + token + "' is not a number")
    }
  }
}

// ---------------------------------------------------------------------------
// Tables
// ---------------------------------------------------------------------------

///|
/// Walk a dotted path from `base`, creating intermediate tables. Used both by
/// dotted keys and by table headers.
fn TomlParser::descend(
  self : TomlParser,
  base : Map[String, Json],
  path : Array[String],
  count : Int,
) -> Map[String, Json] raise TomlError {
  let mut target = base
  for i in 0.. m
      Some(Json::Array(a)) =>
        // A dotted path through an array of tables refers to its last element.
        if a.length() == 0 {
          self.fail("'" + key + "' is an empty array of tables")
        } else {
          match a[a.length() - 1] {
            Json::Object(m) => m
            _ => self.fail("'" + key + "' is not a table")
          }
        }
      Some(_) => self.fail("'" + key + "' is already a value, not a table")
      None => {
        let m : Map[String, Json] = Map([])
        target.set(key, Json::object(m))
        m
      }
    }
  }
  target
}

///|
/// Store `value` at a dotted path relative to `base`.
fn TomlParser::assign_into(
  self : TomlParser,
  base : Map[String, Json],
  path : Array[String],
  value : Json,
) -> Unit raise TomlError {
  if path.is_empty() {
    self.fail("empty key")
  }
  let target = self.descend(base, path, path.length() - 1)
  target.set(path[path.length() - 1], value)
}

///|
/// `[table]` or `[[array of tables]]`. The header switches the table that
/// subsequent bare keys are written into.
fn TomlParser::parse_table_header(self : TomlParser) -> Unit raise TomlError {
  self.bump() // '['
  let is_array = self.peek() == Some('[')
  if is_array {
    self.bump()
  }
  self.skip_spaces()
  let path = self.parse_key_path()
  self.skip_spaces()
  if self.peek() != Some(']') {
    self.fail("expected ']' to close a table header")
  }
  self.bump()
  if is_array {
    if self.peek() != Some(']') {
      self.fail("expected ']]' to close an array-of-tables header")
    }
    self.bump()
  }
  let last = path.length() - 1
  let parent = self.descend(self.root, path, last)
  let key = path[last]
  if is_array {
    let arr = match parent.get(key) {
      Some(Json::Array(a)) => a
      Some(_) =>
        self.fail(
          "'" + key + "' is already defined and is not an array of tables",
        )
      None => {
        let a : Array[Json] = []
        parent.set(key, Json::array(a))
        a
      }
    }
    let element : Map[String, Json] = Map([])
    arr.push(Json::object(element))
    self.table = element
  } else {
    self.table = match parent.get(key) {
      Some(Json::Object(m)) => m
      Some(_) =>
        self.fail("'" + key + "' is already defined and is not a table")
      None => {
        let m : Map[String, Json] = Map([])
        parent.set(key, Json::object(m))
        m
      }
    }
  }
}

///|
fn TomlParser::parse_key_value(self : TomlParser) -> Unit raise TomlError {
  let path = self.parse_key_path()
  self.skip_spaces()
  if self.peek() != Some('=') {
    self.fail("expected '=' after a key")
  }
  self.bump()
  self.skip_spaces()
  let value = self.parse_value()
  self.assign_into(self.table, path, value)
}

// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------

///|
fn TomlParser::parse(self : TomlParser) -> Json raise TomlError {
  while true {
    self.skip_blank()
    if self.eof() {
      break
    }
    match self.peek() {
      Some('[') => self.parse_table_header()
      _ => self.parse_key_value()
    }
    self.finish_line()
  }
  Json::object(self.root)
}