///| Input-quality profiling validates import readiness before reconciliation.

///|
pub(all) enum SourceKind {
  OrdersSource
  PaymentsSource
  RefundsSource
  AdjustmentsSource
} derive(Debug, Eq)

///|
pub(all) enum QualityIssueCode {
  BlankIdentifier
  BlankAccount
  BlankReference
  UnsupportedCurrency
  NonPositiveAmount
  NegativeDay
  DuplicateIdentifier
  OrphanReference
  MissingReason
  InvalidStatusCombination
  FutureDatedRecord
} derive(Debug, Eq)

///|
pub(all) struct QualityIssue {
  source : SourceKind
  row_number : Int
  record_id : String
  order_id : String
  code : QualityIssueCode
  severity : Severity
  message : String
} derive(Debug, Eq)

///|
pub(all) struct SourceProfile {
  source : SourceKind
  record_count : Int
  blank_identifier_count : Int
  duplicate_identifier_count : Int
  invalid_amount_count : Int
  blank_reference_count : Int
  orphan_reference_count : Int
  invalid_currency_count : Int
  future_day_count : Int
} derive(Debug, Eq)

///|
pub(all) struct InputQualityReport {
  orders : SourceProfile
  payments : SourceProfile
  refunds : SourceProfile
  adjustments : SourceProfile
  issues : Array[QualityIssue]
  ready : Bool
} derive(Debug, Eq)

///|
pub fn source_kind_label(source : SourceKind) -> String {
  match source {
    OrdersSource => "orders"
    PaymentsSource => "payments"
    RefundsSource => "refunds"
    AdjustmentsSource => "adjustments"
  }
}

///|
pub fn quality_issue_code_label(code : QualityIssueCode) -> String {
  match code {
    BlankIdentifier => "blank_identifier"
    BlankAccount => "blank_account"
    BlankReference => "blank_reference"
    UnsupportedCurrency => "unsupported_currency"
    NonPositiveAmount => "non_positive_amount"
    NegativeDay => "negative_day"
    DuplicateIdentifier => "duplicate_identifier"
    OrphanReference => "orphan_reference"
    MissingReason => "missing_reason"
    InvalidStatusCombination => "invalid_status_combination"
    FutureDatedRecord => "future_dated_record"
  }
}

///|
pub fn empty_source_profile(source : SourceKind) -> SourceProfile {
  {
    source,
    record_count: 0,
    blank_identifier_count: 0,
    duplicate_identifier_count: 0,
    invalid_amount_count: 0,
    blank_reference_count: 0,
    orphan_reference_count: 0,
    invalid_currency_count: 0,
    future_day_count: 0,
  }
}

///|
pub fn empty_input_quality_report() -> InputQualityReport {
  {
    orders: empty_source_profile(OrdersSource),
    payments: empty_source_profile(PaymentsSource),
    refunds: empty_source_profile(RefundsSource),
    adjustments: empty_source_profile(AdjustmentsSource),
    issues: [],
    ready: true,
  }
}

///|
pub fn quality_severity(code : QualityIssueCode) -> Severity {
  match code {
    BlankIdentifier => Error
    BlankAccount => Warning
    BlankReference => Error
    UnsupportedCurrency => Error
    NonPositiveAmount => Error
    NegativeDay => Error
    DuplicateIdentifier => Error
    OrphanReference => Error
    MissingReason => Warning
    InvalidStatusCombination => Warning
    FutureDatedRecord => Warning
  }
}

///|
pub fn quality_issue(
  source : SourceKind,
  row_number : Int,
  record_id : String,
  order_id : String,
  code : QualityIssueCode,
  message : String,
) -> QualityIssue {
  {
    source,
    row_number,
    record_id,
    order_id,
    code,
    severity: quality_severity(code),
    message,
  }
}

///|
fn add_issue(issues : Array[QualityIssue], issue : QualityIssue) -> Unit {
  issues.push(issue)
}

///|
fn allowed_currency(policy : Policy, currency : String) -> Bool {
  contains_string(policy.allowed_currencies, currency)
}

///|
fn duplicate_position(ids : Array[String], id : String, position : Int) -> Bool {
  if is_blank(id) {
    return false
  }
  for index = 0; index < position; index = index + 1 {
    if ids[index] == id {
      return true
    }
  }
  false
}

///|
fn update_profile_for_issue(
  profile : SourceProfile,
  code : QualityIssueCode,
) -> SourceProfile {
  match code {
    BlankIdentifier =>
      { ..profile, blank_identifier_count: profile.blank_identifier_count + 1, }
    BlankReference =>
      { ..profile, blank_reference_count: profile.blank_reference_count + 1, }
    NonPositiveAmount =>
      { ..profile, invalid_amount_count: profile.invalid_amount_count + 1, }
    DuplicateIdentifier =>
      {
        ..profile,
        duplicate_identifier_count: profile.duplicate_identifier_count + 1,
      }
    OrphanReference =>
      { ..profile, orphan_reference_count: profile.orphan_reference_count + 1, }
    UnsupportedCurrency =>
      { ..profile, invalid_currency_count: profile.invalid_currency_count + 1, }
    FutureDatedRecord =>
      { ..profile, future_day_count: profile.future_day_count + 1, }
    _ => profile
  }
}

///|
fn record_issue(
  profile : SourceProfile,
  issues : Array[QualityIssue],
  issue : QualityIssue,
) -> SourceProfile {
  add_issue(issues, issue)
  update_profile_for_issue(profile, issue.code)
}

///|
fn inspect_orders(
  input : LedgerInput,
  policy : Policy,
  current_day : Int,
  issues : Array[QualityIssue],
) -> SourceProfile {
  let mut profile = {
    ..empty_source_profile(OrdersSource),
    record_count: input.orders.length(),
  }
  let ids : Array[String] = []
  for order in input.orders {
    ids.push(order.id)
  }
  for index = 0; index < input.orders.length(); index = index + 1 {
    let order = input.orders[index]
    let row = index + 2
    if is_blank(order.id) {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          OrdersSource,
          row,
          order.id,
          order.id,
          BlankIdentifier,
          "order identifier is blank",
        ),
      )
    }
    if duplicate_position(ids, order.id, index) {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          OrdersSource,
          row,
          order.id,
          order.id,
          DuplicateIdentifier,
          "order identifier duplicates an earlier row",
        ),
      )
    }
    if is_blank(order.account_id) {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          OrdersSource,
          row,
          order.id,
          order.id,
          BlankAccount,
          "order account is blank",
        ),
      )
    }
    if order.amount_minor <= 0 {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          OrdersSource,
          row,
          order.id,
          order.id,
          NonPositiveAmount,
          "order amount must be positive",
        ),
      )
    }
    if !allowed_currency(policy, order.currency) {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          OrdersSource,
          row,
          order.id,
          order.id,
          UnsupportedCurrency,
          "order currency is not allowed",
        ),
      )
    }
    if order.created_day < 0 {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          OrdersSource,
          row,
          order.id,
          order.id,
          NegativeDay,
          "order day cannot be negative",
        ),
      )
    }
    if order.created_day > current_day {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          OrdersSource,
          row,
          order.id,
          order.id,
          FutureDatedRecord,
          "order is dated after reconciliation day",
        ),
      )
    }
    if order.status == Refunded && order.amount_minor <= 0 {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          OrdersSource,
          row,
          order.id,
          order.id,
          InvalidStatusCombination,
          "refunded order needs a positive original amount",
        ),
      )
    }
  }
  profile
}

///|
fn inspect_payments(
  input : LedgerInput,
  policy : Policy,
  current_day : Int,
  issues : Array[QualityIssue],
) -> SourceProfile {
  let mut profile = {
    ..empty_source_profile(PaymentsSource),
    record_count: input.payments.length(),
  }
  let ids : Array[String] = []
  for payment in input.payments {
    ids.push(payment.id)
  }
  for index = 0; index < input.payments.length(); index = index + 1 {
    let payment = input.payments[index]
    let row = index + 2
    if is_blank(payment.id) {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          PaymentsSource,
          row,
          payment.id,
          payment.order_id,
          BlankIdentifier,
          "payment identifier is blank",
        ),
      )
    }
    if duplicate_position(ids, payment.id, index) {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          PaymentsSource,
          row,
          payment.id,
          payment.order_id,
          DuplicateIdentifier,
          "payment identifier duplicates an earlier row",
        ),
      )
    }
    if is_blank(payment.order_id) {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          PaymentsSource,
          row,
          payment.id,
          payment.order_id,
          BlankReference,
          "payment order reference is blank",
        ),
      )
    } else if find_order(input, payment.order_id) is None {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          PaymentsSource,
          row,
          payment.id,
          payment.order_id,
          OrphanReference,
          "payment order reference does not exist",
        ),
      )
    }
    if payment.amount_minor <= 0 {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          PaymentsSource,
          row,
          payment.id,
          payment.order_id,
          NonPositiveAmount,
          "payment amount must be positive",
        ),
      )
    }
    if !allowed_currency(policy, payment.currency) {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          PaymentsSource,
          row,
          payment.id,
          payment.order_id,
          UnsupportedCurrency,
          "payment currency is not allowed",
        ),
      )
    }
    if payment.received_day < 0 {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          PaymentsSource,
          row,
          payment.id,
          payment.order_id,
          NegativeDay,
          "payment day cannot be negative",
        ),
      )
    }
    if payment.received_day > current_day {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          PaymentsSource,
          row,
          payment.id,
          payment.order_id,
          FutureDatedRecord,
          "payment is dated after reconciliation day",
        ),
      )
    }
    if payment.status == Failed && payment.amount_minor > 0 {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          PaymentsSource,
          row,
          payment.id,
          payment.order_id,
          InvalidStatusCombination,
          "failed payment has a non-zero amount",
        ),
      )
    }
  }
  profile
}

///|
fn inspect_refunds(
  input : LedgerInput,
  policy : Policy,
  current_day : Int,
  issues : Array[QualityIssue],
) -> SourceProfile {
  let mut profile = {
    ..empty_source_profile(RefundsSource),
    record_count: input.refunds.length(),
  }
  let ids : Array[String] = []
  for refund in input.refunds {
    ids.push(refund.id)
  }
  for index = 0; index < input.refunds.length(); index = index + 1 {
    let refund = input.refunds[index]
    let row = index + 2
    if is_blank(refund.id) {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          RefundsSource,
          row,
          refund.id,
          refund.order_id,
          BlankIdentifier,
          "refund identifier is blank",
        ),
      )
    }
    if duplicate_position(ids, refund.id, index) {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          RefundsSource,
          row,
          refund.id,
          refund.order_id,
          DuplicateIdentifier,
          "refund identifier duplicates an earlier row",
        ),
      )
    }
    if is_blank(refund.order_id) {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          RefundsSource,
          row,
          refund.id,
          refund.order_id,
          BlankReference,
          "refund order reference is blank",
        ),
      )
    } else if find_order(input, refund.order_id) is None {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          RefundsSource,
          row,
          refund.id,
          refund.order_id,
          OrphanReference,
          "refund order reference does not exist",
        ),
      )
    }
    if refund.amount_minor <= 0 {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          RefundsSource,
          row,
          refund.id,
          refund.order_id,
          NonPositiveAmount,
          "refund amount must be positive",
        ),
      )
    }
    if !allowed_currency(policy, refund.currency) {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          RefundsSource,
          row,
          refund.id,
          refund.order_id,
          UnsupportedCurrency,
          "refund currency is not allowed",
        ),
      )
    }
    if refund.refunded_day < 0 {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          RefundsSource,
          row,
          refund.id,
          refund.order_id,
          NegativeDay,
          "refund day cannot be negative",
        ),
      )
    }
    if refund.refunded_day > current_day {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          RefundsSource,
          row,
          refund.id,
          refund.order_id,
          FutureDatedRecord,
          "refund is dated after reconciliation day",
        ),
      )
    }
    if is_blank(refund.reason) {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          RefundsSource,
          row,
          refund.id,
          refund.order_id,
          MissingReason,
          "refund reason is blank",
        ),
      )
    }
  }
  profile
}

///|
fn inspect_adjustments(
  input : LedgerInput,
  policy : Policy,
  current_day : Int,
  issues : Array[QualityIssue],
) -> SourceProfile {
  let mut profile = {
    ..empty_source_profile(AdjustmentsSource),
    record_count: input.adjustments.length(),
  }
  let ids : Array[String] = []
  for adjustment in input.adjustments {
    ids.push(adjustment.id)
  }
  for index = 0; index < input.adjustments.length(); index = index + 1 {
    let adjustment = input.adjustments[index]
    let row = index + 2
    if is_blank(adjustment.id) {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          AdjustmentsSource,
          row,
          adjustment.id,
          adjustment.order_id,
          BlankIdentifier,
          "adjustment identifier is blank",
        ),
      )
    }
    if duplicate_position(ids, adjustment.id, index) {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          AdjustmentsSource,
          row,
          adjustment.id,
          adjustment.order_id,
          DuplicateIdentifier,
          "adjustment identifier duplicates an earlier row",
        ),
      )
    }
    if is_blank(adjustment.order_id) {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          AdjustmentsSource,
          row,
          adjustment.id,
          adjustment.order_id,
          BlankReference,
          "adjustment order reference is blank",
        ),
      )
    } else if find_order(input, adjustment.order_id) is None {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          AdjustmentsSource,
          row,
          adjustment.id,
          adjustment.order_id,
          OrphanReference,
          "adjustment order reference does not exist",
        ),
      )
    }
    if adjustment.amount_minor <= 0 {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          AdjustmentsSource,
          row,
          adjustment.id,
          adjustment.order_id,
          NonPositiveAmount,
          "adjustment amount must be positive",
        ),
      )
    }
    if !allowed_currency(policy, adjustment.currency) {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          AdjustmentsSource,
          row,
          adjustment.id,
          adjustment.order_id,
          UnsupportedCurrency,
          "adjustment currency is not allowed",
        ),
      )
    }
    if adjustment.day < 0 {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          AdjustmentsSource,
          row,
          adjustment.id,
          adjustment.order_id,
          NegativeDay,
          "adjustment day cannot be negative",
        ),
      )
    }
    if adjustment.day > current_day {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          AdjustmentsSource,
          row,
          adjustment.id,
          adjustment.order_id,
          FutureDatedRecord,
          "adjustment is dated after reconciliation day",
        ),
      )
    }
    if is_blank(adjustment.reason) {
      profile = record_issue(
        profile,
        issues,
        quality_issue(
          AdjustmentsSource,
          row,
          adjustment.id,
          adjustment.order_id,
          MissingReason,
          "adjustment reason is blank",
        ),
      )
    }
  }
  profile
}

///|
pub fn inspect_input_quality(
  input : LedgerInput,
  policy : Policy,
  current_day : Int,
) -> InputQualityReport {
  let issues : Array[QualityIssue] = []
  let orders = inspect_orders(input, policy, current_day, issues)
  let payments = inspect_payments(input, policy, current_day, issues)
  let refunds = inspect_refunds(input, policy, current_day, issues)
  let adjustments = inspect_adjustments(input, policy, current_day, issues)
  let mut ready = true
  for issue in issues {
    if issue.severity == Error {
      ready = false
    }
  }
  { orders, payments, refunds, adjustments, issues, ready, }
}

///|
pub fn quality_issues_for_source(
  report : InputQualityReport,
  source : SourceKind,
) -> Array[QualityIssue] {
  let selected : Array[QualityIssue] = []
  for issue in report.issues {
    if issue.source == source {
      selected.push(issue)
    }
  }
  selected
}

///|
pub fn quality_issues_for_code(
  report : InputQualityReport,
  code : QualityIssueCode,
) -> Array[QualityIssue] {
  let selected : Array[QualityIssue] = []
  for issue in report.issues {
    if issue.code == code {
      selected.push(issue)
    }
  }
  selected
}

///|
pub fn blocking_quality_issues(
  report : InputQualityReport,
) -> Array[QualityIssue] {
  let selected : Array[QualityIssue] = []
  for issue in report.issues {
    if issue.severity == Error {
      selected.push(issue)
    }
  }
  selected
}

///|
pub fn render_quality_report(report : InputQualityReport) -> String {
  let mut output = "SOURCE | ROW | SEVERITY | CODE | RECORD | MESSAGE\n"
  for issue in report.issues {
    output = output +
      "\{source_kind_label(issue.source)} | \{issue.row_number} | \{severity_label(issue.severity)} | \{quality_issue_code_label(issue.code)} | \{issue.record_id} | \{issue.message}\n"
  }
  output + "ready=\{report.ready} issues=\{report.issues.length()}"
}

///|
pub fn source_profile_is_consistent(profile : SourceProfile) -> Bool {
  profile.record_count >= 0 &&
  profile.blank_identifier_count >= 0 &&
  profile.duplicate_identifier_count >= 0 &&
  profile.invalid_amount_count >= 0 &&
  profile.blank_reference_count >= 0 &&
  profile.orphan_reference_count >= 0 &&
  profile.invalid_currency_count >= 0 &&
  profile.future_day_count >= 0
}

///|
pub fn input_quality_report_is_consistent(report : InputQualityReport) -> Bool {
  if !source_profile_is_consistent(report.orders) ||
    !source_profile_is_consistent(report.payments) ||
    !source_profile_is_consistent(report.refunds) ||
    !source_profile_is_consistent(report.adjustments) {
    return false
  }
  let mut expected_ready = true
  for issue in report.issues {
    if issue.row_number < 2 || is_blank(issue.message) {
      return false
    }
    if issue.severity == Error {
      expected_ready = false
    }
  }
  report.ready == expected_ready
}