///|
/// Active fallback mode for unrouted requests.
pub(all) enum FallbackMode {
  None
  Spa
  TryFiles(String)
} derive(Eq, Debug)

///|
/// Normalize a URL mount prefix per D-04.
pub fn normalize_base_url(value : String) -> String raise ConfigError {
  if value == "" || value == "/" {
    return "/"
  }
  if value.contains("?") ||
    value.contains("#") ||
    value.contains("\\") ||
    value.contains("\u0000") ||
    value.contains("\r") ||
    value.contains("\n") {
    raise ConfigError::InvalidBaseUrl("base_url contains invalid characters")
  }
  if value.contains("..") || value.contains("//") {
    raise ConfigError::InvalidBaseUrl(
      "base_url contains dot segments or consecutive slashes",
    )
  }
  let prefixed = if value[0] == '/' { value } else { "/" + value }
  if prefixed.length() > 1 && prefixed[prefixed.length() - 1] == '/' {
    prefixed[:prefixed.length() - 1].to_owned()
  } else {
    prefixed
  }
}

///|
/// Resolve base_url and base_dir aliases.
pub fn resolve_base_url(
  base_url : String?,
  base_dir : String?,
) -> String raise ConfigError {
  match (base_url, base_dir) {
    (None, None) => "/"
    (Some(u), None) => normalize_base_url(u)
    (None, Some(d)) => normalize_base_url(d)
    (Some(u), Some(d)) => {
      let norm_u = normalize_base_url(u)
      let norm_d = normalize_base_url(d)
      if norm_u != norm_d {
        raise ConfigError::ConflictingBaseUrl(
          "conflicting --base-url '\{u}' and --base-dir '\{d}'",
        )
      }
      norm_u
    }
  }
}

///|
/// Match a decoded request path against the normalized base_url using strict
/// component boundary matching. Returns Some(relative_path) on match, or None
/// if the request falls outside the base_url mount.
pub fn match_and_strip_base_url(
  base_url : String,
  decoded_path : String,
) -> String? {
  if base_url == "/" {
    if decoded_path.length() > 0 && decoded_path[0] == '/' {
      Some(decoded_path[1:].to_owned())
    } else if decoded_path == "" {
      Some("")
    } else {
      None
    }
  } else {
    let base_len = base_url.length()
    if decoded_path == base_url {
      Some("")
    } else if decoded_path.length() > base_len &&
      decoded_path.has_prefix(base_url) &&
      decoded_path[base_len] == '/' {
      Some(decoded_path[base_len + 1:].to_owned())
    } else {
      None
    }
  }
}

///|
/// Formats the Location header for a directory 302 redirect, ensuring
/// trailing slash is added before query and base_url is preserved.
pub fn format_dir_redirect(raw_path : String, raw_query : String?) -> String {
  let path_with_slash = if raw_path.has_suffix("/") {
    raw_path
  } else {
    raw_path + "/"
  }
  match raw_query {
    Some(q) if q.length() > 0 => path_with_slash + "?" + q
    _ => path_with_slash
  }
}

///|
/// A route-based proxy rule.
pub(all) struct ProxyRule {
  pattern : String
  target : String
  path_rewrite : Array[(String, String)]
} derive(Debug, Eq)

///|
/// Check if a request path matches a glob or prefix pattern.
pub fn match_glob_path(pattern : String, path : String) -> Bool {
  if pattern == "**" || pattern == "*" {
    return true
  }
  if pattern.has_suffix("/**") {
    let pfx = pattern[:pattern.length() - 3].to_owned()
    return path == pfx || path.has_prefix(pfx + "/")
  }
  if pattern.has_suffix("/*") {
    let pfx = pattern[:pattern.length() - 2].to_owned()
    if path.has_prefix(pfx + "/") {
      let rest = path[pfx.length() + 1:].to_owned()
      return !rest.contains("/")
    }
    return false
  }
  pattern == path
}

///|
/// Match a URL path against a proxy rule and apply path rewriting.
/// Returns Some((target, rewritten_path)) if matched, None otherwise.
pub fn match_proxy_rule(rule : ProxyRule, path : String) -> (String, String)? {
  if !match_glob_path(rule.pattern, path) {
    return None
  }
  let mut rewritten = path
  for pair in rule.path_rewrite {
    let (from_pat, to_str) = pair
    if from_pat.has_prefix("^") {
      let pfx = from_pat[1:].to_owned()
      if rewritten.has_prefix(pfx) {
        rewritten = to_str + rewritten[pfx.length():].to_owned()
      }
    } else if rewritten.contains(from_pat) {
      rewritten = rewritten.replace_all(old=from_pat, new=to_str)
    }
  }
  Some((rule.target, rewritten))
}


///|
pub extend FallbackMode with Eq::{not_equal, equal}

///|
pub extend FallbackMode with Debug::{to_repr}

///|
pub extend ProxyRule with Debug::{to_repr}

///|
pub extend ProxyRule with Eq::{not_equal, equal}