use std::collections::{BTreeSet, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use tracing::info;

/// Returns `true` if the file at `path` contains an `alef:hash:` line in its
/// first 10 lines — the cryptographic marker that alef's finalizer appends.
///
/// Loose markers such as `"auto-generated by alef"` or `"DO NOT EDIT"` are
/// intentionally ignored: they appear in countless vendored files (cgo headers,
/// swig output, autoconf artefacts) that alef must never delete.
pub(crate) fn has_alef_hash(path: &Path) -> bool {
    let Ok(content) = fs::read_to_string(path) else {
        return false;
    };
    crate::core::hash::extract_hash(&content).is_some()
}

/// Returns `true` if the file at `path` is recognizably alef-owned even when
/// the cryptographic `alef:hash:` line is missing.
///
/// Backwards-compatibility safety net for files emitted by older alef versions
/// (or files whose hash line was stripped by a post-write tool). Detection
/// requires the specific phrase `auto-generated by alef` (case-sensitive) inside
/// the first ten lines. The looser `by alef` substring used previously
/// false-matched descriptive prose like `# Test apps are driven by alef` in
/// files that were never alef-emitted, causing accidental deletion. Tightening
/// to the full template phrase keeps cgo, swig, autoconf, bindgen, protobuf,
/// and arbitrary documentation prose safe — only alef's own `// This file is
/// auto-generated by alef. DO NOT EDIT.` header templates match.
pub(crate) fn has_alef_self_referential_header(path: &Path) -> bool {
    let Ok(content) = fs::read_to_string(path) else {
        return false;
    };
    content
        .lines()
        .take(10)
        .any(|line| line.contains("auto-generated by alef"))
}

/// Clean up orphan alef-generated files that are no longer in the current generation output.
///
/// Strategy: walk only the directories where the current run actually wrote files
/// (the parent dir of every entry in `current_gen_paths`). For each file in those
/// directories whose first lines contain an alef-generated header marker, if its
/// canonicalized absolute path is NOT in the current run's path set, delete it.
///
/// Walking only the parents of just-written files is what keeps the cleanup safe
/// when callers (e.g. `alef generate`) emit only a subset of categories: scaffold
/// dirs that the current run did not touch are never visited, so untouched files
/// in those dirs (e.g. user-customized package manifests) are preserved.
pub fn cleanup_orphaned_files(current_gen_paths: &HashSet<PathBuf>) -> anyhow::Result<usize> {
    if current_gen_paths.is_empty() {
        return Ok(0);
    }

    // Normalize current_gen_paths so the comparison below is consistent. canonicalize()
    // resolves `.` / `..` / symlinks. If a file does not exist (yet), fall back to the
    // raw absolute path. The set is what we compare against during the walk.
    let normalized: HashSet<PathBuf> = current_gen_paths
        .iter()
        .map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()))
        .collect();

    // Collect the set of parent directories actually touched in this run.
    // Canonicalize so cross-platform path resolution (e.g. macOS /tmp vs.
    // /private/tmp symlinks) does not silently break the descend-check
    // comparisons in `cleanup_dir_recursive`, which always sees canonicalized
    // subdirectory paths.
    let touched_dirs: BTreeSet<PathBuf> = current_gen_paths
        .iter()
        .filter_map(|p| p.parent().map(|d| d.canonicalize().unwrap_or_else(|_| d.to_path_buf())))
        .collect();

    let mut removed_count = 0;
    let mut visited_dirs: HashSet<PathBuf> = HashSet::new();

    for dir in &touched_dirs {
        if !dir.exists() {
            continue;
        }
        let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.clone());
        if !visited_dirs.insert(canonical_dir.clone()) {
            continue;
        }
        // Skip entire touched subtrees that live under a dependency-cache
        // ancestor (e.g. test_apps/<lang>/tests/). The recursive descent
        // already short-circuits direct subdirs by name; this guards the
        // entry point so registry-mode writes into a protected subtree do
        // not pull the walker into a sweep of curated downstream files.
        if has_dependency_cache_ancestor(&canonical_dir) {
            continue;
        }
        removed_count += cleanup_dir_recursive(&canonical_dir, &normalized, &touched_dirs)?;
    }

    Ok(removed_count)
}

/// Walk `dir` and remove orphan alef-generated files. Recurses into subdirectories
/// that are themselves touched, that contain a touched path, or that live beneath
/// a touched directory. The third clause catches orphans in subtrees a backend
/// previously owned but no longer writes to (e.g. the kotlin-android backend
/// dropped its `src/main/java/` Java DTO emit and left stale alef-marked Java
/// files behind). The `has_alef_hash` gate is the safety net that prevents
/// deletion of user-customised files and vendored artefacts.
/// Consumer dependency cache directory names. The cleanup walker must NEVER
/// descend into these because they contain alef-generated files that were
/// installed from registries (PyPI venv, Hex deps, npm node_modules, Composer
/// vendor, Bundler vendor, Rust target, etc.). Those files have legitimate
/// `alef:hash:` headers from the published package but are not part of the
/// current generator output — they belong to a downstream consumer's cache.
const DEPENDENCY_CACHE_DIRS: &[&str] = &[
    ".venv",
    "venv",
    "__pypackages__",
    "node_modules",
    "deps",   // Elixir/mix hex deps
    "_build", // Elixir/mix build
    "vendor", // Composer / Bundler
    "target", // Rust cargo build (also typically gitignored)
    ".cargo",
    "pkg",     // wasm-pack output
    ".gradle", // Gradle build cache
    ".m2",     // Maven local repo
    ".cache",
    // test_apps/ holds consumer-owned downstream test projects in the
    // polyglot binding repository pattern. Files there may carry alef:hash:
    // headers from version-sync text_replacements or from prior registry-mode
    // e2e generation, but the surrounding scaffolding (composer.json,
    // build.gradle.kts, Cargo.toml, …) is curated by humans. Treat the whole
    // tree as opaque to the cleanup walker: alef may still write into it from
    // the registry-mode e2e backend, but orphan-delete is unsafe here because
    // partial regeneration would remove curated files that lost their previous
    // companions.
    "test_apps",
];

/// True if `dir`'s own basename matches a dependency-cache name. Used inside
/// the recursive descent to short-circuit `node_modules/`, `vendor/`, etc.
fn is_consumer_dependency_dir(dir: &Path) -> bool {
    dir.file_name()
        .and_then(|n| n.to_str())
        .is_some_and(|name| DEPENDENCY_CACHE_DIRS.contains(&name))
}

/// True if any path component of `dir` matches a dependency-cache name. Used
/// at the touched-dirs entry point because `current_gen_paths.parent()` can
/// land on a path like `test_apps/python/tests/` whose basename (`tests`)
/// would otherwise sneak past `is_consumer_dependency_dir` and trigger
/// orphan-delete inside a protected subtree.
fn has_dependency_cache_ancestor(dir: &Path) -> bool {
    dir.components()
        .filter_map(|c| c.as_os_str().to_str())
        .any(|name| DEPENDENCY_CACHE_DIRS.contains(&name))
}

fn cleanup_dir_recursive(
    dir: &Path,
    normalized_gen_paths: &HashSet<PathBuf>,
    touched_dirs: &BTreeSet<PathBuf>,
) -> anyhow::Result<usize> {
    let mut removed_count = 0;
    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();

        if path.is_dir() {
            // Skip well-known consumer dependency cache dirs unconditionally.
            // These directories hold alef-generated files installed from
            // registries (.venv, deps, node_modules, vendor, …) — they belong
            // to the consumer's install cache, not the current emission.
            if is_consumer_dependency_dir(&path) {
                continue;
            }
            // Recurse if the subdirectory itself is touched, contains a touched
            // path, OR is a descendant of any touched dir. Combined with the
            // alef-header check below, this lets us sweep stale binding output
            // in subtrees that the current run no longer writes to without
            // touching user files.
            let canonical_sub = path.canonicalize().unwrap_or_else(|_| path.clone());
            let descend = touched_dirs
                .iter()
                .any(|td| td == &canonical_sub || td.starts_with(&canonical_sub) || canonical_sub.starts_with(td));
            if descend {
                removed_count += cleanup_dir_recursive(&path, normalized_gen_paths, touched_dirs)?;
            }
            continue;
        }

        // Two acceptance paths:
        //  1. Cryptographic `alef:hash:` line — the canonical alef marker.
        //  2. Self-referential header — backwards-compatibility for files
        //     emitted by older alef versions that wrote a "auto-generated by
        //     alef" comment + a "To regenerate: alef ..." line but never the
        //     cryptographic hash. Without this path, those files persist
        //     forever as orphans once a later alef version excludes them
        //     (e.g. via `[crates.exclude]` or `#[cfg_attr(alef, alef(skip))]`).
        //     The two-marker requirement keeps cgo/swig/autoconf output safe.
        if !has_alef_hash(&path) && !has_alef_self_referential_header(&path) {
            continue;
        }

        let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
        if !normalized_gen_paths.contains(&canonical_path) {
            info!("Removing stale alef-generated file: {}", path.display());
            fs::remove_file(&path)?;
            removed_count += 1;
        }
    }

    Ok(removed_count)
}

#[cfg(test)]
mod tests {
    use super::{cleanup_orphaned_files, has_alef_hash};
    use std::collections::HashSet;
    use std::fs;

    /// A representative 64-char hex string used as a stand-in for a real alef hash
    /// in test fixtures. The actual hex value is irrelevant — only the presence of
    /// the `alef:hash:` prefix matters for ownership detection.
    const TEST_HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";

    #[test]
    fn cleanup_removes_orphan_with_alef_hash_header() {
        let tempdir = tempfile::tempdir().expect("tempdir");
        let package_dir = tempdir.path().join("packages/kotlin/src/main/kotlin/dev/demo");
        fs::create_dir_all(&package_dir).expect("create package dir");

        let current_file = package_dir.join("GraphQLRouteConfig.kt");
        let stale_file = package_dir.join("DefaultClient.kt");
        let alef_header = format!("// alef:hash:{TEST_HASH}\n\n");
        fs::write(&current_file, format!("{alef_header}class GraphQLRouteConfig\n")).expect("write current file");
        fs::write(&stale_file, format!("{alef_header}class DefaultClient\n")).expect("write stale file");

        let current_gen_paths = HashSet::from([current_file.clone()]);

        let removed = cleanup_orphaned_files(&current_gen_paths).expect("cleanup");

        assert_eq!(removed, 1);
        assert!(current_file.exists());
        assert!(!stale_file.exists());
    }

    /// Loose markers such as "Generated by alef" or "DO NOT EDIT" without the
    /// `alef:hash:` line must NOT trigger deletion — they appear in vendored cgo
    /// headers, swig output, and other consumer-managed files.
    #[test]
    fn cleanup_preserves_file_with_loose_marker_but_no_hash() {
        let tempdir = tempfile::tempdir().expect("tempdir");
        let package_dir = tempdir.path().join("packages/go/include");
        fs::create_dir_all(&package_dir).expect("create dir");

        // Simulate a vendored cgo header: has a "DO NOT EDIT" comment but no alef:hash.
        let vendored = package_dir.join("sample_crawler.h");
        fs::write(
            &vendored,
            "// DO NOT EDIT — generated by cgo. See CGO_ENABLED.\n#ifndef SAMPLE_CRAWLER_H\n#define SAMPLE_CRAWLER_H\n#endif\n",
        )
        .expect("write vendored header");

        // Put another alef-owned file in the same dir so the dir IS in touched_dirs.
        let alef_file = package_dir.join("bindings.go");
        fs::write(&alef_file, format!("// alef:hash:{TEST_HASH}\npackage main\n")).expect("write alef file");

        let current_gen_paths = HashSet::from([alef_file.clone()]);
        let removed = cleanup_orphaned_files(&current_gen_paths).expect("cleanup");

        assert_eq!(removed, 0, "vendored file without alef:hash must not be deleted");
        assert!(vendored.exists(), "vendored cgo header must survive");
        assert!(alef_file.exists(), "current alef file must survive");
    }

    /// `has_alef_hash` must return true only when the `alef:hash:` line is present.
    #[test]
    fn has_alef_hash_detects_hash_line() {
        let tempdir = tempfile::tempdir().expect("tempdir");
        let with_hash = tempdir.path().join("with_hash.rs");
        let without_hash = tempdir.path().join("without_hash.rs");
        fs::write(&with_hash, format!("// alef:hash:{TEST_HASH}\nfn main() {{}}\n")).expect("write");
        fs::write(
            &without_hash,
            "// auto-generated by alef\n// DO NOT EDIT\nfn main() {}\n",
        )
        .expect("write");

        assert!(has_alef_hash(&with_hash), "must detect alef:hash: line");
        assert!(!has_alef_hash(&without_hash), "must not match loose markers");
    }

    /// Regression: stale alef-emitted files from older versions (or files whose
    /// hash line was stripped by a downstream tool) must still be cleanable.
    /// Detection requires BOTH the alef-generated comment AND a self-referential
    /// `alef ...` regenerate command — together they uniquely identify alef-
    /// emitted output without false-matching cgo/swig/autoconf artefacts.
    #[test]
    fn cleanup_removes_stale_file_with_alef_header_but_no_hash() {
        use super::has_alef_self_referential_header;

        let tempdir = tempfile::tempdir().expect("tempdir");
        let java_dir = tempdir.path().join("packages/java/dev/demo");
        fs::create_dir_all(&java_dir).expect("create dir");

        // Stale Java file emitted by an older alef version: standard 3-line
        // header but no `alef:hash:` injection.
        let stale = java_dir.join("FixedDelayHedge.java");
        fs::write(
            &stale,
            "// This file is auto-generated by alef — DO NOT EDIT.\n\
             // To regenerate: alef generate\n\
             // To verify freshness: alef verify --exit-code\n\
             package dev.demo;\npublic class FixedDelayHedge {}\n",
        )
        .expect("write stale file");

        // Sentinel file with both header and hash so the dir IS in touched_dirs.
        let fresh = java_dir.join("AssistantMessage.java");
        fs::write(
            &fresh,
            format!("// auto-generated by alef\n// alef:hash:{TEST_HASH}\npackage dev.demo;\npublic class AssistantMessage {{}}\n"),
        )
        .expect("write fresh file");

        // Sanity-check the detection helpers.
        assert!(has_alef_self_referential_header(&stale));
        assert!(!has_alef_self_referential_header(
            java_dir.parent().unwrap().parent().unwrap()
        ));

        let current_gen_paths = HashSet::from([fresh.clone()]);
        let removed = cleanup_orphaned_files(&current_gen_paths).expect("cleanup");

        assert_eq!(removed, 1, "stale file with alef header must be removed");
        assert!(!stale.exists(), "stale java file should be deleted");
        assert!(fresh.exists(), "fresh java file must survive");
    }

    /// Conversely: a vendored cgo/swig header that mentions "auto-generated by"
    /// but does NOT reference an `alef` command must still survive — the
    /// self-referential check is what keeps the two paths distinguishable.
    #[test]
    fn cleanup_preserves_cgo_style_header_without_alef_command() {
        use super::has_alef_self_referential_header;

        let tempdir = tempfile::tempdir().expect("tempdir");
        let include_dir = tempdir.path().join("packages/go/include");
        fs::create_dir_all(&include_dir).expect("create dir");

        let cgo = include_dir.join("auto.h");
        fs::write(
            &cgo,
            "// auto-generated by autoconf, DO NOT EDIT\n#ifndef AUTO_H\n#define AUTO_H\n#endif\n",
        )
        .expect("write cgo header");

        let alef_file = include_dir.join("bindings.go");
        fs::write(&alef_file, format!("// alef:hash:{TEST_HASH}\npackage main\n")).expect("write");

        assert!(
            !has_alef_self_referential_header(&cgo),
            "cgo header without alef command must NOT register as alef-owned"
        );

        let current_gen_paths = HashSet::from([alef_file.clone()]);
        let removed = cleanup_orphaned_files(&current_gen_paths).expect("cleanup");

        assert_eq!(removed, 0, "cgo header must survive");
        assert!(cgo.exists(), "cgo header preserved");
        assert!(alef_file.exists(), "fresh alef file preserved");
    }

    /// Regression: descriptive prose mentioning alef in the header (e.g.
    /// `# Test apps are driven by alef`) must NOT register as alef-owned.
    /// Before tightening, the loose `by alef` substring matched any line
    /// containing "by alef" — including `.task/test-apps.yml`'s descriptive
    /// header — and alef's cleanup pass deleted it on every regen, breaking
    /// Taskfile.yml's include of the file.
    #[test]
    fn cleanup_preserves_file_with_descriptive_prose_mentioning_alef() {
        use super::has_alef_self_referential_header;

        let tempdir = tempfile::tempdir().expect("tempdir");
        let task_dir = tempdir.path().join(".task");
        fs::create_dir_all(&task_dir).expect("create .task dir");

        let descriptive = task_dir.join("test-apps.yml");
        fs::write(
            &descriptive,
            "version: \"3\"\n\
             \n\
             # Test apps are driven by alef (`alef test-apps run`). The per-language\n\
             # commands live in alef.toml; a single alef invocation exercises every\n\
             # published binding consistently. Homebrew is run separately because\n\
             # alef does not yet manage the brew tap.\n\
             \n\
             tasks:\n  all:\n    cmds:\n      - echo hi\n",
        )
        .expect("write descriptive file");

        let alef_file = task_dir.join("c.yml");
        fs::write(&alef_file, format!("# alef:hash:{TEST_HASH}\nversion: \"3\"\n")).expect("write alef file");

        assert!(
            !has_alef_self_referential_header(&descriptive),
            "descriptive prose mentioning alef must NOT register as alef-owned"
        );

        let current_gen_paths = HashSet::from([alef_file.clone()]);
        let removed = cleanup_orphaned_files(&current_gen_paths).expect("cleanup");

        assert_eq!(removed, 0, "descriptive yaml file must survive");
        assert!(descriptive.exists(), "descriptive file preserved");
        assert!(alef_file.exists(), "fresh alef file preserved");
    }

    /// Regression: orphans in a sibling subtree of a touched directory must be
    /// swept. This models the kotlin-android case where the backend wrote
    /// Java DTOs into `src/main/java/` in older versions, then dropped that
    /// emit but kept writing Kotlin to `src/main/kotlin/`. The stale Java
    /// orphans live in a subtree the current run never writes to, but they
    /// are descendants of the package root that IS touched (via
    /// `build.gradle.kts` etc.).
    #[test]
    fn cleanup_removes_orphan_in_sibling_subtree_of_touched_dir() {
        let tempdir = tempfile::tempdir().expect("tempdir");
        let package_root = tempdir.path().join("packages/kotlin-android");
        let kotlin_dir = package_root.join("src/main/kotlin/dev/demo/android");
        let java_dir = package_root.join("src/main/java/dev/demo");
        fs::create_dir_all(&kotlin_dir).expect("create kotlin dir");
        fs::create_dir_all(&java_dir).expect("create java dir");

        let alef_header = format!("// alef:hash:{TEST_HASH}\n");
        let build_gradle = package_root.join("build.gradle.kts");
        let bridge_kt = kotlin_dir.join("DemoBridge.kt");
        let stale_java = java_dir.join("CrawlEngineHandle.java");
        let user_java = java_dir.join("UserCode.java");
        fs::write(&build_gradle, format!("{alef_header}plugins {{}}\n")).expect("write build.gradle.kts");
        fs::write(&bridge_kt, format!("{alef_header}object DemoBridge\n")).expect("write bridge.kt");
        fs::write(
            &stale_java,
            format!("{alef_header}public class CrawlEngineHandle {{}}\n"),
        )
        .expect("write stale java");
        // User-customised file in the same orphan subtree — must survive.
        fs::write(&user_java, "// hand-written\npublic class UserCode {}\n").expect("write user java");

        let current_gen_paths = HashSet::from([build_gradle.clone(), bridge_kt.clone()]);
        let removed = cleanup_orphaned_files(&current_gen_paths).expect("cleanup");

        assert_eq!(removed, 1, "exactly the alef-marked orphan must be removed");
        assert!(build_gradle.exists(), "current build.gradle.kts must survive");
        assert!(bridge_kt.exists(), "current bridge.kt must survive");
        assert!(!stale_java.exists(), "stale java orphan must be removed");
        assert!(user_java.exists(), "user-written java must survive (no alef hash)");
    }

    /// Regression: when the current run writes some files into a `test_apps/`
    /// subtree (registry-mode e2e generation), unrelated alef-marked files in
    /// the same `test_apps/` tree from previous runs must NOT be swept.
    /// Downstream polyglot repos hold curated test projects under
    /// `test_apps/<lang>/`; partial regeneration must not wipe co-located
    /// curated scaffolding (composer.json, build.gradle.kts, pyproject.toml)
    /// just because the current iteration emits a different subset.
    #[test]
    fn cleanup_skips_test_apps_subtree_unconditionally() {
        let tempdir = tempfile::tempdir().expect("tempdir");
        let test_apps_python = tempdir.path().join("test_apps/python");
        let test_apps_python_tests = test_apps_python.join("tests");
        fs::create_dir_all(&test_apps_python_tests).expect("create test_apps tests dir");

        let alef_header = format!("// alef:hash:{TEST_HASH}\n");
        // Current run regenerates a test file under test_apps/python/tests/
        // — its parent becomes a touched_dir entry point of the walker.
        let regen_test = test_apps_python_tests.join("test_smoke.py");
        fs::write(&regen_test, format!("{alef_header}def test_smoke(): pass\n")).expect("write regen");
        // Stale alef-marked file from a previous run in the same touched dir
        // — without the test_apps ancestor exclusion it would be deleted.
        let stale_sibling = test_apps_python_tests.join("test_legacy.py");
        fs::write(&stale_sibling, format!("{alef_header}def test_legacy(): pass\n")).expect("write stale sibling");

        let current_gen_paths = HashSet::from([regen_test.clone()]);
        let removed = cleanup_orphaned_files(&current_gen_paths).expect("cleanup");

        assert_eq!(removed, 0, "test_apps subtree must be skipped by the walker");
        assert!(regen_test.exists(), "regen test survives");
        assert!(
            stale_sibling.exists(),
            "stale sibling must survive despite alef hash header"
        );
    }

    /// Regression: renamed files in the same directory must be cleaned up.
    /// This models the Swift backend's rename of `SwiftPluginHelpers.swift`
    /// to `ZSwiftPluginHelpers.swift` (Z-prefix anti-collision pattern).
    /// When the new file is emitted, the old file must be deleted.
    #[test]
    fn cleanup_removes_renamed_file_in_same_directory() {
        let tempdir = tempfile::tempdir().expect("tempdir");
        let rust_bridge_dir = tempdir.path().join("packages/swift/Sources/RustBridge");
        fs::create_dir_all(&rust_bridge_dir).expect("create rust bridge dir");

        let alef_header = "// Generated by alef. Do not edit by hand.\n".to_string();
        let old_name = rust_bridge_dir.join("SwiftPluginHelpers.swift");
        let new_name = rust_bridge_dir.join("ZSwiftPluginHelpers.swift");

        // Simulate the old file from a previous alef run
        fs::write(
            &old_name,
            format!("{alef_header}// alef:hash:{TEST_HASH}\n\nenum InboundEnvelope\n"),
        )
        .expect("write old file");

        // New alef run only emits the renamed file
        let new_content = format!("{alef_header}// alef:hash:{TEST_HASH}\n\nenum InboundEnvelope\n");
        fs::write(&new_name, new_content).expect("write new file");

        let current_gen_paths = HashSet::from([new_name.clone()]);
        let removed = cleanup_orphaned_files(&current_gen_paths).expect("cleanup");

        assert_eq!(removed, 1, "exactly the old file must be removed");
        assert!(!old_name.exists(), "old SwiftPluginHelpers.swift must be removed");
        assert!(new_name.exists(), "new ZSwiftPluginHelpers.swift must survive");
    }
}
