///|
fn check_headers(block : Block) -> Unit raise JcampError {
  let unique : Map[String, Bool] = Map([])
  for record in block.records {
    let key = canonical_label(record.label)
    if [
        "TITLE", "JCAMPDX", "DATATYPE", "DATACLASS", "NPOINTS", "FIRSTX", "LASTX",
        "FIRSTY", "DELTAX", "XFACTOR", "YFACTOR", "XUNITS", "YUNITS", "BLOCKS",
      ].contains(key) {
      guard !unique.contains(key) else {
        reject(
          "HEADER_DUPLICATE",
          "duplicate semantic label: " + key,
          line=record.line,
        )
      }
      unique[key] = true
    }
    if key == "NTUPLES" || key == "PEAKASSIGNMENTS" {
      reject(
        "UNSUPPORTED_TABLE",
        "this release does not interpret " + key,
        line=record.line,
      )
    }
  }
  guard block_title(block) != "" else {
    reject("TITLE_EMPTY", "TITLE must be nonempty", line=block.start_line)
  }
  let version = required_number(block.records, "JCAMP-DX", block.start_line)
  guard version == 4.24 || version == 5.0 || version == 5.01 else {
    reject(
      "UNSUPPORTED_VERSION",
      "supported declarations are 4.24, 5.00 and 5.01",
      line=block.start_line,
    )
  }
  guard block_kind(block) != "" else {
    reject("HEADER_MISSING", "missing DATA TYPE", line=block.start_line)
  }
}

///|
fn scaling(block : Block) -> (Double, Double) raise JcampError {
  let x = optional_number(block.records, "XFACTOR", 1.0, block.start_line)
  let y = optional_number(block.records, "YFACTOR", 1.0, block.start_line)
  guard x != 0.0 && y != 0.0 else {
    reject(
      "SCALE_ZERO",
      "XFACTOR and YFACTOR must be nonzero",
      line=block.start_line,
    )
  }
  (x, y)
}

///|
fn scaled(
  value : Double,
  factor : Double,
  line : Int32,
) -> Double raise JcampError {
  let result = value * factor
  guard finite(result) else {
    reject("SCALE_RANGE", "scaled value is not finite", line~)
  }
  result
}

///|
fn scaled_y(
  value : Double?,
  factor : Double,
  line : Int32,
) -> Double? raise JcampError {
  match value {
    Some(y) => Some(scaled(y, factor, line))
    None => None
  }
}

///|
fn matching_y(a : Double?, b : Double?, options : ParseOptions) -> Bool {
  match (a, b) {
    (Some(a), Some(b)) => close(a, b, options)
    (None, None) => true
    _ => false
  }
}

///|
fn table_options(table : DataTable, options : ParseOptions) -> ParseOptions {
  if options.numeric_mode != Auto {
    return options
  }
  for row in table.rows {
    let affn = try {
      ignore(numeric_tokens(strip_comment(row.text), Affn, options, row.line))
      true
    } catch {
      Invalid(_) => false
    }
    if !affn {
      return { ..options, numeric_mode: Asdf, }
    }
  }
  { ..options, numeric_mode: Affn, }
}

///|
fn incremental_points(
  block : Block,
  table : DataTable,
  options : ParseOptions,
) -> Array[Point] raise JcampError {
  let options = table_options(table, options)
  guard canonical_label(table.descriptor) == "(X++(Y..Y))" else {
    reject(
      "UNSUPPORTED_DESCRIPTOR",
      "XYDATA requires (X++(Y..Y))",
      line=table.line,
    )
  }
  let count = point_count(block.records, options, block.start_line)
  let first = required_number(block.records, "FIRSTX", block.start_line)
  let last = required_number(block.records, "LASTX", block.start_line)
  let step = if count == 1 {
    0.0
  } else {
    (last - first) / (count - 1).to_double()
  }
  guard finite(step) && (count == 1 || step != 0.0) else {
    reject(
      "AXIS_STEP",
      "incremental axis requires a finite nonzero step",
      line=table.line,
    )
  }
  if count == 1 && !close(first, last, options) {
    reject(
      "AXIS_ENDPOINT",
      "single-point FIRSTX and LASTX must agree",
      line=table.line,
    )
  }
  if record_value(block.records, "DELTAX") is Some(value) {
    let declared = number(value, line=table.line)
    if count > 1 && !close(declared, step, options) {
      reject(
        "AXIS_STEP",
        "DELTAX disagrees with endpoints and NPOINTS",
        line=table.line,
      )
    }
  }
  let (xfactor, yfactor) = scaling(block)
  guard options.limits.max_points < 2147483647 else {
    reject("OPTION_LIMIT", "point budget is too large")
  }
  let row_options = {
    ..options,
    limits: { ..options.limits, max_points: options.limits.max_points + 1, },
  }
  let points : Array[Point] = []
  let mut previous_difference = false
  for row in table.rows {
    let decoded = decode_line(row.text, row_options, row.line)
    guard decoded.values.length() >= 2 && decoded.first_ordinate_absolute else {
      reject(
        "ROW_ANCHOR",
        "each data row requires absolute X and Y anchors",
        line=row.line,
      )
    }
    guard decoded.values[0] is Some(raw_x) else {
      reject("X_MISSING", "X anchor cannot be missing", line=row.line)
    }
    let overlap = previous_difference && !points.is_empty()
    let index = if overlap { points.length() - 1 } else { points.length() }
    let expected = first + step * index.to_double()
    let actual = scaled(raw_x, xfactor, row.line)
    guard finite(expected) && close(actual, expected, options) else {
      reject(
        "X_CHECKPOINT",
        "row X anchor disagrees with reconstructed axis",
        line=row.line,
      )
    }
    if overlap {
      let checkpoint = scaled_y(decoded.values[1], yfactor, row.line)
      guard matching_y(checkpoint, points[points.length() - 1].y, options) else {
        reject(
          "Y_CHECKPOINT",
          "DIF checkpoint differs from the preceding terminal ordinate",
          line=row.line,
        )
      }
    }
    let mut i = if overlap { 2 } else { 1 }
    while i < decoded.values.length() {
      guard points.length() < count else {
        reject(
          "POINT_COUNT",
          "data table contains more values than NPOINTS",
          line=row.line,
        )
      }
      let x = first + step * points.length().to_double()
      guard finite(x) else {
        reject("AXIS_RANGE", "reconstructed axis overflow", line=row.line)
      }
      points.push({ x, y: scaled_y(decoded.values[i], yfactor, row.line), })
      i += 1
    }
    previous_difference = decoded.uses_difference
  }
  guard points.length() == count else {
    reject(
      "POINT_COUNT",
      "decoded point count differs from NPOINTS",
      line=table.line,
    )
  }
  points
}

///|
fn paired_points(
  block : Block,
  table : DataTable,
  options : ParseOptions,
) -> Array[Point] raise JcampError {
  let options = table_options(table, options)
  guard canonical_label(table.descriptor) == "(XY..XY)" else {
    reject(
      "UNSUPPORTED_DESCRIPTOR",
      "explicit point tables require (XY..XY)",
      line=table.line,
    )
  }
  let count = point_count(block.records, options, block.start_line)
  let (xfactor, yfactor) = scaling(block)
  let points : Array[Point] = []
  guard options.limits.max_points <= 1073741823 else {
    reject("OPTION_LIMIT", "point budget is too large")
  }
  let row_options = {
    ..options,
    limits: { ..options.limits, max_points: options.limits.max_points * 2, },
  }
  for row in table.rows {
    let decoded = decode_line(row.text, row_options, row.line)
    guard !decoded.uses_difference && decoded.values.length() % 2 == 0 else {
      reject(
        "PAIR_ARITY",
        "explicit pair rows require complete absolute X,Y pairs",
        line=row.line,
      )
    }
    let mut i = 0
    while i < decoded.values.length() {
      guard decoded.values[i] is Some(raw_x) else {
        reject("X_MISSING", "explicit X cannot be missing", line=row.line)
      }
      guard points.length() < count else {
        reject("POINT_COUNT", "more explicit pairs than NPOINTS", line=row.line)
      }
      points.push({
        x: scaled(raw_x, xfactor, row.line),
        y: scaled_y(decoded.values[i + 1], yfactor, row.line),
      })
      i += 2
    }
  }
  guard points.length() == count else {
    reject(
      "POINT_COUNT",
      "explicit pair count differs from NPOINTS",
      line=table.line,
    )
  }
  points
}

///|
fn check_endpoints(
  block : Block,
  points : Array[Point],
  options : ParseOptions,
) -> Unit raise JcampError {
  if record_value(block.records, "FIRSTX") is Some(value) {
    guard close(number(value, line=block.start_line), points[0].x, options) else {
      reject(
        "AXIS_ENDPOINT",
        "FIRSTX disagrees with first data point",
        line=block.start_line,
      )
    }
  }
  if record_value(block.records, "LASTX") is Some(value) {
    guard close(
      number(value, line=block.start_line),
      points[points.length() - 1].x,
      options,
    ) else {
      reject(
        "AXIS_ENDPOINT",
        "LASTX disagrees with last data point",
        line=block.start_line,
      )
    }
  }
  if record_value(block.records, "FIRSTY") is Some(value) {
    guard points[0].y is Some(y) &&
      close(number(value, line=block.start_line), y, options) else {
      reject(
        "Y_ENDPOINT",
        "FIRSTY disagrees with first data point",
        line=block.start_line,
      )
    }
  }
}

///|
pub fn spectrum_from_block(
  block : Block,
  options~ : ParseOptions = ParseOptions::default(),
) -> Spectrum raise JcampError {
  check_options(options)
  check_headers(block)
  guard block.children.is_empty() &&
    canonical_label(block_kind(block)) != "LINK" else {
    reject(
      "SPECTRUM_EXPECTED",
      "a compound block is not a single spectrum",
      line=block.start_line,
    )
  }
  guard block.tables.length() == 1 else {
    reject(
      "TABLE_COUNT",
      "a spectrum requires exactly one supported data table",
      line=block.start_line,
    )
  }
  guard canonical_label(block_kind(block)).has_suffix("SPECTRUM") ||
    canonical_label(block_kind(block)).contains("PEAK") else {
    reject(
      "UNSUPPORTED_DATA_TYPE",
      "only one-dimensional real spectra and peak tables are interpreted",
      line=block.start_line,
    )
  }
  let table = block.tables[0]
  let key = canonical_label(table.label)
  if record_value(block.records, "DATA CLASS") is Some(data_class) {
    guard canonical_label(data_class) == key else {
      reject(
        "DATA_CLASS",
        "DATA CLASS differs from table label",
        line=table.line,
      )
    }
  }
  let x_units = record_value(block.records, "XUNITS").unwrap_or("")
  let y_units = record_value(block.records, "YUNITS").unwrap_or("")
  guard trim(x_units) != "" && trim(y_units) != "" else {
    reject(
      "HEADER_UNITS",
      "nonempty XUNITS and YUNITS are required",
      line=block.start_line,
    )
  }
  let points = match key {
    "XYDATA" => incremental_points(block, table, options)
    "XYPOINTS" | "XYPAIRS" | "PEAKTABLE" => paired_points(block, table, options)
    _ =>
      reject(
        "UNSUPPORTED_TABLE",
        "unsupported data table: " + table.label,
        line=table.line,
      )
  }
  check_endpoints(block, points, options)
  {
    title: block_title(block),
    data_type: block_kind(block),
    x_units,
    y_units,
    points,
    records: block.records.copy(),
    source_line: block.start_line,
  }
}

///|
pub fn spectra_from_document(
  document : Document,
  options~ : ParseOptions = ParseOptions::default(),
) -> Array[Spectrum] raise JcampError {
  check_options(options)
  let result : Array[Spectrum] = []
  let mut total_points = 0
  let mut total_blocks = 0
  fn visit(block : Block, depth : Int32) -> Unit raise JcampError {
    guard depth <= options.limits.max_depth else {
      reject(
        "LIMIT_DEPTH",
        "compound nesting exceeds budget",
        line=block.start_line,
      )
    }
    total_blocks += 1
    guard total_blocks <= options.limits.max_blocks else {
      reject(
        "LIMIT_BLOCKS",
        "document block budget exceeded",
        line=block.start_line,
      )
    }
    if canonical_label(block_kind(block)) == "LINK" {
      check_headers(block)
      guard block.tables.is_empty() && !block.children.is_empty() else {
        reject(
          "LINK_CONTENT",
          "LINK requires child blocks and no numeric table",
          line=block.start_line,
        )
      }
      let declared = required_number(block.records, "BLOCKS", block.start_line)
      guard declared == block.children.length().to_double() else {
        reject(
          "LINK_COUNT",
          "BLOCKS differs from direct child count",
          line=block.start_line,
        )
      }
      for child in block.children {
        visit(child, depth + 1)
      }
    } else {
      let spectrum = spectrum_from_block(block, options~)
      guard spectrum.points.length() <= options.limits.max_points - total_points else {
        reject(
          "LIMIT_POINTS",
          "total document points exceed budget",
          line=block.start_line,
        )
      }
      total_points += spectrum.points.length()
      result.push(spectrum)
    }
  }
  for block in document.blocks {
    visit(block, 1)
  }
  guard !result.is_empty() else {
    reject("SPECTRUM_EMPTY", "document contains no interpreted spectra")
  }
  result
}

///|
pub fn read_spectra(
  text : String,
  options~ : ParseOptions = ParseOptions::default(),
) -> Array[Spectrum] raise JcampError {
  spectra_from_document(parse_document(text, options~), options~)
}

///|
pub fn inspect(
  text : String,
  options~ : ParseOptions = ParseOptions::default(),
) -> Report {
  try {
    let spectra = read_spectra(text, options~)
    let mut points = 0
    let mut missing = 0
    for spectrum in spectra {
      points += spectrum.points.length()
      missing += spectrum.points.filter(p => p.y is None).length()
    }
    {
      valid: true,
      spectra: spectra.length(),
      points,
      missing,
      diagnostics: [],
    }
  } catch {
    Invalid(diagnostic) =>
      {
        valid: false,
        spectra: 0,
        points: 0,
        missing: 0,
        diagnostics: [diagnostic],
      }
  }
}