//! Filesystem API for plugins.
//!
//! Provides sandboxed read, list, and watch operations restricted to paths
//! within the user's home directory. Plugins declare `fs:read`, `fs:list`,
//! or `fs:watch` capabilities in their manifest to use these commands.

use crate::AppState;
use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::PathBuf;
use std::sync::Arc;
#[cfg(feature = "desktop")]
use tauri::{AppHandle, Emitter, State};

/// Maximum file size readable via plugin_read_file (10 MB).
const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;

// ---------------------------------------------------------------------------
// Path validation
// ---------------------------------------------------------------------------

/// Test-only serialization lock for tests that use filesystem operations.
/// Tests that set a home dir override acquire this lock first to prevent
/// parallel interference with tests that use the real home dir.
#[cfg(test)]
static FS_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// Test-only override for the home directory used by path validation.
/// Uses RwLock to avoid deadlock (write tests set it, effective_home_dir reads it).
#[cfg(test)]
static HOME_DIR_OVERRIDE: std::sync::RwLock<Option<PathBuf>> = std::sync::RwLock::new(None);

/// Set home dir override. Returns a guard that clears the override on drop
/// and holds the serialization lock.
#[cfg(test)]
fn set_home_dir_override(dir: PathBuf) -> impl Drop {
    let fs_guard = FS_TEST_LOCK.lock().unwrap();
    *HOME_DIR_OVERRIDE.write().unwrap() = Some(dir);
    struct Guard(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
    impl Drop for Guard {
        fn drop(&mut self) {
            *HOME_DIR_OVERRIDE.write().unwrap() = None;
        }
    }
    Guard(fs_guard)
}

fn effective_home_dir() -> Result<PathBuf, String> {
    #[cfg(test)]
    if let Some(dir) = HOME_DIR_OVERRIDE.read().unwrap().clone() {
        return dir
            .canonicalize()
            .map_err(|e| format!("Failed to resolve home override: {e}"));
    }
    dirs::home_dir().ok_or("Cannot determine home directory".into())
}

/// Resolve and validate that a path is within $HOME.
/// Returns the canonicalized path on success.
fn validate_within_home(raw: &str) -> Result<PathBuf, String> {
    if raw.is_empty() {
        return Err("Path is empty".into());
    }

    let path = PathBuf::from(crate::cli::expand_tilde(raw));
    if !path.is_absolute() {
        return Err("Path must be absolute".into());
    }

    // Canonicalize resolves symlinks and .. components
    let canonical = path
        .canonicalize()
        .map_err(|e| format!("Failed to resolve path: {e}"))?;

    let home = effective_home_dir()?;

    if !canonical.starts_with(&home) {
        return Err("Path must be within the user's home directory".into());
    }

    Ok(canonical)
}

// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------

/// Read a file's content as UTF-8 text.
/// Validates the path is within $HOME, enforces a 10 MB size limit.
#[cfg(feature = "desktop")]
#[tauri::command]
pub async fn plugin_read_file(
    path: String,
    plugin_id: String,
    state: tauri::State<'_, std::sync::Arc<crate::AppState>>,
) -> Result<String, String> {
    crate::plugins::check_plugin_capability(&state, &plugin_id, "fs:read")?;
    let canonical = validate_within_home(&path)?;

    // Check file size before reading
    let metadata =
        std::fs::metadata(&canonical).map_err(|e| format!("Failed to stat file: {e}"))?;

    if !metadata.is_file() {
        return Err("Path is not a file".into());
    }

    if metadata.len() > MAX_FILE_SIZE {
        return Err(format!(
            "File exceeds maximum size ({} bytes > {} bytes)",
            metadata.len(),
            MAX_FILE_SIZE
        ));
    }

    std::fs::read_to_string(&canonical).map_err(|e| format!("Failed to read file: {e}"))
}

/// List filenames in a directory, optionally filtered by a glob pattern.
/// Returns filenames only (not full paths). Validates path is within $HOME.
#[cfg(feature = "desktop")]
#[tauri::command]
pub async fn plugin_list_directory(
    path: String,
    pattern: Option<String>,
    sort_by: Option<String>,
    plugin_id: String,
    state: tauri::State<'_, std::sync::Arc<crate::AppState>>,
) -> Result<Vec<String>, String> {
    crate::plugins::check_plugin_capability(&state, &plugin_id, "fs:list")?;
    plugin_list_directory_inner(path, pattern, sort_by).await
}

async fn plugin_list_directory_inner(
    path: String,
    pattern: Option<String>,
    sort_by: Option<String>,
) -> Result<Vec<String>, String> {
    let canonical = validate_within_home(&path)?;

    if !canonical.is_dir() {
        return Err("Path is not a directory".into());
    }

    let glob_pattern = pattern
        .as_deref()
        .map(|p| glob::Pattern::new(p).map_err(|e| format!("Invalid glob pattern: {e}")))
        .transpose()?;

    let entries =
        std::fs::read_dir(&canonical).map_err(|e| format!("Failed to read directory: {e}"))?;

    // Sort mode: "name" (default, alphabetical) or "mtime" (newest first).
    // mtime mode enables plugins to efficiently find recently-modified files
    // without scanning every entry (e.g. cache-keepalive picking the active JSONL).
    let sort_mode = sort_by.as_deref().unwrap_or("name");
    let mut items: Vec<(String, std::time::SystemTime)> = Vec::new();
    for entry in entries.flatten() {
        let name = entry.file_name().to_string_lossy().to_string();
        if let Some(ref pat) = glob_pattern
            && !pat.matches(&name)
        {
            continue;
        }
        let mtime = if sort_mode == "mtime" {
            entry
                .metadata()
                .and_then(|m| m.modified())
                .unwrap_or(std::time::UNIX_EPOCH)
        } else {
            std::time::UNIX_EPOCH
        };
        items.push((name, mtime));
    }

    match sort_mode {
        "mtime" => items.sort_by_key(|a| std::cmp::Reverse(a.1)),
        _ => items.sort_by(|a, b| a.0.cmp(&b.0)),
    }
    Ok(items.into_iter().map(|(n, _)| n).collect())
}

/// Read the last `max_bytes` of a file as UTF-8 text.
/// Seeks to `file_size - max_bytes`, then skips to the next newline to avoid
/// partial lines. If the file is smaller than `max_bytes`, reads the entire file.
/// Validates path is within $HOME, same as plugin_read_file.
#[cfg(feature = "desktop")]
#[tauri::command]
pub async fn plugin_read_file_tail(
    path: String,
    max_bytes: u64,
    plugin_id: String,
    state: tauri::State<'_, std::sync::Arc<crate::AppState>>,
) -> Result<String, String> {
    crate::plugins::check_plugin_capability(&state, &plugin_id, "fs:read")?;
    plugin_read_file_tail_inner(path, max_bytes).await
}

async fn plugin_read_file_tail_inner(path: String, max_bytes: u64) -> Result<String, String> {
    use std::io::{Read, Seek, SeekFrom};

    let canonical = validate_within_home(&path)?;

    let metadata =
        std::fs::metadata(&canonical).map_err(|e| format!("Failed to stat file: {e}"))?;

    if !metadata.is_file() {
        return Err("Path is not a file".into());
    }

    let file_size = metadata.len();

    // If the file fits within max_bytes, read the whole thing
    if file_size <= max_bytes {
        return std::fs::read_to_string(&canonical)
            .map_err(|e| format!("Failed to read file: {e}"));
    }

    let mut file =
        std::fs::File::open(&canonical).map_err(|e| format!("Failed to open file: {e}"))?;

    let seek_pos = file_size - max_bytes;
    file.seek(SeekFrom::Start(seek_pos))
        .map_err(|e| format!("Failed to seek: {e}"))?;

    let mut buf = Vec::with_capacity(max_bytes as usize);
    file.read_to_end(&mut buf)
        .map_err(|e| format!("Failed to read file tail: {e}"))?;

    let text = String::from_utf8_lossy(&buf);

    // Skip partial first line (find first newline and skip past it)
    match text.find('\n') {
        Some(idx) => Ok(text[idx + 1..].to_string()),
        None => Ok(text.to_string()),
    }
}

/// Start watching a path for filesystem changes.
/// Returns a watch_id (UUID) that can be used with plugin_unwatch.
/// Emits `plugin-fs-change-{plugin_id}` Tauri events on changes.
#[cfg(feature = "desktop")]
#[tauri::command]
pub async fn plugin_watch_path(
    path: String,
    plugin_id: String,
    recursive: Option<bool>,
    debounce_ms: Option<u64>,
    state: State<'_, Arc<AppState>>,
    app: AppHandle,
) -> Result<String, String> {
    crate::plugins::check_plugin_capability(&state, &plugin_id, "fs:watch")?;
    let canonical = validate_within_home(&path)?;

    let watch_id = uuid::Uuid::new_v4().to_string();
    let event_name = format!("plugin-fs-change-{plugin_id}");
    let debounce = std::time::Duration::from_millis(debounce_ms.unwrap_or(300));
    let mode = if recursive.unwrap_or(false) {
        RecursiveMode::Recursive
    } else {
        RecursiveMode::NonRecursive
    };

    // Channel for debouncing: collect events, emit after quiet period
    let (tx, rx) = std::sync::mpsc::channel::<notify::Result<Event>>();

    let mut watcher = RecommendedWatcher::new(tx, notify::Config::default())
        .map_err(|e| format!("Failed to create watcher: {e}"))?;

    watcher
        .watch(&canonical, mode)
        .map_err(|e| format!("Failed to watch path: {e}"))?;

    // Store watcher in AppState for cleanup
    let wid = watch_id.clone();
    state
        .plugin_watchers
        .insert(wid.clone(), (plugin_id.clone(), watcher));

    // Spawn debounce thread that emits Tauri events
    let app_handle = app.clone();
    std::thread::spawn(move || {
        debounce_loop(rx, debounce, &event_name, &app_handle);
    });

    Ok(watch_id)
}

/// Stop watching a previously registered path.
#[cfg(feature = "desktop")]
#[tauri::command]
pub async fn plugin_unwatch(
    watch_id: String,
    _plugin_id: String,
    state: State<'_, Arc<AppState>>,
) -> Result<(), String> {
    // Remove drops the watcher, which stops the notify thread
    match state.plugin_watchers.remove(&watch_id) {
        Some(_) => Ok(()),
        None => Err(format!("Watch ID not found: {watch_id}")),
    }
}

// ---------------------------------------------------------------------------
// Debounce loop
// ---------------------------------------------------------------------------

#[cfg(feature = "desktop")]
/// Collect notify events and emit batched Tauri events after a quiet period.
fn debounce_loop(
    rx: std::sync::mpsc::Receiver<notify::Result<Event>>,
    debounce: std::time::Duration,
    event_name: &str,
    app: &AppHandle,
) {
    use std::collections::HashMap;

    loop {
        // Block until first event (or channel close)
        let first = match rx.recv() {
            Ok(Ok(event)) => event,
            Ok(Err(e)) => {
                crate::app_logger::log_via_handle(
                    app,
                    "warn",
                    "plugin",
                    &format!("[plugin_fs] Watcher error: {e}"),
                );
                continue;
            }
            Err(_) => break, // Channel closed — watcher was dropped
        };

        // Collect events during the debounce window
        let mut events_by_path: HashMap<PathBuf, String> = HashMap::new();
        classify_event(&first, &mut events_by_path);

        let deadline = std::time::Instant::now() + debounce;
        loop {
            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
            if remaining.is_zero() {
                break;
            }
            match rx.recv_timeout(remaining) {
                Ok(Ok(event)) => classify_event(&event, &mut events_by_path),
                Ok(Err(e)) => crate::app_logger::log_via_handle(
                    app,
                    "warn",
                    "plugin",
                    &format!("[plugin_fs] Watcher error: {e}"),
                ),
                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => break,
                Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return,
            }
        }

        // Emit batched changes
        let changes: Vec<serde_json::Value> = events_by_path
            .into_iter()
            .map(|(path, kind)| {
                serde_json::json!({
                    "type": kind,
                    "path": path.to_string_lossy(),
                })
            })
            .collect();

        if !changes.is_empty() {
            let _ = app.emit(event_name, changes);
        }
    }
}

/// Map a notify event to a simplified type string and collect by path.
fn classify_event(event: &Event, map: &mut std::collections::HashMap<PathBuf, String>) {
    let kind = match event.kind {
        notify::EventKind::Create(_) => "create",
        notify::EventKind::Modify(_) => "modify",
        notify::EventKind::Remove(_) => "delete",
        _ => return,
    };

    for path in &event.paths {
        map.insert(path.clone(), kind.to_string());
    }
}

// ---------------------------------------------------------------------------
// Write & Rename (capability-gated: fs:write, fs:rename)
// ---------------------------------------------------------------------------

/// Maximum content size writable via plugin_write_file (10 MB).
const MAX_WRITE_SIZE: usize = 10 * 1024 * 1024;

/// Write content to a file within $HOME.
/// Creates parent directories if needed. Refuses to overwrite directories.
#[cfg(feature = "desktop")]
#[tauri::command]
pub async fn plugin_write_file(
    path: String,
    content: String,
    plugin_id: String,
    state: tauri::State<'_, std::sync::Arc<crate::AppState>>,
) -> Result<(), String> {
    crate::plugins::check_plugin_capability(&state, &plugin_id, "fs:write")?;
    plugin_write_file_inner(path, content).await
}

/// Core write logic, separated from the Tauri command wrapper for testability.
async fn plugin_write_file_inner(path: String, content: String) -> Result<(), String> {
    if content.len() > MAX_WRITE_SIZE {
        return Err(format!(
            "Content exceeds maximum size ({} bytes > {} bytes)",
            content.len(),
            MAX_WRITE_SIZE
        ));
    }

    let file_path = PathBuf::from(&path);
    if !file_path.is_absolute() {
        return Err("Path must be absolute".into());
    }

    let home = effective_home_dir()?;

    if file_path.exists() {
        let canonical = file_path
            .canonicalize()
            .map_err(|e| format!("Failed to resolve path: {e}"))?;
        if !canonical.starts_with(&home) {
            return Err("Path must be within the user's home directory".into());
        }
        if canonical.is_dir() {
            return Err("Cannot overwrite a directory".into());
        }
    } else {
        let parent = file_path
            .parent()
            .ok_or("Cannot determine parent directory")?;
        if !parent.exists() {
            std::fs::create_dir_all(parent)
                .map_err(|e| format!("Failed to create parent directories: {e}"))?;
        }
        let canonical_parent = parent
            .canonicalize()
            .map_err(|e| format!("Failed to resolve parent path: {e}"))?;
        if !canonical_parent.starts_with(&home) {
            return Err("Path must be within the user's home directory".into());
        }
    }

    std::fs::write(&file_path, &content).map_err(|e| format!("Failed to write file: {e}"))
}

/// Rename/move a file within $HOME.
/// Both source and destination must be within $HOME. Source must exist.
#[cfg(feature = "desktop")]
#[tauri::command]
pub async fn plugin_rename_path(
    from: String,
    to: String,
    plugin_id: String,
    state: tauri::State<'_, std::sync::Arc<crate::AppState>>,
) -> Result<(), String> {
    crate::plugins::check_plugin_capability(&state, &plugin_id, "fs:rename")?;
    plugin_rename_path_inner(from, to).await
}

async fn plugin_rename_path_inner(from: String, to: String) -> Result<(), String> {
    let from_path = validate_within_home(&from)?;

    let to_path = PathBuf::from(&to);
    if !to_path.is_absolute() {
        return Err("Destination path must be absolute".into());
    }

    let home = effective_home_dir()?;

    let to_parent = to_path
        .parent()
        .ok_or("Cannot determine destination parent directory")?;
    if !to_parent.exists() {
        std::fs::create_dir_all(to_parent)
            .map_err(|e| format!("Failed to create destination parent directories: {e}"))?;
    }
    let canonical_parent = to_parent
        .canonicalize()
        .map_err(|e| format!("Failed to resolve destination parent: {e}"))?;
    if !canonical_parent.starts_with(&home) {
        return Err("Destination must be within the user's home directory".into());
    }

    std::fs::rename(&from_path, &to_path).map_err(|e| format!("Failed to rename: {e}"))
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    #[test]
    fn validate_rejects_empty_path() {
        assert!(validate_within_home("").is_err());
    }

    #[test]
    fn validate_rejects_relative_path() {
        assert!(validate_within_home("relative/path").is_err());
    }

    #[test]
    fn validate_rejects_outside_home() {
        let _guard = FS_TEST_LOCK.lock().unwrap();
        let home = dirs::home_dir().unwrap();
        if !Path::new("/tmp").starts_with(&home) {
            assert!(validate_within_home("/tmp").is_err());
        }
    }

    #[test]
    fn validate_accepts_home_dir() {
        let _guard = FS_TEST_LOCK.lock().unwrap();
        let home = dirs::home_dir().unwrap();
        let result = validate_within_home(home.to_str().unwrap());
        assert!(result.is_ok());
    }

    #[test]
    fn validate_rejects_traversal() {
        let _guard = FS_TEST_LOCK.lock().unwrap();
        let home = dirs::home_dir().unwrap();
        let traversal = format!("{}/../../../etc/passwd", home.display());
        assert!(validate_within_home(&traversal).is_err());
    }

    #[test]
    fn classify_create_event() {
        let mut map = std::collections::HashMap::new();
        let event = Event {
            kind: notify::EventKind::Create(notify::event::CreateKind::File),
            paths: vec![PathBuf::from("/test/file.txt")],
            attrs: Default::default(),
        };
        classify_event(&event, &mut map);
        assert_eq!(map.get(Path::new("/test/file.txt")).unwrap(), "create");
    }

    #[test]
    fn classify_modify_event() {
        let mut map = std::collections::HashMap::new();
        let event = Event {
            kind: notify::EventKind::Modify(notify::event::ModifyKind::Data(
                notify::event::DataChange::Content,
            )),
            paths: vec![PathBuf::from("/test/file.txt")],
            attrs: Default::default(),
        };
        classify_event(&event, &mut map);
        assert_eq!(map.get(Path::new("/test/file.txt")).unwrap(), "modify");
    }

    #[test]
    fn classify_remove_event() {
        let mut map = std::collections::HashMap::new();
        let event = Event {
            kind: notify::EventKind::Remove(notify::event::RemoveKind::File),
            paths: vec![PathBuf::from("/test/file.txt")],
            attrs: Default::default(),
        };
        classify_event(&event, &mut map);
        assert_eq!(map.get(Path::new("/test/file.txt")).unwrap(), "delete");
    }

    #[test]
    fn classify_ignores_access_event() {
        let mut map = std::collections::HashMap::new();
        let event = Event {
            kind: notify::EventKind::Access(notify::event::AccessKind::Read),
            paths: vec![PathBuf::from("/test/file.txt")],
            attrs: Default::default(),
        };
        classify_event(&event, &mut map);
        assert!(map.is_empty());
    }

    #[test]
    fn tail_reads_entire_small_file() {
        let tmp = tempfile::tempdir().unwrap();
        let _guard = set_home_dir_override(tmp.path().to_path_buf());
        let test_file = tmp.path().join("tail-small.txt");
        std::fs::write(&test_file, "line1\nline2\nline3\n").unwrap();

        let rt = tokio::runtime::Runtime::new().unwrap();
        let result = rt.block_on(plugin_read_file_tail_inner(
            test_file.to_string_lossy().to_string(),
            1024,
        ));
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), "line1\nline2\nline3\n");
    }

    #[test]
    fn tail_reads_last_bytes_skipping_partial_line() {
        let tmp = tempfile::tempdir().unwrap();
        let _guard = set_home_dir_override(tmp.path().to_path_buf());
        let test_file = tmp.path().join("tail-large.txt");
        let content = "line1\nline2\nline3\nline4\nline5\n";
        std::fs::write(&test_file, content).unwrap();

        let rt = tokio::runtime::Runtime::new().unwrap();
        let result = rt.block_on(plugin_read_file_tail_inner(
            test_file.to_string_lossy().to_string(),
            12,
        ));
        assert!(result.is_ok());
        let text = result.unwrap();
        assert_eq!(text, "line5\n");
    }

    #[test]
    fn tail_rejects_non_file() {
        let _guard = FS_TEST_LOCK.lock().unwrap();
        let home = dirs::home_dir().unwrap();
        let rt = tokio::runtime::Runtime::new().unwrap();
        let result = rt.block_on(plugin_read_file_tail_inner(
            home.to_string_lossy().to_string(),
            1024,
        ));
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("not a file"));
    }

    #[test]
    fn classify_last_event_wins() {
        let mut map = std::collections::HashMap::new();
        let create = Event {
            kind: notify::EventKind::Create(notify::event::CreateKind::File),
            paths: vec![PathBuf::from("/test/file.txt")],
            attrs: Default::default(),
        };
        let modify = Event {
            kind: notify::EventKind::Modify(notify::event::ModifyKind::Data(
                notify::event::DataChange::Content,
            )),
            paths: vec![PathBuf::from("/test/file.txt")],
            attrs: Default::default(),
        };
        classify_event(&create, &mut map);
        classify_event(&modify, &mut map);
        assert_eq!(map.get(Path::new("/test/file.txt")).unwrap(), "modify");
    }

    // -- plugin_write_file tests --

    #[test]
    fn write_file_creates_new_file() {
        let tmp = tempfile::tempdir().unwrap();
        let _guard = set_home_dir_override(tmp.path().to_path_buf());
        let test_file = tmp.path().join("write-new.txt");

        let rt = tokio::runtime::Runtime::new().unwrap();
        let result = rt.block_on(plugin_write_file_inner(
            test_file.to_string_lossy().to_string(),
            "hello write".to_string(),
        ));
        let content = std::fs::read_to_string(&test_file).unwrap_or_default();

        assert!(result.is_ok(), "write failed: {:?}", result);
        assert_eq!(content, "hello write");
    }

    #[test]
    fn write_file_overwrites_existing() {
        let tmp = tempfile::tempdir().unwrap();
        let _guard = set_home_dir_override(tmp.path().to_path_buf());
        let test_file = tmp.path().join("write-overwrite.txt");
        let _ = std::fs::write(&test_file, "old content");

        let rt = tokio::runtime::Runtime::new().unwrap();
        let result = rt.block_on(plugin_write_file_inner(
            test_file.to_string_lossy().to_string(),
            "new content".to_string(),
        ));
        let content = std::fs::read_to_string(&test_file).unwrap_or_default();

        assert!(result.is_ok());
        assert_eq!(content, "new content");
    }

    #[test]
    fn write_file_rejects_relative_path() {
        let rt = tokio::runtime::Runtime::new().unwrap();
        let result = rt.block_on(plugin_write_file_inner(
            "relative/file.txt".to_string(),
            "content".to_string(),
        ));
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("absolute"));
    }

    #[test]
    fn write_file_rejects_outside_home() {
        let _guard = FS_TEST_LOCK.lock().unwrap();
        let home = dirs::home_dir().unwrap();
        if !Path::new("/tmp").starts_with(&home) {
            let rt = tokio::runtime::Runtime::new().unwrap();
            let result = rt.block_on(plugin_write_file_inner(
                "/tmp/.tuic-test-write-outside.txt".to_string(),
                "content".to_string(),
            ));
            assert!(result.is_err());
            assert!(result.unwrap_err().contains("home directory"));
        }
    }

    #[test]
    fn write_file_rejects_directory_overwrite() {
        let tmp = tempfile::tempdir().unwrap();
        let _guard = set_home_dir_override(tmp.path().to_path_buf());
        let test_dir = tmp.path().join("write-dir");
        let _ = std::fs::create_dir_all(&test_dir);

        let rt = tokio::runtime::Runtime::new().unwrap();
        let result = rt.block_on(plugin_write_file_inner(
            test_dir.to_string_lossy().to_string(),
            "content".to_string(),
        ));

        assert!(result.is_err());
        assert!(result.unwrap_err().contains("directory"));
    }

    // -- plugin_rename_path tests --

    #[test]
    fn rename_moves_file() {
        let tmp = tempfile::tempdir().unwrap();
        let _guard = set_home_dir_override(tmp.path().to_path_buf());
        let from = tmp.path().join("rename-from.txt");
        let to = tmp.path().join("rename-to.txt");
        let _ = std::fs::write(&from, "rename me");

        let rt = tokio::runtime::Runtime::new().unwrap();
        let result = rt.block_on(plugin_rename_path_inner(
            from.to_string_lossy().to_string(),
            to.to_string_lossy().to_string(),
        ));
        let content = std::fs::read_to_string(&to).unwrap_or_default();
        let from_exists = from.exists();

        assert!(result.is_ok(), "rename failed: {:?}", result);
        assert_eq!(content, "rename me");
        assert!(!from_exists);
    }

    #[test]
    fn rename_rejects_source_outside_home() {
        let _guard = FS_TEST_LOCK.lock().unwrap();
        let home = dirs::home_dir().unwrap();
        if !Path::new("/tmp").starts_with(&home) {
            let rt = tokio::runtime::Runtime::new().unwrap();
            let result = rt.block_on(plugin_rename_path_inner(
                "/tmp/.tuic-test-rename.txt".to_string(),
                home.join(".tuic-test-rename-dest.txt")
                    .to_string_lossy()
                    .to_string(),
            ));
            assert!(result.is_err());
        }
    }

    #[test]
    fn rename_rejects_relative_destination() {
        let tmp = tempfile::tempdir().unwrap();
        let _guard = set_home_dir_override(tmp.path().to_path_buf());
        let from = tmp.path().join("rename-rel.txt");
        let _ = std::fs::write(&from, "test");

        let rt = tokio::runtime::Runtime::new().unwrap();
        let result = rt.block_on(plugin_rename_path_inner(
            from.to_string_lossy().to_string(),
            "relative/dest.txt".to_string(),
        ));

        assert!(result.is_err());
        assert!(result.unwrap_err().contains("absolute"));
    }
}
