///|
/// Interpretable risk-scorecard primitives for turning posterior probabilities
/// into operational bands, actions, and monitored portfolio summaries.
pub struct RiskBand {
  name : String
  lower : Double
  upper : Double
  action : String
} derive(Debug, Eq, ToJson, FromJson)

///|
pub fn RiskBand::new(
  name : String,
  lower : Double,
  upper : Double,
  action : String,
) -> RiskBand {
  if name == "" || action == "" || lower < 0.0 || upper > 1.0 || lower >= upper {
    abort("RiskBand: invalid name, interval, or action")
  }
  { name, lower, upper, action }
}

///|
pub fn RiskBand::contains(self : RiskBand, probability : Double) -> Bool {
  probability >= self.lower && probability < self.upper
}

///|
pub struct RiskFeatureContribution {
  name : String
  value : Double
  weight : Double
  contribution : Double
} derive(Debug, Eq, ToJson, FromJson)

///|
pub fn RiskFeatureContribution::new(
  name : String,
  value : Double,
  weight : Double,
) -> RiskFeatureContribution {
  if name == "" ||
    value != value ||
    value.is_inf() ||
    weight != weight ||
    weight.is_inf() {
    abort("RiskFeatureContribution: name, value and weight are required")
  }
  { name, value, weight, contribution: value * weight }
}

///|
pub struct RiskScorecard {
  intercept : Double
  features : Array[RiskFeatureContribution]
  bands : Array[RiskBand]
  minimum : Double
  maximum : Double
} derive(Debug, Eq, ToJson, FromJson)

///|
pub fn RiskScorecard::new(intercept : Double) -> RiskScorecard {
  { intercept, features: [], bands: [], minimum: 0.0, maximum: 1.0 }
}

///|
pub fn RiskScorecard::with_bounds(
  self : RiskScorecard,
  minimum : Double,
  maximum : Double,
) -> RiskScorecard {
  if minimum < 0.0 || maximum > 1.0 || minimum >= maximum {
    abort("RiskScorecard: invalid probability bounds")
  }
  { ..self, minimum, maximum }
}

///|
pub fn RiskScorecard::add_feature(
  self : RiskScorecard,
  feature : RiskFeatureContribution,
) -> RiskScorecard {
  let features = self.features.copy()
  features.push(feature)
  { ..self, features, }
}

///|
pub fn RiskScorecard::add_band(
  self : RiskScorecard,
  band : RiskBand,
) -> RiskScorecard {
  for existing in self.bands.iter() {
    if band.lower < existing.upper && existing.lower < band.upper {
      abort("RiskScorecard: risk bands must not overlap")
    }
  }
  let bands = self.bands.copy()
  bands.push(band)
  { ..self, bands, }
}

///|
pub fn RiskScorecard::logit(self : RiskScorecard) -> Double {
  self.intercept +
  self.features.fold(init=0.0, (total, feature) => total + feature.contribution)
}

///|
pub fn RiskScorecard::probability(self : RiskScorecard) -> Double {
  let logit = self.logit()
  let raw = if logit >= 0.0 {
    1.0 / (1.0 + @math.pow(2.718281828459045, 0.0 - logit))
  } else {
    let exponential = @math.pow(2.718281828459045, logit)
    exponential / (1.0 + exponential)
  }
  raw.max(self.minimum).min(self.maximum)
}

///|
pub fn RiskScorecard::band(self : RiskScorecard) -> RiskBand? {
  let probability = self.probability()
  for item in self.bands.iter() {
    if item.contains(probability) ||
      (probability == self.maximum && item.upper == self.maximum) {
      return Some(item)
    }
  }
  None
}

///|
pub fn RiskScorecard::explain(
  self : RiskScorecard,
) -> Array[RiskFeatureContribution] {
  let result = self.features.copy()
  for left in 0.. result[left].contribution {
        let swap = result[left]
        result[left] = result[right]
        result[right] = swap
      }
    }
  }
  result
}

///|
pub struct RiskDecision {
  probability : Double
  band : String
  action : String
  expected_loss : Double
  accepted : Bool
} derive(Debug, Eq, ToJson, FromJson)

///|
pub fn decide_risk(
  scorecard : RiskScorecard,
  loss_if_positive : Double,
  loss_if_negative : Double,
  accept_loss : Double,
) -> RiskDecision {
  if loss_if_positive < 0.0 || loss_if_negative < 0.0 || accept_loss < 0.0 {
    abort("decide_risk: losses must be non-negative")
  }
  let probability = scorecard.probability()
  let expected_loss = probability * loss_if_positive +
    (1.0 - probability) * loss_if_negative
  let selected_band = scorecard.band()
  let band = match selected_band {
    Some(item) => item.name
    None => "unbanded"
  }
  let action = match selected_band {
    Some(item) => item.action
    None => "review"
  }
  {
    probability,
    band,
    action,
    expected_loss,
    accepted: expected_loss <= accept_loss,
  }
}

///|
pub struct PortfolioRiskSummary {
  count : Int
  mean_probability : Double
  expected_events : Double
  high_risk_count : Int
  total_exposure : Double
  expected_loss : Double
} derive(Debug, Eq, ToJson, FromJson)

///|
pub fn summarize_portfolio(
  probabilities : Array[Double],
  exposures : Array[Double],
  high_risk_threshold : Double,
  loss_if_event : Double,
) -> PortfolioRiskSummary {
  if probabilities.length() == 0 ||
    probabilities.length() != exposures.length() ||
    high_risk_threshold < 0.0 ||
    high_risk_threshold > 1.0 ||
    loss_if_event < 0.0 {
    abort("summarize_portfolio: invalid probabilities, exposures, or policy")
  }
  let mut expected_events = 0.0
  let mut high_risk_count = 0
  let mut total_exposure = 0.0
  let mut expected_loss = 0.0
  for index in 0.. 1.0 || exposure < 0.0 {
      abort("summarize_portfolio: invalid observation")
    }
    expected_events = expected_events + probability
    if probability >= high_risk_threshold {
      high_risk_count = high_risk_count + 1
    }
    total_exposure = total_exposure + exposure
    expected_loss = expected_loss + probability * loss_if_event * exposure
  }
  {
    count: probabilities.length(),
    mean_probability: mean(probabilities),
    expected_events,
    high_risk_count,
    total_exposure,
    expected_loss,
  }
}

///|
pub struct RiskCalibrationRow {
  lower : Double
  upper : Double
  count : Int
  predicted : Double
  observed : Double
  calibration_gap : Double
} derive(Debug, Eq, ToJson, FromJson)

///|
pub fn risk_calibration_table(
  probabilities : Array[Double],
  outcomes : Array[Bool],
  buckets : Int,
) -> Array[RiskCalibrationRow] {
  if probabilities.length() == 0 ||
    probabilities.length() != outcomes.length() ||
    buckets <= 0 {
    abort("risk_calibration_table: invalid inputs")
  }
  let result : Array[RiskCalibrationRow] = []
  for bucket in 0..= lower &&
        (probabilities[index] < upper || is_last) {
        count = count + 1
        predicted = predicted + probabilities[index]
        if outcomes[index] {
          observed = observed + 1.0
        }
      }
    }
    let predicted_rate = if count == 0 {
      0.0
    } else {
      predicted / count.to_double()
    }
    let observed_rate = if count == 0 {
      0.0
    } else {
      observed / count.to_double()
    }
    result.push({
      lower,
      upper,
      count,
      predicted: predicted_rate,
      observed: observed_rate,
      calibration_gap: predicted_rate - observed_rate,
    })
  }
  result
}

///|
pub fn risk_calibration_error(rows : Array[RiskCalibrationRow]) -> Double {
  let total = rows.fold(init=0, (count, row) => count + row.count)
  if total == 0 {
    return 0.0
  }
  rows.fold(init=0.0, (error, row) => {
    error +
    row.count.to_double() / total.to_double() * row.calibration_gap.abs()
  })
}

///|
pub struct LossCurvePoint {
  threshold : Double
  accepted : Int
  reviewed : Int
  expected_loss : Double
  acceptance_rate : Double
} derive(Debug, Eq, ToJson, FromJson)

///|
pub fn risk_loss_curve(
  probabilities : Array[Double],
  exposures : Array[Double],
  loss_if_event : Double,
  review_cost : Double,
  steps : Int,
) -> Array[LossCurvePoint] {
  if probabilities.length() == 0 ||
    probabilities.length() != exposures.length() ||
    loss_if_event < 0.0 ||
    review_cost < 0.0 ||
    steps <= 0 {
    abort("risk_loss_curve: invalid inputs")
  }
  let result : Array[LossCurvePoint] = []
  for step in 0..<=steps {
    let threshold = step.to_double() / steps.to_double()
    let mut accepted = 0
    let mut reviewed = 0
    let mut loss = 0.0
    for index in 0.. LossCurvePoint {
  if points.length() == 0 {
    abort("minimum_loss_policy: points must not be empty")
  }
  let mut best = points[0]
  for point in points.iter() {
    if point.expected_loss < best.expected_loss {
      best = point
    }
  }
  best
}