///|
pub(all) struct LeachingStage {
  stage : Int
  wash_solution : Double
  inlet_concentration : Double
  outlet_concentration : Double
  overflow_solution : Double
  overflow_solute : Double
  solute_remaining : Double
} derive(Debug, Eq, ToJson)

///|
pub(all) struct CrosscurrentLeachingResult {
  stages : Array[LeachingStage]
  final_underflow_concentration : Double
  final_solute_remaining : Double
  recovered_solute : Double
  recovery : Double
  balance_residual : Double
} derive(Debug, Eq, ToJson)

///|
/// Repeatedly wash a retained solution with fresh or specified wash streams.
pub fn crosscurrent_leach(
  dry_solid_mass~ : Double,
  initial_solution_mass~ : Double,
  initial_solute_fraction~ : Double,
  wash_masses : ArrayView[Double],
  wash_solute_fractions? : Array[Double] = [],
  retention_ratio~ : Double,
) -> CrosscurrentLeachingResult raise @domain.ExtractionError {
  @domain.require_positive("dry solid mass", dry_solid_mass)
  @domain.require_positive("initial solution mass", initial_solution_mass)
  @domain.require_fraction("initial solute fraction", initial_solute_fraction)
  @domain.require_non_negative("retention ratio", retention_ratio)
  if wash_masses.length() == 0 {
    raise @domain.ExtractionError::InsufficientData(required=1, actual=0)
  }
  if wash_solute_fractions.length() != 0 &&
    wash_solute_fractions.length() != wash_masses.length() {
    raise @domain.ExtractionError::InvalidArgument(
      message="wash fractions must be empty or match wash masses",
    )
  }
  let initial_solute = initial_solution_mass * initial_solute_fraction
  let stages : Array[LeachingStage] = []
  let mut retained_solution = initial_solution_mass
  let mut concentration = initial_solute_fraction
  let mut recovered = 0.0
  let mut wash_inlet_solute = 0.0
  for index, wash_mass in wash_masses {
    let wash_fraction = if wash_solute_fractions.length() == 0 {
      0.0
    } else {
      wash_solute_fractions[index]
    }
    let result = single_stage_leach(
      dry_solid_mass~,
      initial_solution_mass=retained_solution,
      initial_solute_fraction=concentration,
      wash_solution_mass=wash_mass,
      wash_solute_fraction=wash_fraction,
      retention_ratio~,
    )
    wash_inlet_solute += wash_mass * wash_fraction
    recovered += result.solute_in_overflow
    stages.push({
      stage: index + 1,
      wash_solution: wash_mass,
      inlet_concentration: concentration,
      outlet_concentration: result.equilibrium_concentration,
      overflow_solution: result.overflow_solution,
      overflow_solute: result.solute_in_overflow,
      solute_remaining: result.solute_in_underflow,
    })
    retained_solution = result.retained_solution
    concentration = result.equilibrium_concentration
  }
  let remaining = retained_solution * concentration
  let feed_basis_recovered = initial_solute - remaining
  {
    stages,
    final_underflow_concentration: concentration,
    final_solute_remaining: remaining,
    recovered_solute: feed_basis_recovered,
    recovery: if initial_solute > 0.0 {
      feed_basis_recovered / initial_solute
    } else {
      0.0
    },
    balance_residual: initial_solute + wash_inlet_solute - recovered - remaining,
  }
}

///|
pub fn crosscurrent_equal_wash(
  dry_solid_mass~ : Double,
  initial_solution_mass~ : Double,
  initial_solute_fraction~ : Double,
  total_wash_mass~ : Double,
  stage_count~ : Int,
  retention_ratio~ : Double,
) -> CrosscurrentLeachingResult raise @domain.ExtractionError {
  @domain.require_positive("total wash mass", total_wash_mass)
  if stage_count <= 0 {
    raise @domain.ExtractionError::InvalidArgument(
      message="stage_count must be positive",
    )
  }
  crosscurrent_leach(
    dry_solid_mass~,
    initial_solution_mass~,
    initial_solute_fraction~,
    Array::make(stage_count, total_wash_mass / stage_count.to_double()),
    retention_ratio~,
  )
}

///|
/// Evaluate a grid of stage counts and select the least wash meeting a target.
pub fn wash_for_target(
  dry_solid_mass~ : Double,
  initial_solution_mass~ : Double,
  initial_solute_fraction~ : Double,
  stage_count~ : Int,
  retention_ratio~ : Double,
  target_remaining_fraction~ : Double,
  tolerance? : Double = 1.0e-8,
  max_wash_mass? : Double = 1.0e9,
) -> CrosscurrentLeachingResult raise @domain.ExtractionError {
  @domain.require_fraction(
    "target remaining fraction", target_remaining_fraction,
  )
  @domain.require_positive("tolerance", tolerance)
  @domain.require_positive("max wash mass", max_wash_mass)
  if target_remaining_fraction >= 1.0 {
    raise @domain.ExtractionError::InvalidArgument(
      message="target remaining fraction must be below one",
    )
  }
  let target_solute = initial_solution_mass *
    initial_solute_fraction *
    target_remaining_fraction
  let mut lower = 0.0
  let mut upper = 1.0
  let mut upper_result = crosscurrent_equal_wash(
    dry_solid_mass~,
    initial_solution_mass~,
    initial_solute_fraction~,
    total_wash_mass=upper,
    stage_count~,
    retention_ratio~,
  )
  for ;; {
    if upper_result.final_solute_remaining <= target_solute {
      break
    }
    upper *= 2.0
    if upper > max_wash_mass {
      raise @domain.ExtractionError::ConvergenceFailure(
        iterations=0,
        residual=upper_result.final_solute_remaining - target_solute,
      )
    }
    upper_result = crosscurrent_equal_wash(
      dry_solid_mass~,
      initial_solution_mass~,
      initial_solute_fraction~,
      total_wash_mass=upper,
      stage_count~,
      retention_ratio~,
    )
  }
  for _ in 0..<100 {
    let middle = lower + (upper - lower) / 2.0
    let result = crosscurrent_equal_wash(
      dry_solid_mass~,
      initial_solution_mass~,
      initial_solute_fraction~,
      total_wash_mass=middle,
      stage_count~,
      retention_ratio~,
    )
    if result.final_solute_remaining <= target_solute {
      upper = middle
      upper_result = result
    } else {
      lower = middle
    }
    if upper - lower <= tolerance {
      break
    }
  }
  upper_result
}