// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0

///|
/// How a collection is laid out.
///
/// `Block` is the style a configuration file is written in: one entry per line, a
/// person can read it and a diff shows one change per line. `Flow` is the brace and
/// bracket form, which JSON shares and which a machine-generated file often wants.
/// `Auto` picks per collection: flow when it holds nothing but scalars and the
/// whole of it fits in [`Style::width`], block otherwise. A collection of
/// collections stays block, because that is the shape of a configuration file.
pub(all) enum Layout {
  Block
  Flow
  Auto
} derive(Eq, Debug)

///|
pub extend Layout with Debug::{to_repr}

///|
pub extend Layout with Eq::{not_equal, equal}

///|
/// Which quote a string gets when it needs one.
///
/// `Minimal` quotes only what would otherwise read as something else, which is what
/// makes the output look written rather than generated. `Single` and `Double` are
/// for a caller whose output feeds a second reader that is happier with one form.
pub(all) enum Quotes {
  Minimal
  Single
  Double
} derive(Eq, Debug)

///|
pub extend Quotes with Debug::{to_repr}

///|
pub extend Quotes with Eq::{not_equal, equal}

///|
/// How a document is written: the indentation, the line width, the layout, the
/// quotes, and whether a repeated subtree is written once and referred to after.
///
/// The preset is [`style`]. Build another with [`Style::new`], or update one in
/// place with `{ ..style, width: 120 }`.
pub(all) struct Style {
  /// Spaces per level.
  indent : Int32
  /// The column past which a collection is no longer written on one line, and at
  /// which a long plain scalar is folded.
  width : Int32
  layout : Layout
  quotes : Quotes
  /// Whether a subtree written twice is written once with `&a1` and referred to by
  /// `*a1` after. Off for output that a person will edit by hand, because an anchor
  /// is a thing to read around; on by default for files a program writes, where it
  /// turns a repeated block into one line.
  anchors : Bool
  /// Whether mapping keys are written in sorted order.
  sort : Bool
} derive(Eq, Debug)

///|
pub extend Style with Debug::{to_repr}

///|
pub extend Style with Eq::{not_equal, equal}

///|
/// How much a nested level is indented.
///
/// Two spaces: what Kubernetes manifests, Compose files and every configuration
/// example in the wild use, and what a reader's eye is trained on.
pub let indent : Int32 = 2

///|
/// The column a writer folds at.
///
/// 80: the width `yamllint` defaults to, which is itself the width of a terminal and
/// of the Python PEP 8 convention the tooling around YAML grew up beside.
pub let width : Int32 = 80

///|
/// The preset: two-space block style, folded at 80 columns, quoting only what has
/// to be quoted, anchors for what repeats.
///
/// Block, not `Auto`: what this writes is a configuration file, and a configuration
/// file is read one entry to a line. `Auto` is a knob for output whose reader is a
/// machine.
pub let style : Style = {
  indent,
  width,
  layout: Block,
  quotes: Minimal,
  anchors: true,
  sort: false,
}

///|
/// The same document, all in flow style — the form JSON shares.
pub let flow : Style = { ..style, layout: Flow, anchors: false, }

///|
/// A style by name, every knob with the preset's value.
pub fn Style::new(
  indent~ : Int32 = indent,
  width~ : Int32 = width,
  layout~ : Layout = Auto,
  quotes~ : Quotes = Minimal,
  anchors~ : Bool = true,
  sort~ : Bool = false,
) -> Style {
  { indent, width, layout, quotes, anchors, sort, }
}

///|
/// Whether a string can be written without quotes.
///
/// It cannot if it is empty, if it would read back as something other than a
/// string, if it begins with a character YAML gives a meaning to, if it carries
/// a `: ` or a ` #` that would be read as structure or a comment, or if it has
/// space at either end where the reader would trim it.
fn bare(s : String, rules : Rules) -> Bool {
  if s.length() == 0 {
    return false
  }
  if s == "true" || s == "false" || s == "null" || s == "~" {
    return false
  }
  if rules.yes_no &&
    (
      s == "yes" ||
      s == "no" ||
      s == "on" ||
      s == "off" ||
      s == "Yes" ||
      s == "No" ||
      s == "On" ||
      s == "Off" ||
      s == "YES" ||
      s == "NO" ||
      s == "ON" ||
      s == "OFF"
    ) {
    return false
  }
  if rules.letters && (s == "y" || s == "n" || s == "Y" || s == "N") {
    return false
  }
  // Anything the core schema would read back as a number has to be quoted, or the
  // reader would hand the caller a number where a string was written.
  if number(s, rules) is Some(_) {
    return false
  }
  if is_blank(s[0].to_int()) || is_blank(s[s.length() - 1].to_int()) {
    return false
  }
  // The characters YAML reserves at the start of a scalar.
  for c in "-?:,[]{}#&*!|>'\"%@`" {
    if s[0].to_int() == c.to_int() {
      return false
    }
  }
  for i = 0; i < s.length(); i = i + 1 {
    let c = s[i].to_int()
    if c < 0x20 || c == 0x7f {
      return false
    }
    if c == ':'.to_int() && (i + 1 == s.length() || is_blank(s[i + 1].to_int())) {
      return false
    }
    if c == '#'.to_int() && i > 0 && is_blank(s[i - 1].to_int()) {
      return false
    }
  }
  true
}

///|
/// A string in double quotes, with the escapes YAML defines.
fn quoted(out : StringBuilder, s : String) -> Unit {
  out.write_char('"')
  for i = 0; i < s.length(); i = i + 1 {
    let c = s[i].to_int()
    match c {
      0x22 => out.write_string("\\\"")
      0x5c => out.write_string("\\\\")
      0x0a => out.write_string("\\n")
      0x0d => out.write_string("\\r")
      0x09 => out.write_string("\\t")
      _ =>
        if c < 0x20 || c == 0x7f {
          out.write_string("\\x")
          two_hex(out, c)
        } else {
          out.write_char(s[i].unsafe_to_char())
        }
    }
  }
  out.write_char('"')
}

///|
/// A string in single quotes, where the only thing to escape is a quote.
fn single_quoted(out : StringBuilder, s : String) -> Unit {
  out.write_char('\'')
  for i = 0; i < s.length(); i = i + 1 {
    let c = s[i].to_int()
    if c == 0x27 {
      out.write_string("''")
    } else {
      out.write_char(s[i].unsafe_to_char())
    }
  }
  out.write_char('\'')
}

///|
/// A byte as two hex digits.
fn two_hex(out : StringBuilder, c : Int32) -> Unit {
  let digits = "0123456789abcdef"
  out.write_char(digits[(c >> 4) & 0xf].unsafe_to_char())
  out.write_char(digits[c & 0xf].unsafe_to_char())
}

///|
/// A number as YAML writes it: a whole value without a fractional part, so a
/// port is `8080` rather than `8080.0`.
fn write_number(out : StringBuilder, n : Double) -> Unit {
  if n == n.floor() && n.abs() < 1.0e15 {
    out.write_string(n.to_int64().to_string())
  } else {
    out.write_string(n.to_string())
  }
}

///|
/// A string with the quotes the style asks for, given whether plain would do.
fn write_text(
  out : StringBuilder,
  s : String,
  style : Style,
  rules : Rules,
) -> Unit {
  let plain = bare(s, rules)
  match style.quotes {
    Minimal => if plain { out.write_string(s) } else { quoted(out, s) }
    Double => quoted(out, s)
    Single =>
      if plain {
        out.write_string(s)
      } else if has_control(s) || index_of_unit(s, 0x0a) >= 0 {
        // A single-quoted scalar cannot hold a control character, and a block is
        // the only form that keeps a line break readable.
        quoted(out, s)
      } else {
        single_quoted(out, s)
      }
  }
}

///|
/// Whether a string holds a character no single-quoted scalar may carry.
fn has_control(s : String) -> Bool {
  for i = 0; i < s.length(); i = i + 1 {
    let c = s[i].to_int()
    if c < 0x20 || c == 0x7f {
      return true
    }
  }
  false
}

///|
/// A scalar, quoted only when plain would read back as something else.
fn write_scalar(
  out : StringBuilder,
  value : Json,
  style : Style,
  rules : Rules,
) -> Unit {
  match value {
    Null => out.write_string("null")
    True => out.write_string("true")
    False => out.write_string("false")
    Number(n, ..) => write_number(out, n)
    String(s) => write_text(out, s, style, rules)
    _ => out.write_string("null")
  }
}

///|
/// A plain scalar folded at spaces so that no line runs past `width`.
///
/// A plain scalar's line breaks read back as spaces (§6.5), so folding one at a
/// space is the one place a writer can wrap a long line without changing it. It is
/// done only where that is exactly true: single spaces inside, none at either end.
fn write_folded(
  out : StringBuilder,
  s : String,
  column : Int32,
  width : Int32,
) -> Unit {
  if width <= 0 || column + s.length() <= width || !foldable(s) {
    out.write_string(s)
    return
  }
  let mut col = column
  let mut start = 0
  for i = 0; i <= s.length(); i = i + 1 {
    if i == s.length() || (s[i].to_int() == 0x20 && i > start) {
      let piece = s[start:i].to_owned()
      if col > column && col + 1 + piece.length() > width {
        out.write_char('\n')
        pad(out, column)
        col = column
      } else if col > column {
        out.write_char(' ')
        col = col + 1
      }
      out.write_string(piece)
      col = col + piece.length()
      start = i + 1
    }
  }
}

///|
/// Whether a scalar can be folded without changing what it says.
fn foldable(s : String) -> Bool {
  if !bare(s, core_rules) || s.length() == 0 {
    return false
  }
  if is_blank(s[0].to_int()) || is_blank(s[s.length() - 1].to_int()) {
    return false
  }
  let mut i = 0
  while i < s.length() {
    if s[i].to_int() == 0x20 {
      if i + 1 < s.length() && s[i + 1].to_int() == 0x20 {
        return false
      }
    }
    i = i + 1
  }
  true
}

///|
/// A string that is better written as a literal block scalar than escaped into
/// one quoted line: the shell script, the PEM key, the message of the day.
///
/// It has to have a line break to be worth it, and it has to carry nothing a
/// literal block cannot hold — a carriage return, a control character, or a line
/// that begins with space, which would need an explicit indentation indicator to
/// survive the round trip.
fn literal_of(value : Json) -> String? {
  guard value is String(s) else { return None }
  if index_of_unit(s, 0x0a) < 0 {
    return None
  }
  let mut bol = true
  for i = 0; i < s.length(); i = i + 1 {
    let c = s[i].to_int()
    if c == 0x0a {
      bol = true
      continue
    }
    if c < 0x20 || c == 0x7f {
      return None
    }
    if bol && is_blank(c) {
      return None
    }
    bol = false
  }
  Some(s)
}

///|
/// A literal block scalar: the header on the current line, then the content on the
/// lines under it at `column`.
///
/// The chomping indicator carries the trailing breaks exactly — `-` for none, none
/// for one, `+` for more — so what is read back is what was written.
fn write_literal(out : StringBuilder, s : String, column : Int32) -> Unit {
  let mut end = s.length()
  let mut trailing = 0
  while end > 0 && s[end - 1].to_int() == 0x0a {
    end = end - 1
    trailing = trailing + 1
  }
  out.write_string(
    match trailing {
      0 => "|-"
      1 => "|"
      _ => "|+"
    },
  )
  let body = s[0:end].to_owned()
  let mut start = 0
  for i = 0; i <= body.length(); i = i + 1 {
    if i == body.length() || body[i].to_int() == 0x0a {
      out.write_char('\n')
      if i > start {
        pad(out, column)
        out.write_string(body[start:i].to_owned())
      }
      start = i + 1
    }
  }
  for i = 1; i < trailing; i = i + 1 {
    out.write_char('\n')
  }
}

///|
/// Whether a value is written on the same line as the key or item that owns it.
fn inline(value : Json) -> Bool {
  match value {
    Array(items) => items.length() == 0
    Object(members) => members.length() == 0
    _ => true
  }
}

///|
/// A value that fits on one line: a scalar, or the flow form of an empty
/// collection — which is the only way block style can express one.
fn write_inline(
  out : StringBuilder,
  value : Json,
  style : Style,
  rules : Rules,
) -> Unit {
  match value {
    Array(_) => out.write_string("[]")
    Object(_) => out.write_string("{}")
    _ => write_scalar(out, value, style, rules)
  }
}

///|
fn pad(out : StringBuilder, n : Int32) -> Unit {
  for _ in 0.. Out {
  let counts : Map[String, Int32] = Map([])
  tally(counts, value)
  { buf: StringBuilder(), style, rules, counts, bound: Map([]), next: 0, }
}

///|
/// Count the collection subtrees a document holds, by their text, so the writer
/// knows which ones repeat.
fn tally(counts : Map[String, Int32], value : Json) -> Unit {
  match value {
    Array(items) =>
      if items.length() > 0 {
        let k = value.stringify()
        counts[k] = match counts.get(k) {
          Some(n) => n + 1
          None => 1
        }
        for item in items {
          tally(counts, item)
        }
      }
    Object(members) =>
      if members.length() > 0 {
        let k = value.stringify()
        counts[k] = match counts.get(k) {
          Some(n) => n + 1
          None => 1
        }
        for _, item in members {
          tally(counts, item)
        }
      }
    _ => ()
  }
}

///|
/// The reference to write in front of a repeated subtree: `*a1` where it has been
/// written, `&a1` where it has not, or nothing at all.
fn Out::anchor(self : Out, value : Json) -> String {
  let style = self.style
  if !style.anchors {
    return ""
  }
  let k = value.stringify()
  let repeats = match self.counts.get(k) {
    Some(n) => n > 1
    None => false
  }
  if !repeats {
    return ""
  }
  match self.bound.get(k) {
    Some(name) => "*" + name
    None => {
      let name = "a" + self.next.to_string()
      self.next = self.next + 1
      self.bound[k] = name
      "&" + name
    }
  }
}

// -- writing -------------------------------------------------------------------

///|
/// A block at `column`, which is the number of spaces its own lines begin with.
fn block(out : Out, value : Json, column : Int32) -> Unit {
  let style = out.style
  let rules = out.rules
  let step = style.indent
  match value {
    Object(members) => {
      let names = members.keys().collect()
      if style.sort {
        names.sort()
      }
      let mut first = true
      for name in names {
        let held = members.get(name).unwrap()
        if !first {
          out.buf.write_char('\n')
          pad(out.buf, column)
        }
        first = false
        write_text(out.buf, name, { ..style, quotes: Minimal, }, rules)
        out.buf.write_char(':')
        let anchor = if held is Object(_) || held is Array(_) {
          out.anchor(held)
        } else {
          ""
        }
        if anchor.length() > 0 && anchor[0].to_int() == 0x2a {
          out.buf.write_char(' ')
          out.buf.write_string(anchor)
          continue
        }
        if anchor.length() > 0 {
          out.buf.write_char(' ')
          out.buf.write_string(anchor)
        }
        if literal_of(held) is Some(text) {
          if anchor.length() > 0 {
            out.buf.write_char('\n')
            pad(out.buf, column + step)
          } else {
            out.buf.write_char(' ')
          }
          write_literal(out.buf, text, column + step)
        } else if inline(held) {
          out.buf.write_char(' ')
          write_inline(out.buf, held, style, rules)
        } else if style.layout is Auto &&
          fits(held, style, column + step, rules) {
          out.buf.write_char(' ')
          out.buf.write_string(flow_text(held, style, rules))
        } else if style.layout is Flow {
          out.buf.write_char(' ')
          out.buf.write_string(flow_text(held, style, rules))
        } else {
          out.buf.write_char('\n')
          pad(out.buf, column + step)
          block(out, held, column + step)
        }
      }
    }
    Array(items) => {
      let mut first = true
      for item in items {
        if !first {
          out.buf.write_char('\n')
          pad(out.buf, column)
        }
        first = false
        let anchor = if item is Object(_) || item is Array(_) {
          out.anchor(item)
        } else {
          ""
        }
        out.buf.write_string("- ")
        if anchor.length() > 0 && anchor[0].to_int() == 0x2a {
          out.buf.write_string(anchor)
          continue
        }
        if anchor.length() > 0 {
          out.buf.write_string(anchor)
          out.buf.write_char('\n')
          pad(out.buf, column + 2)
        }
        if literal_of(item) is Some(text) {
          write_literal(out.buf, text, column + 2)
        } else if inline(item) {
          write_inline(out.buf, item, style, rules)
        } else if style.layout is Auto && fits(item, style, column + 2, rules) {
          out.buf.write_string(flow_text(item, style, rules))
        } else if style.layout is Flow {
          out.buf.write_string(flow_text(item, style, rules))
        } else {
          // A nested block under a `-` begins on the same line, indented to
          // just past the dash, which is how every hand-written file does it.
          block(out, item, column + 2)
        }
      }
    }
    _ =>
      if literal_of(value) is Some(text) {
        write_literal(out.buf, text, column + step)
      } else if value is String(s) {
        write_folded(out.buf, s, column, style.width)
      } else {
        write_scalar(out.buf, value, style, rules)
      }
  }
}

///|
/// Whether a collection should be written in flow style under `Auto`: it holds
/// nothing but scalars, and the whole of it fits past `column`.
fn fits(value : Json, style : Style, column : Int32, rules : Rules) -> Bool {
  if !flat(value) {
    return false
  }
  flow_text(value, style, rules).length() + column <= style.width
}

///|
/// Whether a value is a collection of scalars, with no collection under it.
fn flat(value : Json) -> Bool {
  match value {
    Array(items) => {
      for item in items {
        if !(item is String(_) ||
          item is Number(_, ..) ||
          item is True ||
          item is False ||
          item is Null) {
          return false
        }
      }
      true
    }
    Object(members) => {
      for _, item in members {
        if !(item is String(_) ||
          item is Number(_, ..) ||
          item is True ||
          item is False ||
          item is Null) {
          return false
        }
      }
      true
    }
    _ => false
  }
}

///|
/// A value in flow style, on one line.
fn flow_text(value : Json, style : Style, rules : Rules) -> String {
  let out = StringBuilder()
  flow_into(out, value, style, rules)
  out.to_string()
}

///|
fn flow_into(
  out : StringBuilder,
  value : Json,
  style : Style,
  rules : Rules,
) -> Unit {
  match value {
    Array(items) => {
      out.write_char('[')
      let mut first = true
      for item in items {
        if !first {
          out.write_string(", ")
        }
        first = false
        flow_into(out, item, style, rules)
      }
      out.write_char(']')
    }
    Object(members) => {
      out.write_char('{')
      let names = members.keys().collect()
      if style.sort {
        names.sort()
      }
      let mut first = true
      for name in names {
        if !first {
          out.write_string(", ")
        }
        first = false
        write_text(out, name, style, rules)
        out.write_string(": ")
        flow_into(out, members.get(name).unwrap(), style, rules)
      }
      out.write_char('}')
    }
    _ => write_scalar(out, value, style, rules)
  }
}

///|
/// Write a `Json` tree as a YAML document.
///
/// Block style by default, which is what a configuration file is written in and
/// what a person reads. A string is quoted only when plain would read back as
/// something else — a number, a boolean, a null, or not at all — so the output
/// looks like a file someone wrote rather than one a program emitted.
///
/// This is the reason to have a writer at all: a generator that builds YAML by
/// joining strings gets the escaping wrong the first time a value contains a
/// colon, and gets the indentation wrong the first time a value is a list.
///
/// `style` says how; `indent` and `sort` override the two knobs that change from
/// call to call. A flat parameter wins over the record — see "Configuration" in
/// the README.
pub fn dumps(
  value : Json,
  style~ : Style = style,
  rules~ : Rules = core_rules,
  indent? : Int32,
  sort? : Bool,
) -> String {
  let style = with_flat(style, indent?, sort?)
  guard style.indent >= 1 else {
    abort("yaml: an indent of \{style.indent} would not nest anything")
  }
  let out = Out::new(style, value, rules)
  if style.layout is Flow {
    flow_into(out.buf, value, style, rules)
    out.buf.write_char('\n')
    return out.buf.to_string()
  }
  match value {
    Object(members) =>
      if members.length() == 0 {
        out.buf.write_string("{}")
      } else {
        block(out, value, 0)
      }
    Array(items) =>
      if items.length() == 0 {
        out.buf.write_string("[]")
      } else {
        block(out, value, 0)
      }
    _ =>
      if value is String(s) {
        write_folded(out.buf, s, 0, style.width)
      } else {
        write_scalar(out.buf, value, style, rules)
      }
  }
  out.buf.write_char('\n')
  out.buf.to_string()
}

///|
/// The style with the flat overrides applied.
fn with_flat(style : Style, indent? : Int32, sort? : Bool) -> Style {
  let mut out = style
  match indent {
    Some(n) => out = { ..out, indent: n, }
    None => ()
  }
  match sort {
    Some(b) => out = { ..out, sort: b, }
    None => ()
  }
  out
}

///|
/// The same, as UTF-8 bytes — the `s` on [`dumps`] is the one Python puts on
/// the form that answers a string.
pub fn dump(
  value : Json,
  style~ : Style = style,
  rules~ : Rules = core_rules,
  indent? : Int32,
  sort? : Bool,
) -> Bytes {
  @utf8.encode(dumps(value, style~, rules~, indent?, sort?)[:])
}

///|
/// A stream of documents, each opened by its own `---`.
///
/// The separator goes before each document rather than between them. The two
/// conventions are both in use — PyYAML and Go's `yaml.v3` write it only between,
/// `kubectl` writes it before each — and this takes the one that stays correct
/// under concatenation: two streams joined end to end still read as their
/// documents, and appending one is appending lines. The reader accepts either.
pub fn dumps_all(
  values : ArrayView[Json],
  style~ : Style = style,
  rules~ : Rules = core_rules,
  indent? : Int32,
  sort? : Bool,
) -> String {
  let out = StringBuilder()
  for value in values {
    out.write_string("---\n")
    out.write_string(dumps(value, style~, rules~, indent?, sort?))
  }
  out.to_string()
}

///|
/// The same, as UTF-8 bytes.
pub fn dump_all(
  values : ArrayView[Json],
  style~ : Style = style,
  rules~ : Rules = core_rules,
  indent? : Int32,
  sort? : Bool,
) -> Bytes {
  @utf8.encode(dumps_all(values, style~, rules~, indent?, sort?)[:])
}