///|
/// Validation result for one pain.001 document.
pub struct Diagnostic {
  code : String
  path : String
  message : String
}

///|
pub struct ValidationReport {
  summary : Pain001Summary
  diagnostics : Array[Diagnostic]
}

///|
fn text_present(value : String?) -> Bool {
  match value {
    Some(text) => text != ""
    None => false
  }
}

///|
fn push_diagnostic(
  diagnostics : Array[Diagnostic],
  code : String,
  path : String,
  message : String,
) -> Unit {
  diagnostics.push({ code, path, message, })
}


///|
fn is_digit(c : Char) -> Bool {
  c >= '0' && c <= '9'
}

///|
fn is_iso_date(value : String) -> Bool {
  if value.length() != 10 {
    return false
  }
  for i = 0; i < value.length(); i = i + 1 {
    let c = value[i].unsafe_to_char()
    if i == 4 || i == 7 {
      if c != '-' {
        return false
      }
    } else if !is_digit(c) {
      return false
    }
  }
  true
}

///|
fn is_iso_datetime(value : String) -> Bool {
  if value.length() < 19 {
    return false
  }
  for i = 0; i < 19; i = i + 1 {
    let c = value[i].unsafe_to_char()
    if i == 4 || i == 7 {
      if c != '-' {
        return false
      }
    } else if i == 10 {
      if c != 'T' {
        return false
      }
    } else if i == 13 || i == 16 {
      if c != ':' {
        return false
      }
    } else if !is_digit(c) {
      return false
    }
  }
  true
}

///|
fn is_currency(value : String) -> Bool {
  if value.length() != 3 {
    return false
  }
  for c in value {
    if c < 'A' || c > 'Z' {
      return false
    }
  }
  true
}

///|
fn is_decimal_amount(value : String) -> Bool {
  if value == "" || value[0] == '.' || value[value.length() - 1] == '.' {
    return false
  }
  let mut dot_count = 0
  let mut digit_count = 0
  let mut fraction_digits = 0
  let mut seen_dot = false
  for c in value {
    if is_digit(c) {
      digit_count += 1
      if seen_dot {
        fraction_digits += 1
      }
    } else if c == '.' {
      dot_count += 1
      seen_dot = true
    } else {
      return false
    }
  }
  digit_count > 0 && dot_count <= 1 && fraction_digits <= 2
}

///|
fn parse_amount_minor_units(value : String) -> Int? {
  if !is_decimal_amount(value) {
    return None
  }
  let mut units = 0
  let mut fraction_digits = 0
  let mut seen_dot = false
  for c in value {
    if c == '.' {
      seen_dot = true
    } else {
      units = units * 10 + (c.to_int() - '0'.to_int())
      if seen_dot {
        fraction_digits += 1
      }
    }
  }
  if fraction_digits == 0 {
    units = units * 100
  } else if fraction_digits == 1 {
    units = units * 10
  }
  Some(units)
}

///|
fn is_unsigned_integer(value : String) -> Bool {
  if value == "" {
    return false
  }
  for c in value {
    if !is_digit(c) {
      return false
    }
  }
  true
}

///|
fn indexed_path(base : String, index : Int) -> String {
  base + "[" + index.to_string() + "]"
}

///|
pub fn validate_pain001(
  xml_text : String,
) -> ValidationReport raise @xml.XmlError {
  let data = parse_pain001_data(xml_text)
  let diagnostics : Array[Diagnostic] = []
  if !text_present(data.summary.message_id) {
    push_diagnostic(
      diagnostics,
      "MSG_ID_MISSING",
      "GrpHdr/MsgId",
      "Message ID is required.",
    )
  }
  if !text_present(data.summary.creation_datetime) {
    push_diagnostic(
      diagnostics,
      "CREATION_TIME_MISSING",
      "GrpHdr/CreDtTm",
      "Creation date time is required.",
    )
  } else {
    match data.summary.creation_datetime {
      Some(value) =>
        if !is_iso_datetime(value) {
          push_diagnostic(
            diagnostics,
            "CREATION_TIME_INVALID",
            "GrpHdr/CreDtTm",
            "Creation date time must use ISO 8601 datetime format.",
          )
        }
      None => ()
    }
  }
  if data.summary.payment_instruction_count == 0 {
    push_diagnostic(
      diagnostics,
      "PAYMENT_INSTRUCTION_MISSING",
      "PmtInf",
      "At least one payment instruction is required.",
    )
  }
  for i = 0; i < data.payment_instruction_ids.length(); i = i + 1 {
    if !text_present(data.payment_instruction_ids[i]) {
      push_diagnostic(
        diagnostics,
        "PAYMENT_INSTRUCTION_ID_MISSING",
        indexed_path("PmtInf/PmtInfId", i),
        "Payment instruction ID is required.",
      )
    }
    if !text_present(data.execution_dates[i]) {
      push_diagnostic(
        diagnostics,
        "EXECUTION_DATE_MISSING",
        indexed_path("PmtInf/ReqdExctnDt", i),
        "Requested execution date is required.",
      )
    } else {
      match data.execution_dates[i] {
        Some(value) =>
          if !is_iso_date(value) {
            push_diagnostic(
              diagnostics,
              "EXECUTION_DATE_INVALID",
              indexed_path("PmtInf/ReqdExctnDt", i),
              "Requested execution date must use ISO 8601 date format.",
            )
          }
        None => ()
      }
    }
  }
  let seen_end_to_end_ids : Map[String, Bool] = Map([])
  let mut amount_sum = 0
  let mut all_amounts_valid = true
  for i = 0; i < data.end_to_end_ids.length(); i = i + 1 {
    let end_to_end_id = data.end_to_end_ids[i]
    if !text_present(end_to_end_id) {
      push_diagnostic(
        diagnostics,
        "END_TO_END_ID_MISSING",
        indexed_path("CdtTrfTxInf/PmtId/EndToEndId", i),
        "End-to-end ID is required.",
      )
    } else {
      match end_to_end_id {
        Some(value) => {
          if seen_end_to_end_ids.contains(value) {
            push_diagnostic(
              diagnostics,
              "END_TO_END_ID_DUPLICATE",
              indexed_path("CdtTrfTxInf/PmtId/EndToEndId", i),
              "End-to-end ID is duplicated: " + value,
            )
          } else {
            seen_end_to_end_ids.set(value, true)
          }
        }
        None => ()
      }
    }
    if !text_present(data.instruction_amounts[i]) {
      push_diagnostic(
        diagnostics,
        "INSTRUCTION_AMOUNT_MISSING",
        indexed_path("CdtTrfTxInf/Amt/InstdAmt", i),
        "Instructed amount is required.",
      )
    } else {
      match data.instruction_amounts[i] {
        Some(value) =>
          match parse_amount_minor_units(value) {
            Some(units) => amount_sum += units
            None => {
              all_amounts_valid = false
              push_diagnostic(
                diagnostics,
                "INSTRUCTION_AMOUNT_INVALID",
                indexed_path("CdtTrfTxInf/Amt/InstdAmt", i),
                "Instructed amount must be a positive decimal number with up to two decimal places.",
              )
            }
          }
        None => ()
      }
    }
    if !text_present(data.instruction_currencies[i]) {
      push_diagnostic(
        diagnostics,
        "INSTRUCTION_CURRENCY_MISSING",
        indexed_path("CdtTrfTxInf/Amt/InstdAmt/@Ccy", i),
        "Instruction currency is required.",
      )
    } else {
      match data.instruction_currencies[i] {
        Some(value) =>
          if !is_currency(value) {
            push_diagnostic(
              diagnostics,
              "INSTRUCTION_CURRENCY_INVALID",
              indexed_path("CdtTrfTxInf/Amt/InstdAmt/@Ccy", i),
              "Instruction currency must be a three-letter uppercase code.",
            )
          }
        None => ()
      }
    }
  }
  match data.declared_control_sum {
    Some(declared) => {
      match parse_amount_minor_units(declared) {
        Some(declared_sum) =>
          if all_amounts_valid && declared_sum != amount_sum {
            push_diagnostic(
              diagnostics,
              "CONTROL_SUM_MISMATCH",
              "GrpHdr/CtrlSum",
              "Declared control sum does not match the sum of instructed amounts.",
            )
          }
        None =>
          push_diagnostic(
            diagnostics,
            "CONTROL_SUM_INVALID",
            "GrpHdr/CtrlSum",
            "Declared control sum must be a decimal number with up to two decimal places.",
          )
      }
    }
    None => ()
  }
  match data.declared_transaction_count {
    Some(declared) => {
      if !is_unsigned_integer(declared) {
        push_diagnostic(
          diagnostics,
          "TRANSACTION_COUNT_INVALID",
          "GrpHdr/NbOfTxs",
          "Declared transaction count must be an unsigned integer.",
        )
      } else if declared != data.summary.transaction_count.to_string() {
        push_diagnostic(
          diagnostics,
          "TRANSACTION_COUNT_MISMATCH",
          "GrpHdr/NbOfTxs",
          "Declared transaction count does not match parsed transactions.",
        )
      }
    }
    None => ()
  }
  { summary: data.summary, diagnostics, }
}

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

///|
pub fn ValidationReport::to_text(self : ValidationReport) -> String {
  let output = StringBuilder()
  output.write_string(self.summary.to_text())
  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 ValidationReport::to_json(self : ValidationReport) -> 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.summary.message_id {
    Some(value) => Json::string(value)
    None => Json::null()
  }
  let creation_datetime : Json = match self.summary.creation_datetime {
    Some(value) => Json::string(value)
    None => Json::null()
  }
  let summary = Map([])
  summary.set("messageId", message_id)
  summary.set("creationDateTime", creation_datetime)
  summary.set(
    "paymentInstructionCount",
    Json::number(self.summary.payment_instruction_count.to_double()),
  )
  summary.set(
    "transactionCount",
    Json::number(self.summary.transaction_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 ValidationReport::to_markdown(self : ValidationReport) -> String {
  let output = StringBuilder()
  output.write_string("# MoonISO Lite validation report\n\n")
  output.write_string("**Result:** ")
  if self.is_valid() {
    output.write_string("VALID\n\n")
  } else {
    output.write_string("INVALID\n\n")
  }
  output.write_string("## Summary\n\n")
  output.write_string("- Message ID: ")
  output.write_string(match self.summary.message_id {
    Some(value) => value
    None => "(missing)"
  })
  output.write_string("\n- Creation time: ")
  output.write_string(match self.summary.creation_datetime {
    Some(value) => value
    None => "(missing)"
  })
  output.write_string("\n- Payment instructions: ")
  output.write_string(self.summary.payment_instruction_count.to_string())
  output.write_string("\n- Transactions: ")
  output.write_string(self.summary.transaction_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()
}