///|
/// The version of the JSON Lines schema of `format_json_line`.
const SCHEMA_VERSION : Int = 1

///|
/// The JSON property layout of a file sink. OrderedArray keeps schema version
/// 1. NamedObject selects schema version 2 with last-wins values and history.
pub(all) enum JsonProperties {
  /// Schema version 1, with every occurrence in an ordered array.
  OrderedArray
  /// Schema version 2, with last-wins named values and complete history.
  NamedObject
} derive(Eq, Debug)

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

///|
pub extend JsonProperties with @debug.Debug::{to_repr}

///|
/// True when `repr` has the complete JSON number grammar:
/// `-? (0 | [1-9][0-9]*) (. [0-9]+)? ([eE] [+-]? [0-9]+)?`. A number with other
/// text, for example `1e`, `+1`, `01` or `--2`, is written from its value, so a
/// property cannot break the line.
fn is_number_text(repr : String) -> Bool {
  let chars = repr.to_array()
  let length = chars.length()
  let is_digit = (char : Char) => char >= '0' && char <= '9'
  let mut at = 0
  // Skip the digits from `at`, and give `true` when there was at least one.
  let digits = () => {
    let start = at
    while at < length && is_digit(chars[at]) {
      at += 1
    }
    at > start
  }
  if at < length && chars[at] == '-' {
    at += 1
  }
  guard at < length else { return false }
  if chars[at] == '0' {
    at += 1
  } else if !(chars[at] >= '1' && chars[at] <= '9' && digits()) {
    return false
  }
  if at < length && chars[at] == '.' {
    at += 1
    guard digits() else { return false }
  }
  if at < length && (chars[at] == 'e' || chars[at] == 'E') {
    at += 1
    if at < length && (chars[at] == '+' || chars[at] == '-') {
      at += 1
    }
    guard digits() else { return false }
  }
  at == length
}

///|
/// Write `text` as a JSON string. Each control character, including ESC, CR and
/// LF, becomes a JSON escape.
fn write_json_string(out : StringBuilder, text : String) -> Unit {
  out.write_string(Json::string(text).stringify())
}

///|
/// Write `value` as compact JSON. A number that is not finite and has no number
/// text is `null`, because JSON has no such numbers.
fn write_json(out : StringBuilder, value : Json) -> Unit {
  match value {
    Number(number, repr~) =>
      match repr {
        Some(text) if is_number_text(text) => out.write_string(text)
        _ =>
          if number.is_nan() || number.is_inf() {
            out.write_string("null")
          } else {
            out.write_string(Json::number(number).stringify())
          }
      }
    Array(items) => {
      out.write_char('[')
      for index, item in items {
        if index > 0 {
          out.write_char(',')
        }
        write_json(out, item)
      }
      out.write_char(']')
    }
    Object(fields) => {
      out.write_char('{')
      let mut first = true
      for name, field in fields {
        if !first {
          out.write_char(',')
        }
        first = false
        write_json_string(out, name)
        out.write_char(':')
        write_json(out, field)
      }
      out.write_char('}')
    }
    _ => out.write_string(value.stringify())
  }
}

///|
/// Write `text` as a JSON string, or `null` for `None`.
fn write_optional(out : StringBuilder, text : String?) -> Unit {
  match text {
    Some(text) => write_json_string(out, text)
    None => out.write_string("null")
  }
}

///|
/// Render `record` as one JSON Lines record of schema version 1: one JSON object
/// on one physical line, with a final LF, no byte order mark and no ANSI codes.
/// The keys are in this order:
///
/// | Key | Value |
/// | --- | --- |
/// | `schemaVersion` | The number `1` |
/// | `timestamp` | The UTC time, for example `"2026-10-27T14:23:07.024Z"` |
/// | `level` | The level name, for example `"Information"` |
/// | `category` | The category, or `null` |
/// | `messageTemplate` | The template as the caller wrote it |
/// | `message` | The message text |
/// | `properties` | An array of `{"name": ..., "value": ...}` objects in hole order, with each repeated name and the original JSON value |
/// | `error` | The `Show` text of the error, or `null` |
///
/// Control characters in strings are JSON escapes, for example `\n` and
/// `\u001b`. A number that is not finite becomes `null`.
pub fn format_json_line(record : LogRecord) -> String {
  json_line(record, OrderedArray)
}

///|
/// Render schema version 2 with named values under `properties`. The last
/// occurrence wins. For each repeated name, `duplicateProperties` holds all
/// values in original per-name order, including the winner. Omit that object
/// without repeats. JSON types, escaping and metadata isolation are preserved.
pub fn format_json_object_line(record : LogRecord) -> String {
  json_line(record, NamedObject)
}

///|
fn json_line(record : LogRecord, properties : JsonProperties) -> String {
  let out = StringBuilder()
  let version = match properties {
    OrderedArray => SCHEMA_VERSION
    NamedObject => 2
  }
  out.write_string("{\"schemaVersion\":\{version},\"timestamp\":")
  write_json_string(out, record.timestamp.to_iso8601())
  out.write_string(",\"level\":")
  write_json_string(out, record.level.to_string())
  out.write_string(",\"category\":")
  write_optional(out, record.category)
  out.write_string(",\"messageTemplate\":")
  write_json_string(out, record.template)
  out.write_string(",\"message\":")
  write_json_string(out, record.message)
  out.write_string(",\"properties\":")
  match properties {
    OrderedArray => {
      out.write_char('[')
      for index, entry in record.properties {
        if index > 0 {
          out.write_char(',')
        }
        out.write_string("{\"name\":")
        write_json_string(out, entry.0)
        out.write_string(",\"value\":")
        write_json(out, entry.1)
        out.write_char('}')
      }
      out.write_char(']')
    }
    NamedObject => {
      let values : Map[String, Json] = Map([])
      let occurrences : Map[String, Array[Json]] = Map([])
      for (name, value) in record.properties {
        values[name] = value
        match occurrences.get(name) {
          Some(items) => items.push(value)
          None => occurrences[name] = [value]
        }
      }
      write_json(out, Json::object(values))
      let duplicates : Map[String, Json] = Map([])
      for name, items in occurrences {
        if items.length() > 1 {
          duplicates[name] = Json::array(items)
        }
      }
      if !duplicates.is_empty() {
        out.write_string(",\"duplicateProperties\":")
        write_json(out, Json::object(duplicates))
      }
    }
  }
  out.write_string(",\"error\":")
  write_optional(out, record.error_text)
  out.write_string("}\n")
  out.to_string()
}

///|
/// The number of bytes of `text` in UTF-8. A sink uses it to count the bytes of
/// a rendered record. A lone surrogate counts as 3 bytes, the size of the
/// replacement character that an encoder writes for it.
pub fn utf8_length(text : String) -> Int64 {
  let mut bytes = 0L
  for char in text {
    let code = char.to_int()
    bytes += if code < 0x80 {
      1L
    } else if code < 0x800 {
      2L
    } else if code < 0x10000 {
      3L
    } else {
      4L
    }
  }
  bytes
}