// generator.mbt — Building Content-Disposition header values from a filename.
//
// Generating is the inverse of parsing: a caller has a filename (from their
// own data, not from a header) and wants a Content-Disposition value that
// preserves it. The generator:
//
//   - always emits the disposition type in canonical lowercase;
//   - emits `filename="..."` as a quoted-string (RFC 7230 escaping, never
//     bare tokens — quoting is the deterministic, lossless form);
//   - when the filename contains non-ASCII characters, additionally emits an
//     RFC 8187 `filename*=UTF-8''` parameter (the
//     RFC 8187 Section 5 producer requirement);
//   - rejects CR, LF, NUL and other control characters outright (RFC 6266
//     Section 7 injection defence) rather than emitting them.
//
// ASCII fallback policy: this library deliberately does NOT transliterate
// non-ASCII filenames into ASCII approximations (`café` stays `café`, it is
// never emitted as `cafe`). When the filename contains non-ASCII, the plain
// `filename` parameter carries the original characters (quoted, obs-text
// bytes) and `filename*` carries the unambiguous UTF-8 encoding. This is the
// documented, honest fallback; see `docs/compatibility.md`.

///|
/// Options controlling one generation.
pub struct GenerateOptions {
  include_filename_star : Bool
  always_filename_star : Bool
  language : String?
}

///|
/// Default generation options: emit `filename*` when the filename contains
/// non-ASCII characters (RFC 8187 Section 5), no language tag.
pub fn GenerateOptions::default() -> GenerateOptions {
  { include_filename_star: true, always_filename_star: false, language: None }
}

///|
/// Whether the RFC 8187 `filename*` parameter is emitted at all.
pub fn GenerateOptions::include_filename_star(self : GenerateOptions) -> Bool {
  self.include_filename_star
}

///|
/// Whether `filename*` is emitted even for pure-ASCII filenames (default
/// false; it is emitted only when the filename contains non-ASCII).
pub fn GenerateOptions::always_filename_star(self : GenerateOptions) -> Bool {
  self.always_filename_star
}

///|
/// The RFC 5646 language tag attached to `filename*`, if any.
pub fn GenerateOptions::language(self : GenerateOptions) -> String? {
  self.language
}

///|
/// A copy of these options with the given language tag.
pub fn GenerateOptions::with_language(self : GenerateOptions, language : String?) -> GenerateOptions {
  { include_filename_star: self.include_filename_star, always_filename_star: self.always_filename_star, language }
}

///|
/// A copy of these options with `filename*` disabled.
pub fn GenerateOptions::without_filename_star(self : GenerateOptions) -> GenerateOptions {
  { include_filename_star: false, always_filename_star: false, language: self.language }
}

///|
/// Generates an `attachment` Content-Disposition value for a filename.
///
/// Errors: `QuotedString::InvalidControlCharacter` (the filename contains a
/// control character — CR/LF/NUL injection defence).
pub fn generate_attachment(filename : String) -> Result[String, DispositionError] {
  generate_content_disposition(Attachment, filename, GenerateOptions::default())
}

///|
/// Generates an `inline` Content-Disposition value for a filename.
///
/// Errors: `QuotedString::InvalidControlCharacter`.
pub fn generate_inline(filename : String) -> Result[String, DispositionError] {
  generate_content_disposition(Inline, filename, GenerateOptions::default())
}

///|
/// Generates an `attachment` Content-Disposition value with explicit options.
pub fn generate_attachment_with_options(
  filename : String,
  options : GenerateOptions
) -> Result[String, DispositionError] {
  generate_content_disposition(Attachment, filename, options)
}

///|
/// Generates an `inline` Content-Disposition value with explicit options.
pub fn generate_inline_with_options(
  filename : String,
  options : GenerateOptions
) -> Result[String, DispositionError] {
  generate_content_disposition(Inline, filename, options)
}

///|
/// Generates a Content-Disposition value for the given disposition type and
/// filename with explicit options.
///
/// Errors: `QuotedString::InvalidControlCharacter` (control character in the
/// filename) and `ExtendedValue::InvalidLanguage` (a language tag was given
/// but is not a valid RFC 5646 tag).
pub fn generate_content_disposition(
  disposition_type : DispositionType,
  filename : String,
  options : GenerateOptions
) -> Result[String, DispositionError] {
  // CR/LF/NUL injection defence: no control byte may reach the wire.
  let bytes = @utf8.encode(filename)
  for i = 0; i < bytes.length(); i = i + 1 {
    if is_control_byte(bytes[i]) {
      return Err(
        disposition_error(
          QuotedString,
          InvalidControlCharacter,
          "filename contains a control character (CR/LF/NUL injection is rejected)",
        ),
      )
    }
  }
  let has_non_ascii = has_non_ascii_bytes(filename)
  let emit_star = options.include_filename_star() &&
    (options.always_filename_star() || has_non_ascii)
  // Validate a provided language tag before emitting it.
  match options.language() {
    Some(lang) => {
      if emit_star && !valid_language_tag(lang) {
        return Err(
          disposition_error(ExtendedValue, InvalidLanguage, "invalid RFC 5646 language tag: \{lang}"),
        )
      }
    }
    None => ()
  }
  let sb = StringBuilder()
  sb.write_string(disposition_type.to_lower_name())
  sb.write_string("; filename=")
  sb.write_string(serialize_quoted_string(filename))
  if emit_star {
    sb.write_string("; filename*=")
    sb.write_string("UTF-8'")
    match options.language() {
      Some(lang) => sb.write_string(lang)
      None => ()
    }
    sb.write_string("'")
    sb.write_string(percent_encode_attr_value(filename))
  }
  Ok(sb.to_string())
}

// Whether a string contains any byte >= 0x80.
fn has_non_ascii_bytes(value : String) -> Bool {
  let bytes = @utf8.encode(value)
  for i = 0; i < bytes.length(); i = i + 1 {
    if bytes[i].to_int() >= 128 {
      return true
    }
  }
  false
}