///|
/// A stream state used by sequential flowsheet calculations.
pub(all) struct ProcessStream {
  name : String
  carrier_flow : Double
  solvent_flow : Double
  carrier_concentration : Double
  solvent_concentration : Double
  temperature : @units.Temperature
  pressure : @units.Pressure
} derive(Debug, Eq, ToJson)

///|
pub fn ProcessStream::new(
  name~ : String,
  carrier_flow~ : Double,
  solvent_flow~ : Double,
  carrier_concentration~ : Double,
  solvent_concentration? : Double = 0.0,
  temperature~ : @units.Temperature,
  pressure~ : @units.Pressure,
) -> ProcessStream raise @domain.ExtractionError {
  if name.trim().length() == 0 {
    raise @domain.ExtractionError::InvalidArgument(
      message="process stream name cannot be blank",
    )
  }
  @domain.require_positive("carrier flow", carrier_flow)
  @domain.require_non_negative("solvent flow", solvent_flow)
  @domain.require_non_negative("carrier concentration", carrier_concentration)
  @domain.require_non_negative("solvent concentration", solvent_concentration)
  {
    name,
    carrier_flow,
    solvent_flow,
    carrier_concentration,
    solvent_concentration,
    temperature,
    pressure,
  }
}

///|
pub fn ProcessStream::name(self : ProcessStream) -> String {
  self.name
}

///|
pub fn ProcessStream::total_flow(self : ProcessStream) -> Double {
  self.carrier_flow + self.solvent_flow
}

///|
pub fn ProcessStream::carrier_solute(self : ProcessStream) -> Double {
  self.carrier_flow * self.carrier_concentration
}

///|
pub fn ProcessStream::solvent_solute(self : ProcessStream) -> Double {
  self.solvent_flow * self.solvent_concentration
}

///|
pub fn ProcessStream::total_solute(self : ProcessStream) -> Double {
  self.carrier_solute() + self.solvent_solute()
}

///|
pub fn ProcessStream::overall_concentration(self : ProcessStream) -> Double {
  if self.total_flow() <= 0.0 {
    0.0
  } else {
    self.total_solute() / self.total_flow()
  }
}

///|
pub fn ProcessStream::with_name(
  self : ProcessStream,
  name : String,
) -> ProcessStream {
  {
    name,
    carrier_flow: self.carrier_flow,
    solvent_flow: self.solvent_flow,
    carrier_concentration: self.carrier_concentration,
    solvent_concentration: self.solvent_concentration,
    temperature: self.temperature,
    pressure: self.pressure,
  }
}

///|
pub fn ProcessStream::with_temperature(
  self : ProcessStream,
  temperature : @units.Temperature,
) -> ProcessStream {
  {
    name: self.name,
    carrier_flow: self.carrier_flow,
    solvent_flow: self.solvent_flow,
    carrier_concentration: self.carrier_concentration,
    solvent_concentration: self.solvent_concentration,
    temperature,
    pressure: self.pressure,
  }
}

///|
pub fn ProcessStream::with_pressure(
  self : ProcessStream,
  pressure : @units.Pressure,
) -> ProcessStream {
  {
    name: self.name,
    carrier_flow: self.carrier_flow,
    solvent_flow: self.solvent_flow,
    carrier_concentration: self.carrier_concentration,
    solvent_concentration: self.solvent_concentration,
    temperature: self.temperature,
    pressure,
  }
}

///|
pub fn ProcessStream::scale(
  self : ProcessStream,
  factor : Double,
) -> ProcessStream raise @domain.ExtractionError {
  @domain.require_non_negative("stream scale", factor)
  {
    name: self.name,
    carrier_flow: self.carrier_flow * factor,
    solvent_flow: self.solvent_flow * factor,
    carrier_concentration: self.carrier_concentration,
    solvent_concentration: self.solvent_concentration,
    temperature: self.temperature,
    pressure: self.pressure,
  }
}

///|
/// Mix two streams on a common mass-flow basis.
pub fn mix_streams(
  first : ProcessStream,
  second : ProcessStream,
  name~ : String,
) -> ProcessStream raise @domain.ExtractionError {
  let carrier_flow = first.carrier_flow + second.carrier_flow
  let solvent_flow = first.solvent_flow + second.solvent_flow
  let carrier_concentration = if carrier_flow <= 0.0 {
    0.0
  } else {
    (first.carrier_solute() + second.carrier_solute()) / carrier_flow
  }
  let solvent_concentration = if solvent_flow <= 0.0 {
    0.0
  } else {
    (first.solvent_solute() + second.solvent_solute()) / solvent_flow
  }
  let total = first.total_flow() + second.total_flow()
  let temperature = if total <= 0.0 {
    first.temperature
  } else {
    @units.Temperature::new(
      value=(
          first.temperature.kelvin() * first.total_flow() +
          second.temperature.kelvin() * second.total_flow()
        ) /
        total,
      unit=@units.TemperatureUnit::Kelvin,
    )
  }
  let pressure = if first.pressure.pascal() <= second.pressure.pascal() {
    first.pressure
  } else {
    second.pressure
  }
  ProcessStream::new(
    name~,
    carrier_flow~,
    solvent_flow~,
    carrier_concentration~,
    solvent_concentration~,
    temperature~,
    pressure~,
  )
}

///|
pub(all) enum OperationKind {
  Contact(distribution~ : Double)
  Heat(outlet_temperature_kelvin~ : Double)
  Cool(outlet_temperature_kelvin~ : Double)
  PressureDrop(outlet_pressure_pascal~ : Double)
  Split(fraction~ : Double)
  HoldUp(residence_seconds~ : Double)
} derive(Debug, Eq, ToJson)

///|
pub(all) struct ProcessStep {
  name : String
  kind : OperationKind
} derive(Debug, Eq, ToJson)

///|
pub fn ProcessStep::new(
  name~ : String,
  kind~ : OperationKind,
) -> ProcessStep raise @domain.ExtractionError {
  if name.trim().length() == 0 {
    raise @domain.ExtractionError::InvalidArgument(
      message="process step name cannot be blank",
    )
  }
  match kind {
    Contact(distribution~) =>
      @domain.require_non_negative("step distribution", distribution)
    Heat(outlet_temperature_kelvin~) =>
      @domain.require_positive("outlet temperature", outlet_temperature_kelvin)
    Cool(outlet_temperature_kelvin~) =>
      @domain.require_positive("outlet temperature", outlet_temperature_kelvin)
    PressureDrop(outlet_pressure_pascal~) =>
      @domain.require_positive("outlet pressure", outlet_pressure_pascal)
    Split(fraction~) => {
      @domain.require_fraction("split fraction", fraction)
      if fraction <= 0.0 || fraction >= 1.0 {
        raise @domain.ExtractionError::InvalidArgument(
          message="split fraction must be strictly between zero and one",
        )
      }
    }
    HoldUp(residence_seconds~) =>
      @domain.require_positive("residence time", residence_seconds)
  }
  { name, kind }
}

///|
pub(all) struct StepResult {
  step_name : String
  inlet : ProcessStream
  outlet : ProcessStream
  residual : Double
  duty : Double
  warning : String
} derive(Debug, Eq, ToJson)

///|
fn contact_step(
  inlet : ProcessStream,
  name : String,
  distribution : Double,
) -> ProcessStream raise @domain.ExtractionError {
  let result = @liquid.single_stage_constant_k(
    feed_flow=inlet.carrier_flow,
    feed_concentration=inlet.carrier_concentration,
    solvent_flow=inlet.solvent_flow.max(1.0e-12),
    solvent_concentration=inlet.solvent_concentration,
    distribution~,
  )
  ProcessStream::new(
    name~,
    carrier_flow=inlet.carrier_flow,
    solvent_flow=inlet.solvent_flow.max(1.0e-12),
    carrier_concentration=result.raffinate_concentration,
    solvent_concentration=result.extract_concentration,
    temperature=inlet.temperature,
    pressure=inlet.pressure,
  )
}

///|
pub fn apply_step(
  inlet : ProcessStream,
  step : ProcessStep,
) -> StepResult raise @domain.ExtractionError {
  let outlet = match step.kind {
    Contact(distribution~) => contact_step(inlet, step.name, distribution)
    Heat(outlet_temperature_kelvin~) => {
      let temperature = @units.Temperature::new(
        value=outlet_temperature_kelvin,
        unit=@units.TemperatureUnit::Kelvin,
      )
      inlet.with_name(step.name).with_temperature(temperature)
    }
    Cool(outlet_temperature_kelvin~) => {
      let temperature = @units.Temperature::new(
        value=outlet_temperature_kelvin,
        unit=@units.TemperatureUnit::Kelvin,
      )
      inlet.with_name(step.name).with_temperature(temperature)
    }
    PressureDrop(outlet_pressure_pascal~) => {
      let pressure = @units.Pressure::new(
        value=outlet_pressure_pascal,
        unit=@units.PressureUnit::Pascal,
      )
      inlet.with_name(step.name).with_pressure(pressure)
    }
    Split(fraction~) => inlet.scale(fraction).with_name(step.name)
    HoldUp(_) => inlet.with_name(step.name)
  }
  let duty = match step.kind {
    Heat(_) => outlet.temperature.kelvin() - inlet.temperature.kelvin()
    Cool(_) => outlet.temperature.kelvin() - inlet.temperature.kelvin()
    _ => 0.0
  }
  let residual = inlet.total_solute() - outlet.total_solute()
  let warning = match step.kind {
    Split(_) => "split outlet represents one branch"
    HoldUp(_) => "residence model does not change composition"
    _ => ""
  }
  { step_name: step.name, inlet, outlet, residual, duty, warning }
}

///|
pub(all) struct Flowsheet {
  name : String
  steps : Array[ProcessStep]
} derive(Debug, Eq, ToJson)

///|
pub fn Flowsheet::new(
  name~ : String,
) -> Flowsheet raise @domain.ExtractionError {
  if name.trim().length() == 0 {
    raise @domain.ExtractionError::InvalidArgument(
      message="flowsheet name cannot be blank",
    )
  }
  { name, steps: [] }
}

///|
pub fn Flowsheet::add(self : Flowsheet, step : ProcessStep) -> Flowsheet {
  let steps = self.steps.copy()
  steps.push(step)
  { name: self.name, steps }
}

///|
pub fn Flowsheet::step_count(self : Flowsheet) -> Int {
  self.steps.length()
}

///|
pub fn Flowsheet::steps(self : Flowsheet) -> Array[ProcessStep] {
  self.steps.copy()
}

///|
pub(all) struct FlowsheetResult {
  name : String
  inlet : ProcessStream
  outlet : ProcessStream
  steps : Array[StepResult]
  total_residual : Double
  maximum_step_residual : Double
  passed : Bool
} derive(Debug, Eq, ToJson)

///|
pub fn simulate(
  flowsheet : Flowsheet,
  inlet : ProcessStream,
  tolerance? : Double = 1.0e-8,
) -> FlowsheetResult raise @domain.ExtractionError {
  @domain.require_positive("flowsheet tolerance", tolerance)
  let results : Array[StepResult] = []
  let mut current = inlet
  let mut maximum = 0.0
  for step in flowsheet.steps {
    let result = apply_step(current, step)
    results.push(result)
    current = result.outlet
    maximum = maximum.max(result.residual.abs())
  }
  let total_residual = inlet.total_solute() - current.total_solute()
  {
    name: flowsheet.name,
    inlet,
    outlet: current,
    steps: results,
    total_residual,
    maximum_step_residual: maximum,
    passed: maximum <= tolerance,
  }
}

///|
/// A recycle convergence result for a single recycle fraction.
pub(all) struct RecycleResult {
  converged_stream : ProcessStream
  iterations : Int
  residual : Double
  converged : Bool
} derive(Debug, Eq, ToJson)

///|
pub fn simulate_recycle(
  flowsheet : Flowsheet,
  inlet : ProcessStream,
  recycle_fraction~ : Double,
  tolerance? : Double = 1.0e-8,
  maximum_iterations? : Int = 100,
) -> RecycleResult raise @domain.ExtractionError {
  @domain.require_fraction("recycle fraction", recycle_fraction)
  @domain.require_positive("recycle tolerance", tolerance)
  if maximum_iterations < 1 {
    raise @domain.ExtractionError::InvalidArgument(
      message="recycle iteration limit must be positive",
    )
  }
  let mut guess = inlet
  let mut residual = 0.0
  let mut iterations = 0
  for index in 0.. Array[LedgerEntry] raise @domain.ExtractionError {
  [
    for step in result.steps => {
      ledger_entry(
        name=step.step_name,
        inlet=step.inlet.total_solute(),
        outlet=step.outlet.total_solute(),
      )
    }
  ]
}

///|
pub fn outlet_recovery(result : FlowsheetResult) -> Double {
  let inlet = result.inlet.total_solute()
  if inlet <= 0.0 {
    0.0
  } else {
    result.outlet.total_solute() / inlet
  }
}

///|
pub fn total_temperature_change(result : FlowsheetResult) -> Double {
  result.outlet.temperature.kelvin() - result.inlet.temperature.kelvin()
}

///|
pub fn total_pressure_drop(result : FlowsheetResult) -> Double {
  (result.inlet.pressure.pascal() - result.outlet.pressure.pascal()).max(0.0)
}

///|
/// Estimate a heat duty from a flowsheet temperature change.
pub fn estimate_heat_duty(
  result : FlowsheetResult,
  heat_capacity_kj_per_kg_k~ : Double,
) -> @units.Energy raise @domain.ExtractionError {
  @domain.require_non_negative("heat capacity", heat_capacity_kj_per_kg_k)
  let flow = result.inlet.total_flow()
  let delta = total_temperature_change(result)
  @units.Energy::new(
    value=flow * heat_capacity_kj_per_kg_k * delta,
    unit=@units.EnergyUnit::KiloJoule,
  )
}