///|
/// Times are bounded below 2^53, so JSON numbers preserve every microsecond.
fn time_json(time : Int64?) -> Json {
  match time {
    Some(value) => Json::number(value.to_double())
    None => Json::null()
  }
}

///|
fn case_json(case_ : TestCase?, omit_source~ : Bool = false) -> Json {
  match case_ {
    None => Json::null()
    Some(c) =>
      {
        "outcome": Json(c.outcome.label()),
        "attempts": Json(c.attempts),
        "flaky": Json(c.flaky),
        "time_us": time_json(c.time_us),
        "message": Json(c.message),
        "source": Json(if omit_source { "" } else { c.source }),
      }
  }
}

///|
fn source_extra(case_ : TestCase?) -> Int64 {
  case_.map(c => c.source_units - 2L).unwrap_or(0L)
}

///|
fn diagnostic_json(d : Diagnostic) -> Json {
  { "source": Json(d.source), "code": Json(d.code), "message": Json(d.message) }
}

///|
fn report_case_json(c : TestCase, measure~ : Bool = false) -> Json {
  {
    "id": if measure {
      Json::null()
    } else {
      c.id.to_json()
    },
    "name": Json(c.name),
    "classname": match c.classname {
      Some(s) => Json(s)
      None => Json::null()
    },
    "is_passed": Json(c.is_passed()),
    "is_failure": Json(c.is_failure()),
    "is_error": Json(c.is_error()),
    "is_skipped": Json(c.is_skipped()),
    "is_flaky": Json(c.is_flaky()),
    "is_rerun": Json(c.is_rerun()),
    "final_results": Json(c.final_results),
    "interim_results": Json(c.interim_results),
    "result": case_json(Some(c), omit_source=measure),
  }
}

///|
fn diff_entry_json(e : DiffEntry, measure~ : Bool = false) -> Json {
  {
    "id": if measure {
      Json::null()
    } else {
      e.id.to_json()
    },
    "category": Json(e.category),
    "before": case_json(e.before, omit_source=measure),
    "after": case_json(e.after, omit_source=measure),
    "baseline_time_us": time_json(e.baseline_time_us),
    "slowdown": Json(e.slowdown),
    "known_flaky": Json(e.known_flaky),
  }
}

///|
pub fn Report::to_json(
  self : Report,
  max_units~ : Int64 = 100000000L,
) -> Json raise InputError {
  let budget = output_budget(max_units)
  budget.add(json_units({ "cases": [], "diagnostics": [] }))
  for i = 0; i < self.cases.length(); i = i + 1 {
    let c = self.cases[i]
    let id_size = self.scopes[c.scope].id_units(Json(c.name).stringify(), 0)
    budget.add(
      json_units(report_case_json(c, measure=true)) +
      id_size -
      4L +
      source_extra(Some(c)) +
      (if i > 0 { 1L } else { 0L }),
    )
  }
  for i = 0; i < self.diagnostics.length(); i = i + 1 {
    budget.add(
      json_units(diagnostic_json(self.diagnostics[i])) +
      (if i > 0 { 1L } else { 0L }),
    )
  }
  {
    "cases": Json(self.cases.map(c => report_case_json(c))),
    "diagnostics": Json(self.diagnostics.map(d => diagnostic_json(d))),
  }
}

///|
pub fn DiffResult::to_json(
  self : DiffResult,
  max_units~ : Int64 = 100000000L,
) -> Json raise InputError {
  let counts : Map[String, Int32] = Map([])
  for item in self.entries {
    counts[item.category] = counts.get(item.category).unwrap_or(0) + 1
  }
  let header : Json = {
    "schema_version": 1,
    "baseline_count": Json(self.baseline_count),
    "candidate_count": Json(self.candidate_count),
    "history_count": Json(self.history_count),
    "counts": Json(counts),
    "gate": {
      "passed": Json(self.violations.is_empty()),
      "violations": Json(self.violations),
      "exit_code": Json(self.exit_code()),
    },
    "entries": [],
    "diagnostics": [],
  }
  let budget = output_budget(max_units)
  budget.add(json_units(header))
  for i = 0; i < self.entries.length(); i = i + 1 {
    let e = self.entries[i]
    budget.add(
      json_units(diff_entry_json(e, measure=true)) +
      self.scopes[e.scope].id_units(e.encoded_name, 0) -
      4L +
      source_extra(e.before) +
      source_extra(e.after) +
      (if i > 0 { 1L } else { 0L }),
    )
  }
  for i = 0; i < self.diagnostics.length(); i = i + 1 {
    budget.add(
      json_units(diagnostic_json(self.diagnostics[i])) +
      (if i > 0 { 1L } else { 0L }),
    )
  }
  guard header is Object(fields) else {
    raise InvalidInput("invalid output state")
  }
  fields["entries"] = Json(self.entries.map(e => diff_entry_json(e)))
  fields["diagnostics"] = Json(self.diagnostics.map(d => diagnostic_json(d)))
  header
}

///|
pub fn DiffResult::to_json_string(
  self : DiffResult,
  max_units~ : Int64 = 100000000L,
) -> String raise InputError {
  self.to_json(max_units~).stringify()
}

///|
/// Render untrusted fields as plain text: no HTML, links or Markdown control syntax.
fn safe_cell(text : String) -> String {
  let builder = StringBuilder()
  for ch in text {
    match ch {
      '&' => builder.write_string("&")
      '<' => builder.write_string("<")
      '>' => builder.write_string(">")
      '|' => builder.write_string("|")
      '`' => builder.write_string("`")
      '[' => builder.write_string("[")
      ']' => builder.write_string("]")
      '*' => builder.write_string("*")
      '_' => builder.write_string("_")
      '$' => builder.write_string("$")
      '~' => builder.write_string("~")
      '\\' => builder.write_string("\")
      '\n' | '\r' | '\t' => builder.write_string(" ")
      _ =>
        if ch.to_int() >= 32 && (ch.to_int() < 127 || ch.to_int() > 159) {
          builder.write_char(ch)
        }
    }
  }
  builder.to_string()
}

///|
fn entry_note(entry : DiffEntry) -> String {
  let notes : Array[String] = []
  if entry.after.map(c => c.flaky).unwrap_or(false) {
    notes.push("flaky attempts")
  }
  if entry.known_flaky {
    notes.push("historically inconsistent")
  }
  if entry.slowdown {
    notes.push("duration increase")
  }
  notes.join(", ")
}

///|
fn entry_has_detail(entry : DiffEntry) -> Bool {
  entry.category != "unchanged" ||
  entry.slowdown ||
  entry.known_flaky ||
  entry.after.map(c => c.flaky).unwrap_or(false)
}

///|
pub fn DiffResult::to_markdown(
  self : DiffResult,
  limit~ : Int32 = 50,
  max_units~ : Int64 = 100000000L,
) -> String raise InputError {
  if limit < 0 {
    raise InvalidInput("detail limit cannot be negative")
  }
  let budget = output_budget(max_units)
  let builder = StringBuilder()
  let gate = if self.violations.is_empty() { "PASS" } else { "FAIL" }
  budget.write(
    builder,
    "# JUnit Delta\n\nGate: **\{gate}**. Baseline: \{self.baseline_count}; candidate: \{self.candidate_count}; history runs: \{self.history_count}.\n\n",
  )
  if !self.violations.is_empty() {
    budget.write(builder, "Triggered: \{self.violations.join(", ")}.\n\n")
  }
  budget.write(
    builder, "| Change | Test ID | Notes | Message |\n| --- | --- | --- | --- |\n",
  )
  let mut shown = 0
  for entry in self.entries {
    if !entry_has_detail(entry) || shown >= limit {
      continue
    }
    shown = shown + 1
    let message = entry.after.map(c => c.message).unwrap_or("")
    let short = if message.length() > 240 {
      truncate_utf16(message, 240) + "…"
    } else {
      message
    }
    let prefix = "| \{entry.category} | "
    let suffix = " | \{safe_cell(entry_note(entry))} | \{safe_cell(short)} |\n"
    budget.add(
      prefix.length().to_int64() +
      self.scopes[entry.scope].id_units(entry.encoded_name, 1) +
      suffix.length().to_int64(),
    )
    builder.write_string(prefix + safe_cell(entry.id.key()) + suffix)
  }
  budget.write(
    builder,
    "\nShown \{shown} details (limit \{limit}); complete entries are available in JSON.\n",
  )
  if !self.diagnostics.is_empty() {
    budget.write(builder, "\nDiagnostics: \{self.diagnostics.length()}.\n")
    for i = 0; i < self.diagnostics.length() && i < 10; i = i + 1 {
      let d = self.diagnostics[i]
      budget.add(
        8L + safe_units(d.source) + safe_units(d.code) + safe_units(d.message),
      )
      builder.write_string(
        "- \{safe_cell(d.source)}: \{safe_cell(d.code)} — \{safe_cell(d.message)}\n",
      )
    }
  }
  builder.to_string()
}

///|
pub fn DiffResult::to_text(
  self : DiffResult,
  max_units~ : Int64 = 100000000L,
) -> String raise InputError {
  let budget = output_budget(max_units)
  let builder = StringBuilder()
  let gate = if self.violations.is_empty() { "PASS" } else { "FAIL" }
  budget.write(
    builder,
    "JUnit Delta: \{gate}\nBaseline \{self.baseline_count}, candidate \{self.candidate_count}, history \{self.history_count}\n",
  )
  for entry in self.entries {
    if entry_has_detail(entry) {
      let note = entry_note(entry)
      budget.add(
        self.scopes[entry.scope].id_units(entry.encoded_name, 2) +
        entry.category.length().to_int64() +
        note.length().to_int64() +
        3L,
      )
      builder.write_string(
        "\{entry.category}\t\{terminal_text(entry.id.key())}\t\{note}\n",
      )
    }
  }
  if !self.violations.is_empty() {
    budget.write(builder, "Triggered: \{self.violations.join(", ")}\n")
  }
  budget.write(builder, "Diagnostics: \{self.diagnostics.length()}\n")
  builder.to_string()
}

///|
fn OutputBudget::write(
  self : OutputBudget,
  builder : StringBuilder,
  text : String,
) -> Unit raise InputError {
  self.add(text.length().to_int64())
  builder.write_string(text)
}