///|
pub(all) enum MessageKind {
  Pain001
  Pain002
  Pacs008
  Pacs002
  Camt053
  Unknown
}

///|
pub fn MessageKind::name(self : MessageKind) -> String {
  match self {
    Pain001 => "pain.001"
    Pain002 => "pain.002"
    Pacs008 => "pacs.008"
    Pacs002 => "pacs.002"
    Camt053 => "camt.053"
    Unknown => "unknown"
  }
}

///|
pub struct MessageInfo {
  kind : MessageKind
  message_id : String?
  creation_datetime : String?
  original_message_id : String?
  entry_count : Int
}

///|
pub struct MessageValidation {
  info : MessageInfo
  diagnostics : Array[Diagnostic]
}

///|
fn first_optional(values : Array[String?]) -> String? {
  for value in values {
    match value {
      Some(text) => return Some(text)
      None => ()
    }
  }
  None
}

///|
fn detect_from_fields(fields : Array[XmlField]) -> MessageKind {
  for field in fields {
    if field.text is None {
      if field.path.has_suffix("/CstmrCdtTrfInitn") {
        return Pain001
      } else if field.path.has_suffix("/CstmrPmtStsRpt") {
        return Pain002
      } else if field.path.has_suffix("/FIToFICstmrCdtTrf") {
        return Pacs008
      } else if field.path.has_suffix("/FIToFIPmtStsRpt") {
        return Pacs002
      } else if field.path.has_suffix("/BkToCstmrStmt") {
        return Camt053
      }
    }
  }
  Unknown
}

///|
pub fn detect_message_kind(xml_text : String) -> MessageKind raise XmlParseError {
  detect_from_fields(flatten_xml(xml_text))
}

///|
fn is_valid_status(value : String) -> Bool {
  value == "ACCP" ||
  value == "ACSC" ||
  value == "ACSP" ||
  value == "ACTC" ||
  value == "ACWC" ||
  value == "PDNG" ||
  value == "RCVD" ||
  value == "RJCT"
}

///|
fn build_info(kind : MessageKind, fields : Array[XmlField]) -> MessageInfo {
  let original_message_id = first_optional([
    first_text_by_suffix(fields, "/OrgnlGrpInfAndSts/OrgnlMsgId"),
    first_text_by_suffix(fields, "/OrgnlGrpInf/OrgnlMsgId"),
  ])
  let entry_count = match kind {
    Pain002 | Pacs002 => elements_by_suffix(fields, "/TxInfAndSts").length()
    Pacs008 => elements_by_suffix(fields, "/CdtTrfTxInf").length()
    Camt053 => elements_by_suffix(fields, "/Ntry").length()
    Pain001 => 0
    Unknown => 0
  }
  {
    kind,
    message_id: first_text_by_suffix(fields, "/GrpHdr/MsgId"),
    creation_datetime: first_text_by_suffix(fields, "/GrpHdr/CreDtTm"),
    original_message_id,
    entry_count,
  }
}

///|
pub fn validate_message(
  xml_text : String,
) -> MessageValidation raise XmlParseError {
  let kind = detect_message_kind(xml_text)
  if kind is Pain001 {
    let report = validate_pain001(xml_text)
    return {
      info: {
        kind,
        message_id: report.summary.message_id,
        creation_datetime: report.summary.creation_datetime,
        original_message_id: None,
        entry_count: report.summary.transaction_count,
      },
      diagnostics: report.diagnostics,
    }
  }
  let fields = flatten_xml(xml_text)
  let info = build_info(kind, fields)
  let diagnostics : Array[Diagnostic] = []
  if kind is Unknown {
    push_diagnostic(
      diagnostics,
      "UNKNOWN_MESSAGE_TYPE",
      "Document",
      "Unsupported ISO 20022 message type.",
    )
    return { info, diagnostics, }
  }
  if !text_present(info.message_id) {
    push_diagnostic(
      diagnostics,
      "MSG_ID_MISSING",
      "GrpHdr/MsgId",
      "Message ID is required.",
    )
  }
  if !text_present(info.creation_datetime) {
    push_diagnostic(
      diagnostics,
      "CREATION_TIME_MISSING",
      "GrpHdr/CreDtTm",
      "Creation date time is required.",
    )
  }
  if info.entry_count == 0 {
    push_diagnostic(
      diagnostics,
      "ENTRY_MISSING",
      "Message",
      "At least one message entry is required.",
    )
  }
  match kind {
    Pain002 | Pacs002 => {
      let status_ids = texts_by_suffix(fields, "/TxInfAndSts/OrgnlEndToEndId")
      let statuses = texts_by_suffix(fields, "/TxInfAndSts/TxSts")
      if status_ids.length() != statuses.length() {
        push_diagnostic(
          diagnostics,
          "STATUS_ENTRY_INCOMPLETE",
          "TxInfAndSts",
          "Each status entry must contain both an original end-to-end ID and a status.",
        )
      }
      let seen : Map[String, Bool] = Map([])
      for i = 0; i < status_ids.length(); i = i + 1 {
        let id = status_ids[i]
        if seen.contains(id) {
          push_diagnostic(
            diagnostics,
            "STATUS_ID_DUPLICATE",
            "TxInfAndSts/OrgnlEndToEndId",
            "Original end-to-end ID is duplicated: " + id,
          )
        } else {
          seen.set(id, true)
        }
      }
      for status in statuses {
        if !is_valid_status(status) {
          push_diagnostic(
            diagnostics,
            "STATUS_CODE_INVALID",
            "TxInfAndSts/TxSts",
            "Unknown transaction status: " + status,
          )
        }
      }
    }
    Pacs008 => {
      let ids = texts_by_suffix(fields, "/CdtTrfTxInf/PmtId/EndToEndId")
      let amount_values = texts_by_suffix(fields, "/CdtTrfTxInf/IntrBkSttlmAmt")
      let amount_elements = elements_by_suffix(fields, "/CdtTrfTxInf/IntrBkSttlmAmt")
      let seen : Map[String, Bool] = Map([])
      for id in ids {
        if seen.contains(id) {
          push_diagnostic(
            diagnostics,
            "PACS_END_TO_END_ID_DUPLICATE",
            "CdtTrfTxInf/PmtId/EndToEndId",
            "End-to-end ID is duplicated: " + id,
          )
        } else {
          seen.set(id, true)
        }
      }
      if amount_values.length() != amount_elements.length() {
        push_diagnostic(
          diagnostics,
          "SETTLEMENT_AMOUNT_MISSING",
          "CdtTrfTxInf/IntrBkSttlmAmt",
          "Each settlement amount element must contain a value.",
        )
      }
      for value in amount_values {
        match Decimal::parse(value) {
          Some(decimal) =>
            if !decimal.is_positive() {
              push_diagnostic(
                diagnostics,
                "SETTLEMENT_AMOUNT_NOT_POSITIVE",
                "CdtTrfTxInf/IntrBkSttlmAmt",
                "Settlement amount must be greater than zero.",
              )
            }
          None =>
            push_diagnostic(
              diagnostics,
              "SETTLEMENT_AMOUNT_INVALID",
              "CdtTrfTxInf/IntrBkSttlmAmt",
              "Settlement amount must be a decimal with up to two decimal places.",
            )
        }
      }
      for field in amount_elements {
        match field.get_attribute("Ccy") {
          Some(value) =>
            if !is_currency(value) {
              push_diagnostic(
                diagnostics,
                "SETTLEMENT_CURRENCY_INVALID",
                "CdtTrfTxInf/IntrBkSttlmAmt/@Ccy",
                "Settlement currency must be a three-letter uppercase code.",
              )
            }
          None =>
            push_diagnostic(
              diagnostics,
              "SETTLEMENT_CURRENCY_MISSING",
              "CdtTrfTxInf/IntrBkSttlmAmt/@Ccy",
              "Settlement currency is required.",
            )
        }
      }
    }
    Camt053 => {
      let amount_values = texts_by_suffix(fields, "/Ntry/Amt")
      let amount_elements = elements_by_suffix(fields, "/Ntry/Amt")
      if amount_values.length() != amount_elements.length() {
        push_diagnostic(
          diagnostics,
          "STATEMENT_AMOUNT_MISSING",
          "Ntry/Amt",
          "Each statement amount element must contain a value.",
        )
      }
      for value in amount_values {
        if Decimal::parse(value) is None {
          push_diagnostic(
            diagnostics,
            "STATEMENT_AMOUNT_INVALID",
            "Ntry/Amt",
            "Statement entry amount must be a decimal with up to two decimal places.",
          )
        }
      }
      for field in amount_elements {
        match field.get_attribute("Ccy") {
          Some(value) =>
            if !is_currency(value) {
              push_diagnostic(
                diagnostics,
                "STATEMENT_CURRENCY_INVALID",
                "Ntry/Amt/@Ccy",
                "Statement currency must be a three-letter uppercase code.",
              )
            }
          None =>
            push_diagnostic(
              diagnostics,
              "STATEMENT_CURRENCY_MISSING",
              "Ntry/Amt/@Ccy",
              "Statement currency is required.",
            )
        }
      }
      let indicators = texts_by_suffix(fields, "/Ntry/CdtDbtInd")
      if indicators.length() != info.entry_count {
        push_diagnostic(
          diagnostics,
          "ENTRY_CREDIT_DEBIT_MISSING",
          "Ntry/CdtDbtInd",
          "Each statement entry must contain a credit/debit indicator.",
        )
      }
      for value in indicators {
        if value != "CRDT" && value != "DBIT" {
          push_diagnostic(
            diagnostics,
            "ENTRY_CREDIT_DEBIT_INVALID",
            "Ntry/CdtDbtInd",
            "Credit/debit indicator must be CRDT or DBIT.",
          )
        }
      }
    }
    Pain001 | Unknown => ()
  }
  { info, diagnostics, }
}

///|
pub fn MessageValidation::is_valid(self : MessageValidation) -> Bool {
  self.diagnostics.length() == 0
}

///|
pub fn MessageValidation::to_text(self : MessageValidation) -> String {
  let output = StringBuilder()
  output.write_string("Message type: ")
  output.write_string(self.info.kind.name())
  output.write_string("\nMessage ID: ")
  output.write_string(match self.info.message_id {
    Some(value) => value
    None => "(missing)"
  })
  output.write_string("\nCreation time: ")
  output.write_string(match self.info.creation_datetime {
    Some(value) => value
    None => "(missing)"
  })
  output.write_string("\nOriginal message ID: ")
  output.write_string(match self.info.original_message_id {
    Some(value) => value
    None => "(not present)"
  })
  output.write_string("\nEntries: ")
  output.write_string(self.info.entry_count.to_string())
  output.write_string("\nResult: ")
  if self.is_valid() {
    output.write_string("VALID")
  } else {
    output.write_string("INVALID (")
    output.write_string(self.diagnostics.length().to_string())
    output.write_string(" issue(s))")
    for diagnostic in self.diagnostics {
      output.write_string("\n- [")
      output.write_string(diagnostic.code)
      output.write_string("] ")
      output.write_string(diagnostic.path)
      output.write_string(": ")
      output.write_string(diagnostic.message)
    }
  }
  output.to_string()
}

///|
pub fn MessageValidation::to_json(self : MessageValidation) -> String {
  let diagnostics : Array[Json] = []
  for diagnostic in self.diagnostics {
    let item : Json = {
      "code": diagnostic.code,
      "path": diagnostic.path,
      "message": diagnostic.message,
    }
    diagnostics.push(item)
  }
  let message_id : Json = match self.info.message_id {
    Some(value) => Json::string(value)
    None => Json::null()
  }
  let creation_datetime : Json = match self.info.creation_datetime {
    Some(value) => Json::string(value)
    None => Json::null()
  }
  let original_message_id : Json = match self.info.original_message_id {
    Some(value) => Json::string(value)
    None => Json::null()
  }
  let summary = Map([])
  summary.set("messageType", Json::string(self.info.kind.name()))
  summary.set("messageId", message_id)
  summary.set("creationDateTime", creation_datetime)
  summary.set("originalMessageId", original_message_id)
  summary.set("entryCount", Json::number(self.info.entry_count.to_double()))
  let root = Map([])
  root.set("valid", Json::boolean(self.is_valid()))
  root.set("summary", Json::object(summary))
  root.set("diagnostics", Json::array(diagnostics))
  Json::object(root).stringify(indent=2)
}

///|
pub fn MessageValidation::to_markdown(self : MessageValidation) -> String {
  let output = StringBuilder()
  output.write_string("# MoonISO validation report\n\n")
  output.write_string("**Message type:** ")
  output.write_string(self.info.kind.name())
  output.write_string("\n\n**Result:** ")
  if self.is_valid() {
    output.write_string("VALID\n")
  } else {
    output.write_string("INVALID\n")
  }
  output.write_string("\n## Summary\n\n")
  output.write_string("- Message ID: ")
  output.write_string(match self.info.message_id {
    Some(value) => value
    None => "(missing)"
  })
  output.write_string("\n- Creation time: ")
  output.write_string(match self.info.creation_datetime {
    Some(value) => value
    None => "(missing)"
  })
  output.write_string("\n- Original message ID: ")
  output.write_string(match self.info.original_message_id {
    Some(value) => value
    None => "(not present)"
  })
  output.write_string("\n- Entries: ")
  output.write_string(self.info.entry_count.to_string())
  output.write_string("\n\n## Diagnostics\n\n")
  if self.is_valid() {
    output.write_string("No issues found.\n")
  } else {
    output.write_string("| Code | Path | Message |\n")
    output.write_string("|---|---|---|\n")
    for diagnostic in self.diagnostics {
      output.write_string("| `")
      output.write_string(diagnostic.code)
      output.write_string("` | `")
      output.write_string(diagnostic.path)
      output.write_string("` | ")
      output.write_string(diagnostic.message)
      output.write_string(" |\n")
    }
  }
  output.to_string()
}