///|
/// The HTML report: one self-contained document with four charts.
///
/// The charts are drawn by Apache ECharts, which is vendored under `assets/` and
/// inlined here by the caller. Inlining rather than linking keeps the promise the
/// rest of the tool makes — everything happens on the machine you run it on, with
/// no network and no second file to keep next to the report.

///|
/// One module with the functions that belong to it, heaviest first.
///
/// The treemap needs the nesting, and the pie needs the totals, so the grouping
/// is done once here rather than twice in the page.
priv struct ModuleGroup {
  name : String
  size : Int
  functions : Int
  children : Array[FunctionStat]
}

///|
/// Group functions by the package their names carry, heaviest total first.
fn group_modules(stats : Array[FunctionStat]) -> Array[ModuleGroup] {
  let order : Array[String] = []
  let index : Map[String, Int] = Map([])
  let groups : Array[ModuleGroup] = []
  for stat in stats {
    let at = match index.get(stat.module_name) {
      Some(at) => at
      None => {
        index.set(stat.module_name, groups.length())
        order.push(stat.module_name)
        groups.push({
          name: stat.module_name,
          size: 0,
          functions: 0,
          children: [],
        })
        groups.length() - 1
      }
    }
    let group = groups[at]
    groups[at] = {
      name: group.name,
      size: group.size + stat.total_size,
      functions: group.functions + 1,
      children: group.children,
    }
    groups[at].children.push(stat)
  }
  for group in groups {
    group.children.sort_by(fn(a, b) {
      if a.total_size != b.total_size {
        b.total_size - a.total_size
      } else {
        a.index - b.index
      }
    })
  }
  groups.sort_by(fn(a, b) {
    if a.size != b.size {
      b.size - a.size
    } else {
      String::compare(a.name, b.name)
    }
  })
  groups
}

///|
/// The bytes spent on a named section, or zero when the module has none.
fn section_size(parsed : WasmModule, kind : SectionId) -> Int {
  let mut total = 0
  for section in parsed.sections {
    if same_kind(section.kind, kind) {
      total = total + section.total_size
    }
  }
  total
}

///|
/// The bytes in functions no root can reach.
fn dead_size(analysis : Analysis) -> Int {
  let mut total = 0
  for stat in analysis.stats {
    if !analysis.reachable.contains(stat.index) {
      total = total + stat.total_size
    }
  }
  total
}

///|
/// An integer as a JSON number.
///
/// The representation is pinned to the decimal text: a file size has no
/// fractional part, and letting the printer choose would turn 10645 into
/// `10645.0` or `1.0645E4` depending on its mood.
fn int_json(value : Int) -> Json {
  Json::number(value.to_double(), repr=value.to_string())
}

///|
/// The payload the page draws from, as one JSON object.
fn report_data(analysis : Analysis, file_path : String) -> Json {
  let parsed = analysis.parsed
  let members : Map[String, Json] = Map([])
  members.set("file", Json::string(file_path))
  members.set("fileSize", int_json(parsed.file_size))
  members.set("gzipSize", int_json(compressed_size(analysis.data)))
  members.set("codeSize", int_json(section_size(parsed, Code)))
  members.set("deadSize", int_json(dead_size(analysis)))
  let sections : Array[Json] = []
  for section in ordered_sections(parsed) {
    let row : Map[String, Json] = Map([])
    row.set("name", Json::string(section_label(section)))
    row.set("total", int_json(section.total_size))
    row.set("payload", int_json(section.payload_size))
    row.set(
      "gzip",
      int_json(
        compressed_span(analysis.data, section.offset, section.total_size),
      ),
    )
    sections.push(Json::object(row))
  }
  members.set("sections", Json::array(sections))
  let ranked = analysis.stats.copy()
  ranked.sort_by(fn(a, b) {
    if a.body_size != b.body_size {
      b.body_size - a.body_size
    } else {
      a.index - b.index
    }
  })
  let functions : Array[Json] = []
  for stat in ranked {
    let row : Map[String, Json] = Map([])
    row.set("index", int_json(stat.index))
    row.set("name", Json::string(stat.name))
    row.set("module", Json::string(stat.module_name))
    row.set("bodySize", int_json(stat.body_size))
    row.set("size", int_json(stat.total_size))
    row.set(
      "gzip",
      int_json(compressed_span(analysis.data, stat.offset, stat.total_size)),
    )
    row.set("dead", Json::boolean(!analysis.reachable.contains(stat.index)))
    functions.push(Json::object(row))
  }
  members.set("functions", Json::array(functions))
  let groups = group_modules(analysis.stats)
  let modules : Array[Json] = []
  for group in groups {
    let children : Array[Json] = []
    for child in group.children {
      let leaf : Map[String, Json] = Map([])
      leaf.set("name", Json::string(child.name))
      leaf.set("value", int_json(child.total_size))
      leaf.set(
        "gzip",
        int_json(compressed_span(analysis.data, child.offset, child.total_size)),
      )
      leaf.set("dead", Json::boolean(!analysis.reachable.contains(child.index)))
      children.push(Json::object(leaf))
    }
    let row : Map[String, Json] = Map([])
    row.set("name", Json::string(group.name))
    row.set("size", int_json(group.size))
    row.set("functions", int_json(group.functions))
    row.set("children", Json::array(children))
    modules.push(Json::object(row))
  }
  members.set("modules", Json::array(modules))
  // The pie gets its own roster: thin slices are rolled up there, while the
  // treemap keeps every module because its cells are drawn to scale.
  let slices : Array[Json] = []
  for slice in pie_slices(groups) {
    let row : Map[String, Json] = Map([])
    row.set("name", Json::string(slice.name))
    row.set("size", int_json(slice.size))
    let names : Array[Json] = []
    for name in slice.members {
      names.push(Json::string(name))
    }
    row.set("members", Json::array(names))
    slices.push(Json::object(row))
  }
  members.set("pie", Json::array(slices))
  Json::object(members)
}

///|
/// One slice of the module pie.
priv struct PieSlice {
  name : String
  size : Int
  /// The modules this slice stands for; empty when it is a module itself.
  members : Array[String]
}

///|
/// A slice below half a percent of the code section cannot carry a legible label
/// or be aimed at with a pointer, so the ones below it are rolled into a single
/// slice that names them on hover.
const MIN_SLICE_TENTHS : Int = 5

///|
/// The pie's slices: every module at or above the threshold, plus one `other`
/// slice holding the rest.
///
/// The comparison is integer arithmetic — `size / total < 0.5%` is
/// `size * 1000 < total * 5` — so it cannot drift the way a float would.
fn pie_slices(groups : Array[ModuleGroup]) -> Array[PieSlice] {
  let slices : Array[PieSlice] = []
  let mut total = 0
  for group in groups {
    total = total + group.size
  }
  let big : Array[ModuleGroup] = []
  let small : Array[ModuleGroup] = []
  for group in groups {
    if total > 0 && group.size * 1000 < total * MIN_SLICE_TENTHS {
      small.push(group)
    } else {
      big.push(group)
    }
  }
  // Bucketing nothing, or bucketing everything, would both make the chart worse.
  if small.length() == 0 || big.length() == 0 {
    for group in groups {
      slices.push({ name: group.name, size: group.size, members: [], })
    }
    return slices
  }
  for group in big {
    slices.push({ name: group.name, size: group.size, members: [], })
  }
  let mut rest = 0
  let members : Array[String] = []
  for group in small {
    rest = rest + group.size
    members.push(group.name)
  }
  slices.push({
    name: "other (" + small.length().to_string() + ")",
    size: rest,
    members,
  })
  slices
}

///|
/// The length of the UTF-8 sequence a lead byte introduces.
fn utf8_width(lead : Byte) -> Int {
  if lead < b'\x80' {
    1
  } else if lead >= b'\xf0' {
    4
  } else if lead >= b'\xe0' {
    3
  } else if lead >= b'\xc0' {
    2
  } else {
    1
  }
}

///|
/// Replace every occurrence of `needle` in `text`, which must not be empty.
fn replace_all(text : String, needle : String, replacement : String) -> String {
  if needle.length() == 0 {
    return text
  }
  let bytes = @utf8.encode(text)
  let find = @utf8.encode(needle)
  let out = StringBuilder(size_hint=bytes.length())
  let mut i = 0
  while i < bytes.length() {
    if i + find.length() <= bytes.length() && matches_at(bytes, find, i) {
      out.write_string(replacement)
      i = i + find.length()
    } else {
      // A whole character at a time: one byte of a multi-byte character is not
      // decodable on its own, so decoding it lossily would turn every non-ASCII
      // character in the document into a replacement character.
      let width = utf8_width(bytes[i])
      let end = if i + width <= bytes.length() { i + width } else { i + 1 }
      out.write_string(@utf8.decode_lossy(bytes[i:end]))
      i = end
    }
  }
  out.to_string()
}

///|
/// True when `needle` sits in `bytes` at `at`.
fn matches_at(bytes : Bytes, needle : Bytes, at : Int) -> Bool {
  let mut i = 0
  while i < needle.length() {
    if bytes[at + i] != needle[i] {
      return false
    }
    i = i + 1
  }
  true
}

///|
/// Make text safe inside a `\n",
    )
  } else {
    out.write_string(
      "\n",
    )
  }
  out.write_string("\n\n\n")
  out.to_string()
}