///|
/// One text file captured during a deterministic project scan.
pub(all) struct ProjectFile {
  path : String
  content : String
  line_count : Int
  char_count : Int
} derive(Debug, Eq)

///|
pub fn ProjectFile::new(path : String, content : String) -> ProjectFile {
  let line_count = if content.length() == 0 {
    0
  } else {
    content.split("\n").to_array().length()
  }
  { path, content, line_count, char_count: content.length() }
}

///|
pub fn ProjectFile::line_of(self : ProjectFile, needle : String) -> Int? {
  let mut line = 1
  for text in self.content.split("\n") {
    if text.to_owned().contains(needle) {
      return Some(line)
    }
    line += 1
  }
  None
}

///|
pub fn ProjectFile::location_of(
  self : ProjectFile,
  needle : String,
) -> SourceLocation? {
  match self.line_of(needle) {
    Some(line) => Some(SourceLocation::new(self.path, line, 1))
    None => None
  }
}

///|
/// Complete, offline tree inventory for a project root.
pub(all) struct ProjectInventory {
  root : String
  files : Array[ProjectFile]
  directories : Array[String]
  ignored_paths : Array[String]
} derive(Debug, Eq)

///|
pub fn ProjectInventory::new(
  root : String,
  files : Array[ProjectFile],
  directories : Array[String],
  ignored_paths : Array[String],
) -> ProjectInventory {
  { root, files, directories, ignored_paths }
}

///|
pub fn ProjectInventory::has_file(
  self : ProjectInventory,
  path : String,
) -> Bool {
  self.files.any(file => file.path == path)
}

///|
pub fn ProjectInventory::has_directory(
  self : ProjectInventory,
  path : String,
) -> Bool {
  self.directories.contains(path)
}

///|
pub fn ProjectInventory::find_file(
  self : ProjectInventory,
  path : String,
) -> ProjectFile? {
  for file in self.files {
    if file.path == path {
      return Some(file)
    }
  }
  None
}

///|
pub fn ProjectInventory::files_with_suffix(
  self : ProjectInventory,
  suffix : String,
) -> Array[ProjectFile] {
  let found : Array[ProjectFile] = []
  for file in self.files {
    if file.path.has_suffix(suffix) {
      found.push(file)
    }
  }
  found
}

///|
pub fn ProjectInventory::files_under(
  self : ProjectInventory,
  path : String,
) -> Array[ProjectFile] {
  let prefix = if path.has_suffix("/") { path } else { path + "/" }
  let found : Array[ProjectFile] = []
  for file in self.files {
    if file.path.has_prefix(prefix) {
      found.push(file)
    }
  }
  found
}

///|
pub fn ProjectInventory::total_lines(self : ProjectInventory) -> Int {
  let mut total = 0
  for file in self.files {
    total += file.line_count
  }
  total
}

///|
pub fn ProjectInventory::to_snapshot(
  self : ProjectInventory,
) -> ProjectSnapshot {
  let files : Map[String, String] = {}
  for file in self.files {
    files[file.path] = file.content
  }
  ProjectSnapshot::new(self.root, files, self.directories)
}

///|
fn inventory_join(root : String, relative : String) -> String {
  if root.has_suffix("/") || root.has_suffix("\\") {
    root + relative
  } else if relative.length() == 0 {
    root
  } else {
    root + "/" + relative
  }
}

///|
fn child_relative(parent : String, child : String) -> String {
  if parent.length() == 0 {
    child
  } else {
    parent + "/" + child
  }
}

///|
fn should_ignore(config : DoctorConfig, path : String) -> Bool {
  config.ignores_path(path) || path == ".git" || path.has_prefix(".git/")
}

///|
fn scan_directory(
  root : String,
  relative : String,
  config : DoctorConfig,
  files : Array[ProjectFile],
  directories : Array[String],
  ignored_paths : Array[String],
) -> Unit raise {
  let current_path = inventory_join(root, relative)
  for entry in @fs.read_dir(current_path) {
    let child_relative_path = child_relative(relative, entry)
    let child_path = inventory_join(root, child_relative_path)
    if should_ignore(config, child_relative_path) {
      ignored_paths.push(child_relative_path)
    } else if @fs.is_dir(child_path) {
      directories.push(child_relative_path)
      scan_directory(
        root, child_relative_path, config, files, directories, ignored_paths,
      )
    } else if @fs.is_file(child_path) {
      files.push(
        ProjectFile::new(
          child_relative_path,
          @fs.read_file_to_string(child_path),
        ),
      )
    }
  }
}

///|
/// Recursively scan a project without touching ignored or generated directories.
pub fn scan_project(
  root : String,
  config : DoctorConfig,
) -> ProjectInventory raise {
  if !@fs.path_exists(root) || !@fs.is_dir(root) {
    fail("project path is not a directory: " + root)
  }
  let files : Array[ProjectFile] = []
  let directories : Array[String] = []
  let ignored_paths : Array[String] = []
  scan_directory(root, "", config, files, directories, ignored_paths)
  ProjectInventory::new(root, files, directories, ignored_paths)
}

///|
pub fn scan_project_default(root : String) -> ProjectInventory raise {
  scan_project(root, DoctorConfig::default())
}