///|
pub(all) struct TextDifference {
  start : Int
  end : Int
  original : String
  replacement : String
} derive(Debug, Eq)

///|
pub(all) struct RedactionContract {
  valid_findings : Bool
  monotonic_offsets : Bool
  newline_preserved : Bool
  output_changed : Bool
  audit_consistent : Bool
  passes : Bool
} derive(Debug, Eq)

///|
pub fn result_contract(
  input : String,
  result : DeidResult,
) -> RedactionContract {
  let valid_findings = validate_findings(input, result.findings).is_empty()
  let monotonic_offsets = offsets_are_monotonic(result.offsets)
  let newline_preserved = redaction_preserves_newlines(input, result)
  let output_changed = input != result.text || result.findings.is_empty()
  let audit_consistent = result.audit.finding_count == result.findings.length() &&
    result.audit.applied_count == result.offsets.length()
  {
    valid_findings,
    monotonic_offsets,
    newline_preserved,
    output_changed,
    audit_consistent,
    passes: valid_findings &&
    monotonic_offsets &&
    newline_preserved &&
    audit_consistent,
  }
}

///|
pub fn contract_to_text(contract : RedactionContract) -> String {
  [
    "valid_findings=\{contract.valid_findings}",
    "monotonic_offsets=\{contract.monotonic_offsets}",
    "newline_preserved=\{contract.newline_preserved}",
    "output_changed=\{contract.output_changed}",
    "audit_consistent=\{contract.audit_consistent}",
    "passes=\{contract.passes}",
  ].join("\n")
}

///|
pub fn contract_to_json(contract : RedactionContract) -> String {
  "{" +
  "\"valid_findings\":\{contract.valid_findings}," +
  "\"monotonic_offsets\":\{contract.monotonic_offsets}," +
  "\"newline_preserved\":\{contract.newline_preserved}," +
  "\"output_changed\":\{contract.output_changed}," +
  "\"audit_consistent\":\{contract.audit_consistent}," +
  "\"passes\":\{contract.passes}" +
  "}"
}

///|
pub fn result_differences(
  input : String,
  result : DeidResult,
) -> Array[TextDifference] {
  result.offsets.map(fn(offset) {
    {
      start: offset.original_start,
      end: offset.original_end,
      original: input[offset.original_start:offset.original_end].to_owned(),
      replacement: result.text[offset.replacement_start:offset.replacement_end].to_owned(),
    }
  })
}

///|
pub fn differences_to_markdown(differences : Array[TextDifference]) -> String {
  let lines = ["| Span | Original | Replacement |", "| ---: | --- | --- |"]
  for difference in differences {
    lines.push(
      "| \{difference.start}..\{difference.end} | \{quote_for_log(difference.original)} | \{quote_for_log(difference.replacement)} |",
    )
  }
  lines.join("\n")
}

///|
pub fn differences_to_json(differences : Array[TextDifference]) -> String {
  "[" +
  differences
  .map(fn(item) {
    "{" +
    "\"start\":\{item.start}," +
    "\"end\":\{item.end}," +
    "\"original\":\{json_string(item.original)}," +
    "\"replacement\":\{json_string(item.replacement)}" +
    "}"
  })
  .join(",") +
  "]"
}

///|
pub fn result_change_count(result : DeidResult) -> Int {
  result.offsets.length()
}

///|
pub fn result_original_coverage(result : DeidResult) -> Int {
  audit_offset_coverage(result.audit)
}

///|
pub fn result_output_coverage(result : DeidResult) -> Int {
  result.offsets.fold(init=0, (sum, item) => {
    sum + item.replacement_end - item.replacement_start
  })
}

///|
pub fn result_delta(result : DeidResult) -> Int {
  result_output_coverage(result) - result_original_coverage(result)
}

///|
pub fn result_replacement_kinds(result : DeidResult) -> Array[String] {
  let result_names = []
  for item in result.findings {
    let name = phi_kind_name(item.kind)
    if !result_names.contains(name) {
      result_names.push(name)
    }
  }
  result_names
}

///|
pub fn result_contract_report(input : String, result : DeidResult) -> String {
  let contract = result_contract(input, result)
  let kinds = result_replacement_kinds(result).join(",")
  [
    contract_to_text(contract),
    "changes=\{result_change_count(result)}",
    "original_coverage=\{result_original_coverage(result)}",
    "output_coverage=\{result_output_coverage(result)}",
    "delta=\{result_delta(result)}",
    "kinds=\{kinds}",
  ].join("\n")
}

///|
pub fn result_matches_text(result : DeidResult, expected : String) -> Bool {
  result.text == expected
}

///|
pub fn result_has_replacement(
  result : DeidResult,
  replacement : String,
) -> Bool {
  result.findings.any(fn(item) { item.replacement == replacement })
}

///|
pub fn result_has_kind(result : DeidResult, kind : PhiKind) -> Bool {
  result.findings.any(fn(item) { item.kind == kind })
}

///|
pub fn result_findings_between(
  result : DeidResult,
  start : Int,
  end : Int,
) -> Array[Finding] {
  findings_overlapping_span(result.findings, { start, end })
}

///|
pub fn result_text_window(
  result : DeidResult,
  center : Int,
  radius : Int,
) -> TextWindow {
  text_window(result.text, center, radius)
}

///|
pub fn result_mapping_description(result : DeidResult) -> String {
  mapping_from_result(result).describe()
}

///|
pub fn result_has_stable_mapping(result : DeidResult) -> Bool {
  mapping_from_result(result).is_monotonic()
}

///|
pub fn result_replay_checksum(result : DeidResult) -> String {
  stable_hash(result.text + audit_to_json(result.audit))
}

///|
pub fn empty_result(input : String) -> DeidResult {
  {
    text: input,
    findings: [],
    offsets: [],
    audit: {
      input_length: input.length(),
      output_length: input.length(),
      finding_count: 0,
      applied_count: 0,
      counts: Map([]),
      findings: [],
      offsets: [],
    },
  }
}