///|
fn transformed(
  source : Spectrum,
  points : Array[Point],
  operation : Json,
  x_units~ : String = source.x_units,
  y_units~ : String = source.y_units,
) -> Spectrum raise JcampError {
  let mut records = remove_records(source.records, [
    "NPOINTS", "FIRSTX", "LASTX", "FIRSTY", "LASTY", "MINX", "MAXX", "MINY", "MAXY",
    "DELTAX", "XFACTOR", "YFACTOR", "DATACLASS",
  ])
  let history : Array[Json] = match
    record_value(records, "$MOONJCAMP.HISTORY") {
    None => []
    Some(text) => {
      let value = @json.parse(text[:], reject_duplicate_keys=true) catch {
        _ => reject("PROVENANCE_INVALID", "history must be a JSON array")
      }
      guard value is Array(items) && items.length() < 100 else {
        reject("PROVENANCE_LIMIT", "history must have fewer than 100 steps")
      }
      items.copy()
    }
  }
  history.push(operation)
  records = set_record(records, "$MOONJCAMP.HISTORY", Json(history).stringify())
  records = set_record(records, "XUNITS", x_units)
  records = set_record(records, "YUNITS", y_units)
  let result = { ..source, points, records, x_units, y_units, source_line: 0, }
  checked_spectrum(result)
  result
}

///|
pub fn crop(
  spectrum : Spectrum,
  minimum : Double,
  maximum : Double,
) -> Spectrum raise JcampError {
  checked_spectrum(spectrum)
  guard finite(minimum) && finite(maximum) && minimum <= maximum else {
    reject("CROP_RANGE", "crop bounds must be finite and ordered")
  }
  let points = spectrum.points.filter(p => p.x >= minimum && p.x <= maximum)
  guard !points.is_empty() else {
    reject("CROP_EMPTY", "crop selects no data points")
  }
  transformed(spectrum, points, {
    "operation": "crop",
    "minimum": minimum,
    "maximum": maximum,
  })
}

///|
fn axis_direction(points : Array[Point]) -> Int32 {
  if points.length() < 2 {
    return 0
  }
  let direction = if points[1].x > points[0].x {
    1
  } else if points[1].x < points[0].x {
    -1
  } else {
    0
  }
  if direction == 0 {
    return 0
  }
  for i = 1; i < points.length(); i = i + 1 {
    if (direction > 0 && points[i].x <= points[i - 1].x) ||
      (direction < 0 && points[i].x >= points[i - 1].x) {
      return 0
    }
  }
  direction
}

///|
pub fn resample(
  spectrum : Spectrum,
  start : Double,
  end : Double,
  count : Int32,
) -> Spectrum raise JcampError {
  checked_spectrum(spectrum)
  guard count >= 2 &&
    count <= Limits::default().max_points &&
    finite(start) &&
    finite(end) &&
    start != end else {
    reject(
      "RESAMPLE_GRID", "resampling requires a finite nonzero interval and 2..max_points samples",
    )
  }
  let direction = axis_direction(spectrum.points)
  guard direction != 0 else {
    reject(
      "RESAMPLE_AXIS", "resampling requires a strictly monotone axis with at least two points",
    )
  }
  let source = if direction > 0 {
    spectrum.points.copy()
  } else {
    spectrum.points.rev()
  }
  let low = if start < end { start } else { end }
  let high = if start > end { start } else { end }
  guard low >= source[0].x && high <= source[source.length() - 1].x else {
    reject(
      "RESAMPLE_EXTRAPOLATION", "resampling does not extrapolate beyond measured data",
    )
  }
  let step = (high - low) / (count - 1).to_double()
  guard finite(step) && step > 0.0 else {
    reject("RESAMPLE_GRID", "resampling step overflow or underflow")
  }
  let points : Array[Point] = []
  let mut cursor = 0
  for i = 0; i < count; i = i + 1 {
    let x = if i == count - 1 { high } else { low + i.to_double() * step }
    guard finite(x) && (points.is_empty() || x > points[points.length() - 1].x) else {
      reject("RESAMPLE_PRECISION", "grid points collapse at binary64 precision")
    }
    while cursor + 1 < source.length() - 1 && source[cursor + 1].x < x {
      cursor += 1
    }
    let left = source[cursor]
    let right = source[cursor + 1]
    let y = if x == left.x {
      left.y
    } else if x == right.x {
      right.y
    } else {
      match (left.y, right.y) {
        (Some(a), Some(b)) => {
          let denominator = right.x - left.x
          guard finite(denominator) else {
            reject("RESAMPLE_RANGE", "interpolation interval overflow")
          }
          let weight = (x - left.x) / denominator
          let value = a * (1.0 - weight) + b * weight
          guard finite(value) else {
            reject("RESAMPLE_RANGE", "interpolation overflow")
          }
          Some(value)
        }
        _ => None
      }
    }
    points.push({ x, y, })
  }
  let points = if start > end { points.rev() } else { points }
  transformed(spectrum, points, {
    "operation": "linear-resample",
    "start": start,
    "end": end,
    "count": count,
    "missing": "propagate",
    "extrapolate": false,
  })
}

///|
fn x_unit(unit : String) -> String raise JcampError {
  match canonical_label(unit) {
    "1/CM" | "CM1" | "CM^1" => "1/CM"
    "NM" | "NANOMETERS" => "NM"
    "UM" | "MICROMETERS" | "MICRONS" => "MICROMETERS"
    _ => reject("X_UNIT", "supported X units are 1/CM, NM and MICROMETERS")
  }
}

///|
pub fn convert_x(
  spectrum : Spectrum,
  target : String,
) -> Spectrum raise JcampError {
  checked_spectrum(spectrum)
  let from = x_unit(spectrum.x_units)
  let to = x_unit(target)
  let points : Array[Point] = []
  for p in spectrum.points {
    guard p.x > 0.0 else {
      reject("UNIT_DOMAIN", "wavelength and wavenumber must be positive")
    }
    let nm = match from {
      "1/CM" => 10000000.0 / p.x
      "MICROMETERS" => p.x * 1000.0
      _ => p.x
    }
    let x = if from == to {
      p.x
    } else {
      match to {
        "1/CM" => 10000000.0 / nm
        "MICROMETERS" => nm / 1000.0
        _ => nm
      }
    }
    guard finite(nm) && finite(x) && nm > 0.0 && x > 0.0 else {
      reject("UNIT_RANGE", "unit conversion overflow or underflow")
    }
    points.push({ ..p, x, })
  }
  transformed(
    spectrum,
    points,
    { "operation": "convert-x", "from": from, "to": to },
    x_units=to,
  )
}

///|
fn y_unit(unit : String) -> String raise JcampError {
  match canonical_label(unit) {
    "ABSORBANCE" => "ABSORBANCE"
    "TRANSMITTANCE" => "TRANSMITTANCE"
    "%TRANSMITTANCE" | "PERCENTTRANSMITTANCE" => "%TRANSMITTANCE"
    _ =>
      reject(
        "Y_UNIT", "supported Y units are ABSORBANCE, TRANSMITTANCE and %TRANSMITTANCE",
      )
  }
}

///|
pub fn convert_y(
  spectrum : Spectrum,
  target : String,
) -> Spectrum raise JcampError {
  checked_spectrum(spectrum)
  let from = y_unit(spectrum.y_units)
  let to = y_unit(target)
  let points : Array[Point] = []
  for p in spectrum.points {
    let y = match p.y {
      None => None
      Some(value) => {
        let transmission = match from {
          "ABSORBANCE" => {
            guard value >= 0.0 else {
              reject(
                "UNIT_DOMAIN", "negative absorbance requires an explicit physical policy outside this profile",
              )
            }
            @math.pow(10.0, -value)
          }
          "%TRANSMITTANCE" => value / 100.0
          _ => value
        }
        guard finite(transmission) && transmission > 0.0 && transmission <= 1.0 else {
          reject(
            "UNIT_DOMAIN", "transmittance must be in (0,1], or (0,100] percent",
          )
        }
        let result = if from == to {
          value
        } else {
          match to {
            "ABSORBANCE" => -@math.log10(transmission)
            "%TRANSMITTANCE" => transmission * 100.0
            _ => transmission
          }
        }
        guard finite(result) else {
          reject("UNIT_RANGE", "unit conversion produced a nonfinite value")
        }
        Some(result)
      }
    }
    points.push({ ..p, y, })
  }
  transformed(
    spectrum,
    points,
    { "operation": "convert-y", "from": from, "to": to },
    y_units=to,
  )
}