//! 历史项目发现 — monorepo 识别 + 子项目展开(纯规则,不跑 LLM)。

use std::path::Path;

use anyhow::Result;

use super::stack::{detect_stack, has_file_with_ext};
use super::SAMPLE_IGNORED_DIRS;

/// monorepo 工作区配置文件名(JS 生态主流:pnpm/lerna/turbo/nx)
const MONOREPO_MARKERS: &[&str] = &[
    "pnpm-workspace.yaml",
    "lerna.json",
    "turbo.json",
    "nx.json",
];

/// 判定目录是否为 monorepo 根(JS 生态主流工作区管理器)。
///
/// 命中任一即视为 monorepo:
/// - pnpm-workspace.yaml / lerna.json / turbo.json / nx.json 存在
/// - package.json 含 `workspaces` 字段(npm/yarn workspaces)
pub fn is_monorepo(root: &Path) -> bool {
    if !root.is_dir() {
        return false;
    }
    for marker in MONOREPO_MARKERS {
        if root.join(marker).is_file() {
            return true;
        }
    }
    // package.json workspaces 字段(npm/yarn)。数组(`["packages/*"]`)或对象
    // (`{"packages":[...]}`,Yarn)均为真正的工作区;JSON 显式 null 表示「无」,
    // 不应误判为 monorepo(`.is_some()` 对 key 存在但值为 null 仍返回 true → 误报)。
    let pkg_path = root.join("package.json");
    if pkg_path.is_file() {
        if let Ok(content) = std::fs::read_to_string(&pkg_path) {
            if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
                if pkg.get("workspaces").is_some_and(|v| !v.is_null()) {
                    return true;
                }
            }
        }
    }
    false
}

/// 单个发现的候选项目(monorepo 子项目或独立项目)
#[derive(Debug, Clone)]
pub struct DiscoveredProject {
    /// 项目根目录绝对路径
    pub path: String,
    /// 推断的项目名(目录名)
    pub name: String,
    /// 规则探测的技术栈(空=未识别)
    pub stack: Vec<String>,
    /// 是否为 monorepo 根(便于前端标记)
    pub is_monorepo: bool,
}

/// 在指定根目录下发现候选项目。
///
/// 策略(只展开一层,不做深递归):
/// 1. 根目录本身有项目标志(Cargo.toml/package.json/go.mod 等)→ 根为独立项目
/// 2. 根目录是 monorepo → 展开 packages/\*/apps/\* 直接子目录(各子目录跑 detect_stack 过滤空)
/// 3. 否则:扫根的直接子目录,凡 detect_stack 非空的视为候选项目
///
/// 不跑 LLM(快),不读源码。空 stack 的目录在 monorepo 展开/子目录扫描时被过滤。
pub fn discover_projects(root: &Path) -> Result<Vec<DiscoveredProject>> {
    if !root.is_dir() {
        anyhow::bail!("路径不是目录: {}", root.display());
    }

    let mut out: Vec<DiscoveredProject> = Vec::new();
    let mono = is_monorepo(root);

    // 1. 根目录自身是项目(有 manifest 标志)
    if has_project_manifest(root) {
        let stack = detect_stack(root).unwrap_or_default();
        out.push(DiscoveredProject {
            path: root.to_string_lossy().to_string(),
            name: root
                .file_name()
                .and_then(|n| n.to_str())
                .map(|s| s.to_string())
                .unwrap_or_else(|| root.to_string_lossy().to_string()),
            stack,
            is_monorepo: mono,
        });
    }

    // 2. monorepo → 展开 packages/* apps/* 直接子目录
    // 3. 普通目录 → 扫直接子目录,凡 detect_stack 非空的入选
    let scan_globs: &[&str] = if mono {
        &["packages", "apps"]
    } else {
        &[""]
    };

    for glob in scan_globs {
        let target = if glob.is_empty() {
            root.to_path_buf()
        } else {
            root.join(glob)
        };
        if !target.is_dir() {
            continue;
        }
        let Ok(entries) = std::fs::read_dir(&target) else {
            continue;
        };
        for e in entries.flatten() {
            let p = e.path();
            if !p.is_dir() {
                continue;
            }
            let name = e.file_name().to_string_lossy().to_string();
            if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) || name.starts_with('.') {
                continue;
            }
            // 必须有项目标志 + detect_stack 非空
            if !has_project_manifest(&p) {
                continue;
            }
            let stack = match detect_stack(&p) {
                Ok(s) => s,
                Err(_) => continue,
            };
            if stack.is_empty() {
                continue;
            }
            out.push(DiscoveredProject {
                path: p.to_string_lossy().to_string(),
                name,
                stack,
                is_monorepo: false,
            });
        }
    }

    Ok(out)
}

/// 目录是否含任一项目清单标志文件
fn has_project_manifest(dir: &Path) -> bool {
    const MARKS: &[&str] = &[
        "Cargo.toml",
        "package.json",
        "go.mod",
        "pyproject.toml",
        "requirements.txt",
        "pom.xml",
        "build.gradle",
        "build.gradle.kts",
    ];
    MARKS.iter().any(|m| dir.join(m).is_file()) || has_file_with_ext(dir, "csproj")
}

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

    /// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
    fn scratch(name: &str) -> PathBuf {
        let mut p = std::env::temp_dir();
        p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
        let _ = fs::remove_dir_all(&p);
        fs::create_dir_all(&p).unwrap();
        p
    }

    #[test]
    fn detects_monorepo_pnpm() {
        let d = scratch("mono-pnpm");
        fs::write(d.join("pnpm-workspace.yaml"), "packages:\n  - packages/*\n").unwrap();
        assert!(is_monorepo(&d));
        fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn detects_monorepo_npm_workspaces() {
        let d = scratch("mono-npm");
        fs::write(
            d.join("package.json"),
            r#"{"name":"root","workspaces":["packages/*"]}"#,
        )
        .unwrap();
        assert!(is_monorepo(&d));
        fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn detects_non_monorepo() {
        let d = scratch("nonmono");
        fs::write(d.join("package.json"), r#"{"name":"x"}"#).unwrap();
        assert!(!is_monorepo(&d));
        fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn discover_monorepo_children() {
        let d = scratch("discover-mono");
        fs::write(d.join("pnpm-workspace.yaml"), "packages:\n  - packages/*\n").unwrap();
        // 子项目:packages/web(有 package.json + vue)、packages/cli(有 Cargo.toml)
        fs::create_dir_all(d.join("packages/web")).unwrap();
        fs::write(
            d.join("packages/web/package.json"),
            r#"{"name":"web","dependencies":{"vue":"3"}}"#,
        )
        .unwrap();
        fs::create_dir_all(d.join("packages/cli")).unwrap();
        fs::write(d.join("packages/cli/Cargo.toml"), "[package]\nname=\"cli\"\n").unwrap();
        // 空 stack 子目录应过滤
        fs::create_dir_all(d.join("packages/empty")).unwrap();
        fs::write(d.join("packages/empty/x.txt"), "x").unwrap();
        let found = discover_projects(&d).unwrap();
        // 根自身无 manifest 不入选;packages/web + packages/cli 入选;empty 过滤
        let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
        assert!(names.contains(&"web"), "names={names:?}");
        assert!(names.contains(&"cli"), "names={names:?}");
        assert!(!names.contains(&"empty"), "空 stack 未过滤: {names:?}");
        fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn discover_flat_children() {
        // 非 monorepo:扫根直接子目录中 detect_stack 非空的
        let d = scratch("discover-flat");
        fs::create_dir_all(d.join("proj-a")).unwrap();
        fs::write(d.join("proj-a/Cargo.toml"), "").unwrap();
        fs::create_dir_all(d.join("not-a-project")).unwrap();
        fs::write(d.join("not-a-project/readme.txt"), "x").unwrap();
        let found = discover_projects(&d).unwrap();
        let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
        assert!(names.contains(&"proj-a"), "names={names:?}");
        assert!(!names.contains(&"not-a-project"), "空 stack 未过滤: {names:?}");
        fs::remove_dir_all(&d).ok();
    }

    // ── is_monorepo workspaces:null 防回归(wd2fnjh3s) ──
    // .is_some_and(!is_null) 修复点:key 存在但值为 JSON null 时不得判为 monorepo。
    #[test]
    fn is_monorepo_workspaces_null_not_misclassified() {
        let d = scratch("ws-null");
        fs::write(
            d.join("package.json"),
            r#"{"name":"x","workspaces":null}"#,
        )
        .unwrap();
        assert!(
            !is_monorepo(&d),
            "workspaces: null 不应判为 monorepo(回归 wd2fnjh3s)"
        );
        fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn is_monorepo_workspaces_object_treated_as_real() {
        // Yarn 形式 {"packages":[...]} —— 非 null,应判为 monorepo
        let d = scratch("ws-obj");
        fs::write(
            d.join("package.json"),
            r#"{"name":"y","workspaces":{"packages":["packages/*"]}}"#,
        )
        .unwrap();
        assert!(is_monorepo(&d), "Yarn workspaces 对象形式应判为 monorepo");
        fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn is_monorepo_non_dir_returns_false() {
        assert!(!is_monorepo(Path::new("definitely-not-exist-xyz-456")));
    }
}
