#[cfg(not(feature = "parallel"))]
mod rayon;

#[cfg(feature = "zopfli")]
use std::num::NonZeroU64;
use std::{
    ffi::{OsStr, OsString},
    fs::DirBuilder,
    io::{IsTerminal, Write, stdout},
    path::PathBuf,
    process::ExitCode,
    sync::atomic::{AtomicUsize, Ordering::AcqRel},
    time::Duration,
};

use clap::ArgMatches;
mod cli;
use indexmap::IndexSet;
use log::{Level, LevelFilter, error, warn};
#[cfg(feature = "zopfli")]
use oxipng::ZopfliOptions;
use oxipng::{
    Deflater, FilterStrategy, InFile, OptimizationResult, Options, OutFile, PngError, StripChunks,
};
use rayon::prelude::*;

use crate::cli::DISPLAY_CHUNKS;

fn main() -> ExitCode {
    let matches = cli::build_command()
        // Set the value parser for filters which isn't appropriate to do in the build_command function
        .mut_arg("filters", |arg| {
            arg.value_parser(|x: &str| {
                parse_numeric_range_opts(x, 0, 9).map_err(|_| "Invalid option for filters")
            })
        })
        .after_help("Run `oxipng --help` to see full details of all options")
        .after_long_help("")
        .get_matches_from(std::env::args());

    let (mut out_file, out_dir, opts) = match parse_opts_into_struct(&matches) {
        Ok(x) => x,
        Err(x) => {
            error!("{x}");
            return ExitCode::FAILURE;
        }
    };

    // Determine input and output
    let file_args = matches.get_many::<PathBuf>("files").unwrap().cloned();
    #[cfg(windows)]
    let inputs: Vec<_> = file_args.flat_map(apply_glob_pattern).collect();
    #[cfg(not(windows))]
    let inputs: Vec<_> = file_args.collect();
    let using_stdin = inputs.len() == 1 && inputs[0].to_str() == Some("-");
    if using_stdin && out_dir.is_some() {
        error!("Cannot use --dir when reading from stdin.");
        return ExitCode::FAILURE;
    }
    if using_stdin && matches!(out_file, OutFile::Path { path: None, .. }) {
        out_file = OutFile::StdOut;
    }
    let using_stdout = matches!(out_file, OutFile::StdOut);
    let json = matches.get_flag("json");
    if using_stdout && json {
        error!("Cannot use --json when writing to stdout.");
        return ExitCode::FAILURE;
    }

    let files = if using_stdin {
        vec![(InFile::StdIn, out_file)]
    } else {
        collect_files(
            inputs,
            &out_dir,
            &out_file,
            matches.get_flag("recursive"),
            true,
        )
    };

    // Include version info in verbose output
    let is_verbose = matches.get_count("verbose") > 0;
    if is_verbose && !using_stdout {
        println!("{} {}\n", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
    }

    let print_summary = !matches.get_flag("quiet") && !using_stdout;
    let print_progress = print_summary && !is_verbose && stdout().is_terminal();
    let total_files = files.len();
    let num_processed = AtomicUsize::new(0);
    if print_progress {
        print!("Files processed: 0/{}...", total_files);
        stdout().flush().ok();
    }
    let process = |(input, output): &(InFile, OutFile)| {
        let result = process_file(input, output, &opts);
        if print_progress && matches!(result, OptimizationResult::Ok(_)) {
            let value = num_processed.fetch_add(1, AcqRel) + 1;
            print!("\rFiles processed: {}/{}...", value, total_files);
            stdout().flush().ok();
        }
        result
    };
    let results: Vec<OptimizationResult> = if matches.get_flag("parallel-files") {
        files.par_iter().map(process).collect()
    } else {
        files.iter().map(process).collect()
    };

    // Collect stats
    let mut num_succeeded = 0;
    let mut num_not_optimized = 0;
    let mut num_failed = 0;
    let mut total_in: i64 = 0;
    let mut total_out: i64 = 0;
    for result in &results {
        match result {
            Ok((insize, outsize)) => {
                num_succeeded += 1;
                total_in += *insize as i64;
                total_out += *outsize as i64;
                if !opts.force && insize == outsize {
                    num_not_optimized += 1;
                }
            }
            Err(PngError::ChunkPreventsChanges(_) | PngError::InflatedDataTooLong(_)) => {}
            Err(_) => num_failed += 1,
        }
    }

    // Print results
    if json {
        json_output(&files, &results);
    } else if print_summary {
        let in_bytes = format_bytes(total_in, true);
        let out_bytes = format_bytes(total_out, true);
        let saved = total_in - total_out;
        let saved_bytes = format_bytes(saved, false);
        let percent = if total_in > 0 {
            saved as f64 / total_in as f64 * 100_f64
        } else {
            0_f64
        };
        if is_verbose {
            println!("--------------------");
        }
        println!("\rFiles processed: {num_succeeded}/{total_files}   ");
        println!("Input size: {}", in_bytes);
        println!("Output size: {}", out_bytes);
        println!("Total saved: {} ({:.2}%)", saved_bytes, percent);
        if num_not_optimized == 1 {
            println!("({num_not_optimized} file could not be optimized further)");
        } else if num_not_optimized > 0 {
            println!("({num_not_optimized} files could not be optimized further)");
        }
        if matches.get_flag("dry-run") {
            println!("Dry run, no changes saved");
        }
    }

    // For optimizing single files, this will return the correct exit code always.
    // For recursive optimization, the correct choice is a bit subjective.
    // We're choosing to return a 0 exit code if ANY file in the set
    // runs correctly.
    // The reason for this is that recursion may pick up files that are not
    // PNG files, and return an error for them.
    // We don't really want to return an error code for those files.
    if num_succeeded > 0 {
        ExitCode::SUCCESS
    } else if num_failed > 0 {
        ExitCode::FAILURE
    } else {
        ExitCode::from(3)
    }
}

fn collect_files(
    files: Vec<PathBuf>,
    out_dir: &Option<PathBuf>,
    out_file: &OutFile,
    recursive: bool,
    top_level: bool, //explicitly specify files
) -> Vec<(InFile, OutFile)> {
    let mut in_out_pairs = Vec::new();
    for input in files {
        if input.is_dir() {
            if recursive {
                match input.read_dir() {
                    Ok(dir) => {
                        let files = dir.filter_map(|x| x.ok().map(|x| x.path())).collect();
                        in_out_pairs
                            .extend(collect_files(files, out_dir, out_file, recursive, false));
                    }
                    Err(e) => {
                        warn!("{}: {}", input.display(), e);
                    }
                }
            } else {
                warn!("{} is a directory, skipping", input.display());
            }
            continue;
        }

        // Skip non png files if not given on top level
        if !top_level && {
            let extension = input.extension().map(OsStr::to_ascii_lowercase);
            extension != Some(OsString::from("png")) && extension != Some(OsString::from("apng"))
        } {
            continue;
        }

        let out_file =
            if let (Some(out_dir), &OutFile::Path { preserve_attrs, .. }) = (out_dir, out_file) {
                let path = Some(out_dir.join(input.file_name().unwrap()));
                OutFile::Path {
                    path,
                    preserve_attrs,
                }
            } else {
                (*out_file).clone()
            };
        let in_file = InFile::Path(input);
        in_out_pairs.push((in_file, out_file));
    }
    in_out_pairs
}

#[cfg(windows)]
fn apply_glob_pattern(path: PathBuf) -> Vec<PathBuf> {
    let matches = path
        .to_str()
        // Use MatchOptions::default() to disable case-sensitivity
        .and_then(|pattern| glob::glob_with(pattern, glob::MatchOptions::default()).ok())
        .map(|paths| paths.flatten().collect::<Vec<_>>());

    match matches {
        Some(paths) if !paths.is_empty() => paths,
        _ => vec![path],
    }
}

fn parse_opts_into_struct(
    matches: &ArgMatches,
) -> Result<(OutFile, Option<PathBuf>, Options), String> {
    let log_level = match matches.get_count("verbose") {
        _ if matches.get_flag("quiet") => LevelFilter::Off,
        0 => LevelFilter::Warn,
        1 => LevelFilter::Info,
        2 => LevelFilter::Debug,
        _ => LevelFilter::Trace,
    };
    env_logger::builder()
        .filter_module(module_path!(), log_level)
        .format(|buf, record| {
            match record.level() {
                Level::Error | Level::Warn => {
                    let style = buf.default_level_style(record.level());
                    // Prepend carriage return to clear progress line
                    writeln!(buf, "\r{style}{}{style:#}", record.args())
                }
                // Leave info, debug and trace unstyled
                _ => writeln!(buf, "{}", record.args()),
            }
        })
        .init();

    let mut opts = match matches.get_one::<String>("optimization") {
        None => Options::default(),
        Some(x) if x == "max" => Options::max_compression(),
        Some(level) => Options::from_preset(level.parse::<u8>().unwrap()),
    };

    // Get custom brute settings and rebuild the filter set to apply them
    let mut brute_lines = matches.get_one::<usize>("brute-lines").copied();
    let mut brute_level = matches.get_one::<i64>("brute-level").map(|x| *x as u8);
    let mut new_filters = IndexSet::new();
    for mut f in opts.filters.drain(..) {
        if let FilterStrategy::Brute { num_lines, level } = &mut f {
            *num_lines = brute_lines.unwrap_or(*num_lines);
            *level = brute_level.unwrap_or(*level);
            // If custom settings were not given, we still need to retain the default values
            // from the preset so we can re-apply them if the filters are overridden below
            brute_lines = Some(*num_lines);
            brute_level = Some(*level);
        }
        new_filters.insert(f);
    }
    opts.filters = new_filters;

    if let Some(x) = matches.get_one::<IndexSet<u8>>("filters") {
        opts.filters = x
            .iter()
            .map(|&f| match f {
                0..=4 => FilterStrategy::Basic(f.try_into().unwrap()),
                5 => FilterStrategy::MinSum,
                6 => FilterStrategy::Entropy,
                7 => FilterStrategy::Bigrams,
                8 => FilterStrategy::BigEnt,
                9 => FilterStrategy::Brute {
                    num_lines: brute_lines.unwrap_or(3),
                    level: brute_level.unwrap_or(1),
                },
                _ => unreachable!(),
            })
            .collect();
    }

    if let Some(&num) = matches.get_one::<u64>("timeout") {
        opts.timeout = Some(Duration::from_secs(num));
    }

    let out_dir = if let Some(path) = matches.get_one::<PathBuf>("output_dir") {
        if !path.exists() {
            match DirBuilder::new().recursive(true).create(path) {
                Ok(()) => (),
                Err(x) => return Err(format!("Could not create output directory {x}")),
            }
        } else if !path.is_dir() {
            return Err(format!(
                "{} is an existing file (not a directory), cannot create directory",
                path.display()
            ));
        }
        Some(path.to_owned())
    } else {
        None
    };

    let out_file = if matches.get_flag("dry-run") {
        OutFile::None
    } else if matches.get_flag("stdout") {
        OutFile::StdOut
    } else {
        OutFile::Path {
            path: matches.get_one::<PathBuf>("output_file").cloned(),
            preserve_attrs: matches.get_flag("preserve"),
        }
    };

    opts.optimize_alpha = matches.get_flag("alpha");

    opts.scale_16 = matches.get_flag("scale16");

    // The default value for fast depends on the preset - make sure we don't change when not provided
    if matches.get_flag("fast") {
        opts.fast_evaluation = matches.get_flag("fast");
    }

    opts.force = matches.get_flag("force");

    opts.fix_errors = matches.get_flag("fix");

    opts.max_decompressed_size = matches.get_one::<u64>("max-size").map(|&x| x as usize);

    opts.bit_depth_reduction = !matches.get_flag("no-bit-reduction");

    opts.color_type_reduction = !matches.get_flag("no-color-reduction");

    opts.palette_reduction = !matches.get_flag("no-palette-reduction");

    opts.grayscale_reduction = !matches.get_flag("no-grayscale-reduction");

    if matches.get_flag("no-reductions") {
        opts.bit_depth_reduction = false;
        opts.color_type_reduction = false;
        opts.palette_reduction = false;
        opts.grayscale_reduction = false;
        opts.interlace = None;
    }

    opts.idat_recoding = !matches.get_flag("no-recoding");

    if let Some(x) = matches.get_one::<String>("interlace") {
        opts.interlace = match x.as_str() {
            "off" | "0" => Some(false),
            "on" | "1" => Some(true),
            _ => None, // keep
        };
    }

    const FORBIDDEN_CHUNKS: [[u8; 4]; 5] = [*b"IHDR", *b"IDAT", *b"tRNS", *b"PLTE", *b"IEND"];
    if let Some(keep) = matches.get_one::<String>("keep") {
        let mut keep_display = false;
        let mut names = keep
            .split(',')
            .filter_map(|x| {
                if x == "display" {
                    keep_display = true;
                    return None;
                }
                Some(match parse_chunk_name(x) {
                    Ok(name) if FORBIDDEN_CHUNKS.contains(&name) => Err(format!(
                        "{x} chunk is controlled internally and may not be explicitly kept"
                    )),
                    name => name,
                })
            })
            .collect::<Result<IndexSet<_>, _>>()?;
        if keep_display {
            names.extend(DISPLAY_CHUNKS.iter().copied());
        }
        opts.strip = StripChunks::Keep(names);
    }

    if let Some(strip) = matches.get_one::<String>("strip") {
        if strip == "safe" {
            opts.strip = StripChunks::Safe;
        } else if strip == "all" {
            opts.strip = StripChunks::All;
        } else {
            let names = strip
                .split(',')
                .map(|x| {
                    if x == "safe" || x == "all" {
                        return Err(
                            "'safe' or 'all' presets for --strip should be used by themselves"
                                .to_owned(),
                        );
                    }
                    let name = parse_chunk_name(x)?;
                    if FORBIDDEN_CHUNKS.contains(&name) {
                        return Err(format!("{x} chunk is not allowed to be stripped"));
                    }
                    Ok(name)
                })
                .collect::<Result<_, _>>()?;
            opts.strip = StripChunks::Strip(names);
        }
    }

    if matches.get_flag("strip-safe") {
        opts.strip = StripChunks::Safe;
    }

    #[cfg(feature = "zopfli")]
    if matches.get_flag("zopfli") {
        let iteration_count = *matches.get_one::<NonZeroU64>("iterations").unwrap();
        let iterations_without_improvement = *matches
            .get_one::<NonZeroU64>("iterations-without-improvement")
            .unwrap_or(&NonZeroU64::MAX);

        if iterations_without_improvement > iteration_count
            && iterations_without_improvement != NonZeroU64::MAX
        {
            warn!(
                "--ziwi ({}) is higher than --zi ({}) and will never be reached.",
                iterations_without_improvement, iteration_count
            );
        }

        opts.deflater = Deflater::Zopfli(ZopfliOptions {
            iteration_count,
            iterations_without_improvement,
            ..Default::default()
        });
    }
    if let (Deflater::Libdeflater { compression }, Some(x)) =
        (&mut opts.deflater, matches.get_one::<i64>("compression"))
    {
        *compression = *x as u8;
    }

    #[cfg(feature = "parallel")]
    if let Some(&threads) = matches.get_one::<usize>("threads") {
        rayon::ThreadPoolBuilder::new()
            .num_threads(threads)
            .build_global()
            .map_err(|err| err.to_string())?;
    }

    Ok((out_file, out_dir, opts))
}

fn parse_chunk_name(name: &str) -> Result<[u8; 4], String> {
    name.trim()
        .as_bytes()
        .try_into()
        .map_err(|_| format!("Invalid chunk name {name}"))
}

fn parse_numeric_range_opts(
    input: &str,
    min_value: u8,
    max_value: u8,
) -> Result<IndexSet<u8>, String> {
    const ERROR_MESSAGE: &str = "Not a valid input";
    let mut items = IndexSet::new();

    // one value
    if let Ok(one_value) = input.parse::<u8>()
        && (min_value <= one_value && one_value <= max_value)
    {
        items.insert(one_value);
        return Ok(items);
    }

    // a range ("A-B")
    let range_values = input.split('-').collect::<Vec<&str>>();
    if range_values.len() == 2 {
        let first_opt = range_values[0].parse::<u8>();
        let second_opt = range_values[1].parse::<u8>();
        if let (Ok(first), Ok(second)) = (first_opt, second_opt)
            && (min_value <= first && first < second && second <= max_value)
        {
            for i in first..=second {
                items.insert(i);
            }
            return Ok(items);
        }
        return Err(ERROR_MESSAGE.to_owned());
    }

    // a list ("A,B[,…]")
    let list_items = input.split(',').collect::<Vec<&str>>();
    if list_items.len() > 1 {
        for value in list_items {
            if let Ok(value_int) = value.parse::<u8>()
                && (min_value <= value_int && value_int <= max_value)
                && !items.contains(&value_int)
            {
                items.insert(value_int);
                continue;
            }
            return Err(ERROR_MESSAGE.to_owned());
        }
        return Ok(items);
    }

    Err(ERROR_MESSAGE.to_owned())
}

fn process_file(input: &InFile, output: &OutFile, opts: &Options) -> OptimizationResult {
    if let (Some(max_size), InFile::Path(path)) = (opts.max_decompressed_size, input)
        && path.metadata().is_ok_and(|m| m.len() > max_size as u64)
    {
        warn!("{input}: Skipped: File exceeds the maximum size ({max_size} bytes)");
        return Err(PngError::InflatedDataTooLong(max_size));
    }

    let result = oxipng::optimize(input, output, opts);
    match &result {
        Ok(_) => {}
        Err(e @ PngError::ChunkPreventsChanges(_) | e @ PngError::InflatedDataTooLong(_)) => {
            warn!("{input}: Skipped: {e}");
        }
        Err(e) => {
            error!("{input}: {e}");
        }
    }
    result
}

/// Write optimization results as json.
/// ```
/// {
///   "results": [
///     {
///       "input": string,
///       "status": "success",
///       "output": string|null,
///       "insize": number,
///       "outsize": number
///     },
///     {
///       "input": string,
///       "status": "error",
///       "error": string
///     }
///   ]
/// }
/// ```
fn json_output(files: &[(InFile, OutFile)], results: &[OptimizationResult]) {
    print!(r#"{{"results":["#);
    let mut first = true;
    results
        .iter()
        .zip(files)
        .for_each(|(result, (input, output))| {
            if !first {
                print!(",");
            }
            print!(r#"{{"input":"{}","#, json_escape(&input.to_string()));
            match result {
                Ok((insize, outsize)) => {
                    let outpath = match output {
                        OutFile::None => "null".to_owned(),
                        OutFile::Path { path: None, .. } => {
                            format!(r#""{}""#, json_escape(&input.to_string()))
                        }
                        OutFile::Path { path: Some(p), .. } => {
                            format!(r#""{}""#, json_escape(&p.display().to_string()))
                        }
                        OutFile::StdOut => unreachable!(),
                    };
                    print!(
                        r#""status":"success","output":{},"insize":{},"outsize":{}}}"#,
                        outpath, insize, outsize
                    );
                }
                Err(e) => {
                    print!(
                        r#""status":"error","error":"{}"}}"#,
                        json_escape(&e.to_string())
                    );
                }
            }
            first = false;
        });
    print!("]}}");
}

fn json_escape(string: &str) -> String {
    string
        .replace("\\", "\\\\")
        .replace("\"", "\\\"")
        .replace("\n", "\\n")
        .replace("\t", "\\t")
        .replace("\r", "\\r")
        .replace("\x08", "\\b")
        .replace("\x0c", "\\f")
}

/// Format byte counts as IEC units to 3 significant figures.
fn format_bytes(count: i64, include_raw: bool) -> String {
    const K: i64 = 1 << 10;
    const M: i64 = 1 << 20;
    const G: i64 = 1 << 30;
    fn format_3sf(value: f64) -> String {
        match value.abs() {
            ..9.995 => format!("{:.2}", value),
            9.995..99.95 => format!("{:.1}", value),
            _ => format!("{:.0}", value),
        }
    }
    let formatted = match count.abs() {
        ..K => format!("{} bytes", count),
        K..M => format!("{} KiB", format_3sf(count as f64 / K as f64)),
        M..G => format!("{} MiB", format_3sf(count as f64 / M as f64)),
        _ => format!("{} GiB", format_3sf(count as f64 / G as f64)),
    };
    if include_raw && count.abs() >= K {
        format!("{} ({} bytes)", formatted, count)
    } else {
        formatted
    }
}

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

    #[test]
    fn test_format_bytes() {
        assert_eq!(format_bytes(1023, false), "1023 bytes");
        assert_eq!(format_bytes(800_000, false), "781 KiB");
        assert_eq!(format_bytes(12_500_000, false), "11.9 MiB");
        assert_eq!(format_bytes(2_000_000_000, false), "1.86 GiB");
        assert_eq!(format_bytes(-1024, false), "-1.00 KiB");
    }
}
