///|
/// Content detection is conservative. Printable text with no record signature
/// remains Unknown; a binary hint is needed for ambiguous printable raw bytes.
pub fn detect_format(bytes : Bytes) -> @model.Format {
  if bytes.is_empty() {
    return Unknown
  }
  let mut start = 0
  if bytes.length() >= 3 &&
    bytes[0] == b'\xEF' &&
    bytes[1] == b'\xBB' &&
    bytes[2] == b'\xBF' {
    start = 3
  }
  let limit = bytes.length().min(65536)
  let mut line_start = true
  for i in start..= 48 &&
        bytes[i + 1].to_int() <= 57 {
        return SRecord
      }
      if b == 35 || b == 59 {
        line_start = false
        continue
      }
      break
    } else if b == 10 {
      line_start = true
    }
  }
  for i in 0..= 128 {
      return RawBinary
    }
  }
  Unknown
}

///|
/// Extension hints are subordinate to content, and never suppress text errors.
pub fn format_from_path(path : String) -> @model.Format {
  let name = path.to_lower()
  if name.has_suffix(".hex") ||
    name.has_suffix(".ihx") ||
    name.has_suffix(".ihex") {
    IntelHex
  } else if name.has_suffix(".srec") ||
    name.has_suffix(".s19") ||
    name.has_suffix(".s28") ||
    name.has_suffix(".s37") ||
    name.has_suffix(".srecord") ||
    name.has_suffix(".mot") {
    SRecord
  } else if name.has_suffix(".bin") {
    RawBinary
  } else {
    Unknown
  }
}

///|
/// Byte-to-text boundary rejects non-ASCII, except a leading UTF-8 BOM which the
/// permissive line policy handles explicitly. Invalid UTF-8 is never replaced.
pub fn decode_text(
  bytes : Bytes,
  format : @model.Format,
  max_length? : Int = 256 * 1024 * 1024,
) -> String raise @model.FirmwareError {
  if max_length < 0 || bytes.length() > max_length {
    raise @model.FirmwareError(
      @model.diagnostic(ResourceLimit, "input text exceeds configured limit"),
    )
  }
  let out = StringBuilder(size_hint=bytes.length())
  let mut pos = 0
  let mut line = 1
  let mut column = 1
  if bytes.length() >= 3 &&
    bytes[0] == b'\xEF' &&
    bytes[1] == b'\xBB' &&
    bytes[2] == b'\xBF' {
    out.write_char('\uFEFF')
    pos = 3
    column = 2
  }
  while pos < bytes.length() {
    let n = bytes[pos].to_int()
    if n >= 128 || n == 0 {
      let loc : @codec.Location = {
        format,
        line,
        column_offset: 0,
        record_type: None,
      }
      raise loc.error(
        InvalidDigit,
        column,
        "firmware text must contain ASCII bytes",
      )
    }
    // n has been bounded to ASCII above, so conversion cannot fail.
    out.write_char(n.to_char().unwrap())
    if n == 10 {
      line += 1
      column = 1
    } else {
      column += 1
    }
    pos += 1
  }
  out.to_string()
}

///|
/// Load bytes with optional format hint and explicit raw binary placement.
/// Record signatures take precedence over hints, including a misleading .bin.
pub fn load_firmware(
  bytes : Bytes,
  hint? : @model.Format = Unknown,
  base_address? : Int64,
  options? : @model.ParseOptions = @model.ParseOptions::default(),
) -> @model.FirmwareImage raise @model.FirmwareError {
  options.validate()
  let detected = detect_format(bytes)
  let format = match detected {
    IntelHex | SRecord => detected
    _ => if hint != Unknown { hint } else { detected }
  }
  if format != RawBinary && base_address != None {
    raise @model.FirmwareError(
      @model.diagnostic(
        InvalidOption,
        "base address applies only to raw binary input",
      ),
    )
  }
  match format {
    IntelHex =>
      @ihex.parse_document(
        decode_text(bytes, format, max_length=options.max_text_length),
        options~,
      )
    SRecord =>
      @srec.parse_document(
        decode_text(bytes, format, max_length=options.max_text_length),
        options~,
      )
    RawBinary => {
      let base = match base_address {
        Some(address) => address
        None =>
          raise @model.FirmwareError(
            @model.diagnostic(
              InvalidOption,
              "raw binary input requires base address",
            ),
          )
      }
      if bytes.length() > options.max_payload {
        raise @model.FirmwareError(
          @model.diagnostic(ResourceLimit, "raw input exceeds payload limit"),
        )
      }
      @model.FirmwareImage::from_binary(bytes, base)
    }
    Unknown =>
      raise @model.FirmwareError(
        @model.diagnostic(
          UnknownFormat,
          "unrecognized input; specify a format hint",
        ),
      )
  }
}

///|
/// Unified output configuration, keeping the protocol-specific controls typed.
pub(all) struct ConvertOptions {
  format : @model.Format
  binary_range : @model.AddressRange?
  fill : Byte?
  max_binary_size : Int
  intel_hex : @ihex.WriterOptions
  srecord : @srec.WriterOptions
}

///|
/// Defaults keep sparse BIN expansion below 16 MiB and require explicit fill.
pub fn ConvertOptions::new(format : @model.Format) -> ConvertOptions {
  {
    format,
    binary_range: None,
    fill: None,
    max_binary_size: 16 * 1024 * 1024,
    intel_hex: @ihex.WriterOptions::default(),
    srecord: @srec.WriterOptions::default(),
  }
}

///|
/// Encoded bytes and explicit information about metadata that the output loses.
pub(all) struct ConversionResult {
  bytes : Bytes
  warnings : Array[@model.Diagnostic]
  binary_base : Int64?
}

///|
/// Convert an in-memory image, reporting unavoidable format metadata changes.
pub fn convert(
  image : @model.FirmwareImage,
  options : ConvertOptions,
) -> ConversionResult raise @model.FirmwareError {
  let warnings = image.warnings.copy()
  let mut binary_base = None
  let bytes = match options.format {
    IntelHex => {
      if image.metadata.header != None {
        warnings.push(
          @model.diagnostic(
            InvalidRecord,
            "Intel HEX cannot preserve S0 header bytes",
          ),
        )
      }
      @utf8.encode(@ihex.encode(image, options=options.intel_hex))
    }
    SRecord => {
      if image.entry == None {
        warnings.push(
          @model.diagnostic(
            InvalidRecord,
            "SREC termination uses entry address zero when source has no entry",
          ),
        )
      }
      if image.entry is Some(Segment(_, _)) &&
        options.srecord.flatten_segment_entry {
        warnings.push(
          @model.diagnostic(
            InvalidRecord,
            "SREC flattened CS:IP into an absolute entry address",
          ),
        )
      }
      @utf8.encode(@srec.encode(image, options=options.srecord))
    }
    RawBinary => {
      let range = match options.binary_range {
        Some(value) => value
        None =>
          match image.memory.bounds() {
            Some(value) => value
            None => @model.AddressRange::new(0L, 0L)
          }
      }
      binary_base = Some(range.start)
      if image.entry != None || image.metadata.header != None {
        warnings.push(
          @model.diagnostic(
            InvalidRecord,
            "raw binary discards execution entry and header metadata",
          ),
        )
      }
      if options.binary_range != None &&
        image.memory.slice(range).payload_size() != image.memory.payload_size() {
        warnings.push(
          @model.diagnostic(
            InvalidRange,
            "binary window excludes source payload outside the selected range",
          ),
        )
      }
      image.memory.to_binary(
        range,
        fill?=options.fill,
        max_size=options.max_binary_size,
      )
    }
    Unknown =>
      raise @model.FirmwareError(
        @model.diagnostic(UnknownFormat, "output format must be explicit"),
      )
  }
  { bytes, warnings, binary_base, }
}