///|
/// A supported MoonBit configuration file.
pub(all) enum Kind {
  Mod
  Pkg
  Work
} derive(Eq, Debug)

///|
/// A diagnostic reported by moon_config or by the lossless edit layer.
pub struct Diagnostic {
  pub message : String
  pub start : Int32
  pub end : Int32
} derive(Eq, Debug)

///|
/// A byte range in the original UTF-8 source.
pub struct Span {
  pub start : Int32
  pub end : Int32
} derive(Eq, Debug)

///|
/// A parsed manifest. source is retained byte-for-byte for stale-plan checks.
pub struct Document {
  pub kind : Kind
  pub path : String
  pub source : Bytes
  ast : @moon_config.Ast
  pub diagnostics : Array[Diagnostic]
} derive(Debug)

///|
pub fn parse(
  kind : Kind,
  source : String,
  path~ : String = "moon.mod",
) -> Document {
  let (ast, reports) = match kind {
    Mod => @moon_config.parse_moon_mod(name=path, source)
    Pkg => @moon_config.parse_moon_pkg(name=path, source)
    Work => @moon_config.parse_moon_work(name=path, source)
  }
  let diagnostics : Array[Diagnostic] = []
  for report in reports {
    diagnostics.push({
      message: report.msg,
      start: position_to_byte(source, report.loc.start),
      end: position_to_byte(source, report.loc.end),
    })
  }
  { kind, path, source: @utf8.encode(source), ast, diagnostics, }
}

///|
fn position_to_byte(source : String, position : @basic.Position) -> Int32 {
  let bytes = @utf8.encode(source)
  let mut line = 1
  let mut offset = 0
  while offset < bytes.length() && line < position.lnum {
    if bytes[offset] == b'\n' {
      line += 1
    }
    offset += 1
  }
  let mut line_text = ""
  let mut current_line = 1
  for item in source.split("\n") {
    if current_line == position.lnum {
      line_text = item.to_owned()
      break
    }
    current_line += 1
  }
  let local_column = position.cnum - position.bol
  let prefix = if local_column <= 0 {
    ""
  } else {
    line_text[:local_column].to_owned()
  }
  offset + @utf8.encode(prefix).length()
}

///|
fn fields(ast : @moon_config.Ast) -> Array[(String, @moon_config.Ast)] {
  match ast {
    @moon_config.Ast::Obj(items, ..) => items.to_array()
    _ => []
  }
}

///|
fn string_value(ast : @moon_config.Ast) -> String? {
  match ast {
    @moon_config.Ast::Str(value, ..) => Some(value)
    _ => None
  }
}

///|
pub fn Document::module_name(self : Document) -> String? {
  for (key, value) in fields(self.ast) {
    if key == "name" {
      return string_value(value)
    }
  }
  None
}

///|
pub fn Document::version(self : Document) -> String? {
  for (key, value) in fields(self.ast) {
    if key == "version" {
      return string_value(value)
    }
  }
  None
}

///|
pub fn Document::members(self : Document) -> Array[String] {
  for (key, value) in fields(self.ast) {
    if key == "members" {
      match value {
        @moon_config.Ast::Arr(items, ..) => {
          let result : Array[String] = []
          for item in items {
            if string_value(item) is Some(value) {
              result.push(value)
            }
          }
          return result
        }
        _ => return []
      }
    }
  }
  []
}

///|
pub fn Document::dependency_version(
  self : Document,
  module : String,
) -> String? {
  for (key, value) in fields(self.ast) {
    if key == "deps" {
      for (dep, version) in fields(value) {
        if dep == module {
          return string_value(version)
        }
      }
    }
  }
  None
}

///|
/// Find a scalar string assignment without changing comments or surrounding bytes.
pub fn Document::find_string(self : Document, key : String) -> Span? {
  find_string_span(self.source, key)
}

///|
/// Replace the first key = "..." or "key": "..." value.
pub fn Document::replace_string(
  self : Document,
  key : String,
  value : String,
) -> String {
  match find_string_span(self.source, key) {
    None => @utf8.decode_lossy(self.source)
    Some(span) => {
      let encoded = @utf8.encode(Json::string(value).stringify())
      let output = StringBuilder()
      output.write_string(@utf8.decode_lossy(self.source[:span.start]))
      output.write_string(@utf8.decode_lossy(encoded))
      output.write_string(@utf8.decode_lossy(self.source[span.end:]))
      output.to_string()
    }
  }
}

///|
fn find_string_span(source : Bytes, key : String) -> Span? {
  let key_bytes = @utf8.encode(key)
  let mut index = 0
  let mut in_string = false
  let mut escaped = false
  let mut line_comment = false
  let mut block_comment = false
  while index < source.length() {
    let current = source[index]
    let next = if index + 1 < source.length() {
      source[index + 1]
    } else {
      b'\x00'
    }
    if line_comment {
      if current == b'\n' {
        line_comment = false
      }
      index += 1
      continue
    }
    if block_comment {
      if current == b'*' && next == b'/' {
        block_comment = false
        index += 2
      } else {
        index += 1
      }
      continue
    }
    if in_string {
      if escaped {
        escaped = false
      } else if current == b'\\' {
        escaped = true
      } else if current == b'"' {
        in_string = false
      }
      index += 1
      continue
    }
    if current == b'/' && next == b'/' {
      line_comment = true
      index += 2
      continue
    }
    if current == b'/' && next == b'*' {
      block_comment = true
      index += 2
      continue
    }
    if current == b'"' {
      in_string = true
      index += 1
      continue
    }
    if index + key_bytes.length() <= source.length() &&
      source[index:index + key_bytes.length()] == key_bytes {
      let before_ok = index == 0 || !identifier_byte(source[index - 1])
      let after = index + key_bytes.length()
      let after_ok = after == source.length() || !identifier_byte(source[after])
      if before_ok && after_ok {
        let mut cursor = after
        while cursor < source.length() && whitespace(source[cursor]) {
          cursor += 1
        }
        if cursor < source.length() &&
          (source[cursor] == b'=' || source[cursor] == b':') {
          cursor += 1
          while cursor < source.length() && whitespace(source[cursor]) {
            cursor += 1
          }
          if cursor < source.length() && source[cursor] == b'"' {
            let start = cursor
            cursor += 1
            let mut escaped_value = false
            while cursor < source.length() {
              if escaped_value {
                escaped_value = false
              } else if source[cursor] == b'\\' {
                escaped_value = true
              } else if source[cursor] == b'"' {
                return Some({ start, end: cursor + 1, })
              }
              cursor += 1
            }
          }
        }
      }
    }
    index += 1
  }
  None
}

///|
fn whitespace(value : Byte) -> Bool {
  value == b' ' || value == b'\t' || value == b'\r' || value == b'\n'
}

///|
fn identifier_byte(value : Byte) -> Bool {
  (value >= b'a' && value <= b'z') ||
  (value >= b'A' && value <= b'Z') ||
  (value >= b'0' && value <= b'9') ||
  value == b'_' ||
  value == b'-'
}