use std::cmp::Ordering;
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use ignore::{gitignore::Gitignore, gitignore::GitignoreBuilder, Match};

/// Default directory patterns that should be omitted from generated tree snapshots.
pub const DEFAULT_TREE_EXCLUDES: &[&str] = &[
    ".git/",
    "target/",
    "node_modules/",
    "__pycache__/",
    ".venv/",
    "venv/",
];

/// Render a text-based directory tree for the workspace, honoring ignore patterns.
///
/// Entries matching either the built-in [`DEFAULT_TREE_EXCLUDES`] or the caller-provided
/// `excludes` list are skipped. The output mirrors the style of `tree --noreport`.
pub fn generate_repo_tree<S: AsRef<str>>(workspace_root: &Path, excludes: &[S]) -> Result<String> {
    let matcher = build_matcher(workspace_root, excludes)?;
    let mut builder = TreeBuilder::new(workspace_root, matcher);
    builder.generate()
}

/// Build a gitignore matcher combining default excludes with user-provided patterns.
fn build_matcher<S: AsRef<str>>(root: &Path, excludes: &[S]) -> Result<Gitignore> {
    let mut builder = GitignoreBuilder::new(root);
    for pattern in DEFAULT_TREE_EXCLUDES {
        builder
            .add_line(None, pattern)
            .context("invalid default exclude pattern")?;
    }
    for pattern in excludes {
        builder
            .add_line(None, pattern.as_ref())
            .with_context(|| format!("invalid exclude pattern `{}`", pattern.as_ref()))?;
    }
    builder
        .build()
        .context("failed to compile exclude patterns")
}

struct TreeBuilder<'a> {
    root: &'a Path,
    matcher: Gitignore,
}

impl<'a> TreeBuilder<'a> {
    /// Create a new tree builder rooted at `root` using the supplied matcher.
    fn new(root: &'a Path, matcher: Gitignore) -> Self {
        Self { root, matcher }
    }

    /// Generate the rendered tree, starting at the workspace root.
    fn generate(&mut self) -> Result<String> {
        let mut output = String::from(".\n");
        self.walk_dir(self.root, &mut output, &mut Vec::new())?;
        Ok(output)
    }

    /// Depth-first traversal writing entries with ASCII tree connectors.
    fn walk_dir(&self, dir: &Path, output: &mut String, branches: &mut Vec<bool>) -> Result<()> {
        let mut entries = self.read_entries(dir)?;
        entries.sort_by(|a, b| compare_names(&a.path, &b.path));

        let total = entries.len();
        for (index, entry) in entries.into_iter().enumerate() {
            let is_last = index + 1 == total;
            self.write_entry(output, branches, &entry.path, entry.is_symlink, is_last)?;
            if entry.is_dir && !entry.is_symlink {
                branches.push(is_last);
                self.walk_dir(&entry.path, output, branches)?;
                branches.pop();
            }
        }
        Ok(())
    }

    /// Read and filter directory entries respecting the ignore matcher.
    fn read_entries(&self, dir: &Path) -> Result<Vec<Entry>> {
        let mut entries = Vec::new();
        for entry in
            fs::read_dir(dir).with_context(|| format!("failed to read `{}`", dir.display()))?
        {
            let entry =
                entry.with_context(|| format!("failed to read entry in `{}`", dir.display()))?;
            let path = entry.path();
            let file_type = entry
                .file_type()
                .with_context(|| format!("failed to read file type for `{}`", path.display()))?;
            let rel = path.strip_prefix(self.root).unwrap_or(&path);
            if rel.as_os_str().is_empty() {
                continue;
            }
            if self.should_ignore(rel, file_type.is_dir()) {
                continue;
            }
            entries.push(Entry {
                path,
                is_dir: file_type.is_dir(),
                is_symlink: file_type.is_symlink(),
            });
        }
        Ok(entries)
    }

    /// Check whether a relative path should be excluded from traversal.
    fn should_ignore(&self, rel: &Path, is_dir: bool) -> bool {
        matches!(
            self.matcher.matched_path_or_any_parents(rel, is_dir),
            Match::Ignore(_)
        )
    }

    /// Append a formatted entry to the output buffer.
    fn write_entry(
        &self,
        output: &mut String,
        branches: &[bool],
        path: &Path,
        is_symlink: bool,
        is_last: bool,
    ) -> Result<()> {
        for &last in branches {
            if last {
                output.push_str("    ");
            } else {
                output.push_str("│   ");
            }
        }
        output.push_str(if is_last { "└── " } else { "├── " });
        let name = path
            .file_name()
            .context("missing file name")?
            .to_string_lossy();
        output.push_str(&name);
        if is_symlink {
            if let Ok(target) = fs::read_link(path) {
                output.push_str(" -> ");
                output.push_str(&target.to_string_lossy());
            }
        }
        output.push('\n');
        Ok(())
    }
}

struct Entry {
    path: PathBuf,
    is_dir: bool,
    is_symlink: bool,
}

/// Sort paths lexicographically using their final component.
fn compare_names(a: &Path, b: &Path) -> Ordering {
    let a_name = a.file_name().and_then(|n| n.to_str()).unwrap_or_default();
    let b_name = b.file_name().and_then(|n| n.to_str()).unwrap_or_default();
    a_name.cmp(b_name)
}

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

    #[test]
    fn generates_tree_without_cli() {
        let tmp = tempdir().unwrap();
        let root = tmp.path();
        fs::create_dir_all(root.join("src/bin")).unwrap();
        fs::write(root.join("Cargo.toml"), "").unwrap();
        fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();

        let tree = generate_repo_tree(root, &[] as &[&str]).unwrap();
        let expected = indoc! {"\
            .
            ├── Cargo.toml
            └── src
                ├── bin
                └── main.rs
        "};
        assert_eq!(tree, expected);
    }

    #[test]
    fn respects_default_excludes() {
        let tmp = tempdir().unwrap();
        let root = tmp.path();
        fs::create_dir_all(root.join("target/debug")).unwrap();
        fs::write(root.join("README.md"), "# Test").unwrap();

        let tree = generate_repo_tree(root, &[] as &[&str]).unwrap();
        assert!(!tree.contains("target"));
        assert!(tree.contains("README.md"));
    }

    #[test]
    fn respects_custom_excludes() {
        let tmp = tempdir().unwrap();
        let root = tmp.path();
        fs::create_dir_all(root.join("foo/bar")).unwrap();
        fs::write(root.join("foo/file.txt"), "hello").unwrap();
        fs::write(root.join("foo/bar/baz.txt"), "world").unwrap();

        let tree = generate_repo_tree(root, &["foo/"]).unwrap();
        assert!(!tree.contains("foo"));
    }
}
