///|
/// Parse a single S-record block. Strict mode enforces homogeneous data width,
/// one leading S0, at most one count, and a matching termination record.
pub fn parse_document(
  text : String,
  options? : @model.ParseOptions = @model.ParseOptions::default(),
) -> @model.FirmwareImage raise @model.FirmwareError {
  let memory = @model.MemoryMap::new()
  let warnings = []
  let counts = Array::make(10, 0)
  let mut header : Bytes? = None
  let mut entry : @model.EntryPoint? = None
  let mut data_kind = 0
  let mut data_count = 0
  let mut count_seen = false
  let mut terminated = false
  let mut total = 0
  let mut last_line = 0
  @codec.for_each_line(text, SRecord, options, warnings, source => {
    total += 1
    last_line = source.location.line
    if total > options.max_records {
      raise source.location.error(
        ResourceLimit,
        1,
        "record count limit exceeded",
      )
    }
    let record = parse_at(source.text, source.location)
    let loc = { ..source.location, record_type: Some(record.kind), }
    if terminated {
      raise loc.error(AfterTerminator, 1, "record after S-record termination")
    }
    counts[record.kind] += 1
    match record.kind {
      0 => {
        if total != 1 || header != None {
          raise loc.error(
            DuplicateRecord,
            1,
            "S0 must occur once at the beginning",
          )
        }
        header = Some(record.data)
      }
      1 | 2 | 3 => {
        if count_seen {
          raise loc.error(InvalidRecord, 1, "data record after S5/S6 count")
        }
        if data_kind != 0 && data_kind != record.kind {
          if options.mode == Strict {
            raise loc.error(
              InvalidRecord,
              2,
              "mixed S1/S2/S3 widths in one block",
            )
          }
          let @model.FirmwareError(d) = loc.error(
            InvalidRecord,
            2,
            "accepted mixed data address widths in permissive mode",
          )
          warnings.push(d)
        }
        data_kind = data_kind.max(record.kind)
        data_count += 1
        memory.insert(record.address, record.data, policy=options.overlap) catch {
          e => raise @codec.locate_error(e, loc)
        }
        if memory.payload_size() > options.max_payload {
          raise loc.error(ResourceLimit, 3, "payload limit exceeded")
        }
      }
      5 | 6 => {
        if count_seen {
          raise loc.error(DuplicateRecord, 1, "multiple S5/S6 count records")
        }
        if record.address != data_count.to_int64() {
          raise loc.error(
            CountMismatch,
            5,
            "declared \{record.address} data records but observed \{data_count}",
          )
        }
        count_seen = true
      }
      7 | 8 | 9 => {
        let expected = 10 - data_kind
        if data_kind != 0 && record.kind != expected {
          raise loc.error(
            InvalidRecord,
            2,
            "termination must be S\{expected} for the data address width",
          )
        }
        entry = Some(@model.EntryPoint::Linear(record.address))
        terminated = true
      }
      _ => ()
    }
  })
  if !terminated {
    let loc : @codec.Location = {
      format: SRecord,
      line: last_line + 1,
      column_offset: 0,
      record_type: None,
    }
    let error = loc.error(
      MissingTerminator,
      1,
      "missing S7/S8/S9 termination record",
    )
    if options.mode == Strict || total == 0 {
      raise error
    }
    let @model.FirmwareError(d) = error
    if warnings.length() >= options.max_warnings {
      raise loc.error(ResourceLimit, 1, "document exceeds warning limit")
    }
    warnings.push(d)
  }
  {
    memory,
    entry,
    warnings,
    metadata: {
      source_format: SRecord,
      record_counts: counts,
      header,
      checksum_status: Verified,
    },
  }
}

///|
/// Validate record checksums, addresses, overlap policy and block structure.
pub fn validate(
  text : String,
  options? : @model.ParseOptions = @model.ParseOptions::default(),
) -> Array[@model.Diagnostic] raise @model.FirmwareError {
  parse_document(text, options~).warnings
}