///|
/// Mass-balance closure and utility accounting for process streams.
pub(all) struct StreamBalance {
  stream : String
  carrier_in : Double
  carrier_out : Double
  solvent_in : Double
  solvent_out : Double
  solute_in : Double
  solute_out : Double
  carrier_error : Double
  solvent_error : Double
  solute_error : Double
  closed : Bool
} derive(Debug, Eq, ToJson)

///|
pub(all) struct BalanceLedger {
  entries : Array[StreamBalance]
  total_carrier_error : Double
  total_solvent_error : Double
  total_solute_error : Double
  maximum_relative_error : Double
  closed : Bool
} derive(Debug, Eq, ToJson)

///|
pub(all) struct ComponentLedger {
  component : String
  feed : Double
  product : Double
  waste : Double
  recovered : Double
  recovery : Double
  closure_error : Double
} derive(Debug, Eq, ToJson)

///|
pub fn stream_balance(
  name : String,
  inlet : ProcessStream,
  outlet : ProcessStream,
  tolerance : Double,
) -> StreamBalance {
  let carrier_in = inlet.carrier_flow
  let carrier_out = outlet.carrier_flow
  let solvent_in = inlet.solvent_flow
  let solvent_out = outlet.solvent_flow
  let solute_in = inlet.total_solute()
  let solute_out = outlet.total_solute()
  let carrier_error = carrier_out - carrier_in
  let solvent_error = solvent_out - solvent_in
  let solute_error = solute_out - solute_in
  {
    stream: name,
    carrier_in,
    carrier_out,
    solvent_in,
    solvent_out,
    solute_in,
    solute_out,
    carrier_error,
    solvent_error,
    solute_error,
    closed: carrier_error.abs() <= tolerance &&
    solvent_error.abs() <= tolerance &&
    solute_error.abs() <= tolerance,
  }
}

///|
pub fn balance_ledger(
  entries : ArrayView[StreamBalance],
  tolerance : Double,
) -> BalanceLedger {
  let mut carrier = 0.0
  let mut solvent = 0.0
  let mut solute = 0.0
  let mut maximum = 0.0
  let mut closed = true
  for entry in entries {
    carrier += entry.carrier_error
    solvent += entry.solvent_error
    solute += entry.solute_error
    let relative = entry.solute_error.abs() / entry.solute_in.max(1.0e-12)
    maximum = maximum.max(relative)
    if !entry.closed {
      closed = false
    }
  }
  {
    entries: entries.to_owned(),
    total_carrier_error: carrier,
    total_solvent_error: solvent,
    total_solute_error: solute,
    maximum_relative_error: maximum,
    closed: closed &&
    carrier.abs() <= tolerance &&
    solvent.abs() <= tolerance &&
    solute.abs() <= tolerance,
  }
}

///|
pub fn component_ledger(
  component : String,
  feed : Double,
  product : Double,
  waste : Double,
  tolerance : Double,
) -> ComponentLedger raise @domain.ExtractionError {
  if feed < 0.0 || product < 0.0 || waste < 0.0 {
    raise @domain.ExtractionError::InvalidArgument(
      message="component ledger values cannot be negative",
    )
  }
  let recovered = product + waste
  let closure = recovered - feed
  {
    component,
    feed,
    product,
    waste,
    recovered,
    recovery: if feed == 0.0 {
      0.0
    } else {
      product / feed
    },
    closure_error: closure.abs().max(tolerance),
  }
}

///|
pub fn ledgers_reconciled(
  ledgers : ArrayView[ComponentLedger],
  tolerance : Double,
) -> Bool {
  for ledger in ledgers {
    if ledger.closure_error > tolerance {
      return false
    }
  }
  true
}

///|
pub fn total_feed(ledgers : ArrayView[ComponentLedger]) -> Double {
  ledgers.fold(init=0.0, (sum, ledger) => sum + ledger.feed)
}

///|
pub fn total_product(ledgers : ArrayView[ComponentLedger]) -> Double {
  ledgers.fold(init=0.0, (sum, ledger) => sum + ledger.product)
}

///|
pub fn overall_recovery(ledgers : ArrayView[ComponentLedger]) -> Double {
  let feed = total_feed(ledgers)
  if feed == 0.0 {
    0.0
  } else {
    total_product(ledgers) / feed
  }
}

///|
pub fn split_stream(
  stream : ProcessStream,
  fraction : Double,
  first_name : String,
  second_name : String,
) -> (ProcessStream, ProcessStream) raise @domain.ExtractionError {
  if fraction < 0.0 || fraction > 1.0 {
    raise @domain.ExtractionError::InvalidFraction(
      name="split fraction",
      value=fraction,
    )
  }
  let first = ProcessStream::new(
    name=first_name,
    carrier_flow=stream.carrier_flow * fraction,
    solvent_flow=stream.solvent_flow * fraction,
    carrier_concentration=stream.carrier_concentration,
    solvent_concentration=stream.solvent_concentration,
    temperature=stream.temperature,
    pressure=stream.pressure,
  )
  let second = ProcessStream::new(
    name=second_name,
    carrier_flow=stream.carrier_flow * (1.0 - fraction),
    solvent_flow=stream.solvent_flow * (1.0 - fraction),
    carrier_concentration=stream.carrier_concentration,
    solvent_concentration=stream.solvent_concentration,
    temperature=stream.temperature,
    pressure=stream.pressure,
  )
  (first, second)
}

///|
pub fn merge_streams(
  first : ProcessStream,
  second : ProcessStream,
  name : String,
) -> ProcessStream raise @domain.ExtractionError {
  let carrier = first.carrier_flow + second.carrier_flow
  let solvent = first.solvent_flow + second.solvent_flow
  let carrier_solute = first.carrier_solute() + second.carrier_solute()
  let solvent_solute = first.solvent_solute() + second.solvent_solute()
  ProcessStream::new(
    name~,
    carrier_flow=carrier,
    solvent_flow=solvent,
    carrier_concentration=if carrier == 0.0 {
      0.0
    } else {
      carrier_solute / carrier
    },
    solvent_concentration=if solvent == 0.0 {
      0.0
    } else {
      solvent_solute / solvent
    },
    temperature=first.temperature,
    pressure=first.pressure,
  )
}

///|
pub fn concentration_after_recovery(
  feed : Double,
  recovery : Double,
) -> Double raise @domain.ExtractionError {
  if feed < 0.0 || recovery < 0.0 || recovery > 1.0 {
    raise @domain.ExtractionError::InvalidArgument(
      message="recovery calculation inputs are invalid",
    )
  }
  feed * recovery
}

///|
pub fn cascade_recovery(
  stage_recoveries : ArrayView[Double],
) -> Double raise @domain.ExtractionError {
  let mut remaining = 1.0
  for recovery in stage_recoveries {
    if recovery < 0.0 || recovery > 1.0 {
      raise @domain.ExtractionError::InvalidFraction(
        name="stage recovery",
        value=recovery,
      )
    }
    remaining *= 1.0 - recovery
  }
  1.0 - remaining
}

///|
pub fn required_recovery_per_stage(
  target_recovery : Double,
  stage_count : Int,
) -> Double raise @domain.ExtractionError {
  if target_recovery < 0.0 || target_recovery > 1.0 || stage_count < 1 {
    raise @domain.ExtractionError::InvalidArgument(
      message="stage recovery target is invalid",
    )
  }
  let mut remaining = 1.0 - target_recovery
  for _ in 0.. String {
  let lines = [
    "| Stream | Carrier error | Solvent error | Solute error | Closed |", "|---|---:|---:|---:|---|",
  ]
  for entry in ledger.entries {
    lines.push(
      "| " +
      entry.stream +
      " | " +
      entry.carrier_error.to_string() +
      " | " +
      entry.solvent_error.to_string() +
      " | " +
      entry.solute_error.to_string() +
      " | " +
      entry.closed.to_string() +
      " |",
    )
  }
  lines.join("\n")
}

///|
pub fn component_ledger_csv(ledgers : ArrayView[ComponentLedger]) -> String {
  let lines = ["component,feed,product,waste,recovered,recovery,closure_error"]
  for ledger in ledgers {
    lines.push(
      ledger.component +
      "," +
      ledger.feed.to_string() +
      "," +
      ledger.product.to_string() +
      "," +
      ledger.waste.to_string() +
      "," +
      ledger.recovered.to_string() +
      "," +
      ledger.recovery.to_string() +
      "," +
      ledger.closure_error.to_string(),
    )
  }
  lines.join("\n")
}

///|
pub fn balance_error_vector(ledger : BalanceLedger) -> Array[Double] {
  [
    ledger.total_carrier_error,
    ledger.total_solvent_error,
    ledger.total_solute_error,
    ledger.maximum_relative_error,
  ]
}

///|
pub fn stream_concentration_ratio(stream : ProcessStream) -> Double {
  if stream.carrier_concentration == 0.0 {
    0.0
  } else {
    stream.solvent_concentration / stream.carrier_concentration
  }
}

///|
pub fn stream_solute_fraction(stream : ProcessStream) -> Double {
  let total = stream.total_solute()
  if total == 0.0 {
    0.0
  } else {
    stream.carrier_solute() / total
  }
}

///|
pub fn stream_quality_label(stream : ProcessStream) -> String {
  let ratio = stream_concentration_ratio(stream)
  if ratio <= 0.01 {
    "lean"
  } else if ratio <= 0.2 {
    "normal"
  } else {
    "rich"
  }
}