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

#[cfg(feature = "tui-crossplatform")]
use crate::interactive::input::input_channel;
#[cfg(feature = "tui-crossplatform")]
use crate::interactive::terminal::TerminalApp;

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
    }
}

fn is_tiktoken_stack_overflow_panic(info: &PanicHookInfo<'_>) -> bool {
    let is_tiktoken_location = info
        .location()
        .is_some_and(|location| location.file().contains("vendor_tiktoken.rs"));

    let message_contains_stack_overflow = info
        .payload()
        .downcast_ref::<String>()
        .is_some_and(|payload| payload.contains("RuntimeError(StackOverflow)"))
        || info
            .payload()
            .downcast_ref::<&str>()
            .is_some_and(|payload| payload.contains("RuntimeError(StackOverflow)"));

    is_tiktoken_location && message_contains_stack_overflow
}

fn install_tiktoken_panic_filter() {
    let default_hook = panic::take_hook();
    panic::set_hook(Box::new(move |info| {
        if is_tiktoken_stack_overflow_panic(info) {
            return;
        }
        default_hook(info);
    }));
}

#[cfg(feature = "tui-crossplatform")]
struct InteractiveTerminalGuard;

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

fn main() -> Result<()> {
    use options::Command::*;

    let raw_std_args = std::env::args_os().collect::<Vec<_>>();
    if raw_std_args.len() == 2
        && (raw_std_args[1] == OsStr::new("--version") || raw_std_args[1] == OsStr::new("-V"))
    {
        println!(
            "dua {} (dua-cli fork; token counting support)",
            env!("CARGO_PKG_VERSION")
        );
        return Ok(());
    }

    let mut command = options::Args::command();
    command = command.version(concat!(
        env!("CARGO_PKG_VERSION"),
        " (dua-cli fork; token counting support)"
    ));
    let matches = command.get_matches_from(wild::args_os());
    let opt: options::Args = options::Args::from_arg_matches(&matches)
        .expect("clap matches should always deserialize into options::Args");

    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:#?}");
    }

    if opt.tokens {
        install_tiktoken_panic_filter();
    }

    let token_mode = if opt.tokens {
        let config = TokenModeConfig {
            model: opt
                .token_model
                .clone()
                .unwrap_or_else(|| "gpt-5".to_string()),
            additional_ignore_patterns: opt.ignore_pattern.clone(),
            include_lockfiles: opt.tokens_include_lockfiles,
            include_editor: opt.tokens_include_editor,
            include_minified: opt.tokens_include_minified,
            include_build: opt.tokens_include_build,
            include_ai_agent_config: opt.tokens_include_ai_agent_config,
            read_dustignore: !opt.tokens_no_dustignore,
            apply_ignore_rules: !opt.tokens_no_ignore,
            debug_ignored: opt.tokens_debug_ignored || opt.tokens_dry_run,
            dry_run: opt.tokens_dry_run,
        };
        Some(TokenModeRuntime::try_new(config)?)
    } else {
        None
    };

    let count_mode = if token_mode.is_some() {
        CountMode::Tokens
    } else {
        CountMode::Bytes
    };

    let byte_format: dua::ByteFormat = if count_mode == CountMode::Tokens {
        dua::ByteFormat::Tokens
    } else {
        opt.format.into()
    };

    let mut walk_options = dua::WalkOptions {
        threads: opt.threads,
        apparent_size: opt.apparent_size,
        count_hard_links: opt.count_hard_links,
        sorting: TraversalSorting::None,
        cross_filesystems: !opt.stay_on_filesystem,
        ignore_dirs: canonicalize_ignore_dirs(&opt.ignore_dirs),
        count_mode,
        token_mode,
        token_ignore_root: None,
    };

    if walk_options.threads == 0 {
        // avoid using the global rayon pool, as it will keep a lot of threads alive after we are done.
        // Also means that we will spin up a bunch of threads per root path, instead of reusing them.
        walk_options.threads = num_cpus::get();
    }

    let (input_paths, token_ignore_root) =
        extract_paths_maybe_set_cwd(opt.input.clone(), &walk_options)?;
    walk_options.token_ignore_root = token_ignore_root;

    let res = match opt.command {
        #[cfg(feature = "tui-crossplatform")]
        Some(Interactive { no_entry_check }) => {
            use anyhow::{Context, anyhow};
            use crossterm::{
                execute,
                terminal::{EnterAlternateScreen, enable_raw_mode},
            };
            use tui::{Terminal, backend::CrosstermBackend};

            let config = dua::Config::load()?;

            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();
            enable_raw_mode().with_context(|| no_tty_msg)?;
            execute!(stderr, EnterAlternateScreen).with_context(|| no_tty_msg)?;
            let terminal_guard = InteractiveTerminalGuard;
            let mut terminal = Terminal::new(CrosstermBackend::new(stderr))
                .with_context(|| "Could not instantiate terminal")?;

            let keys_rx = input_channel();
            let mut app = TerminalApp::initialize(
                &mut terminal,
                walk_options,
                byte_format,
                !no_entry_check,
                input_paths.clone(),
                config,
            )?;
            app.traverse()?;

            let res = app.process_events(&mut terminal, keys_rx);

            let res = res.map(|r| {
                (
                    r,
                    app.window
                        .mark_pane
                        .take()
                        .map(|marked| marked.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
            std::process::exit(
                res.map(|(walk_result, paths)| {
                    if let Some(paths) = paths {
                        for path in paths {
                            println!("{}", path.display())
                        }
                    }
                    walk_result.to_exit_code()
                })
                .unwrap_or(0),
            );
        }
        Some(Aggregate {
            no_total,
            no_sort,
            statistics,
        }) => {
            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.clone(),
            )?;
            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: options::ConfigCommand::Edit,
        }) => {
            edit_config()?;
            return Ok(());
        }
        None => {
            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 extract_paths_maybe_set_cwd(
    mut paths: Vec<PathBuf>,
    walk_options: &dua::WalkOptions,
) -> Result<(Vec<PathBuf>, Option<PathBuf>), io::Error> {
    if paths.len() == 1 && paths[0].is_dir() {
        std::env::set_current_dir(&paths[0])?;
        paths.clear();
    }
    let device_id = std::env::current_dir()
        .ok()
        .and_then(|cwd| crossdev::init(&cwd).ok());

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

            (
                walk_options.filter_token_mode_paths(Path::new("."), paths),
                Some(PathBuf::from(".")),
            )
        })
    } else {
        Ok((paths, None))
    }
}

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

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

    Ok(())
}
