///|
/// Authoritative post-posting state for refunds and reversals.
pub(all) enum ReturnLedgerState {
  LedgerNotPosted
  LedgerPaid
  LedgerPartiallyReturned
  LedgerFullyReturned
  LedgerPaymentReversed
  LedgerEvidenceIncomplete
  LedgerConflict
}

///|
pub fn ReturnLedgerState::name(self : ReturnLedgerState) -> String {
  match self {
    LedgerNotPosted => "NOT_POSTED"
    LedgerPaid => "PAID"
    LedgerPartiallyReturned => "PARTIALLY_RETURNED"
    LedgerFullyReturned => "FULLY_RETURNED"
    LedgerPaymentReversed => "PAYMENT_REVERSED"
    LedgerEvidenceIncomplete => "EVIDENCE_INCOMPLETE"
    LedgerConflict => "CONFLICT"
  }
}

///|
pub(all) enum LedgerLinkQuality {
  LedgerExplicitLink
  LedgerInferredLink
  LedgerAmbiguousLink
  LedgerMissingLink
}

///|
pub fn LedgerLinkQuality::name(self : LedgerLinkQuality) -> String {
  match self {
    LedgerExplicitLink => "EXPLICIT"
    LedgerInferredLink => "INFERRED_UNIQUE"
    LedgerAmbiguousLink => "AMBIGUOUS"
    LedgerMissingLink => "MISSING"
  }
}

///|
pub(all) enum LedgerLegKind {
  OriginalPayment
  RefundCredit
  ReversalOfPayment
  ReversalOfRefund
  UnlinkedReversal
}

///|
pub fn LedgerLegKind::name(self : LedgerLegKind) -> String {
  match self {
    OriginalPayment => "ORIGINAL_PAYMENT"
    RefundCredit => "REFUND_CREDIT"
    ReversalOfPayment => "REVERSAL_OF_PAYMENT"
    ReversalOfRefund => "REVERSAL_OF_REFUND"
    UnlinkedReversal => "UNLINKED_REVERSAL"
  }
}

///|
pub struct LedgerIssue {
  code : String
  severity : String
  message : String
}

///|
pub struct ReturnLedgerLeg {
  statement_id : String?
  booking_date : String?
  entry_reference : String?
  transaction_id : String?
  uetr : String?
  original_entry_reference : String?
  original_transaction_id : String?
  amount : Decimal
  currency : String
  credit_debit : String
  reversal : Bool
  kind : LedgerLegKind
  linked_to : String?
  link_quality : LedgerLinkQuality
}

///|
pub struct ReturnLedgerCase {
  end_to_end_id : String
  declared_amount : Decimal
  currency : String
  account_role : String
  state : ReturnLedgerState
  observed_payment : Decimal
  observed_refund : Decimal
  posted_payment : Decimal?
  effective_refund : Decimal?
  remaining : Decimal?
  crossed_period : Bool
  legs : Array[ReturnLedgerLeg]
  issues : Array[LedgerIssue]
  action : String
}

///|
pub struct ReturnLedgerReport {
  statement_count : Int
  cases : Array[ReturnLedgerCase]
  orphan_entries : Array[ReturnLedgerLeg]
  diagnostics : Array[Diagnostic]
}

///|
fn ledger_option_text(value : String?) -> String {
  match value {
    Some(text) => text
    None => ""
  }
}

///|
fn ledger_payment_role(record : StatementRecord, payment : PaymentRecord) -> String {
  match record.account_iban {
    Some(account) => {
      match payment.debtor_iban {
        Some(debtor) =>
          if account == debtor {
            return "DEBTOR"
          }
        None => ()
      }
      match payment.creditor_iban {
        Some(creditor) =>
          if account == creditor {
            return "CREDITOR"
          }
        None => ()
      }
    }
    None => ()
  }
  "UNKNOWN"
}

///|
fn ledger_base_direction(role : String) -> String {
  if role == "CREDITOR" {
    "CRDT"
  } else {
    "DBIT"
  }
}

///|
fn ledger_leg_label(record : StatementRecord, index : Int) -> String {
  match record.entry_reference {
    Some(value) => value
    None =>
      match record.transaction_id {
        Some(value) => value
        None => "ENTRY-" + index.to_string()
      }
  }
}

///|
fn ledger_date_key(record : StatementRecord) -> String {
  match record.booking_date {
    Some(value) => value
    None => ""
  }
}

///|
fn ledger_statement_key(record : StatementRecord) -> String {
  match record.statement_id {
    Some(value) => value
    None => ""
  }
}

///|
fn compare_ledger_records(left : StatementRecord, right : StatementRecord) -> Int {
  let date_order = ledger_date_key(left).compare(ledger_date_key(right))
  if date_order != 0 {
    return date_order
  }
  let statement_order = ledger_statement_key(left).compare(ledger_statement_key(right))
  if statement_order != 0 {
    return statement_order
  }
  ledger_leg_label(left, 0).compare(ledger_leg_label(right, 0))
}

///|
fn sort_ledger_records(records : Array[StatementRecord]) -> Array[StatementRecord] {
  let sorted : Array[StatementRecord] = []
  for record in records {
    let mut position = sorted.length()
    for i = 0; i < sorted.length(); i = i + 1 {
      if compare_ledger_records(record, sorted[i]) < 0 {
        position = i
        break
      }
    }
    sorted.insert(position, record)
  }
  sorted
}

///|
fn push_ledger_issue(
  issues : Array[LedgerIssue],
  code : String,
  severity : String,
  message : String,
) -> Unit {
  issues.push({ code, severity, message, })
}

///|
fn ledger_action(state : ReturnLedgerState) -> String {
  match state {
    LedgerNotPosted => "Load the statement containing the original payment posting before deciding the refund state."
    LedgerPaid => "Archive the posting and retain the statement reference."
    LedgerPartiallyReturned => "Track the remaining amount and require later refund or reversal evidence."
    LedgerFullyReturned => "Do not treat the payment as completed; retain the linked refund chain as evidence."
    LedgerPaymentReversed => "Mark the original payment as reversed and close it only after checking the reversal reference."
    LedgerEvidenceIncomplete => "Load prior and later statement periods; do not infer full or partial return from net amount alone."
    LedgerConflict => "Freeze automated settlement and investigate the conflicting refund/reversal chain."
  }
}

///|
fn ledger_target_candidates(
  records : Array[StatementRecord],
  reversal : StatementRecord,
  current_index : Int,
  already_used : Array[Bool],
  explicit_only : Bool,
) -> Array[Int] {
  let candidates : Array[Int] = []
  for i = 0; i < current_index; i = i + 1 {
    let candidate = records[i]
    if already_used[i] || candidate.reversal {
      continue
    }
    if candidate.currency != reversal.currency {
      continue
    }
    if candidate.amount.compare(reversal.amount) != 0 {
      continue
    }
    if candidate.credit_debit == reversal.credit_debit {
      continue
    }
    let entry_match = match reversal.original_entry_reference {
      Some(reference) =>
        match candidate.entry_reference {
          Some(value) => reference == value
          None => false
        }
      None => false
    }
    let transaction_match = match reversal.original_transaction_id {
      Some(reference) =>
        match candidate.transaction_id {
          Some(value) => reference == value
          None => false
        }
      None => false
    }
    let uetr_match = match reversal.uetr {
      Some(reference) =>
        match candidate.uetr {
          Some(value) => reference == value
          None => false
        }
      None => false
    }
    let explicit_match = entry_match || transaction_match || uetr_match
    if explicit_only {
      if explicit_match {
        candidates.push(i)
      }
    } else if !explicit_match {
      candidates.push(i)
    }
  }
  candidates
}

///|
fn has_ledger_explicit_reference(record : StatementRecord) -> Bool {
  record.original_entry_reference is Some(_) ||
  record.original_transaction_id is Some(_) ||
  record.uetr is Some(_)
}

///|
fn ledger_leg_from_record(
  record : StatementRecord,
  kind : LedgerLegKind,
  linked_to : String?,
  quality : LedgerLinkQuality,
) -> ReturnLedgerLeg {
  {
    statement_id: record.statement_id,
    booking_date: record.booking_date,
    entry_reference: record.entry_reference,
    transaction_id: record.transaction_id,
    uetr: record.uetr,
    original_entry_reference: record.original_entry_reference,
    original_transaction_id: record.original_transaction_id,
    amount: record.amount,
    currency: record.currency,
    credit_debit: record.credit_debit,
    reversal: record.reversal,
    kind,
    linked_to,
    link_quality: quality,
  }
}

///|
fn ledger_optional_decimal(value : Decimal?) -> Json {
  match value {
    Some(number) => Json::string(number.to_string())
    None => Json::null()
  }
}

///|
fn build_return_ledger_case(
  payment : PaymentRecord,
  records : Array[StatementRecord],
  role : String,
) -> ReturnLedgerCase {
  let sorted = sort_ledger_records(records)
  let issues : Array[LedgerIssue] = []
  let base_direction = ledger_base_direction(role)
  let already_used : Array[Bool] = []
  let target_indexes : Array[Int?] = []
  let link_qualities : Array[LedgerLinkQuality] = []
  let kinds : Array[LedgerLegKind] = []
  for i = 0; i < sorted.length(); i = i + 1 {
    already_used.push(false)
    target_indexes.push(None)
    link_qualities.push(LedgerMissingLink)
    kinds.push(UnlinkedReversal)
  }
  for i = 0; i < sorted.length(); i = i + 1 {
    let record = sorted[i]
    if !record.reversal {
      if record.credit_debit == base_direction {
        kinds[i] = OriginalPayment
      } else {
        kinds[i] = RefundCredit
      }
    } else {
      let explicit = has_ledger_explicit_reference(record)
      let candidates = ledger_target_candidates(
        sorted, record, i, already_used, explicit,
      )
      if candidates.length() == 1 {
        let target = candidates[0]
        target_indexes[i] = Some(target)
        already_used[target] = true
        link_qualities[i] = if explicit { LedgerExplicitLink } else { LedgerInferredLink }
        let target_kind = kinds[target]
        if target_kind is OriginalPayment {
          kinds[i] = ReversalOfPayment
          push_ledger_issue(
            issues,
            "PAYMENT_REVERSAL_APPLIED",
            "INFO",
            "Reversal is linked to original payment entry " +
            ledger_leg_label(sorted[target], target) +
            ".",
          )
        } else if target_kind is RefundCredit {
          kinds[i] = ReversalOfRefund
          push_ledger_issue(
            issues,
            "REFUND_REVERSAL_APPLIED",
            "INFO",
            "Reversal is linked to refund entry " +
            ledger_leg_label(sorted[target], target) +
            ".",
          )
        }
      } else if candidates.length() > 1 {
        link_qualities[i] = LedgerAmbiguousLink
        kinds[i] = UnlinkedReversal
        push_ledger_issue(
          issues,
          "REVERSAL_TARGET_AMBIGUOUS",
          "ERROR",
          "Reversal has multiple matching prior entries; explicit original reference is required.",
        )
      } else {
        kinds[i] = UnlinkedReversal
        if explicit {
          push_ledger_issue(
            issues,
            "REVERSAL_REFERENCE_NOT_FOUND",
            "ERROR",
            "Reversal names an original entry but no matching prior entry exists in the provided history.",
          )
        } else {
          push_ledger_issue(
            issues,
            "REVERSAL_TARGET_NOT_FOUND",
            "ERROR",
            "Reversal cannot be linked to a prior payment or refund entry.",
          )
        }
      }
    }
  }
  let mut observed_payment = Decimal::zero()
  let mut observed_refund = Decimal::zero()
  let mut reversed_payment = Decimal::zero()
  let mut reversed_refund = Decimal::zero()
  for i = 0; i < sorted.length(); i = i + 1 {
    let kind = kinds[i]
    if kind is OriginalPayment {
      observed_payment = observed_payment.add(sorted[i].amount)
    } else if kind is RefundCredit {
      observed_refund = observed_refund.add(sorted[i].amount)
    } else if kind is ReversalOfPayment {
      reversed_payment = reversed_payment.add(sorted[i].amount)
    } else if kind is ReversalOfRefund {
      reversed_refund = reversed_refund.add(sorted[i].amount)
    }
  }
  if role == "UNKNOWN" {
    push_ledger_issue(
      issues,
      "ACCOUNT_UNMATCHED",
      "ERROR",
      "Statement account does not match debtor or creditor account in the payment record.",
    )
  }
  let history_complete = observed_payment.compare(payment.amount) == 0
  if observed_payment.compare(Decimal::zero()) == 0 {
    push_ledger_issue(
      issues,
      "RETURN_HISTORY_INCOMPLETE",
      "ERROR",
      "Original payment posting is outside the provided statement history.",
    )
  } else if observed_payment.compare(payment.amount) < 0 {
    push_ledger_issue(
      issues,
      "PAYMENT_POSTING_INCOMPLETE",
      "ERROR",
      "Observed payment postings are less than the declared payment amount.",
    )
  } else if observed_payment.compare(payment.amount) > 0 {
    push_ledger_issue(
      issues,
      "PAYMENT_AMOUNT_MISMATCH",
      "ERROR",
      "Observed payment postings exceed the declared payment amount.",
    )
  }
  let posted_payment = if history_complete {
    Some(observed_payment.subtract(reversed_payment))
  } else {
    None
  }
  let effective_refund = if history_complete {
    Some(observed_refund.subtract(reversed_refund))
  } else {
    None
  }
  let remaining = match (posted_payment, effective_refund) {
    (Some(paid), Some(refunded)) => Some(paid.subtract(refunded))
    _ => None
  }
  let mut conflict = false
  for issue in issues {
    if issue.severity == "ERROR" &&
      issue.code != "RETURN_HISTORY_INCOMPLETE" &&
      issue.code != "PAYMENT_POSTING_INCOMPLETE" {
      conflict = true
    }
  }
  let state = if sorted.length() == 0 {
    LedgerNotPosted
  } else if !history_complete {
    if conflict {
      LedgerConflict
    } else {
      LedgerEvidenceIncomplete
    }
  } else {
    match (posted_payment, effective_refund, remaining) {
      (Some(paid), Some(refunded), Some(left)) =>
        if left.compare(Decimal::zero()) < 0 {
          push_ledger_issue(
            issues,
            "REFUND_OVERPAYMENT",
            "ERROR",
            "Effective refunds exceed the effective payment amount.",
          )
          LedgerConflict
        } else if conflict {
          LedgerConflict
        } else if paid.compare(Decimal::zero()) == 0 &&
          refunded.compare(Decimal::zero()) == 0 {
          LedgerPaymentReversed
        } else if left.compare(Decimal::zero()) == 0 && refunded.compare(Decimal::zero()) > 0 {
          LedgerFullyReturned
        } else if refunded.compare(Decimal::zero()) > 0 {
          LedgerPartiallyReturned
        } else {
          LedgerPaid
        }
      _ => LedgerEvidenceIncomplete
    }
  }
  let statement_ids : Map[String, Bool] = Map([])
  let booking_dates : Map[String, Bool] = Map([])
  for record in sorted {
    match record.statement_id {
      Some(value) => statement_ids.set(value, true)
      None => ()
    }
    match record.booking_date {
      Some(value) => booking_dates.set(value, true)
      None => ()
    }
  }
  let crossed_period = statement_ids.length() > 1 || booking_dates.length() > 1
  let legs : Array[ReturnLedgerLeg] = []
  for i = 0; i < sorted.length(); i = i + 1 {
    let linked_to = match target_indexes[i] {
      Some(target) => Some(ledger_leg_label(sorted[target], target))
      None => None
    }
    legs.push(ledger_leg_from_record(sorted[i], kinds[i], linked_to, link_qualities[i]))
  }
  {
    end_to_end_id: payment.end_to_end_id,
    declared_amount: payment.amount,
    currency: payment.currency,
    account_role: role,
    state,
    observed_payment,
    observed_refund,
    posted_payment,
    effective_refund,
    remaining,
    crossed_period,
    legs,
    issues,
    action: ledger_action(state),
  }
}

///|
/// Build a cross-statement return/reversal ledger for every payment.
pub fn return_ledger_report(
  payment_xml : String,
  statement_xmls : Array[String],
) -> ReturnLedgerReport raise XmlParseError {
  let payments = payment_records_auto(payment_xml)
  let all_records : Array[StatementRecord] = []
  for statement_xml in statement_xmls {
    for record in statement_records(statement_xml) {
      all_records.push(record)
    }
  }
  let payment_ids : Map[String, Bool] = Map([])
  for payment in payments {
    payment_ids.set(payment.end_to_end_id, true)
  }
  let cases : Array[ReturnLedgerCase] = []
  for payment in payments {
    let payment_records : Array[StatementRecord] = []
    let mut has_debtor = false
    let mut has_creditor = false
    for record in all_records {
      if record.end_to_end_id == payment.end_to_end_id {
        payment_records.push(record)
        let role = ledger_payment_role(record, payment)
        if role == "DEBTOR" {
          has_debtor = true
        } else if role == "CREDITOR" {
          has_creditor = true
        }
      }
    }
    let role = if has_debtor {
      "DEBTOR"
    } else if has_creditor {
      "CREDITOR"
    } else {
      "UNKNOWN"
    }
    let selected : Array[StatementRecord] = []
    for record in payment_records {
      if ledger_payment_role(record, payment) == role {
        selected.push(record)
      }
    }
    cases.push(build_return_ledger_case(payment, selected, role))
  }
  let orphan_entries : Array[ReturnLedgerLeg] = []
  for record in all_records {
    if !payment_ids.contains(record.end_to_end_id) {
      orphan_entries.push(
        ledger_leg_from_record(
          record, UnlinkedReversal, None, LedgerMissingLink,
        ),
      )
    }
  }
  { statement_count: statement_xmls.length(), cases, orphan_entries, diagnostics: [], }
}

///|
pub fn ReturnLedgerReport::is_valid(self : ReturnLedgerReport) -> Bool {
  if self.orphan_entries.length() > 0 {
    return false
  }
  for case in self.cases {
    if case.state is LedgerConflict || case.state is LedgerEvidenceIncomplete {
      return false
    }
  }
  true
}

///|
fn ledger_issue_json(issue : LedgerIssue) -> Json {
  { "code": issue.code, "severity": issue.severity, "message": issue.message, }
}

///|
fn ledger_leg_json(leg : ReturnLedgerLeg) -> Json {
  let statement_id : Json = match leg.statement_id {
    Some(value) => Json::string(value)
    None => Json::null()
  }
  let booking_date : Json = match leg.booking_date {
    Some(value) => Json::string(value)
    None => Json::null()
  }
  let entry_reference : Json = match leg.entry_reference {
    Some(value) => Json::string(value)
    None => Json::null()
  }
  let transaction_id : Json = match leg.transaction_id {
    Some(value) => Json::string(value)
    None => Json::null()
  }
  let uetr : Json = match leg.uetr {
    Some(value) => Json::string(value)
    None => Json::null()
  }
  let original_entry_reference : Json = match leg.original_entry_reference {
    Some(value) => Json::string(value)
    None => Json::null()
  }
  let original_transaction_id : Json = match leg.original_transaction_id {
    Some(value) => Json::string(value)
    None => Json::null()
  }
  let linked_to : Json = match leg.linked_to {
    Some(value) => Json::string(value)
    None => Json::null()
  }
  {
    "statementId": statement_id,
    "bookingDate": booking_date,
    "entryReference": entry_reference,
    "transactionId": transaction_id,
    "uetr": uetr,
    "originalEntryReference": original_entry_reference,
    "originalTransactionId": original_transaction_id,
    "amount": leg.amount.to_string(),
    "currency": leg.currency,
    "creditDebit": leg.credit_debit,
    "reversal": leg.reversal,
    "kind": leg.kind.name(),
    "linkedTo": linked_to,
    "linkQuality": leg.link_quality.name(),
  }
}

///|
pub fn ReturnLedgerReport::to_text(self : ReturnLedgerReport) -> String {
  let output = StringBuilder()
  output.write_string("Return and reversal ledger\n")
  output.write_string("Statements: ")
  output.write_string(self.statement_count.to_string())
  for case in self.cases {
    output.write_string("\n\n")
    output.write_string(case.end_to_end_id)
    output.write_string(" | ")
    output.write_string(case.declared_amount.to_string())
    output.write_string(" ")
    output.write_string(case.currency)
    output.write_string(" | account=")
    output.write_string(case.account_role)
    output.write_string(" | state=")
    output.write_string(case.state.name())
    output.write_string(" | crossPeriod=")
    output.write_string(if case.crossed_period { "true" } else { "false" })
    output.write_string("\nPosted payment: ")
    output.write_string(match case.posted_payment {
      Some(value) => value.to_string()
      None => "(not verifiable)"
    })
    output.write_string("\nEffective refund: ")
    output.write_string(match case.effective_refund {
      Some(value) => value.to_string()
      None => "(not verifiable)"
    })
    output.write_string("\nRemaining: ")
    output.write_string(match case.remaining {
      Some(value) => value.to_string()
      None => "(not verifiable)"
    })
    output.write_string("\nAction: ")
    output.write_string(case.action)
    for leg in case.legs {
      output.write_string("\n- ")
      output.write_string(ledger_option_text(leg.booking_date))
      output.write_string(" ")
      output.write_string(ledger_option_text(leg.entry_reference))
      output.write_string(" ")
      output.write_string(leg.credit_debit)
      output.write_string(" ")
      output.write_string(leg.amount.to_string())
      output.write_string(" ")
      output.write_string(leg.currency)
      output.write_string(" | kind=")
      output.write_string(leg.kind.name())
      output.write_string(" | link=")
      output.write_string(leg.link_quality.name())
      match leg.linked_to {
        Some(value) => {
          output.write_string(" -> ")
          output.write_string(value)
        }
        None => ()
      }
    }
    for issue in case.issues {
      output.write_string("\n  [")
      output.write_string(issue.severity)
      output.write_string("] ")
      output.write_string(issue.code)
      output.write_string(": ")
      output.write_string(issue.message)
    }
  }
  if self.orphan_entries.length() > 0 {
    output.write_string("\n\nStatement entries without payment:")
    for leg in self.orphan_entries {
      output.write_string("\n- ")
      output.write_string(ledger_option_text(leg.entry_reference))
      output.write_string(" ")
      output.write_string(leg.amount.to_string())
      output.write_string(" ")
      output.write_string(leg.currency)
    }
  }
  output.to_string()
}

///|
pub fn ReturnLedgerReport::to_json(self : ReturnLedgerReport) -> String {
  let cases : Array[Json] = []
  for case in self.cases {
    let legs : Array[Json] = []
    for leg in case.legs {
      legs.push(ledger_leg_json(leg))
    }
    let issues : Array[Json] = []
    for issue in case.issues {
      issues.push(ledger_issue_json(issue))
    }
    cases.push({
      "endToEndId": case.end_to_end_id,
      "declaredAmount": case.declared_amount.to_string(),
      "currency": case.currency,
      "accountRole": case.account_role,
      "state": case.state.name(),
      "observedPayment": case.observed_payment.to_string(),
      "observedRefund": case.observed_refund.to_string(),
      "postedPayment": ledger_optional_decimal(case.posted_payment),
      "effectiveRefund": ledger_optional_decimal(case.effective_refund),
      "remaining": ledger_optional_decimal(case.remaining),
      "crossedPeriod": case.crossed_period,
      "action": case.action,
      "legs": Json::array(legs),
      "issues": Json::array(issues),
    })
  }
  let orphans : Array[Json] = []
  for leg in self.orphan_entries {
    orphans.push(ledger_leg_json(leg))
  }
  let root = Map([])
  root.set("valid", Json::boolean(self.is_valid()))
  root.set("statementCount", Json::number(self.statement_count.to_double()))
  root.set("cases", Json::array(cases))
  root.set("orphanEntries", Json::array(orphans))
  Json::object(root).stringify(indent=2)
}

///|
pub fn ReturnLedgerReport::to_markdown(self : ReturnLedgerReport) -> String {
  let output = StringBuilder()
  output.write_string("# Return and reversal ledger\n\n")
  output.write_string("| EndToEndId | Declared | State | Posted | Refunded | Remaining | Cross period |\n")
  output.write_string("|---|---|---|---|---|---|---|\n")
  for case in self.cases {
    output.write_string("| `")
    output.write_string(case.end_to_end_id)
    output.write_string("` | ")
    output.write_string(case.declared_amount.to_string())
    output.write_string(" ")
    output.write_string(case.currency)
    output.write_string(" | `")
    output.write_string(case.state.name())
    output.write_string("` | ")
    output.write_string(match case.posted_payment {
      Some(value) => value.to_string()
      None => "(unknown)"
    })
    output.write_string(" | ")
    output.write_string(match case.effective_refund {
      Some(value) => value.to_string()
      None => "(unknown)"
    })
    output.write_string(" | ")
    output.write_string(match case.remaining {
      Some(value) => value.to_string()
      None => "(unknown)"
    })
    output.write_string(" | ")
    output.write_string(if case.crossed_period { "yes" } else { "no" })
    output.write_string(" |\n")
  }
  output.write_string("\n## Linked legs\n\n")
  for case in self.cases {
    output.write_string("### `")
    output.write_string(case.end_to_end_id)
    output.write_string("`\n\n")
    output.write_string(case.action)
    output.write_string("\n\n")
    output.write_string("| Date | Entry | Amount | Kind | Linked to | Link quality |\n")
    output.write_string("|---|---|---|---|---|---|\n")
    for leg in case.legs {
      output.write_string("| ")
      output.write_string(ledger_option_text(leg.booking_date))
      output.write_string(" | `")
      output.write_string(ledger_option_text(leg.entry_reference))
      output.write_string("` | ")
      output.write_string(leg.amount.to_string())
      output.write_string(" ")
      output.write_string(leg.currency)
      output.write_string(" | `")
      output.write_string(leg.kind.name())
      output.write_string("` | `")
      output.write_string(ledger_option_text(leg.linked_to))
      output.write_string("` | `")
      output.write_string(leg.link_quality.name())
      output.write_string("` |\n")
    }
    for issue in case.issues {
      output.write_string("\n- `")
      output.write_string(issue.code)
      output.write_string("`: ")
      output.write_string(issue.message)
    }
    output.write_string("\n")
  }
  output.write_string("\n## Orphan entries\n\n")
  if self.orphan_entries.length() == 0 {
    output.write_string("None.\n")
  } else {
    for leg in self.orphan_entries {
      output.write_string("- `")
      output.write_string(ledger_option_text(leg.entry_reference))
      output.write_string("`: ")
      output.write_string(leg.amount.to_string())
      output.write_string(" ")
      output.write_string(leg.currency)
      output.write_string("\n")
    }
  }
  output.to_string()
}