///|
/// MoonFolio parses Markdown front matter blocks without pulling in a full
/// YAML/TOML/JSON parser. It targets common static-site metadata conventions.

///|
pub(all) enum MatterFormat {
  None
  Yaml
  Toml
  Json
} derive(Eq, Debug)

///|
pub(all) enum MetaValue {
  Text(String)
  Bool(Bool)
  Int(Int)
  List(Array[String])
} derive(Eq, Debug)

///|
pub(all) enum Severity {
  Warning
  Error
} derive(Eq, Debug)

///|
pub(all) struct Diagnostic {
  severity : Severity
  line : Int
  code : String
  message : String
} derive(Eq, Debug)

///|
pub(all) struct Document {
  format : MatterFormat
  fields : Map[String, MetaValue]
  order : Array[String]
  raw : String
  body : String
  start_line : Int
  end_line : Int
} derive(Eq, Debug)

///|
pub(all) struct ParseOutput {
  document : Document
  diagnostics : Array[Diagnostic]
} derive(Eq, Debug)

///|
pub(all) struct ValidationReport {
  ok : Bool
  missing : Array[String]
  present : Array[String]
  diagnostics : Array[Diagnostic]
} derive(Eq, Debug)

///|
pub(all) enum FieldType {
  Any
  TextType
  BoolType
  IntType
  ListType
} derive(Eq, Debug)

///|
pub(all) struct FieldRule {
  key : String
  kind : FieldType
  required : Bool
} derive(Eq, Debug)

///|
pub(all) struct ContractReport {
  ok : Bool
  checked : Array[String]
  diagnostics : Array[Diagnostic]
} derive(Eq, Debug)

///|
pub(all) struct ContentFolio {
  title : String
  slug : String
  permalink : String
  fingerprint : String
  format : MatterFormat
  manifest_line : String
} derive(Eq, Debug)

///|
pub fn parse(input : String) -> ParseOutput {
  parse_with_required(input, [])
}

///|
pub fn parse_with_required(
  input : String,
  required : Array[String],
) -> ParseOutput {
  let normalized = normalize_newlines(input)
  let lines = normalized.split("\n").map(view => view.to_owned()).to_array()
  if lines.length() == 0 {
    let doc = empty_document(normalized)
    { document: doc, diagnostics: [] }
  } else {
    let first = lines[0].trim().to_owned()
    let (format, delimiter) = detect_delimiter(first)
    if format == None {
      let doc = empty_document(normalized)
      let report = validate_required(doc, required)
      { document: doc, diagnostics: report.diagnostics }
    } else {
      let end_index = find_end_delimiter(lines, delimiter)
      match end_index {
        None => {
          let raw = join_range(lines, 1, lines.length(), "\n")
          let doc = {
            format,
            fields: Map([]),
            order: [],
            raw,
            body: "",
            start_line: 1,
            end_line: lines.length(),
          }
          let diagnostics = [
            Diagnostic::{
              severity: Error,
              line: lines.length(),
              code: "missing-end-delimiter",
              message: "front matter block is opened but not closed",
            },
          ]
          { document: doc, diagnostics }
        }
        Some(end) => {
          let raw = join_range(lines, 1, end, "\n")
          let body = join_range(lines, end + 1, lines.length(), "\n")
          let parsed = parse_metadata(format, raw, start_line=2)
          let doc = {
            format,
            fields: parsed.fields,
            order: parsed.order,
            raw,
            body,
            start_line: 1,
            end_line: end + 1,
          }
          let report = validate_required(doc, required)
          {
            document: doc,
            diagnostics: parsed.diagnostics + report.diagnostics,
          }
        }
      }
    }
  }
}

///|
pub fn strip_front_matter(input : String) -> String {
  parse(input).document.body
}

///|
pub fn is_valid(output : ParseOutput) -> Bool {
  output.diagnostics.all(d => d.severity == Warning)
}

///|
pub fn format_name(format : MatterFormat) -> String {
  match format {
    None => "none"
    Yaml => "yaml"
    Toml => "toml"
    Json => "json"
  }
}

///|
pub fn has_field(doc : Document, key : String) -> Bool {
  doc.fields.contains(key)
}

///|
pub fn get_value(doc : Document, key : String) -> MetaValue? {
  doc.fields.get(key)
}

///|
pub fn get_text(doc : Document, key : String) -> String? {
  match doc.fields.get(key) {
    Some(Text(value)) => Some(value)
    Some(Bool(value)) => Some(value.to_string())
    Some(Int(value)) => Some(value.to_string())
    _ => None
  }
}

///|
pub fn get_bool(doc : Document, key : String) -> Bool? {
  match doc.fields.get(key) {
    Some(Bool(value)) => Some(value)
    _ => None
  }
}

///|
pub fn get_int(doc : Document, key : String) -> Int? {
  match doc.fields.get(key) {
    Some(Int(value)) => Some(value)
    _ => None
  }
}

///|
pub fn get_list(doc : Document, key : String) -> Array[String]? {
  match doc.fields.get(key) {
    Some(List(value)) => Some(value)
    _ => None
  }
}

///|
pub fn validate_required(
  doc : Document,
  required : Array[String],
) -> ValidationReport {
  let missing : Array[String] = []
  let present : Array[String] = []
  let diagnostics : Array[Diagnostic] = []
  for key in required {
    if doc.fields.contains(key) {
      present.push(key)
    } else {
      missing.push(key)
      diagnostics.push(Diagnostic::{
        severity: Error,
        line: doc.start_line,
        code: "missing-required-field",
        message: "required field `" + key + "` is missing",
      })
    }
  }
  { ok: missing.length() == 0, missing, present, diagnostics }
}

///|
pub fn validate_contract(
  doc : Document,
  rules : Array[FieldRule],
) -> ContractReport {
  let checked : Array[String] = []
  let diagnostics : Array[Diagnostic] = []
  for rule in rules {
    checked.push(rule.key)
    match doc.fields.get(rule.key) {
      None =>
        if rule.required {
          diagnostics.push(Diagnostic::{
            severity: Error,
            line: doc.start_line,
            code: "contract-missing-field",
            message: "contract requires `" + rule.key + "`",
          })
        }
      Some(value) =>
        if !value_matches(value, rule.kind) {
          diagnostics.push(Diagnostic::{
            severity: Error,
            line: doc.start_line,
            code: "contract-type-mismatch",
            message: "field `" +
            rule.key +
            "` must be " +
            field_type_name(rule.kind) +
            ", got " +
            field_type_name(infer_value_type(value)),
          })
        }
    }
  }
  { ok: diagnostics.length() == 0, checked, diagnostics }
}

///|
pub fn build_folio(doc : Document, base_path : String) -> ContentFolio {
  let title = get_text(doc, "title").unwrap_or("untitled")
  let slug = match get_text(doc, "slug") {
    Some(value) => slugify(value)
    None => slugify(title)
  }
  let permalink = join_permalink(base_path, slug)
  let fingerprint = fingerprint_document(doc)
  let manifest_line = slug +
    "|" +
    fingerprint +
    "|" +
    format_name(doc.format) +
    "|" +
    title
  { title, slug, permalink, fingerprint, format: doc.format, manifest_line }
}

///|
pub fn slugify(text : String) -> String {
  let buf = StringBuilder(size_hint=text.length())
  let mut last_dash = false
  for ch in text.to_lower().iter() {
    if is_slug_char(ch) {
      buf.write_char(ch)
      last_dash = false
    } else if !last_dash && !buf.is_empty() {
      buf.write_char('-')
      last_dash = true
    }
  }
  let slug = buf.to_string().trim(chars="-").to_owned()
  if slug.is_empty() {
    "untitled"
  } else {
    slug
  }
}

///|
pub fn fingerprint_document(doc : Document) -> String {
  fingerprint_text(to_yaml(doc))
}

///|
pub fn folio_manifest(docs : Array[Document], base_path : String) -> String {
  let lines : Array[String] = []
  for doc in docs {
    lines.push(build_folio(doc, base_path).manifest_line)
  }
  lines.join("\n")
}

///|
pub fn to_yaml(doc : Document) -> String {
  if doc.format == None {
    doc.body
  } else {
    let buf = StringBuilder(size_hint=doc.raw.length() + doc.body.length() + 16)
    buf.write_string("---\n")
    for key in doc.order {
      match doc.fields.get(key) {
        Some(value) => {
          buf.write_string(key)
          buf.write_string(": ")
          buf.write_string(format_yaml_value(value))
          buf.write_string("\n")
        }
        None => ()
      }
    }
    buf.write_string("---")
    if !doc.body.is_empty() {
      buf.write_string("\n")
      buf.write_string(doc.body)
    }
    buf.to_string()
  }
}

///|
pub fn to_toml(doc : Document) -> String {
  if doc.format == None {
    doc.body
  } else {
    let buf = StringBuilder(size_hint=doc.raw.length() + doc.body.length() + 16)
    buf.write_string("+++\n")
    for key in doc.order {
      match doc.fields.get(key) {
        Some(value) => {
          buf.write_string(key)
          buf.write_string(" = ")
          buf.write_string(format_toml_value(value))
          buf.write_string("\n")
        }
        None => ()
      }
    }
    buf.write_string("+++")
    if !doc.body.is_empty() {
      buf.write_string("\n")
      buf.write_string(doc.body)
    }
    buf.to_string()
  }
}

///|
pub fn summary(output : ParseOutput) -> String {
  let doc = output.document
  let buf = StringBuilder(size_hint=128)
  buf.write_string("format=")
  buf.write_string(format_name(doc.format))
  buf.write_string(", fields=")
  buf.write_string(doc.fields.length().to_string())
  buf.write_string(", diagnostics=")
  buf.write_string(output.diagnostics.length().to_string())
  if doc.order.length() > 0 {
    buf.write_string(", keys=")
    buf.write_string(doc.order.join(","))
  }
  buf.to_string()
}

///|
priv struct MetadataParse {
  fields : Map[String, MetaValue]
  order : Array[String]
  diagnostics : Array[Diagnostic]
}

///|
fn empty_document(body : String) -> Document {
  {
    format: None,
    fields: Map([]),
    order: [],
    raw: "",
    body,
    start_line: 1,
    end_line: 0,
  }
}

///|
fn detect_delimiter(line : String) -> (MatterFormat, String) {
  match line {
    "---" => (Yaml, "---")
    "+++" => (Toml, "+++")
    ";;;" => (Json, ";;;")
    _ => (None, "")
  }
}

///|
fn find_end_delimiter(lines : Array[String], delimiter : String) -> Int? {
  for i in 1.. MetadataParse {
  match format {
    Json => parse_json_like(raw, start_line~)
    _ => parse_line_pairs(raw, format, start_line~)
  }
}

///|
fn parse_line_pairs(
  raw : String,
  format : MatterFormat,
  start_line~ : Int,
) -> MetadataParse {
  let fields : Map[String, MetaValue] = Map([])
  let order : Array[String] = []
  let diagnostics : Array[Diagnostic] = []
  let delimiter = if format == Toml { "=" } else { ":" }
  let lines = raw.split("\n").map(view => view.to_owned()).to_array()
  for i in 0..
        diagnostics.push(Diagnostic::{
          severity: Error,
          line: line_number,
          code: "invalid-entry",
          message: "metadata entry must contain `" + delimiter + "`",
        })
      Some((raw_key, raw_value)) => {
        let key = raw_key.trim().to_owned()
        let value_text = strip_inline_comment(raw_value.trim().to_owned())
        if !is_valid_key(key) {
          diagnostics.push(Diagnostic::{
            severity: Error,
            line: line_number,
            code: "invalid-key",
            message: "metadata key `" +
            key +
            "` contains unsupported characters",
          })
        } else {
          if fields.contains(key) {
            diagnostics.push(Diagnostic::{
              severity: Warning,
              line: line_number,
              code: "duplicate-key",
              message: "metadata key `" + key + "` overrides an earlier value",
            })
          } else {
            order.push(key)
          }
          fields.set(key, parse_value(value_text))
        }
      }
    }
  }
  { fields, order, diagnostics }
}

///|
fn parse_json_like(raw : String, start_line~ : Int) -> MetadataParse {
  let fields : Map[String, MetaValue] = Map([])
  let order : Array[String] = []
  let diagnostics : Array[Diagnostic] = []
  let trimmed = raw.trim().to_owned()
  if !trimmed.has_prefix("{") || !trimmed.has_suffix("}") {
    diagnostics.push(Diagnostic::{
      severity: Error,
      line: start_line,
      code: "invalid-json-object",
      message: "json front matter must be a single object",
    })
    return { fields, order, diagnostics }
  }
  let inner = trimmed.sub(start=1, end=trimmed.length() - 1).trim().to_owned()
  if inner.is_empty() {
    return { fields, order, diagnostics }
  }
  let entries = split_top_level(inner, ',')
  for i in 0..
        diagnostics.push(Diagnostic::{
          severity: Error,
          line: start_line,
          code: "invalid-json-entry",
          message: "json entry must contain `:`",
        })
      Some((raw_key, raw_value)) => {
        let key = unquote(raw_key.trim().to_owned())
        let value_text = raw_value.trim().to_owned()
        if !is_valid_key(key) {
          diagnostics.push(Diagnostic::{
            severity: Error,
            line: start_line,
            code: "invalid-key",
            message: "metadata key `" +
            key +
            "` contains unsupported characters",
          })
        } else {
          if fields.contains(key) {
            diagnostics.push(Diagnostic::{
              severity: Warning,
              line: start_line,
              code: "duplicate-key",
              message: "metadata key `" + key + "` overrides an earlier value",
            })
          } else {
            order.push(key)
          }
          fields.set(key, parse_value(value_text))
        }
      }
    }
  }
  { fields, order, diagnostics }
}

///|
fn parse_value(text : String) -> MetaValue {
  let value = text.trim().to_owned()
  if value.has_prefix("[") && value.has_suffix("]") {
    let inner = value.sub(start=1, end=value.length() - 1).to_owned()
    List(parse_list(inner))
  } else if value == "true" {
    Bool(true)
  } else if value == "false" {
    Bool(false)
  } else {
    match parse_int_maybe(value) {
      Some(n) => Int(n)
      None => Text(unquote(value))
    }
  }
}

///|
fn parse_list(inner : String) -> Array[String] {
  let values : Array[String] = []
  if inner.trim().is_empty() {
    return values
  }
  let parts = split_top_level(inner, ',')
  for part in parts {
    let item = unquote(part.trim().to_owned())
    values.push(item)
  }
  values
}

///|
fn parse_int_maybe(value : String) -> Int? {
  if value.is_empty() {
    return None
  }
  let mut sign = 1
  let mut start = 0
  if value.has_prefix("-") {
    sign = -1
    start = 1
  }
  if start == value.length() {
    return None
  }
  let mut result = 0
  for i in start.. 57 {
      return None
    }
    result = result * 10 + (code - 48)
  }
  Some(result * sign)
}

///|
fn split_first_unquoted(line : String, needle : String) -> (String, String)? {
  let mut in_single = false
  let mut in_double = false
  let mut depth = 0
  for i in 0.. Array[String] {
  let values : Array[String] = []
  let mut in_single = false
  let mut in_double = false
  let mut depth = 0
  let mut start = 0
  for i in 0.. String {
  let mut in_single = false
  let mut in_double = false
  for i in 0.. String {
  if value.length() >= 2 {
    if (value.has_prefix("\"") && value.has_suffix("\"")) ||
      (value.has_prefix("'") && value.has_suffix("'")) {
      return value.sub(start=1, end=value.length() - 1).to_owned()
    }
  }
  value
}

///|
fn is_valid_key(key : String) -> Bool {
  if key.is_empty() {
    return false
  }
  key.all(ch => is_key_char(ch))
}

///|
fn is_key_char(ch : Char) -> Bool {
  let code = ch.to_int()
  (code >= 48 && code <= 57) ||
  (code >= 65 && code <= 90) ||
  (code >= 97 && code <= 122) ||
  ch == '_' ||
  ch == '-' ||
  ch == '.'
}

///|
fn normalize_newlines(input : String) -> String {
  input.replace_all(old="\r\n", new="\n").replace_all(old="\r", new="\n")
}

///|
fn join_range(
  lines : Array[String],
  start : Int,
  end : Int,
  separator : String,
) -> String {
  let selected : Array[String] = []
  if start >= end {
    return ""
  }
  for i in start.. String {
  match value {
    Text(text) => quote_if_needed(text)
    Bool(flag) => flag.to_string()
    Int(number) => number.to_string()
    List(items) =>
      "[" + items.map(item => quote_if_needed(item)).join(", ") + "]"
  }
}

///|
fn format_toml_value(value : MetaValue) -> String {
  match value {
    Text(text) => "\"" + escape_string(text) + "\""
    Bool(flag) => flag.to_string()
    Int(number) => number.to_string()
    List(items) =>
      "[" +
      items.map(item => "\"" + escape_string(item) + "\"").join(", ") +
      "]"
  }
}

///|
fn quote_if_needed(text : String) -> String {
  if text.is_empty() ||
    text.contains(":") ||
    text.contains("#") ||
    text.contains(",") ||
    text.contains("[") ||
    text.contains("]") ||
    text.contains("\"") ||
    text.contains("'") ||
    text.trim().to_owned() != text {
    "\"" + escape_string(text) + "\""
  } else {
    text
  }
}

///|
fn escape_string(text : String) -> String {
  text.replace_all(old="\\", new="\\\\").replace_all(old="\"", new="\\\"")
}

///|
fn value_matches(value : MetaValue, kind : FieldType) -> Bool {
  match kind {
    Any => true
    TextType => value is Text(_)
    BoolType => value is Bool(_)
    IntType => value is Int(_)
    ListType => value is List(_)
  }
}

///|
fn infer_value_type(value : MetaValue) -> FieldType {
  match value {
    Text(_) => TextType
    Bool(_) => BoolType
    Int(_) => IntType
    List(_) => ListType
  }
}

///|
fn field_type_name(kind : FieldType) -> String {
  match kind {
    Any => "any"
    TextType => "text"
    BoolType => "bool"
    IntType => "int"
    ListType => "list"
  }
}

///|
fn join_permalink(base_path : String, slug : String) -> String {
  let base = base_path.trim_end(chars="/").to_owned()
  if base.is_empty() {
    "/" + slug
  } else {
    base + "/" + slug
  }
}

///|
fn is_slug_char(ch : Char) -> Bool {
  let code = ch.to_int()
  (code >= 48 && code <= 57) || (code >= 97 && code <= 122)
}

///|
fn fingerprint_text(text : String) -> String {
  let mut a = 1
  let mut b = 0
  for i in 0.. String {
  value.to_string(radix=16).to_lower().pad_start(4, '0')
}