use serde::Serialize;
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::path::{Component, Path, PathBuf};
use tokio::sync::Mutex;

pub const READONLY_MODE: u32 = 0o444;
pub const WRITABLE_MODE: u32 = 0o644;

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TreeNode {
    pub name: String,
    #[serde(rename = "type")]
    pub node_type: &'static str,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub size_bytes: Option<u64>,
    pub modified_ms: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub children: Option<Vec<TreeNode>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hash: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub writable: Option<bool>,
}

#[derive(Serialize)]
#[serde(rename_all_fields = "camelCase", untagged)]
pub enum TreeResult {
    Ok { ok: bool, children: Vec<TreeNode>, modified_ms: Option<f64> },
    Err { ok: bool, error: String },
}

pub fn clean_hostname(raw: &str) -> String {
    let lower = raw.to_ascii_lowercase();
    if lower.ends_with(".local") {
        raw[..raw.len() - ".local".len()].to_string()
    } else {
        raw.to_string()
    }
}

/// Workspace-relative depot path ('Assets/x.cs') → absolute path, refusing
/// anything that escapes the workspace folder. Mirrors main.js: lexical
/// resolution (no symlink following) + prefix check.
pub fn resolve_workspace_file(folder_path: &Path, rel_path: &str) -> Option<PathBuf> {
    if rel_path.trim().is_empty() {
        return None;
    }
    let rel = rel_path.replace('\\', "/");
    let mut out = folder_path.to_path_buf();
    for comp in Path::new(&rel).components() {
        match comp {
            Component::Normal(part) => out.push(part),
            Component::CurDir => {}
            _ => return None, // ParentDir, RootDir, Prefix all escape
        }
    }
    Some(out)
}

pub fn mtime_ms(meta: &std::fs::Metadata) -> Option<f64> {
    meta.modified().ok().and_then(|t| {
        t.duration_since(std::time::UNIX_EPOCH)
            .ok()
            .map(|d| d.as_secs_f64() * 1000.0)
    })
}

pub async fn chmod_safe(path: &Path, mode: u32) -> bool {
    #[cfg(unix)]
    {
        use std::fs::Permissions;
        use std::os::unix::fs::PermissionsExt;
        tokio::fs::set_permissions(path, Permissions::from_mode(mode)).await.is_ok()
    }
    #[cfg(not(unix))]
    {
        // Windows: map to the read-only attribute.
        match tokio::fs::metadata(path).await {
            Ok(meta) => {
                let mut perms = meta.permissions();
                perms.set_readonly(mode == READONLY_MODE);
                tokio::fs::set_permissions(path, perms).await.is_ok()
            }
            Err(_) => false,
        }
    }
}

pub(crate) fn is_writable(meta: &std::fs::Metadata) -> bool {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        meta.permissions().mode() & 0o200 != 0
    }
    #[cfg(not(unix))]
    {
        !meta.permissions().readonly()
    }
}

pub type HashCache = Mutex<crate::hashcache::HashCacheState>;

/// One file that missed the cache during the walk and needs hashing.
pub struct PendingFile {
    pub path: PathBuf,
    pub key: String,
    /// Index path from the root children vec down to this file's node
    /// (e.g. `[2, 0]` = third entry at the root, first entry inside it).
    /// Hashing writes back through this address instead of rejoining
    /// `base.join(&node.name)`, because `node.name` is a lossy
    /// `to_string_lossy()` render — for a non-UTF-8 name it never matches
    /// the real `PathBuf` key the hash was computed under, silently
    /// leaving `hash: None` on that node.
    pub addr: Vec<usize>,
}

pub(crate) fn cache_key(path: &Path, meta: &std::fs::Metadata) -> String {
    format!("{} {} {}", path.display(), meta.len(), mtime_ms(meta).unwrap_or(0.0))
}

/// Streamed sha256 — 1 MiB window, so memory stays flat no matter how large
/// the file is (game repos routinely hold multi-GB single assets).
pub fn hash_file_streamed(path: &Path) -> std::io::Result<String> {
    use std::io::Read;
    let mut file = std::fs::File::open(path)?;
    let mut hasher = Sha256::new();
    let mut buf = vec![0u8; 1024 * 1024];
    loop {
        let n = file.read(&mut buf)?;
        if n == 0 {
            break;
        }
        hasher.update(&buf[..n]);
    }
    Ok(hex::encode(hasher.finalize()))
}

/// sha256 keyed by path+size+mtime so unchanged files are never re-read.
/// Kept API-compatible for transfer.rs (pull verification).
pub async fn file_hash(
    cache: &HashCache,
    full_path: &Path,
    meta: &std::fs::Metadata,
) -> std::io::Result<String> {
    let key = cache_key(full_path, meta);
    if let Some(hit) = cache.lock().await.entries.get(&key) {
        return Ok(hit.clone());
    }
    let path = full_path.to_path_buf();
    let hash = tokio::task::spawn_blocking(move || hash_file_streamed(&path))
        .await
        .map_err(|e| std::io::Error::other(e))??;
    cache.lock().await.entries.insert(key, hash.clone());
    Ok(hash)
}

/// Phase 1: blocking recursive walk. Builds the tree, resolves cache hits
/// inline, and collects misses into `pending` plus every file's cache key
/// into `live_keys` (for post-scan pruning). No entry or depth caps: the
/// listing is complete at any workspace size.
fn scan_dir(
    dir_path: &Path,
    with_hashes: bool,
    cache: &HashMap<String, String>,
    pending: &mut Vec<PendingFile>,
    live_keys: &mut HashSet<String>,
    addr: &mut Vec<usize>,
) -> Vec<TreeNode> {
    let mut children = Vec::new();
    let Ok(entries) = std::fs::read_dir(dir_path) else { return children };
    for entry in entries.flatten() {
        let name = entry.file_name().to_string_lossy().to_string();
        // .rc-download names are transfer.rs's in-flight pull temps — visible
        // to the scanner they'd flash as phantom "new" files mid-pull.
        if name == ".DS_Store" || name.ends_with(".rc-download") {
            continue;
        }
        let Ok(file_type) = entry.file_type() else { continue };
        if file_type.is_symlink() {
            continue;
        }
        let full_path = entry.path();
        if file_type.is_dir() {
            addr.push(children.len());
            let sub = scan_dir(&full_path, with_hashes, cache, pending, live_keys, addr);
            addr.pop();
            children.push(TreeNode {
                name,
                node_type: "folder",
                size_bytes: None,
                modified_ms: entry.metadata().ok().as_ref().and_then(mtime_ms),
                children: Some(sub),
                hash: None,
                writable: None,
            });
        } else if file_type.is_file() {
            let meta = entry.metadata().ok();
            let mut node = TreeNode {
                name,
                node_type: "file",
                size_bytes: Some(meta.as_ref().map(|m| m.len()).unwrap_or(0)),
                modified_ms: meta.as_ref().and_then(mtime_ms),
                children: None,
                hash: None,
                writable: None,
            };
            if with_hashes {
                if let Some(meta) = &meta {
                    node.writable = Some(is_writable(meta));
                    let key = cache_key(&full_path, meta);
                    live_keys.insert(key.clone());
                    if let Some(hit) = cache.get(&key) {
                        node.hash = Some(hit.clone());
                    } else {
                        addr.push(children.len());
                        pending.push(PendingFile { path: full_path.clone(), key, addr: addr.clone() });
                        addr.pop();
                    }
                }
            }
            children.push(node);
        }
    }
    children
}

/// Phase 2: hash cache misses with bounded concurrency (one streamed hash per
/// core), each on the blocking pool. Peak memory is O(cores × 1 MiB).
async fn hash_pending(pending: Vec<PendingFile>) -> Vec<(PendingFile, Option<String>)> {
    let permits = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4);
    let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(permits));
    let mut set = tokio::task::JoinSet::new();
    for pf in pending {
        let sem = sem.clone();
        set.spawn(async move {
            let _permit = sem.acquire_owned().await.expect("semaphore closed");
            let path = pf.path.clone();
            let hash = tokio::task::spawn_blocking(move || hash_file_streamed(&path).ok())
                .await
                .ok()
                .flatten();
            (pf, hash)
        });
    }
    let mut out = Vec::new();
    while let Some(res) = set.join_next().await {
        if let Ok(pair) = res {
            out.push(pair);
        }
    }
    out
}

/// Write one freshly computed hash back into the tree by index path rather
/// than by rejoining `base.join(&node.name)` — `node.name` is a lossy
/// `to_string_lossy()` render, so for a non-UTF-8 name that join never
/// matches the real `PathBuf` the hash was computed under, leaving the node
/// `hash: None` even though it was hashed. See `PendingFile::addr`.
fn fill_hash_at(nodes: &mut [TreeNode], addr: &[usize], hash: String) {
    match addr {
        [i] => {
            if let Some(node) = nodes.get_mut(*i) {
                node.hash = Some(hash);
            }
        }
        [i, rest @ ..] => {
            if let Some(children) = nodes.get_mut(*i).and_then(|n| n.children.as_mut()) {
                fill_hash_at(children, rest, hash);
            }
        }
        [] => {}
    }
}

/// Full scan pipeline against a caller-owned cache map (the command wraps
/// this with the shared Tauri-state cache; tests call it directly).
/// Returns (tree, live cache keys, newly hashed count). New hashes are
/// inserted into `entries`.
pub async fn scan_workspace(
    root: &Path,
    with_hashes: bool,
    entries: &mut HashMap<String, String>,
) -> (Vec<TreeNode>, HashSet<String>, usize) {
    let snapshot = entries.clone();
    let root_owned = root.to_path_buf();
    let (mut children, pending, live_keys) = tokio::task::spawn_blocking(move || {
        let mut pending = Vec::new();
        let mut live_keys = HashSet::new();
        let mut addr = Vec::new();
        let children =
            scan_dir(&root_owned, with_hashes, &snapshot, &mut pending, &mut live_keys, &mut addr);
        (children, pending, live_keys)
    })
    .await
    .unwrap_or_else(|_| (Vec::new(), Vec::new(), HashSet::new()));

    let mut new_count = 0;
    if with_hashes && !pending.is_empty() {
        for (pf, hash) in hash_pending(pending).await {
            if let Some(h) = hash {
                entries.insert(pf.key, h.clone());
                fill_hash_at(&mut children, &pf.addr, h);
                new_count += 1;
            }
        }
    }
    (children, live_keys, new_count)
}

pub fn get_device_name() -> String {
    clean_hostname(&gethostname::gethostname().to_string_lossy())
}

#[derive(serde::Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct TreeOptions {
    #[serde(default)]
    pub hashes: bool,
}

#[derive(Serialize)]
pub struct FailedResult {
    pub ok: bool,
    pub failed: Vec<String>,
}

pub async fn set_writable_impl(folder_path: &str, paths: &[String], writable: bool) -> FailedResult {
    let root = Path::new(folder_path);
    let mode = if writable { WRITABLE_MODE } else { READONLY_MODE };
    let mut failed = Vec::new();
    for rel in paths {
        let Some(full) = resolve_workspace_file(root, rel) else {
            failed.push(rel.clone());
            continue;
        };
        if tokio::fs::metadata(&full).await.is_err() || !chmod_safe(&full, mode).await {
            failed.push(rel.clone());
        }
    }
    FailedResult { ok: failed.is_empty(), failed }
}

pub async fn delete_files_impl(
    folder_path: &str,
    paths: &[String],
    folders: &[String],
) -> FailedResult {
    let root = Path::new(folder_path);
    let mut failed = Vec::new();
    for rel in paths {
        let Some(full) = resolve_workspace_file(root, rel) else {
            failed.push(rel.clone());
            continue;
        };
        if tokio::fs::metadata(&full).await.is_err() {
            continue; // already gone: fine
        }
        chmod_safe(&full, WRITABLE_MODE).await; // read-only bit blocks unlink on some platforms
        if tokio::fs::remove_file(&full).await.is_err() {
            failed.push(rel.clone());
        }
    }
    for rel in folders {
        let Some(full) = resolve_workspace_file(root, rel) else {
            failed.push(rel.clone());
            continue;
        };
        match tokio::fs::remove_dir_all(&full).await {
            Ok(_) => {}
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
            Err(_) => failed.push(rel.clone()),
        }
    }
    FailedResult { ok: failed.is_empty(), failed }
}

#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateFolderPayload {
    pub folder_path: String,
    /// Folder to create, relative to the workspace root ("Assets/Textures").
    pub rel_path: String,
}

#[derive(Serialize)]
pub struct CreateFolderResult {
    pub ok: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

impl CreateFolderResult {
    fn err(message: impl Into<String>) -> Self {
        CreateFolderResult { ok: false, error: Some(message.into()) }
    }
}

/// Create one folder inside the workspace. The parent must already exist:
/// `rel_path`'s parent always comes from a tree the user is looking at, so a
/// missing one means that tree is stale and deserves to be reported, not
/// papered over by create_dir_all.
pub async fn create_folder_impl(folder_path: &str, rel_path: &str) -> CreateFolderResult {
    if folder_path.trim().is_empty() {
        // Path::new("") would leave resolve_workspace_file returning a relative
        // path, i.e. a folder created wherever the process happens to be.
        return CreateFolderResult::err("No workspace folder is set.");
    }
    let Some(full) = resolve_workspace_file(Path::new(folder_path), rel_path) else {
        return CreateFolderResult::err("Invalid folder name.");
    };
    // The renderer only ever sends '/'-separated relative paths.
    let leaf = rel_path.rsplit('/').next().unwrap_or(rel_path);
    if tokio::fs::metadata(&full).await.is_ok() {
        return CreateFolderResult::err(format!("An item named \"{leaf}\" already exists here."));
    }
    match tokio::fs::create_dir(&full).await {
        Ok(()) => CreateFolderResult { ok: true, error: None },
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            CreateFolderResult::err("The containing folder no longer exists.")
        }
        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
            CreateFolderResult::err(format!("An item named \"{leaf}\" already exists here."))
        }
        Err(e) => CreateFolderResult::err(format!("The folder could not be created ({e}).")),
    }
}

#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetWritablePayload {
    pub folder_path: String,
    pub paths: Vec<String>,
    #[serde(default)]
    pub writable: bool,
}

#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeletePayload {
    pub folder_path: String,
    pub paths: Vec<String>,
    #[serde(default)]
    pub folders: Vec<String>,
}

#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportPayload {
    pub folder_path: String,
    /// Destination folder relative to the workspace root ("" = root).
    #[serde(default)]
    pub dest_rel: String,
    /// Absolute OS paths dragged in from the file manager.
    pub sources: Vec<String>,
    /// Report top-level name collisions only; write nothing.
    #[serde(default)]
    pub probe: bool,
    /// On collision: false = skip the item, true = replace it.
    #[serde(default)]
    pub overwrite: bool,
}

#[derive(Serialize, Default)]
pub struct ImportOutcome {
    pub ok: bool,
    pub copied: Vec<String>,
    pub collisions: Vec<String>,
    pub skipped: Vec<String>,
    pub errors: Vec<ImportError>,
}

#[derive(Serialize, Debug)]
pub struct ImportError {
    pub path: String,
    pub error: String,
}

fn copy_tree(src: &Path, dest: &Path, rel: &str, out: &mut ImportOutcome) {
    let meta = match std::fs::metadata(src) {
        Ok(m) => m,
        Err(e) => {
            out.errors.push(ImportError { path: rel.into(), error: e.to_string() });
            return;
        }
    };
    if meta.is_dir() {
        if let Err(e) = std::fs::create_dir_all(dest) {
            out.errors.push(ImportError { path: rel.into(), error: e.to_string() });
            return;
        }
        let entries = match std::fs::read_dir(src) {
            Ok(it) => it,
            Err(e) => {
                out.errors.push(ImportError { path: rel.into(), error: e.to_string() });
                return;
            }
        };
        for entry in entries {
            // Don't swallow per-entry read errors: a silently missing child
            // would report a partial import as ok:true.
            let entry = match entry {
                Ok(entry) => entry,
                Err(e) => {
                    out.errors.push(ImportError { path: rel.into(), error: e.to_string() });
                    continue;
                }
            };
            let name = entry.file_name();
            let child_rel = format!("{rel}/{}", name.to_string_lossy());
            copy_tree(&entry.path(), &dest.join(&name), &child_rel, out);
        }
    } else {
        match std::fs::copy(src, dest) {
            Ok(_) => out.copied.push(rel.into()),
            Err(e) => out.errors.push(ImportError { path: rel.into(), error: e.to_string() }),
        }
    }
}

pub fn import_impl(payload: ImportPayload) -> ImportOutcome {
    let mut out = ImportOutcome::default();
    let root = PathBuf::from(&payload.folder_path);
    let Ok(canon_root) = root.canonicalize() else {
        out.errors.push(ImportError {
            path: payload.folder_path.clone(),
            error: "Workspace folder not found.".into(),
        });
        return out;
    };
    // destRel must stay inside the workspace: plain names only. An absolute
    // destRel would otherwise slip through the per-component loop below
    // ("/etc" splits to ["etc"]), so reject it up front.
    if payload.dest_rel.starts_with('/')
        || payload.dest_rel.contains('\\')
        || Path::new(&payload.dest_rel).is_absolute()
    {
        out.errors.push(ImportError {
            path: payload.dest_rel.clone(),
            error: "Invalid destination folder.".into(),
        });
        return out;
    }
    let mut dest_dir = canon_root.clone();
    for part in payload.dest_rel.split('/').filter(|p| !p.is_empty()) {
        if part == ".." || part.contains('\\') || Path::new(part).is_absolute() {
            out.errors.push(ImportError {
                path: payload.dest_rel.clone(),
                error: "Invalid destination folder.".into(),
            });
            return out;
        }
        dest_dir.push(part);
    }

    for source in &payload.sources {
        let src = PathBuf::from(source);
        let Some(name) = src.file_name().map(|n| n.to_string_lossy().to_string()) else {
            out.errors.push(ImportError { path: source.clone(), error: "Unnamed source.".into() });
            continue;
        };
        // Sources already inside the workspace, or that contain the
        // destination (folder into its own subtree), are silent no-ops.
        if let Ok(canon_src) = src.canonicalize() {
            if canon_src.starts_with(&canon_root) || dest_dir.starts_with(&canon_src) {
                out.skipped.push(name);
                continue;
            }
        }
        let dest = dest_dir.join(&name);
        // symlink_metadata (not exists()): a dangling symlink at `dest` is
        // still a collision, and exists() follows links — copying "through"
        // one would land bytes wherever it points, outside the workspace.
        if let Ok(dest_meta) = std::fs::symlink_metadata(&dest) {
            if payload.probe {
                out.collisions.push(name);
                continue;
            }
            if !payload.overwrite {
                out.skipped.push(name);
                continue;
            }
            // Remove by the entry's own type: only a real directory takes
            // remove_dir_all; a symlink (even one pointing at a directory)
            // must be unlinked, never followed.
            let file_type = dest_meta.file_type();
            let removed = if file_type.is_dir() && !file_type.is_symlink() {
                std::fs::remove_dir_all(&dest)
            } else {
                std::fs::remove_file(&dest)
            };
            if let Err(e) = removed {
                out.errors.push(ImportError { path: name, error: e.to_string() });
                continue;
            }
        }
        if payload.probe {
            continue;
        }
        let rel = if payload.dest_rel.is_empty() {
            name.clone()
        } else {
            format!("{}/{name}", payload.dest_rel.trim_matches('/'))
        };
        if let Err(e) = std::fs::create_dir_all(&dest_dir) {
            out.errors.push(ImportError { path: rel, error: e.to_string() });
            continue;
        }
        copy_tree(&src, &dest, &rel, &mut out);
    }
    out.ok = out.errors.is_empty();
    out
}

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

    #[test]
    fn resolve_rejects_escape() {
        let ws = Path::new("/tmp/ws");
        assert!(resolve_workspace_file(ws, "../etc/passwd").is_none());
        assert!(resolve_workspace_file(ws, "").is_none());
        assert!(resolve_workspace_file(ws, "   ").is_none());
        assert_eq!(
            resolve_workspace_file(ws, "Assets/x.cs").unwrap(),
            Path::new("/tmp/ws/Assets/x.cs")
        );
    }

    #[test]
    fn strip_local_suffix_from_hostname() {
        assert_eq!(clean_hostname("alices-imac.local"), "alices-imac");
        assert_eq!(clean_hostname("alices-imac.LOCAL"), "alices-imac");
        assert_eq!(clean_hostname("build-box"), "build-box");
    }

    #[tokio::test]
    async fn scan_shapes_and_hashes() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir(dir.path().join("Assets")).unwrap();
        std::fs::write(dir.path().join("Assets/a.txt"), b"hello").unwrap();
        std::fs::write(dir.path().join(".DS_Store"), b"x").unwrap();

        let mut entries = HashMap::new();
        let (children, live, new_count) = scan_workspace(dir.path(), true, &mut entries).await;

        assert_eq!(children.len(), 1); // .DS_Store skipped
        let folder = &children[0];
        assert_eq!(folder.name, "Assets");
        assert_eq!(folder.node_type, "folder");
        let file = &folder.children.as_ref().unwrap()[0];
        assert_eq!(file.node_type, "file");
        assert_eq!(file.size_bytes, Some(5));
        // sha256("hello")
        assert_eq!(
            file.hash.as_deref(),
            Some("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824")
        );
        assert_eq!(file.writable, Some(true));
        assert!(file.modified_ms.is_some());
        assert_eq!(live.len(), 1);
        assert_eq!(new_count, 1);
    }

    #[tokio::test]
    async fn scan_skips_in_flight_download_temps() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("foo.txt"), b"real").unwrap();
        std::fs::write(dir.path().join("foo.txt.rc-download"), b"partial").unwrap();

        let mut entries = HashMap::new();
        let (children, _, _) = scan_workspace(dir.path(), false, &mut entries).await;

        assert_eq!(children.len(), 1);
        assert_eq!(children[0].name, "foo.txt");
    }

    #[tokio::test]
    async fn scan_has_no_entry_cap() {
        // The old scanner truncated at 20_000 entries; prove every entry now lands.
        let dir = tempfile::tempdir().unwrap();
        for i in 0..21_000u32 {
            std::fs::File::create(dir.path().join(format!("f{i:05}.bin"))).unwrap();
        }
        let mut entries = HashMap::new();
        let (children, _, _) = scan_workspace(dir.path(), false, &mut entries).await;
        assert_eq!(children.len(), 21_000);
    }

    #[tokio::test]
    async fn scan_has_no_depth_cap() {
        // The old scanner silently stopped recursing at depth 12.
        let dir = tempfile::tempdir().unwrap();
        let mut path = dir.path().to_path_buf();
        for i in 0..20 {
            path.push(format!("d{i}"));
        }
        std::fs::create_dir_all(&path).unwrap();
        std::fs::write(path.join("deep.txt"), b"bottom").unwrap();

        let mut entries = HashMap::new();
        let (children, _, _) = scan_workspace(dir.path(), false, &mut entries).await;
        let mut node = &children[0];
        for _ in 1..20 {
            node = &node.children.as_ref().unwrap()[0];
        }
        let file = &node.children.as_ref().unwrap()[0];
        assert_eq!(file.name, "deep.txt");
    }

    #[tokio::test]
    async fn streamed_hash_matches_whole_file_hash() {
        let dir = tempfile::tempdir().unwrap();
        // 5 MiB — several streaming windows.
        let bytes = vec![0xabu8; 5 * 1024 * 1024];
        let path = dir.path().join("big.bin");
        std::fs::write(&path, &bytes).unwrap();
        assert_eq!(
            hash_file_streamed(&path).unwrap(),
            hex::encode(Sha256::digest(&bytes))
        );
    }

    #[tokio::test]
    async fn rescan_hits_cache_until_mtime_changes() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("a.txt"), b"one").unwrap();

        let mut entries = HashMap::new();
        let (_, _, first) = scan_workspace(dir.path(), true, &mut entries).await;
        assert_eq!(first, 1);
        let (_, _, second) = scan_workspace(dir.path(), true, &mut entries).await;
        assert_eq!(second, 0); // cache hit, nothing re-hashed

        // Change content (size changes too) — must re-hash.
        std::fs::write(dir.path().join("a.txt"), b"four").unwrap();
        let (children, _, third) = scan_workspace(dir.path(), true, &mut entries).await;
        assert_eq!(third, 1);
        assert_eq!(
            children[0].hash.as_deref(),
            Some(hex::encode(Sha256::digest(b"four")).as_str())
        );
    }

    #[tokio::test]
    async fn parallel_hashing_fills_every_file() {
        let dir = tempfile::tempdir().unwrap();
        for i in 0..50u32 {
            std::fs::write(dir.path().join(format!("f{i}.txt")), format!("content-{i}")).unwrap();
        }
        let mut entries = HashMap::new();
        let (children, _, new_count) = scan_workspace(dir.path(), true, &mut entries).await;
        assert_eq!(new_count, 50);
        for node in &children {
            let expected = hex::encode(Sha256::digest(
                format!("content-{}", &node.name[1..node.name.len() - 4]).as_bytes(),
            ));
            assert_eq!(node.hash.as_deref(), Some(expected.as_str()), "file {}", node.name);
        }
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn non_utf8_names_still_get_hashes() {
        use std::os::unix::ffi::OsStrExt;
        let dir = tempfile::tempdir().unwrap();
        let name = std::ffi::OsStr::from_bytes(b"bad\xFFname.bin");
        std::fs::write(dir.path().join(name), b"content").unwrap();
        let mut entries = HashMap::new();
        let (children, _, new_count) = scan_workspace(dir.path(), true, &mut entries).await;
        assert_eq!(new_count, 1);
        assert!(children[0].hash.is_some(), "non-UTF-8 named file must still carry its hash");
    }

    #[test]
    fn tree_result_serializes_camel_case_fields() {
        let ok = TreeResult::Ok {
            ok: true,
            children: vec![TreeNode {
                name: "a.txt".into(),
                node_type: "file",
                size_bytes: Some(5),
                modified_ms: Some(123.0),
                children: None,
                hash: None,
                writable: None,
            }],
            modified_ms: Some(456.0),
        };
        let v = serde_json::to_value(&ok).unwrap();
        assert!(v.get("modifiedMs").is_some(), "expected modifiedMs key, got {v}");
        assert!(v.get("truncated").is_none(), "truncated must be gone, got {v}");
        let file = &v["children"][0];
        assert!(file.get("sizeBytes").is_some(), "expected sizeBytes key, got {file}");

        let err = TreeResult::Err { ok: false, error: "boom".into() };
        let v = serde_json::to_value(&err).unwrap();
        assert_eq!(v["error"], "boom");
    }

    #[tokio::test]
    async fn set_writable_toggles_and_reports_missing() {
        let dir = tempfile::tempdir().unwrap();
        let ws = dir.path().to_str().unwrap().to_string();
        std::fs::write(dir.path().join("a.txt"), b"x").unwrap();

        let res = set_writable_impl(&ws, &["a.txt".into(), "missing.txt".into()], false).await;
        assert!(!res.ok);
        assert_eq!(res.failed, vec!["missing.txt".to_string()]);
        let meta = std::fs::metadata(dir.path().join("a.txt")).unwrap();
        assert!(!is_writable(&meta));

        let res = set_writable_impl(&ws, &["a.txt".into()], true).await;
        assert!(res.ok);
        assert!(is_writable(&std::fs::metadata(dir.path().join("a.txt")).unwrap()));
    }

    #[tokio::test]
    async fn delete_removes_readonly_files_and_folders() {
        let dir = tempfile::tempdir().unwrap();
        let ws = dir.path().to_str().unwrap().to_string();
        std::fs::write(dir.path().join("locked.txt"), b"x").unwrap();
        chmod_safe(&dir.path().join("locked.txt"), READONLY_MODE).await;
        std::fs::create_dir_all(dir.path().join("sub/deep")).unwrap();
        std::fs::write(dir.path().join("sub/deep/f.txt"), b"y").unwrap();

        let res = delete_files_impl(
            &ws,
            &["locked.txt".into(), "already-gone.txt".into()],
            &["sub".into()],
        )
        .await;
        assert!(res.ok, "failed: {:?}", res.failed);
        assert!(!dir.path().join("locked.txt").exists());
        assert!(!dir.path().join("sub").exists());
    }

    /// Scale/perf harness, not part of the default suite. Point it at any
    /// large directory tree (tens of thousands of files) and tell it how many
    /// files to expect:
    ///   RC_SCALE_DIR=/path RC_SCALE_EXPECTED=18000 \
    ///     cargo test --release scale_scan_no_truncation -- --ignored --nocapture
    #[tokio::test]
    #[ignore]
    async fn scale_scan_no_truncation() {
        let root = PathBuf::from(std::env::var("RC_SCALE_DIR").expect("set RC_SCALE_DIR"));
        let expected: usize = std::env::var("RC_SCALE_EXPECTED")
            .expect("set RC_SCALE_EXPECTED (file count printed by the generator)")
            .parse()
            .unwrap();

        fn count_files(nodes: &[TreeNode]) -> usize {
            nodes
                .iter()
                .map(|n| match &n.children {
                    Some(children) => count_files(children),
                    None => 1,
                })
                .sum()
        }

        let mut entries = HashMap::new();
        let t0 = std::time::Instant::now();
        let (children, _, hashed_cold) = scan_workspace(&root, true, &mut entries).await;
        let cold = t0.elapsed();
        let files = count_files(&children);
        println!("cold scan: {files} files, {hashed_cold} hashed, {cold:.2?}");
        assert_eq!(files, expected, "every file must be listed — no truncation");
        assert_eq!(hashed_cold, expected);

        let t1 = std::time::Instant::now();
        let (children, _, hashed_warm) = scan_workspace(&root, true, &mut entries).await;
        let warm = t1.elapsed();
        println!("warm scan: {} files, {hashed_warm} hashed, {warm:.2?}", count_files(&children));
        assert_eq!(count_files(&children), expected);
        assert_eq!(hashed_warm, 0, "warm rescan must be stat-only");
        assert!(warm.as_secs_f64() < 10.0, "warm rescan took {warm:.2?}");
    }

    #[tokio::test]
    async fn create_folder_refuses_to_escape_the_workspace() {
        let dir = tempfile::tempdir().unwrap();
        let ws = dir.path().to_string_lossy().to_string();

        let out = create_folder_impl(&ws, "../escaped").await;
        assert!(!out.ok);
        assert_eq!(out.error.as_deref(), Some("Invalid folder name."));
        assert!(!dir.path().parent().unwrap().join("escaped").exists());

        // An empty workspace path would make resolve_workspace_file hand back a
        // *relative* path, creating the folder in the process's cwd.
        let out = create_folder_impl("", "New").await;
        assert!(!out.ok);
        assert_eq!(out.error.as_deref(), Some("No workspace folder is set."));
    }

    #[tokio::test]
    async fn create_folder_makes_a_dir_and_refuses_collisions() {
        let dir = tempfile::tempdir().unwrap();
        let ws = dir.path().to_string_lossy().to_string();
        std::fs::create_dir(dir.path().join("Assets")).unwrap();

        let out = create_folder_impl(&ws, "Assets/Textures").await;
        assert!(out.ok, "{:?}", out.error);
        assert!(dir.path().join("Assets/Textures").is_dir());

        let out = create_folder_impl(&ws, "Assets/Textures").await;
        assert!(!out.ok);
        assert_eq!(
            out.error.as_deref(),
            Some("An item named \"Textures\" already exists here.")
        );

        // A *file* of the same name collides just as hard as a folder.
        std::fs::write(dir.path().join("Assets/notes.txt"), b"x").unwrap();
        let out = create_folder_impl(&ws, "Assets/notes.txt").await;
        assert!(!out.ok);
        assert_eq!(
            out.error.as_deref(),
            Some("An item named \"notes.txt\" already exists here.")
        );
    }

    #[tokio::test]
    async fn create_folder_reports_a_missing_parent_instead_of_conjuring_it() {
        let dir = tempfile::tempdir().unwrap();
        let ws = dir.path().to_string_lossy().to_string();

        let out = create_folder_impl(&ws, "Nope/Deeper").await;
        assert!(!out.ok);
        assert_eq!(out.error.as_deref(), Some("The containing folder no longer exists."));
        // create_dir, not create_dir_all: a stale tree must not silently
        // materialise folders the user never asked for.
        assert!(!dir.path().join("Nope").exists());
    }
}

#[cfg(test)]
mod import_tests {
    use super::*;

    fn touch(path: &std::path::Path, contents: &str) {
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(path, contents).unwrap();
    }

    #[test]
    fn probe_reports_collisions_without_writing() {
        let ws = tempfile::tempdir().unwrap();
        let src = tempfile::tempdir().unwrap();
        touch(&src.path().join("a.txt"), "new");
        touch(&ws.path().join("a.txt"), "old");
        let out = import_impl(ImportPayload {
            folder_path: ws.path().to_string_lossy().into(),
            dest_rel: String::new(),
            sources: vec![src.path().join("a.txt").to_string_lossy().into()],
            probe: true,
            overwrite: false,
        });
        assert!(out.ok);
        assert_eq!(out.collisions, vec!["a.txt"]);
        assert_eq!(std::fs::read_to_string(ws.path().join("a.txt")).unwrap(), "old");
    }

    #[test]
    fn copies_folders_recursively_and_skips_or_replaces() {
        let ws = tempfile::tempdir().unwrap();
        let src = tempfile::tempdir().unwrap();
        touch(&src.path().join("pack/inner/f.txt"), "x");
        let payload = |overwrite| ImportPayload {
            folder_path: ws.path().to_string_lossy().into(),
            dest_rel: "sub".into(),
            sources: vec![src.path().join("pack").to_string_lossy().into()],
            probe: false,
            overwrite,
        };
        let out = import_impl(payload(false));
        assert!(out.ok, "{:?}", out.errors);
        assert_eq!(out.copied, vec!["sub/pack/inner/f.txt"]);
        // second run collides -> skipped
        let out2 = import_impl(payload(false));
        assert_eq!(out2.skipped, vec!["pack"]);
        // overwrite replaces
        touch(&src.path().join("pack/inner/f.txt"), "y");
        let out3 = import_impl(payload(true));
        assert!(out3.copied.contains(&"sub/pack/inner/f.txt".to_string()));
        assert_eq!(
            std::fs::read_to_string(ws.path().join("sub/pack/inner/f.txt")).unwrap(),
            "y"
        );
    }

    #[test]
    fn refuses_sources_inside_workspace_and_bad_dest() {
        let ws = tempfile::tempdir().unwrap();
        touch(&ws.path().join("inside.txt"), "z");
        let out = import_impl(ImportPayload {
            folder_path: ws.path().to_string_lossy().into(),
            dest_rel: String::new(),
            sources: vec![ws.path().join("inside.txt").to_string_lossy().into()],
            probe: false,
            overwrite: false,
        });
        assert!(out.ok);
        assert_eq!(out.skipped, vec!["inside.txt"]);
        assert!(out.copied.is_empty());

        let bad = import_impl(ImportPayload {
            folder_path: ws.path().to_string_lossy().into(),
            dest_rel: "../escape".into(),
            sources: vec![],
            probe: false,
            overwrite: false,
        });
        assert!(!bad.ok);
    }

    #[test]
    fn absolute_dest_rel_is_rejected() {
        let ws = tempfile::tempdir().unwrap();
        let src = tempfile::tempdir().unwrap();
        touch(&src.path().join("a.txt"), "new");
        let out = import_impl(ImportPayload {
            folder_path: ws.path().to_string_lossy().into(),
            dest_rel: "/etc".into(),
            sources: vec![src.path().join("a.txt").to_string_lossy().into()],
            probe: false,
            overwrite: false,
        });
        assert!(!out.ok);
        assert_eq!(out.errors.len(), 1);
        assert!(out.copied.is_empty());
        assert!(!ws.path().join("etc").exists(), "nothing may be written");
    }

    #[cfg(unix)]
    #[test]
    fn dangling_symlink_at_destination_is_a_collision() {
        let ws = tempfile::tempdir().unwrap();
        let src = tempfile::tempdir().unwrap();
        touch(&src.path().join("a.txt"), "real");
        let target = ws.path().join("nowhere");
        std::os::unix::fs::symlink(&target, ws.path().join("a.txt")).unwrap();

        let payload = |probe, overwrite| ImportPayload {
            folder_path: ws.path().to_string_lossy().into(),
            dest_rel: String::new(),
            sources: vec![src.path().join("a.txt").to_string_lossy().into()],
            probe,
            overwrite,
        };
        // Probe must see the dangling symlink as a collision.
        let out = import_impl(payload(true, false));
        assert!(out.ok);
        assert_eq!(out.collisions, vec!["a.txt"]);

        // Overwrite must replace the symlink itself with a real file …
        let out2 = import_impl(payload(false, true));
        assert!(out2.ok, "{:?}", out2.errors);
        assert_eq!(out2.copied, vec!["a.txt"]);
        let meta = std::fs::symlink_metadata(ws.path().join("a.txt")).unwrap();
        assert!(!meta.file_type().is_symlink(), "symlink must be gone");
        assert_eq!(std::fs::read_to_string(ws.path().join("a.txt")).unwrap(), "real");
        // … and nothing may land at the symlink's old target.
        assert!(!target.exists(), "no bytes may be written through the old link");
    }
}
