///|
/// Versioned data interchange. Units and provenance are caller-managed;
/// this schema deliberately serializes measured coordinates and values only.
pub fn Spectrum::to_json(self : Spectrum) -> Json {
  Json::object({
    "schema": Json::number(1.0),
    "axis": @json.to_json(self.axis),
    "values": @json.to_json(self.values),
  })
}

///|
fn require_keys(
  value : Json,
  keys : Array[String],
) -> Map[String, Json] raise SpectralError {
  let fields = match value {
    Json::Object(fields) => fields
    _ => raise Invalid("JSON object required")
  }
  if fields.length() != keys.length() {
    raise Invalid("JSON fields differ from schema")
  }
  for key in keys {
    if !fields.contains(key) {
      raise Invalid("missing JSON field: " + key)
    }
  }
  match fields.get("schema") {
    Some(schema) =>
      if !(schema is Json::Number(1.0, ..)) {
        raise Invalid("unsupported JSON schema version")
      }
    None => raise Invalid("missing JSON schema")
  }
  fields
}

///|
fn required_field(
  fields : Map[String, Json],
  key : String,
) -> Json raise SpectralError {
  match fields.get(key) {
    Some(value) => value
    None => raise Invalid("missing JSON field: " + key)
  }
}

///|
/// Validate lengths before allocating a second numeric copy; then use the
/// same spectrum constructor invariants as direct API input.
pub fn spectrum_from_json(value : Json) -> Spectrum raise SpectralError {
  let fields = require_keys(value, ["schema", "axis", "values"])
  let x = match required_field(fields, "axis") {
    Json::Array(items) => items
    _ => raise Invalid("axis must be a JSON array")
  }
  let y = match required_field(fields, "values") {
    Json::Array(items) => items
    _ => raise Invalid("values must be a JSON array")
  }
  if x.length() != y.length() {
    raise Invalid("JSON array lengths differ")
  }
  if x.length() < 3 || x.length() > 65536 {
    raise Budget("JSON spectrum needs 3..65536 samples")
  }
  let axis = Array::make(x.length(), 0.0)
  let values = Array::make(y.length(), 0.0)
  for i in 0.. v
      _ => raise Invalid("axis item must be numeric")
    }
    values[i] = match y[i] {
      Json::Number(v, ..) => v
      _ => raise Invalid("value item must be numeric")
    }
  }
  spectrum(axis, values)
}

///|
/// Bounded parser for untrusted spectrum text; no I/O or filesystem access.
pub fn parse_spectrum(text : String) -> Spectrum raise SpectralError {
  if text.length() > 8388608 {
    raise Budget("JSON input exceeds 8 Mi UTF-16 code units")
  }
  let mut depth = 0
  let mut in_string = false
  let mut escaped = false
  for ch in text.iter() {
    if in_string {
      if escaped {
        escaped = false
      } else if ch == '\\' {
        escaped = true
      } else if ch == '"' {
        in_string = false
      }
    } else if ch == '"' {
      in_string = true
    } else if ch == '[' || ch == '{' {
      depth = depth + 1
      if depth > 8 {
        raise Budget("JSON nesting exceeds eight levels")
      }
    } else if ch == ']' || ch == '}' {
      depth = depth - 1
    }
  }
  let parsed = @json.parse(text) catch {
    _ => raise Invalid("malformed spectrum JSON")
  }
  spectrum_from_json(parsed)
}

///|
/// Persist only the frozen reference; derived regression statistics are
/// recomputed and validated on load rather than trusting serialized numbers.
pub fn ScatterModel::to_json(self : ScatterModel) -> Json {
  Json::object({
    "schema": Json::number(1.0),
    "reference": self.reference.to_json(),
  })
}

///|
pub fn scatter_model_from_json(
  value : Json,
) -> ScatterModel raise SpectralError {
  let fields = require_keys(value, ["schema", "reference"])
  scatter_reference(spectrum_from_json(required_field(fields, "reference")))
}

///|
fn optional_number(value : Double?) -> Json {
  match value {
    Some(v) => Json::number(v)
    None => Json::null()
  }
}

///|
fn optional_bool(value : Bool?) -> Json {
  match value {
    Some(v) => Json::boolean(v)
    None => Json::null()
  }
}

///|
/// Compact deterministic report; no intermediate spectra, environment paths,
/// account credentials, timestamps or private metadata are collected.
pub fn PipelineResult::to_json(self : PipelineResult) -> Json {
  let trace = self.trace.map(fn(item) {
    Json::object({
      "name": Json::string(item.name),
      "input_samples": Json::number(item.input_samples.to_double()),
      "output_samples": Json::number(item.output_samples.to_double()),
      "rms_before": Json::number(item.rms_before),
      "rms_after": Json::number(item.rms_after),
      "iterations": match item.iterations {
        Some(n) => Json::number(n.to_double())
        None => Json::null()
      },
      "converged": optional_bool(item.converged),
      "relative_change": optional_number(item.relative_change),
    })
  })
  Json::object({
    "schema": Json::number(1.0),
    "spectrum": self.spectrum.to_json(),
    "trace": Json::array(trace),
    "estimated_work": Json::number(self.estimated_work),
  })
}