///|
/// Batch execution primitives for repeatable chemical-process campaigns.
pub struct BatchCharge {
  material : String
  mass_kg : Float
  purity : Float
}

///|
pub fn batch_charge(
  material : String,
  mass_kg : Float,
  purity : Float,
) -> BatchCharge {
  { material, mass_kg, purity }
}

///|
pub fn BatchCharge::active_mass(self : BatchCharge) -> Float {
  self.mass_kg * self.purity
}

///|
pub fn BatchCharge::valid(self : BatchCharge) -> Bool {
  self.mass_kg >= 0.0 && self.purity >= 0.0 && self.purity <= 1.0
}

///|
pub struct BatchRecipe {
  name : String
  target_kg : Float
  charges : Array[BatchCharge]
  hold_hours : Float
  yield_fraction : Float
}

///|
pub fn batch_recipe(
  name : String,
  target_kg : Float,
  charges : Array[BatchCharge],
  hold_hours : Float,
  yield_fraction : Float,
) -> BatchRecipe {
  { name, target_kg, charges, hold_hours, yield_fraction }
}

///|
pub fn BatchRecipe::input_mass(self : BatchRecipe) -> Float {
  self.charges.fold(init=0.0, fn(total, charge) { total + charge.mass_kg })
}

///|
pub fn BatchRecipe::active_input(self : BatchRecipe) -> Float {
  self.charges.fold(init=0.0, fn(total, charge) { total + charge.active_mass() })
}

///|
pub fn BatchRecipe::expected_output(self : BatchRecipe) -> Float {
  self.active_input() * self.yield_fraction
}

///|
pub fn BatchRecipe::conversion(self : BatchRecipe) -> Float {
  if self.active_input() == 0.0 {
    0.0
  } else {
    self.expected_output() / self.active_input()
  }
}

///|
pub fn BatchRecipe::valid(self : BatchRecipe) -> Bool {
  self.target_kg >= 0.0 &&
  self.hold_hours >= 0.0 &&
  self.yield_fraction >= 0.0 &&
  self.yield_fraction <= 1.0 &&
  self.charges.all(fn(c) { c.valid() })
}

///|
pub struct BatchStep {
  name : String
  duration_hours : Float
  temperature_c : Float
  pressure_bar : Float
  agitation_rpm : Float
}

///|
pub fn batch_step(
  name : String,
  duration_hours : Float,
  temperature_c : Float,
  pressure_bar : Float,
  agitation_rpm : Float,
) -> BatchStep {
  { name, duration_hours, temperature_c, pressure_bar, agitation_rpm }
}

///|
pub fn BatchStep::safe(
  self : BatchStep,
  max_temperature : Float,
  max_pressure : Float,
) -> Bool {
  self.duration_hours >= 0.0 &&
  self.temperature_c <= max_temperature &&
  self.pressure_bar <= max_pressure &&
  self.pressure_bar >= 0.0
}

///|
pub struct BatchPlan {
  recipe : BatchRecipe
  steps : Array[BatchStep]
  cleaning_hours : Float
  setup_hours : Float
}

///|
pub fn batch_plan(
  recipe : BatchRecipe,
  steps : Array[BatchStep],
  cleaning_hours : Float,
  setup_hours : Float,
) -> BatchPlan {
  { recipe, steps, cleaning_hours, setup_hours }
}

///|
pub fn BatchPlan::process_hours(self : BatchPlan) -> Float {
  self.steps.fold(init=0.0, fn(total, step) { total + step.duration_hours })
}

///|
pub fn BatchPlan::cycle_hours(self : BatchPlan) -> Float {
  self.process_hours() + self.cleaning_hours + self.setup_hours
}

///|
pub fn BatchPlan::throughput(self : BatchPlan) -> Float {
  if self.cycle_hours() == 0.0 {
    0.0
  } else {
    self.recipe.expected_output() / self.cycle_hours()
  }
}

///|
pub fn BatchPlan::safe(
  self : BatchPlan,
  max_temperature : Float,
  max_pressure : Float,
) -> Bool {
  self.recipe.valid() &&
  self.cleaning_hours >= 0.0 &&
  self.setup_hours >= 0.0 &&
  self.steps.all(fn(s) { s.safe(max_temperature, max_pressure) })
}

///|
pub struct BatchRecord {
  id : String
  plan_name : String
  started_at : String
  finished_at : String
  output_kg : Float
  scrap_kg : Float
  accepted : Bool
}

///|
pub fn batch_record(
  id : String,
  plan_name : String,
  started_at : String,
  finished_at : String,
  output_kg : Float,
  scrap_kg : Float,
  accepted : Bool,
) -> BatchRecord {
  { id, plan_name, started_at, finished_at, output_kg, scrap_kg, accepted }
}

///|
pub fn BatchRecord::total_mass(self : BatchRecord) -> Float {
  self.output_kg + self.scrap_kg
}

///|
pub fn BatchRecord::yield_rate(self : BatchRecord, input_kg : Float) -> Float {
  if input_kg == 0.0 {
    0.0
  } else {
    self.output_kg / input_kg
  }
}

///|
pub fn BatchRecord::scrap_rate(self : BatchRecord) -> Float {
  if self.total_mass() == 0.0 {
    0.0
  } else {
    self.scrap_kg / self.total_mass()
  }
}

///|
pub fn batch_records_total(records : Array[BatchRecord]) -> Float {
  records.fold(init=0.0, fn(total, record) { total + record.output_kg })
}

///|
pub fn batch_records_acceptance(records : Array[BatchRecord]) -> Float {
  if records.length() == 0 {
    0.0
  } else {
    Float::from_int(records.filter(fn(r) { r.accepted }).length()) /
    Float::from_int(records.length())
  }
}

///|
pub fn batch_records_table(records : Array[BatchRecord]) -> ReportTable {
  let rows : Array[Array[String]] = []
  for record in records {
    rows.push([
      record.id,
      record.plan_name,
      record.started_at,
      record.finished_at,
      "\{record.output_kg}",
      "\{record.scrap_kg}",
      "\{record.accepted}",
    ])
  }
  table(
    ["id", "plan", "started", "finished", "output kg", "scrap kg", "accepted"],
    rows,
  )
}

///|
pub fn batch_scale(plan : BatchPlan, factor : Float) -> BatchPlan {
  let scaled = plan.steps.map(fn(step) {
    batch_step(
      step.name,
      step.duration_hours,
      step.temperature_c,
      step.pressure_bar,
      step.agitation_rpm,
    )
  })
  let charges = plan.recipe.charges.map(fn(c) {
    batch_charge(c.material, c.mass_kg * factor, c.purity)
  })
  {
    recipe: batch_recipe(
      plan.recipe.name,
      plan.recipe.target_kg * factor,
      charges,
      plan.recipe.hold_hours,
      plan.recipe.yield_fraction,
    ),
    steps: scaled,
    cleaning_hours: plan.cleaning_hours,
    setup_hours: plan.setup_hours,
  }
}

///|
pub fn batch_campaign_yield(
  records : Array[BatchRecord],
  input_kg : Float,
) -> Float {
  if input_kg == 0.0 {
    0.0
  } else {
    batch_records_total(records) / input_kg
  }
}

///|
pub fn batch_campaign_scrap(records : Array[BatchRecord]) -> Float {
  let total : Float = records.fold(init=0.0, fn(v, r) { v + r.total_mass() })
  if total == 0.0 {
    0.0
  } else {
    let scrap : Float = records.fold(init=0.0, fn(v, r) { v + r.scrap_kg })
    scrap / total
  }
}