///|
pub(all) struct FilenamePolicy {
  fallback : String
  max_length : Int
  allow_leading_dot : Bool
  allow_spaces : Bool
  allowed_extensions : Array[String]
  blocked_extensions : Array[String]
} derive(Debug, Eq)

///|
pub fn default_filename_policy() -> FilenamePolicy {
  {
    fallback: "upload.bin",
    max_length: 120,
    allow_leading_dot: false,
    allow_spaces: true,
    allowed_extensions: [],
    blocked_extensions: [
      "exe", "bat", "cmd", "com", "scr", "ps1", "sh", "dll", "so",
    ],
  }
}

///|
pub fn strict_filename_policy() -> FilenamePolicy {
  {
    fallback: "upload.bin",
    max_length: 80,
    allow_leading_dot: false,
    allow_spaces: false,
    allowed_extensions: [],
    blocked_extensions: [
      "exe", "bat", "cmd", "com", "scr", "ps1", "sh", "dll", "so", "js",
    ],
  }
}

///|
pub fn image_filename_policy() -> FilenamePolicy {
  {
    fallback: "upload.bin",
    max_length: 120,
    allow_leading_dot: false,
    allow_spaces: true,
    allowed_extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg"],
    blocked_extensions: [],
  }
}

///|
pub fn sanitize_filename_with_policy(
  filename : String,
  policy : FilenamePolicy,
) -> String {
  let cleaned = safe_filename(filename)
  let chars = Array::new()
  let mut i = 0
  while i < cleaned.length() {
    let code = cleaned[i]
    if code == 32 && !policy.allow_spaces {
      chars.push('_')
    } else if is_filename_policy_char(code) {
      chars.push(char_at(cleaned, i))
    } else {
      chars.push('_')
    }
    i = i + 1
  }
  let mut out = trim_ascii(String::from_array(chars))
  if out == "" || out == "." || out == ".." {
    out = policy.fallback
  }
  if !policy.allow_leading_dot {
    while out.has_prefix(".") {
      out = slice_from(out, 1)
    }
    if out == "" {
      out = policy.fallback
    }
  }
  if out.length() > policy.max_length && policy.max_length > 0 {
    out = shorten_filename(out, policy.max_length)
  }
  if policy.allowed_extensions.length() > 0 &&
    !filename_has_allowed_extension(out, policy.allowed_extensions) {
    out = append_extension_if_missing(out, "bin")
  }
  if policy.blocked_extensions.length() > 0 &&
    filename_has_allowed_extension(out, policy.blocked_extensions) {
    out = out + ".upload"
  }
  out
}

///|
pub fn filename_extension(filename : String) -> String? {
  let safe = safe_filename(filename)
  match safe.rev_split_once(".") {
    Some((head, tail)) =>
      if head.to_owned() == "" || tail.to_owned() == "" {
        None
      } else {
        Some(to_lower_ascii(tail.to_owned()))
      }
    None => None
  }
}

///|
pub fn filename_stem(filename : String) -> String {
  let safe = safe_filename(filename)
  match safe.rev_split_once(".") {
    Some((head, tail)) =>
      if head.to_owned() == "" || tail.to_owned() == "" {
        safe
      } else {
        head.to_owned()
      }
    None => safe
  }
}

///|
pub fn filename_has_extension(filename : String, extension : String) -> Bool {
  match filename_extension(filename) {
    Some(actual) => actual == normalize_extension(extension)
    None => false
  }
}

///|
pub fn filename_has_allowed_extension(
  filename : String,
  allowed : Array[String],
) -> Bool {
  if allowed.length() == 0 {
    return true
  }
  match filename_extension(filename) {
    Some(actual) => {
      let mut i = 0
      while i < allowed.length() {
        if actual == normalize_extension(allowed[i]) {
          return true
        }
        i = i + 1
      }
      false
    }
    None => false
  }
}

///|
pub fn filename_contains_path_separator(filename : String) -> Bool {
  filename.find("/") is Some(_) || filename.find("\\") is Some(_)
}

///|
pub fn filename_looks_like_windows_drive(filename : String) -> Bool {
  filename.length() >= 2 &&
  is_ascii_alpha_code(filename[0]) &&
  filename[1] == 58
}

///|
pub fn filename_has_control_chars(filename : String) -> Bool {
  let mut i = 0
  while i < filename.length() {
    let code = filename[i]
    if code < 32 || code == 127 {
      return true
    }
    i = i + 1
  }
  false
}

///|
pub fn filename_security_notes(filename : String) -> Array[String] {
  let notes = Array::new()
  if filename_contains_path_separator(filename) {
    notes.push("path separator removed")
  }
  if filename_looks_like_windows_drive(filename) {
    notes.push("windows drive prefix removed")
  }
  if filename_has_control_chars(filename) {
    notes.push("control characters removed")
  }
  if safe_filename(filename) != filename {
    notes.push("filename normalized")
  }
  notes
}

///|
fn shorten_filename(filename : String, max_length : Int) -> String {
  if filename.length() <= max_length {
    return filename
  }
  match filename_extension(filename) {
    Some(ext) => {
      let suffix = "." + ext
      let budget = max_length - suffix.length()
      if budget <= 0 {
        slice_string(filename, 0, max_length)
      } else {
        slice_string(filename_stem(filename), 0, budget) + suffix
      }
    }
    None => slice_string(filename, 0, max_length)
  }
}

///|
fn append_extension_if_missing(filename : String, extension : String) -> String {
  match filename_extension(filename) {
    Some(_) => filename
    None => filename + "." + normalize_extension(extension)
  }
}

///|
fn normalize_extension(extension : String) -> String {
  let trimmed = trim_ascii(extension)
  if trimmed.has_prefix(".") {
    to_lower_ascii(slice_from(trimmed, 1))
  } else {
    to_lower_ascii(trimmed)
  }
}

///|
fn is_filename_policy_char(code : UInt16) -> Bool {
  code >= 32 && code != 47 && code != 92 && code != 58
}

///|
fn is_ascii_alpha_code(code : UInt16) -> Bool {
  (code >= 65 && code <= 90) || (code >= 97 && code <= 122)
}