//! Find every `MUSTS.yml` under the workspace root.
//!
//! Per `docs/PLAN.md` §4.5 we always do a full parallel walk (no dir-mtime
//! optimisation, which is unreliable on APFS/HFS+ and across git checkouts).
//! The `ignore` crate honours `.gitignore` and our built-in ignore list.

use std::path::{Path, PathBuf};

use crate::error::{Error, Result};

use super::MANIFEST_FILE;

/// One discovered manifest file (path workspace-relative).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManifestEntry {
    pub rel_path: PathBuf,
    pub abs_path: PathBuf,
}

/// Walk `workspace_root` and return every `MUSTS.yml` encountered, sorted
/// by `rel_path` for determinism.
pub fn discover(workspace_root: &Path) -> Result<Vec<ManifestEntry>> {
    let mut entries: Vec<ManifestEntry> = Vec::new();
    let root = workspace_root.to_path_buf();
    let walker = ignore::WalkBuilder::new(workspace_root)
        .standard_filters(true)
        .git_ignore(true)
        .git_exclude(true)
        // Honour `.gitignore` even outside a git repo (the workspace might
        // not be a git checkout, e.g. when discovered via the
        // MUSTS.yml fallback rule).
        .require_git(false)
        // `.mustsignore` is `.gitignore` for musts: files matched here are
        // excluded from the walk (and therefore from the scope hash), so
        // edits to them never re-invalidate checks. Must be committed —
        // a divergent `.mustsignore` across clones produces different
        // scope hashes for the same code.
        .add_custom_ignore_filename(".mustsignore")
        .hidden(false)
        .follow_links(false)
        .filter_entry(move |entry| {
            if !skip_built_in_ignores(entry) {
                return false;
            }
            !is_sub_workspace(&root, entry)
        })
        .build();

    for entry in walker {
        let entry = entry.map_err(|err| Error::Io {
            path: workspace_root.to_path_buf(),
            source: err
                .into_io_error()
                .unwrap_or_else(|| std::io::Error::other("manifest discovery error")),
        })?;
        if entry.file_type().is_some_and(|t| t.is_file()) && entry.file_name() == MANIFEST_FILE {
            let abs_path = entry.path().to_path_buf();
            let rel_path = abs_path
                .strip_prefix(workspace_root)
                .unwrap_or(&abs_path)
                .to_path_buf();
            entries.push(ManifestEntry { rel_path, abs_path });
        }
    }
    entries.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
    Ok(entries)
}

/// A directory below the workspace root is a "sub-workspace" when it
/// declares its own `.musts/` configuration. We skip these so the parent
/// walker does not try to resolve their manifests with the parent's
/// extension index — examples include test fixtures and demo
/// walkthroughs that are vendored as standalone musts workspaces.
///
/// The workspace root itself always has a `.musts/`; the `ancestors !=
/// root` check is what makes it survive this filter.
fn is_sub_workspace(workspace_root: &Path, entry: &ignore::DirEntry) -> bool {
    if !entry.file_type().is_some_and(|t| t.is_dir()) {
        return false;
    }
    if entry.path() == workspace_root {
        return false;
    }
    entry.path().join(".musts").is_dir()
}

/// Built-in ignore predicate. These directories never contain manifests we
/// care about, and walking into them on huge monorepos is wasteful.
///
/// `bazel-*` is a prefix match per `docs/PLAN.md` §4.5 — Bazel creates
/// workspace-named convenience symlinks (`bazel-<workspace>`,
/// `bazel-bin`, `bazel-out`, `bazel-testlogs`), and any of them under the
/// workspace root must be skipped.
fn skip_built_in_ignores(entry: &ignore::DirEntry) -> bool {
    let Some(name) = entry.file_name().to_str() else {
        return true;
    };
    let exact = matches!(
        name,
        ".git" | ".musts" | "node_modules" | "target" | "DerivedData" | "xcuserdata"
    );
    !(exact || name.starts_with("bazel-"))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    fn write(path: &Path, body: &str) {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(path, body).unwrap();
    }

    #[test]
    fn finds_root_and_nested_manifests_sorted() {
        let dir = TempDir::new().unwrap();
        let root = dir.path();
        write(&root.join("MUSTS.yml"), "version: 1\nchecks: {}\n");
        write(
            &root.join("App/Login/MUSTS.yml"),
            "version: 1\nchecks: {}\n",
        );
        write(
            &root.join("App/Checkout/MUSTS.yml"),
            "version: 1\nchecks: {}\n",
        );

        let entries = discover(root).unwrap();
        let rels: Vec<_> = entries
            .iter()
            .map(|e| e.rel_path.to_str().unwrap().to_string())
            .collect();
        assert_eq!(
            rels,
            vec![
                "App/Checkout/MUSTS.yml".to_string(),
                "App/Login/MUSTS.yml".to_string(),
                "MUSTS.yml".to_string(),
            ]
        );
    }

    #[test]
    fn skips_built_in_ignored_dirs() {
        let dir = TempDir::new().unwrap();
        let root = dir.path();
        write(&root.join("MUSTS.yml"), "version: 1\nchecks: {}\n");
        // These should be ignored.
        write(&root.join(".git/MUSTS.yml"), "version: 1\nchecks: {}\n");
        write(
            &root.join(".musts/extensions/MUSTS.yml"),
            "version: 1\nchecks: {}\n",
        );
        write(
            &root.join("node_modules/foo/MUSTS.yml"),
            "version: 1\nchecks: {}\n",
        );
        write(&root.join("target/MUSTS.yml"), "version: 1\nchecks: {}\n");
        // Every `bazel-*` directory (PLAN.md §4.5 prefix rule) — the
        // workspace-named convenience symlinks are the load-bearing case.
        write(
            &root.join("bazel-bin/MUSTS.yml"),
            "version: 1\nchecks: {}\n",
        );
        write(
            &root.join("bazel-out/MUSTS.yml"),
            "version: 1\nchecks: {}\n",
        );
        write(
            &root.join("bazel-testlogs/MUSTS.yml"),
            "version: 1\nchecks: {}\n",
        );
        write(
            &root.join("bazel-myrepo/MUSTS.yml"),
            "version: 1\nchecks: {}\n",
        );
        write(
            &root.join("DerivedData/MUSTS.yml"),
            "version: 1\nchecks: {}\n",
        );

        let entries = discover(root).unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].rel_path, PathBuf::from("MUSTS.yml"));
    }

    #[test]
    fn empty_workspace_returns_empty() {
        let dir = TempDir::new().unwrap();
        let entries = discover(dir.path()).unwrap();
        assert!(entries.is_empty());
    }

    #[test]
    fn skips_sub_workspaces() {
        // A child directory that declares its own `.musts/` is a
        // standalone musts workspace (vendored fixtures, demo
        // walkthroughs). The parent walker must not descend into it,
        // otherwise root validate tries to resolve manifests with the
        // wrong extension index.
        let dir = TempDir::new().unwrap();
        let root = dir.path();
        write(&root.join("MUSTS.yml"), "version: 1\nchecks: {}\n");
        write(
            &root.join("fixtures/demo/MUSTS.yml"),
            "version: 1\nchecks: {}\n",
        );
        write(
            &root.join("fixtures/demo/App/MUSTS.yml"),
            "version: 1\nchecks: {}\n",
        );
        // Demo has its own `.musts/`.
        fs::create_dir_all(root.join("fixtures/demo/.musts/extensions")).unwrap();
        // A sibling subtree without its own `.musts/` is still discovered.
        write(&root.join("src/MUSTS.yml"), "version: 1\nchecks: {}\n");

        let entries = discover(root).unwrap();
        let rels: Vec<_> = entries
            .iter()
            .map(|e| e.rel_path.to_str().unwrap().to_string())
            .collect();
        assert_eq!(
            rels,
            vec!["MUSTS.yml".to_string(), "src/MUSTS.yml".to_string(),]
        );
    }

    #[test]
    fn obeys_gitignore() {
        let dir = TempDir::new().unwrap();
        let root = dir.path();
        write(&root.join(".gitignore"), "ignored-dir/\n");
        write(&root.join("MUSTS.yml"), "version: 1\nchecks: {}\n");
        write(
            &root.join("ignored-dir/MUSTS.yml"),
            "version: 1\nchecks: {}\n",
        );
        let entries = discover(root).unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].rel_path, PathBuf::from("MUSTS.yml"));
    }

    #[test]
    fn obeys_mustsignore() {
        let dir = TempDir::new().unwrap();
        let root = dir.path();
        write(&root.join(".mustsignore"), "scratch/\n");
        write(&root.join("MUSTS.yml"), "version: 1\nchecks: {}\n");
        write(&root.join("scratch/MUSTS.yml"), "version: 1\nchecks: {}\n");
        // Sanity: a non-ignored sibling is still discovered.
        write(&root.join("app/MUSTS.yml"), "version: 1\nchecks: {}\n");
        let entries = discover(root).unwrap();
        let rels: Vec<_> = entries
            .iter()
            .map(|e| e.rel_path.to_str().unwrap().to_string())
            .collect();
        assert_eq!(
            rels,
            vec!["MUSTS.yml".to_string(), "app/MUSTS.yml".to_string(),]
        );
    }

    #[test]
    fn mustsignore_supports_negation() {
        let dir = TempDir::new().unwrap();
        let root = dir.path();
        // Same gotcha as `.gitignore`: if you ignore a *directory*, you
        // can't re-include children with `!`. The working idiom is to
        // ignore the directory's children individually so the directory
        // itself is still walked, then negate the path you want kept.
        write(
            &root.join(".mustsignore"),
            "fixtures/*\n!fixtures/canonical\n",
        );
        write(&root.join("MUSTS.yml"), "version: 1\nchecks: {}\n");
        write(
            &root.join("fixtures/throwaway/MUSTS.yml"),
            "version: 1\nchecks: {}\n",
        );
        write(
            &root.join("fixtures/canonical/MUSTS.yml"),
            "version: 1\nchecks: {}\n",
        );
        let entries = discover(root).unwrap();
        let rels: Vec<_> = entries
            .iter()
            .map(|e| e.rel_path.to_str().unwrap().to_string())
            .collect();
        assert_eq!(
            rels,
            vec![
                "MUSTS.yml".to_string(),
                "fixtures/canonical/MUSTS.yml".to_string(),
            ]
        );
    }

    #[test]
    fn nested_mustsignore_scopes_to_subtree() {
        // A `.mustsignore` in a subdirectory applies only to that subtree,
        // identical to nested `.gitignore` behaviour.
        let dir = TempDir::new().unwrap();
        let root = dir.path();
        write(&root.join("MUSTS.yml"), "version: 1\nchecks: {}\n");
        write(&root.join("app/.mustsignore"), "vendored/\n");
        write(
            &root.join("app/vendored/MUSTS.yml"),
            "version: 1\nchecks: {}\n",
        );
        // Same dir name in a sibling subtree should NOT be ignored.
        write(
            &root.join("lib/vendored/MUSTS.yml"),
            "version: 1\nchecks: {}\n",
        );
        let entries = discover(root).unwrap();
        let rels: Vec<_> = entries
            .iter()
            .map(|e| e.rel_path.to_str().unwrap().to_string())
            .collect();
        assert_eq!(
            rels,
            vec![
                "MUSTS.yml".to_string(),
                "lib/vendored/MUSTS.yml".to_string(),
            ]
        );
    }
}
