///|
fn html_escape(s : String) -> String {
  s
  .replace_all(old="&", new="&")
  .replace_all(old="<", new="<")
  .replace_all(old=">", new=">")
  .replace_all(old="\"", new=""")
}

///|
fn display_json(v : Json) -> String {
  match v {
    String(s) => s
    Null => ""
    _ => v.stringify()
  }
}

///|
fn csv_cell(v : Json, key : String) -> String {
  let raw = display_json(v)
  let guarded = match v {
    String(_) =>
      if key == "id" ||
        raw.has_prefix("=") ||
        raw.has_prefix("+") ||
        raw.has_prefix("-") ||
        raw.has_prefix("@") ||
        raw.has_prefix("\t") ||
        raw.has_prefix("\r") {
        "'" + raw
      } else {
        raw
      }
    _ => raw
  }
  "\"" + guarded.replace_all(old="\"", new="\"\"") + "\""
}

///|
priv struct ReportWriter {
  builder : StringBuilder
  mut size : Int32
}

///|
fn ReportWriter::write(
  self : ReportWriter,
  text : String,
) -> Unit raise HeapError {
  guard text.length() <= 67108864 - self.size else {
    raise Limit("report exceeds 64 Mi characters; narrow the query")
  }
  self.builder.write_string(text)
  self.size += text.length()
}

///|
/// Collect every matching row, fail explicitly when max_rows would be exceeded.
pub fn Inspector::report(
  self : Inspector,
  handle : Int32,
  input : String,
) -> Json raise HeapError {
  let req = request(input)
  let view = string_option(req, "view", "nodes")
  let cap = int_option(req, "max_rows", 100000)
  guard cap > 0 && cap <= 1000000 else {
    raise Invalid("max_rows must be in 1..1000000")
  }
  let opts = match req {
    Object(m) => m.copy()
    _ => Map([])
  }
  opts["op"] = @json.to_json(if view == "refs" { "references" } else { view })
  opts["limit"] = @json.to_json(1000)
  let items : Array[Json] = []
  let groups : Array[Json] = []
  let changes : Array[Json] = []
  let mut cursor = 0
  let mut first = Json::null()
  while true {
    opts["offset"] = @json.to_json(cursor)
    opts["group_offset"] = @json.to_json(cursor)
    opts["change_offset"] = @json.to_json(cursor)
    let options = @json.to_json(opts).stringify()
    let raw_page = if view == "diff" {
      self.diff(
        handle,
        integer(field(req, "after_handle"), "after_handle"),
        options,
      )
    } else if view == "investigate" {
      self.investigate(
        handle,
        integer(field(req, "growth_handle"), "growth_handle"),
        integer(field(req, "cleanup_handle"), "cleanup_handle"),
        options,
      )
    } else {
      self.query(handle, options)
    }
    // A tree report includes every direct child of the selected node. Flatten
    // its paginated children, retaining node and ancestor context in the report.
    let page : Json = if view == "tree" {
      let children = field(raw_page, "children")
      {
        "node": field(raw_page, "node"),
        "ancestors": field(raw_page, "ancestors"),
        "items": field(children, "items"),
        "total": field(children, "total"),
      }
    } else {
      raw_page
    }
    if cursor == 0 {
      first = page
    }
    let mut total = 0
    for key in ["total", "total_groups", "total_changes"] {
      match optional(page, key) {
        Some(v) => {
          let n = integer(v, key)
          if n > total {
            total = n
          }
        }
        None => ()
      }
    }
    guard total <= cap else {
      raise Limit(
        "report has \{total} rows in a section, exceeding max_rows=\{cap}; no partial report was produced",
      )
    }
    for
      (key, target) in [
        ("items", items),
        ("groups", groups),
        ("changes", changes),
      ] {
      match optional(page, key) {
        Some(v) =>
          for item in array(v, key) {
            target.push(item)
          }
        None => ()
      }
    }
    cursor += 1000
    if cursor >= total {
      break
    }
  }
  let result = match first {
    Object(m) => {
      let merged = m.copy()
      if merged.contains("items") {
        merged["items"] = @json.to_json(items)
      }
      if merged.contains("groups") {
        merged["groups"] = @json.to_json(groups)
      }
      if merged.contains("changes") {
        merged["changes"] = @json.to_json(changes)
      }
      merged["offset"] = @json.to_json(0)
      if merged.contains("group_offset") {
        merged["group_offset"] = @json.to_json(0)
        merged["change_offset"] = @json.to_json(0)
      }
      @json.to_json(merged)
    }
    _ => first
  }
  {
    "format": "moonheap-report-v2",
    "complete": true,
    "view": @json.to_json(view),
    "policy": "snapshot-strong-v1",
    "request": req,
    "summary": self.query(handle, "{}"),
    "data": result,
    "interpretation": "All matching rows within the declared budget. Bounded path searches have their own completeness flag. Growth and survival are evidence for investigation, not automatic leak verdicts.",
  }
}

///|
fn report_columns(view : String) -> Array[String] {
  match view {
    "refs" | "references" =>
      ["index", "kind", "name", "retaining", "source", "target"]
    "groups" => ["kind", "name", "count", "shallow_bytes", "max_retained_bytes"]
    "diff" =>
      [
        "record_type", "kind", "name", "id", "status", "before_count", "after_count",
        "count_delta", "before_bytes", "after_bytes", "bytes_delta", "before_retained_bytes",
        "after_retained_bytes", "retained_delta", "excluded_id_count", "excluded_ids_sample",
      ]
    "investigate" =>
      [
        "record_type", "kind", "name", "id", "before_count", "growth_count", "cleanup_count",
        "remaining_count", "before_bytes", "growth_bytes", "cleanup_bytes", "remaining_bytes",
        "shallow_bytes", "retained_bytes", "distance", "excluded_id_count", "excluded_ids_sample",
      ]
    "cycles" => ["cycle_id", "count", "shallow_bytes", "sample"]
    _ =>
      [
        "index", "id", "kind", "name", "shallow_bytes", "retained_bytes", "distance",
        "dominator_id", "dominated_count",
      ]
  }
}

///|
pub fn Inspector::export_report(
  self : Inspector,
  handle : Int32,
  input : String,
) -> String raise HeapError {
  let req = request(input)
  let format = string_option(req, "format", "json")
  guard ["json", "csv", "html"].contains(format) else {
    raise Invalid("format must be json, csv or html")
  }
  let report = self.report(handle, input)
  let writer = { builder: StringBuilder(), size: 0, }
  if format == "json" {
    writer.write(report.stringify())
    return writer.builder.to_string()
  }
  let view = string_option(req, "view", "nodes")
  let columns = report_columns(view)
  let data = field(report, "data")
  let records : Array[Json] = []
  if int_option(data, "excluded_id_count", 0) > 0 {
    records.push({
      "record_type": "identity_exclusions",
      "name": "Ambiguous IDs excluded from identity counts and candidates; aggregate counts unchanged",
      "excluded_id_count": field(data, "excluded_id_count"),
      "excluded_ids_sample": field(data, "excluded_ids_sample"),
    })
  }
  for section in ["items", "groups", "changes"] {
    match optional(data, section) {
      Some(v) =>
        for row in array(v, section) {
          match row {
            Object(m) => {
              let copy = m.copy()
              copy["record_type"] = @json.to_json(section)
              records.push(@json.to_json(copy))
            }
            _ => ()
          }
        }
      None => ()
    }
  }
  if format == "csv" {
    guard !records.is_empty() ||
      optional(data, "items") is Some(_) ||
      optional(data, "groups") is Some(_) else {
      raise Invalid("CSV requires a tabular view")
    }
    writer.write(columns.join(",") + "\r\n")
    for row in records {
      let cells = columns.map(key => {
        csv_cell(optional(row, key).unwrap_or(Json::null()), key)
      })
      writer.write(cells.join(",") + "\r\n")
    }
  } else {
    writer.write(
      "MoonHeap report

MoonHeap · 完整调查报告

视图:" + html_escape(view) + " · 已包含全部匹配行。增长或存活本身不证明泄漏。

概览与查询条件

" +
      html_escape(field(report, "summary").stringify()) +
      "\n" +
      html_escape(req.stringify()) +
      "
", ) if records.is_empty() { writer.write( "

结果

" + html_escape(data.stringify()) + "
", ) } else { writer.write("") for key in columns { writer.write("") } writer.write("") for row in records { writer.write("") for key in columns { writer.write( "", ) } writer.write("") } writer.write("
" + html_escape(key) + "
" + html_escape( display_json(optional(row, key).unwrap_or(Json::null())), ) + "
") } writer.write( "

引用策略:snapshot-strong-v1。WeakMap 条件边保守计算;保留体积不等于进程 RSS。

", ) } writer.builder.to_string() }