///|
/// Left-pad to `width` so numeric columns line up.
fn pad_left(text : String, width : Int) -> String {
  let mut out = ""
  let mut i = text.length()
  while i < width {
    out = out + " "
    i = i + 1
  }
  out + text
}

///|
/// Right-pad to `width` so the section-name column is a stable gutter.
fn pad_right(text : String, width : Int) -> String {
  let mut out = text
  while out.length() < width {
    out = out + " "
  }
  out
}

///|
/// The module's sections, heaviest first.
///
/// The chart is about size, so the reader should be able to see which sections
/// matter without scanning the whole list. Ties keep file order, which is the
/// only other ordering the section table has.
fn ordered_sections(parsed : WasmModule) -> Array[Section] {
  let ordered = parsed.sections.copy()
  ordered.sort_by(fn(a, b) {
    if a.total_size != b.total_size {
      b.total_size - a.total_size
    } else {
      a.offset - b.offset
    }
  })
  ordered
}

///|
/// The label a section is known by in the report.
fn section_label(section : Section) -> String {
  match section.custom_name {
    Some(name) => section.kind.name() + " (" + name + ")"
    None => section.kind.name()
  }
}

///|
/// Render a ratio as a percentage with one decimal, using double arithmetic so
/// large module sizes cannot overflow the multiplication.
fn share_text(part : Int, whole : Int) -> String {
  if whole <= 0 {
    return "n/a"
  }
  let tenths = (part.to_double() / whole.to_double() * 1000.0).to_int()
  (tenths / 10).to_string() + "." + (tenths % 10).to_string() + "%"
}

///|
/// Name the binary format version, not just its number.
fn version_label(version : Int) -> String {
  if version == 1 {
    "1 (WebAssembly MVP)"
  } else {
    version.to_string()
  }
}

///|
/// The user-facing report: one line per section, each with its byte count.
///
/// The result carries no trailing newline, so the caller decides how to end it.
pub fn format_report(parsed : WasmModule, path : String) -> String {
  let mut accounted = 0
  for section in parsed.sections {
    accounted = accounted + section.total_size
  }
  let mut out = "moonsize — WebAssembly section report\n\n"
  out = out + "  file      " + path + "\n"
  out = out + "  size      " + parsed.file_size.to_string() + " bytes\n"
  out = out + "  version   " + version_label(parsed.version) + "\n"
  out = out + "  sections  " + parsed.sections.length().to_string() + "\n\n"
  out = out +
    "  " +
    pad_left("ID", 3) +
    "  " +
    pad_right("SECTION", 12) +
    "  " +
    pad_left("PAYLOAD", 10) +
    "  " +
    pad_left("TOTAL", 10) +
    "  " +
    pad_left("SHARE", 7) +
    "\n"
  for section in parsed.sections {
    let annotation = match section.custom_name {
      Some(name) => "  " + name
      None => ""
    }
    out = out +
      "  " +
      pad_left(section.id.to_string(), 3) +
      "  " +
      pad_right(section.kind.name(), 12) +
      "  " +
      pad_left(section.payload_size.to_string(), 10) +
      "  " +
      pad_left(section.total_size.to_string(), 10) +
      "  " +
      pad_left(share_text(section.payload_size, parsed.file_size), 7) +
      annotation +
      "\n"
  }
  out = out +
    "\n  accounted  " +
    accounted.to_string() +
    " bytes in sections (" +
    share_text(accounted, parsed.file_size) +
    " of file)"
  out
}

///|
/// `1 module` reads better than `1 modules`, and a report is prose too.
fn plural(count : Int, noun : String) -> String {
  if count == 1 {
    count.to_string() + " " + noun
  } else {
    count.to_string() + " " + noun + "s"
  }
}

///|
/// The width a right-aligned decimal column needs, never below `minimum`.
fn number_width(value : Int, minimum : Int) -> Int {
  let digits = value.to_string().length()
  if digits < minimum {
    minimum
  } else {
    digits
  }
}

///|
/// One function's byte budget, with the name and package the binary gives it.
pub struct FunctionStat {
  /// Index in the function index space, where imported functions come first.
  index : Int
  /// Display name: demangled when the binary mangles it.
  name : String
  /// Original nonempty symbol from the name section, before display demangling.
  /// None means missing or empty; the literal symbol `(unnamed)` is Some.
  raw_name : String?
  /// Package attributed by `module_of`.
  module_name : String
  /// Locals plus instruction bytes, i.e. what a compiler can still shrink.
  body_size : Int
  /// The body's size prefix plus the body: what the file actually spends.
  total_size : Int
  /// Offset of that size prefix from the start of the file.
  offset : Int
}

///|
/// Every function attributed to one package, rolled up.
priv struct ModuleStat {
  name : String
  functions : Int
  body_size : Int
  total_size : Int
}

///|
/// Attribute a body to a name and a package.
///
/// Attribution is best effort by design: a release binary keeps no function
/// names, and the sizes are still worth reporting, so unnamed functions come
/// back labelled `(unnamed)` rather than failing the run.
pub fn function_stats(
  bodies : Array[FunctionBody],
  names : Map[Int, String]?,
) -> Array[FunctionStat] {
  let stats : Array[FunctionStat] = []
  for body in bodies {
    let raw = match names {
      Some(names) =>
        match names.get(body.index) {
          Some(name) => name
          None => ""
        }
      None => ""
    }
    stats.push({
      index: body.index,
      raw_name: if raw.is_empty() {
        None
      } else {
        Some(raw)
      },
      name: if raw.length() == 0 {
        UNNAMED_FUNCTION
      } else {
        display_name(raw)
      },
      module_name: module_of(raw),
      body_size: body.body_size,
      total_size: body.total_size,
      offset: body.offset,
    })
  }
  stats
}

///|
/// Read a module's function names and sizes.
///
/// A convenience over `analyze` for callers that only want the size table; the
/// two share `function_stats`, so they cannot disagree about attribution.
pub fn collect_function_stats(
  parsed : WasmModule,
  data : Bytes,
) -> Result[Array[FunctionStat], WasmError] {
  let sections = find_sections(parsed)
  let imported = match sections.imports {
    Some(section) =>
      match count_imported_functions(data, section) {
        Ok(count) => count
        Err(error) => return Err(error)
      }
    None => 0
  }
  // The code section lists defined functions only, and imported functions hold
  // the first index slots, so the import count is what lines the two up.
  let bodies = match sections.code {
    Some(section) =>
      match parse_code_section(data, section, base_index=imported) {
        Ok(bodies) => bodies
        Err(error) => return Err(error)
      }
    None => []
  }
  let names = match sections.names {
    Some(section) => parse_name_section(data, section)
    None => None
  }
  Ok(function_stats(bodies, names))
}

///|
/// Rank functions by the bytes they cost, heaviest first, breaking ties by index
/// so equal-size functions keep a stable order.
fn rank_functions(stats : Array[FunctionStat]) -> Array[FunctionStat] {
  let ranked = stats.copy()
  ranked.sort_by(fn(a, b) {
    if a.total_size != b.total_size {
      b.total_size - a.total_size
    } else {
      a.index - b.index
    }
  })
  ranked
}

///|
/// Group functions by package, heaviest total first.
fn aggregate_by_module(stats : Array[FunctionStat]) -> Array[ModuleStat] {
  let rows : Array[ModuleStat] = []
  let seen : Map[String, Int] = Map([])
  for stat in stats {
    match seen.get(stat.module_name) {
      Some(at) => {
        let row = rows[at]
        rows[at] = {
          name: row.name,
          functions: row.functions + 1,
          body_size: row.body_size + stat.body_size,
          total_size: row.total_size + stat.total_size,
        }
      }
      None => {
        seen.set(stat.module_name, rows.length())
        rows.push({
          name: stat.module_name,
          functions: 1,
          body_size: stat.body_size,
          total_size: stat.total_size,
        })
      }
    }
  }
  rows.sort_by(fn(a, b) {
    if a.total_size != b.total_size {
      b.total_size - a.total_size
    } else {
      String::compare(a.name, b.name)
    }
  })
  rows
}

///|
/// The Top-N function table, heaviest first.
///
/// Shares are of the whole file, so a row here can be read against the section
/// table above it. `top` is clamped to the number of functions available.
pub fn render_top_functions(
  stats : Array[FunctionStat],
  file_size : Int,
  top : Int,
) -> String {
  let ranked = rank_functions(stats)
  let shown = if top < 0 {
    0
  } else if top > ranked.length() {
    ranked.length()
  } else {
    top
  }
  let mut index_width = 5
  let mut total_width = 10
  let mut body_width = 10
  for stat in stats {
    index_width = number_width(stat.index, index_width)
    body_width = number_width(stat.body_size, body_width)
    total_width = number_width(stat.total_size, total_width)
  }
  let rank_width = number_width(shown, 3)
  let mut out = "Top " +
    shown.to_string() +
    " of " +
    stats.length().to_string() +
    " functions by size\n"
  if shown == 0 {
    let reason = if stats.length() == 0 {
      "no functions in this binary"
    } else {
      "--top 0 requested, nothing to rank"
    }
    return out + "\n  " + reason
  }
  out = out +
    "\n  " +
    pad_left("#", rank_width) +
    "  " +
    pad_left("INDEX", index_width) +
    "  " +
    pad_left("BODY", body_width) +
    "  " +
    pad_left("TOTAL", total_width) +
    "  " +
    pad_left("SHARE", 7) +
    "  FUNCTION\n"
  let mut covered = 0
  let mut rank = 0
  while rank < shown {
    let stat = ranked[rank]
    covered = covered + stat.total_size
    out = out +
      "  " +
      pad_left((rank + 1).to_string(), rank_width) +
      "  " +
      pad_left(stat.index.to_string(), index_width) +
      "  " +
      pad_left(stat.body_size.to_string(), body_width) +
      "  " +
      pad_left(stat.total_size.to_string(), total_width) +
      "  " +
      pad_left(share_text(stat.total_size, file_size), 7) +
      "  " +
      stat.name +
      "\n"
    rank = rank + 1
  }
  out = out +
    "\n  these " +
    shown.to_string() +
    " hold " +
    covered.to_string() +
    " bytes (" +
    share_text(covered, file_size) +
    " of file)"
  out
}

///|
/// The per-package roll-up of every function in the binary.
pub fn render_module_summary(
  stats : Array[FunctionStat],
  file_size : Int,
) -> String {
  let modules = aggregate_by_module(stats)
  // The name column is the only variable-width one, so the gutter it needs is
  // simply the longest name; every other column keeps a fixed width.
  let mut gutter = "MODULE".length()
  let mut functions = 0
  let mut covered = 0
  for group in modules {
    if group.name.length() > gutter {
      gutter = group.name.length()
    }
    functions = functions + group.functions
    covered = covered + group.total_size
  }
  let mut out = "Size by module\n"
  if modules.length() == 0 {
    return out + "\n  no functions in this binary"
  }
  out = out +
    "\n  " +
    pad_right("MODULE", gutter) +
    "  " +
    pad_left("FUNCS", 5) +
    "  " +
    pad_left("BODY", 10) +
    "  " +
    pad_left("TOTAL", 10) +
    "  " +
    pad_left("SHARE", 7) +
    "\n"
  for group in modules {
    out = out +
      "  " +
      pad_right(group.name, gutter) +
      "  " +
      pad_left(group.functions.to_string(), 5) +
      "  " +
      pad_left(group.body_size.to_string(), 10) +
      "  " +
      pad_left(group.total_size.to_string(), 10) +
      "  " +
      pad_left(share_text(group.total_size, file_size), 7) +
      "\n"
  }
  out = out +
    "\n  " +
    plural(modules.length(), "module") +
    ", " +
    plural(functions, "function") +
    ", " +
    covered.to_string() +
    " bytes (" +
    share_text(covered, file_size) +
    " of file)"
  out
}

///|
/// Printed when the tool is invoked without a file, or with arguments it cannot
/// make sense of.
pub fn usage() -> String {
  "usage: moonsize  [--top ] [--retained] [--dead-code]\n" +
  "                 [--compress] [--strict] [--call-graph ] [--html ]\n" +
  "                 [--max-size ] [--baseline ] [--diff-functions]\n" +
  "                 [--data-segments] [--diff-data] [--data-json ]\n" +
  "                 [--budget ] [--budget-json ] [--budget-md ]\n" +
  "                 [--experiment ] [--experiment-context ]\n" +
  "       moonsize  --why-index  [--why-root ] [--why-json ] [--strict]\n\n" +
  "Report the section table of a WebAssembly binary, rank its heaviest\n" +
  "functions, roll the bytes up by module, and follow the call graph to\n" +
  "identify candidates for investigation.\n\n" +
  "  --top             how many functions to rank (default 10)\n" +
  "  --retained           dominated function bytes in the current graph model\n" +
  "  --dead-code          functions no root can reach\n" +
  "  --compress           whole-file gzip and independent section gzip sizes\n" +
  "  --data-segments      append data-entry file bytes (payload-ranked by --top)\n" +
  "  --data-json    enable details and write all entries to a new .json file\n" +
  "                       offset expressions are recorded, not evaluated\n" +
  "  --diff-data          compare exact payload content groups; requires --baseline\n" +
  "                       --data-json then writes the data comparison format\n" +
  "  --strict             refuse to report when the call graph is incomplete,\n" +
  "                       instead of reporting it with a warning\n" +
  "  --call-graph   write a new .dot or .json call graph; refuses existing paths\n" +
  "  --html         write a new self-contained HTML report; refuses existing paths\n" +
  "                       --data-segments or --data-json adds current-input data details\n" +
  "  --max-size     fail with exit code 3 above this size, where size is\n" +
  "                       bytes or a KB/MB/GB count such as 10KB or 1.5MB\n" +
  "  --budget       validated multi-metric byte budgets; growth needs --baseline\n" +
  "  --budget-json  write budget JSON; requires --budget, refuses existing paths\n" +
  "  --budget-md    write CI Markdown summary; requires --budget, refuses existing paths\n" +
  "  --baseline     compare against another module and print the change;\n" +
  "                       --max-size then bounds the growth, not the file\n" +
  "  --diff-functions     append function and package changes; requires --baseline\n" +
  "  --experiment   write measured before/after results; requires --baseline\n" +
  "                       refuses to overwrite an existing path\n" +
  "  --experiment-context   attach declared build settings and validation\n" +
  "  --why-index       explain a shortest model reference path to function n\n" +
  "  --why-root        search from one actual model root (requires --why-index)\n" +
  "  --why-json     write path JSON; refuses existing paths\n" +
  "                       query mode allows only these flags and --strict\n" +
  "                       element functions are conservative roots; paths are\n" +
  "                       not execution traces or predictions of savings\n\n" +
  "All report outputs refuse existing files, directories and link aliases.\n" +
  "Targets are checked together; each file is created exclusively. Output errors use exit 1.\n" +
  "Multiple files are not a transaction; a later failure may leave earlier new outputs.\n\n" +
  "Exit codes: 0 completed, 1 input/query/output/unavailable measurement, 2 bad arguments/config,\n" +
  "            3 over budget, incomplete strict graph, or path search limited."
}

///|
/// A lookup from function index to the name to print for it.
fn names_by_index(stats : Array[FunctionStat]) -> Map[Int, String] {
  let names : Map[Int, String] = Map([])
  for stat in stats {
    names.set(stat.index, stat.name)
  }
  names
}

///|
/// The name to print for an index, with a fallback for anything the size table
/// does not know about.
fn name_of(names : Map[Int, String], index : Int) -> String {
  match names.get(index) {
    Some(name) => name
    None => UNNAMED_FUNCTION
  }
}

///|
/// The line a section prints under its title when the call graph has holes, or
/// `""` when it does not, so a complete graph's report reads exactly as before.
fn incomplete_note(graph : CallGraph) -> String {
  let count = graph.diagnostics.length()
  if count == 0 {
    return ""
  }
  "  (graph incomplete: " +
  count.to_string() +
  " analysis " +
  (if count == 1 { "gap" } else { "gaps" }) +
  ")\n" +
  render_graph_warning(graph) +
  "\n"
}

///|
/// A copy so rendering never reorders the caller's diagnostic records.
fn sorted_diagnostics(graph : CallGraph) -> Array[GraphDiagnostic] {
  let diagnostics = graph.diagnostics.copy()
  diagnostics.sort_by(fn(a, b) {
    if a.caller != b.caller {
      a.caller - b.caller
    } else if a.offset != b.offset {
      a.offset - b.offset
    } else {
      let reason = String::compare(
        diagnostic_code(a.kind),
        diagnostic_code(b.kind),
      )
      if reason != 0 {
        reason
      } else {
        let left = match a.source {
          Some(source) => source.offset
          None => -1
        }
        let right = match b.source {
          Some(source) => source.offset
          None => -1
        }
        left - right
      }
    }
  })
  diagnostics
}

///|
fn diagnostic_code(kind : GraphDiagnosticKind) -> String {
  match kind {
    NoStaticTableTargets => "no_static_table_targets"
    UnmodeledReferenceCall => "unmodeled_reference_call"
    ImportedTableContents => "imported_table_contents"
    ExportedTableContents => "exported_table_contents"
    RuntimeTableMutation => "runtime_table_mutation"
    UnmodeledTableInitializer => "unmodeled_table_initializer"
    UnknownTableContents => "unknown_table_contents"
  }
}

///|
fn diagnostic_reason(kind : GraphDiagnosticKind) -> String {
  match kind {
    NoStaticTableTargets => "no static table targets are resolvable."
    UnmodeledReferenceCall =>
      "reference-call target analysis is not implemented."
    ImportedTableContents =>
      "imported table contents are supplied or shared by the host."
    ExportedTableContents =>
      "exported table contents may be changed by the host."
    RuntimeTableMutation => "runtime table contents may change."
    UnmodeledTableInitializer =>
      "explicit table initializer contents are not modelled."
    UnknownTableContents =>
      "target table is absent from the parsed table context."
  }
}

///|
fn diagnostic_details(diagnostic : GraphDiagnostic) -> String {
  let reason = diagnostic_reason(diagnostic.kind)
  match diagnostic.source {
    Some(source) => {
      let table = match diagnostic.table_index {
        Some(index) => "table " + index.to_string()
        None => "table"
      }
      match (source.caller, source.opcode) {
        (Some(caller), Some(opcode)) =>
          table +
          " contents may change through " +
          opcode.name() +
          " at function " +
          caller.to_string() +
          ", offset " +
          source.offset.to_string() +
          "."
        _ =>
          table +
          ": " +
          reason +
          " Source offset " +
          source.offset.to_string() +
          "."
      }
    }
    None => reason
  }
}

///|
fn graph_modeling_scope() -> String {
  "Direct calls, including direct tail calls, use exact targets; table calls use static element candidates. " +
  "Reference-call targets and dynamic table contents are not solved. " +
  "Imported/exported tables, runtime writes and explicit initializers are conservatively diagnosed, even with static candidates. " +
  "Element references are conservative roots, including private, passive and declarative segments. " +
  "graph_complete means no gaps detected within this scope, not full Wasm certification. " +
  retained_modeling_scope()
}

///|
fn retained_modeling_scope() -> String {
  "RETAINED sums dominated encoded function bytes within the current graph/root model. " +
  "It does not predict recompiled file reduction or whole-file gzip savings, even when --strict passes."
}

///|
/// The warning a report carries when the call graph could not follow every call,
/// or `""` when there is nothing to warn about.
///
/// It belongs above the whole report rather than beside the sections it
/// undermines: those sections are spread through the output, and a reader who
/// skips one of them still has to see this. Saying it is the point — a tool that
/// silently reports an incomplete graph as a fact is how a function in use gets
/// deleted.
pub fn render_graph_warning(graph : CallGraph) -> String {
  let diagnostics = sorted_diagnostics(graph)
  let count = diagnostics.length()
  if count == 0 {
    return ""
  }
  let mut warning = "WARNING: the call graph has " +
    count.to_string() +
    " analysis " +
    (if count == 1 { "gap" } else { "gaps" }) +
    ".\n"
  for diagnostic in diagnostics {
    warning = warning +
      "  function " +
      diagnostic.caller.to_string() +
      ", offset " +
      diagnostic.offset.to_string() +
      ": " +
      diagnostic.opcode.name() +
      ": " +
      diagnostic_details(diagnostic) +
      "\n"
  }
  warning +
  "\n" +
  "Dead-code and retained-size results may be inaccurate; " +
  "do not use them as proof that a function can be removed."
}

///|
/// The retained-size table, computed within the current graph model.
///
/// `SIZE` is the function's encoded bytes. `RETAINED` sums the encoded bytes of
/// reachable defined functions it dominates in this graph/root model, including
/// itself. `DIES` counts the other members of that model set. None of these
/// numbers predicts a source edit's recompiled file or gzip reduction.
pub fn render_retained(analysis : Analysis, top : Int) -> String {
  let stats = analysis.stats
  let retained = analysis.retained
  let indirect = analysis.indirect
  let file_size = analysis.parsed.file_size
  let names = names_by_index(stats)
  let ranked = retained.copy()
  ranked.sort_by(fn(a, b) {
    if a.retained != b.retained {
      b.retained - a.retained
    } else {
      a.index - b.index
    }
  })
  let shown = if top < 0 {
    0
  } else if top > ranked.length() {
    ranked.length()
  } else {
    top
  }
  let mut index_width = 5
  let mut size_width = 4
  let mut retained_width = 8
  for entry in retained {
    size_width = number_width(entry.own_size, size_width)
    retained_width = number_width(entry.retained, retained_width)
    index_width = number_width(entry.index, index_width)
  }
  let mut out = "Retained size\n"
  out = out + "  " + retained_modeling_scope() + "\n"
  out = out + incomplete_note(analysis.graph)
  if shown == 0 {
    return out + "\n  no reachable functions to rank"
  }
  let rank_width = number_width(shown, 3)
  out = out +
    "\n  " +
    pad_left("#", rank_width) +
    "  " +
    pad_left("INDEX", index_width) +
    "  " +
    pad_left("SIZE", size_width) +
    "  " +
    pad_left("RETAINED", retained_width) +
    "  " +
    pad_left("DIES", 4) +
    "  " +
    pad_left("SHARE", 7) +
    "  IND  FUNCTION\n"
  let mut rank = 0
  while rank < shown {
    let entry = ranked[rank]
    out = out +
      "  " +
      pad_left((rank + 1).to_string(), rank_width) +
      "  " +
      pad_left(entry.index.to_string(), index_width) +
      "  " +
      pad_left(entry.own_size.to_string(), size_width) +
      "  " +
      pad_left(entry.retained.to_string(), retained_width) +
      "  " +
      pad_left(entry.retained_functions.to_string(), 4) +
      "  " +
      pad_left(share_text(entry.retained, file_size), 7) +
      "  " +
      pad_left(if indirect.contains(entry.index) { "*" } else { "" }, 3) +
      "  " +
      name_of(names, entry.index) +
      "\n"
    rank = rank + 1
  }
  out = out +
    "\n  DIES counts other functions dominated by this function in the current model.\n" +
    "  IND marks a function an indirect call could reach."
  out
}

///|
/// The dead-code section: functions no root can reach.
///
/// Unreachable means no path within the current model. Detected gaps are always
/// shown, so a caller cannot omit the warning by forgetting an optional count.
pub fn render_dead_code(analysis : Analysis, top : Int) -> String {
  let stats = analysis.stats
  let reachable = analysis.reachable
  let file_size = analysis.parsed.file_size
  let dead : Array[FunctionStat] = []
  let mut bytes = 0
  for stat in stats {
    if !reachable.contains(stat.index) {
      dead.push(stat)
      bytes = bytes + stat.total_size
    }
  }
  dead.sort_by(fn(a, b) {
    if a.total_size != b.total_size {
      b.total_size - a.total_size
    } else {
      a.index - b.index
    }
  })
  let mut out = "Dead code\n"
  out = out + incomplete_note(analysis.graph)
  if stats.length() == 0 {
    return out + "\n  no functions in this binary"
  }
  out = out +
    "\n  " +
    dead.length().to_string() +
    " of " +
    stats.length().to_string() +
    " functions are unreachable from the roots\n" +
    "  " +
    bytes.to_string() +
    " bytes (" +
    share_text(bytes, file_size) +
    " of file)\n"
  if dead.length() == 0 {
    return out + "\n  every function is reachable"
  }
  let shown = if top < 0 {
    0
  } else if top > dead.length() {
    dead.length()
  } else {
    top
  }
  let mut size_width = 4
  for stat in dead {
    size_width = number_width(stat.total_size, size_width)
  }
  out = out +
    "\n  Top " +
    plural(shown, "dead function") +
    "\n\n  " +
    pad_left("SIZE", size_width) +
    "  " +
    pad_left("SHARE", 7) +
    "  FUNCTION\n"
  let mut rank = 0
  while rank < shown {
    let stat = dead[rank]
    if rank > 0 {
      out = out + "\n"
    }
    out = out +
      "  " +
      pad_left(stat.total_size.to_string(), size_width) +
      "  " +
      pad_left(share_text(stat.total_size, file_size), 7) +
      "  " +
      stat.name
    rank = rank + 1
  }
  out
}

///|
/// A compression ratio with two decimals, or `n/a` when there is nothing to
/// divide by.
fn ratio_text(raw : Int, gzip : Int) -> String {
  if gzip <= 0 {
    return "n/a"
  }
  let hundredths = (raw.to_double() / gzip.to_double() * 100.0).to_int()
  let fraction = hundredths % 100
  let fraction_text = if fraction < 10 {
    "0" + fraction.to_string()
  } else {
    fraction.to_string()
  }
  (hundredths / 100).to_string() + "." + fraction_text + "x"
}

///|
/// Whole-file gzip and independent full-section streams. Each ratio divides
/// that row's raw bytes by its own gzip bytes. Independent streams neither add
/// up to nor represent shares of whole-file gzip. Failures retain their reasons.
pub fn render_compression(
  raw_size : Int,
  gzip : Result[Int, String],
  sections : Array[SectionCompression],
) -> String {
  let mut label_width = "SECTION".length()
  let mut raw_width = "RAW".length()
  let mut gzip_width = "GZIP".length()
  let mut ratio_width = "RATIO".length()
  let ratios : Array[String] = []
  for section in sections {
    if section.label.length() > label_width {
      label_width = section.label.length()
    }
    raw_width = number_width(section.raw, raw_width)
    match section.gzip {
      Ok(size) => {
        gzip_width = number_width(size, gzip_width)
        let ratio = ratio_text(section.raw, size)
        if ratio.length() > ratio_width {
          ratio_width = ratio.length()
        }
        ratios.push(ratio)
      }
      Err(_) => {
        // The column has to fit the word as well as the numbers under it.
        if gzip_width < "failed".length() {
          gzip_width = "failed".length()
        }
        ratios.push("-")
      }
    }
  }
  let mut total_width = number_width(raw_size, 1)
  let total_text = match gzip {
    Ok(size) => {
      total_width = number_width(size, total_width)
      pad_left(size.to_string(), total_width) +
      " B  (" +
      share_text(size, raw_size) +
      " of raw, ratio " +
      ratio_text(raw_size, size) +
      ")"
    }
    Err(message) => "failed: " + message
  }
  let mut out = "COMPRESSED SIZE\n  " +
    pad_right("raw", 4) +
    "  " +
    pad_left(raw_size.to_string(), total_width) +
    " B\n  " +
    pad_right("gzip", 4) +
    "  " +
    total_text +
    "\n\n  BY SECTION — INDEPENDENT GZIP STREAMS\n"
  let note = "\n  Section gzip sizes are measured independently and are not additive.\n" +
    "  They do not represent shares of the whole-file gzip size.\n"
  if sections.length() == 0 {
    return out + "\n  no sections\n" + note
  }
  out = out +
    "\n  " +
    pad_right("SECTION", label_width) +
    "  " +
    pad_left("RAW", raw_width) +
    "  " +
    pad_left("GZIP", gzip_width) +
    "  " +
    pad_left("RATIO", ratio_width) +
    "\n"
  let mut i = 0
  for section in sections {
    let (gzip_cell, ratio_cell) = match section.gzip {
      Ok(size) =>
        (
          pad_left(size.to_string(), gzip_width),
          pad_left(ratios[i], ratio_width),
        )
      Err(_) => (pad_left("failed", gzip_width), pad_left("-", ratio_width))
    }
    out = out +
      "  " +
      pad_right(section.label, label_width) +
      "  " +
      pad_left(section.raw.to_string(), raw_width) +
      "  " +
      gzip_cell +
      "  " +
      ratio_cell +
      "\n"
    i = i + 1
  }
  out = out + note
  for section in sections {
    if section.gzip is Err(message) {
      out = out + "  gzip failed for " + section.label + ": " + message + "\n"
    }
  }
  out
}