///|
/// A bounded one-dimensional search configuration.
pub(all) struct SearchConfig {
  minimum : Double
  maximum : Double
  steps : Int
} derive(Debug, ToJson)

///|
/// Construct a normalized search configuration.
pub fn SearchConfig::new(
  minimum~ : Double,
  maximum~ : Double,
  steps~ : Int,
) -> SearchConfig {
  {
    minimum: minimum.min(maximum),
    maximum: maximum.max(minimum),
    steps: steps.max(1),
  }
}

///|
/// Sample a closed interval uniformly.
pub fn uniform_grid(config : SearchConfig) -> Array[Double] {
  let result : Array[Double] = []
  for i in 0..<=config.steps {
    let fraction = Double::from_int(i) / Double::from_int(config.steps)
    result.push(config.minimum + fraction * (config.maximum - config.minimum))
  }
  result
}

///|
/// Evaluate a scalar objective over a uniform grid.
pub fn evaluate_grid(
  config : SearchConfig,
  objective : (Double) -> Double,
) -> Array[ObjectiveScore] {
  let result : Array[ObjectiveScore] = []
  for x in uniform_grid(config) {
    let score = objective(x)
    result.push({
      volume: x,
      conversion: 0.0,
      temperature: 0.0,
      score,
      feasible: true,
    })
  }
  result
}

///|
/// Return the minimizer of a scalar objective.
pub fn minimum_scalar(scores : ArrayView[ObjectiveScore]) -> ObjectiveScore? {
  let mut best : ObjectiveScore? = None
  for score in scores {
    match best {
      None => best = Some(score)
      Some(current) => if score.score < current.score { best = Some(score) }
    }
  }
  best
}

///|
/// Return the maximizer of a scalar objective.
pub fn maximum_scalar(scores : ArrayView[ObjectiveScore]) -> ObjectiveScore? {
  best_objective(scores)
}

///|
/// Penalize an infeasible objective without changing feasible ranking.
pub fn penalty_score(
  score : Double,
  feasible : Bool,
  penalty : Double,
) -> Double {
  if feasible {
    score
  } else {
    score - penalty.abs()
  }
}

///|
/// Pareto dominance for conversion, volume, and temperature.
pub fn dominates(a : ObjectiveScore, b : ObjectiveScore) -> Bool {
  let no_worse = a.conversion >= b.conversion &&
    a.volume <= b.volume &&
    a.temperature <= b.temperature
  let strictly_better = a.conversion > b.conversion ||
    a.volume < b.volume ||
    a.temperature < b.temperature
  no_worse && strictly_better
}

///|
/// Filter a set to its non-dominated frontier.
pub fn pareto_frontier(
  scores : ArrayView[ObjectiveScore],
) -> Array[ObjectiveScore] {
  let frontier : Array[ObjectiveScore] = []
  for candidate in scores {
    let mut dominated = false
    for other in scores {
      if dominates(other, candidate) {
        dominated = true
      }
    }
    if !dominated {
      frontier.push(candidate)
    }
  }
  frontier
}

///|
/// Compute a weighted distance from a target design.
pub fn target_distance(
  point : ObjectiveScore,
  target_conversion : Double,
  target_volume : Double,
  target_temperature : Double,
) -> Double {
  (point.conversion - target_conversion).abs() +
  (point.volume - target_volume).abs() +
  (point.temperature - target_temperature).abs()
}

///|
/// Select the closest target design.
pub fn closest_target(
  scores : ArrayView[ObjectiveScore],
  target_conversion : Double,
  target_volume : Double,
  target_temperature : Double,
) -> ObjectiveScore? {
  let mut best : ObjectiveScore? = None
  let mut best_distance = 1.0e30
  for score in scores {
    let distance = target_distance(
      score, target_conversion, target_volume, target_temperature,
    )
    if distance < best_distance {
      best = Some(score)
      best_distance = distance
    }
  }
  best
}

///|
/// Find the smallest volume satisfying a conversion target and temperature limit.
pub fn optimize_pfr_volume(
  reaction : Reaction,
  feed : Feed,
  config : SearchConfig,
  target_conversion : Double,
  maximum_temperature : Double,
) -> ObjectiveScore? {
  let points : Array[ObjectiveScore] = []
  for volume in uniform_grid(config) {
    let point = design_pfr(reaction, feed, volume)
    if point.conversion >= clamp_conversion(target_conversion) &&
      point.outlet_temperature <= maximum_temperature {
      points.push(score_design(point, 1.0, 0.0, maximum_temperature))
    }
  }
  if points.length() == 0 {
    None
  } else {
    let mut best = points[0]
    for point in points {
      if point.volume < best.volume {
        best = point
      }
    }
    Some(best)
  }
}

///|
/// Optimize a CSTR/PFR train count at fixed total volume.
pub fn optimize_train_count(
  reaction : Reaction,
  feed : Feed,
  total_volume : Double,
  minimum_stages : Int,
  maximum_stages : Int,
) -> ObjectiveScore? {
  let mut best : ObjectiveScore? = None
  for
    count in minimum_stages.min(maximum_stages)..<=maximum_stages.max(
      minimum_stages,
    ) {
    let stages = equal_volume_cstr_train(count, total_volume)
    let result = run_network(reaction, feed, stages)
    let point = {
      volume: total_volume,
      conversion: result.final_conversion,
      temperature: result.final_temperature,
      score: result.final_conversion,
      feasible: true,
    }
    match best {
      None => best = Some(point)
      Some(current) =>
        if point.conversion > current.conversion {
          best = Some(point)
        }
    }
  }
  best
}

///|
/// Search a reaction rate multiplier under a thermal limit.
pub fn optimize_rate_multiplier(
  reaction : Reaction,
  feed : Feed,
  volume : Double,
  config : SearchConfig,
  maximum_temperature : Double,
) -> ObjectiveScore? {
  let scores : Array[ObjectiveScore] = []
  for multiplier in uniform_grid(config) {
    let point = design_pfr(
      { ..reaction, k_ref: reaction.k_ref * multiplier.max(0.0) },
      feed,
      volume,
    )
    if point.outlet_temperature <= maximum_temperature {
      scores.push(score_design(point, 1.0, 0.0, maximum_temperature))
    }
  }
  best_objective(scores)
}

///|
/// Return the percentage improvement between two objective scores.
pub fn objective_improvement(
  reference : ObjectiveScore,
  candidate : ObjectiveScore,
) -> Double {
  if reference.score.abs() <= 1.0e-12 {
    0.0
  } else {
    (candidate.score - reference.score) / reference.score.abs()
  }
}

///|
/// Stable tie-breaking comparator for design scores.
pub fn score_precedes(a : ObjectiveScore, b : ObjectiveScore) -> Bool {
  if a.score != b.score {
    a.score > b.score
  } else {
    a.volume < b.volume
  }
}

///|
/// Select the first score under a maximum volume.
pub fn first_under_volume(
  scores : ArrayView[ObjectiveScore],
  maximum_volume : Double,
) -> ObjectiveScore? {
  for score in scores {
    if score.volume <= maximum_volume {
      return Some(score)
    }
  }
  None
}

///|
/// Select the last score over a minimum conversion.
pub fn last_over_conversion(
  scores : ArrayView[ObjectiveScore],
  minimum_conversion : Double,
) -> ObjectiveScore? {
  let mut result : ObjectiveScore? = None
  for score in scores {
    if score.conversion >= minimum_conversion {
      result = Some(score)
    }
  }
  result
}