///|
/// Exploratory delay suggestions from the observed series. Correlation uses
/// paired samples at each lag; mutual information uses fixed equal-width bins
/// over the complete series, so values are comparable across lags.
pub fn series_lag_hints(text : String) -> (Json, String) raise TopologyError {
  if text.length() > 1000000 {
    raise TopologyError("series hints request exceeds one million characters")
  }
  let value = @json.parse(text) catch {
    _ => raise TopologyError("invalid series hints JSON")
  }
  let object = match value {
    Object(fields) => fields
    _ => raise TopologyError("series hints 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_hints" {
    raise TopologyError("kind must be series_hints")
  }
  for field in object.keys() {
    if !["kind", "series", "max_lag", "bins"].contains(field) {
      raise TopologyError("unknown series hints field: " + field)
    }
  }
  let series : Array[Double] = @json.from_json(required_field(object, "series")) catch {
    _ => raise TopologyError("series must be an array of numbers")
  }
  if series.length() < 8 || series.length() > 10000 {
    raise TopologyError("series hints require 8..10000 samples")
  }
  let mut minimum = series[0]
  let mut maximum_value = series[0]
  for sample in series {
    if !finite(sample) || sample.abs() > 1.0e100 {
      raise TopologyError("time samples must be finite and bounded")
    }
    if sample < minimum {
      minimum = sample
    }
    if sample > maximum_value {
      maximum_value = sample
    }
  }
  if minimum == maximum_value {
    raise TopologyError("constant series has no identifiable delay")
  }
  let default_lag = if series.length() / 2 < 32 {
    series.length() / 2
  } else {
    32
  }
  let max_lag = integer_field(object, "max_lag", default_lag)
  if max_lag < 2 || max_lag > 128 || max_lag > series.length() / 2 {
    raise TopologyError(
      "max_lag must be 2..128 and no more than half the sample count",
    )
  }
  let bins = integer_field(object, "bins", 8)
  if bins < 2 || bins > 32 {
    raise TopologyError("bins must be 2..32")
  }
  let labels : Array[Int] = []
  for sample in series {
    let scaled = (sample - minimum) / (maximum_value - minimum)
    let mut label = (scaled * bins.to_double()).to_int()
    if label >= bins {
      label = bins - 1
    }
    labels.push(label)
  }
  let correlations : Array[Double] = []
  let informations : Array[Double] = []
  let rows : Array[Json] = []
  let mut csv = "lag,pair_count,autocorrelation,mutual_information_nats\n"
  for lag = 1; lag <= max_lag; lag = lag + 1 {
    let count = series.length() - lag
    let mut left_sum = 0.0
    let mut right_sum = 0.0
    let left_counts = Array::make(bins, 0)
    let right_counts = Array::make(bins, 0)
    let joint = Array::make(bins * bins, 0)
    for i = 0; i < count; i = i + 1 {
      left_sum += series[i]
      right_sum += series[i + lag]
      let a = labels[i]
      let b = labels[i + lag]
      left_counts[a] += 1
      right_counts[b] += 1
      joint[a * bins + b] += 1
    }
    let left_mean = left_sum / count.to_double()
    let right_mean = right_sum / count.to_double()
    let mut covariance = 0.0
    let mut left_variance = 0.0
    let mut right_variance = 0.0
    for i = 0; i < count; i = i + 1 {
      let a = series[i] - left_mean
      let b = series[i + lag] - right_mean
      covariance += a * b
      left_variance += a * a
      right_variance += b * b
    }
    let raw_correlation = if left_variance == 0.0 || right_variance == 0.0 {
      0.0
    } else {
      covariance / left_variance.sqrt() / right_variance.sqrt()
    }
    let correlation = if raw_correlation > 1.0 {
      1.0
    } else if raw_correlation < -1.0 {
      -1.0
    } else {
      raw_correlation
    }
    let mut information = 0.0
    for a = 0; a < bins; a = a + 1 {
      for b = 0; b < bins; b = b + 1 {
        let frequency = joint[a * bins + b]
        if frequency > 0 {
          let ratio = frequency.to_double() *
            count.to_double() /
            (left_counts[a].to_double() * right_counts[b].to_double())
          information += frequency.to_double() /
            count.to_double() *
            @math.ln(ratio)
        }
      }
    }
    correlations.push(correlation)
    informations.push(information)
    rows.push({
      "lag": lag,
      "pair_count": count,
      "autocorrelation": correlation,
      "mutual_information_nats": information,
    })
    csv += "\{lag},\{count},\{correlation},\{information}\n"
  }
  let mut correlation_lag = 0
  let correlation_limit = 1.0 / 2.718281828459045
  for i = 0; i < correlations.length(); i = i + 1 {
    if correlations[i] <= correlation_limit {
      correlation_lag = i + 1
      break
    }
  }
  let mut information_lag = 0
  for i = 1; i + 1 < informations.length(); i = i + 1 {
    if informations[i] < informations[i - 1] &&
      informations[i] <= informations[i + 1] {
      information_lag = i + 1
      break
    }
  }
  let hints : Array[Json] = []
  if correlation_lag > 0 {
    hints.push({
      "lag": correlation_lag,
      "method": "first autocorrelation at or below 1/e",
      "value": correlations[correlation_lag - 1],
    })
  }
  if information_lag > 0 {
    hints.push({
      "lag": information_lag,
      "method": "first strict local minimum of binned mutual information",
      "value": informations[information_lag - 1],
    })
  }
  (
    {
      "schema_version": 1,
      "kind": "series_hints",
      "sample_count": series.length(),
      "max_lag": max_lag,
      "bins": bins,
      "rows": rows,
      "suggestions": hints,
      "interpretation": "exploratory delay candidates only; no period inference or embedding-dimension guarantee; an empty suggestion list means no crossing or local minimum in the scanned range",
    },
    csv,
  )
}