///|
pub fn mode_name(mode : TorrentMode) -> String {
  match mode {
    V1 => "v1"
    V2 => "v2"
    Hybrid => "hybrid"
  }
}

///|
fn optional_text(value : String?) -> Json {
  match value {
    Some(s) => Json::string(s)
    None => Json::null()
  }
}

///|
fn j_object(pairs : Array[(String, Json)]) -> Json {
  Json::object(Map(pairs))
}

///|
/// Sizes are decimal strings so consumers never round 64-bit file offsets.
pub fn info_json(info : TorrentInfo) -> Json {
  let mut total = 0L
  for file in info.files {
    total += file.length
  }
  j_object([
    ("schema", Json::string("swarmcrate.info.v1")),
    ("name", Json::string(info.name)),
    ("mode", Json::string(mode_name(info.mode))),
    ("piece_length", Json::number(info.piece_length.to_double())),
    ("total_bytes", Json::string(total.to_string())),
    ("single_file", Json::boolean(info.single_file)),
    ("private", Json::boolean(info.private_torrent)),
    ("info_hash_v1", optional_text(info.info_hash_v1)),
    ("info_hash_v2", optional_text(info.info_hash_v2)),
    ("comment", Json::string(info.comment)),
    (
      "trackers",
      Json::array(info.trackers.map(fn(t) { Json::array(t.map(Json::string)) })),
    ),
    ("web_seeds", Json::array(info.web_seeds.map(Json::string))),
    (
      "files",
      Json::array(
        info.files.map(fn(f) {
          j_object([
            ("path", Json::array(f.path.map(Json::string))),
            ("length", Json::string(f.length.to_string())),
          ])
        }),
      ),
    ),
  ])
}

///|
pub fn verification_json(report : VerificationReport) -> Json {
  j_object([
    ("schema", Json::string("swarmcrate.verify.v2")),
    ("ok", Json::boolean(report.ok)),
    ("complete", Json::boolean(report.scope.complete)),
    (
      "selected_files",
      Json::array(
        report.scope.selected_files.map(fn(i) { Json::number(i.to_double()) }),
      ),
    ),
    (
      "required_files",
      Json::array(
        report.scope.required_files.map(fn(i) { Json::number(i.to_double()) }),
      ),
    ),
    ("checked_pieces", Json::number(report.checked_pieces.to_double())),
    ("skipped_pieces", Json::number(report.skipped_pieces.to_double())),
    ("failed_pieces", Json::number(report.failed_pieces.to_double())),
    ("unreadable_pieces", Json::number(report.unreadable_pieces.to_double())),
    ("details_truncated", Json::boolean(report.details_truncated)),
    (
      "file_issues",
      Json::array(
        report.file_issues.map(fn(f) {
          j_object([
            ("file", Json::number(f.file.to_double())),
            ("path", Json::string(f.path)),
            ("expected", Json::string(f.expected.to_string())),
            (
              "actual",
              match f.actual {
                Some(n) => Json::string(n.to_string())
                None => Json::null()
              },
            ),
            ("reason", Json::string(f.reason)),
          ])
        }),
      ),
    ),
    (
      "failures",
      Json::array(
        report.failures.map(fn(p) {
          j_object([
            ("scheme", Json::string(p.scheme)),
            ("piece", Json::number(p.piece.to_double())),
            ("reason", Json::string(p.reason)),
            (
              "spans",
              Json::array(
                p.spans.map(fn(s) {
                  j_object([
                    ("file", Json::number(s.file.to_double())),
                    ("offset", Json::string(s.offset.to_string())),
                    ("length", Json::number(s.length.to_double())),
                  ])
                }),
              ),
            ),
          ])
        }),
      ),
    ),
  ])
}

///|
fn html_escape(text : String) -> String {
  let mut result = text
  for
    (from, to) in [
      ("&", "&"),
      ("<", "<"),
      (">", ">"),
      ("\"", """),
      ("'", "'"),
    ] {
    result = result.split(from).map(fn(p) { p.to_owned() }).to_array().join(to)
  }
  result
}

///|
/// A self-contained, script-free report that can be opened offline or attached to a release.
pub fn verification_html(
  info : TorrentInfo,
  report : VerificationReport,
) -> String {
  let rows : Array[String] = []
  for file in report.file_issues {
    let actual = match file.actual {
      Some(n) => n.to_string()
      None => "missing"
    }
    rows.push(
      "" +
      html_escape(file.path) +
      "" +
      html_escape(file.reason) +
      "" +
      file.expected.to_string() +
      "" +
      actual +
      "",
    )
  }
  let pieces : Array[String] = []
  for failure in report.failures {
    let locations = failure.spans.map(fn(s) {
      html_escape(info.files[s.file].path.join("/")) +
      " @ " +
      s.offset.to_string() +
      " + " +
      s.length.to_string()
    })
    pieces.push(
      "" +
      html_escape(failure.scheme) +
      "" +
      failure.piece.to_string() +
      "" +
      html_escape(failure.reason) +
      "" +
      locations.join("
") + "", ) } let status = if !report.ok { "FAIL" } else if report.scope.complete { "PASS" } else { "PARTIAL PASS" } let selected = report.scope.selected_files .map(fn(i) { html_escape(info.files[i].path.join("/")) }) .join("
") let required = report.scope.required_files .map(fn(i) { html_escape(info.files[i].path.join("/")) }) .join("
") "SwarmCrate verification" + "" + "

" + status + " · " + html_escape(info.name) + "

Mode: " + mode_name(info.mode) + " · Checked: " + report.checked_pieces.to_string() + " · Failed: " + report.failed_pieces.to_string() + " · Unreadable: " + report.unreadable_pieces.to_string() + " · Skipped: " + report.skipped_pieces.to_string() + "

" + "

Scope: " + (if report.scope.complete { "all files" } else { "selected files only; other files are not certified" }) + "

Selected files" + selected + "
Required files (includes shared v1 boundaries)" + required + "
" + "

SHA-1: " + html_escape(info.info_hash_v1.unwrap_or("—")) + "
SHA-256: " + html_escape(info.info_hash_v2.unwrap_or("—")) + "

" + "

File issues

" + rows.join("") + "
FileReasonExpected bytesActual bytes
" + file_diagnostics_html(info, report) + "

Piece failures

" + pieces.join("") + "
SchemeIndex (zero-based)ReasonFile @ offset + length
" + "SwarmCrate 0.14.0 · Hash checks show consistency with this metainfo, not publisher authenticity. Detail list truncated: " + report.details_truncated.to_string() + "." }