///|
#cfg(platform="windows")
fn git_path_spelling(path : String) -> Bool {
  !path.contains("\\") && !path.contains(":")
}

///|
#cfg(not(platform="windows"))
fn git_path_spelling(_path : String) -> Bool {
  true
}

///|
/// Current working-tree rules for Git paths, including deleted files.
struct PathFilter {
  root : String
  options : SelectionOptions
  includes : Array[Pattern]
  custom : Array[Pattern]
  defaults : Array[Pattern]
  inherited : Array[Rule]
  blocked : Rule?
  output : String?
  exact : Map[String, Bool]?
  cache : Map[String, Array[Rule]]
  names : Map[String, Map[String, Bool]]
}

///|
async fn make_path_filter(
  root : String,
  options : SelectionOptions,
  paths : Array[String]?,
) -> PathFilter {
  let includes = custom_patterns(options.include_patterns, "include")
  let custom = custom_patterns(options.ignore, "ignore")
  let defaults = default_patterns(options.use_default_patterns)
  let output = match options.output {
    None => None
    Some(path) => {
      if !absolute(separators(path)) {
        raise SelectionError::InvalidOutput(path)
      }
      Some(normalize(output_identity(separators(path))))
    }
  }
  let (inherited, blocked) = inherited_rules(root, options)
  {
    root,
    options,
    includes,
    custom,
    defaults,
    inherited,
    blocked,
    output,
    exact: paths.map(paths => Map::from_array(paths.map(path => (path, true)))),
    cache: Map([]),
    names: Map([]),
  }
}

///|
/// The root is the canonical root returned by `select`.
/// Some([]) selects no paths; explicit entries are literal root-relative files.
pub async fn path_filter(
  root : String,
  options : SelectionOptions,
  file_paths~ : Array[String]? = None,
) -> PathFilter {
  let paths = exact_paths(file_paths)
  if !absolute(root) || optional_kind(root) != Some(Directory) {
    raise SelectionError::InvalidRoot(root)
  }
  make_path_filter(root, options, paths)
}

///|
// Shared decision for exact candidate discovery and Git filtering. Only the
// latter allows missing paths (deletions). Ancestors are checked before loading
// rules beneath them. Exact lists also check on-disk spelling using cached names
// of visited parents; pruned directories are never listed or entered.
async fn PathFilter::reason(
  self : PathFilter,
  relative : String,
  require_file : Bool,
) -> String? {
  if !relative_file_path(relative) || !git_path_spelling(relative) {
    return Some("InvalidPath")
  }
  if self.exact is Some(paths) && !paths.contains(relative) {
    return Some("NotListed")
  }
  if self.blocked is Some(rule) {
    return Some("IgnoredDirectory:\{rule.source}:\{rule.line}")
  }
  let parts = relative.split("/").map(p => p.to_owned()).collect()
  let mut directory = self.root
  let mut inherited = self.inherited
  for i in 0.. rules
      None => {
        let rules = local_rules(directory, inherited, self.options)
        self.cache[directory] = rules
        rules
      }
    }
    let part = parts[i]
    let path = join(directory, part)
    let is_dir = i < parts.length() - 1
    let kind = optional_kind(path)
    match kind {
      Some(SymLink) => return Some("Symlink")
      Some(kind) if kind != Regular && kind != Directory =>
        return Some("UnsupportedFileType")
      None if require_file => return Some("MissingFile")
      _ => ()
    }
    if self.exact is Some(_) && kind is Some(_) {
      // stat can accept case-folded, normalization or short-name aliases. Only
      // ordinary existing entries reach here; compare their parent's raw names
      // before entering this component or applying string-based exclusions.
      let names = match self.names.get(directory) {
        Some(names) => names
        None => {
          let names = Map::from_array(
            @fs.readdir(directory, include_hidden=true, sort=false).map(name => {
              (name, true)
            }),
          )
          self.names[directory] = names
          names
        }
      }
      if !names.contains(part) {
        return Some("NonLiteralPath")
      }
    }
    match kind {
      Some(Directory) if !is_dir => return Some("Directory")
      Some(Regular) if is_dir => return Some("NotDirectory")
      _ => ()
    }
    if self.output is Some(output) && normalize(path) == output {
      return Some("OutputFile")
    }
    let ignored = if is_dir { "IgnoredDirectory" } else { "Ignored" }
    if self.defaults.any(p => p.matches_parts(parts[:i + 1], is_dir)) {
      return Some("\{ignored}:default")
    }
    if self.custom.any(p => p.matches_parts(parts[:i + 1], is_dir)) {
      return Some("\{ignored}:custom")
    }
    if ignored_by(rules, path, is_dir) is Some(rule) {
      return Some("\{ignored}:\{rule.source}:\{rule.line}")
    }
    directory = path
    inherited = rules
  }
  if included_parts(self.includes, parts[:]) {
    None
  } else {
    Some("NotIncluded")
  }
}

///|
/// Apply include/ignore, ancestor pruning and no-follow rules without opening a file.
/// Missing paths are valid because Git diffs and history can describe deletions.
pub async fn PathFilter::allows(self : PathFilter, relative : String) -> Bool {
  self.reason(relative, false) is None
}