///|
pub fn sanitize_filename(filename : String) -> FilenameReport {
  sanitize_filename_with_policy(filename, FilenamePolicy::default())
}

///|
pub fn sanitize_filename_with_policy(
  filename : String,
  policy : FilenamePolicy,
) -> FilenameReport {
  let diagnostics : Array[Diagnostic] = []
  let extracted = last_path_segment(filename)
  if extracted != filename {
    push_diag(
      diagnostics,
      PathSegmentStripped,
      Warning,
      "Path components were stripped from the filename",
      None,
    )
  }
  let collapsed = collapse_unsafe_chars(extracted, policy, diagnostics)
  let trimmed = trim_filename_edges(collapsed)
  let non_empty = if trimmed.is_empty() {
    push_diag(
      diagnostics,
      EmptyFilename,
      Warning,
      "Filename was empty after normalization",
      None,
    )
    policy.default_name
  } else {
    trimmed
  }
  let visible = if !policy.allow_hidden && non_empty.has_prefix(".") {
    push_diag(
      diagnostics,
      UnsafeFilename,
      Warning,
      "Leading dot was replaced to avoid hidden files",
      Some(0),
    )
    policy.replacement + non_empty[1:].to_owned()
  } else {
    non_empty
  }
  let reserved_safe = avoid_reserved_device_name(visible, policy, diagnostics)
  let length_safe = limit_filename_length(reserved_safe, policy, diagnostics)
  {
    value: length_safe,
    original: filename,
    changed: length_safe != filename,
    diagnostics,
  }
}

///|
fn last_path_segment(filename : String) -> String {
  let mut last = 0
  let mut index = 0
  while index < filename.length() {
    let c = char_at(filename, index)
    if c == '/' || c == '\\' {
      last = index + 1
    }
    index = index + c.utf16_len()
  }
  filename[last:].to_owned()
}

///|
fn collapse_unsafe_chars(
  filename : String,
  policy : FilenamePolicy,
  diagnostics : Array[Diagnostic],
) -> String {
  let buf = StringBuilder(size_hint=filename.length())
  let mut changed = false
  for c in filename.iter() {
    if is_filename_unsafe(c) {
      changed = true
      if is_ctl(c) {
        push_diag(
          diagnostics,
          ControlCharacter,
          Warning,
          "Control character was replaced in filename",
          None,
        )
      }
      buf.write_string(policy.replacement)
    } else {
      buf.write_char(c)
    }
  }
  if changed {
    push_diag(
      diagnostics,
      UnsafeFilename,
      Warning,
      "Unsafe filename characters were replaced",
      None,
    )
  }
  coalesce_replacement(buf.to_string(), policy.replacement)
}

///|
fn is_filename_unsafe(c : Char) -> Bool {
  is_ctl(c) ||
  c == '/' ||
  c == '\\' ||
  c == ':' ||
  c == '*' ||
  c == '?' ||
  c == '"' ||
  c == '<' ||
  c == '>' ||
  c == '|'
}

///|
fn trim_filename_edges(filename : String) -> String {
  filename.trim(chars=" \t\r\n").to_owned().trim_end(chars=".").to_owned()
}

///|
fn coalesce_replacement(filename : String, replacement : String) -> String {
  if replacement.is_empty() {
    return filename
  }
  let buf = StringBuilder(size_hint=filename.length())
  let mut previous = false
  for c in filename.iter() {
    let s = c.to_string()
    if s == replacement {
      if !previous {
        buf.write_string(replacement)
      }
      previous = true
    } else {
      buf.write_char(c)
      previous = false
    }
  }
  buf.to_string()
}

///|
fn avoid_reserved_device_name(
  filename : String,
  policy : FilenamePolicy,
  diagnostics : Array[Diagnostic],
) -> String {
  let stem = filename_stem_for_reserved_check(filename)
  if is_reserved_device_name(stem) {
    push_diag(
      diagnostics,
      ReservedName,
      Warning,
      "Reserved device filename was prefixed",
      None,
    )
    policy.replacement + filename
  } else {
    filename
  }
}

///|
fn filename_stem_for_reserved_check(filename : String) -> String {
  let trimmed = filename.trim(chars=" \t.").to_owned()
  match trimmed.split_once(".") {
    Some((stem, _)) => stem.to_owned().to_lower()
    None => trimmed.to_lower()
  }
}

///|
fn is_reserved_device_name(stem : String) -> Bool {
  stem == "con" ||
  stem == "prn" ||
  stem == "aux" ||
  stem == "nul" ||
  stem == "clock$" ||
  is_numbered_reserved(stem, "com") ||
  is_numbered_reserved(stem, "lpt")
}

///|
fn is_numbered_reserved(stem : String, prefix : String) -> Bool {
  if !stem.has_prefix(prefix) {
    return false
  }
  let rest = stem[prefix.length():].to_owned()
  rest.length() == 1 && char_at(rest, 0) >= '1' && char_at(rest, 0) <= '9'
}

///|
fn limit_filename_length(
  filename : String,
  policy : FilenamePolicy,
  diagnostics : Array[Diagnostic],
) -> String {
  if policy.max_length <= 0 || filename.char_length() <= policy.max_length {
    return filename
  }
  push_diag(
    diagnostics,
    LengthReduced,
    Warning,
    "Filename was shortened to fit the configured length limit",
    None,
  )
  take_chars(filename, policy.max_length)
}

///|
fn take_chars(filename : String, max_chars : Int) -> String {
  let buf = StringBuilder(size_hint=max_chars)
  let mut count = 0
  for c in filename.iter() {
    if count >= max_chars {
      break
    }
    buf.write_char(c)
    count = count + 1
  }
  buf.to_string()
}

///|
pub fn safe_filename_from_header(
  header : String,
) -> Result[FilenameReport, ParseError] {
  match parse_header(header) {
    Ok(disposition) =>
      match disposition.filename() {
        Some(name) => Ok(sanitize_filename(name))
        None =>
          Err({
            code: MissingValue,
            message: "Header does not contain a filename parameter",
            offset: None,
          })
      }
    Err(err) => Err(err)
  }
}

///|
pub fn filename_ascii_fallback(filename : String) -> String {
  sanitize_filename(ascii_fallback(filename)).value
}