// SPDX-FileCopyrightText: 2021-2024 Lynnesbian
// SPDX-License-Identifier: GPL-3.0-or-later

// fif - a command-line tool for detecting and optionally correcting files with incorrect extensions.

#![forbid(unsafe_code)]
#![warn(
	trivial_casts,
	unused_lifetimes,
	unused_qualifications,
	missing_copy_implementations,
	unused_allocation
)]

use std::io::{stdin, stdout, BufWriter, Write};
use std::process::exit;

use cfg_if::cfg_if;
use clap::Parser;
use fif::files::{scan_directory, scan_from_walkdir};
use fif::formats::{self, Format};
use fif::parameters::{self, OutputFormat, Prompt};
use fif::utils::{os_name, CLAP_LONG_VERSION};
use itertools::Itertools;
use log::{debug, error, info, trace, warn, Level};

#[cfg(test)]
mod tests;

#[doc(hidden)]
#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
fn main() {
	let args: parameters::Parameters = parameters::Parameters::parse();

	let mut builder = env_logger::Builder::new();
	builder
		.filter_level(args.get_verbosity()) // set default log level
		.parse_default_env() // set log level from RUST_LOG
		.parse_env("FIF_LOG") // set log level from FIF_LOG
		.format(|buf, r| {
			let mut style = buf.default_level_style(r.level());
			// use bold for warnings and errors
			style.set_bold(r.level() <= Level::Warn);
			// only use the first character of the log level name
			let abbreviation = style.value(r.level().to_string().chars().next().unwrap());
			// e.g. [D] Debug message
			writeln!(buf, "[{}] {}", abbreviation, r.args())
		})
		.init();

	trace!(
		"fif {}, running on {} {}",
		CLAP_LONG_VERSION.as_str(),
		std::env::consts::ARCH,
		os_name()
	);

	debug!("Iterating directory: {:?}", args.dir);

	let extensions = args.extensions();
	let excludes = args.excluded_extensions();

	if let Some(extensions) = &extensions {
		debug!("Checking files with extensions: {:?}", extensions);
	} else if let Some(excludes) = &excludes {
		debug!("Skipping files with extensions: {:?}", excludes);
	} else {
		debug!("Checking files regardless of extensions");
	}

	let entries = match scan_directory(&args.dir, extensions.as_ref(), excludes.as_ref(), &args.get_scan_opts()) {
		// no need to log anything for fatal errors - fif will already have printed something obvious like
		// "[ERROR] /fake/path: No such file or directory (os error 2)". we can assume that if this has happened, the dir
		// given as input doesn't exist or is otherwise unreadable.
		None => exit(exitcode::NOINPUT),
		Some(e) => e,
	};

	if entries.is_empty() {
		warn!("No files matching requested options found.");
		exit(exitcode::OK);
	}

	trace!("Found {} items to check", entries.len());

	cfg_if! {
		if #[cfg(feature = "multi-threaded")] {
			let use_threads = args.jobs != 1;

			if use_threads {
				// 0 is a special case - it should be understood to mean "all available host CPUs"
				let jobs = if args.jobs == 0 { num_cpus::get() } else { args.jobs };

				// set up the global thread pool with the requested number of threads
				rayon::ThreadPoolBuilder::new().num_threads(jobs).build_global().unwrap();
				trace!("Multithreading enabled, using {} threads", jobs);
			} else {
				trace!("Multithreading disabled at runtime");
			}

		} else { // `multi-threading` feature disabled
			let use_threads = false;
			trace!("Multithreading disabled at compile time");
		}
	}

	let (findings, errors) = scan_from_walkdir(&entries, args.canonical_paths, use_threads);
	trace!("Scanning complete");

	if findings.is_empty() && errors.is_empty() {
		info!("All files have valid extensions!");
		exit(exitcode::OK);
	}

	// remove files that already have the correct extension, then sort - first by whether or not they have a
	// recommended_extension() (with None before Some(ext)), then by filename
	let findings = findings
		.into_iter()
		.filter(|f| !f.valid)
		.sorted_unstable()
		.collect_vec();
	// sort errors (File errors before Mime errors), then log a warning for each error
	let errors = errors
		.into_iter()
		.sorted_unstable()
		.map(|e| {
			warn!("{}", &e);
			e
		})
		.collect_vec();

	if args.fix {
		fn ask(message: &str) -> bool {
			let mut buf = String::with_capacity(1);
			print!("{message} [y/N] ");

			// flush stdout to ensure message is displayed
			stdout().flush().expect("Failed to flush stdout");

			if let Err(e) = stdin().read_line(&mut buf) {
				// something went wrong while reading input - just exit
				error!("{}", e);
				exit(exitcode::IOERR)
			}
			buf.starts_with('y') || buf.starts_with('Y')
		}

		let prompt = args.prompt.unwrap_or(Prompt::Error);

		let mut renamed = 0_u32; // files that were successfully renamed
		let mut skipped = 0_u32; // files that were skipped over without trying to rename
		let mut failed = 0_u32; // files that fif failed to rename - e.g. files that are exclusively locked

		for f in findings {
			if let Some(rename_to) = f.recommended_path() {
				let will_rename = {
					if !args.overwrite && rename_to.exists() {
						// handles: --prompt never, --prompt error, --prompt always
						// user didn't specify --overwrite, and the destination exists
						info!("Not renaming {:#?}: Target {:#?} exists", f.file, rename_to);
						false
					} else if prompt == Prompt::Never {
						// handles: --prompt never --overwrite
						// user specified --prompt never in conjunction with --overwrite, so always rename
						true
					} else if prompt == Prompt::Error || ask(&format!("Rename {:#?} to {:#?}?", &f.file, &rename_to)) {
						// handles: --prompt error --overwrite, --prompt always --overwrite [y]
						// if the target exists, prompt before renaming; otherwise, just rename
						!rename_to.exists() || ask(&format!("Destination {rename_to:#?} already exists, overwrite?"))
					} else {
						// handles: --prompt always --overwrite [n]
						// user was prompted and replied "no"
						false
					}
				};

				if !will_rename {
					skipped += 1;
					continue;
				}

				loop {
					// until file is renamed successfully
					match std::fs::rename(&f.file, &rename_to) {
						Ok(()) => {
							info!("Renamed {:#?} -> {:#?}", f.file, rename_to);
							renamed += 1;
							break;
						}
						Err(e) => {
							warn!("Couldn't rename {:#?} to {:#?}: {:#?}", f.file, rename_to, e);
							// if the user passed --prompt never, continue to the next file
							// otherwise, prompt user to retry move, retrying until the rename succeeds or they respond "N"
							if prompt == Prompt::Never || !ask(&format!("Error while renaming file: {e:#?}. Try again?")) {
								failed += 1;
								break;
							}
						}
					}
				}
			} else {
				// no recommended name :c
				info!("No known extension for file {:#?} of type {}", f.file, f.mime);
				skipped += 1;
			}
		}

		info!(
			"Processed {} files: Renamed {}, skipped {}, failed to rename {}",
			renamed + skipped + failed,
			renamed,
			skipped,
			failed
		);
	} else {
		let mut buffered_stdout = BufWriter::new(stdout());

		if match args.output_format {
			// TODO: simplify this to something like formats::write_all(args.output_format, ...)
			OutputFormat::Sh => formats::Shell.write_all(&mut buffered_stdout, &findings, &errors),
			OutputFormat::PowerShell => formats::PowerShell.write_all(&mut buffered_stdout, &findings, &errors),
			#[cfg(feature = "json")]
			OutputFormat::Json => formats::Json.write_all(&mut buffered_stdout, &findings, &errors),
			OutputFormat::Text => formats::Text.write_all(&mut buffered_stdout, &findings, &errors),
		}
		.is_err()
		{
			error!("Failed to write to stdout.");
			exit(exitcode::IOERR);
		}

		if buffered_stdout.flush().is_err() {
			error!("Failed to flush stdout.");
			exit(exitcode::IOERR);
		}
	}

	debug!("Done");
}