///|
pub struct StatementRecord {
  end_to_end_id : String
  amount : Decimal
  currency : String
  account_iban : String?
  credit_debit : String
  reversal : Bool
}

///|
pub(all) enum LifecycleState {
  Settled
  SettledNotPosted
  Rejected
  Pending
  MissingStatus
  StatementMismatch
  StatementBeforeFinalStatus
  PartiallyRefunded
  FullyRefunded
  AccountUnmatched
}

///|
pub fn LifecycleState::name(self : LifecycleState) -> String {
  match self {
    Settled => "SETTLED"
    SettledNotPosted => "SETTLED_NOT_POSTED"
    Rejected => "REJECTED"
    Pending => "PENDING"
    MissingStatus => "MISSING_STATUS"
    StatementMismatch => "STATEMENT_MISMATCH"
    StatementBeforeFinalStatus => "STATEMENT_BEFORE_FINAL_STATUS"
    PartiallyRefunded => "PARTIALLY_REFUNDED"
    FullyRefunded => "FULLY_REFUNDED"
    AccountUnmatched => "ACCOUNT_UNMATCHED"
  }
}

///|
pub struct LifecycleCase {
  end_to_end_id : String
  amount : Decimal
  currency : String
  debtor_iban : String?
  creditor_iban : String?
  status : String?
  statement_role : String
  net_posted : Decimal?
  reversal_count : Int
  state : LifecycleState
  action : String
}

///|
pub struct LifecycleReport {
  cases : Array[LifecycleCase]
  orphan_statuses : Array[String]
  orphan_statements : Array[String]
}

///|
pub fn statement_records(xml_text : String) -> Array[StatementRecord] raise XmlParseError {
  let reader = XmlReader::from_string(xml_text)
  let path : Array[String] = []
  let records : Array[StatementRecord] = []
  let mut account_iban : String? = None
  let mut in_entry = false
  let mut current_id : String? = None
  let mut current_amount : String? = None
  let mut current_currency : String? = None
  let mut current_direction = ""
  let mut current_reversal = false
  for ;; {
    match reader.read_event().kind {
      Start(element) => {
        path.push(element.name)
        if path_equals(path, ["Document", "BkToCstmrStmt", "Stmt", "Ntry"]) {
          in_entry = true
          current_id = None
          current_amount = None
          current_currency = None
          current_direction = ""
          current_reversal = false
        }
        if path_equals(
          path,
          ["Document", "BkToCstmrStmt", "Stmt", "Ntry", "Amt"],
        ) {
          current_currency = element.get("Ccy")
        }
      }
      Empty(element) => {
        path.push(element.name)
        if path_equals(
          path,
          ["Document", "BkToCstmrStmt", "Stmt", "Ntry", "Amt"],
        ) {
          current_currency = element.get("Ccy")
        }
        let _ = path.remove(path.length() - 1)
      }
      Text(content) => {
        if path_equals(
          path,
          ["Document", "BkToCstmrStmt", "Stmt", "Acct", "Id", "IBAN"],
        ) {
          account_iban = Some(content)
        } else if path_equals(
          path,
          ["Document", "BkToCstmrStmt", "Stmt", "Ntry", "Amt"],
        ) {
          current_amount = Some(content)
        } else if path_equals(
          path,
          ["Document", "BkToCstmrStmt", "Stmt", "Ntry", "CdtDbtInd"],
        ) {
          current_direction = content
        } else if path_equals(
          path,
          ["Document", "BkToCstmrStmt", "Stmt", "Ntry", "RvslInd"],
        ) {
          current_reversal = content == "true" || content == "1"
        } else if path_equals(
          path,
          [
            "Document",
            "BkToCstmrStmt",
            "Stmt",
            "Ntry",
            "NtryDtls",
            "TxDtls",
            "Refs",
            "EndToEndId",
          ],
        ) {
          current_id = Some(content)
        }
      }
      End(name) => {
        if name == "Ntry" && in_entry {
          match current_id {
            Some(id) =>
              match current_amount {
                Some(amount_text) =>
                  match current_currency {
                    Some(currency) =>
                      match Decimal::parse(amount_text) {
                        Some(amount) =>
                          records.push({
                            end_to_end_id: id,
                            amount,
                            currency,
                            account_iban,
                            credit_debit: current_direction,
                            reversal: current_reversal,
                          })
                        None => ()
                      }
                    None => ()
                  }
                None => ()
              }
            None => ()
          }
          in_entry = false
        }
        if path.length() > 0 {
          let _ = path.remove(path.length() - 1)
        }
      }
      Eof => break
    }
  }
  records
}

///|
fn action_for_state(state : LifecycleState) -> String {
  match state {
    Settled => "No action; retain the report as audit evidence."
    SettledNotPosted => "Check statement import and bank posting."
    Rejected => "Review the rejection reason and decide whether to resend."
    Pending => "Query the payment status again later."
    MissingStatus => "Request a status report from the payment provider."
    StatementMismatch => "Open an exception and verify amount, currency or posting direction."
    StatementBeforeFinalStatus => "Reconcile the statement entry with the current status."
    PartiallyRefunded => "Check the refund/reversal amount and remaining balance."
    FullyRefunded => "Do not treat this payment as completed; verify the refund or reversal."
    AccountUnmatched => "Verify that the statement account belongs to the debtor or creditor."
  }
}

///|
fn statement_role(
  statement : StatementRecord,
  payment : PaymentRecord,
) -> String {
  match statement.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 selected_statements(
  statements : Array[StatementRecord],
  payment : PaymentRecord,
) -> Array[StatementRecord] {
  let has_debtor = statements.iter().any(fn(statement) {
    statement_role(statement, payment) == "DEBTOR"
  })
  let has_creditor = statements.iter().any(fn(statement) {
    statement_role(statement, payment) == "CREDITOR"
  })
  let selected_role = if has_debtor {
    "DEBTOR"
  } else if has_creditor {
    "CREDITOR"
  } else {
    "UNKNOWN"
  }
  let result : Array[StatementRecord] = []
  for statement in statements {
    if statement_role(statement, payment) == selected_role {
      result.push(statement)
    }
  }
  result
}

///|
fn classify_case(
  status : String?,
  all_statements : Array[StatementRecord],
  payment : PaymentRecord,
) -> (LifecycleState, String, Decimal?, Int) {
  let statements = selected_statements(all_statements, payment)
  if statements.length() == 0 {
    let state = match status {
      Some(value) =>
        if value == "ACSC" {
          SettledNotPosted
        } else if value == "RJCT" {
          Rejected
        } else {
          Pending
        }
      None => MissingStatus
    }
    return (state, "UNKNOWN", None, 0)
  }
  let role = statement_role(statements[0], payment)
  if role == "UNKNOWN" {
    return (AccountUnmatched, role, None, 0)
  }
  let normal_direction = if role == "DEBTOR" { "DBIT" } else { "CRDT" }
  let mut net = Decimal::zero()
  let mut reversal_count = 0
  let mut currency_mismatch = false
  for statement in statements {
    if statement.currency != payment.currency {
      currency_mismatch = true
    }
    let normal = statement.credit_debit == normal_direction && !statement.reversal
    if normal {
      net = net.add(statement.amount)
    } else {
      net = net.subtract(statement.amount)
      reversal_count += 1
    }
  }
  if currency_mismatch {
    return (StatementMismatch, role, Some(net), reversal_count)
  }
  if net.compare(Decimal::zero()) <= 0 {
    return (FullyRefunded, role, Some(net), reversal_count)
  }
  if net.compare(payment.amount) == 0 {
    let state = match status {
      Some(value) =>
        if value == "ACSC" {
          Settled
        } else if value == "RJCT" {
          Rejected
        } else {
          StatementBeforeFinalStatus
        }
      None => MissingStatus
    }
    return (state, role, Some(net), reversal_count)
  }
  if net.compare(payment.amount) < 0 {
    if reversal_count > 0 {
      return (PartiallyRefunded, role, Some(net), reversal_count)
    }
    return (StatementMismatch, role, Some(net), reversal_count)
  }
  (StatementMismatch, role, Some(net), reversal_count)
}

///|
pub fn lifecycle_report(
  pain_xml : String,
  status_xml : String,
  statement_xml : String,
) -> LifecycleReport raise XmlParseError {
  let payments = pain001_payment_records(pain_xml)
  let statuses = status_records(status_xml)
  let statements = statement_records(statement_xml)
  let status_map : Map[String, String] = Map([])
  for record in statuses {
    status_map.set(record.end_to_end_id, record.status)
  }
  let payment_ids : Map[String, Bool] = Map([])
  let cases : Array[LifecycleCase] = []
  for payment in payments {
    payment_ids.set(payment.end_to_end_id, true)
    let records : Array[StatementRecord] = []
    for statement in statements {
      if statement.end_to_end_id == payment.end_to_end_id {
        records.push(statement)
      }
    }
    let status = status_map.get(payment.end_to_end_id)
    let (state, role, net_posted, reversal_count) = classify_case(status, records, payment)
    cases.push({
      end_to_end_id: payment.end_to_end_id,
      amount: payment.amount,
      currency: payment.currency,
      debtor_iban: payment.debtor_iban,
      creditor_iban: payment.creditor_iban,
      status,
      statement_role: role,
      net_posted,
      reversal_count,
      state,
      action: action_for_state(state),
    })
  }
  let orphan_statuses : Array[String] = []
  for record in statuses {
    if !payment_ids.contains(record.end_to_end_id) {
      orphan_statuses.push(record.end_to_end_id)
    }
  }
  let orphan_statements : Array[String] = []
  for record in statements {
    if !payment_ids.contains(record.end_to_end_id) {
      orphan_statements.push(record.end_to_end_id)
    }
  }
  { cases, orphan_statuses, orphan_statements }
}

///|
pub fn LifecycleReport::to_text(self : LifecycleReport) -> String {
  let output = StringBuilder()
  output.write_string("Payment lifecycle report\n")
  for case in self.cases {
    output.write_string("\n")
    output.write_string(case.end_to_end_id)
    output.write_string(" | ")
    output.write_string(case.amount.to_string())
    output.write_string(" ")
    output.write_string(case.currency)
    output.write_string(" | status=")
    output.write_string(match case.status {
      Some(value) => value
      None => "(missing)"
    })
    output.write_string(" | account=")
    output.write_string(case.statement_role)
    output.write_string(" | net=")
    output.write_string(match case.net_posted {
      Some(value) => value.to_string()
      None => "(not posted)"
    })
    output.write_string(" | reversals=")
    output.write_string(case.reversal_count.to_string())
    output.write_string(" | state=")
    output.write_string(case.state.name())
    output.write_string("\nAction: ")
    output.write_string(case.action)
    output.write_string("\n")
  }
  if self.orphan_statuses.length() > 0 {
    output.write_string("\nStatus entries without payment:")
    for id in self.orphan_statuses {
      output.write_string("\n- ")
      output.write_string(id)
    }
  }
  if self.orphan_statements.length() > 0 {
    output.write_string("\nStatement entries without payment:")
    for id in self.orphan_statements {
      output.write_string("\n- ")
      output.write_string(id)
    }
  }
  output.to_string()
}

///|
pub fn LifecycleReport::to_json(self : LifecycleReport) -> String {
  let cases : Array[Json] = []
  for case in self.cases {
    let status : Json = match case.status {
      Some(value) => Json::string(value)
      None => Json::null()
    }
    let debtor_iban : Json = match case.debtor_iban {
      Some(value) => Json::string(value)
      None => Json::null()
    }
    let creditor_iban : Json = match case.creditor_iban {
      Some(value) => Json::string(value)
      None => Json::null()
    }
    let net_posted : Json = match case.net_posted {
      Some(value) => Json::string(value.to_string())
      None => Json::null()
    }
    cases.push({
      "endToEndId": case.end_to_end_id,
      "amount": case.amount.to_string(),
      "currency": case.currency,
      "debtorIban": debtor_iban,
      "creditorIban": creditor_iban,
      "status": status,
      "statementRole": case.statement_role,
      "netPosted": net_posted,
      "reversalCount": case.reversal_count,
      "state": case.state.name(),
      "action": case.action,
    })
  }
  let orphan_statuses : Array[Json] = []
  for id in self.orphan_statuses {
    orphan_statuses.push(Json::string(id))
  }
  let orphan_statements : Array[Json] = []
  for id in self.orphan_statements {
    orphan_statements.push(Json::string(id))
  }
  let root = Map([])
  root.set("cases", Json::array(cases))
  root.set("orphanStatuses", Json::array(orphan_statuses))
  root.set("orphanStatements", Json::array(orphan_statements))
  Json::object(root).stringify(indent=2)
}

///|
pub fn LifecycleReport::to_markdown(self : LifecycleReport) -> String {
  let output = StringBuilder()
  output.write_string("# Payment lifecycle report\n\n")
  output.write_string("| EndToEndId | Amount | Status | Account | Net posted | State | Action |\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.amount.to_string())
    output.write_string(" ")
    output.write_string(case.currency)
    output.write_string(" | ")
    output.write_string(match case.status {
      Some(value) => value
      None => "(missing)"
    })
    output.write_string(" | ")
    output.write_string(case.statement_role)
    output.write_string(" | ")
    output.write_string(match case.net_posted {
      Some(value) => value.to_string()
      None => "(not posted)"
    })
    output.write_string(" | `")
    output.write_string(case.state.name())
    output.write_string("` | ")
    output.write_string(case.action)
    output.write_string(" |\n")
  }
  output.write_string("\n## Orphan entries\n\n")
  output.write_string("- Status entries without payment: ")
  output.write_string(self.orphan_statuses.length().to_string())
  output.write_string("\n- Statement entries without payment: ")
  output.write_string(self.orphan_statements.length().to_string())
  output.write_string("\n")
  output.to_string()
}