///|
pub(all) enum Step {
  Asls(Double, IterationOptions)
  Arpls(IterationOptions)
  Snip(Int32, Bool, Bool)
  PolynomialBaseline(Array[Bool], Int32)
  LocalPolynomial(Int32, Int32, Int32)
  Median(Int32)
  Snv
  UnitVector
  RangeNormalize
  Scatter(ScatterModel)
  Affine(Double, Double)
  Crop(Double, Double)
  Resample(Array[Double])
  Absorbance(Bool)
  Transmittance(Bool)
}

///|
pub struct Pipeline {
  steps : Array[Step]
  maximum_work : Double
  require_convergence : Bool
}

///|
pub(all) struct StepReport {
  name : String
  input_samples : Int32
  output_samples : Int32
  rms_before : Double
  rms_after : Double
  iterations : Int32?
  converged : Bool?
  relative_change : Double?
}

///|
pub(all) struct PipelineResult {
  spectrum : Spectrum
  trace : Array[StepReport]
  estimated_work : Double
}

///|
/// Copy configuration arrays at construction. The budget is a conservative
/// operation estimate, not wall-clock time or a cancellation guarantee.
pub fn pipeline(
  steps : Array[Step],
  maximum_work~ : Double = 100000000.0,
  require_convergence~ : Bool = true,
) -> Pipeline raise SpectralError {
  if steps.length() < 1 || steps.length() > 32 {
    raise Budget("pipeline needs 1..32 steps")
  }
  finite(maximum_work, "pipeline work budget")
  if maximum_work < 1.0 || maximum_work > 1000000000.0 {
    raise Budget("pipeline work budget outside [1,1e9]")
  }
  let owned = steps.map(fn(step) {
    match step {
      Resample(axis) => Resample(axis.copy())
      PolynomialBaseline(mask, degree) =>
        PolynomialBaseline(mask.copy(), degree)
      other => other
    }
  })
  { steps: owned, maximum_work, require_convergence, }
}

///|
fn step_name(step : Step) -> String {
  match step {
    Asls(_, _) => "asls"
    Arpls(_) => "arpls"
    Snip(_, _, _) => "snip"
    PolynomialBaseline(_, _) => "polynomial_baseline"
    LocalPolynomial(_, _, _) => "local_polynomial"
    Median(_) => "median"
    Snv => "snv"
    UnitVector => "unit_vector"
    RangeNormalize => "range_normalize"
    Scatter(_) => "msc"
    Affine(_, _) => "affine"
    Crop(_, _) => "crop"
    Resample(_) => "resample"
    Absorbance(_) => "absorbance"
    Transmittance(_) => "transmittance"
  }
}

///|
fn estimate_work(step : Step, count : Int32) -> Double raise SpectralError {
  let n = count.to_double()
  match step {
    Asls(_, options) | Arpls(options) => {
      validate_iteration_options(options)
      100.0 * n * options.max_iterations.to_double()
    }
    Snip(window, _, _) => {
      if window < 1 || window > 2048 {
        raise Budget("SNIP window outside budget")
      }
      20.0 * n * window.to_double()
    }
    LocalPolynomial(window, degree, _) => {
      if window < 3 || window > 101 || degree < 0 || degree > 8 {
        raise Budget("local fit outside budget")
      }
      let p = (degree + 1).to_double()
      10.0 * n * window.to_double() * p * p
    }
    PolynomialBaseline(_, degree) => {
      if degree < 0 || degree > 8 {
        raise Budget("polynomial degree outside budget")
      }
      let p = (degree + 1).to_double()
      10.0 * n * p * p
    }
    Median(window) => {
      if window < 3 || window > 101 {
        raise Budget("median window outside budget")
      }
      10.0 * n * window.to_double() * window.to_double()
    }
    Resample(axis) => {
      if axis.length() > 65536 {
        raise Budget("resampling axis outside budget")
      }
      20.0 * (n + axis.length().to_double())
    }
    _ => 100.0 * n
  }
}

///|
fn apply_step(
  step : Step,
  input : Spectrum,
) -> (Spectrum, BaselineResult?) raise SpectralError {
  match step {
    Asls(p, options) => {
      let fit = asls(input, asymmetry=p, options~)
      (input.with_values(fit.corrected), Some(fit))
    }
    Arpls(options) => {
      let fit = arpls(input, options~)
      (input.with_values(fit.corrected), Some(fit))
    }
    Snip(window, decreasing, transformed) => {
      let fit = snip(
        input,
        half_window=window,
        decreasing~,
        count_transform=transformed,
      )
      (input.with_values(fit.corrected), Some(fit))
    }
    PolynomialBaseline(mask, degree) => {
      let fit = masked_polynomial_baseline(input, mask, degree~)
      (input.with_values(fit.corrected), Some(fit))
    }
    LocalPolynomial(window, degree, order) =>
      (local_polynomial(input, window~, degree~, derivative=order), None)
    Median(window) => (median_filter(input, window~), None)
    Snv => (snv(input), None)
    UnitVector => (unit_vector(input), None)
    RangeNormalize => (range_normalize(input), None)
    Scatter(model) => (model.transform(input).spectrum, None)
    Affine(scale, offset) => (affine_intensity(input, scale, offset), None)
    Crop(left, right) => (crop(input, left, right), None)
    Resample(axis) => (resample(input, axis), None)
    Absorbance(percent) => (absorbance(input, percent~), None)
    Transmittance(percent) => (transmittance(input, percent~), None)
  }
}

///|
/// Run deterministically and return only a complete result. Original input
/// and configuration remain owned; each successful step adds compact metrics.
pub fn Pipeline::run(
  self : Pipeline,
  input : Spectrum,
) -> PipelineResult raise SpectralError {
  let mut current = input
  let trace : Array[StepReport] = []
  let mut work = 0.0
  for step in self.steps {
    work = work + estimate_work(step, current.length())
    if work > self.maximum_work {
      raise Budget("pipeline work estimate exceeds configured budget")
    }
    let before = root_mean_square(current.values)
    let input_samples = current.length()
    let (next, baseline) = apply_step(step, current)
    let (iterations, converged, change) = match baseline {
      Some(fit) => {
        if self.require_convergence && !fit.converged {
          raise Numerical("baseline step did not converge: " + step_name(step))
        }
        (Some(fit.iterations), Some(fit.converged), Some(fit.relative_change))
      }
      None => (None, None, None)
    }
    trace.push({
      name: step_name(step),
      input_samples,
      output_samples: next.length(),
      rms_before: before,
      rms_after: root_mean_square(next.values),
      iterations,
      converged,
      relative_change: change,
    })
    current = next
  }
  { spectrum: current, trace, estimated_work: work, }
}