// filename_sanitize.mbt — Policy-driven filename sanitisation.
//
// A download filename comes from an HTTP header and must never be used as a
// filesystem path without being sanitised first: the header is untrusted
// input. This module turns a raw filename (usually the output of
// `resolve_filename`) into a safe filesystem name by applying a
// `FilenamePolicy`. The result is always a single path component:
//
//   - no path separators survive (`/` and `\` are replaced per profile);
//   - the whole-name forms `.` and `..` are defused;
//   - Windows reserved device names (`CON`, `PRN`, ...) are defused when the
//     policy enables it;
//   - Windows-forbidden characters are removed per profile;
//   - trailing dots and spaces are trimmed when the profile calls for it;
//   - the name is truncated to the policy's maximum length.
//
// Every transformation is recorded as a stable issue key in
// `SafeFilenameResult::issues`, so a caller can log why a name changed.
// There is deliberately no transliteration: a non-portable character is
// replaced with `_`, never "sounded out" into ASCII.

///|
/// The result of sanitising a filename: the original value, the safe value,
/// whether anything changed, and the list of issues (each stable key at most
/// once).
pub struct SafeFilenameResult {
  original : String
  safe : String
  changed : Bool
  issues : Array[String]
}

///|
/// The original (unsanitised) filename.
pub fn SafeFilenameResult::original(self : SafeFilenameResult) -> String {
  self.original
}

///|
/// The sanitised filename, safe to use as a single path component.
pub fn SafeFilenameResult::safe(self : SafeFilenameResult) -> String {
  self.safe
}

///|
/// Whether the sanitised name differs from the original.
pub fn SafeFilenameResult::changed(self : SafeFilenameResult) -> Bool {
  self.changed
}

///|
/// The stable issue keys produced while sanitising, each at most once:
/// `replaced-path-separator`, `replaced-unsafe-character`,
/// `defused-dot-name`, `prefixed-reserved-name`, `trimmed-trailing-char`,
/// `truncated-to-max-length`, `denied-extension`,
/// `extension-not-in-allow-list`.
pub fn SafeFilenameResult::issues(self : SafeFilenameResult) -> Array[String] {
  self.issues
}

///|
/// Sanitises a filename with the default (`Portable`) policy. Equivalent to
/// `sanitize_filename(name, FilenamePolicy::default())`.
///
/// Errors: `FilenamePolicy::UnsafeFilename` when the input is empty.
pub fn sanitize_portable_filename(name : String) -> Result[SafeFilenameResult, DispositionError] {
  sanitize_filename(name, FilenamePolicy::default())
}

///|
/// Sanitises a filename with an explicit policy. The returned name is a
/// single path component: it contains no `/` or `\` and cannot be `.` or
/// `..`, and it is never empty or a dot-only name. A non-empty input always
/// produces a safe name (`.` and `..` become `_`; a name made only of dots
/// and spaces falls back to `_`). See `FilenamePolicy` for the per-profile
/// character rules.
///
/// Errors: `FilenamePolicy::UnsafeFilename` when the input is empty.
pub fn sanitize_filename(
  name : String,
  policy : FilenamePolicy
) -> Result[SafeFilenameResult, DispositionError] {
  if name.char_length() == 0 {
    return Err(
      disposition_error(FilenamePolicy, UnsafeFilename, "cannot sanitise an empty filename"),
    )
  }
  let issues : Array[String] = []
  let profile = policy.profile()

  // 1. Defuse the whole-name forms `.` and `..` on the raw name. This must
  //    happen before the trailing trim: `.` and `..` are made only of dots,
  //    so trimming first would erase them.
  let mut working = name
  if working == "." || working == ".." {
    working = "_" + working
    push_issue(issues, "defused-dot-name")
  }

  // 2. Trim trailing dots and spaces from the raw name (Portable and
  //    WindowsLike profiles). A Windows filesystem silently drops trailing
  //    dots and spaces, so "CON " is the reserved name "CON"; trimming before
  //    the character pass also keeps a trailing space a trim rather than a
  //    replacement with `_`.
  if profile != PosixLike {
    let pre_trimmed = trim_trailing_dot_space(working)
    if pre_trimmed != working {
      working = pre_trimmed
      push_issue(issues, "trimmed-trailing-char")
    }
  }

  // 3. Character-level replacement.
  let sb = StringBuilder()
  for ch in working {
    if profile_safe_char(profile, ch) {
      sb.write_char(ch)
    } else {
      sb.write_char('_')
      if ch == '/' || ch == '\\' {
        push_issue(issues, "replaced-path-separator")
      } else {
        push_issue(issues, "replaced-unsafe-character")
      }
    }
  }
  let mut safe = sb.to_string()

  // 4. Defuse Windows reserved device names.
  if policy.windows_reserved().is_enabled() && is_windows_reserved_name(safe) {
    safe = "_" + safe
    push_issue(issues, "prefixed-reserved-name")
  }

  // 5. Trim trailing dots and spaces once more (Portable and WindowsLike
  //    profiles), in case a replacement left one.
  if profile != PosixLike {
    let trimmed = trim_trailing_dot_space(safe)
    if trimmed != safe {
      safe = trimmed
      push_issue(issues, "trimmed-trailing-char")
    }
  }

  // 6. Truncate to the policy maximum length.
  let cap = if policy.max_length() < 1 { 1 } else { policy.max_length() }
  if safe.char_length() > cap {
    safe = safe[:cap].to_owned()
    push_issue(issues, "truncated-to-max-length")
  }

  // 7. Truncation cuts mid-string, which can reintroduce a defusable whole
  //    name, a Windows reserved name, or a trailing dot/space. Normalise
  //    once more so the result is a single stable safe path component:
  //    sanitising the result of a sanitisation is a no-op.
  if safe == "." || safe == ".." {
    safe = "_" + safe
    push_issue(issues, "defused-dot-name")
  }
  if policy.windows_reserved().is_enabled() && is_windows_reserved_name(safe) {
    safe = "_" + safe
    push_issue(issues, "prefixed-reserved-name")
  }
  if profile != PosixLike {
    let stable = trim_trailing_dot_space(safe)
    if stable != safe {
      safe = stable
      push_issue(issues, "trimmed-trailing-char")
    }
  }

  // 8. A sanitised name must not be empty: a name made only of dots and
  //    spaces (for example " .") is trimmed to nothing, so fall back to `_`
  //    rather than failing.
  if safe.char_length() == 0 {
    safe = "_"
    push_issue(issues, "replaced-unsafe-character")
  }

  // 9. Advisory extension audit (never an error).
  audit_extension(safe, policy, issues)

  Ok({ original: name, safe, changed: safe != name, issues })
}

// Whether a code point survives the profile's character rule.
fn profile_safe_char(profile : PolicyProfile, ch : Char) -> Bool {
  match profile {
    Portable => portable_safe_char(ch)
    WindowsLike => windows_safe_char(ch)
    PosixLike => posix_safe_char(ch)
  }
}

// Portable: only `A-Za-z0-9._-`.
fn portable_safe_char(ch : Char) -> Bool {
  let v = ch.to_int()
  (v >= 48 && v <= 57) || (v >= 65 && v <= 90) || (v >= 97 && v <= 122) ||
    ch == '.' || ch == '_' || ch == '-'
}

// Windows: forbid C0 controls, DEL and `< > : " / \ | ? *`.
fn windows_safe_char(ch : Char) -> Bool {
  let v = ch.to_int()
  if v < 32 || v == 127 {
    return false
  }
  ch != '<' && ch != '>' && ch != ':' && ch != '"' && ch != '/' && ch != '\\' &&
    ch != '|' && ch != '?' && ch != '*'
}

// Posix: only `/` and NUL are forbidden.
fn posix_safe_char(ch : Char) -> Bool {
  ch != '/' && ch.to_int() != 0
}

// Whether a sanitised name is a Windows reserved device name (the stem, the
// part before the first dot, uppercased, matches CON/PRN/AUX/NUL or
// COM1-9/LPT1-9).
fn is_windows_reserved_name(name : String) -> Bool {
  let stem = match name.split_once(".") {
    Some((s, _)) => s.to_owned()
    None => name
  }
  let upper = stem.to_upper()
  if upper == "CON" || upper == "PRN" || upper == "AUX" || upper == "NUL" {
    return true
  }
  if upper.char_length() == 4 && (upper.has_prefix("COM") || upper.has_prefix("LPT")) {
    let last = upper[3].to_int()
    if last >= 49 && last <= 57 {
      return true
    }
  }
  false
}

// Removes trailing '.' and ' ' characters.
fn trim_trailing_dot_space(name : String) -> String {
  let mut end = name.char_length()
  while end > 0 {
    let c = name[end - 1]
    if c == '.' || c == ' ' {
      end = end - 1
    } else {
      break
    }
  }
  name[:end].to_owned()
}

// The index of the last '.' in a name, or `None`.
fn last_dot_index(name : String) -> Int? {
  let mut idx : Int? = None
  for i = 0; i < name.char_length(); i = i + 1 {
    if name[i] == '.' {
      idx = Some(i)
    }
  }
  idx
}

// Appends an issue key at most once.
fn push_issue(issues : Array[String], key : String) -> Unit {
  for existing in issues {
    if existing == key {
      return
    }
  }
  issues.push(key)
}

// Compares an extension against a list entry: both are lower-cased and a
// leading dot is stripped.
fn ext_matches(ext : String, entry : String) -> Bool {
  let normalized = if entry.char_length() > 0 && entry[0] == '.' {
    entry[1:].to_owned().to_lower()
  } else {
    entry.to_lower()
  }
  ext == normalized
}

// Records advisory extension issues (never errors).
fn audit_extension(
  safe : String,
  policy : FilenamePolicy,
  issues : Array[String]
) -> Unit {
  let ext_policy = policy.extension()
  if !ext_policy.is_enabled() {
    return
  }
  match last_dot_index(safe) {
    Some(i) => {
      let ext = safe[(i + 1):].to_owned().to_lower()
      for entry in ext_policy.deny_list() {
        if ext_matches(ext, entry) {
          push_issue(issues, "denied-extension")
        }
      }
      let allow = ext_policy.allow_list()
      if !allow.is_empty() {
        let mut allowed = false
        for entry in allow {
          if ext_matches(ext, entry) {
            allowed = true
          }
        }
        if !allowed {
          push_issue(issues, "extension-not-in-allow-list")
        }
      }
    }
    None => ()
  }
}