///|
/// A named process study input for matrix evaluation.
pub(all) struct StudyCase {
  name : String
  feed_flow : Double
  feed_concentration : Double
  solvent_flow : Double
  distribution : Double
  target_recovery : Double
} derive(Debug, Eq, ToJson)

///|
pub fn StudyCase::new(
  name~ : String,
  feed_flow~ : Double,
  feed_concentration~ : Double,
  solvent_flow~ : Double,
  distribution~ : Double,
  target_recovery? : Double = 0.0,
) -> StudyCase raise @domain.ExtractionError {
  if name.trim().length() == 0 {
    raise @domain.ExtractionError::InvalidArgument(
      message="study case name cannot be blank",
    )
  }
  @domain.require_positive("study feed flow", feed_flow)
  @domain.require_non_negative("study feed concentration", feed_concentration)
  @domain.require_positive("study solvent flow", solvent_flow)
  @domain.require_non_negative("study distribution", distribution)
  @domain.require_fraction("study target recovery", target_recovery)
  {
    name,
    feed_flow,
    feed_concentration,
    solvent_flow,
    distribution,
    target_recovery,
  }
}

///|
pub fn StudyCase::run(
  self : StudyCase,
) -> StudyCaseResult raise @domain.ExtractionError {
  let result = @liquid.single_stage_constant_k(
    feed_flow=self.feed_flow,
    feed_concentration=self.feed_concentration,
    solvent_flow=self.solvent_flow,
    distribution=self.distribution,
  )
  {
    case_name: self.name,
    result,
    passed: result.recovery >= self.target_recovery,
    recovery_margin: result.recovery - self.target_recovery,
    solvent_to_feed: self.solvent_flow / self.feed_flow,
  }
}

///|
pub(all) struct StudyCaseResult {
  case_name : String
  result : @liquid.SingleStageResult
  passed : Bool
  recovery_margin : Double
  solvent_to_feed : Double
} derive(Debug, Eq, ToJson)

///|
pub(all) struct StudyMatrix {
  cases : Array[StudyCase]
  results : Array[StudyCaseResult]
  passed_count : Int
  failed_count : Int
  best_recovery : StudyCaseResult
  worst_recovery : StudyCaseResult
} derive(Debug, Eq, ToJson)

///|
pub fn run_study_matrix(
  cases : ArrayView[StudyCase],
) -> StudyMatrix raise @domain.ExtractionError {
  if cases.length() == 0 {
    raise @domain.ExtractionError::InsufficientData(required=1, actual=0)
  }
  let results = [ for item in cases => item.run() ]
  let mut best = results[0]
  let mut worst = results[0]
  let mut passed = 0
  for result in results {
    if result.passed {
      passed += 1
    }
    if result.result.recovery > best.result.recovery {
      best = result
    }
    if result.result.recovery < worst.result.recovery {
      worst = result
    }
  }
  {
    cases: cases.to_owned(),
    results,
    passed_count: passed,
    failed_count: results.length() - passed,
    best_recovery: best,
    worst_recovery: worst,
  }
}

///|
pub fn StudyMatrix::result(
  self : StudyMatrix,
  index : Int,
) -> StudyCaseResult raise @domain.ExtractionError {
  match self.results.get(index) {
    Some(value) => value
    None =>
      raise @domain.ExtractionError::InvalidArgument(
        message="study result index out of range",
      )
  }
}

///|
pub fn StudyMatrix::recoveries(self : StudyMatrix) -> Array[Double] {
  [
    for result in self.results => result.result.recovery
  ]
}

///|
pub fn StudyMatrix::mean_recovery(self : StudyMatrix) -> Double {
  self.results.fold(init=0.0, (sum, item) => sum + item.result.recovery) /
  self.results.length().to_double()
}

///|
pub fn StudyMatrix::mean_solvent_ratio(self : StudyMatrix) -> Double {
  self.results.fold(init=0.0, (sum, item) => sum + item.solvent_to_feed) /
  self.results.length().to_double()
}

///|
pub fn StudyMatrix::to_csv(self : StudyMatrix) -> String {
  let lines = [
    "case,recovery,raffinate,extract,balance_residual,solvent_to_feed,passed",
  ]
  for item in self.results {
    lines.push(
      "\{item.case_name},\{item.result.recovery},\{item.result.raffinate_concentration},\{item.result.extract_concentration},\{item.result.balance_residual},\{item.solvent_to_feed},\{item.passed}",
    )
  }
  lines.join("\n")
}

///|
pub fn StudyMatrix::to_markdown(self : StudyMatrix) -> String {
  let lines = [
    "| Case | Recovery | Raffinate | Balance residual | Status |", "|---|---:|---:|---:|---|",
  ]
  for item in self.results {
    let status = if item.passed { "PASS" } else { "FAIL" }
    lines.push(
      "| \{item.case_name} | \{item.result.recovery} | \{item.result.raffinate_concentration} | \{item.result.balance_residual} | \{status} |",
    )
  }
  lines.join("\n")
}

///|
pub fn sensitivity_matrix(
  base : StudyCase,
  feed_multipliers : ArrayView[Double],
  solvent_multipliers : ArrayView[Double],
) -> Array[StudyCaseResult] raise @domain.ExtractionError {
  if feed_multipliers.length() == 0 || solvent_multipliers.length() == 0 {
    raise @domain.ExtractionError::InsufficientData(required=1, actual=0)
  }
  let cases : Array[StudyCase] = []
  for feed_multiplier in feed_multipliers {
    for solvent_multiplier in solvent_multipliers {
      cases.push(
        StudyCase::new(
          name="feed={feed_multiplier},solvent={solvent_multiplier}",
          feed_flow=base.feed_flow * feed_multiplier,
          feed_concentration=base.feed_concentration,
          solvent_flow=base.solvent_flow * solvent_multiplier,
          distribution=base.distribution,
          target_recovery=base.target_recovery,
        ),
      )
    }
  }
  [
    for item in cases => item.run()
  ]
}

///|
pub fn recovery_elasticity(
  base : StudyCase,
  relative_step? : Double = 0.01,
) -> (Double, Double) raise @domain.ExtractionError {
  @domain.require_positive("study relative step", relative_step)
  let lower = StudyCase::{
    name: base.name,
    feed_flow: base.feed_flow * (1.0 - relative_step),
    feed_concentration: base.feed_concentration,
    solvent_flow: base.solvent_flow,
    distribution: base.distribution,
    target_recovery: base.target_recovery,
  }.run()
  let upper = StudyCase::{
    name: base.name,
    feed_flow: base.feed_flow * (1.0 + relative_step),
    feed_concentration: base.feed_concentration,
    solvent_flow: base.solvent_flow,
    distribution: base.distribution,
    target_recovery: base.target_recovery,
  }.run()
  let slope = (upper.result.recovery - lower.result.recovery) /
    (2.0 * relative_step * base.feed_flow)
  let nominal = base.run().result.recovery
  let normalized = if nominal.abs() <= 1.0e-12 {
    slope
  } else {
    slope * base.feed_flow / nominal
  }
  (slope, normalized)
}