///|
/// Supported output formats.
pub(all) enum Format {
  Table
  Json
  Csv
  Yaml
}

///|
fn pad_right(s : String, width : Int32) -> String {
  let n = s.length()
  if n >= width {
    s
  } else {
    let buf = StringBuilder()
    buf.write_string(s)
    let mut i = n
    while i < width {
      buf.write_char(' ')
      i += 1
    }
    buf.to_string()
  }
}

///|
fn pad_left(s : String, width : Int32) -> String {
  let n = s.length()
  if n >= width {
    s
  } else {
    let buf = StringBuilder()
    let mut i = n
    while i < width {
      buf.write_char(' ')
      i += 1
    }
    buf.write_string(s)
    buf.to_string()
  }
}

///|
fn repeat(ch : Char, n : Int32) -> String {
  let buf = StringBuilder()
  let mut i = 0
  while i < n {
    buf.write_char(ch)
    i += 1
  }
  buf.to_string()
}

///|
const LINE_WIDTH = 79

///|
/// Render the tokei-style table:
/// ===============================================================================
///  Language            Files        Lines         Code     Comments       Blanks
/// ===============================================================================
///  C++                     5         2250         1650          300          300
/// -------------------------------------------------------------------------------
///  Total                   5         2250         1650          300          300
/// ===============================================================================
pub fn render_table(r : @report.Report) -> String {
  render_table_with_options(r, LINE_WIDTH, "plain")
}

///|
/// Match upstream's 79-column minimum; wider tables expand the language cell.
pub fn render_table_with_options(
  r : @report.Report,
  columns : Int32,
  num_format : String,
) -> String {
  render_table_with_files(r, [], columns, num_format, false)
}

///|
/// Show child-language counts beneath each container language, as Tokei does.
pub fn render_table_with_files(
  r : @report.Report,
  files : Array[(String, String, @counter.Counts)],
  columns : Int32,
  num_format : String,
  compact : Bool,
) -> String {
  let buf = StringBuilder()
  let table_width = if columns > LINE_WIDTH { columns } else { LINE_WIDTH }
  let name_width = table_width - 60
  buf.write_string(repeat('=', table_width))
  buf.write_char('\n')
  buf.write_string(pad_right(" Language", name_width))
  buf.write_string(pad_left("Files", 7))
  buf.write_string(pad_left("Lines", 13))
  buf.write_string(pad_left("Code", 13))
  buf.write_string(pad_left("Comments", 13))
  buf.write_string(pad_left("Blanks", 13))
  buf.write_char('\n')
  buf.write_string(repeat('=', table_width))
  buf.write_char('\n')
  for s in r.langs {
    write_table_row_width(
      buf,
      s.name,
      s.files,
      s.lines,
      s.code,
      s.comments,
      s.blanks,
      name_width,
      num_format,
    )
    if !compact {
      write_child_table_rows(buf, s, files, name_width, num_format)
    }
  }
  buf.write_string(repeat('=', table_width))
  buf.write_char('\n')
  let t = r.total
  write_table_row_width(
    buf,
    t.name,
    t.files,
    t.lines,
    t.code,
    t.comments,
    t.blanks,
    name_width,
    num_format,
  )
  buf.write_string(repeat('=', table_width))
  buf.write_char('\n')
  buf.to_string()
}

///|
fn write_child_table_rows(
  buf : StringBuilder,
  parent : @report.LangStat,
  files : Array[(String, String, @counter.Counts)],
  name_width : Int32,
  num_format : String,
) -> Unit {
  let children : Map[String, @counter.Counts] = Map([])
  for entry in files {
    if entry.1 == parent.key {
      for child in entry.2.blobs {
        match children.get(child.0) {
          Some(existing) => existing.add(child.1)
          None => children.set(child.0, child.1.copy())
        }
      }
    }
  }
  if children.is_empty() {
    return
  }
  let keys : Array[String] = []
  for child in children {
    keys.push(child.0)
  }
  keys.sort_by(fn(a, b) { child_name(a).lexical_compare(child_name(b)) })
  let sum = @counter.Counts::new()
  sum.lines = parent.lines
  sum.code = parent.code
  sum.comments = parent.comments
  sum.blanks = parent.blanks
  for key in keys {
    match children.get(key) {
      Some(child) => {
        write_table_row_width(
          buf,
          "|- " + child_name(key),
          child.files,
          child.lines,
          child.code,
          child.comments,
          child.blanks,
          name_width,
          num_format,
        )
        sum.add(child)
      }
      None => ()
    }
  }
  buf.write_string(pad_right(" (Total)", name_width))
  buf.write_string(repeat(' ', 7))
  buf.write_string(pad_left(format_number(sum.lines, num_format), 13))
  buf.write_string(pad_left(format_number(sum.code, num_format), 13))
  buf.write_string(pad_left(format_number(sum.comments, num_format), 13))
  buf.write_string(pad_left(format_number(sum.blanks, num_format), 13))
  buf.write_char('\n')
}

///|
fn write_table_row_width(
  buf : StringBuilder,
  name : String,
  files : Int32,
  lines : Int32,
  code : Int32,
  comments : Int32,
  blanks : Int32,
  width : Int32,
  num_format : String,
) -> Unit {
  buf.write_string(pad_right(" " + name, width))
  buf.write_string(pad_left(format_number(files, num_format), 7))
  buf.write_string(pad_left(format_number(lines, num_format), 13))
  buf.write_string(pad_left(format_number(code, num_format), 13))
  buf.write_string(pad_left(format_number(comments, num_format), 13))
  buf.write_string(pad_left(format_number(blanks, num_format), 13))
  buf.write_char('\n')
}

///|
fn format_number(value : Int32, style : String) -> String {
  let separator = match style {
    "commas" => ","
    "dots" => "."
    "underscores" => "_"
    _ => return value.to_string()
  }
  let digits = value.to_string()
  let out = StringBuilder()
  for i = 0; i < digits.length(); i = i + 1 {
    if i > 0 && (digits.length() - i) % 3 == 0 {
      out.write_string(separator)
    }
    out.write_char(Int32::unsafe_to_char(digits.at(i).to_int()))
  }
  out.to_string()
}

///|
/// JSON follows Tokei's serialized Language / Report / CodeStats model.
/// Derived file and line totals belong in the table, not in this format.
pub fn render_json(
  r : @report.Report,
  files : Array[(String, String, @counter.Counts)],
) -> String {
  let buf = StringBuilder()
  buf.write_char('{')
  let mut first = true
  for s in r.langs {
    write_json_obj(buf, s.name, s, files, !first)
    first = false
  }
  if !first {
    buf.write_char(',')
  }
  write_json_total(buf, r, files)
  buf.write_char('}')
  buf.to_string()
}

///|
fn write_json_obj(
  buf : StringBuilder,
  name : String,
  s : @report.LangStat,
  files : Array[(String, String, @counter.Counts)],
  comma_before : Bool,
) -> Unit {
  if comma_before {
    buf.write_char(',')
  }
  buf.write_string(json_escape(name))
  buf.write_string(":{\"blanks\":")
  buf.write_string(s.blanks.to_string())
  buf.write_string(",\"comments\":")
  buf.write_string(s.comments.to_string())
  buf.write_string(",\"code\":")
  buf.write_string(s.code.to_string())
  buf.write_string(",\"reports\":[")
  write_json_reports(buf, s.key, files)
  buf.write_string("],\"children\":{")
  write_json_child_reports(buf, s.key, files)
  buf.write_string("},\"inaccurate\":false")
  buf.write_string("}")
}

///|
fn child_keys(
  key : String,
  files : Array[(String, String, @counter.Counts)],
) -> Array[String] {
  let keys : Array[String] = []
  for entry in files {
    if entry.1 == key {
      for kv in entry.2.blobs {
        if !keys.contains(kv.0) {
          keys.push(kv.0)
        }
      }
    }
  }
  keys.sort()
  keys
}

///|
fn child_name(key : String) -> String {
  match @lang.by_key(key) {
    Some(lang) => lang.name
    None => key
  }
}

///|
fn write_json_stats(buf : StringBuilder, counts : @counter.Counts) -> Unit {
  buf.write_string("{\"blanks\":")
  buf.write_string(counts.blanks.to_string())
  buf.write_string(",\"code\":")
  buf.write_string(counts.code.to_string())
  buf.write_string(",\"comments\":")
  buf.write_string(counts.comments.to_string())
  buf.write_string(",\"blobs\":{")
  let keys : Array[String] = []
  for kv in counts.blobs {
    keys.push(kv.0)
  }
  keys.sort()
  for i, key in keys {
    if i > 0 {
      buf.write_char(',')
    }
    buf.write_string(json_escape(child_name(key)))
    buf.write_char(':')
    match counts.blobs.get(key) {
      Some(child) => write_json_stats(buf, child)
      None => ()
    }
  }
  buf.write_string("}}")
}

///|
fn write_json_report(
  buf : StringBuilder,
  path : String,
  counts : @counter.Counts,
) -> Unit {
  buf.write_string("{\"stats\":")
  write_json_stats(buf, counts)
  buf.write_string(",\"name\":")
  buf.write_string(json_escape(path))
  buf.write_char('}')
}

///|
fn write_json_child_reports(
  buf : StringBuilder,
  key : String,
  files : Array[(String, String, @counter.Counts)],
) -> Unit {
  let keys = child_keys(key, files)
  for i, child_key in keys {
    if i > 0 {
      buf.write_char(',')
    }
    buf.write_string(json_escape(child_name(child_key)))
    buf.write_string(": [")
    let mut first = true
    for entry in files {
      if entry.1 == key {
        match entry.2.blobs.get(child_key) {
          Some(child) => {
            if !first {
              buf.write_char(',')
            }
            first = false
            write_json_report(buf, entry.0, child)
          }
          None => ()
        }
      }
    }
    buf.write_char(']')
  }
}

///|
fn write_json_reports(
  buf : StringBuilder,
  key : String,
  files : Array[(String, String, @counter.Counts)],
) -> Unit {
  let mut first = true
  for entry in files {
    if entry.1 == key {
      if !first {
        buf.write_char(',')
      }
      first = false
      write_json_report(buf, entry.0, entry.2)
    }
  }
}

///|
fn write_json_total(
  buf : StringBuilder,
  r : @report.Report,
  files : Array[(String, String, @counter.Counts)],
) -> Unit {
  buf.write_string("\"Total\":{\"blanks\":")
  buf.write_string(r.total.blanks.to_string())
  buf.write_string(",\"code\":")
  buf.write_string(r.total.code.to_string())
  buf.write_string(",\"comments\":")
  buf.write_string(r.total.comments.to_string())
  buf.write_string(",\"reports\":[],\"children\":{")
  let mut first = true
  for s in r.langs {
    if !first {
      buf.write_char(',')
    }
    first = false
    buf.write_string(json_escape(s.key))
    buf.write_string(": [")
    write_json_reports(buf, s.key, files)
    buf.write_char(']')
  }
  buf.write_string("},\"inaccurate\":false")
  buf.write_string("}")
}

///|
fn json_escape(s : String) -> String {
  let buf = StringBuilder()
  buf.write_char('"')
  let n = s.length()
  let mut i = 0
  while i < n {
    let c = s.at(i).to_int()
    if c == 0x22 {
      buf.write_string("\\\"")
    } else if c == 0x5C {
      buf.write_string("\\\\")
    } else if c == 0x0A {
      buf.write_string("\\n")
    } else if c == 0x0D {
      buf.write_string("\\r")
    } else if c == 0x09 {
      buf.write_string("\\t")
    } else {
      buf.write_char(Int32::unsafe_to_char(c))
    }
    i += 1
  }
  buf.write_char('"')
  buf.to_string()
}

///|
/// CSV output:
/// Language,Files,Lines,Code,Comments,Blanks
pub fn render_csv(r : @report.Report) -> String {
  let buf = StringBuilder()
  buf.write_string("Language,Files,Lines,Code,Comments,Blanks\n")
  for s in r.langs {
    write_csv_row(buf, s)
  }
  write_csv_row(buf, r.total)
  buf.to_string()
}

///|
fn write_csv_row(buf : StringBuilder, s : @report.LangStat) -> Unit {
  buf.write_string(json_escape(s.name))
  buf.write_string(",")
  buf.write_string(s.files.to_string())
  buf.write_string(",")
  buf.write_string(s.lines.to_string())
  buf.write_string(",")
  buf.write_string(s.code.to_string())
  buf.write_string(",")
  buf.write_string(s.comments.to_string())
  buf.write_string(",")
  buf.write_string(s.blanks.to_string())
  buf.write_char('\n')
}

///|
/// YAML uses the same language/report/stat fields as JSON.
pub fn render_yaml(
  r : @report.Report,
  files : Array[(String, String, @counter.Counts)],
) -> String {
  let buf = StringBuilder()
  buf.write_string("---\n")
  for s in r.langs {
    write_yaml_obj(buf, s.name, s, files)
  }
  write_yaml_total(buf, r, files)
  buf.to_string()
}

///|
fn write_yaml_obj(
  buf : StringBuilder,
  name : String,
  s : @report.LangStat,
  files : Array[(String, String, @counter.Counts)],
) -> Unit {
  buf.write_string(json_escape(name))
  buf.write_string(":\n")
  buf.write_string("  blanks: ")
  buf.write_string(s.blanks.to_string())
  let keys = child_keys(s.key, files)
  if keys.length() == 0 {
    buf.write_string("\n  children: {}\n")
  } else {
    buf.write_string("\n  children:\n")
    for child_key in keys {
      buf.write_string("    " + json_escape(child_name(child_key)) + ":\n")
      for entry in files {
        if entry.1 == s.key {
          match entry.2.blobs.get(child_key) {
            Some(child) =>
              write_yaml_report(buf, (entry.0, child_key, child), 6)
            None => ()
          }
        }
      }
    }
  }
  buf.write_string("  code: ")
  buf.write_string(s.code.to_string())
  buf.write_string("\n")
  buf.write_string("  comments: ")
  buf.write_string(s.comments.to_string())
  buf.write_string("\n  inaccurate: false\n")
  let mut any_reports = false
  for entry in files {
    if entry.1 == s.key {
      if !any_reports {
        buf.write_string("  reports:\n")
        any_reports = true
      }
      write_yaml_report(buf, entry, 4)
    }
  }
  if !any_reports {
    buf.write_string("  reports: []\n")
  }
}

///|
fn write_yaml_report(
  buf : StringBuilder,
  entry : (String, String, @counter.Counts),
  indent : Int32,
) -> Unit {
  let p = repeat(' ', indent)
  buf.write_string(p + "- name: " + json_escape(entry.0) + "\n")
  buf.write_string(p + "  stats:\n")
  write_yaml_stats(buf, entry.2, indent + 4)
}

///|
fn write_yaml_stats(
  buf : StringBuilder,
  counts : @counter.Counts,
  indent : Int32,
) -> Unit {
  let p = repeat(' ', indent)
  buf.write_string(p + "blanks: " + counts.blanks.to_string() + "\n")
  let keys : Array[String] = []
  for kv in counts.blobs {
    keys.push(kv.0)
  }
  keys.sort()
  if keys.length() == 0 {
    buf.write_string(p + "blobs: {}\n")
  } else {
    buf.write_string(p + "blobs:\n")
    for key in keys {
      buf.write_string(p + "  " + json_escape(child_name(key)) + ":\n")
      match counts.blobs.get(key) {
        Some(child) => write_yaml_stats(buf, child, indent + 4)
        None => ()
      }
    }
  }
  buf.write_string(p + "code: " + counts.code.to_string() + "\n")
  buf.write_string(p + "comments: " + counts.comments.to_string() + "\n")
}

///|
fn write_yaml_total(
  buf : StringBuilder,
  r : @report.Report,
  files : Array[(String, String, @counter.Counts)],
) -> Unit {
  buf.write_string("Total:\n  blanks: ")
  buf.write_string(r.total.blanks.to_string())
  if r.langs.length() == 0 {
    buf.write_string("\n  children: {}\n")
  } else {
    buf.write_string("\n  children:\n")
    for s in r.langs {
      buf.write_string("    " + json_escape(s.key) + ":\n")
      for entry in files {
        if entry.1 == s.key {
          write_yaml_report(buf, entry, 6)
        }
      }
    }
  }
  buf.write_string("  code: " + r.total.code.to_string() + "\n")
  buf.write_string("  comments: " + r.total.comments.to_string() + "\n")
  buf.write_string("  inaccurate: false\n  reports: []\n")
}

///|
/// Per-file statistics grouped under their language totals (--files mode).
pub fn render_files(
  r : @report.Report,
  files : Array[(String, String, @counter.Counts)],
) -> String {
  render_files_with_options(r, files, LINE_WIDTH, "plain", false)
}

///|
pub fn render_files_with_options(
  r : @report.Report,
  files : Array[(String, String, @counter.Counts)],
  columns : Int32,
  num_format : String,
  compact : Bool,
) -> String {
  let buf = StringBuilder()
  let table_width = if columns > LINE_WIDTH { columns } else { LINE_WIDTH }
  let name_width = table_width - 60
  buf.write_string(repeat('=', table_width))
  buf.write_char('\n')
  buf.write_string(pad_right(" Language", name_width))
  buf.write_string(pad_left("Files", 7))
  buf.write_string(pad_left("Lines", 13))
  buf.write_string(pad_left("Code", 13))
  buf.write_string(pad_left("Comments", 13))
  buf.write_string(pad_left("Blanks", 13))
  buf.write_char('\n')
  buf.write_string(repeat('=', table_width))
  buf.write_char('\n')
  for index, lang in r.langs {
    write_table_row_width(
      buf,
      lang.name,
      lang.files,
      lang.lines,
      lang.code,
      lang.comments,
      lang.blanks,
      name_width,
      num_format,
    )
    if !compact {
      write_child_table_rows(buf, lang, files, name_width, num_format)
    }
    buf.write_string(repeat('-', table_width))
    buf.write_char('\n')
    let mut embedded_index = 0
    for entry in files {
      if entry.1 == lang.key {
        if entry.2.blobs.is_empty() {
          write_file_table_row(
            buf,
            entry.0,
            entry.2,
            name_width + 7,
            num_format,
          )
        } else {
          write_embedded_file_rows(
            buf,
            entry.0,
            lang.name,
            entry.2,
            name_width + 7,
            table_width,
            num_format,
            embedded_index,
          )
          embedded_index += 1
        }
      }
    }
    if index + 1 < r.langs.length() {
      buf.write_string(repeat('-', table_width))
      buf.write_char('\n')
    }
  }
  buf.write_string(repeat('=', table_width))
  buf.write_char('\n')
  let t = r.total
  write_table_row_width(
    buf,
    t.name,
    t.files,
    t.lines,
    t.code,
    t.comments,
    t.blanks,
    name_width,
    num_format,
  )
  buf.write_string(repeat('=', table_width))
  buf.write_char('\n')
  buf.to_string()
}

///|
/// Tokei expands each mixed-language file beneath its language summary.
/// The first file starts with its path; subsequent files use a path separator.
fn write_embedded_file_rows(
  buf : StringBuilder,
  path : String,
  language : String,
  counts : @counter.Counts,
  name_width : Int32,
  table_width : Int32,
  num_format : String,
  index : Int32,
) -> Unit {
  if index == 0 {
    buf.write_string(" " + path + "\n")
  } else {
    let prefix = "-- " + path + " "
    buf.write_string(prefix)
    if prefix.length() < table_width {
      buf.write_string(repeat('-', table_width - prefix.length()))
    }
    buf.write_char('\n')
  }
  write_file_table_row(buf, "|- " + language, counts, name_width, num_format)
  let children : Array[String] = []
  for child in counts.blobs {
    children.push(child.0)
  }
  children.sort_by(fn(a, b) { child_name(a).lexical_compare(child_name(b)) })
  for key in children {
    match counts.blobs.get(key) {
      Some(child) =>
        write_file_table_row(
          buf,
          "|- " + child_name(key),
          child,
          name_width,
          num_format,
        )
      None => ()
    }
  }
  // Upstream leaves the full path visible in this summary, even beyond the
  // fixed name column used for ordinary files.
  buf.write_string(pad_right(" " + path, name_width))
  buf.write_string(pad_left(format_number(counts.lines, num_format), 13))
  buf.write_string(pad_left(format_number(counts.code, num_format), 13))
  buf.write_string(pad_left(format_number(counts.comments, num_format), 13))
  buf.write_string(pad_left(format_number(counts.blanks, num_format), 13))
  buf.write_char('\n')
}

///|
fn write_file_table_row(
  buf : StringBuilder,
  path : String,
  counts : @counter.Counts,
  width : Int32,
  num_format : String,
) -> Unit {
  let label = if path.length() + 1 <= width {
    " " + path
  } else {
    " |" + path[path.length() - (width - 2):].to_owned()
  }
  buf.write_string(pad_right(label, width))
  buf.write_string(pad_left(format_number(counts.lines, num_format), 13))
  buf.write_string(pad_left(format_number(counts.code, num_format), 13))
  buf.write_string(pad_left(format_number(counts.comments, num_format), 13))
  buf.write_string(pad_left(format_number(counts.blanks, num_format), 13))
  buf.write_char('\n')
}