use std::cmp::Ordering;
use std::collections::BTreeSet;
use std::env;
use std::ffi::OsStr;
use std::fs::{self, DirEntry, Metadata, OpenOptions};
use std::io::{self, BufRead};
use std::path::{Component, Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

const USER_DIR_CONFIG_LINE_LIMIT: usize = 128;

#[derive(Clone, Debug)]
pub struct FileEntry {
    pub name: String,
    pub path: PathBuf,
    pub kind: EntryKind,
    pub size: u64,
    pub modified: Option<SystemTime>,
    pub permissions: String,
    pub readonly: bool,
    pub hidden: bool,
    pub symlink_target: Option<PathBuf>,
}

impl FileEntry {
    pub fn from_path(path: PathBuf) -> io::Result<Self> {
        let name = path
            .file_name()
            .map(|name| name.to_string_lossy().into_owned())
            .unwrap_or_else(|| path.display().to_string());
        let metadata = fs::symlink_metadata(&path)?;
        Ok(Self::from_metadata(path, name, metadata))
    }

    fn from_dir_entry(entry: DirEntry) -> io::Result<Self> {
        let path = entry.path();
        let name = entry.file_name().to_string_lossy().into_owned();
        let metadata = fs::symlink_metadata(&path)?;
        Ok(Self::from_metadata(path, name, metadata))
    }

    fn from_metadata(path: PathBuf, name: String, metadata: Metadata) -> Self {
        let file_type = metadata.file_type();
        let symlink_target = if file_type.is_symlink() {
            fs::read_link(&path).ok()
        } else {
            None
        };
        let kind = if file_type.is_symlink() {
            EntryKind::Symlink
        } else if file_type.is_dir() {
            EntryKind::Directory
        } else if file_type.is_file() {
            EntryKind::File
        } else {
            EntryKind::Other
        };

        Self {
            hidden: name.starts_with('.'),
            name,
            path,
            kind,
            size: metadata.len(),
            modified: metadata.modified().ok(),
            permissions: permission_label(&metadata),
            readonly: metadata.permissions().readonly(),
            symlink_target,
        }
    }

    pub fn extension(&self) -> String {
        self.path
            .extension()
            .and_then(OsStr::to_str)
            .unwrap_or_default()
            .to_ascii_lowercase()
    }

    pub fn type_label(&self) -> &'static str {
        match self.kind {
            EntryKind::Directory => "Folder",
            EntryKind::Symlink => "Link",
            EntryKind::Other => "Special",
            EntryKind::File => match self.extension().as_str() {
                "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" => "Image",
                "mp3" | "flac" | "wav" | "ogg" | "m4a" => "Audio",
                "mp4" | "mkv" | "mov" | "webm" | "avi" => "Video",
                "rs" | "c" | "h" | "cpp" | "hpp" | "py" | "js" | "ts" | "tsx" | "jsx" | "go"
                | "java" | "kt" | "swift" => "Code",
                "md" | "txt" | "rst" | "log" => "Text",
                "pdf" => "PDF",
                "zip" | "tar" | "gz" | "xz" | "zst" | "7z" | "rar" => "Archive",
                _ => "File",
            },
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum EntryKind {
    Directory,
    File,
    Symlink,
    Other,
}

impl EntryKind {
    fn order(self) -> u8 {
        match self {
            Self::Directory => 0,
            Self::Symlink => 1,
            Self::File => 2,
            Self::Other => 3,
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SortKey {
    Name,
    Modified,
    Size,
    Kind,
}

impl SortKey {
    pub const ALL: [Self; 4] = [Self::Name, Self::Modified, Self::Size, Self::Kind];

    pub fn label(self) -> &'static str {
        match self {
            Self::Name => "Name",
            Self::Modified => "Modified",
            Self::Size => "Size",
            Self::Kind => "Kind",
        }
    }
}

#[derive(Clone, Debug)]
pub struct DirectorySnapshot {
    pub path: PathBuf,
    pub entries: Vec<FileEntry>,
    pub total_entries: usize,
    pub hidden_entries: usize,
    pub directory_count: usize,
    pub file_count: usize,
    pub total_size: u64,
    pub errors: Vec<String>,
    pub scan_limited: bool,
    pub error_limited: bool,
    pub entry_limit: usize,
    pub error_limit: usize,
}

#[derive(Clone, Copy, Debug)]
pub struct ScanOptions {
    pub show_hidden: bool,
    pub sort_key: SortKey,
    pub reverse: bool,
    pub folders_first: bool,
    pub entry_limit: usize,
    pub error_limit: usize,
}

#[derive(Clone, Debug)]
pub struct RecursiveSearchResults {
    pub entries: Vec<FileEntry>,
    pub visited_dirs: usize,
    pub result_limit: usize,
    pub dir_limit: usize,
    pub queue_limit: usize,
    pub result_limited: bool,
    pub dir_limited: bool,
    pub queue_limited: bool,
}

#[derive(Clone, Copy, Debug)]
pub struct RecursiveSearchOptions {
    pub show_hidden: bool,
    pub sort_key: SortKey,
    pub reverse: bool,
    pub folders_first: bool,
    pub result_limit: usize,
    pub dir_limit: usize,
    pub queue_limit: usize,
}

impl RecursiveSearchResults {
    pub fn limited(&self) -> bool {
        self.result_limited || self.dir_limited || self.queue_limited
    }
}

pub fn scan_directory(path: &Path, options: ScanOptions) -> io::Result<DirectorySnapshot> {
    let entry_limit = options.entry_limit.max(1);
    let error_limit = options.error_limit;
    let mut entries = Vec::new();
    let mut errors = Vec::new();
    let mut total_entries = 0;
    let mut hidden_entries = 0;
    let mut directory_count = 0;
    let mut file_count = 0;
    let mut total_size: u64 = 0;
    let mut scan_limited = false;
    let mut error_limited = false;

    for entry in fs::read_dir(path)? {
        if total_entries >= entry_limit {
            scan_limited = true;
            break;
        }
        total_entries += 1;
        match entry.and_then(FileEntry::from_dir_entry) {
            Ok(entry) => {
                if entry.hidden {
                    hidden_entries += 1;
                    if !options.show_hidden {
                        continue;
                    }
                }
                match entry.kind {
                    EntryKind::Directory => directory_count += 1,
                    EntryKind::File | EntryKind::Symlink | EntryKind::Other => file_count += 1,
                }
                total_size = total_size.saturating_add(entry.size);
                entries.push(entry);
            }
            Err(error) => {
                if errors.len() < error_limit {
                    errors.push(error.to_string());
                } else {
                    error_limited = true;
                }
            }
        }
    }

    sort_entries(
        &mut entries,
        options.sort_key,
        options.reverse,
        options.folders_first,
    );

    Ok(DirectorySnapshot {
        path: path.to_path_buf(),
        entries,
        total_entries,
        hidden_entries,
        directory_count,
        file_count,
        total_size,
        errors,
        scan_limited,
        error_limited,
        entry_limit,
        error_limit,
    })
}

pub fn sort_entries(
    entries: &mut [FileEntry],
    sort_key: SortKey,
    reverse: bool,
    folders_first: bool,
) {
    entries.sort_by(|left, right| {
        if folders_first {
            let kind_order = left.kind.order().cmp(&right.kind.order());
            if kind_order != Ordering::Equal {
                return kind_order;
            }
        }

        let ordering = match sort_key {
            SortKey::Name => compare_names(&left.name, &right.name),
            SortKey::Modified => left.modified.cmp(&right.modified),
            SortKey::Size => left.size.cmp(&right.size),
            SortKey::Kind => left
                .type_label()
                .cmp(right.type_label())
                .then_with(|| compare_names(&left.name, &right.name)),
        };

        if reverse {
            ordering.reverse()
        } else {
            ordering
        }
    });
}

pub fn filter_entries(entries: &[FileEntry], query: &str) -> Vec<FileEntry> {
    let needle = query.trim().to_ascii_lowercase();
    if needle.is_empty() {
        return entries.to_vec();
    }

    entries
        .iter()
        .filter(|entry| {
            entry.name.to_ascii_lowercase().contains(&needle)
                || entry.extension().contains(&needle)
                || entry.type_label().to_ascii_lowercase().contains(&needle)
        })
        .cloned()
        .collect()
}

pub fn recursive_search(
    root: &Path,
    query: &str,
    options: RecursiveSearchOptions,
) -> RecursiveSearchResults {
    recursive_search_cancellable(root, query, options, || false)
}

pub fn recursive_search_cancellable(
    root: &Path,
    query: &str,
    options: RecursiveSearchOptions,
    mut cancelled: impl FnMut() -> bool,
) -> RecursiveSearchResults {
    let result_limit = options.result_limit.max(1);
    let dir_limit = options.dir_limit.max(1);
    let queue_limit = options.queue_limit.max(1);
    let needle = query.trim().to_ascii_lowercase();
    if needle.is_empty() {
        return RecursiveSearchResults {
            entries: Vec::new(),
            visited_dirs: 0,
            result_limit,
            dir_limit,
            queue_limit,
            result_limited: false,
            dir_limited: false,
            queue_limited: false,
        };
    }

    let mut results = Vec::new();
    let mut stack = vec![root.to_path_buf()];
    let mut visited_dirs = 0;
    let mut result_limited = false;
    let mut dir_limited = false;
    let mut queue_limited = false;

    'search: while let Some(path) = stack.pop() {
        if cancelled() {
            break;
        }
        if visited_dirs >= dir_limit {
            dir_limited = true;
            break;
        }
        visited_dirs += 1;
        let Ok(read_dir) = fs::read_dir(&path) else {
            continue;
        };

        for entry in read_dir.flatten() {
            if cancelled() {
                break 'search;
            }
            if results.len() >= result_limit {
                result_limited = true;
                sort_entries(
                    &mut results,
                    options.sort_key,
                    options.reverse,
                    options.folders_first,
                );
                return RecursiveSearchResults {
                    entries: results,
                    visited_dirs,
                    result_limit,
                    dir_limit,
                    queue_limit,
                    result_limited,
                    dir_limited,
                    queue_limited,
                };
            }

            let Ok(file_entry) = FileEntry::from_dir_entry(entry) else {
                continue;
            };

            if file_entry.hidden && !options.show_hidden {
                continue;
            }

            if file_entry.kind == EntryKind::Directory {
                if stack.len() < queue_limit {
                    stack.push(file_entry.path.clone());
                } else {
                    queue_limited = true;
                }
            }

            if file_entry.name.to_ascii_lowercase().contains(&needle)
                || file_entry.extension().contains(&needle)
                || file_entry
                    .type_label()
                    .to_ascii_lowercase()
                    .contains(&needle)
            {
                results.push(file_entry);
            }
        }
    }

    sort_entries(
        &mut results,
        options.sort_key,
        options.reverse,
        options.folders_first,
    );
    RecursiveSearchResults {
        entries: results,
        visited_dirs,
        result_limit,
        dir_limit,
        queue_limit,
        result_limited,
        dir_limited,
        queue_limited,
    }
}

pub fn canonical_display_path(path: &Path) -> PathBuf {
    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}

pub fn expand_tilde(input: &str) -> PathBuf {
    let trimmed = input.trim();
    if trimmed == "~" {
        return home_dir();
    }
    if let Some(rest) = trimmed.strip_prefix("~/") {
        return home_dir().join(rest);
    }
    PathBuf::from(trimmed)
}

pub fn home_dir() -> PathBuf {
    env::var_os("HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from("/")))
}

#[derive(Clone, Debug)]
pub struct Place {
    pub label: String,
    pub path: PathBuf,
    pub detail: String,
}

pub fn known_places(current_dir: &Path) -> Vec<Place> {
    let home = home_dir();
    let mut seen = BTreeSet::new();
    let mut places = Vec::new();

    push_place(&mut places, &mut seen, "Home", home.clone(), "USER ROOT");
    push_place(
        &mut places,
        &mut seen,
        "Desktop",
        xdg_user_dir("XDG_DESKTOP_DIR", "Desktop"),
        "WORKSPACE",
    );
    push_place(
        &mut places,
        &mut seen,
        "Documents",
        xdg_user_dir("XDG_DOCUMENTS_DIR", "Documents"),
        "DOCS",
    );
    push_place(
        &mut places,
        &mut seen,
        "Downloads",
        xdg_user_dir("XDG_DOWNLOAD_DIR", "Downloads"),
        "INBOX",
    );
    push_place(
        &mut places,
        &mut seen,
        "Pictures",
        xdg_user_dir("XDG_PICTURES_DIR", "Pictures"),
        "MEDIA",
    );
    push_place(
        &mut places,
        &mut seen,
        "Music",
        xdg_user_dir("XDG_MUSIC_DIR", "Music"),
        "AUDIO",
    );
    push_place(
        &mut places,
        &mut seen,
        "Videos",
        xdg_user_dir("XDG_VIDEOS_DIR", "Videos"),
        "VIDEO",
    );
    push_place(
        &mut places,
        &mut seen,
        "Filesystem",
        PathBuf::from("/"),
        "ROOT",
    );
    push_place(
        &mut places,
        &mut seen,
        "Temp",
        PathBuf::from("/tmp"),
        "SCRATCH",
    );
    push_place(
        &mut places,
        &mut seen,
        "Trash",
        trash_files_dir(),
        "LOCAL TRASH",
    );
    push_place(
        &mut places,
        &mut seen,
        "Current",
        current_dir.to_path_buf(),
        "ACTIVE SLOT",
    );

    places
}

pub fn trash_files_dir() -> PathBuf {
    xdg_data_home().join("Trash/files")
}

pub fn trash_info_dir() -> PathBuf {
    xdg_data_home().join("Trash/info")
}

pub fn xdg_data_home() -> PathBuf {
    env::var_os("XDG_DATA_HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|| home_dir().join(".local/share"))
}

fn push_place(
    places: &mut Vec<Place>,
    seen: &mut BTreeSet<PathBuf>,
    label: &str,
    path: PathBuf,
    detail: &str,
) {
    if !path.exists() && label != "Trash" {
        return;
    }

    let normalized = canonical_display_path(&path);
    if seen.insert(normalized) {
        places.push(Place {
            label: label.to_string(),
            path,
            detail: detail.to_string(),
        });
    }
}

fn xdg_user_dir(key: &str, fallback: &str) -> PathBuf {
    if let Some(path) = parse_user_dirs_config(key) {
        return path;
    }
    home_dir().join(fallback)
}

fn parse_user_dirs_config(key: &str) -> Option<PathBuf> {
    let config_path = env::var_os("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|| home_dir().join(".config"))
        .join("user-dirs.dirs");
    let file = fs::File::open(config_path).ok()?;
    let reader = io::BufReader::new(file);

    for line in reader
        .lines()
        .map_while(Result::ok)
        .take(USER_DIR_CONFIG_LINE_LIMIT)
    {
        let line = line.trim();
        if !line.starts_with(key) {
            continue;
        }
        let (_, value) = line.split_once('=')?;
        let value = value.trim().trim_matches('"');
        let expanded = value.replace("$HOME", &home_dir().to_string_lossy());
        return Some(PathBuf::from(expanded));
    }

    None
}

#[derive(Clone, Debug)]
pub struct NameValidation {
    pub accepted: bool,
    pub message: Option<String>,
    pub warning: bool,
}

pub fn validate_child_name(
    parent: &Path,
    name: &str,
    target_is_folder: bool,
    original_name: Option<&str>,
) -> NameValidation {
    let stripped = name.trim();
    let noun = if target_is_folder { "folder" } else { "file" };

    if stripped.is_empty() {
        return NameValidation::error(format!("The {noun} name cannot be empty"));
    }
    if stripped.contains('/') {
        return NameValidation::error(format!("{} names cannot contain /", capitalize(noun)));
    }
    if stripped.contains('\0') {
        return NameValidation::error(format!("{} names cannot contain NUL", capitalize(noun)));
    }
    if stripped == "." || stripped == ".." {
        return NameValidation::error(format!("A {noun} cannot be called . or .."));
    }
    if stripped.len() > 255 {
        return NameValidation::error(format!("The {noun} name is too long"));
    }

    let same_as_original = original_name.is_some_and(|original| original == stripped);
    if !same_as_original && parent.join(stripped).exists() {
        let existing_is_dir = parent.join(stripped).is_dir();
        let existing = if existing_is_dir { "folder" } else { "file" };
        return NameValidation::error(format!("A {existing} with that name already exists"));
    }

    if stripped.starts_with('.') {
        return NameValidation {
            accepted: true,
            message: Some(format!(
                "{}s with . at the beginning of their name are hidden",
                capitalize(noun)
            )),
            warning: true,
        };
    }

    NameValidation {
        accepted: true,
        message: None,
        warning: false,
    }
}

impl NameValidation {
    fn error(message: String) -> Self {
        Self {
            accepted: false,
            message: Some(message),
            warning: false,
        }
    }
}

pub fn create_folder(parent: &Path, name: &str) -> Result<PathBuf, String> {
    let validation = validate_child_name(parent, name, true, None);
    if !validation.accepted {
        return Err(validation
            .message
            .unwrap_or_else(|| "Invalid folder name".to_string()));
    }

    let path = parent.join(name.trim());
    fs::create_dir(&path).map_err(|error| format!("Cannot create folder: {error}"))?;
    Ok(path)
}

pub fn create_file(parent: &Path, name: &str) -> Result<PathBuf, String> {
    let validation = validate_child_name(parent, name, false, None);
    if !validation.accepted {
        return Err(validation
            .message
            .unwrap_or_else(|| "Invalid file name".to_string()));
    }

    let path = parent.join(name.trim());
    OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(&path)
        .map_err(|error| format!("Cannot create file: {error}"))?;
    Ok(path)
}

pub fn rename_path(path: &Path, new_name: &str) -> Result<PathBuf, String> {
    let parent = path
        .parent()
        .ok_or_else(|| "Cannot rename a path without a parent".to_string())?;
    let original_name = path.file_name().and_then(OsStr::to_str);
    let validation = validate_child_name(parent, new_name, path.is_dir(), original_name);
    if !validation.accepted {
        return Err(validation
            .message
            .unwrap_or_else(|| "Invalid file name".to_string()));
    }

    let target = parent.join(new_name.trim());
    if target == path {
        return Ok(target);
    }
    fs::rename(path, &target).map_err(|error| format!("Cannot rename item: {error}"))?;
    Ok(target)
}

pub fn human_size(size: u64) -> String {
    const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
    let mut value = size as f64;
    let mut unit = 0;
    while value >= 1024.0 && unit < UNITS.len() - 1 {
        value /= 1024.0;
        unit += 1;
    }

    if unit == 0 {
        format!("{size} {}", UNITS[unit])
    } else if value >= 100.0 {
        format!("{value:.0} {}", UNITS[unit])
    } else {
        format!("{value:.1} {}", UNITS[unit])
    }
}

pub fn modified_label(time: Option<SystemTime>) -> String {
    let Some(time) = time else {
        return "unknown".to_string();
    };
    let Ok(age) = SystemTime::now().duration_since(time) else {
        return "future".to_string();
    };

    relative_duration(age)
}

pub fn absolute_time_label(time: Option<SystemTime>) -> String {
    let Some(time) = time else {
        return "unknown".to_string();
    };
    let Ok(duration) = time.duration_since(UNIX_EPOCH) else {
        return "before unix epoch".to_string();
    };
    unix_timestamp_label(duration.as_secs())
}

pub fn path_for_display(path: &Path) -> String {
    let home = home_dir();
    if let Ok(stripped) = path.strip_prefix(&home) {
        if stripped.as_os_str().is_empty() {
            return "~".to_string();
        }
        return format!("~/{}", stripped.display());
    }
    path.display().to_string()
}

pub fn breadcrumbs(path: &Path) -> Vec<(String, PathBuf)> {
    let mut parts = Vec::new();
    let mut current = PathBuf::new();

    for component in path.components() {
        match component {
            Component::RootDir => {
                current.push("/");
                parts.push(("/".to_string(), current.clone()));
            }
            Component::Normal(name) => {
                current.push(name);
                parts.push((name.to_string_lossy().into_owned(), current.clone()));
            }
            Component::Prefix(prefix) => {
                current.push(prefix.as_os_str());
                parts.push((
                    prefix.as_os_str().to_string_lossy().into_owned(),
                    current.clone(),
                ));
            }
            Component::CurDir | Component::ParentDir => {}
        }
    }

    parts
}

pub fn item_glyph(entry: &FileEntry) -> &'static str {
    match entry.kind {
        EntryKind::Directory => "[DIR]",
        EntryKind::Symlink => "[LNK]",
        EntryKind::Other => "[SYS]",
        EntryKind::File => match entry.extension().as_str() {
            "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" => "[IMG]",
            "mp3" | "flac" | "wav" | "ogg" | "m4a" => "[AUD]",
            "mp4" | "mkv" | "mov" | "webm" | "avi" => "[VID]",
            "rs" | "c" | "h" | "cpp" | "hpp" | "py" | "js" | "ts" | "tsx" | "jsx" | "go"
            | "java" | "kt" | "swift" => "[SRC]",
            "md" | "txt" | "rst" | "log" => "[TXT]",
            "pdf" => "[PDF]",
            "zip" | "tar" | "gz" | "xz" | "zst" | "7z" | "rar" => "[ARC]",
            _ => "[FIL]",
        },
    }
}

fn compare_names(left: &str, right: &str) -> Ordering {
    left.to_ascii_lowercase()
        .cmp(&right.to_ascii_lowercase())
        .then_with(|| left.cmp(right))
}

fn capitalize(value: &str) -> String {
    let mut chars = value.chars();
    match chars.next() {
        Some(first) => first.to_uppercase().chain(chars).collect(),
        None => String::new(),
    }
}

fn relative_duration(duration: Duration) -> String {
    let seconds = duration.as_secs();
    match seconds {
        0..=59 => "just now".to_string(),
        60..=3_599 => format!("{}m ago", seconds / 60),
        3_600..=86_399 => format!("{}h ago", seconds / 3_600),
        86_400..=2_592_000 => format!("{}d ago", seconds / 86_400),
        2_592_001..=31_536_000 => format!("{}mo ago", seconds / 2_592_000),
        _ => format!("{}y ago", seconds / 31_536_000),
    }
}

fn unix_timestamp_label(seconds: u64) -> String {
    let days = (seconds / 86_400) as i64;
    let seconds_of_day = seconds % 86_400;
    let (year, month, day) = civil_from_days(days);
    let hour = seconds_of_day / 3_600;
    let minute = (seconds_of_day % 3_600) / 60;
    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02} UTC")
}

fn civil_from_days(days_since_epoch: i64) -> (i64, u32, u32) {
    let z = days_since_epoch + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let day = doy - (153 * mp + 2) / 5 + 1;
    let month = mp + if mp < 10 { 3 } else { -9 };
    let year = y + if month <= 2 { 1 } else { 0 };
    (year, month as u32, day as u32)
}

#[cfg(unix)]
fn permission_label(metadata: &Metadata) -> String {
    use std::os::unix::fs::PermissionsExt;
    let mode = metadata.permissions().mode();
    let file_type = if metadata.file_type().is_dir() {
        'd'
    } else if metadata.file_type().is_symlink() {
        'l'
    } else {
        '-'
    };
    let flags = [
        (0o400, 'r'),
        (0o200, 'w'),
        (0o100, 'x'),
        (0o040, 'r'),
        (0o020, 'w'),
        (0o010, 'x'),
        (0o004, 'r'),
        (0o002, 'w'),
        (0o001, 'x'),
    ];
    let mut label = String::with_capacity(10);
    label.push(file_type);
    for (bit, chr) in flags {
        label.push(if mode & bit != 0 { chr } else { '-' });
    }
    label
}

#[cfg(not(unix))]
fn permission_label(metadata: &Metadata) -> String {
    if metadata.permissions().readonly() {
        "read-only".to_string()
    } else {
        "read-write".to_string()
    }
}

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

    struct TestDir(PathBuf);

    impl TestDir {
        fn new(label: &str) -> Self {
            let nonce = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .map(|duration| duration.as_nanos())
                .unwrap_or_default();
            let path = env::temp_dir().join(format!("guth-{label}-{}-{nonce}", std::process::id()));
            fs::create_dir_all(&path).unwrap();
            Self(path)
        }

        fn path(&self) -> &Path {
            &self.0
        }
    }

    impl Drop for TestDir {
        fn drop(&mut self) {
            let _ = fs::remove_dir_all(&self.0);
        }
    }

    #[test]
    fn validates_nautilus_style_names() {
        let root = env::temp_dir();
        assert!(!validate_child_name(&root, "", true, None).accepted);
        assert!(!validate_child_name(&root, ".", true, None).accepted);
        assert!(!validate_child_name(&root, "a/b", false, None).accepted);
        let hidden = validate_child_name(&root, ".secret", false, None);
        assert!(hidden.accepted);
        assert!(hidden.warning);
    }

    #[test]
    fn formats_epoch_calendar_day() {
        assert_eq!(unix_timestamp_label(0), "1970-01-01 00:00 UTC");
        assert_eq!(unix_timestamp_label(86_400), "1970-01-02 00:00 UTC");
    }

    #[test]
    fn scan_directory_reports_entry_limit() {
        let root = TestDir::new("scan-limit");
        for index in 0..4 {
            fs::write(root.path().join(format!("file-{index}.txt")), b"x").unwrap();
        }

        let snapshot = scan_directory(
            root.path(),
            ScanOptions {
                show_hidden: true,
                sort_key: SortKey::Name,
                reverse: false,
                folders_first: true,
                entry_limit: 2,
                error_limit: 4,
            },
        )
        .unwrap();

        assert!(snapshot.scan_limited);
        assert_eq!(snapshot.total_entries, 2);
        assert_eq!(snapshot.entry_limit, 2);
        assert!(snapshot.entries.len() <= 2);
    }

    #[test]
    fn recursive_search_reports_result_limit() {
        let root = TestDir::new("search-result-limit");
        fs::write(root.path().join("alpha-one.txt"), b"x").unwrap();
        fs::write(root.path().join("alpha-two.txt"), b"x").unwrap();

        let results = recursive_search(
            root.path(),
            "alpha",
            RecursiveSearchOptions {
                show_hidden: true,
                sort_key: SortKey::Name,
                reverse: false,
                folders_first: true,
                result_limit: 1,
                dir_limit: 8,
                queue_limit: 8,
            },
        );

        assert_eq!(results.entries.len(), 1);
        assert!(results.result_limited);
        assert!(results.limited());
    }

    #[test]
    fn recursive_search_can_cancel_before_traversal() {
        let results = recursive_search_cancellable(
            Path::new("/tmp"),
            "anything",
            RecursiveSearchOptions {
                show_hidden: true,
                sort_key: SortKey::Name,
                reverse: false,
                folders_first: true,
                result_limit: 8,
                dir_limit: 8,
                queue_limit: 8,
            },
            || true,
        );

        assert!(results.entries.is_empty());
        assert_eq!(results.visited_dirs, 0);
    }

    #[test]
    fn recursive_search_reports_traversal_limits() {
        let root = TestDir::new("search-traversal-limit");
        fs::create_dir(root.path().join("child-a")).unwrap();
        fs::create_dir(root.path().join("child-b")).unwrap();

        let dir_limited = recursive_search(
            root.path(),
            "child",
            RecursiveSearchOptions {
                show_hidden: true,
                sort_key: SortKey::Name,
                reverse: false,
                folders_first: true,
                result_limit: 8,
                dir_limit: 1,
                queue_limit: 8,
            },
        );

        let queue_limited = recursive_search(
            root.path(),
            "child",
            RecursiveSearchOptions {
                show_hidden: true,
                sort_key: SortKey::Name,
                reverse: false,
                folders_first: true,
                result_limit: 8,
                dir_limit: 8,
                queue_limit: 1,
            },
        );

        assert!(dir_limited.dir_limited);
        assert!(queue_limited.queue_limited);
    }

    #[test]
    fn sort_entries_can_disable_folders_first() {
        let root = TestDir::new("folders-first");
        fs::create_dir(root.path().join("z-folder")).unwrap();
        fs::write(root.path().join("a-file.txt"), b"x").unwrap();
        let mut entries = vec![
            FileEntry::from_path(root.path().join("z-folder")).unwrap(),
            FileEntry::from_path(root.path().join("a-file.txt")).unwrap(),
        ];

        sort_entries(&mut entries, SortKey::Name, false, true);
        assert_eq!(entries[0].name, "z-folder");

        sort_entries(&mut entries, SortKey::Name, false, false);
        assert_eq!(entries[0].name, "a-file.txt");
    }
}
