//! Bearing-project resolution per `EXEC-022`.
//!
//! [`bearing_project`] inspects a starting directory (typically
//! the process's cwd) and returns the project (if any) whose
//! canonical root contains that directory's canonical path. When
//! multiple project roots match (a permitted but rare case of
//! nested project roots), the *innermost* match wins. When no
//! project root matches, the invocation has no bearing project
//! and the caller routes through the no-bearing arms of
//! `EXEC-023` / `EXEC-024`.

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

use haz_domain::name::ProjectName;
use haz_domain::path::ProjectRoot;
use haz_domain::workspace::Workspace;

/// Return the project whose canonical root contains the canonical
/// `cwd`, or `None` when no project does.
///
/// The comparison is symlink-resolved on both sides
/// ([`std::fs::canonicalize`] is called on the cwd and on each
/// project's host-absolute root). When multiple project roots
/// match (nested project layout), the innermost match (deepest
/// canonical root) wins per `EXEC-022`.
///
/// `None` is returned when:
///
/// - The cwd fails to canonicalize (cwd was deleted under the
///   process, for instance).
/// - No project's canonical root is a prefix of the canonical cwd.
///
/// The function consults only the workspace's project roots and
/// the host filesystem; it does not load any task body.
#[must_use]
pub fn bearing_project<'w>(cwd: &Path, workspace: &'w Workspace) -> Option<&'w ProjectName> {
    let cwd_canonical = std::fs::canonicalize(cwd).ok()?;
    let workspace_root = workspace.root.as_path();

    let mut best: Option<(&ProjectName, usize)> = None;
    for (name, project) in &workspace.projects {
        let host_root = host_root(workspace_root, &project.root);
        let Ok(canon_root) = std::fs::canonicalize(&host_root) else {
            continue;
        };
        if !cwd_canonical.starts_with(&canon_root) {
            continue;
        }
        // Component count is a faithful proxy for nesting depth on
        // a canonical (symlink-resolved) path: the deeper project's
        // canonical root has strictly more components than any
        // ancestor that also matches.
        let depth = canon_root.components().count();
        match best {
            Some((_, best_depth)) if depth <= best_depth => {}
            _ => best = Some((name, depth)),
        }
    }
    best.map(|(name, _)| name)
}

/// Compute the host-absolute path of a project's root by joining
/// the workspace root with the project's workspace-relative
/// canonical path.
///
/// For `ProjectRoot::WorkspaceRoot` (implicit-mode singleton), the
/// project's host-absolute root *is* the workspace root.
fn host_root(workspace_root: &Path, project_root: &ProjectRoot) -> PathBuf {
    match project_root {
        ProjectRoot::WorkspaceRoot => workspace_root.to_path_buf(),
        ProjectRoot::Nested(canonical) => {
            let mut p = workspace_root.to_path_buf();
            for segment in canonical.segments().iter() {
                p.push(segment.as_str());
            }
            p
        }
    }
}

#[cfg(test)]
mod tests {
    use super::bearing_project;
    use std::collections::{BTreeMap, BTreeSet};
    use std::fs;
    use std::path::Path;
    use std::str::FromStr;

    use haz_domain::name::ProjectName;
    use haz_domain::path::{CanonicalPath, HazPath, ProjectRoot, WorkspaceRootPath};
    use haz_domain::project::Project;
    use haz_domain::settings::WorkspaceSettings;
    use haz_domain::workspace::Workspace;

    /// Build a `Workspace` rooted at `root_path` containing a
    /// project for each `(name, root_spec)` entry. `root_spec` is
    /// either `"/"` (implicit-mode singleton mapped to
    /// `ProjectRoot::WorkspaceRoot`) or a workspace-absolute path
    /// like `"/lib_core"` (mapped to `ProjectRoot::Nested`).
    ///
    /// Mirrors the in-memory Workspace pattern already used in
    /// `haz-exec`'s integration tests (see
    /// `tests/common/make_workspace_at`).
    fn workspace_at(root_path: &Path, projects: &[(&str, &str)]) -> Workspace {
        let mut map = BTreeMap::new();
        for (name, root_spec) in projects {
            let project_name = ProjectName::from_str(name).unwrap();
            let root = if *root_spec == "/" {
                ProjectRoot::WorkspaceRoot
            } else {
                ProjectRoot::Nested(
                    CanonicalPath::from_absolute(&HazPath::parse(root_spec).unwrap()).unwrap(),
                )
            };
            map.insert(
                project_name.clone(),
                Project {
                    name: project_name,
                    root,
                    tags: BTreeSet::new(),
                    tasks: BTreeMap::new(),
                },
            );
        }
        Workspace {
            root: WorkspaceRootPath::try_new(root_path.to_path_buf()).unwrap(),
            projects: map,
            overlays: BTreeMap::new(),
            settings: WorkspaceSettings::default(),
        }
    }

    #[test]
    fn cwd_at_workspace_root_has_no_bearing_in_explicit_mode() {
        // Explicit mode: projects under sibling directories, none
        // containing the workspace root.
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();
        fs::create_dir_all(root.join("lib_core")).unwrap();
        fs::create_dir_all(root.join("apps/web")).unwrap();
        let workspace = workspace_at(root, &[("lib_core", "/lib_core"), ("web", "/apps/web")]);
        // cwd is the workspace root, which is *outside* every
        // project root in explicit mode.
        assert!(bearing_project(root, &workspace).is_none());
    }

    #[test]
    fn cwd_inside_a_project_picks_that_project() {
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();
        fs::create_dir_all(root.join("lib_core/src")).unwrap();
        fs::create_dir_all(root.join("apps/web")).unwrap();
        let workspace = workspace_at(root, &[("lib_core", "/lib_core"), ("web", "/apps/web")]);
        let bearing = bearing_project(&root.join("lib_core/src"), &workspace).unwrap();
        assert_eq!(bearing.to_string(), "lib_core");
    }

    #[test]
    fn cwd_at_project_root_itself_picks_that_project() {
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();
        fs::create_dir_all(root.join("lib_core")).unwrap();
        let workspace = workspace_at(root, &[("lib_core", "/lib_core")]);
        let bearing = bearing_project(&root.join("lib_core"), &workspace).unwrap();
        assert_eq!(bearing.to_string(), "lib_core");
    }

    #[test]
    fn nested_project_roots_innermost_wins() {
        // Outer project at /outer; inner project at /outer/inner.
        // cwd inside the inner project picks the inner (deepest).
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();
        fs::create_dir_all(root.join("outer/inner/src")).unwrap();
        let workspace = workspace_at(root, &[("outer", "/outer"), ("inner", "/outer/inner")]);
        let bearing = bearing_project(&root.join("outer/inner/src"), &workspace).unwrap();
        assert_eq!(bearing.to_string(), "inner");
        // cwd at the *outer* project (but outside the inner) picks
        // the outer.
        let bearing_outer = bearing_project(&root.join("outer"), &workspace).unwrap();
        assert_eq!(bearing_outer.to_string(), "outer");
    }

    #[test]
    fn implicit_mode_cwd_at_workspace_root_picks_singleton_project() {
        // DISC-003: implicit-mode workspace with a single project
        // at the workspace root. Modelled by
        // `ProjectRoot::WorkspaceRoot`.
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();
        let workspace = workspace_at(root, &[("only_proj", "/")]);
        let bearing = bearing_project(root, &workspace).unwrap();
        assert_eq!(bearing.to_string(), "only_proj");
    }

    #[test]
    fn cwd_outside_every_project_root_yields_none() {
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();
        fs::create_dir_all(root.join("lib_core")).unwrap();
        // `docs/` is inside the workspace but outside every
        // project root.
        fs::create_dir_all(root.join("docs")).unwrap();
        let workspace = workspace_at(root, &[("lib_core", "/lib_core")]);
        assert!(bearing_project(&root.join("docs"), &workspace).is_none());
    }

    #[test]
    fn non_existent_cwd_yields_none() {
        let tempdir = tempfile::TempDir::new().unwrap();
        let root = tempdir.path();
        fs::create_dir_all(root.join("lib_core")).unwrap();
        let workspace = workspace_at(root, &[("lib_core", "/lib_core")]);
        // `lib_core/does-not-exist` is not on disk; canonicalize
        // fails, the function returns None.
        let bogus = root.join("lib_core/does-not-exist");
        assert!(bearing_project(&bogus, &workspace).is_none());
    }
}
