///|
fn sweep_integer_list(
  object : Map[String, Json],
  name : String,
  lower : Int,
  upper : Int,
) -> Array[Int] raise TopologyError {
  let values : Array[Json] = @json.from_json(required_field(object, name)) catch {
    _ => raise TopologyError(name + " must be an array of integers")
  }
  if values.is_empty() || values.length() > 12 {
    raise TopologyError(name + " must contain 1..12 values")
  }
  let result : Array[Int] = []
  for value in values {
    let number = match value {
      Number(n, ..) => n
      _ => raise TopologyError(name + " must contain only integers")
    }
    if !finite(number) ||
      number < lower.to_double() ||
      number > upper.to_double() ||
      number != @math.floor(number) {
      raise TopologyError(name + " contains an out-of-range integer")
    }
    let item = number.to_int()
    if result.contains(item) {
      raise TopologyError(name + " contains a duplicate value")
    }
    result.push(item)
  }
  result
}

///|
/// Evaluate a bounded Cartesian sweep of delay-embedding parameters. Every
/// vector uses the same H1 landscape grid and excludes cutoff-censored bars.
/// The returned CSV tables share the JSON case order.
pub fn series_sweep(
  text : String,
) -> (Json, String, String) raise TopologyError {
  if text.length() > 1000000 {
    raise TopologyError("series sweep request exceeds one million characters")
  }
  let value = @json.parse(text) catch {
    _ => raise TopologyError("invalid series sweep JSON")
  }
  let object = match value {
    Object(fields) => fields
    _ => raise TopologyError("series sweep request must be an object")
  }
  let kind : String = @json.from_json(required_field(object, "kind")) catch {
    _ => raise TopologyError("kind must be a string")
  }
  if kind != "series_sweep" {
    raise TopologyError("kind must be series_sweep")
  }
  for field in object.keys() {
    if ![
        "kind", "series", "threshold", "dimensions", "lags", "stride", "max_cells",
      ].contains(field) {
      raise TopologyError("unknown series sweep field: " + field)
    }
  }
  let series : Array[Double] = @json.from_json(required_field(object, "series")) catch {
    _ => raise TopologyError("series must be an array of numbers")
  }
  let threshold = number_field(object, "threshold")
  if !finite(threshold) || threshold <= 0.0 || threshold > 1.0e100 {
    raise TopologyError("sweep threshold must be finite and positive")
  }
  let dimensions = sweep_integer_list(object, "dimensions", 1, 32)
  let lags = sweep_integer_list(object, "lags", 1, 10000)
  if dimensions.length() * lags.length() > 12 {
    raise TopologyError(
      "series sweep supports at most 12 parameter combinations",
    )
  }
  let stride = integer_field(object, "stride", 1)
  let budget = integer_field(object, "max_cells", 4000)
  let grid = landscape_grid(0.0, threshold, 101)
  let cases : Array[Json] = []
  let mut summary_csv = "dimension,lag,stride,point_count,cell_count,h1_finite_count,h1_alive_at_cutoff,longest_h1_observed_lifetime,h1_at_cutoff\n"
  let mut vector_csv = "dimension,lag,stride"
  for layer = 1; layer <= 3; layer = layer + 1 {
    for sample = 0; sample < 101; sample = sample + 1 {
      vector_csv += ",lambda_\{layer}_sample_\{sample}"
    }
  }
  vector_csv += "\n"
  let mut total_cells = 0
  for dimension in dimensions {
    for lag in lags {
      let points = delay_embed(series, dimension~, lag~, stride~)
      let analysis = analyze(rips(points, threshold~, max_cells=budget))
      let cell_count = analysis.filtration.cells.length()
      total_cells += cell_count
      if total_cells > 16000 {
        raise TopologyError("series sweep exceeds 16000 total filtration cells")
      }
      let mut finite_count = 0
      let mut alive_count = 0
      let mut longest_observed = 0.0
      for interval in analysis.intervals {
        if interval.dimension != 1 {
          continue
        }
        if interval.death is None {
          alive_count += 1
        } else {
          finite_count += 1
        }
        longest_observed = maximum(
          longest_observed,
          observed_lifetime(analysis, interval),
        )
      }
      let landscape = persistence_landscape(
        analysis,
        dimension=1,
        start=0.0,
        end=threshold,
      )
      let vector : Array[Double] = []
      for layer in landscape {
        for sample in layer {
          vector.push(sample)
        }
      }
      let h1_at_cutoff = betti(analysis, 1, threshold)
      cases.push({
        "dimension": dimension,
        "lag": lag,
        "stride": stride,
        "point_count": points.length(),
        "cell_count": cell_count,
        "h1_finite_count": finite_count,
        "h1_alive_at_cutoff": alive_count,
        "longest_h1_observed_lifetime": longest_observed,
        "h1_at_cutoff": h1_at_cutoff,
        "h1_landscape_vector": vector,
      })
      summary_csv += "\{dimension},\{lag},\{stride},\{points.length()},\{cell_count},\{finite_count},\{alive_count},\{longest_observed},\{h1_at_cutoff}\n"
      vector_csv += "\{dimension},\{lag},\{stride}"
      for sample in vector {
        vector_csv += ",\{sample}"
      }
      vector_csv += "\n"
    }
  }
  (
    {
      "schema_version": 1,
      "kind": "series_sweep",
      "threshold": threshold,
      "dimensions": dimensions,
      "lags": lags,
      "stride": stride,
      "case_count": cases.length(),
      "total_cells": total_cells,
      "landscape": {
        "homology_dimension": 1,
        "samples": 101,
        "layers": 3,
        "grid": grid,
        "vector_order": "layer-major, then increasing scale",
        "normalization": "none; raw tent heights",
        "censoring": "alive-at-cutoff intervals are excluded from landscape vectors",
      },
      "cases": cases,
      "interpretation": "parameter sensitivity only; dimensions and lags change the embedded point cloud and may change its point count",
    },
    summary_csv,
    vector_csv,
  )
}