#![allow(clippy::bool_to_int_with_if)]
#![allow(clippy::cast_possible_truncation)]
#![allow(clippy::enum_glob_use)]
#![allow(clippy::match_same_arms)]
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::module_name_repetitions)]
#![allow(clippy::needless_pass_by_value)]
#![allow(clippy::redundant_closure_for_method_calls)]
#![allow(clippy::wildcard_imports)]

use clap::builder::NonEmptyStringValueParser;
use clap::error::ErrorKind::MissingRequiredArgument;
use clap::value_parser;
use yuv::color::MatrixCoefficients;
use gifski::{Repeat, Settings};
use std::io::stdin;
use std::io::BufRead;
use std::io::BufReader;
use std::io::IsTerminal;
use std::io::Read;
use std::io::StdinLock;
use std::io::Stdout;

#[cfg(feature = "video")]
mod ffmpeg_source;
mod gif_source;
mod png;
mod source;
mod y4m_source;
use crate::source::Source;

use gifski::progress::{NoProgress, ProgressReporter};

pub type BinResult<T, E = Box<dyn std::error::Error + Send + Sync>> = Result<T, E>;

use clap::{Arg, ArgAction, Command};

use std::env;
use std::fmt;
use std::fs::File;
use std::io;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;

#[cfg(feature = "video")]
const VIDEO_FRAMES_ARG_HELP: &str = "one video file supported by FFmpeg, or multiple PNG image files";
#[cfg(not(feature = "video"))]
const VIDEO_FRAMES_ARG_HELP: &str = "PNG image files for the animation frames, or a .y4m file";

fn main() {
    if let Err(e) = bin_main() {
        eprintln!("error: {e}");
        if let Some(e) = e.source() {
            eprintln!("error: {e}");
        }
        std::process::exit(1);
    }
}

#[allow(clippy::float_cmp)]
fn bin_main() -> BinResult<()> {
    let matches = Command::new(clap::crate_name!())
                        .version(clap::crate_version!())
                        .about("https://gif.ski by Kornel Lesiński")
                        .arg_required_else_help(true)
                        .allow_negative_numbers(true)
                        .arg(Arg::new("output")
                            .long("output")
                            .short('o')
                            .help("Destination file to write to; \"-\" means stdout")
                            .num_args(1)
                            .value_name("a.gif")
                            .value_parser(value_parser!(PathBuf))
                            .required(true))
                        .arg(Arg::new("fps")
                            .long("fps")
                            .short('r')
                            .help("Frame rate of animation. If using PNG files as \
                                   input, this means the speed, as all frames are \
                                   kept.\nIf video is used, it will be resampled to \
                                   this constant rate by dropping and/or duplicating \
                                   frames")
                            .value_parser(value_parser!(f32))
                            .value_name("num")
                            .default_value("20"))
                        .arg(Arg::new("fast-forward")
                            .long("fast-forward")
                            .help("Multiply speed of video by a factor")
                            .value_parser(value_parser!(f32))
                            .value_name("x")
                            .default_value("1"))
                        .arg(Arg::new("fast")
                            .num_args(0)
                            .action(ArgAction::SetTrue)
                            .long("fast")
                            .help("50% faster encoding, but 10% worse quality and larger file size"))
                        .arg(Arg::new("extra")
                            .long("extra")
                            .conflicts_with("fast")
                            .num_args(0)
                            .action(ArgAction::SetTrue)
                            .help("50% slower encoding, but 1% better quality and usually larger file size"))
                        .arg(Arg::new("quality")
                            .long("quality")
                            .short('Q')
                            .value_name("1-100")
                            .value_parser(value_parser!(u8).range(1..=100))
                            .num_args(1)
                            .default_value("90")
                            .help("Lower quality may give smaller file"))
                        .arg(Arg::new("motion-quality")
                            .long("motion-quality")
                            .value_name("1-100")
                            .value_parser(value_parser!(u8).range(1..=100))
                            .num_args(1)
                            .help("Lower values reduce motion"))
                        .arg(Arg::new("lossy-quality")
                            .long("lossy-quality")
                            .value_name("1-100")
                            .value_parser(value_parser!(u8).range(1..=100))
                            .num_args(1)
                            .help("Lower values introduce noise and streaks"))
                        .arg(Arg::new("width")
                            .long("width")
                            .short('W')
                            .num_args(1)
                            .value_parser(value_parser!(u32))
                            .value_name("px")
                            .help("Maximum width.\nBy default anims are limited to about 800x600"))
                        .arg(Arg::new("height")
                            .long("height")
                            .short('H')
                            .num_args(1)
                            .value_parser(value_parser!(u32))
                            .value_name("px")
                            .help("Maximum height (stretches if the width is also set)"))
                        .arg(Arg::new("nosort")
                            .alias("nosort")
                            .long("no-sort")
                            .num_args(0)
                            .action(ArgAction::SetTrue)
                            .hide_short_help(true)
                            .help("Use files exactly in the order given, rather than sorted"))
                        .arg(Arg::new("quiet")
                            .long("quiet")
                            .short('q')
                            .num_args(0)
                            .action(ArgAction::SetTrue)
                            .help("Do not display anything on standard output/console"))
                        .arg(Arg::new("FILES")
                            .help(VIDEO_FRAMES_ARG_HELP)
                            .num_args(1..)
                            .value_parser(NonEmptyStringValueParser::new())
                            .use_value_delimiter(false)
                            .required(true))
                        .arg(Arg::new("repeat")
                            .long("repeat")
                            .help("Number of times the animation is repeated (-1 none, 0 forever or <value> repetitions)")
                            .num_args(1)
                            .value_parser(value_parser!(i16))
                            .value_name("num"))
                        .arg(Arg::new("bounce")
                            .long("bounce")
                            .num_args(0)
                            .action(ArgAction::SetTrue)
                            .hide_short_help(true)
                            .help("Make animation play forwards then backwards"))
                        .arg(Arg::new("fixed-color")
                            .long("fixed-color")
                            .help("Always include this color in the palette")
                            .hide_short_help(true)
                            .num_args(1)
                            .action(ArgAction::Append)
                            .value_parser(parse_colors)
                            .value_name("RGBHEX"))
                        .arg(Arg::new("matte")
                            .long("matte")
                            .help("Background color for semitransparent pixels")
                            .num_args(1)
                            .value_parser(parse_color)
                            .value_name("RGBHEX"))
                        .arg(Arg::new("y4m-color-override")
                            .long("y4m-color-override")
                            .help("The color space of the input YUV4MPEG2 video\n\
                                   Possible values: bt709 fcc bt470bg bt601 smpte240 ycgco\n\
                                   Defaults to bt709 for HD and bt601 for SD resolutions")
                            .num_args(1)
                            .hide_short_help(true)
                            .value_parser(parse_color_space)
                            .value_name("bt709"))
                        .try_get_matches_from(wild::args_os())
                        .unwrap_or_else(|e| {
                            if e.kind() == MissingRequiredArgument && !stdin().is_terminal() {
                                eprintln!("If you're trying to pipe a file, use \"-\" as the input file name");
                            }
                            e.exit()
                        });

    let mut frames: Vec<&str> = matches.get_many::<String>("FILES").ok_or("?")?.map(|s| s.as_str()).collect();
    let bounce = matches.get_flag("bounce");
    if !matches.get_flag("nosort") && frames.len() > 1 {
        frames.sort_by(|a, b| natord::compare(a, b));
    }
    let mut frames: Vec<_> = frames.into_iter().map(PathBuf::from).collect();

    let output_path = DestPath::new(matches.get_one::<PathBuf>("output").ok_or("?")?);
    let width = matches.get_one::<u32>("width").copied();
    let height = matches.get_one::<u32>("height").copied();
    let repeat_int = matches.get_one::<i16>("repeat").copied().unwrap_or(0);
    let repeat = match repeat_int {
        -1 => Repeat::Finite(0),
        0 => Repeat::Infinite,
        _ => Repeat::Finite(repeat_int as u16),
    };

    let extra = matches.get_flag("extra");
    let motion_quality = matches.get_one::<u8>("motion-quality").copied();
    let lossy_quality = matches.get_one::<u8>("lossy-quality").copied();
    let fast = matches.get_flag("fast");
    let settings = Settings {
        width,
        height,
        quality: matches.get_one::<u8>("quality").copied().unwrap_or(100),
        fast,
        repeat,
    };
    let quiet = matches.get_flag("quiet") || output_path == DestPath::Stdout;
    let fps: f32 = matches.get_one::<f32>("fps").copied().ok_or("?")?;
    let speed: f32 = matches.get_one::<f32>("fast-forward").copied().ok_or("?")?;
    let fixed_colors = matches.get_many::<Vec<rgb::RGB8>>("fixed-color");
    let matte = matches.get_one::<rgb::RGB8>("matte");
    let in_color_space = matches.get_one::<MatrixCoefficients>("y4m-color-override").copied();

    let rate = source::Fps { fps, speed };

    if settings.quality < 20 {
        if settings.quality < 1 {
            return Err("Quality too low".into());
        } else if !quiet {
            eprintln!("warning: quality {} will give really bad results", settings.quality);
        }
    } else if settings.quality > 100 {
        return Err("Quality 100 is maximum".into());
    }

    if speed > 1000.0 || speed <= 0.0 {
        return Err("Fast-forward must be 0..1000".into());
    }

    if fps > 100.0 || fps <= 0.0 {
        return Err("100 fps is maximum".into());
    } else if !quiet && fps > 50.0 {
        eprintln!("warning: web browsers support max 50 fps");
    }

    check_if_paths_exist(&frames)?;

    std::thread::scope(move |scope| {

    let (mut collector, mut writer) = gifski::new(settings)?;
    if let Some(fixed_colors) = fixed_colors {
        for f in fixed_colors.flatten() {
            writer.add_fixed_color(*f);
        }
    }
    if let Some(matte) = matte {
        #[allow(deprecated)]
        writer.set_matte_color(*matte);
    }
    if extra {
        #[allow(deprecated)]
        writer.set_extra_effort(true);
    }
    if let Some(motion_quality) = motion_quality {
        #[allow(deprecated)]
        writer.set_motion_quality(motion_quality);
    }
    if let Some(lossy_quality) = lossy_quality {
        #[allow(deprecated)]
        writer.set_lossy_quality(lossy_quality);
    }

    let (decoder_ready_send, decoder_ready_recv) = crossbeam_channel::bounded(1);

    let decode_thread = thread::Builder::new().name("decode".into()).spawn_scoped(scope, move || {
        let mut decoder = if let [path] = &frames[..] {
            if bounce {
                eprintln!("warning: the bounce flag is supported only for individual files, not pipe or video");
            }
            let mut src = if path.as_os_str() == "-" {
                let fd = stdin().lock();
                if fd.is_terminal() {
                    eprintln!("warning: used '-' as the input path, but the stdin is a terminal, not a file.");
                }
                SrcPath::Stdin(BufReader::new(fd))
            } else {
                SrcPath::Path(path.clone())
            };
            match file_type(&mut src).unwrap_or(FileType::Other) {
                FileType::PNG | FileType::JPEG => return Err("Only a single image file was given as an input. This is not enough to make an animation.".into()),
                FileType::GIF => {
                    if !quiet && (width.is_none() && settings.quality > 50) {
                        eprintln!("warning: reading an existing GIF as an input. This can only worsen the quality. Use PNG frames instead.");
                    }
                    Box::new(gif_source::GifDecoder::new(src, rate)?)
                },
                _ if path.is_dir() => {
                    return Err(format!("{} is a directory, not a PNG file", path.display()).into());
                },
                other_type => get_video_decoder(other_type, src, rate, in_color_space, settings)?,
            }
        } else {
            if bounce {
                let mut extra: Vec<_> = frames.iter().skip(1).rev().cloned().collect();
                frames.append(&mut extra);
            }
            if speed != 1.0 {
                eprintln!("warning: --fast-forward option is for videos. It doesn't make sense for images. Use --fps only.");
            }
            let file_type = file_type(&mut SrcPath::Path(frames[0].clone())).unwrap_or(FileType::Other);
            match file_type {
                FileType::JPEG => {
                    return Err("JPEG format is unsuitable for conversion to GIF.\n\n\
                        JPEG's compression artifacts and color space are very problematic for palette-based\n\
                        compression. Please don't use JPEG for making GIF animations. Please re-export\n\
                        your animation using the PNG format.".into())
                },
                FileType::GIF => return unexpected("GIF"),
                FileType::Y4M => return unexpected("Y4M"),
                _ => Box::new(png::Lodecoder::new(frames, rate)),
            }
        };

        decoder_ready_send.send(decoder.total_frames())?;

        decoder.collect(&mut collector)
    })?;

    let mut file_tmp;
    let mut stdio_tmp;
    let mut print_terminal_err = false;
    let out: &mut dyn io::Write = match output_path {
        DestPath::Path(path) => {
            file_tmp = File::create(path)
                .map_err(|err| {
                    let mut msg = format!("Can't write to \"{}\": {err}", path.display());
                    let canon = path.canonicalize();
                    if let Some(parent) = canon.as_deref().unwrap_or(path).parent() {
                        if parent.as_os_str() != "" {
                            use std::fmt::Write;
                            match parent.try_exists() {
                                Ok(true) => {},
                                Ok(false) => {
                                    let _ = write!(&mut msg, " (directory \"{}\" doesn't exist)", parent.display());
                                },
                                Err(err) => {
                                    let _ = write!(&mut msg, " (directory \"{}\" is not accessible: {err})", parent.display());
                                },
                            }
                        }
                    }
                    msg
                })?;
            &mut file_tmp
        },
        DestPath::Stdout => {
            stdio_tmp = io::stdout().lock();
            print_terminal_err = stdio_tmp.is_terminal();
            &mut stdio_tmp
        },
    };

    let total_frames = match decoder_ready_recv.recv() {
        Ok(t) => t,
        Err(_) => {
            // if the decoder failed to start,
            // writer won't have any interesting error to report
            return decode_thread.join().map_err(panic_err)?;
        }
    };

    let mut pb;
    let mut nopb = NoProgress {};
    let progress: &mut dyn ProgressReporter = if quiet {
        &mut nopb
    } else {
        pb = ProgressBar::new(total_frames);
        &mut pb
    };

    if print_terminal_err {
        eprintln!("warning: used '-' as the output path, but the stdout is a terminal, not a file");
        std::thread::sleep(Duration::from_secs(3));
    }
    let write_result = writer.write(io::BufWriter::new(out), progress);
    let thread_result = decode_thread.join().map_err(panic_err)?;
    check_errors(write_result, thread_result)?;
    progress.done(&format!("gifski created {output_path}"));

    Ok(())
    })
}

fn check_errors(err1: Result<(), gifski::Error>, err2: BinResult<()>) -> BinResult<()> {
    use gifski::Error::*;
    match err1 {
        Ok(()) => err2,
        Err(ThreadSend | Aborted | NoFrames) if err2.is_err() => err2,
        Err(err1) => Err(err1.into()),
    }
}

#[cold]
fn unexpected(ftype: &'static str) -> BinResult<()> {
    Err(format!("Too many arguments. Unexpectedly got a {ftype} as an input frame. Only PNG format is supported for individual frames.").into())
}

#[cold]
fn panic_err(err: Box<dyn std::any::Any + Send>) -> String {
    err.downcast::<String>().map(|s| *s)
    .unwrap_or_else(|e| e.downcast_ref::<&str>().copied().unwrap_or("panic").to_owned())
}

fn parse_color(c: &str) -> Result<rgb::RGB8, String> {
    let c = c.trim_matches(|c: char| c.is_ascii_whitespace());
    let c = c.strip_prefix('#').unwrap_or(c);

    if c.len() != 6 {
        return Err(format!("color must be 6-char hex format, not '{c}'"));
    }
    let mut c = c.as_bytes().chunks_exact(2)
        .map(|c| u8::from_str_radix(std::str::from_utf8(c).unwrap_or_default(), 16).map_err(|e| e.to_string()));
    Ok(rgb::RGB8::new(
        c.next().ok_or_else(String::new)??,
        c.next().ok_or_else(String::new)??,
        c.next().ok_or_else(String::new)??,
    ))
}

fn parse_colors(colors: &str) -> Result<Vec<rgb::RGB8>, String> {
    colors.split([' ', ','])
        .filter(|c| !c.is_empty())
        .map(parse_color)
        .collect()
}

#[test]
fn color_parser() {
    assert_eq!(parse_colors("#123456 78abCD,, ,").unwrap(), vec![rgb::RGB8::new(0x12, 0x34, 0x56), rgb::RGB8::new(0x78, 0xab, 0xcd)]);
    assert!(parse_colors("#12345").is_err());
}

fn parse_color_space(value: &str) -> Result<MatrixCoefficients, String> {
    let value = value.to_lowercase();
    let value = value.trim();
    let matrix = match value {
        "bt709" => MatrixCoefficients::BT709,
        "fcc" => MatrixCoefficients::FCC,
        "bt470bg" => MatrixCoefficients::BT470BG,
        "bt601" => MatrixCoefficients::BT601,
        "smpte240" => MatrixCoefficients::SMPTE240,
        "ycgco" => MatrixCoefficients::YCgCo,
        _ => return Err("unsupported color space".into()),
    };
    Ok(matrix)
}

#[allow(clippy::upper_case_acronyms)]
#[derive(PartialEq)]
enum FileType {
    PNG, GIF, JPEG, Y4M, Other,
}

fn file_type(src: &mut SrcPath) -> BinResult<FileType> {
    let mut buf = [0; 4];
    match src {
        SrcPath::Path(path) => match path.extension() {
            Some(e) if e.eq_ignore_ascii_case("y4m") => return Ok(FileType::Y4M),
            Some(e) if e.eq_ignore_ascii_case("png") => return Ok(FileType::PNG),
            _ => {
                let mut file = std::fs::File::open(path)?;
                file.read_exact(&mut buf)?;
            },
        },
        SrcPath::Stdin(stdin) => {
            let buf_in = stdin.fill_buf()?;
            let max_len = buf_in.len().min(4);
            buf[..max_len].copy_from_slice(&buf_in[..max_len]);
            // don't consume
        },
    }

    if &buf == b"\x89PNG" {
        return Ok(FileType::PNG);
    }
    if &buf == b"GIF8" {
        return Ok(FileType::GIF);
    }
    if &buf == b"YUV4" {
        return Ok(FileType::Y4M);
    }
    if buf[..2] == [0xFF, 0xD8] {
        return Ok(FileType::JPEG);
    }
    Ok(FileType::Other)
}

fn check_if_paths_exist(paths: &[PathBuf]) -> BinResult<()> {
    for path in paths {
        // stdin is ok
        if path.as_os_str() == "-" && paths.len() == 1 {
            break;
        }
        let mut msg = match path.try_exists() {
            Ok(true) => continue,
            Ok(false) => format!("Unable to find the input file: \"{}\"", path.display()),
            Err(err) => format!("Unable to access the input file \"{}\": {err}", path.display()),
        };
        let canon = path.canonicalize();
        if let Some(parent) = canon.as_deref().unwrap_or(path).parent() {
            if parent.as_os_str() != "" && matches!(path.try_exists(), Ok(false)) {
                use std::fmt::Write;
                if msg.len() > 80 {
                    msg.push('\n');
                }
                write!(&mut msg, " (directory \"{}\" doesn't exist either)", parent.display())?;
            }
        }
        if path.to_str().is_some_and(|p| p.contains(['*', '?', '['])) {
            msg += "\nThe wildcard pattern did not match any files.";
        } else if path.is_relative() {
            use std::fmt::Write;
            write!(&mut msg, " (searched in \"{}\")", env::current_dir()?.display())?;
        }
        if path.extension() == Some("gif".as_ref()) {
            msg = format!("\nDid you mean to use -o \"{}\" to specify it as the output file instead?", path.display());
        }
        return Err(msg.into());
    }
    Ok(())
}

#[derive(PartialEq)]
enum DestPath<'a> {
    Path(&'a Path),
    Stdout,
}

enum SrcPath {
    Path(PathBuf),
    Stdin(BufReader<StdinLock<'static>>),
}

impl<'a> DestPath<'a> {
    pub fn new(path: &'a Path) -> Self {
        if path.as_os_str() == "-" {
            Self::Stdout
        } else {
            Self::Path(Path::new(path))
        }
    }
}

impl fmt::Display for DestPath<'_> {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Self::Path(orig_path) => {
                let abs_path = dunce::canonicalize(orig_path);
                abs_path.as_ref().map(|p| p.as_path()).unwrap_or(orig_path).display().fmt(f)
            },
            Self::Stdout => f.write_str("stdout"),
        }
    }
}

#[cfg(feature = "video")]
fn get_video_decoder(ftype: FileType, src: SrcPath, fps: source::Fps, in_color_space: Option<MatrixCoefficients>, settings: Settings) -> BinResult<Box<dyn Source>> {
    Ok(if ftype == FileType::Y4M {
        Box::new(y4m_source::Y4MDecoder::new(src, fps, in_color_space)?)
    } else {
        Box::new(ffmpeg_source::FfmpegDecoder::new(src, fps, settings)?)
    })
}

#[cfg(not(feature = "video"))]
#[cold]
fn get_video_decoder(ftype: FileType, src: SrcPath, fps: source::Fps, in_color_space: Option<MatrixCoefficients>, _: Settings) -> BinResult<Box<dyn Source>> {
    if ftype == FileType::Y4M {
        Ok(Box::new(y4m_source::Y4MDecoder::new(src, fps, in_color_space)?))
    } else {
        let path = match &src {
            SrcPath::Path(path) => path,
            SrcPath::Stdin(_) => Path::new("video.mp4"),
        };
        let rel_path = path.file_name().map_or(path, Path::new);
        Err(format!(r#"Video support is permanently disabled in this distribution of gifski.

The only 'video' format supported at this time is YUV4MPEG2, which can be piped from ffmpeg:

    ffmpeg -i "{src}" -f yuv4mpegpipe - | gifski -o "{gif}" -

To enable full video decoding you need to recompile gifski from source.
https://github.com/imageoptim/gifski

Alternatively, use ffmpeg or other tool to export PNG frames, and then specify
the PNG files as input for this executable. Instructions on https://gif.ski
"#,
src = path.display(),
gif = rel_path.with_extension("gif").display()
).into())
    }
}

struct ProgressBar {
    pb: pbr::ProgressBar<Stdout>,
    frames: u64,
    total: Option<u64>,
    previous_estimate: u64,
    displayed_estimate: u64,
}
impl ProgressBar {
    fn new(total: Option<u64>) -> Self {
        let mut pb = pbr::ProgressBar::new(total.unwrap_or(100));
        pb.show_speed = false;
        pb.show_percent = false;
        pb.format(" #_. ");
        pb.message("Frame ");
        pb.set_max_refresh_rate(Some(Duration::from_millis(250)));
        Self {
            pb, frames: 0, total, previous_estimate: 0, displayed_estimate: 0,
        }
    }
}

impl ProgressReporter for ProgressBar {
    fn increase(&mut self) -> bool {
        self.frames += 1;
        if self.total.is_none() {
            self.pb.total = (self.frames + 50).max(100);
        }
        self.pb.inc();
        true
    }

    fn written_bytes(&mut self, bytes: u64) {
        let min_frames = self.total.map_or(10, |t| (t / 16).clamp(5, 50));
        if self.frames > min_frames {
            let total_size = bytes * self.pb.total / self.frames;
            let new_estimate = if total_size >= self.previous_estimate { total_size } else { (self.previous_estimate + total_size) / 2 };
            self.previous_estimate = new_estimate;
            if self.displayed_estimate.abs_diff(new_estimate) > new_estimate / 10 {
                self.displayed_estimate = new_estimate;
                let (num, unit, x) = if new_estimate > 1_000_000 {
                    (new_estimate as f64 / 1_000_000., "MB", if new_estimate > 10_000_000 { 0 } else { 1 })
                } else {
                    (new_estimate as f64 / 1_000., "KB", 0)
                };
                self.pb.message(&format!("{num:.x$}{unit} GIF; Frame "));
            }
        }
    }

    fn done(&mut self, msg: &str) {
        self.pb.finish_print(msg);
    }
}
