use std::path::PathBuf;
use std::process::ExitCode;

use bone_jig::{RunResult, RunStatus, Scenario, ScenarioSource, Step, StepOutcome, run_scenario};
use bone_render::{PixelDiff, PixelDiffThreshold, decode_png, encode_png_rgba};
use thiserror::Error;

const USAGE: &str = "usage:
  bone-jig run <scenario.ron|-> --out <dir> [--document <part>]
  bone-jig diff <a.png> <b.png> [--out <diff.png>] [--threshold <0-255>]";

const EXIT_FAILED: u8 = 1;
const EXIT_USAGE: u8 = 2;
const EXIT_ERROR: u8 = 3;

#[derive(Debug, PartialEq, Eq, Error)]
enum CliError {
    #[error("missing command")]
    MissingCommand,
    #[error("unknown command: {0}")]
    UnknownCommand(String),
    #[error("missing argument: {0}")]
    MissingArg(&'static str),
    #[error("unexpected argument: {0}")]
    UnexpectedArg(String),
    #[error("threshold {0:?} is not an integer in 0-255")]
    BadThreshold(String),
}

#[derive(Debug, PartialEq)]
enum Command {
    Run {
        scenario: ScenarioSource,
        out: PathBuf,
        document: Option<PathBuf>,
    },
    Diff {
        left: PathBuf,
        right: PathBuf,
        out: Option<PathBuf>,
        threshold: PixelDiffThreshold,
    },
}

fn parse(args: &[String]) -> Result<Command, CliError> {
    match args {
        [] => Err(CliError::MissingCommand),
        [command, rest @ ..] if command == "run" => parse_run(rest, RunArgs::default()),
        [command, rest @ ..] if command == "diff" => parse_diff(rest, DiffArgs::default()),
        [command, ..] => Err(CliError::UnknownCommand(command.clone())),
    }
}

#[derive(Default)]
struct RunArgs {
    scenario: Option<ScenarioSource>,
    out: Option<PathBuf>,
    document: Option<PathBuf>,
}

fn scenario_source(arg: &str) -> ScenarioSource {
    if arg == "-" {
        ScenarioSource::Stdin
    } else {
        ScenarioSource::Path(PathBuf::from(arg))
    }
}

fn parse_run(args: &[String], acc: RunArgs) -> Result<Command, CliError> {
    match args {
        [] => Ok(Command::Run {
            scenario: acc.scenario.ok_or(CliError::MissingArg("<scenario.ron>"))?,
            out: acc.out.ok_or(CliError::MissingArg("--out <dir>"))?,
            document: acc.document,
        }),
        [flag, value, rest @ ..] if flag == "--out" => parse_run(
            rest,
            RunArgs {
                out: Some(PathBuf::from(value)),
                ..acc
            },
        ),
        [flag, value, rest @ ..] if flag == "--document" => parse_run(
            rest,
            RunArgs {
                document: Some(PathBuf::from(value)),
                ..acc
            },
        ),
        [flag] if flag == "--out" => Err(CliError::MissingArg("--out <dir>")),
        [flag] if flag == "--document" => Err(CliError::MissingArg("--document <part>")),
        [positional, rest @ ..] if acc.scenario.is_none() && !positional.starts_with("--") => {
            parse_run(
                rest,
                RunArgs {
                    scenario: Some(scenario_source(positional)),
                    ..acc
                },
            )
        }
        [unexpected, ..] => Err(CliError::UnexpectedArg(unexpected.clone())),
    }
}

#[derive(Default)]
struct DiffArgs {
    images: Vec<PathBuf>,
    out: Option<PathBuf>,
    threshold: Option<PixelDiffThreshold>,
}

fn parse_threshold(value: &str) -> Result<PixelDiffThreshold, CliError> {
    value
        .parse::<u8>()
        .map(|n| PixelDiffThreshold::new(f64::from(n) / 255.0))
        .map_err(|_| CliError::BadThreshold(value.to_owned()))
}

fn parse_diff(args: &[String], acc: DiffArgs) -> Result<Command, CliError> {
    match args {
        [] => match <[PathBuf; 2]>::try_from(acc.images) {
            Ok([left, right]) => Ok(Command::Diff {
                left,
                right,
                out: acc.out,
                threshold: acc.threshold.unwrap_or(PixelDiffThreshold::EXACT),
            }),
            Err(_) => Err(CliError::MissingArg("<a.png> <b.png>")),
        },
        [flag, value, rest @ ..] if flag == "--out" => parse_diff(
            rest,
            DiffArgs {
                out: Some(PathBuf::from(value)),
                ..acc
            },
        ),
        [flag, value, rest @ ..] if flag == "--threshold" => parse_diff(
            rest,
            DiffArgs {
                threshold: Some(parse_threshold(value)?),
                ..acc
            },
        ),
        [flag] if flag == "--out" => Err(CliError::MissingArg("--out <diff.png>")),
        [flag] if flag == "--threshold" => Err(CliError::MissingArg("--threshold <0-255>")),
        [positional, rest @ ..] if acc.images.len() < 2 && !positional.starts_with("--") => {
            let mut images = acc.images;
            images.push(PathBuf::from(positional));
            parse_diff(rest, DiffArgs { images, ..acc })
        }
        [unexpected, ..] => Err(CliError::UnexpectedArg(unexpected.clone())),
    }
}

fn main() -> ExitCode {
    let args: Vec<String> = std::env::args().skip(1).collect();
    match parse(&args) {
        Ok(Command::Run {
            scenario,
            out,
            document,
        }) => execute_run(&scenario, &out, document),
        Ok(Command::Diff {
            left,
            right,
            out,
            threshold,
        }) => execute_diff(&left, &right, out.as_deref(), threshold),
        Err(e) => {
            eprintln!("{e}");
            eprintln!("{USAGE}");
            ExitCode::from(EXIT_USAGE)
        }
    }
}

fn with_document(scenario: Scenario, document: Option<PathBuf>) -> Scenario {
    match document {
        None => scenario,
        Some(path) => Scenario {
            steps: std::iter::once(Step::OpenDocument(path))
                .chain(scenario.steps)
                .collect(),
        },
    }
}

fn report_failure(result: &RunResult) {
    result
        .steps
        .iter()
        .enumerate()
        .filter_map(|(index, record)| match &record.outcome {
            StepOutcome::Failed(message) => Some((index, message)),
            StepOutcome::Ok => None,
        })
        .for_each(|(index, message)| eprintln!("step {index} failed: {message}"));
}

fn execute_run(
    source: &ScenarioSource,
    out: &std::path::Path,
    document: Option<PathBuf>,
) -> ExitCode {
    let scenario = match Scenario::load(source) {
        Ok(s) => with_document(s, document),
        Err(e) => {
            eprintln!("{e}");
            return ExitCode::from(EXIT_ERROR);
        }
    };
    match run_scenario(&scenario, out, &mut std::io::stdout().lock()) {
        Ok(result) if result.status == RunStatus::Passed => ExitCode::SUCCESS,
        Ok(result) => {
            report_failure(&result);
            ExitCode::from(EXIT_FAILED)
        }
        Err(e) => {
            eprintln!("{e}");
            ExitCode::from(EXIT_ERROR)
        }
    }
}

fn load_png(path: &std::path::Path) -> Result<(bone_render::ViewportExtent, Vec<u8>), String> {
    let bytes = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
    decode_png(&bytes).map_err(|e| format!("decode {}: {e}", path.display()))
}

fn diff_image(left: &[u8], right: &[u8], limit: u8) -> Vec<u8> {
    left.chunks_exact(4)
        .zip(right.chunks_exact(4))
        .flat_map(|(a, b)| {
            let over = a
                .iter()
                .zip(b.iter())
                .map(|(x, y)| x.abs_diff(*y))
                .max()
                .unwrap_or(0)
                > limit;
            if over {
                [255, 0, 0, 255]
            } else {
                [0, 0, 0, 255]
            }
        })
        .collect()
}

fn execute_diff(
    left: &std::path::Path,
    right: &std::path::Path,
    out: Option<&std::path::Path>,
    threshold: PixelDiffThreshold,
) -> ExitCode {
    let ((extent_left, rgba_left), (extent_right, rgba_right)) =
        match (load_png(left), load_png(right)) {
            (Ok(l), Ok(r)) => (l, r),
            (Err(e), _) | (_, Err(e)) => {
                eprintln!("{e}");
                return ExitCode::from(EXIT_ERROR);
            }
        };
    if extent_left != extent_right {
        eprintln!("size mismatch: {extent_left} vs {extent_right}");
        return ExitCode::from(EXIT_FAILED);
    }
    let report = match PixelDiff::compare_bytes(extent_left, &rgba_left, &rgba_right, threshold) {
        Ok(r) => r,
        Err(e) => {
            eprintln!("{e}");
            return ExitCode::from(EXIT_ERROR);
        }
    };
    println!(
        "pixels over threshold: {} of {}",
        report.over_threshold(),
        extent_left.pixel_count(),
    );
    if let Some(worst) = report.worst() {
        println!(
            "worst: ({},{}) delta={}",
            worst.x(),
            worst.y(),
            worst.max_delta(),
        );
    }
    if let Some(path) = out {
        let rgba = diff_image(&rgba_left, &rgba_right, threshold.as_u8());
        let written = encode_png_rgba(extent_left, &rgba)
            .map_err(|e| e.to_string())
            .and_then(|png| {
                std::fs::write(path, png).map_err(|e| format!("write {}: {e}", path.display()))
            });
        if let Err(e) = written {
            eprintln!("{e}");
            return ExitCode::from(EXIT_ERROR);
        }
    }
    if report.is_clean() {
        ExitCode::SUCCESS
    } else {
        ExitCode::from(EXIT_FAILED)
    }
}

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

    fn strings(parts: &[&str]) -> Vec<String> {
        parts.iter().map(|s| (*s).to_owned()).collect()
    }

    #[test]
    fn run_parses_scenario_out_and_document() {
        let cmd = parse(&strings(&[
            "run",
            "shell.ron",
            "--out",
            "artifacts",
            "--document",
            "clamp.step",
        ]));
        assert_eq!(
            cmd,
            Ok(Command::Run {
                scenario: ScenarioSource::Path(PathBuf::from("shell.ron")),
                out: PathBuf::from("artifacts"),
                document: Some(PathBuf::from("clamp.step")),
            }),
        );
    }

    #[test]
    fn run_dash_reads_stdin() {
        let cmd = parse(&strings(&["run", "-", "--out", "artifacts"]));
        assert_eq!(
            cmd,
            Ok(Command::Run {
                scenario: ScenarioSource::Stdin,
                out: PathBuf::from("artifacts"),
                document: None,
            }),
        );
    }

    #[test]
    fn run_requires_out() {
        assert_eq!(
            parse(&strings(&["run", "shell.ron"])),
            Err(CliError::MissingArg("--out <dir>")),
        );
    }

    #[test]
    fn diff_parses_threshold_and_out() {
        let cmd = parse(&strings(&[
            "diff",
            "a.png",
            "b.png",
            "--threshold",
            "8",
            "--out",
            "d.png",
        ]));
        assert_eq!(
            cmd,
            Ok(Command::Diff {
                left: PathBuf::from("a.png"),
                right: PathBuf::from("b.png"),
                out: Some(PathBuf::from("d.png")),
                threshold: PixelDiffThreshold::new(8.0 / 255.0),
            }),
        );
    }

    #[test]
    fn diff_defaults_to_exact_threshold() {
        let cmd = parse(&strings(&["diff", "a.png", "b.png"]));
        assert_eq!(
            cmd,
            Ok(Command::Diff {
                left: PathBuf::from("a.png"),
                right: PathBuf::from("b.png"),
                out: None,
                threshold: PixelDiffThreshold::EXACT,
            }),
        );
    }

    #[test]
    fn diff_rejects_bad_threshold() {
        assert_eq!(
            parse(&strings(&["diff", "a.png", "b.png", "--threshold", "256"])),
            Err(CliError::BadThreshold("256".to_owned())),
        );
        assert_eq!(
            parse(&strings(&["diff", "a.png"])),
            Err(CliError::MissingArg("<a.png> <b.png>")),
        );
    }

    #[test]
    fn unknown_command_is_rejected() {
        assert_eq!(
            parse(&strings(&["serve"])),
            Err(CliError::UnknownCommand("serve".to_owned())),
        );
        assert_eq!(parse(&[]), Err(CliError::MissingCommand));
    }

    #[test]
    fn with_document_prepends_an_open_step() {
        let scenario = Scenario {
            steps: vec![Step::Advance(bone_app::FrameCount::ONE)],
        };
        let amended = with_document(scenario, Some(PathBuf::from("clamp.step")));
        assert_eq!(
            amended.steps[0],
            Step::OpenDocument(PathBuf::from("clamp.step")),
        );
        assert_eq!(amended.steps.len(), 2);
    }

    #[test]
    fn diff_image_marks_only_over_threshold_pixels() {
        let left = [0_u8, 0, 0, 255, 100, 0, 0, 255];
        let right = [0_u8, 0, 0, 255, 0, 0, 0, 255];
        let image = diff_image(&left, &right, 8);
        assert_eq!(image, vec![0, 0, 0, 255, 255, 0, 0, 255]);
    }
}
