//! Workspace directory discovery — async walkers over `Environment::workspaces_root`.
//!
//! All discovery is lazy (called per-tool-invocation) and non-cached intentionally:
//! the workspace filesystem changes infrequently and caching would complicate
//! invalidation. Tools that list workspaces pay one `read_dir` call per workspace.

use crate::protocol::ToolError;
use std::path::{Path, PathBuf};
use tokio::fs;

// ── Public types ──────────────────────────────────────────────────────────────

#[derive(Debug, Clone)]
pub struct WorkspaceEntry {
    pub name: String,
    pub path: PathBuf,
}

#[derive(Debug, Clone)]
pub struct InfraEntry {
    pub workspace: String,
    pub environment: String,
    pub path: PathBuf,
}

#[derive(Debug, Clone)]
pub struct ComponentEntry {
    pub workspace: String,
    pub environment: String,
    /// Stem of the NCL file (`fip`, `k0s`, …).
    pub name: String,
    pub path: PathBuf,
}

#[derive(Debug, Clone)]
pub struct WorkflowEntry {
    pub workspace: String,
    pub environment: String,
    pub name: String,
    pub path: PathBuf,
}

// ── Discovery functions ───────────────────────────────────────────────────────

/// Return all workspace entries (immediate subdirectories of `root`).
///
/// Hidden directories (`.coder`, `.claude`, …) are skipped.
pub async fn list_workspaces(root: &Path) -> Result<Vec<WorkspaceEntry>, ToolError> {
    let mut entries = Vec::new();
    let mut dir = fs::read_dir(root).await.map_err(|e| {
        ToolError::not_found(format!("workspaces_root {}: {e}", root.display()))
    })?;

    while let Some(entry) = dir.next_entry().await.map_err(io_err)? {
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        let name = dir_name(&path);
        if name.starts_with('.') {
            continue;
        }
        entries.push(WorkspaceEntry { name, path });
    }

    entries.sort_by(|a, b| a.name.cmp(&b.name));
    Ok(entries)
}

/// Return the workspace entry for `name`, or `ToolError::NotFound`.
pub async fn get_workspace(root: &Path, name: &str) -> Result<WorkspaceEntry, ToolError> {
    let path = root.join(name);
    if !path.is_dir() {
        return Err(ToolError::not_found(format!("workspace '{name}'")));
    }
    Ok(WorkspaceEntry { name: name.to_owned(), path })
}

/// Return all `infra/<env>` environments inside `workspace_path`.
pub async fn list_infra_envs(
    workspace: &str,
    workspace_path: &Path,
) -> Result<Vec<InfraEntry>, ToolError> {
    let infra_root = workspace_path.join("infra");
    if !infra_root.is_dir() {
        return Ok(Vec::new());
    }

    let mut entries = Vec::new();
    let mut dir = fs::read_dir(&infra_root).await.map_err(io_err)?;
    while let Some(entry) = dir.next_entry().await.map_err(io_err)? {
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        let env_name = dir_name(&path);
        if env_name.starts_with('.') {
            continue;
        }
        entries.push(InfraEntry {
            workspace: workspace.to_owned(),
            environment: env_name,
            path,
        });
    }

    entries.sort_by(|a, b| a.environment.cmp(&b.environment));
    Ok(entries)
}

/// Return all components (`infra/<env>/components/*.ncl`) for `env_path`.
pub async fn list_components(
    workspace: &str,
    environment: &str,
    env_path: &Path,
) -> Result<Vec<ComponentEntry>, ToolError> {
    ncl_entries_in_dir(
        env_path.join("components"),
        |name, path| ComponentEntry {
            workspace: workspace.to_owned(),
            environment: environment.to_owned(),
            name,
            path,
        },
    )
    .await
}

/// Return all workflows (`infra/<env>/workflows/*.ncl`) for `env_path`.
pub async fn list_workflows(
    workspace: &str,
    environment: &str,
    env_path: &Path,
) -> Result<Vec<WorkflowEntry>, ToolError> {
    ncl_entries_in_dir(
        env_path.join("workflows"),
        |name, path| WorkflowEntry {
            workspace: workspace.to_owned(),
            environment: environment.to_owned(),
            name,
            path,
        },
    )
    .await
}

// ── Helpers ───────────────────────────────────────────────────────────────────

fn dir_name(p: &Path) -> String {
    p.file_name().and_then(|n| n.to_str()).unwrap_or("").to_owned()
}

fn io_err(e: std::io::Error) -> ToolError {
    ToolError::invocation(e.to_string())
}

async fn ncl_entries_in_dir<T, F>(dir: PathBuf, make: F) -> Result<Vec<T>, ToolError>
where
    F: Fn(String, PathBuf) -> T,
    T: Ord + Clone,
    T: std::fmt::Debug,
{
    if !dir.is_dir() {
        return Ok(Vec::new());
    }

    let mut entries = Vec::new();
    let mut read = fs::read_dir(&dir).await.map_err(io_err)?;
    while let Some(entry) = read.next_entry().await.map_err(io_err)? {
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) != Some("ncl") {
            continue;
        }
        let name = path
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or("")
            .to_owned();
        if name.is_empty() || name.starts_with('.') {
            continue;
        }
        entries.push(make(name, path));
    }

    Ok(entries)
}

// ── Ord impls (needed for ncl_entries_in_dir bound) ──────────────────────────

impl PartialEq for ComponentEntry {
    fn eq(&self, other: &Self) -> bool {
        self.path == other.path
    }
}
impl Eq for ComponentEntry {}
impl PartialOrd for ComponentEntry {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}
impl Ord for ComponentEntry {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.name.cmp(&other.name)
    }
}

impl PartialEq for WorkflowEntry {
    fn eq(&self, other: &Self) -> bool {
        self.path == other.path
    }
}
impl Eq for WorkflowEntry {}
impl PartialOrd for WorkflowEntry {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}
impl Ord for WorkflowEntry {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.name.cmp(&other.name)
    }
}

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

    fn scaffold_workspace(root: &Path) {
        // workspace: alpha
        sfs::create_dir_all(root.join("alpha/infra/prod/components")).unwrap();
        sfs::create_dir_all(root.join("alpha/infra/prod/workflows")).unwrap();
        sfs::write(root.join("alpha/infra/prod/components/fip.ncl"), "{}").unwrap();
        sfs::write(root.join("alpha/infra/prod/components/k0s.ncl"), "{}").unwrap();
        sfs::write(root.join("alpha/infra/prod/workflows/deploy.ncl"), "{}").unwrap();
        sfs::write(root.join("alpha/infra/prod/servers.ncl"), "{}").unwrap();
        // hidden dir — must be skipped
        sfs::create_dir_all(root.join(".git")).unwrap();
        // workspace: beta (no infra/)
        sfs::create_dir_all(root.join("beta")).unwrap();
    }

    #[tokio::test]
    async fn list_workspaces_skips_hidden() {
        let dir = TempDir::new().unwrap();
        scaffold_workspace(dir.path());

        let ws = list_workspaces(dir.path()).await.unwrap();
        let names: Vec<_> = ws.iter().map(|w| w.name.as_str()).collect();
        assert_eq!(names, &["alpha", "beta"]);
    }

    #[tokio::test]
    async fn get_workspace_missing_returns_not_found() {
        let dir = TempDir::new().unwrap();
        let err = get_workspace(dir.path(), "nonexistent").await.unwrap_err();
        assert!(matches!(err, ToolError::NotFound(_)));
    }

    #[tokio::test]
    async fn list_infra_envs_returns_prod() {
        let dir = TempDir::new().unwrap();
        scaffold_workspace(dir.path());
        let ws_path = dir.path().join("alpha");

        let envs = list_infra_envs("alpha", &ws_path).await.unwrap();
        assert_eq!(envs.len(), 1);
        assert_eq!(envs[0].environment, "prod");
    }

    #[tokio::test]
    async fn list_components_returns_ncl_files() {
        let dir = TempDir::new().unwrap();
        scaffold_workspace(dir.path());
        let env_path = dir.path().join("alpha/infra/prod");

        let comps = list_components("alpha", "prod", &env_path).await.unwrap();
        let names: Vec<_> = comps.iter().map(|c| c.name.as_str()).collect();
        assert!(names.contains(&"fip"));
        assert!(names.contains(&"k0s"));
        assert_eq!(comps.len(), 2);
    }

    #[tokio::test]
    async fn list_workflows_returns_deploy() {
        let dir = TempDir::new().unwrap();
        scaffold_workspace(dir.path());
        let env_path = dir.path().join("alpha/infra/prod");

        let flows = list_workflows("alpha", "prod", &env_path).await.unwrap();
        assert_eq!(flows.len(), 1);
        assert_eq!(flows[0].name, "deploy");
    }

    #[tokio::test]
    async fn no_components_dir_returns_empty() {
        let dir = TempDir::new().unwrap();
        let env_path = dir.path().join("alpha/infra/prod");
        sfs::create_dir_all(&env_path).unwrap();

        let comps = list_components("alpha", "prod", &env_path).await.unwrap();
        assert!(comps.is_empty());
    }
}
