///| Text and JSON rendering for diagnostics.
///
/// Rendering is deterministic by construction: both renderers copy and sort
/// their input with `Diagnostic`'s total order, emit JSON keys in a fixed
/// order, and never read a hash table. Identical findings therefore always
/// produce byte-identical output, whichever order the caller collected them
/// in.
///
/// JSON omits `input`, `contract`, `oldValue`, and `newValue` when absent
/// instead of emitting `null`, so a missing key always means "not applicable"
/// rather than "unknown".

///|
/// Copies and sorts `diagnostics` into canonical order.
fn sorted_diagnostics(diagnostics : Array[Diagnostic]) -> Array[Diagnostic] {
  let sorted = diagnostics.copy()
  sorted.sort()
  sorted
}

///|
/// Renders findings as one line each:
///
/// ```text
/// error[storage.entry.slot.invalid] fileA:A storage[1].slot: slot "0x1f" is not a non-negative decimal integer
/// ```
///
/// The result is empty when there are no findings; otherwise every line ends
/// with a newline.
pub fn render_text(diagnostics : Array[Diagnostic]) -> String {
  let sorted = sorted_diagnostics(diagnostics)
  let buf = StringBuilder()
  for diagnostic in sorted {
    buf.write_string(diagnostic.severity.name())
    buf.write_char('[')
    buf.write_string(diagnostic.code.name())
    buf.write_string("] ")
    if diagnostic.input is Some(input) {
      buf.write_string(input.name())
      buf.write_char(' ')
    }
    if diagnostic.location.contract is Some(contract) {
      buf.write_string(contract)
      buf.write_char(' ')
    }
    buf.write_string(diagnostic.location.path)
    buf.write_string(": ")
    buf.write_string(diagnostic.message)
    write_text_values(buf, diagnostic)
    buf.write_char('\n')
  }
  buf.to_string()
}

///|
/// Appends the old/new values of a finding, when it has any.
fn write_text_values(buf : StringBuilder, diagnostic : Diagnostic) -> Unit {
  match (diagnostic.old_value, diagnostic.new_value) {
    (None, None) => ()
    (Some(old), None) => {
      buf.write_string(" (old: ")
      buf.write_string(old)
      buf.write_char(')')
    }
    (None, Some(new)) => {
      buf.write_string(" (new: ")
      buf.write_string(new)
      buf.write_char(')')
    }
    (Some(old), Some(new)) => {
      buf.write_string(" (old: ")
      buf.write_string(old)
      buf.write_string(", new: ")
      buf.write_string(new)
      buf.write_char(')')
    }
  }
}

///|
/// Renders findings as a JSON array, pretty-printed with two-space indents.
///
/// ```json
/// [
///   {
///     "code": "storage.entry.slot.invalid",
///     "severity": "error",
///     "location": {"contract": "fileA:A", "path": "storage[1].slot"},
///     "message": "slot \"0x1f\" is not a non-negative decimal integer"
///   }
/// ]
/// ```
///
/// The result is `[]\n` when there are no findings, and otherwise ends with a
/// newline.
pub fn render_json(diagnostics : Array[Diagnostic]) -> String {
  let sorted = sorted_diagnostics(diagnostics)
  let buf = StringBuilder()
  if sorted.is_empty() {
    buf.write_string("[]\n")
    return buf.to_string()
  }
  buf.write_string("[\n")
  let mut first = true
  for diagnostic in sorted {
    if !first {
      buf.write_string(",\n")
    }
    first = false
    buf.write_string("  {\n")
    buf.write_string("    \"code\": ")
    write_json_string(buf, diagnostic.code.name())
    buf.write_string(",\n")
    buf.write_string("    \"severity\": ")
    write_json_string(buf, diagnostic.severity.name())
    buf.write_string(",\n")
    if diagnostic.input is Some(input) {
      buf.write_string("    \"input\": ")
      write_json_string(buf, input.name())
      buf.write_string(",\n")
    }
    buf.write_string("    \"location\": {")
    if diagnostic.location.contract is Some(contract) {
      buf.write_string("\"contract\": ")
      write_json_string(buf, contract)
      buf.write_string(", ")
    }
    buf.write_string("\"path\": ")
    write_json_string(buf, diagnostic.location.path)
    buf.write_string("},\n")
    buf.write_string("    \"message\": ")
    write_json_string(buf, diagnostic.message)
    let has_values = diagnostic.old_value is Some(_) ||
      diagnostic.new_value is Some(_)
    if has_values {
      buf.write_string(",\n")
    } else {
      buf.write_char('\n')
    }
    if diagnostic.old_value is Some(old) {
      buf.write_string("    \"oldValue\": ")
      write_json_string(buf, old)
      if diagnostic.new_value is Some(_) {
        buf.write_string(",\n")
      } else {
        buf.write_char('\n')
      }
    }
    if diagnostic.new_value is Some(new) {
      buf.write_string("    \"newValue\": ")
      write_json_string(buf, new)
      buf.write_char('\n')
    }
    buf.write_string("  }")
  }
  buf.write_string("\n]\n")
  buf.to_string()
}

///|
/// Writes `value` as a JSON string literal, including the quotes.
///
/// Escaping follows RFC 8259: the two mandatory escapes, the short forms for
/// backspace, form feed, newline, carriage return, and tab, and `\u00xx` for
/// the remaining control characters. Every other character, including
/// non-ASCII ones, is written unchanged, so output does not depend on locale
/// or platform.
fn write_json_string(buf : StringBuilder, value : String) -> Unit {
  buf.write_char('"')
  for c in value {
    match c {
      '"' => buf.write_string("\\\"")
      '\\' => buf.write_string("\\\\")
      '\n' => buf.write_string("\\n")
      '\r' => buf.write_string("\\r")
      '\t' => buf.write_string("\\t")
      '\b' => buf.write_string("\\b")
      '\u{c}' => buf.write_string("\\f")
      _ =>
        if c.to_int() < 0x20 {
          buf.write_string("\\u00")
          buf.write_string(c.to_int().to_byte().to_hex())
        } else {
          buf.write_char(c)
        }
    }
  }
  buf.write_char('"')
}