///|
/// The style of console output.
pub(all) enum Theme {
  /// ANSI colors: muted time, brackets and category, a style for each level,
  /// muted literal text, a style for each JSON type of a bound value, and a
  /// style for error text.
  Literate
  /// No ANSI escape codes.
  Plain
} derive(Eq, Debug)

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

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

///|
/// The indent at the start of each continuation line of a message and each line
/// of error text.
const INDENT : String = "    "

///|
/// The ANSI code that removes all styles.
const RESET : String = "\u{1b}[0m"

///|
/// Time, brackets and category: bright black (gray).
const META_STYLE : String = "\u{1b}[90m"

///|
/// Literal message text: white, which is less bright than bound values.
const LITERAL_STYLE : String = "\u{1b}[37m"

///|
/// Error text: bright red.
const ERROR_STYLE : String = "\u{1b}[91m"

///|
/// The style of the level code of `level`.
fn level_style(level : @logging.Level) -> String {
  match level {
    Trace => "\u{1b}[90m"
    Debug => "\u{1b}[37m"
    Information => "\u{1b}[97m"
    Warning => "\u{1b}[93m"
    Error => "\u{1b}[97;41m"
    Critical => "\u{1b}[1;97;41m"
  }
}

///|
/// The style of a bound value, from its JSON type.
fn value_style(value : Json) -> String {
  match value {
    Number(_, ..) => "\u{1b}[95m"
    String(_) => "\u{1b}[96m"
    True | False => "\u{1b}[94m"
    Null => "\u{1b}[34m"
    Array(_) | Object(_) => "\u{1b}[92m"
  }
}

///|
/// The three-letter code of `level`, as in `LogEvent::to_line`.
fn level_code(level : @logging.Level) -> String {
  match level {
    Trace => "TRC"
    Debug => "DBG"
    Information => "INF"
    Warning => "WRN"
    Error => "ERR"
    Critical => "CRT"
  }
}

///|
const HEX_DIGITS : String = "0123456789abcdef"

///|
/// True for a character that `escape_controls` replaces: a C0 control except
/// LF and TAB, DEL, and a C1 control (U+0080 to U+009F).
fn is_unsafe_control(code : Int) -> Bool {
  (code < 0x20 && code != 0x0A && code != 0x09) ||
  code == 0x7F ||
  (code >= 0x80 && code <= 0x9F)
}

///|
/// `text` with each unsafe control character replaced by the visible text
/// `\xNN`, with two lower-case hex digits. For example ESC is `\x1b` and CR is
/// `\x0d`. So caller text cannot send a command to a terminal. LF and TAB stay.
fn escape_controls(text : String) -> String {
  guard text.iter().any(char => is_unsafe_control(char.to_int())) else {
    return text
  }
  let out = StringBuilder()
  for char in text {
    let code = char.to_int()
    if is_unsafe_control(code) {
      out.write_string("\\x")
      out.write_char(HEX_DIGITS.get_char(code / 16).unwrap())
      out.write_char(HEX_DIGITS.get_char(code % 16).unwrap())
    } else {
      out.write_char(char)
    }
  }
  out.to_string()
}

///|
/// A builder of one rendered event that applies the styles of one theme.
priv struct Painter {
  out : StringBuilder
  styled : Bool
}

///|
/// Write `text`, which has no unsafe control characters, in `style`. Each LF
/// starts a continuation line with `INDENT`. Each nonempty piece of a line gets
/// its own style and reset, so no style continues past a line end.
fn Painter::span(self : Painter, style : String, text : String) -> Unit {
  let mut first = true
  for piece in text.split("\n") {
    if !first {
      self.out.write_char('\n')
      self.out.write_string(INDENT)
    }
    first = false
    if piece.is_empty() {
      continue
    }
    if self.styled {
      self.out.write_string(style)
    }
    self.out.write_view(piece)
    if self.styled {
      self.out.write_string(RESET)
    }
  }
}

///|
/// Write `text` with no style.
fn Painter::plain(self : Painter, text : String) -> Unit {
  self.out.write_string(text)
}

///|
/// Write the category and the message of `record`, then each line of its error
/// text, then the final LF.
fn Painter::body(self : Painter, record : LogRecord) -> Unit {
  if record.category is Some(category) {
    self.span(META_STYLE, escape_controls(category) + ":")
    self.plain(" ")
  }
  for segment in record.segments {
    match segment {
      Text(text) => self.span(LITERAL_STYLE, escape_controls(text))
      Value(value~, ..) =>
        self.span(value_style(value), escape_controls(segment.text()))
    }
  }
  if record.error_text is Some(error) {
    self.plain("\n" + INDENT)
    self.span(ERROR_STYLE, escape_controls(error))
  }
  self.plain("\n")
}

///|
/// Render `record` as one console event in `theme`, for example
/// `[14:23:07.012Z INF] workspace.loader: Loaded 3 files from src`, with a final
/// LF. The category and its `: ` are not there when the record has no category.
/// Each continuation line of the message, and each line of the error text, starts
/// with four spaces.
///
/// The renderer replaces each C0 control character except LF and TAB, DEL and
/// each C1 control character (U+0080 to U+009F) in caller text with the visible
/// text `\xNN`, before it adds a style. For example ESC is `\x1b` and the CR of a
/// CR LF pair is `\x0d`. So only the renderer can write ANSI codes. With
/// `Literate`, each styled part ends with a reset, so the event ends with no
/// style before its LF. With `Plain`, the output has no ESC character.
pub fn format_console(record : LogRecord, theme : Theme) -> String {
  let painter = { out: StringBuilder(), styled: theme == Literate }
  painter.span(META_STYLE, "[" + record.timestamp.time_of_day() + " ")
  painter.span(level_style(record.level), level_code(record.level))
  painter.span(META_STYLE, "]")
  painter.plain(" ")
  painter.body(record)
  painter.out.to_string()
}

///|
/// Render `record` as one event of a readable log file, for example
/// `2026-10-27T14:23:07.024Z [INF] workspace.loader: Loaded 3 files from src`,
/// with a final LF. The output has no ANSI codes. The category, the continuation
/// lines and the escape of control characters are as in `format_console`.
pub fn format_text(record : LogRecord) -> String {
  let painter = { out: StringBuilder(), styled: false }
  painter.plain(record.timestamp.to_iso8601())
  painter.plain(" [" + level_code(record.level) + "] ")
  painter.body(record)
  painter.out.to_string()
}