///|
/// Context shared by the two record grammars. Locations use ASCII columns.
pub(all) struct Location {
  format : @model.Format
  line : Int
  column_offset : Int
  record_type : Int?
} derive(Eq, Debug)

///|
/// Construct a source-bound failure, retaining structured parser context.
pub fn Location::error(
  self : Location,
  code : @model.ErrorCode,
  column : Int,
  message : String,
) -> @model.FirmwareError {
  @model.FirmwareError({
    code,
    format: self.format,
    line: self.line,
    column: column + self.column_offset,
    record_type: self.record_type,
    address: None,
    end_address: None,
    message,
  })
}

///|
/// Decode one ASCII digit without locale or Unicode lookalike acceptance.
pub fn hex_digit(code : UInt16) -> Int? {
  let n = code.to_int()
  if n >= 48 && n <= 57 {
    Some(n - 48)
  } else if n >= 65 && n <= 70 {
    Some(n - 55)
  } else if n >= 97 && n <= 102 {
    Some(n - 87)
  } else {
    None
  }
}

///|
/// Decode an exact even number of digits, reporting the first bad column.
pub fn decode_hex(
  text : String,
  start : Int,
  location : Location,
) -> Bytes raise @model.FirmwareError {
  if start < 0 || start > text.length() || (text.length() - start) % 2 != 0 {
    raise location.error(
      InvalidLength,
      text.length(),
      "odd number of hexadecimal digits",
    )
  }
  let bytes = []
  let mut i = start
  while i < text.length() {
    let high = match hex_digit(text[i]) {
      Some(n) => n
      None =>
        raise location.error(
          InvalidDigit,
          i + 1,
          "invalid hex digit '" + text[i:i + 1].to_owned() + "'",
        )
    }
    let low = match hex_digit(text[i + 1]) {
      Some(n) => n
      None =>
        raise location.error(
          InvalidDigit,
          i + 2,
          "invalid hex digit '" + text[i + 1:i + 2].to_owned() + "'",
        )
    }
    bytes.push((high * 16 + low).to_byte())
    i += 2
  }
  Bytes::from_array(bytes)
}

///|
/// Append one encoded byte. The alphabet is shared; checksums are not.
pub fn write_hex_byte(
  out : StringBuilder,
  byte : Byte,
  uppercase : Bool,
) -> Unit {
  let alphabet = if uppercase { "0123456789ABCDEF" } else { "0123456789abcdef" }
  let n = byte.to_int()
  out.write_stringview(alphabet[n / 16:n / 16 + 1])
  out.write_stringview(alphabet[n % 16:n % 16 + 1])
}

///|
/// Encode bytes as plain hexadecimal digits for record bodies and previews.
pub fn encode_hex(bytes : Bytes, uppercase? : Bool = true) -> String {
  let out = StringBuilder(size_hint=bytes.length() * 2)
  for b in bytes {
    write_hex_byte(out, b, uppercase)
  }
  out.to_string()
}

///|
/// Decode a big-endian address of 1..4 bytes from a validated record.
pub fn read_address(
  bytes : Bytes,
  offset : Int,
  width : Int,
) -> Int64 raise @model.FirmwareError {
  if width < 1 || width > 4 || offset < 0 || offset > bytes.length() - width {
    raise @model.FirmwareError(
      @model.diagnostic(InvalidLength, "address field is truncated"),
    )
  }
  let mut value = 0L
  for i in offset..<(offset + width) {
    value = value * 256L + bytes[i].to_int().to_int64()
  }
  value
}

///|
/// Serialize only addresses representable in the requested width.
pub fn address_bytes(
  value : Int64,
  width : Int,
) -> Bytes raise @model.FirmwareError {
  if width < 1 || width > 4 || value < 0L || value >= 1L << (width * 8) {
    raise @model.FirmwareError(
      @model.diagnostic(
        AddressOverflow,
        "address does not fit record width",
        address=value,
      ),
    )
  }
  Bytes::makei(width, i => ((value >> ((width - 1 - i) * 8)) & 255L).to_byte())
}

///|
/// Parse a decimal or 0x-prefixed unsigned address without overflow.
pub fn parse_number(
  text : String,
  max_value? : Int64 = 0xFFFFFFFFL,
) -> Int64 raise @model.FirmwareError {
  let hex = text.has_prefix("0x") || text.has_prefix("0X")
  let start = if hex { 2 } else { 0 }
  let radix = if hex { 16L } else { 10L }
  if start == text.length() || max_value < 0L {
    raise @model.FirmwareError(
      @model.diagnostic(
        InvalidOption,
        "expected unsigned decimal or 0x hexadecimal number",
      ),
    )
  }
  let mut number = 0L
  for i in start.. n.to_int64()
      _ =>
        raise @model.FirmwareError(
          @model.diagnostic(InvalidOption, "invalid digit in number: " + text),
        )
    }
    if digit > max_value || number > (max_value - digit) / radix {
      raise @model.FirmwareError(
        @model.diagnostic(
          AddressOverflow,
          "number exceeds allowed range: " + text,
        ),
      )
    }
    number = number * radix + digit
  }
  number
}

///|
/// Normalized line plus its original starting column after permissive trim.
pub(all) struct SourceLine {
  text : String
  location : Location
}

///|
/// Iterate bounded lines without splitting a large document into an array.
/// Permissive mode accepts ASCII outer whitespace, blank/comment lines and BOM.
/// Every tolerance creates a warning. LF and CRLF are supported in both modes.
pub fn for_each_line(
  text : String,
  format : @model.Format,
  options : @model.ParseOptions,
  warnings : Array[@model.Diagnostic],
  visit : (SourceLine) -> Unit raise @model.FirmwareError,
) -> Unit raise @model.FirmwareError {
  options.validate()
  if text.length() > options.max_text_length {
    raise @model.FirmwareError(
      @model.diagnostic(ResourceLimit, "document exceeds text limit"),
    )
  }
  let mut line = 1
  let mut start = 0
  let mut pos = 0
  while pos <= text.length() {
    if pos == text.length() || text[pos] == 10 {
      // A final newline is a delimiter, not an extra blank record.
      if start == text.length() {
        break
      }
      let mut end = pos
      if end > start && text[end - 1] == 13 {
        end -= 1
      }
      let loc : Location = {
        format,
        line,
        column_offset: 0,
        record_type: None,
      }
      if end - start > options.max_line_length {
        raise loc.error(
          ResourceLimit,
          1,
          "line exceeds configured length limit",
        )
      }
      let original = text[start:end].to_owned()
      let mut from = 0
      let mut until = original.length()
      if options.mode == Permissive {
        if line == 1 && until > 0 && original[0] == 0xFEFF {
          from = 1
        }
        while from < until && (original[from] == 32 || original[from] == 9) {
          from += 1
        }
        while until > from &&
              (original[until - 1] == 32 || original[until - 1] == 9) {
          until -= 1
        }
      }
      let body = original[from:until].to_owned()
      let skip = body.is_empty() || body.has_prefix(";") || body.has_prefix("#")
      if options.mode == Permissive &&
        (skip || from != 0 || until != original.length()) {
        let @model.FirmwareError(d) = loc.error(
          InvalidRecord,
          1,
          "accepted whitespace, BOM or comment in permissive mode",
        )
        warnings.push(d)
      }
      if !(options.mode == Permissive && skip) {
        visit({ text: body, location: { ..loc, column_offset: from, }, })
      }
      // Comments also consume diagnostic space even though they are not records.
      if warnings.length() > options.max_warnings {
        raise loc.error(ResourceLimit, 1, "document exceeds warning limit")
      }
      start = pos + 1
      line += 1
    } else if pos - start > options.max_line_length {
      let loc : Location = {
        format,
        line,
        column_offset: 0,
        record_type: None,
      }
      raise loc.error(ResourceLimit, 1, "line exceeds configured length limit")
    }
    pos += 1
  }
}

///|
/// Reattach source context to an address or overlap failure from the model.
pub fn locate_error(
  error : @model.FirmwareError,
  location : Location,
) -> @model.FirmwareError {
  let @model.FirmwareError(d) = error
  @model.FirmwareError({
    ..d,
    format: location.format,
    line: location.line,
    column: location.column_offset + 1,
    record_type: location.record_type,
  })
}