#![forbid(rust_2018_idioms, unsafe_code)]
use anyhow::{Context, Result, anyhow, bail};
use clap::{CommandFactory as _, FromArgMatches as _};
use dua::canonicalize_ignore_dirs;
use log::info;
use std::{
    fs, io,
    io::{IsTerminal, Write},
    path::{Path, PathBuf},
    process,
};

#[cfg(feature = "tui-crossplatform")]
use crate::interactive::input::{input_channel, input_channel_from_keys};
#[cfg(feature = "tui-crossplatform")]
use crate::interactive::terminal::TerminalApp;
#[cfg(feature = "tui-crossplatform")]
use crossterm::{
    execute,
    terminal::{EnterAlternateScreen, enable_raw_mode},
};
#[cfg(feature = "tui-crossplatform")]
use tui::{Terminal, backend::CrosstermBackend};

#[cfg(feature = "tui-crossplatform")]
mod interactive;
mod options;

fn stderr_if_tty() -> Option<io::Stderr> {
    let stderr = io::stderr();
    if stderr.is_terminal() {
        Some(stderr)
    } else {
        None
    }
}

fn open_snapshot_file(path: &Path) -> Result<fs::File> {
    let file = fs::File::open(path)
        .with_context(|| format!("Could not open snapshot {}", path.display()))?;
    if !file.metadata()?.is_file() {
        bail!("Snapshot {} is not a regular file", path.display());
    }
    Ok(file)
}

fn read_snapshot_file(path: &Path) -> Result<dua::snapshot::Snapshot> {
    dua::snapshot::read(open_snapshot_file(path)?)
        .with_context(|| format!("Could not read snapshot {}", path.display()))
}

fn replay_snapshot_file(path: &Path) -> Result<dua::snapshot::Replay<fs::File>> {
    dua::snapshot::Replay::new(open_snapshot_file(path)?)
        .with_context(|| format!("Could not read snapshot {}", path.display()))
}

#[cfg(feature = "tui-crossplatform")]
struct InteractiveTerminalGuard {
    raw_mode: bool,
    alternate_screen: bool,
    focus_change: bool,
}

#[cfg(feature = "tui-crossplatform")]
impl Drop for InteractiveTerminalGuard {
    fn drop(&mut self) {
        if self.focus_change {
            crossterm::execute!(io::stderr(), crossterm::event::DisableFocusChange).ok();
        }
        crossterm::execute!(io::stderr(), crossterm::cursor::Show).ok();
        if self.raw_mode {
            crossterm::terminal::disable_raw_mode().ok();
        }
        if self.alternate_screen {
            crossterm::execute!(io::stderr(), crossterm::terminal::LeaveAlternateScreen).ok();
        }
    }
}

/// Replaces control characters (which can include terminal escape
/// sequences) with the Unicode replacement character if `stdout_is_terminal`.
fn marked_path_for_output(path: &Path, stdout_is_terminal: bool) -> std::borrow::Cow<'_, str> {
    let path = path.to_string_lossy();
    if stdout_is_terminal {
        path.chars()
            .map(|c| if c.is_control() { '\u{FFFD}' } else { c })
            .collect::<String>()
            .into()
    } else {
        path
    }
}

fn main() -> Result<()> {
    #[cfg(feature = "tui-crossplatform")]
    use options::Command::Interactive;
    use options::Command::{Aggregate, Completions, Config, Diff};

    let matches = options::Args::command().get_matches_from(wild::args_os());
    let global_traversal_options_used = traversal_options_on_command_line(&matches);
    let opt = options::Args::from_arg_matches(&matches)?;

    if let Some(log_file) = &opt.log_file {
        log_panics::init();

        let log_output = std::fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(log_file)?;

        fern::Dispatch::new()
            .level(log::LevelFilter::Debug)
            .format(|formatter_out, log_msg, log_rec| {
                let when = jiff::Zoned::now();
                formatter_out.finish(format_args!(
                    "[{} {} {}:{}] {}",
                    when.strftime("%Y-%m-%d %H:%M:%S%.3f %:z"),
                    log_rec.level(),
                    log_rec.file().unwrap_or("<unknown>"),
                    log_rec.line().unwrap_or(0),
                    log_msg
                ));
            })
            .chain(log_output)
            .apply()?;

        info!("dua options={opt:#?}");
    }

    let options::Args {
        command,
        traversal: global_traversal,
        log_file: _used_above,
    } = opt;

    let res = match command {
        #[cfg(feature = "tui-crossplatform")]
        Some(Interactive {
            traversal: subcommand_traversal,
            export,
            compression,
            import,
            no_entry_check,
            once,
        }) => {
            if import.is_some() && global_traversal_options_used {
                bail!("--import cannot be used with traversal options or input paths");
            }
            let export = export.map(std::path::absolute).transpose()?;
            let once_events = once
                .as_deref()
                .map(input_channel_from_keys)
                .transpose()
                .map_err(anyhow::Error::msg)?;
            let traversal = merge_traversal_args(&global_traversal, &subcommand_traversal);
            let config = dua::Config::load()?;
            let enable_focus_change = config.notifications.any_enabled();
            let byte_format = traversal.byte_format(&config);
            let snapshot_load_start = import.as_ref().map(|_| std::time::Instant::now());
            let snapshot = import.as_deref().map(read_snapshot_file).transpose()?;
            let snapshot_load_duration = snapshot_load_start.map(|start| start.elapsed());
            let read_only = snapshot.is_some();
            let (input_paths, initial_traversal, walk_options, root_path) =
                if let Some(snapshot) = snapshot {
                    let input_paths = snapshot
                        .roots
                        .iter()
                        .map(|root| {
                            snapshot
                                .traversal
                                .tree
                                .name(*root)
                                .expect("snapshot root exists")
                                .into_owned()
                        })
                        .collect();
                    (
                        input_paths,
                        snapshot.traversal,
                        snapshot_walk_options(),
                        None,
                    )
                } else {
                    let walk_options = walk_options_from(&traversal)?;
                    let has_complete_root = traversal.input.is_empty()
                        || traversal.input.len() == 1 && traversal.input[0].is_dir();
                    let input_paths = extract_paths_maybe_set_cwd(traversal.input, &walk_options)?;
                    let root_path = has_complete_root.then(std::env::current_dir).transpose()?;
                    (
                        input_paths,
                        dua::traverse::Traversal::new(),
                        walk_options,
                        root_path,
                    )
                };

            let no_tty_msg = "Interactive mode requires a connected terminal";
            if !io::stderr().is_terminal() {
                return Err(anyhow!(no_tty_msg));
            }

            let mut stderr = io::stderr();
            let terminal_guard = if once_events.is_some() {
                InteractiveTerminalGuard {
                    raw_mode: false,
                    alternate_screen: false,
                    focus_change: false,
                }
            } else {
                enable_raw_mode().with_context(|| no_tty_msg)?;
                execute!(stderr, EnterAlternateScreen,).with_context(|| no_tty_msg)?;
                if enable_focus_change {
                    execute!(stderr, crossterm::event::EnableFocusChange)
                        .with_context(|| no_tty_msg)?;
                }
                InteractiveTerminalGuard {
                    raw_mode: true,
                    alternate_screen: true,
                    focus_change: enable_focus_change,
                }
            };
            let stderr = io::BufWriter::new(stderr);
            let mut terminal = Terminal::new(CrosstermBackend::new(stderr))
                .with_context(|| "Could not instantiate terminal")?;

            let mut app = TerminalApp::initialize(
                &mut terminal,
                walk_options,
                byte_format,
                !no_entry_check,
                input_paths,
                root_path,
                config,
                initial_traversal,
                snapshot_load_duration,
            )?;
            if let Some(path) = export {
                app.traverse_and_export(path, (compression != 0).then_some(compression))?;
            } else if !read_only {
                app.traverse()?;
            }

            let res = match once_events {
                Some(events) => app.process_events_once(&mut terminal, events),
                None => app.process_events(
                    &mut terminal,
                    input_channel(app.state.terminal_focus.clone()),
                ),
            };

            let res = res.map(|r| (r, app.window.mark.take().map(|pane| pane.into_paths())));
            // Leak app memory to avoid having to wait for the hashmap to deallocate,
            // which causes a noticeable delay shortly before the the program exits anyway.
            std::mem::forget(app);

            drop(terminal);
            drop(terminal_guard);
            io::stderr().flush().ok();

            // Exit 'quickly' to avoid having to not have to deal with slightly different types in the other match branches
            let exit_code = match res {
                Ok((walk_result, paths)) => {
                    if let Some(paths) = paths {
                        let stdout_is_terminal = io::stdout().is_terminal();
                        for path in paths {
                            println!("{}", marked_path_for_output(&path, stdout_is_terminal));
                        }
                    }
                    walk_result.to_exit_code()
                }
                Err(err) => {
                    eprintln!("{err:#}");
                    1
                }
            };
            std::process::exit(exit_code);
        }
        Some(Diff {
            old,
            new,
            format,
            directories_only,
            prefix,
            depth,
            summary_limit,
        }) => {
            if global_traversal_options_used {
                bail!("diff cannot be used with traversal options or input paths");
            }
            let (old_replay, new_replay) = std::thread::scope(|scope| {
                let old_replay = scope.spawn(|| replay_snapshot_file(&old));
                let new_replay = scope.spawn(|| replay_snapshot_file(&new));
                (old_replay.join(), new_replay.join())
            });
            let mut old = old_replay.map_err(|_| anyhow!("old snapshot reader panicked"))??;
            let mut new = new_replay.map_err(|_| anyhow!("new snapshot reader panicked"))??;
            let mut display = global_traversal.clone();
            display.format = format.or(display.format);
            let byte_format = display.byte_format(&dua::Config::load()?);
            let stdout = io::stdout();
            let out_is_terminal = stdout.is_terminal();
            dua::diff_snapshots(
                (stdout.lock(), out_is_terminal),
                &mut old,
                &mut new,
                byte_format,
                directories_only,
                prefix.as_deref(),
                depth.map(|depth| depth.saturating_sub(1)),
                summary_limit,
            )?;
            dua::WalkResult::default()
        }
        Some(Aggregate {
            traversal: subcommand_traversal,
            import,
            no_total,
            no_sort,
            statistics,
            stack,
            depth,
        }) => {
            if import.is_some() && global_traversal_options_used {
                bail!("--import cannot be used with traversal options or input paths");
            }
            let traversal = merge_traversal_args(&global_traversal, &subcommand_traversal);
            if let Some(path) = import {
                let mut replay = replay_snapshot_file(&path)?;
                writeln!(
                    io::stderr(),
                    "Results are from a traversal snapshot; no filesystem traversal was performed"
                )
                .ok();
                let stdout = io::stdout();
                if stack {
                    dua::stacks_from_replay(
                        stdout.lock(),
                        &mut replay,
                        depth.map(|depth| depth.saturating_sub(1)),
                    )?
                } else {
                    let config = dua::Config::load()?;
                    let byte_format = traversal.byte_format(&config);
                    let out_supports_colors = stdout.is_terminal();
                    if let Some(depth) = depth {
                        dua::aggregate_tree_from_replay(
                            (stdout.lock(), out_supports_colors),
                            &mut replay,
                            byte_format,
                            depth.saturating_sub(1),
                            !no_total,
                            !no_sort,
                        )?
                    } else {
                        dua::aggregate_replay(
                            (stdout.lock(), out_supports_colors),
                            &mut replay,
                            !no_total,
                            !no_sort,
                            byte_format,
                        )?
                    }
                }
            } else {
                let walk_options = walk_options_from(&traversal)?;
                if stack {
                    let inputs = extract_paths_maybe_set_cwd(traversal.input, &walk_options)?;
                    let stdout = io::stdout();
                    dua::stacks(
                        stdout.lock(),
                        stderr_if_tty(),
                        walk_options,
                        inputs,
                        depth.map(|depth| depth.saturating_sub(1)),
                    )?
                } else if let Some(depth) = depth {
                    let config = dua::Config::load()?;
                    let byte_format = traversal.byte_format(&config);
                    let paths = extract_paths_maybe_set_cwd(traversal.input, &walk_options)?;
                    let stdout = io::stdout();
                    let out_supports_colors = stdout.is_terminal();
                    dua::aggregate_tree(
                        (stdout.lock(), out_supports_colors),
                        stderr_if_tty(),
                        walk_options,
                        byte_format,
                        paths,
                        depth.saturating_sub(1),
                        !no_total,
                        !no_sort,
                    )?
                } else {
                    let config = dua::Config::load()?;
                    let byte_format = traversal.byte_format(&config);
                    let inputs =
                        extract_aggregate_inputs_maybe_set_cwd(traversal.input, &walk_options)?;
                    run_aggregation(
                        inputs,
                        walk_options,
                        !no_total,
                        !no_sort,
                        byte_format,
                        statistics,
                    )?
                }
            }
        }
        Some(Completions { shell }) => {
            let mut cmd = options::Args::command();
            let dua = cmd.get_name().to_string();
            clap_complete::generate(shell, &mut cmd, dua, &mut io::stdout());
            return Ok(());
        }
        Some(Config { command }) => match command {
            options::ConfigCommand::Edit => {
                edit_config()?;
                return Ok(());
            }
            options::ConfigCommand::ShowDefault { reset_with_default } => {
                show_default_config(reset_with_default)?;
                return Ok(());
            }
        },
        None => {
            let config = dua::Config::load()?;
            let byte_format = global_traversal.byte_format(&config);
            let walk_options = walk_options_from(&global_traversal)?;
            let inputs =
                extract_aggregate_inputs_maybe_set_cwd(global_traversal.input, &walk_options)?;
            run_aggregation(inputs, walk_options, true, true, byte_format, false)?
        }
    };

    process::exit(res.to_exit_code());
}

enum AggregateInputs {
    Paths(Vec<PathBuf>),
    #[cfg(any(windows, target_os = "macos"))]
    Entries(Vec<dua_core::Entry>),
}

fn run_aggregation(
    inputs: AggregateInputs,
    walk_options: dua::WalkOptions,
    compute_total: bool,
    sort_by_size_in_bytes: bool,
    byte_format: dua::ByteFormat,
    show_statistics: bool,
) -> Result<dua::WalkResult> {
    let stdout = io::stdout();
    let out_supports_colors = stdout.is_terminal();
    let stdout_locked = stdout.lock();
    let (result, statistics) = match inputs {
        AggregateInputs::Paths(paths) => dua::aggregate(
            (stdout_locked, out_supports_colors),
            stderr_if_tty(),
            walk_options,
            compute_total,
            sort_by_size_in_bytes,
            byte_format,
            paths,
        ),
        #[cfg(any(windows, target_os = "macos"))]
        AggregateInputs::Entries(entries) => dua::aggregate_entries(
            (stdout_locked, out_supports_colors),
            stderr_if_tty(),
            walk_options,
            compute_total,
            sort_by_size_in_bytes,
            byte_format,
            entries,
        ),
    }?;
    if show_statistics {
        writeln!(io::stderr(), "{statistics:?}").ok();
    }

    Ok(result)
}

fn is_default_ignore_dirs(list: &[PathBuf]) -> bool {
    let defaults = options::DEFAULT_IGNORE_DIRS;
    list.len() == defaults.len()
        && list
            .iter()
            .zip(defaults)
            .all(|(input, default)| input == Path::new(default))
}

fn traversal_options_on_command_line(matches: &clap::ArgMatches) -> bool {
    let used = [
        "threads",
        "apparent_size",
        "count_hard_links",
        "stay_on_filesystem",
        "ignore_dirs",
        "ignore_from",
        "input",
    ]
    .into_iter()
    .any(|id| matches.value_source(id) == Some(clap::parser::ValueSource::CommandLine));
    #[cfg(target_os = "macos")]
    let used = used
        || matches.value_source("deduplicate_apfs_clones")
            == Some(clap::parser::ValueSource::CommandLine);
    used
}

#[cfg(feature = "tui-crossplatform")]
fn snapshot_walk_options() -> dua::WalkOptions {
    dua::WalkOptions {
        threads: 1,
        apparent_size: false,
        count_hard_links: false,
        cross_filesystems: true,
        ignore_dirs: std::collections::BTreeSet::new(),
        ignore_patterns: None,
        metadata_options: dua::TraversalOptions::default(),
    }
}

fn merge_traversal_args(
    global: &options::TraversalArgs,
    subcommand: &options::TraversalArgs,
) -> options::TraversalArgs {
    options::TraversalArgs {
        threads: if global.threads == options::DEFAULT_THREADS {
            subcommand.threads
        } else {
            global.threads
        },
        format: global.format.or(subcommand.format),
        apparent_size: global.apparent_size || subcommand.apparent_size,
        count_hard_links: global.count_hard_links || subcommand.count_hard_links,
        #[cfg(target_os = "macos")]
        deduplicate_apfs_clones: global.deduplicate_apfs_clones
            || subcommand.deduplicate_apfs_clones,
        stay_on_filesystem: global.stay_on_filesystem || subcommand.stay_on_filesystem,
        ignore_dirs: if is_default_ignore_dirs(&global.ignore_dirs) {
            subcommand.ignore_dirs.clone()
        } else {
            global.ignore_dirs.clone()
        },
        ignore_from: global
            .ignore_from
            .iter()
            .chain(&subcommand.ignore_from)
            .cloned()
            .collect(),
        input: subcommand.input.clone(),
    }
}

fn walk_options_from(traversal: &options::TraversalArgs) -> Result<dua::WalkOptions> {
    let mut walk_options = dua::WalkOptions {
        threads: traversal.threads,
        apparent_size: traversal.apparent_size,
        count_hard_links: traversal.count_hard_links,
        cross_filesystems: !traversal.stay_on_filesystem,
        ignore_dirs: canonicalize_ignore_dirs(&traversal.ignore_dirs),
        ignore_patterns: dua::IgnorePatterns::from_files(&traversal.ignore_from)?,
        metadata_options: dua::TraversalOptions {
            #[cfg(target_os = "macos")]
            apfs_clone_metadata: traversal.deduplicate_apfs_clones && !traversal.apparent_size,
        },
    };

    if walk_options.threads == 0 {
        walk_options.threads = num_cpus::get();
    }

    Ok(walk_options)
}

fn extract_aggregate_inputs_maybe_set_cwd(
    paths: Vec<PathBuf>,
    walk_options: &dua::WalkOptions,
) -> Result<AggregateInputs, io::Error> {
    #[cfg(any(windows, target_os = "macos"))]
    if paths.is_empty() || paths.len() == 1 && paths[0].is_dir() {
        if let Some(path) = paths.first() {
            std::env::set_current_dir(path)?;
        }

        let cwd = std::env::current_dir()?;
        #[cfg(target_os = "macos")]
        let cwd_device = (!walk_options.cross_filesystems)
            .then(|| device_id(&cwd).ok())
            .flatten();
        let parent_path: std::sync::Arc<Path> = Path::new("").into();
        let mut selected_entries = Vec::new();

        for entry in dua_core::read_dir(Path::new("."), walk_options.metadata_options)? {
            let mut entry = entry?;
            if entry.file_type.is_symlink() {
                continue;
            }
            entry.parent_path = std::sync::Arc::clone(&parent_path);

            #[cfg(target_os = "macos")]
            if let Some(cwd_device) = cwd_device {
                let same_device = entry.metadata.as_ref().map_or_else(
                    |_| device_id(&entry.path()).map_or(true, |device| device == cwd_device),
                    |metadata| metadata.dev() == cwd_device,
                );
                if !same_device {
                    continue;
                }
            }

            if walk_options
                .ignore_patterns
                .as_ref()
                .is_some_and(|patterns| {
                    patterns.is_excluded(Path::new(&entry.file_name), entry.file_type.is_dir())
                })
            {
                continue;
            }

            if walk_options.is_ignored_directory(Path::new(&entry.file_name), &cwd) {
                continue;
            }

            selected_entries.push(entry);
        }
        selected_entries.sort_by(|left, right| left.file_name.cmp(&right.file_name));
        return Ok(AggregateInputs::Entries(selected_entries));
    }

    extract_paths_maybe_set_cwd(paths, walk_options).map(AggregateInputs::Paths)
}

fn extract_paths_maybe_set_cwd(
    mut paths: Vec<PathBuf>,
    walk_options: &dua::WalkOptions,
) -> Result<Vec<PathBuf>, io::Error> {
    let cross_filesystems = walk_options.cross_filesystems;
    // Paths were explicitly passed by the user on the command-line; per `--ignore-dirs`'s
    // documented behavior, these are never subject to `-i`/`--ignore-dirs` filtering, only
    // paths that we ourselves expand into roots below are.
    let paths_were_expanded = paths.is_empty() || (paths.len() == 1 && paths[0].is_dir());
    if paths.len() == 1 && paths[0].is_dir() {
        std::env::set_current_dir(&paths[0])?;
        paths.clear();
    }
    let cwd = std::env::current_dir()?;
    let cwd_device = device_id(&cwd).ok();

    let paths = if paths.is_empty() {
        cwd_dirlist().map(|paths| match cwd_device {
            Some(cwd_device) if !cross_filesystems => paths
                .into_iter()
                .filter(|path| device_id(path).map_or(true, |device| device == cwd_device))
                .collect(),
            _ => paths,
        })?
    } else {
        paths
    };

    // Drop excluded top-level paths here rather than during the walk, so they are left out of the
    // report entirely instead of showing up as being empty.
    Ok(paths
        .into_iter()
        .filter(|path| {
            walk_options
                .ignore_patterns
                .as_ref()
                .is_none_or(|patterns| !patterns.excludes_input_path(path, &cwd))
        })
        .filter(|path| !paths_were_expanded || !walk_options.is_ignored_directory(path, &cwd))
        .collect())
}

#[cfg(unix)]
fn device_id(path: &Path) -> io::Result<u64> {
    use std::os::unix::fs::MetadataExt;

    path.metadata().map(|metadata| metadata.dev())
}

#[cfg(not(unix))]
fn device_id(_path: &Path) -> io::Result<u64> {
    Ok(0)
}

fn cwd_dirlist() -> Result<Vec<PathBuf>, io::Error> {
    let mut entries: Vec<_> = fs::read_dir(".")?
        .filter_map(|e| {
            e.ok()
                .and_then(|e| e.path().strip_prefix(".").ok().map(ToOwned::to_owned))
        })
        .filter(|p| {
            if let Ok(meta) = p.symlink_metadata()
                && meta.file_type().is_symlink()
            {
                return false;
            }
            true
        })
        .collect();

    entries.sort();
    Ok(entries)
}

fn edit_config() -> Result<()> {
    let path = dua::Config::path()?;

    ensure_default_config_file(&path)?;

    let editor = std::env::var("EDITOR").map_err(|_| {
        anyhow!(
            "$EDITOR is not set or is illformed UTF8. Created default configuration at {}",
            path.display()
        )
    })?;

    let Some(mut editor_parts) = shlex::split(&editor) else {
        return Err(anyhow!(
            "$EDITOR has invalid shell quoting. Created default configuration at {}",
            path.display()
        ));
    };

    if editor_parts.is_empty() {
        bail!(
            "$EDITOR is empty. Created default configuration at {}",
            path.display()
        )
    }

    let editor_program = editor_parts.remove(0);
    let status = std::process::Command::new(&editor_program)
        .args(editor_parts)
        .arg(&path)
        .status()
        .with_context(|| {
            format!(
                "Failed to launch editor {editor_program:?} (from $EDITOR={editor:?}) for {}",
                path.display()
            )
        })?;

    if status.success() {
        println!("Successfully edited '{}'", path.display());
        return Ok(());
    }

    Err(anyhow!(
        "Editor {editor_program:?} (from $EDITOR={editor:?}) exited with status {status} while editing {}",
        path.display()
    ))
}

fn show_default_config(overwrite_with_default: bool) -> Result<()> {
    print!("{}", dua::Config::default_file_content());

    if overwrite_with_default {
        let path = dua::Config::path()?;
        write_default_config_file(&path)?;
        eprintln!("Reset configuration at '{}'", path.display());
    }

    Ok(())
}

fn ensure_default_config_file(path: &Path) -> Result<()> {
    if path.exists() {
        return Ok(());
    }

    write_default_config_file(path)
}

fn write_default_config_file(path: &Path) -> Result<()> {
    if let Some(parent_dir) = path.parent() {
        fs::create_dir_all(parent_dir).with_context(|| {
            format!(
                "Could not create configuration directory {}",
                parent_dir.display()
            )
        })?;
    }

    fs::write(path, dua::Config::default_file_content()).with_context(|| {
        format!(
            "Could not write default configuration to {}",
            path.display()
        )
    })?;

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::traversal_options_on_command_line;
    use super::{marked_path_for_output, merge_traversal_args, write_default_config_file};
    use clap::CommandFactory as _;
    use std::fs;
    use std::path::PathBuf;

    #[test]
    fn marked_paths_are_only_sanitized_for_terminals() {
        let path = std::path::Path::new("marked\t\x1b[31m");

        assert_eq!(
            marked_path_for_output(path, false),
            "marked\t\x1b[31m",
            "TAB and ESC control characters are preserved for redirected output"
        );
        assert_eq!(
            marked_path_for_output(path, true),
            "marked��[31m",
            "TAB and ESC control characters are sanitized for terminal output"
        );
    }

    #[cfg(feature = "tui-crossplatform")]
    #[test]
    fn interactive_import_rejects_global_traversal_options() {
        let threads = super::options::DEFAULT_THREADS.to_string();
        let matches = super::options::Args::command()
            .try_get_matches_from([
                "dua",
                "--threads",
                &threads,
                "interactive",
                "--import",
                "scan.dua",
            ])
            .expect("parent traversal options parse before the subcommand");

        assert!(traversal_options_on_command_line(&matches));
    }

    #[test]
    fn aggregate_import_rejects_global_traversal_options() {
        let threads = super::options::DEFAULT_THREADS.to_string();
        let matches = super::options::Args::command()
            .try_get_matches_from([
                "dua",
                "--threads",
                &threads,
                "aggregate",
                "--import",
                "scan.dua",
            ])
            .expect("parent traversal options parse before the subcommand");

        assert!(traversal_options_on_command_line(&matches));
    }

    #[test]
    fn diff_rejects_global_traversal_options_but_accepts_format() {
        let threads = super::options::DEFAULT_THREADS.to_string();
        let matches = super::options::Args::command()
            .try_get_matches_from(["dua", "--threads", &threads, "diff", "old.dua", "new.dua"])
            .expect("parent traversal options parse before the subcommand");
        assert!(traversal_options_on_command_line(&matches));

        let matches = super::options::Args::command()
            .try_get_matches_from(["dua", "--format", "bytes", "diff", "old.dua", "new.dua"])
            .expect("format parses before the subcommand");
        assert!(!traversal_options_on_command_line(&matches));
    }

    #[test]
    fn write_default_config_file_overwrites_existing_config() {
        let dir = tempfile::tempdir().expect("temporary directory");
        let path = dir.path().join("dua-cli").join("config.toml");
        fs::create_dir_all(path.parent().expect("config parent")).expect("config directory");
        fs::write(&path, "old config").expect("existing config");

        write_default_config_file(&path).expect("default config written");

        assert_eq!(
            fs::read_to_string(&path).expect("default config"),
            dua::Config::default_file_content()
        );
    }

    #[cfg(target_os = "macos")]
    #[test]
    fn walk_options_only_request_apfs_clones_for_allocated_size() {
        for (apparent_size, deduplicate_apfs_clones, expected_clone_metadata) in [
            (false, false, false),
            (false, true, true),
            (true, false, false),
            (true, true, false),
        ] {
            let traversal = super::options::TraversalArgs {
                threads: 1,
                format: None,
                apparent_size,
                count_hard_links: false,
                deduplicate_apfs_clones,
                stay_on_filesystem: false,
                ignore_dirs: vec![],
                ignore_from: vec![],
                input: vec![],
            };
            let walk_options =
                super::walk_options_from(&traversal).expect("valid traversal options");

            assert_eq!(
                walk_options.metadata_options.apfs_clone_metadata, expected_clone_metadata,
                "APFS clone metadata is unnecessary for apparent sizes, which use logical file lengths\n\
                 apparent_size={apparent_size}\n\
                 deduplicate_apfs_clones={deduplicate_apfs_clones}"
            );
        }
    }

    #[test]
    fn merge_traversal_args_prefers_global_threads() {
        let custom_threads = super::options::DEFAULT_THREADS + 1;
        let global = super::options::TraversalArgs {
            threads: custom_threads,
            format: None,
            apparent_size: true,
            count_hard_links: false,
            #[cfg(target_os = "macos")]
            deduplicate_apfs_clones: false,
            stay_on_filesystem: false,
            ignore_dirs: vec![],
            ignore_from: vec![PathBuf::from("global-ignore")],
            input: vec![],
        };

        let subcommand = super::options::TraversalArgs {
            threads: 2,
            format: None,
            apparent_size: false,
            count_hard_links: true,
            #[cfg(target_os = "macos")]
            deduplicate_apfs_clones: false,
            stay_on_filesystem: true,
            ignore_dirs: vec![],
            ignore_from: vec![PathBuf::from("subcommand-ignore")],
            input: vec![PathBuf::from("subcommand-input")],
        };

        let merged = merge_traversal_args(&global, &subcommand);

        assert_eq!(merged.threads, custom_threads);
        assert_eq!(merged.input, subcommand.input);
        assert_eq!(
            merged.ignore_from,
            [
                PathBuf::from("global-ignore"),
                PathBuf::from("subcommand-ignore")
            ]
        );
    }

    #[test]
    fn merge_traversal_args_uses_subcommand_threads_when_global_is_default() {
        let global = super::options::TraversalArgs {
            threads: super::options::DEFAULT_THREADS,
            format: None,
            apparent_size: false,
            count_hard_links: false,
            #[cfg(target_os = "macos")]
            deduplicate_apfs_clones: false,
            stay_on_filesystem: false,
            ignore_dirs: vec![],
            ignore_from: vec![],
            input: vec![],
        };

        let subcommand = super::options::TraversalArgs {
            threads: 6,
            format: None,
            apparent_size: false,
            count_hard_links: false,
            #[cfg(target_os = "macos")]
            deduplicate_apfs_clones: false,
            stay_on_filesystem: false,
            ignore_dirs: vec![],
            ignore_from: vec![],
            input: vec![],
        };

        let merged = merge_traversal_args(&global, &subcommand);

        assert_eq!(merged.threads, 6);
    }

    #[test]
    fn merge_traversal_args_uses_global_format_and_or_booleans() {
        let global = super::options::TraversalArgs {
            threads: super::options::DEFAULT_THREADS,
            format: Some(super::options::ByteFormat::MB),
            apparent_size: true,
            count_hard_links: false,
            #[cfg(target_os = "macos")]
            deduplicate_apfs_clones: true,
            stay_on_filesystem: true,
            ignore_dirs: vec![],
            ignore_from: vec![],
            input: vec![],
        };

        let subcommand = super::options::TraversalArgs {
            threads: 4,
            format: Some(super::options::ByteFormat::GB),
            apparent_size: false,
            count_hard_links: true,
            #[cfg(target_os = "macos")]
            deduplicate_apfs_clones: false,
            stay_on_filesystem: false,
            ignore_dirs: vec![],
            ignore_from: vec![],
            input: vec![],
        };

        let merged = merge_traversal_args(&global, &subcommand);

        assert_eq!(merged.format, Some(super::options::ByteFormat::MB));
        assert!(merged.apparent_size);
        assert!(merged.count_hard_links);
        #[cfg(target_os = "macos")]
        assert!(merged.deduplicate_apfs_clones);
        assert!(merged.stay_on_filesystem);
    }

    #[test]
    fn merge_traversal_args_prefers_global_ignore_dirs_when_custom() {
        let global = super::options::TraversalArgs {
            threads: super::options::DEFAULT_THREADS,
            format: None,
            apparent_size: false,
            count_hard_links: false,
            #[cfg(target_os = "macos")]
            deduplicate_apfs_clones: false,
            stay_on_filesystem: false,
            ignore_dirs: vec![PathBuf::from("/custom-global-ignore")],
            ignore_from: vec![],
            input: vec![],
        };

        let subcommand = super::options::TraversalArgs {
            threads: super::options::DEFAULT_THREADS,
            format: None,
            apparent_size: false,
            count_hard_links: false,
            #[cfg(target_os = "macos")]
            deduplicate_apfs_clones: false,
            stay_on_filesystem: false,
            ignore_dirs: vec![PathBuf::from("/custom-subcommand-ignore")],
            ignore_from: vec![],
            input: vec![],
        };

        let merged = merge_traversal_args(&global, &subcommand);

        assert_eq!(merged.ignore_dirs, global.ignore_dirs);
    }

    #[test]
    fn merge_traversal_args_uses_subcommand_ignore_dirs_when_global_is_default() {
        let global = super::options::TraversalArgs {
            threads: super::options::DEFAULT_THREADS,
            format: None,
            apparent_size: false,
            count_hard_links: false,
            #[cfg(target_os = "macos")]
            deduplicate_apfs_clones: false,
            stay_on_filesystem: false,
            ignore_dirs: super::options::DEFAULT_IGNORE_DIRS
                .iter()
                .map(PathBuf::from)
                .collect(),
            ignore_from: vec![],
            input: vec![],
        };

        let subcommand = super::options::TraversalArgs {
            threads: super::options::DEFAULT_THREADS,
            format: None,
            apparent_size: false,
            count_hard_links: false,
            #[cfg(target_os = "macos")]
            deduplicate_apfs_clones: false,
            stay_on_filesystem: false,
            ignore_dirs: vec![PathBuf::from("/custom-subcommand-ignore")],
            ignore_from: vec![],
            input: vec![],
        };

        let merged = merge_traversal_args(&global, &subcommand);

        assert_eq!(merged.ignore_dirs, subcommand.ignore_dirs);
    }
}
