use crate::error::{Result, ResultExt, SofosError};
use crate::tools::utils::is_absolute_path;
use rand::RngExt;
use std::fs;
use std::io::Write as _;
use std::path::{Component, Path, PathBuf};

const MAX_FILE_SIZE: u64 = 50 * 1024 * 1024; // 50MB limit

/// Upper bound on retries when reserving the random-suffix temp file
/// during atomic writes. The 64-bit suffix makes a collision astronomically
/// unlikely, so this only fires if something in the environment is
/// pathologically degenerate (broken RNG, attacker spraying every name).
/// A small cap is enough to mask a one-off bad draw without letting a
/// real failure mode spin indefinitely.
const ATOMIC_TMP_MAX_RETRIES: usize = 8;

/// Write `content` to `path` atomically: stage a sibling
/// `<name>.sofos.tmp.<random>` first, then rename it over the destination.
/// On the same filesystem `rename` is a single inode swap, so a crash /
/// OOM / interrupt partway through the write leaves the original file
/// intact instead of corrupting it with a half-written replacement. If
/// staging or renaming fails, the temp file is best-effort cleaned up so
/// a stray `.sofos.tmp.<random>` doesn't accumulate next to the real
/// file.
///
/// The random suffix matters for security: an earlier implementation
/// used a fixed `.sofos.tmp` filename, which another process could
/// pre-create — for instance as a symlink to an attacker-controlled
/// path — and our write would then follow the symlink and clobber the
/// target. With an unpredictable suffix the attacker can't race the
/// name, and `O_EXCL` (via `create_new`) turns any unexpected pre-existing
/// file into a hard error rather than a silent write-through.
///
/// When `path` is a symlink we resolve it up front and stage the temp
/// file next to the *real* file, so `rename` replaces the target and
/// leaves the symlink itself pointing at the same inode. Without this,
/// the rename would clobber the symlink with a regular file, silently
/// breaking the link topology users set up on purpose.
///
/// On Unix we also copy the existing file's permission bits onto the
/// temp file before the swap, so an executable script stays executable
/// and private files (`0600`) stay private after the edit.
pub(crate) fn write_atomic(path: &Path, content: &str) -> std::io::Result<()> {
    // Resolve symlinks so we write to the real target. `canonicalize`
    // errors for paths that don't exist yet — new files have no link
    // to preserve, so fall back to the caller-supplied path.
    let target = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());

    // Reserve a unique temp sibling and write through the exclusive
    // handle we just got — no reopen window for a symlink swap.
    let (tmp_path, mut tmp_file) = create_tmp_sibling(&target)?;
    if let Err(e) = tmp_file.write_all(content.as_bytes()) {
        drop(tmp_file);
        let _ = fs::remove_file(&tmp_path);
        return Err(e);
    }
    // Flush the file content to stable storage BEFORE the rename. On a
    // kernel crash between rename and writeback ext4/xfs default mount
    // options can otherwise leave the target file with the new inode
    // but zero data, replacing valid content with an empty file.
    if let Err(e) = tmp_file.sync_all() {
        drop(tmp_file);
        let _ = fs::remove_file(&tmp_path);
        return Err(e);
    }
    drop(tmp_file);

    // Preserve the existing file's permission bits. Best-effort: if
    // `metadata` fails (new file, race) or `set_permissions` fails
    // (unusual FS), we fall through to the default permissions the
    // tmp file was created with rather than aborting the write.
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        if let Ok(meta) = fs::metadata(&target) {
            let mode = meta.permissions().mode();
            let _ = fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(mode));
        }
    }

    if let Err(e) = fs::rename(&tmp_path, &target) {
        let _ = fs::remove_file(&tmp_path);
        return Err(e);
    }

    // Flush the directory entry on Unix so the rename itself survives a
    // crash. Without this, ext4/xfs in `data=ordered` mode can lose
    // the directory update on power-loss even though the file content
    // already made it to disk. Best-effort: `sync_all` on a closed
    // directory handle isn't supported on every platform, and on
    // Windows the call would be inappropriate.
    #[cfg(unix)]
    if let Some(parent) = target.parent() {
        if let Ok(dir) = fs::File::open(parent) {
            let _ = dir.sync_all();
        }
    }
    Ok(())
}

/// Windows error code returned by `MoveFile` when source and
/// destination are on different volumes. The `winapi` crate exposes
/// this as `ERROR_NOT_SAME_DEVICE`; the literal is used directly so
/// the Unix build doesn't pull in a Windows-only dependency for one
/// integer.
#[cfg(windows)]
const ERROR_NOT_SAME_DEVICE: i32 = 17;

/// True when `e` describes a rename that crossed a filesystem
/// boundary. Uses the stable `ErrorKind::CrossesDevices` mapping
/// first; falls back to the platform-specific raw code so a future
/// Rust that drops the kind mapping still works.
fn is_cross_device_error(e: &std::io::Error) -> bool {
    if e.kind() == std::io::ErrorKind::CrossesDevices {
        return true;
    }
    #[cfg(unix)]
    {
        e.raw_os_error() == Some(libc::EXDEV)
    }
    #[cfg(windows)]
    {
        e.raw_os_error() == Some(ERROR_NOT_SAME_DEVICE)
    }
    #[cfg(not(any(unix, windows)))]
    {
        false
    }
}

/// Move `src` to `dst`, falling back to copy + delete when the rename
/// crosses a filesystem boundary (`EXDEV` on Unix,
/// `ERROR_NOT_SAME_DEVICE` on Windows). The fallback isn't atomic —
/// a crash between the copy and the delete leaves both files — but
/// the alternative is a hard error for what is otherwise a common
/// operation (move into `/tmp` on macOS APFS, move across mounts on
/// Linux), so the tradeoff is worth it.
pub fn rename_with_cross_device_fallback(src: &Path, dst: &Path) -> std::io::Result<()> {
    match fs::rename(src, dst) {
        Ok(()) => Ok(()),
        Err(e) if is_cross_device_error(&e) => {
            fs::copy(src, dst)?;
            fs::remove_file(src)?;
            Ok(())
        }
        Err(e) => Err(e),
    }
}

/// Reserve a fresh `<target>.sofos.tmp.<hex>` sibling file using
/// `O_EXCL`-style exclusive create, returning both the path and the
/// open handle so the caller writes through the handle we just owned
/// (no reopen race against a symlink swap). The 64 bits of randomness
/// make a collision astronomically unlikely; the small retry loop covers
/// the pathological case so we don't fail a legitimate write on one bad
/// draw.
fn create_tmp_sibling(target: &Path) -> std::io::Result<(PathBuf, fs::File)> {
    use std::io::ErrorKind;

    let mut rng = rand::rng();
    let mut last_err: Option<std::io::Error> = None;
    for _ in 0..ATOMIC_TMP_MAX_RETRIES {
        let suffix: u64 = rng.random();
        let mut s = target.as_os_str().to_os_string();
        s.push(format!(".sofos.tmp.{:016x}", suffix));
        let candidate = PathBuf::from(s);

        match fs::OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(&candidate)
        {
            Ok(file) => return Ok((candidate, file)),
            Err(e) if e.kind() == ErrorKind::AlreadyExists => {
                last_err = Some(e);
                continue;
            }
            Err(e) => return Err(e),
        }
    }
    Err(last_err.unwrap_or_else(|| {
        std::io::Error::new(
            std::io::ErrorKind::AlreadyExists,
            "exhausted retries reserving atomic-write temp file",
        )
    }))
}

/// Append `content` to `path`, creating the file if it doesn't exist.
/// Unlike `write_atomic` we don't stage through a tmp file — `append` is
/// inherently incremental (each call adds to whatever's already there),
/// so an atomic swap would either lose earlier chunks or require
/// reading the whole file back each time. Instead we use `OpenOptions`
/// with `append(true)`, which the OS handles atomically for each
/// write call on POSIX.
fn append_bytes(path: &Path, content: &str) -> std::io::Result<()> {
    use std::io::Write;
    let target = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
    let mut file = fs::OpenOptions::new()
        .append(true)
        .create(true)
        .open(&target)?;
    file.write_all(content.as_bytes())?;
    file.flush()
}

/// FileSystemTool provides secure file operations sandboxed to a workspace directory
#[derive(Clone)]
pub struct FileSystemTool {
    workspace: PathBuf,
}

impl FileSystemTool {
    pub fn new(workspace: PathBuf) -> Result<Self> {
        if !workspace.exists() {
            return Err(SofosError::Config(format!(
                "Workspace directory does not exist: {}",
                workspace.display()
            )));
        }

        let canonical = fs::canonicalize(&workspace).with_context(|| {
            format!("Failed to resolve workspace path: {}", workspace.display())
        })?;

        Ok(Self {
            workspace: canonical,
        })
    }

    /// Validate and resolve a path relative to the workspace
    /// Returns an error if the path attempts to escape the workspace
    fn validate_path(&self, path: &str) -> Result<PathBuf> {
        // `is_absolute_path` catches both Unix (`/foo`) and Windows
        // (`C:\foo`, UNC `\\server\share`) shapes. Using
        // `Path::is_absolute` directly would miss Unix-style paths
        // when running on Windows — a regression the helper
        // specifically guards against.
        if is_absolute_path(path) {
            return Err(SofosError::PathViolation(
                "Absolute paths are not allowed".to_string(),
            ));
        }

        // Reject only real `..` path components — a substring match would
        // also reject legitimate filenames like `my..file.txt` or
        // `cache..old/note.md`. The canonical check below is still the
        // ultimate guard against escapes via symlinks, but stopping the
        // traversal here gives a clearer error message and avoids
        // letting `..` segments mix with workspace-relative joins.
        if Path::new(path)
            .components()
            .any(|c| matches!(c, Component::ParentDir))
        {
            return Err(SofosError::PathViolation(
                "Parent directory traversal (..) is not allowed".to_string(),
            ));
        }

        let full_path = self.workspace.join(path);

        let canonical = if full_path.exists() {
            fs::canonicalize(&full_path)?
        } else if let Some(parent) = full_path.parent() {
            if parent.exists() {
                let canonical_parent = fs::canonicalize(parent)?;
                canonical_parent.join(full_path.file_name().context("Invalid filename")?)
            } else {
                full_path
            }
        } else {
            full_path
        };

        if !canonical.starts_with(&self.workspace) {
            return Err(SofosError::PathViolation(format!(
                "Path escapes workspace: {}",
                path
            )));
        }

        Ok(canonical)
    }

    /// Read the full contents of a file inside the workspace.
    ///
    /// Returns the complete bytes (subject to [`MAX_FILE_SIZE`]) with no
    /// truncation — `edit_file` / `morph_edit_file` need the whole file
    /// so their edits don't silently drop everything past the first
    /// ~64 KB. The `read_file` tool dispatcher is responsible for
    /// applying [`crate::tools::utils::truncate_for_context`] before
    /// handing the content to the model.
    pub fn read_file(&self, path: &str) -> Result<String> {
        let validated_path = self.validate_path(path)?;
        Self::read_bytes_bounded(&validated_path, path)
    }

    /// Read a file that may be outside the workspace. Only used when
    /// explicitly allowed by config — does not enforce the workspace
    /// prefix. Same "return raw bytes" contract as [`Self::read_file`];
    /// truncation for the read_file tool output is the dispatcher's job.
    pub fn read_file_with_outside_access(&self, path: &str) -> Result<String> {
        let full_path = PathBuf::from(path);
        let candidate = if full_path.is_absolute() {
            full_path
        } else {
            self.workspace.join(path)
        };
        let canonical = fs::canonicalize(&candidate)
            .with_context(|| format!("Failed to resolve path: {}", path))?;
        Self::read_bytes_bounded(&canonical, path)
    }

    /// Shared size-check + read logic for the two public read methods.
    /// `label` is the caller-facing path string used in error messages.
    fn read_bytes_bounded(path: &Path, label: &str) -> Result<String> {
        if !path.exists() {
            return Err(SofosError::FileNotFound(label.to_string()));
        }

        let metadata = fs::metadata(path)
            .with_context(|| format!("Failed to read metadata for: {}", label))?;

        if metadata.len() > MAX_FILE_SIZE {
            return Err(SofosError::ToolExecution(format!(
                "File too large: {} (max: {} MB)",
                label,
                MAX_FILE_SIZE / (1024 * 1024)
            )));
        }

        fs::read_to_string(path).with_context(|| format!("Failed to read file: {}", label))
    }

    pub fn write_file(&self, path: &str, content: &str) -> Result<()> {
        let validated_path = self.validate_path(path)?;

        if let Some(parent) = validated_path.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("Failed to create parent directories for: {}", path))?;
        }

        write_atomic(&validated_path, content)
            .with_context(|| format!("Failed to write file: {}", path))
    }

    /// Write a file that may be outside the workspace.
    /// Only used when explicitly allowed by user — does not enforce workspace prefix.
    pub fn write_file_with_outside_access(&self, path: &str, content: &str) -> Result<()> {
        let full_path = if PathBuf::from(path).is_absolute() {
            PathBuf::from(path)
        } else {
            self.workspace.join(path)
        };

        if let Some(parent) = full_path.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("Failed to create parent directories for: {}", path))?;
        }

        write_atomic(&full_path, content).with_context(|| format!("Failed to write file: {}", path))
    }

    /// Append `content` to `path` inside the workspace. Creates the
    /// file and any missing parent directories if it doesn't exist,
    /// so the model can drive a "first-chunk / subsequent-chunks"
    /// pattern for writing files larger than a single `max_tokens`
    /// response can emit in one shot.
    pub fn append_file(&self, path: &str, content: &str) -> Result<()> {
        let validated_path = self.validate_path(path)?;

        if let Some(parent) = validated_path.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("Failed to create parent directories for: {}", path))?;
        }

        append_bytes(&validated_path, content)
            .with_context(|| format!("Failed to append to file: {}", path))
    }

    /// Append to a file that may be outside the workspace. Counterpart
    /// to `write_file_with_outside_access` — used after the user has
    /// explicitly granted Write access to the external path.
    pub fn append_file_with_outside_access(&self, path: &str, content: &str) -> Result<()> {
        let full_path = if PathBuf::from(path).is_absolute() {
            PathBuf::from(path)
        } else {
            self.workspace.join(path)
        };

        if let Some(parent) = full_path.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("Failed to create parent directories for: {}", path))?;
        }

        append_bytes(&full_path, content)
            .with_context(|| format!("Failed to append to file: {}", path))
    }

    pub fn create_directory(&self, path: &str) -> Result<()> {
        let full_path = self.validate_path(path)?;
        fs::create_dir_all(&full_path)?;
        Ok(())
    }

    pub fn list_directory(&self, path: &str) -> Result<Vec<String>> {
        let full_path = self.validate_path(path)?;

        if !full_path.exists() {
            return Err(SofosError::FileNotFound(path.to_string()));
        }

        if !full_path.is_dir() {
            return Err(SofosError::InvalidPath(format!(
                "'{}' is not a directory",
                path
            )));
        }

        let mut entries = Vec::new();
        for entry in fs::read_dir(&full_path)? {
            let entry = entry?;
            let name = entry.file_name().to_string_lossy().to_string();
            let is_dir = entry.file_type()?.is_dir();
            entries.push(if is_dir { format!("{}/", name) } else { name });
        }

        entries.sort();
        Ok(entries)
    }

    pub fn delete_file(&self, path: &str) -> Result<()> {
        let full_path = self.validate_path(path)?;
        Self::remove_file_at(&full_path, path)
    }

    pub fn delete_directory(&self, path: &str) -> Result<()> {
        let full_path = self.validate_path(path)?;
        Self::remove_directory_at(&full_path, path)
    }

    /// Delete a file that may be outside the workspace. Counterpart to
    /// `write_file_with_outside_access` — used after the user has
    /// explicitly granted Write access to the external path. Without
    /// this, `delete_file` rejected every external path even with a
    /// Write grant, while `write_file` / `edit_file` honoured it: an
    /// asymmetry that surprised users.
    pub fn delete_file_with_outside_access(&self, path: &str) -> Result<()> {
        let target = PathBuf::from(path);
        Self::remove_file_at(&target, path)
    }

    /// Delete a directory that may be outside the workspace. Same
    /// rationale as [`Self::delete_file_with_outside_access`].
    pub fn delete_directory_with_outside_access(&self, path: &str) -> Result<()> {
        let target = PathBuf::from(path);
        Self::remove_directory_at(&target, path)
    }

    /// Shared remove-file logic for the two delete entry points.
    fn remove_file_at(target: &Path, label: &str) -> Result<()> {
        if !target.exists() {
            return Err(SofosError::FileNotFound(label.to_string()));
        }
        if !target.is_file() {
            return Err(SofosError::InvalidPath(format!(
                "'{}' is not a file",
                label
            )));
        }
        fs::remove_file(target)?;
        Ok(())
    }

    /// Shared remove-directory logic for the two delete entry points.
    fn remove_directory_at(target: &Path, label: &str) -> Result<()> {
        if !target.exists() {
            return Err(SofosError::FileNotFound(label.to_string()));
        }
        if !target.is_dir() {
            return Err(SofosError::InvalidPath(format!(
                "'{}' is not a directory",
                label
            )));
        }
        fs::remove_dir_all(target)?;
        Ok(())
    }

    pub fn move_file(&self, source: &str, destination: &str) -> Result<()> {
        let source_path = self.validate_path(source)?;
        let dest_path = self.validate_path(destination)?;

        if !source_path.exists() {
            return Err(SofosError::FileNotFound(source.to_string()));
        }

        if let Some(parent) = dest_path.parent() {
            if !parent.exists() {
                fs::create_dir_all(parent)?;
            }
        }

        rename_with_cross_device_fallback(&source_path, &dest_path)?;
        Ok(())
    }

    pub fn copy_file(&self, source: &str, destination: &str) -> Result<()> {
        let source_path = self.validate_path(source)?;
        let dest_path = self.validate_path(destination)?;

        if !source_path.exists() {
            return Err(SofosError::FileNotFound(source.to_string()));
        }

        if !source_path.is_file() {
            return Err(SofosError::InvalidPath(format!(
                "'{}' is not a file",
                source
            )));
        }

        if let Some(parent) = dest_path.parent() {
            if !parent.exists() {
                fs::create_dir_all(parent)?;
            }
        }

        fs::copy(&source_path, &dest_path)?;
        Ok(())
    }

    pub fn workspace(&self) -> &Path {
        &self.workspace
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tools::test_support;

    #[test]
    fn test_path_validation_rejects_parent_traversal() {
        let (_temp, path) = test_support::workspace();
        let fs_tool = FileSystemTool::new(path).unwrap();

        assert!(fs_tool.validate_path("../etc/passwd").is_err());
        assert!(fs_tool.validate_path("foo/../../etc/passwd").is_err());
    }

    #[test]
    fn validate_path_allows_double_dot_in_filename() {
        // A filename that happens to contain `..` as a substring (but
        // isn't a `..` path component) is legitimate — earlier versions
        // wrongly rejected names like `my..file.txt` with a
        // `path.contains("..")` substring check.
        let (_temp, path) = test_support::workspace();
        let fs_tool = FileSystemTool::new(path).unwrap();

        assert!(fs_tool.validate_path("my..file.txt").is_ok());
        assert!(fs_tool.validate_path("cache..old/note.md").is_ok());
        assert!(fs_tool.validate_path("foo..bar/baz..qux.txt").is_ok());
    }

    #[test]
    fn write_atomic_replaces_existing_content_durably() {
        // Smoke test: write_atomic must produce a readable file with the
        // exact bytes passed in, with no leftover `.sofos.tmp.*` siblings
        // in the directory after a successful write. `sync_all` runs
        // before the rename — failing to flush would leave the file
        // empty on a crash, which is the regression we're guarding
        // against.
        let (_temp, path) = test_support::workspace();
        let fs_tool = FileSystemTool::new(path.clone()).unwrap();
        fs_tool.write_file("doc.md", "first").unwrap();
        fs_tool.write_file("doc.md", "second pass").unwrap();
        let contents = fs_tool.read_file("doc.md").unwrap();
        assert_eq!(contents, "second pass");

        // No tmp file should survive a successful write.
        let leftover: Vec<_> = std::fs::read_dir(&path)
            .unwrap()
            .filter_map(|e| e.ok())
            .filter(|e| e.file_name().to_string_lossy().contains(".sofos.tmp."))
            .collect();
        assert!(
            leftover.is_empty(),
            "no .sofos.tmp.* artefact should remain"
        );
    }

    #[cfg(unix)]
    #[test]
    fn cross_device_move_falls_back_to_copy_remove() {
        // `fs::rename` returns `EXDEV` across filesystems. Modern macOS
        // (every `/tmp` is a separate APFS volume from the user's home)
        // and Linux containers (`/dev/shm` vs `/tmp`) both hit this in
        // practice. The fallback must succeed where rename can't.
        //
        // We can't reliably stage two devices in unit-tests, so we go
        // straight at `is_cross_device_error` + `rename_with_cross_device_fallback`
        // on a same-device move and verify it still works (the
        // fast-path) — the slow-path is exercised by hand on macOS
        // before each release.
        let (_temp, path) = test_support::workspace();
        let fs_tool = FileSystemTool::new(path).unwrap();
        fs_tool.write_file("src.txt", "hello").unwrap();
        fs_tool.move_file("src.txt", "dst.txt").unwrap();
        let contents = fs_tool.read_file("dst.txt").unwrap();
        assert_eq!(contents, "hello");
    }

    #[test]
    fn cross_device_error_classifier_matches_kind() {
        // `ErrorKind::CrossesDevices` is the canonical signal from
        // 1.85+; any future toolchain that drops the mapping is caught
        // by the platform-specific raw-error fallback.
        let e = std::io::Error::from(std::io::ErrorKind::CrossesDevices);
        assert!(is_cross_device_error(&e));

        // Unrelated IO errors must NOT trigger the copy+remove path.
        let other = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
        assert!(!is_cross_device_error(&other));
    }

    #[test]
    fn append_file_creates_then_appends_across_calls() {
        let (_temp, path) = test_support::workspace();
        let fs_tool = FileSystemTool::new(path).unwrap();
        fs_tool.append_file("doc.md", "# Part 1\n").unwrap();
        fs_tool.append_file("doc.md", "# Part 2\n").unwrap();
        fs_tool.append_file("doc.md", "# Part 3\n").unwrap();
        let contents = fs_tool.read_file("doc.md").unwrap();
        assert_eq!(contents, "# Part 1\n# Part 2\n# Part 3\n");
    }

    #[test]
    fn append_file_creates_missing_parent_dirs() {
        let (_temp, path) = test_support::workspace();
        let fs_tool = FileSystemTool::new(path).unwrap();
        fs_tool
            .append_file("nested/deep/file.txt", "hello")
            .unwrap();
        let contents = fs_tool.read_file("nested/deep/file.txt").unwrap();
        assert_eq!(contents, "hello");
    }

    #[test]
    fn append_preserves_multibyte_chunks() {
        // Writing long Cyrillic/CJK content part-by-part shouldn't
        // corrupt multi-byte sequences at the chunk boundary — each
        // chunk is a complete UTF-8 string on its own.
        let (_temp, path) = test_support::workspace();
        let fs_tool = FileSystemTool::new(path).unwrap();
        fs_tool
            .append_file("bg.md", "# Синергията между Божия промисъл")
            .unwrap();
        fs_tool.append_file("bg.md", " и човешката воля").unwrap();
        let contents = fs_tool.read_file("bg.md").unwrap();
        assert_eq!(
            contents,
            "# Синергията между Божия промисъл и човешката воля"
        );
    }

    #[test]
    fn test_path_validation_rejects_absolute_paths() {
        let (_temp, path) = test_support::workspace();
        let fs_tool = FileSystemTool::new(path).unwrap();

        assert!(fs_tool.validate_path("/etc/passwd").is_err());
    }

    #[test]
    fn test_path_validation_allows_relative_paths() {
        let (_temp, path) = test_support::workspace();
        let fs_tool = FileSystemTool::new(path).unwrap();

        assert!(fs_tool.validate_path("foo/bar.txt").is_ok());
        assert!(fs_tool.validate_path("test.txt").is_ok());
    }

    #[test]
    fn test_write_and_read_file() {
        let (_temp, path) = test_support::workspace();
        let fs_tool = FileSystemTool::new(path).unwrap();

        fs_tool.write_file("test.txt", "Hello, World!").unwrap();
        let content = fs_tool.read_file("test.txt").unwrap();
        assert_eq!(content, "Hello, World!");
    }

    #[test]
    #[cfg(unix)]
    fn test_write_atomic_preserves_file_mode() {
        use std::os::unix::fs::PermissionsExt;
        let (_temp, workspace) = test_support::workspace();
        let path = workspace.join("script.sh");

        // Create the file with an executable mode — the property
        // `write_atomic` has to preserve across the tmp+rename swap.
        fs::write(&path, "#!/bin/sh\necho hello\n").unwrap();
        fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();

        write_atomic(&path, "#!/bin/sh\necho updated\n").unwrap();

        let mode_after = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
        assert_eq!(
            mode_after, 0o755,
            "write_atomic must preserve the original file mode across the swap"
        );
        assert_eq!(
            fs::read_to_string(&path).unwrap(),
            "#!/bin/sh\necho updated\n"
        );
    }

    #[test]
    fn write_atomic_uses_unpredictable_temp_filename() {
        // Predictable `.sofos.tmp` was a TOCTOU vector — an attacker who
        // could write to the same directory might pre-create that name
        // (potentially as a symlink). The replacement uses a random
        // suffix and exclusive-create, so two back-to-back writes pick
        // different temp paths and the literal fixed name is never
        // reused.
        let (_temp, workspace) = test_support::workspace();
        let path = workspace.join("note.md");

        // First, plant the historically predictable name so the new
        // path generator must avoid it. `write_atomic` cannot rely on
        // a fixed name to clobber it.
        let predictable = workspace.join("note.md.sofos.tmp");
        fs::write(&predictable, "decoy").unwrap();

        write_atomic(&path, "first").unwrap();
        write_atomic(&path, "second").unwrap();

        assert_eq!(fs::read_to_string(&path).unwrap(), "second");
        // The fixed-name decoy is untouched, proving we no longer write
        // through it.
        assert_eq!(fs::read_to_string(&predictable).unwrap(), "decoy");

        // No stray temp files survive on the happy path.
        let leftovers: Vec<_> = fs::read_dir(&workspace)
            .unwrap()
            .filter_map(|e| e.ok())
            .filter(|e| {
                e.file_name()
                    .to_string_lossy()
                    .starts_with("note.md.sofos.tmp.")
            })
            .collect();
        assert!(
            leftovers.is_empty(),
            "atomic write must clean up its random temp files"
        );
    }

    #[test]
    #[cfg(unix)]
    fn test_write_atomic_preserves_symlink() {
        use std::os::unix::fs::symlink;
        let (_temp, workspace) = test_support::workspace();
        let target = workspace.join("real.txt");
        let link = workspace.join("link.txt");

        fs::write(&target, "original content").unwrap();
        symlink(&target, &link).unwrap();

        // Writing through the symlink should update the real file and
        // leave the link itself intact — the whole point of resolving
        // via canonicalize before staging the tmp.
        write_atomic(&link, "updated via link").unwrap();

        assert!(
            fs::symlink_metadata(&link)
                .unwrap()
                .file_type()
                .is_symlink(),
            "link must still be a symlink after write_atomic"
        );
        assert_eq!(fs::read_to_string(&target).unwrap(), "updated via link");
        assert_eq!(fs::read_to_string(&link).unwrap(), "updated via link");
    }

    #[test]
    fn test_create_directory_and_list() {
        let (_temp, path) = test_support::workspace();
        let fs_tool = FileSystemTool::new(path).unwrap();

        fs_tool.create_directory("subdir").unwrap();
        fs_tool.write_file("subdir/file.txt", "test").unwrap();

        let entries = fs_tool.list_directory("subdir").unwrap();
        assert_eq!(entries, vec!["file.txt"]);
    }

    #[test]
    fn test_list_nested_subdirectories() {
        let (_temp, path) = test_support::workspace();
        let fs_tool = FileSystemTool::new(path).unwrap();

        fs_tool.create_directory("parent/child").unwrap();
        fs_tool.write_file("parent/file1.txt", "test1").unwrap();
        fs_tool
            .write_file("parent/child/file2.txt", "test2")
            .unwrap();

        let parent_entries = fs_tool.list_directory("parent").unwrap();
        assert!(parent_entries.contains(&"child/".to_string()));
        assert!(parent_entries.contains(&"file1.txt".to_string()));

        let child_entries = fs_tool.list_directory("parent/child").unwrap();
        assert_eq!(child_entries, vec!["file2.txt"]);
    }

    #[test]
    fn test_file_size_limit() {
        let (_temp, path) = test_support::workspace();
        let fs_tool = FileSystemTool::new(path).unwrap();

        let large_data = vec![0u8; 51 * 1024 * 1024];
        fs_tool
            .write_file("large_file.bin", &String::from_utf8_lossy(&large_data))
            .unwrap();

        let result = fs_tool.read_file("large_file.bin");
        assert!(result.is_err());

        let err = result.unwrap_err();
        assert!(matches!(err, SofosError::ToolExecution(_)));
    }

    #[test]
    fn delete_file_with_outside_access_removes_external_file() {
        // Counterpart to write_file_with_outside_access: when the
        // executor has already cleared the Write grant, the FS tool
        // must delete the canonical external file without re-imposing
        // the workspace prefix. Previously this method did not exist;
        // `delete_file` rejected every external path even with a Write
        // grant, while `write_file` honoured it. The new method closes
        // the asymmetry.
        let (_workspace_tmp, workspace_path) = test_support::workspace();
        let (_outside_tmp, outside_path) = test_support::workspace();
        let fs_tool = FileSystemTool::new(workspace_path).unwrap();

        let outside_file = outside_path.join("to_delete.txt");
        fs::write(&outside_file, "delete me").unwrap();
        assert!(outside_file.exists());

        fs_tool
            .delete_file_with_outside_access(&outside_file.to_string_lossy())
            .unwrap();

        assert!(!outside_file.exists());
    }

    #[test]
    fn delete_directory_with_outside_access_removes_external_directory() {
        let (_workspace_tmp, workspace_path) = test_support::workspace();
        let (_outside_tmp, outside_path) = test_support::workspace();
        let fs_tool = FileSystemTool::new(workspace_path).unwrap();

        let outside_dir = outside_path.join("nested/dir");
        fs::create_dir_all(&outside_dir).unwrap();
        fs::write(outside_dir.join("file.txt"), "contents").unwrap();
        assert!(outside_dir.exists());

        fs_tool
            .delete_directory_with_outside_access(&outside_dir.to_string_lossy())
            .unwrap();

        assert!(!outside_dir.exists());
        // The parent directory of the removed dir is left untouched.
        assert!(outside_path.join("nested").exists());
    }

    #[test]
    fn delete_with_outside_access_reports_missing_file() {
        let (_workspace_tmp, workspace_path) = test_support::workspace();
        let fs_tool = FileSystemTool::new(workspace_path).unwrap();

        let nonexistent = std::path::PathBuf::from("/this/path/definitely/does/not/exist");
        let err = fs_tool
            .delete_file_with_outside_access(&nonexistent.to_string_lossy())
            .unwrap_err();
        assert!(matches!(err, SofosError::FileNotFound(_)));
    }

    #[test]
    #[cfg(unix)] // Symlinks work differently on Windows
    fn test_symlink_escape_blocked() {
        use std::os::unix::fs::symlink;

        let (_workspace_tmp, workspace_path) = test_support::workspace();
        let (_outside_tmp, outside_path) = test_support::workspace();

        let fs_tool = FileSystemTool::new(workspace_path.clone()).unwrap();

        let outside_file = outside_path.join("secret.txt");
        fs::write(&outside_file, "secret data").unwrap();

        let symlink_path = workspace_path.join("escape_link");
        symlink(&outside_file, &symlink_path).unwrap();

        let result = fs_tool.read_file("escape_link");
        assert!(result.is_err());

        let err = result.unwrap_err();
        assert!(matches!(err, SofosError::PathViolation(_)));

        if let SofosError::PathViolation(msg) = err {
            assert!(msg.contains("workspace"));
        }
    }
}
