use super::tool_permissions::{
    ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots,
    resolve_global_skill_path, resolve_project_path,
};
use crate::{AgentTool, ToolCallEventStream, ToolInput};
use agent_client_protocol::schema as acp;
use anyhow::{Context as _, Result, anyhow};
use fs::Fs;
use futures::StreamExt as _;
use gpui::{App, Entity, SharedString, Task};
use project::{Project, ProjectPath, WorktreeSettings};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Settings;
use std::fmt::Write;
use std::path::Path;
use std::sync::Arc;
use util::markdown::MarkdownInlineCode;

/// Lists files and directories in a given path. Prefer the `grep` or `find_path` tools when searching the codebase.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct ListDirectoryToolInput {
    /// The fully-qualified path of the directory to list in the project.
    ///
    /// This path should never be absolute, and the first component of the path should always be a root directory in a project.
    ///
    /// <example>
    /// If the project has the following root directories:
    ///
    /// - directory1
    /// - directory2
    ///
    /// You can list the contents of `directory1` by using the path `directory1`.
    /// </example>
    ///
    /// <example>
    /// If the project has the following root directories:
    ///
    /// - foo
    /// - bar
    ///
    /// If you wanna list contents in the directory `foo/baz`, you should use the path `foo/baz`.
    /// </example>
    pub path: String,
}

pub struct ListDirectoryTool {
    project: Entity<Project>,
}

impl ListDirectoryTool {
    pub fn new(project: Entity<Project>) -> Self {
        Self { project }
    }

    /// List the contents of a directory under the global skills tree directly
    /// via the filesystem. Used for skill resources that live outside any
    /// worktree.
    async fn list_global_skill_directory(
        canonical_path: &Path,
        fs: &dyn Fs,
        input_path: &str,
    ) -> Result<String, String> {
        let mut entries = fs
            .read_dir(canonical_path)
            .await
            .map_err(|err| err.to_string())?;

        let mut folders = Vec::new();
        let mut files = Vec::new();
        while let Some(entry) = entries.next().await {
            let Ok(entry_path) = entry else {
                continue;
            };
            let display = entry_path.to_string_lossy().into_owned();
            // Use a metadata call rather than `is_dir` so we can short-circuit
            // on missing entries (e.g. dangling symlinks).
            let Ok(Some(metadata)) = fs.metadata(&entry_path).await else {
                continue;
            };
            if metadata.is_dir {
                folders.push(display);
            } else {
                files.push(display);
            }
        }

        folders.sort();
        files.sort();

        let mut output = String::new();
        if !folders.is_empty() {
            writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap();
        }
        if !files.is_empty() {
            writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap();
        }
        if output.is_empty() {
            writeln!(output, "{input_path} is empty.").unwrap();
        }
        Ok(output)
    }

    fn build_directory_output(
        project: &Entity<Project>,
        project_path: &ProjectPath,
        input_path: &str,
        cx: &App,
    ) -> Result<String> {
        let worktree = project
            .read(cx)
            .worktree_for_id(project_path.worktree_id, cx)
            .with_context(|| format!("{input_path} is not in a known worktree"))?;

        let global_settings = WorktreeSettings::get_global(cx);
        let worktree_settings = WorktreeSettings::get(Some(project_path.into()), cx);
        let worktree_snapshot = worktree.read(cx).snapshot();
        let worktree_root_name = worktree.read(cx).root_name();

        let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else {
            return Err(anyhow!("Path not found: {}", input_path));
        };

        if !entry.is_dir() {
            return Err(anyhow!("{input_path} is not a directory."));
        }

        let mut folders = Vec::new();
        let mut files = Vec::new();

        for entry in worktree_snapshot.child_entries(&project_path.path) {
            // Skip private and excluded files and directories
            if global_settings.is_path_private(&entry.path)
                || global_settings.is_path_excluded(&entry.path)
            {
                continue;
            }

            let project_path: ProjectPath = (worktree_snapshot.id(), entry.path.clone()).into();
            if worktree_settings.is_path_excluded(&project_path.path)
                || worktree_settings.is_path_private(&project_path.path)
            {
                continue;
            }

            let full_path = worktree_root_name
                .join(&entry.path)
                .display(worktree_snapshot.path_style())
                .into_owned();
            if entry.is_dir() {
                folders.push(full_path);
            } else {
                files.push(full_path);
            }
        }

        let mut output = String::new();

        if !folders.is_empty() {
            writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap();
        }

        if !files.is_empty() {
            writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap();
        }

        if output.is_empty() {
            writeln!(output, "{input_path} is empty.").unwrap();
        }

        Ok(output)
    }
}

impl AgentTool for ListDirectoryTool {
    type Input = ListDirectoryToolInput;
    type Output = String;

    const NAME: &'static str = "list_directory";

    fn kind() -> acp::ToolKind {
        acp::ToolKind::Read
    }

    fn initial_title(
        &self,
        input: Result<Self::Input, serde_json::Value>,
        _cx: &mut App,
    ) -> SharedString {
        if let Ok(input) = input {
            let path = MarkdownInlineCode(&input.path);
            format!("List the {path} directory's contents").into()
        } else {
            "List directory".into()
        }
    }

    fn run(
        self: Arc<Self>,
        input: ToolInput<Self::Input>,
        event_stream: ToolCallEventStream,
        cx: &mut App,
    ) -> Task<Result<Self::Output, Self::Output>> {
        let project = self.project.clone();
        cx.spawn(async move |cx| {
            let input = input
                .recv()
                .await
                .map_err(|e| e.to_string())?;

            // Sometimes models will return these even though we tell it to give a path and not a glob.
            // When this happens, just list the root worktree directories.
            if matches!(input.path.as_str(), "." | "" | "./" | "*") {
                let output = project.read_with(cx, |project, cx| {
                    project
                        .worktrees(cx)
                        .filter_map(|worktree| {
                            let worktree = worktree.read(cx);
                            let root_entry = worktree.root_entry()?;
                            if root_entry.is_dir() {
                                Some(root_entry.path.display(worktree.path_style()))
                            } else {
                                None
                            }
                        })
                        .collect::<Vec<_>>()
                        .join("\n")
                });

                return Ok(output);
            }

            let fs = project.read_with(cx, |project, _cx| project.fs().clone());

            // Fast path: a global skill resource lives outside any worktree, so
            // standard project-path resolution would refuse it. If the path
            // resolves under the global skills tree, list it directly.
            if let Some(skill_path) =
                resolve_global_skill_path(Path::new(&input.path), fs.as_ref()).await
            {
                return Self::list_global_skill_directory(
                    &skill_path,
                    fs.as_ref(),
                    &input.path,
                )
                .await;
            }
            let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;

            let (project_path, symlink_canonical_target) =
                project.read_with(cx, |project, cx| -> anyhow::Result<_> {
                    let resolved = resolve_project_path(project, &input.path, &canonical_roots, cx)?;
                    Ok(match resolved {
                        ResolvedProjectPath::Safe(path) => (path, None),
                        ResolvedProjectPath::SymlinkEscape {
                            project_path,
                            canonical_target,
                        } => (project_path, Some(canonical_target)),
                    })
                }).map_err(|e| e.to_string())?;

            // Check settings exclusions synchronously
            project.read_with(cx, |project, cx| {
                let worktree = project
                    .worktree_for_id(project_path.worktree_id, cx)
                    .with_context(|| {
                        format!("{} is not in a known worktree", &input.path)
                    })?;

                let global_settings = WorktreeSettings::get_global(cx);
                if global_settings.is_path_excluded(&project_path.path) {
                    anyhow::bail!(
                        "Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}",
                        &input.path
                    );
                }

                if global_settings.is_path_private(&project_path.path) {
                    anyhow::bail!(
                        "Cannot list directory because its path matches the user's global `private_files` setting: {}",
                        &input.path
                    );
                }

                let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
                if worktree_settings.is_path_excluded(&project_path.path) {
                    anyhow::bail!(
                        "Cannot list directory because its path matches the user's worktree `file_scan_exclusions` setting: {}",
                        &input.path
                    );
                }

                if worktree_settings.is_path_private(&project_path.path) {
                    anyhow::bail!(
                        "Cannot list directory because its path matches the user's worktree `private_paths` setting: {}",
                        &input.path
                    );
                }

                let worktree_snapshot = worktree.read(cx).snapshot();
                let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else {
                    anyhow::bail!("Path not found: {}", input.path);
                };
                if !entry.is_dir() {
                    anyhow::bail!("{} is not a directory.", input.path);
                }

                anyhow::Ok(())
            }).map_err(|e| e.to_string())?;

            if let Some(canonical_target) = &symlink_canonical_target {
                let authorize = cx.update(|cx| {
                    authorize_symlink_access(
                        Self::NAME,
                        &input.path,
                        canonical_target,
                        &event_stream,
                        cx,
                    )
                });
                authorize.await.map_err(|e| e.to_string())?;
            }

            let list_path = input.path;
            cx.update(|cx| {
                Self::build_directory_output(&project, &project_path, &list_path, cx)
            }).map_err(|e| e.to_string())
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use gpui::{TestAppContext, UpdateGlobal};
    use indoc::indoc;
    use project::{FakeFs, Project};
    use serde_json::json;
    use settings::SettingsStore;
    use std::path::PathBuf;
    use util::path;

    fn platform_paths(path_str: &str) -> String {
        if cfg!(target_os = "windows") {
            path_str.replace("/", "\\")
        } else {
            path_str.to_string()
        }
    }

    fn init_test(cx: &mut TestAppContext) {
        cx.update(|cx| {
            let settings_store = SettingsStore::test(cx);
            cx.set_global(settings_store);
        });
    }

    #[gpui::test]
    async fn test_list_directory_separates_files_and_dirs(cx: &mut TestAppContext) {
        init_test(cx);

        let fs = FakeFs::new(cx.executor());
        fs.insert_tree(
            path!("/project"),
            json!({
                "src": {
                    "main.rs": "fn main() {}",
                    "lib.rs": "pub fn hello() {}",
                    "models": {
                        "user.rs": "struct User {}",
                        "post.rs": "struct Post {}"
                    },
                    "utils": {
                        "helper.rs": "pub fn help() {}"
                    }
                },
                "tests": {
                    "integration_test.rs": "#[test] fn test() {}"
                },
                "README.md": "# Project",
                "Cargo.toml": "[package]"
            }),
        )
        .await;

        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
        let tool = Arc::new(ListDirectoryTool::new(project));

        // Test listing root directory
        let input = ListDirectoryToolInput {
            path: "project".into(),
        };
        let output = cx
            .update(|cx| {
                tool.clone().run(
                    ToolInput::resolved(input),
                    ToolCallEventStream::test().0,
                    cx,
                )
            })
            .await
            .unwrap();
        assert_eq!(
            output,
            platform_paths(indoc! {"
                # Folders:
                project/src
                project/tests

                # Files:
                project/Cargo.toml
                project/README.md
            "})
        );

        // Test listing src directory
        let input = ListDirectoryToolInput {
            path: "project/src".into(),
        };
        let output = cx
            .update(|cx| {
                tool.clone().run(
                    ToolInput::resolved(input),
                    ToolCallEventStream::test().0,
                    cx,
                )
            })
            .await
            .unwrap();
        assert_eq!(
            output,
            platform_paths(indoc! {"
                # Folders:
                project/src/models
                project/src/utils

                # Files:
                project/src/lib.rs
                project/src/main.rs
            "})
        );

        // Test listing directory with only files
        let input = ListDirectoryToolInput {
            path: "project/tests".into(),
        };
        let output = cx
            .update(|cx| {
                tool.clone().run(
                    ToolInput::resolved(input),
                    ToolCallEventStream::test().0,
                    cx,
                )
            })
            .await
            .unwrap();
        assert!(!output.contains("# Folders:"));
        assert!(output.contains("# Files:"));
        assert!(output.contains(&platform_paths("project/tests/integration_test.rs")));
    }

    #[gpui::test]
    async fn test_list_directory_empty_directory(cx: &mut TestAppContext) {
        init_test(cx);

        let fs = FakeFs::new(cx.executor());
        fs.insert_tree(
            path!("/project"),
            json!({
                "empty_dir": {}
            }),
        )
        .await;

        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
        let tool = Arc::new(ListDirectoryTool::new(project));

        let input = ListDirectoryToolInput {
            path: "project/empty_dir".into(),
        };
        let output = cx
            .update(|cx| {
                tool.clone().run(
                    ToolInput::resolved(input),
                    ToolCallEventStream::test().0,
                    cx,
                )
            })
            .await
            .unwrap();
        assert_eq!(output, "project/empty_dir is empty.\n");
    }

    #[gpui::test]
    async fn test_list_directory_error_cases(cx: &mut TestAppContext) {
        init_test(cx);

        let fs = FakeFs::new(cx.executor());
        fs.insert_tree(
            path!("/project"),
            json!({
                "file.txt": "content"
            }),
        )
        .await;

        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
        let tool = Arc::new(ListDirectoryTool::new(project));

        // Test non-existent path
        let input = ListDirectoryToolInput {
            path: "project/nonexistent".into(),
        };
        let output = cx
            .update(|cx| {
                tool.clone().run(
                    ToolInput::resolved(input),
                    ToolCallEventStream::test().0,
                    cx,
                )
            })
            .await;
        assert!(output.unwrap_err().contains("Path not found"));

        // Test trying to list a file instead of directory
        let input = ListDirectoryToolInput {
            path: "project/file.txt".into(),
        };
        let output = cx
            .update(|cx| {
                tool.run(
                    ToolInput::resolved(input),
                    ToolCallEventStream::test().0,
                    cx,
                )
            })
            .await;
        assert!(output.unwrap_err().contains("is not a directory"));
    }

    #[gpui::test]
    async fn test_list_directory_security(cx: &mut TestAppContext) {
        init_test(cx);

        let fs = FakeFs::new(cx.executor());
        fs.insert_tree(
            path!("/project"),
            json!({
                "normal_dir": {
                    "file1.txt": "content",
                    "file2.txt": "content"
                },
                ".mysecrets": "SECRET_KEY=abc123",
                ".secretdir": {
                    "config": "special configuration",
                    "secret.txt": "secret content"
                },
                ".mymetadata": "custom metadata",
                "visible_dir": {
                    "normal.txt": "normal content",
                    "special.privatekey": "private key content",
                    "data.mysensitive": "sensitive data",
                    ".hidden_subdir": {
                        "hidden_file.txt": "hidden content"
                    }
                }
            }),
        )
        .await;

        // Configure settings explicitly
        cx.update(|cx| {
            SettingsStore::update_global(cx, |store, cx| {
                store.update_user_settings(cx, |settings| {
                    settings.project.worktree.file_scan_exclusions = Some(vec![
                        "**/.secretdir".to_string(),
                        "**/.mymetadata".to_string(),
                        "**/.hidden_subdir".to_string(),
                    ]);
                    settings.project.worktree.private_files = Some(
                        vec![
                            "**/.mysecrets".to_string(),
                            "**/*.privatekey".to_string(),
                            "**/*.mysensitive".to_string(),
                        ]
                        .into(),
                    );
                });
            });
        });

        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
        let tool = Arc::new(ListDirectoryTool::new(project));

        // Listing root directory should exclude private and excluded files
        let input = ListDirectoryToolInput {
            path: "project".into(),
        };
        let output = cx
            .update(|cx| {
                tool.clone().run(
                    ToolInput::resolved(input),
                    ToolCallEventStream::test().0,
                    cx,
                )
            })
            .await
            .unwrap();

        // Should include normal directories
        assert!(output.contains("normal_dir"), "Should list normal_dir");
        assert!(output.contains("visible_dir"), "Should list visible_dir");

        // Should NOT include excluded or private files
        assert!(
            !output.contains(".secretdir"),
            "Should not list .secretdir (file_scan_exclusions)"
        );
        assert!(
            !output.contains(".mymetadata"),
            "Should not list .mymetadata (file_scan_exclusions)"
        );
        assert!(
            !output.contains(".mysecrets"),
            "Should not list .mysecrets (private_files)"
        );

        // Trying to list an excluded directory should fail
        let input = ListDirectoryToolInput {
            path: "project/.secretdir".into(),
        };
        let output = cx
            .update(|cx| {
                tool.clone().run(
                    ToolInput::resolved(input),
                    ToolCallEventStream::test().0,
                    cx,
                )
            })
            .await;
        assert!(
            output.unwrap_err().contains("file_scan_exclusions"),
            "Error should mention file_scan_exclusions"
        );

        // Listing a directory should exclude private files within it
        let input = ListDirectoryToolInput {
            path: "project/visible_dir".into(),
        };
        let output = cx
            .update(|cx| {
                tool.clone().run(
                    ToolInput::resolved(input),
                    ToolCallEventStream::test().0,
                    cx,
                )
            })
            .await
            .unwrap();

        // Should include normal files
        assert!(output.contains("normal.txt"), "Should list normal.txt");

        // Should NOT include private files
        assert!(
            !output.contains("privatekey"),
            "Should not list .privatekey files (private_files)"
        );
        assert!(
            !output.contains("mysensitive"),
            "Should not list .mysensitive files (private_files)"
        );

        // Should NOT include subdirectories that match exclusions
        assert!(
            !output.contains(".hidden_subdir"),
            "Should not list .hidden_subdir (file_scan_exclusions)"
        );
    }

    #[gpui::test]
    async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) {
        init_test(cx);

        let fs = FakeFs::new(cx.executor());

        // Create first worktree with its own private files
        fs.insert_tree(
            path!("/worktree1"),
            json!({
                ".zed": {
                    "settings.json": r#"{
                        "file_scan_exclusions": ["**/fixture.*"],
                        "private_files": ["**/secret.rs", "**/config.toml"]
                    }"#
                },
                "src": {
                    "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
                    "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
                    "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
                },
                "tests": {
                    "test.rs": "mod tests { fn test_it() {} }",
                    "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
                }
            }),
        )
        .await;

        // Create second worktree with different private files
        fs.insert_tree(
            path!("/worktree2"),
            json!({
                ".zed": {
                    "settings.json": r#"{
                        "file_scan_exclusions": ["**/internal.*"],
                        "private_files": ["**/private.js", "**/data.json"]
                    }"#
                },
                "lib": {
                    "public.js": "export function greet() { return 'Hello from worktree2'; }",
                    "private.js": "const SECRET_TOKEN = \"private_token_2\";",
                    "data.json": "{\"api_key\": \"json_secret_key\"}"
                },
                "docs": {
                    "README.md": "# Public Documentation",
                    "internal.md": "# Internal Secrets and Configuration"
                }
            }),
        )
        .await;

        // Set global settings
        cx.update(|cx| {
            SettingsStore::update_global(cx, |store, cx| {
                store.update_user_settings(cx, |settings| {
                    settings.project.worktree.file_scan_exclusions =
                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
                    settings.project.worktree.private_files =
                        Some(vec!["**/.env".to_string()].into());
                });
            });
        });

        let project = Project::test(
            fs.clone(),
            [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
            cx,
        )
        .await;

        // Wait for worktrees to be fully scanned
        cx.executor().run_until_parked();

        let tool = Arc::new(ListDirectoryTool::new(project));

        // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings
        let input = ListDirectoryToolInput {
            path: "worktree1/src".into(),
        };
        let output = cx
            .update(|cx| {
                tool.clone().run(
                    ToolInput::resolved(input),
                    ToolCallEventStream::test().0,
                    cx,
                )
            })
            .await
            .unwrap();
        assert!(output.contains("main.rs"), "Should list main.rs");
        assert!(
            !output.contains("secret.rs"),
            "Should not list secret.rs (local private_files)"
        );
        assert!(
            !output.contains("config.toml"),
            "Should not list config.toml (local private_files)"
        );

        // Test listing worktree1/tests - should exclude fixture.sql based on local settings
        let input = ListDirectoryToolInput {
            path: "worktree1/tests".into(),
        };
        let output = cx
            .update(|cx| {
                tool.clone().run(
                    ToolInput::resolved(input),
                    ToolCallEventStream::test().0,
                    cx,
                )
            })
            .await
            .unwrap();
        assert!(output.contains("test.rs"), "Should list test.rs");
        assert!(
            !output.contains("fixture.sql"),
            "Should not list fixture.sql (local file_scan_exclusions)"
        );

        // Test listing worktree2/lib - should exclude private.js and data.json based on local settings
        let input = ListDirectoryToolInput {
            path: "worktree2/lib".into(),
        };
        let output = cx
            .update(|cx| {
                tool.clone().run(
                    ToolInput::resolved(input),
                    ToolCallEventStream::test().0,
                    cx,
                )
            })
            .await
            .unwrap();
        assert!(output.contains("public.js"), "Should list public.js");
        assert!(
            !output.contains("private.js"),
            "Should not list private.js (local private_files)"
        );
        assert!(
            !output.contains("data.json"),
            "Should not list data.json (local private_files)"
        );

        // Test listing worktree2/docs - should exclude internal.md based on local settings
        let input = ListDirectoryToolInput {
            path: "worktree2/docs".into(),
        };
        let output = cx
            .update(|cx| {
                tool.clone().run(
                    ToolInput::resolved(input),
                    ToolCallEventStream::test().0,
                    cx,
                )
            })
            .await
            .unwrap();
        assert!(output.contains("README.md"), "Should list README.md");
        assert!(
            !output.contains("internal.md"),
            "Should not list internal.md (local file_scan_exclusions)"
        );

        // Test trying to list an excluded directory directly
        let input = ListDirectoryToolInput {
            path: "worktree1/src/secret.rs".into(),
        };
        let output = cx
            .update(|cx| {
                tool.clone().run(
                    ToolInput::resolved(input),
                    ToolCallEventStream::test().0,
                    cx,
                )
            })
            .await;
        assert!(output.unwrap_err().contains("Cannot list directory"),);
    }

    #[gpui::test]
    async fn test_list_directory_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
        init_test(cx);

        let fs = FakeFs::new(cx.executor());
        fs.insert_tree(
            path!("/root"),
            json!({
                "project": {
                    "src": {
                        "main.rs": "fn main() {}"
                    }
                },
                "external": {
                    "secrets": {
                        "key.txt": "SECRET_KEY=abc123"
                    }
                }
            }),
        )
        .await;

        fs.create_symlink(
            path!("/root/project/link_to_external").as_ref(),
            PathBuf::from("../external"),
        )
        .await
        .unwrap();

        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
        cx.executor().run_until_parked();

        let tool = Arc::new(ListDirectoryTool::new(project));

        let (event_stream, mut event_rx) = ToolCallEventStream::test();
        let task = cx.update(|cx| {
            tool.clone().run(
                ToolInput::resolved(ListDirectoryToolInput {
                    path: "project/link_to_external".into(),
                }),
                event_stream,
                cx,
            )
        });

        let auth = event_rx.expect_authorization().await;
        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
        assert!(
            title.contains("points outside the project"),
            "Authorization title should mention symlink escape, got: {title}",
        );

        auth.response
            .send(acp_thread::SelectedPermissionOutcome::new(
                acp::PermissionOptionId::new("allow"),
                acp::PermissionOptionKind::AllowOnce,
            ))
            .unwrap();

        let result = task.await;
        assert!(
            result.is_ok(),
            "Tool should succeed after authorization: {result:?}"
        );
    }

    #[gpui::test]
    async fn test_list_directory_symlink_escape_denied(cx: &mut TestAppContext) {
        init_test(cx);

        let fs = FakeFs::new(cx.executor());
        fs.insert_tree(
            path!("/root"),
            json!({
                "project": {
                    "src": {
                        "main.rs": "fn main() {}"
                    }
                },
                "external": {
                    "secrets": {}
                }
            }),
        )
        .await;

        fs.create_symlink(
            path!("/root/project/link_to_external").as_ref(),
            PathBuf::from("../external"),
        )
        .await
        .unwrap();

        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
        cx.executor().run_until_parked();

        let tool = Arc::new(ListDirectoryTool::new(project));

        let (event_stream, mut event_rx) = ToolCallEventStream::test();
        let task = cx.update(|cx| {
            tool.clone().run(
                ToolInput::resolved(ListDirectoryToolInput {
                    path: "project/link_to_external".into(),
                }),
                event_stream,
                cx,
            )
        });

        let auth = event_rx.expect_authorization().await;

        // Deny by dropping the response sender without sending
        drop(auth);

        let result = task.await;
        assert!(
            result.is_err(),
            "Tool should fail when authorization is denied"
        );
    }

    #[gpui::test]
    async fn test_list_directory_symlink_escape_private_path_no_authorization(
        cx: &mut TestAppContext,
    ) {
        init_test(cx);

        let fs = FakeFs::new(cx.executor());
        fs.insert_tree(
            path!("/root"),
            json!({
                "project": {
                    "src": {
                        "main.rs": "fn main() {}"
                    }
                },
                "external": {
                    "secrets": {}
                }
            }),
        )
        .await;

        fs.create_symlink(
            path!("/root/project/link_to_external").as_ref(),
            PathBuf::from("../external"),
        )
        .await
        .unwrap();

        cx.update(|cx| {
            SettingsStore::update_global(cx, |store, cx| {
                store.update_user_settings(cx, |settings| {
                    settings.project.worktree.private_files =
                        Some(vec!["**/link_to_external".to_string()].into());
                });
            });
        });

        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
        cx.executor().run_until_parked();

        let tool = Arc::new(ListDirectoryTool::new(project));

        let (event_stream, mut event_rx) = ToolCallEventStream::test();
        let result = cx
            .update(|cx| {
                tool.clone().run(
                    ToolInput::resolved(ListDirectoryToolInput {
                        path: "project/link_to_external".into(),
                    }),
                    event_stream,
                    cx,
                )
            })
            .await;

        assert!(
            result.is_err(),
            "Expected list_directory to fail on private path"
        );
        let error = result.unwrap_err();
        assert!(
            error.contains("private"),
            "Expected private path validation error, got: {error}"
        );

        let event = event_rx.try_recv();
        assert!(
            !matches!(
                event,
                Ok(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(_)))
            ),
            "No authorization should be requested when validation fails before listing",
        );
    }

    #[gpui::test]
    async fn test_list_directory_no_authorization_for_normal_paths(cx: &mut TestAppContext) {
        init_test(cx);

        let fs = FakeFs::new(cx.executor());
        fs.insert_tree(
            path!("/project"),
            json!({
                "src": {
                    "main.rs": "fn main() {}"
                }
            }),
        )
        .await;

        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
        let tool = Arc::new(ListDirectoryTool::new(project));

        let (event_stream, mut event_rx) = ToolCallEventStream::test();
        let result = cx
            .update(|cx| {
                tool.clone().run(
                    ToolInput::resolved(ListDirectoryToolInput {
                        path: "project/src".into(),
                    }),
                    event_stream,
                    cx,
                )
            })
            .await;

        assert!(
            result.is_ok(),
            "Normal path should succeed without authorization"
        );

        let event = event_rx.try_recv();
        assert!(
            !matches!(
                event,
                Ok(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(_)))
            ),
            "No authorization should be requested for normal paths",
        );
    }

    #[gpui::test]
    async fn test_list_directory_intra_project_symlink_no_authorization(cx: &mut TestAppContext) {
        init_test(cx);

        let fs = FakeFs::new(cx.executor());
        fs.insert_tree(
            path!("/project"),
            json!({
                "real_dir": {
                    "file.txt": "content"
                }
            }),
        )
        .await;

        fs.create_symlink(
            path!("/project/link_dir").as_ref(),
            PathBuf::from("real_dir"),
        )
        .await
        .unwrap();

        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
        cx.executor().run_until_parked();

        let tool = Arc::new(ListDirectoryTool::new(project));

        let (event_stream, mut event_rx) = ToolCallEventStream::test();
        let result = cx
            .update(|cx| {
                tool.clone().run(
                    ToolInput::resolved(ListDirectoryToolInput {
                        path: "project/link_dir".into(),
                    }),
                    event_stream,
                    cx,
                )
            })
            .await;

        assert!(
            result.is_ok(),
            "Intra-project symlink should succeed without authorization: {result:?}",
        );

        let event = event_rx.try_recv();
        assert!(
            !matches!(
                event,
                Ok(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(_)))
            ),
            "No authorization should be requested for intra-project symlinks",
        );
    }

    #[gpui::test]
    async fn test_list_global_skill_directory(cx: &mut TestAppContext) {
        init_test(cx);

        let fs = FakeFs::new(cx.executor());
        fs.insert_tree(path!("/project"), json!({})).await;

        let skill_dir = agent_skills::global_skills_dir().join("my-skill");
        fs.create_dir(&skill_dir).await.unwrap();
        fs.insert_file(
            skill_dir.join("SKILL.md"),
            b"---\nname: my-skill\ndescription: x\n---\nbody".to_vec(),
        )
        .await;
        fs.insert_file(skill_dir.join("rubric.md"), b"# rubric".to_vec())
            .await;
        fs.create_dir(&skill_dir.join("scripts")).await.unwrap();
        fs.insert_file(skill_dir.join("scripts/run.py"), b"print('hi')".to_vec())
            .await;

        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
        let tool = Arc::new(ListDirectoryTool::new(project));

        let input = ListDirectoryToolInput {
            path: skill_dir.to_string_lossy().into_owned(),
        };
        let output = cx
            .update(|cx| {
                tool.run(
                    ToolInput::resolved(input),
                    ToolCallEventStream::test().0,
                    cx,
                )
            })
            .await
            .unwrap();

        // Output should include both the file siblings of SKILL.md and the
        // nested resource directory — listed by their absolute paths.
        assert!(
            output.contains("# Folders:"),
            "expected folders section: {output}"
        );
        assert!(
            output.contains("scripts"),
            "expected nested directory: {output}"
        );
        assert!(
            output.contains("SKILL.md"),
            "expected SKILL.md to appear: {output}"
        );
        assert!(
            output.contains("rubric.md"),
            "expected rubric.md to appear: {output}"
        );
    }

    #[gpui::test]
    async fn test_list_outside_skills_dir_still_rejected(cx: &mut TestAppContext) {
        init_test(cx);

        let fs = FakeFs::new(cx.executor());
        fs.insert_tree(path!("/project"), json!({})).await;
        fs.create_dir(path!("/etc").as_ref()).await.unwrap();
        fs.insert_file(path!("/etc/secret"), b"top secret".to_vec())
            .await;

        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
        let tool = Arc::new(ListDirectoryTool::new(project));

        let input = ListDirectoryToolInput {
            path: path!("/etc").to_string(),
        };
        let result = cx
            .update(|cx| {
                tool.run(
                    ToolInput::resolved(input),
                    ToolCallEventStream::test().0,
                    cx,
                )
            })
            .await;

        assert!(
            result.is_err(),
            "path outside skills dir should be rejected"
        );
    }
}
