#![forbid(rust_2018_idioms, unsafe_code)]
use anyhow::{Context, Result, anyhow, bail};
use clap::{CommandFactory as _, Parser};
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_chars};
#[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};

mod crossdev;
#[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
    }
}

#[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();
        }
    }
}

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

    let opt: options::Args = options::Args::parse_from(wild::args_os());

    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,
            no_entry_check,
            once,
        }) => {
            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 walk_options = walk_options_from(&traversal)?;
            let input_paths = extract_paths_maybe_set_cwd(traversal.input, &walk_options)?;

            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.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 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,
                config,
            )?;
            app.traverse()?;

            let res = match once {
                Some(input) => {
                    app.process_events_once(&mut terminal, input_channel_from_chars(input.as_str()))
                }
                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 {
                        for path in paths {
                            println!("{}", path.display());
                        }
                    }
                    walk_result.to_exit_code()
                }
                Err(err) => {
                    eprintln!("{err:#}");
                    1
                }
            };
            std::process::exit(exit_code);
        }
        Some(Aggregate {
            traversal: subcommand_traversal,
            no_total,
            no_sort,
            statistics,
        }) => {
            let traversal = merge_traversal_args(&global_traversal, &subcommand_traversal);
            let config = dua::Config::load()?;
            let byte_format = traversal.byte_format(&config);
            let walk_options = walk_options_from(&traversal)?;
            let input_paths = extract_paths_maybe_set_cwd(traversal.input, &walk_options)?;
            let stdout = io::stdout();
            let stdout_locked = stdout.lock();
            let (res, stats) = dua::aggregate(
                stdout_locked,
                stderr_if_tty(),
                walk_options,
                !no_total,
                !no_sort,
                byte_format,
                input_paths,
            )?;
            if statistics {
                writeln!(io::stderr(), "{stats:?}").ok();
            }
            res
        }
        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 input_paths = extract_paths_maybe_set_cwd(global_traversal.input, &walk_options)?;
            let stdout = io::stdout();
            let stdout_locked = stdout.lock();
            dua::aggregate(
                stdout_locked,
                stderr_if_tty(),
                walk_options,
                true,
                true,
                byte_format,
                input_paths,
            )?
            .0
        }
    };

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

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 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,
        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)?,
    };

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

    Ok(walk_options)
}

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;
    if paths.len() == 1 && paths[0].is_dir() {
        std::env::set_current_dir(&paths[0])?;
        paths.clear();
    }
    let cwd = std::env::current_dir()?;
    let device_id = crossdev::init(&cwd).ok();

    let paths = if paths.is_empty() {
        cwd_dirlist().map(|paths| match device_id {
            Some(device_id) if !cross_filesystems => paths
                .into_iter()
                .filter(|p| match p.metadata() {
                    Ok(meta) => crossdev::is_same_device(device_id, &meta),
                    Err(_) => true,
                })
                .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))
        })
        .collect())
}

fn cwd_dirlist() -> Result<Vec<PathBuf>, io::Error> {
    let mut v: 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();
    v.sort();
    Ok(v)
}

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::{merge_traversal_args, write_default_config_file};
    use std::fs;
    use std::path::PathBuf;

    #[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()
        );
    }

    #[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,
            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,
            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,
            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,
            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,
            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,
            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);
        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,
            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,
            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,
            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,
            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);
    }
}
