///|
#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::decision(
  self : PathFilter,
  relative : String,
  require_file : Bool,
) -> Exclusion? {
  if !relative_file_path(relative) || !git_path_spelling(relative) {
    return Some(Fixed("InvalidPath"))
  }
  if self.exact is Some(paths) && !paths.contains(relative) {
    return Some(Fixed("NotListed"))
  }
  if self.blocked is Some(rule) {
    return Some(RuleFile(rule, true))
  }
  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)
    if type_exclusion(kind) is Some(exclusion) {
      return Some(exclusion)
    }
    if kind is None && require_file {
      return Some(Fixed("MissingFile"))
    }
    if (require_file || 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(Fixed("NonLiteralPath"))
      }
    }
    match kind {
      Some(Directory) if !is_dir => return Some(Fixed("Directory"))
      Some(Regular) if is_dir => return Some(Fixed("NotDirectory"))
      _ => ()
    }
    if self.exclusion(path, normalize(path), parts[:i + 1], is_dir, rules)
      is Some(exclusion) {
      return Some(exclusion)
    }
    directory = path
    inherited = rules
  }
  self.include_exclusion(parts[:])
}

///|
// Preserve existing exact-selection and Git diagnostic strings.
async fn PathFilter::reason(
  self : PathFilter,
  relative : String,
  require_file : Bool,
) -> String? {
  self.decision(relative, require_file).map(exclusion => exclusion.reason())
}

///|
/// 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
}