///|
/// A normalized stage profile suitable for plotting and equipment review.
pub(all) struct StageProfilePoint {
  stage : Int
  raffinate : Double
  extract : Double
  driving_force : Double
  efficiency : Double
  solute_transfer : Double
  cumulative_transfer : Double
} derive(Debug, Eq, ToJson)

///|
pub(all) struct StageProfile {
  points : Array[StageProfilePoint]
  total_transfer : Double
  average_driving_force : Double
  minimum_driving_force : Double
  limiting_stage : Int
  minimum_efficiency : Double
  maximum_efficiency : Double
} derive(Debug, Eq, ToJson)

///|
fn profile_from_efficiency_steps(
  steps : ArrayView[EfficiencyStep],
) -> StageProfile raise @domain.ExtractionError {
  if steps.length() == 0 {
    raise @domain.ExtractionError::InsufficientData(required=1, actual=0)
  }
  let points : Array[StageProfilePoint] = []
  let mut cumulative = 0.0
  let mut sum_force = 0.0
  let mut minimum_force = 1.0e300
  let mut limiting = 1
  let mut minimum_efficiency = 1.0
  let mut maximum_efficiency = 0.0
  for index, step in steps {
    let force = (step.equilibrium_extract - step.extract_in).max(0.0)
    let transfer = (step.raffinate_in - step.raffinate_out).max(0.0)
    cumulative += transfer
    sum_force += force
    if force < minimum_force {
      minimum_force = force
      limiting = index + 1
    }
    minimum_efficiency = minimum_efficiency.min(step.efficiency)
    maximum_efficiency = maximum_efficiency.max(step.efficiency)
    points.push({
      stage: step.stage,
      raffinate: step.raffinate_out,
      extract: step.actual_extract_out,
      driving_force: force,
      efficiency: step.efficiency,
      solute_transfer: transfer,
      cumulative_transfer: cumulative,
    })
  }
  {
    points,
    total_transfer: cumulative,
    average_driving_force: sum_force / steps.length().to_double(),
    minimum_driving_force: minimum_force,
    limiting_stage: limiting,
    minimum_efficiency,
    maximum_efficiency,
  }
}

///|
pub fn profile_efficiency_result(
  result : EfficiencyStageResult,
) -> StageProfile raise @domain.ExtractionError {
  profile_from_efficiency_steps(result.steps)
}

///|
pub fn profile_ideal_steps(
  result : StageCountResult,
) -> StageProfile raise @domain.ExtractionError {
  if result.steps.length() == 0 {
    raise @domain.ExtractionError::InsufficientData(required=1, actual=0)
  }
  let points : Array[StageProfilePoint] = []
  let mut cumulative = 0.0
  let mut force_sum = 0.0
  let mut minimum_force = 1.0e300
  let mut limiting = 1
  for index, step in result.steps {
    let force = (step.extract_out - step.extract_in).max(0.0)
    let transfer = (step.raffinate_in - step.raffinate_out).max(0.0)
    cumulative += transfer
    force_sum += force
    if force < minimum_force {
      minimum_force = force
      limiting = index + 1
    }
    points.push({
      stage: step.stage,
      raffinate: step.raffinate_out,
      extract: step.extract_out,
      driving_force: force,
      efficiency: 1.0,
      solute_transfer: transfer,
      cumulative_transfer: cumulative,
    })
  }
  {
    points,
    total_transfer: cumulative,
    average_driving_force: force_sum / result.steps.length().to_double(),
    minimum_driving_force: minimum_force,
    limiting_stage: limiting,
    minimum_efficiency: 1.0,
    maximum_efficiency: 1.0,
  }
}

///|
pub fn StageProfile::stage_count(self : StageProfile) -> Int {
  self.points.length()
}

///|
pub fn StageProfile::point(
  self : StageProfile,
  index : Int,
) -> StageProfilePoint raise @domain.ExtractionError {
  match self.points.get(index) {
    Some(value) => value
    None =>
      raise @domain.ExtractionError::InvalidArgument(
        message="stage profile index out of range",
      )
  }
}

///|
pub fn StageProfile::efficiency_range(self : StageProfile) -> Double {
  self.maximum_efficiency - self.minimum_efficiency
}

///|
pub fn StageProfile::uniformity(self : StageProfile) -> Double {
  if self.maximum_efficiency <= 0.0 {
    0.0
  } else {
    self.minimum_efficiency / self.maximum_efficiency
  }
}

///|
pub fn StageProfile::to_csv(self : StageProfile) -> String {
  let lines = [
    "stage,raffinate,extract,driving_force,efficiency,solute_transfer,cumulative_transfer",
  ]
  for point in self.points {
    lines.push(
      "\{point.stage},\{point.raffinate},\{point.extract},\{point.driving_force},\{point.efficiency},\{point.solute_transfer},\{point.cumulative_transfer}",
    )
  }
  lines.join("\n")
}

///|
pub fn StageProfile::to_markdown(self : StageProfile) -> String {
  let lines = [
    "| Stage | Raffinate | Extract | Driving force | Efficiency | Transfer |", "|---:|---:|---:|---:|---:|---:|",
  ]
  for point in self.points {
    lines.push(
      "| \{point.stage} | \{point.raffinate} | \{point.extract} | \{point.driving_force} | \{point.efficiency} | \{point.solute_transfer} |",
    )
  }
  lines.join("\n")
}

///|
pub(all) struct ProfileAlert {
  stage : Int
  code : String
  severity : String
  measured : Double
  threshold : Double
  message : String
} derive(Debug, Eq, ToJson)

///|
pub fn review_profile(
  profile : StageProfile,
  minimum_efficiency~ : Double,
  minimum_driving_force~ : Double,
) -> Array[ProfileAlert] raise @domain.ExtractionError {
  @domain.require_fraction("minimum efficiency", minimum_efficiency)
  @domain.require_non_negative("minimum driving force", minimum_driving_force)
  let alerts : Array[ProfileAlert] = []
  for point in profile.points {
    if point.efficiency < minimum_efficiency {
      alerts.push({
        stage: point.stage,
        code: "EFF-LOW",
        severity: "warning",
        measured: point.efficiency,
        threshold: minimum_efficiency,
        message: "stage efficiency is below operating target",
      })
    }
    if point.driving_force < minimum_driving_force {
      alerts.push({
        stage: point.stage,
        code: "FORCE-LOW",
        severity: "critical",
        measured: point.driving_force,
        threshold: minimum_driving_force,
        message: "stage has a small equilibrium driving force",
      })
    }
  }
  alerts
}

///|
pub fn smooth_efficiency_profile(
  efficiencies : ArrayView[Double],
  window~ : Int,
) -> Array[Double] raise @domain.ExtractionError {
  if efficiencies.length() == 0 {
    raise @domain.ExtractionError::InsufficientData(required=1, actual=0)
  }
  if window < 1 || window > efficiencies.length() {
    raise @domain.ExtractionError::InvalidArgument(
      message="efficiency smoothing window is invalid",
    )
  }
  for efficiency in efficiencies {
    @domain.require_fraction("efficiency", efficiency)
  }
  [
    for index in 0.. {
      let start = (index + 1 - window).max(0)
      let mut sum = 0.0
      for cursor in start..<=index {
        sum += efficiencies[cursor]
      }
      sum / (index - start + 1).to_double()
    }
  ]
}

///|
pub fn required_efficiency_for_target(
  theoretical_stages~ : Double,
  actual_stage_limit~ : Double,
) -> Double raise @domain.ExtractionError {
  @domain.require_positive("theoretical stages", theoretical_stages)
  @domain.require_positive("actual stage limit", actual_stage_limit)
  (theoretical_stages / actual_stage_limit).min(1.0)
}

///|
pub fn allocate_efficiency_budget(
  stage_count~ : Int,
  average_efficiency~ : Double,
  limiting_stage~ : Int,
  limiting_efficiency~ : Double,
) -> Array[Double] raise @domain.ExtractionError {
  if stage_count < 1 || limiting_stage < 1 || limiting_stage > stage_count {
    raise @domain.ExtractionError::InvalidArgument(
      message="efficiency budget stage index is invalid",
    )
  }
  @domain.require_fraction("average efficiency", average_efficiency)
  @domain.require_fraction("limiting efficiency", limiting_efficiency)
  let remainder = average_efficiency * stage_count.to_double() -
    limiting_efficiency
  let other = if stage_count == 1 {
    0.0
  } else {
    (remainder / (stage_count - 1).to_double()).clamp(min=0.0, max=1.0)
  }
  [
    for stage in 1..<=stage_count => {
      if stage == limiting_stage {
        limiting_efficiency
      } else {
        other
      }
    }
  ]
}

///|
pub fn profile_transfer_fraction(
  profile : StageProfile,
  stage : Int,
) -> Double raise @domain.ExtractionError {
  let point = profile.point(stage - 1)
  if profile.total_transfer <= 0.0 {
    0.0
  } else {
    point.solute_transfer / profile.total_transfer
  }
}