///|
pub(all) struct CriterionProfile {
  name : String
  direction : Direction
  minimum : Double
  maximum : Double
  mean : Double
  population_deviation : Double
  distinct_values : Int32
  constant : Bool
  normalized_weight : Double
} derive(@debug.Debug)

///|
/// Observed ranges and scaled population deviations, not confidence estimates.
pub fn criterion_profiles(
  m : DecisionMatrix,
  weights : Array[Double],
) -> Array[CriterionProfile] raise ChoiceError {
  let w = matrix_weights(m, weights)
  let result : Array[CriterionProfile] = []
  for j, name in m.criteria {
    let x = m.cells.map(row => row[j])
    let lo = minimum(x)
    let hi = maximum(x)
    let scale = maximum(x.map(v => v.abs()))
    let scaled = x.map(v => if scale == 0.0 { 0.0 } else { v / scale })
    let center = sum(scaled) / x.length().to_double()
    let variance = sum(scaled.map(v => (v - center) * (v - center))) /
      x.length().to_double()
    let distinct : Array[Double] = []
    for value in x {
      if !distinct.contains(value) {
        distinct.push(value)
      }
    }
    result.push({
      name,
      direction: m.directions[j],
      minimum: lo,
      maximum: hi,
      mean: checked(center * scale),
      population_deviation: checked(variance.sqrt() * scale),
      distinct_values: distinct.length(),
      constant: lo == hi,
      normalized_weight: w[j],
    })
  }
  result
}

///|
pub(all) struct CriterionRemoval {
  omitted : String
  remaining_criteria : Array[String]
  ranking : Ranking
  agreement : RankAgreement
  winner_changed : Bool
} derive(@debug.Debug)

///|
pub(all) struct CriterionSensitivity {
  baseline : Ranking
  removals : Array[CriterionRemoval]
  skipped_zero_weight_remainders : Array[String]
} derive(@debug.Debug)

///|
/// Recomputes each leave-one-criterion-out model; zero remaining weight is recorded.
pub fn criterion_sensitivity(
  m : DecisionMatrix,
  weights : Array[Double],
  algorithm : Method,
) -> CriterionSensitivity raise ChoiceError {
  let w = matrix_weights(m, weights)
  if m.criteria.length() < 2 {
    raise Invalid("criterion removal needs at least two criteria")
  }
  let baseline = evaluate(m, w, algorithm)
  let removals : Array[CriterionRemoval] = []
  let skipped_zero_weight_remainders : Array[String] = []
  for omitted in 0.. j ]
    let remaining_weights = indices.map(j => w[j])
    if sum(remaining_weights) == 0.0 {
      skipped_zero_weight_remainders.push(m.criteria[omitted])
      continue
    }
    let subset = m.select_criteria(indices)
    let reduced_algorithm = match algorithm {
      Outranking(preferences) => {
        preference_specs(m, preferences)
        Outranking(indices.map(j => preferences[j]))
      }
      other => other
    }
    let ranking = evaluate(subset, remaining_weights, reduced_algorithm)
    removals.push({
      omitted: m.criteria[omitted],
      remaining_criteria: subset.criteria.copy(),
      ranking,
      agreement: rank_agreement(baseline, ranking),
      winner_changed: baseline.winners() != ranking.winners(),
    })
  }
  { baseline, removals, skipped_zero_weight_remainders, }
}

///|
pub(all) struct DecisionReport {
  ranking : Ranking
  weights : Array[Double]
  profiles : Array[CriterionProfile]
  pareto : ParetoResult
  additive_contributions : Array[Array[Double]]
  warnings : Array[String]
} derive(@debug.Debug)

///|
/// Auxiliary additive contributions are a separate diagnostic, not every method's score.
pub fn decision_report(
  m : DecisionMatrix,
  weights : Array[Double],
  algorithm : Method,
) -> DecisionReport raise ChoiceError {
  let w = matrix_weights(m, weights)
  let profiles = criterion_profiles(m, w)
  let ranking = evaluate(m, w, algorithm)
  let warnings : Array[String] = []
  for profile in profiles {
    if profile.constant && profile.normalized_weight > 0.0 {
      warnings.push("positive weight on constant criterion: " + profile.name)
    }
  }
  if ranking.winners().length() > 1 {
    warnings.push("multiple alternatives share the first competition rank")
  }
  warnings.push(
    "ranking is conditional on the observed alternatives and stated preferences",
  )
  let additive_contributions = saw(m, w).contributions
  {
    ranking,
    weights: w,
    profiles,
    pareto: pareto_fronts(m),
    additive_contributions,
    warnings,
  }
}

///|
pub fn report_json(report : DecisionReport) -> Json raise ChoiceError {
  let profiles = report.profiles.map(p => {
    Json::object({
      "name": Json(p.name),
      "direction": Json(if p.direction == Benefit { "benefit" } else { "cost" }),
      "minimum": Json(p.minimum),
      "maximum": Json(p.maximum),
      "mean": Json(p.mean),
      "population_deviation": Json(p.population_deviation),
      "distinct_values": Json(p.distinct_values),
      "constant": Json(p.constant),
      "normalized_weight": Json(p.normalized_weight),
    })
  })
  Json::object({
    "schema": Json("moonchoice.report/1"),
    "ranking": ranking_json(report.ranking),
    "weights": Json(report.weights),
    "profiles": Json::array(profiles),
    "pareto_fronts": Json(report.pareto.fronts),
    "additive_minmax_contributions": Json(report.additive_contributions),
    "warnings": Json(report.warnings),
  })
}

///|
pub extend CriterionProfile with @debug.Debug::{to_repr}

///|
pub extend CriterionRemoval with @debug.Debug::{to_repr}

///|
pub extend CriterionSensitivity with @debug.Debug::{to_repr}

///|
pub extend DecisionReport with @debug.Debug::{to_repr}