use std::{
    error::Error,
    ffi::{OsStr, OsString},
    process::{Command, Output},
    thread::sleep,
    time::{Duration, Instant},
};

use clap::Parser;
use duration_str::{DError, parse_std};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};

const MAX_STDERR_LINES: usize = 5;

fn main() {
    if let Err(err) = fallible_main() {
        eprintln!("ERROR: {err}");
        let mut underlying = err.source();
        while let Some(err) = underlying {
            eprintln!("caused by: {err}");
            underlying = err.source();
        }
        std::process::exit(1);
    }
}

fn fallible_main() -> Result<(), Ouch> {
    let args = Args::parse();

    let mut stop_conditions = args.stop_conditions();
    let mut progress = Progress::from(&args);
    loop {
        progress.start_attempt();
        let mut cmd = args.to_cmd();
        let output = cmd.output().map_err(|err| Ouch::at_all(&args.argv0, err))?;
        progress.attempt_output(&output);

        for cond in stop_conditions.iter_mut() {
            if cond.stop_here(&output) {
                progress.finish();
                return Ok(());
            }
        }

        if let Some(wait) = args.wait {
            sleep(wait);
        }
    }
}

// From <https://stackoverflow.com/questions/72313616/using-claps-deriveparser-how-can-i-accept-a-stdtimeduration>
fn parse_duration(arg: &str) -> Result<Duration, String> {
    duration_str::parse(arg)
}

/// Run command repeatedly.
#[derive(Debug, Parser)]
#[clap(version)]
struct Args {
    /// Name of command.
    argv0: OsString,

    /// Arguments to command. Add `--` argument before name of command
    /// if any argument starts with a dash.
    argv_rest: Vec<OsString>,

    /// Wait SECS seconds between running command.
    #[clap(long, value_parser = parse_duration)]
    wait: Option<Duration>,

    /// Repeat until command fails. Default is to repeat until command succeeds.
    #[clap(long)]
    until_failure: bool,

    /// Try at most N times.
    #[clap(long, short = 'n', value_name = "N")]
    max_tries: Option<u64>,

    /// Repeat until DURATION time has passed. Suffixes: s, m, h, etc.
    ///
    /// See https://docs.rs/duration-str/latest/duration_str/ for all suffixes.
    #[clap(long, short = 't', value_name = "DURATION", value_parser = Args::parse_duration)]
    max_time: Option<Duration>,
}

impl Args {
    fn parse_duration(arg: &str) -> Result<Duration, DError> {
        parse_std(arg).map_err(DError::ParseError)
    }

    fn to_cmd(&self) -> Command {
        let mut cmd = Command::new(&self.argv0);
        cmd.args(&self.argv_rest);
        cmd
    }

    fn stop_conditions(&self) -> Vec<StopCond> {
        let mut conds: Vec<StopCond> = vec![];

        match (self.until_failure, self.max_tries) {
            (false, None) => conds.push(StopCond::OnSuccess),
            (true, None) => conds.push(StopCond::OnFailure),
            (false, Some(max)) => conds.push(StopCond::AfterReps { done: 0, max }),
            (true, Some(max)) => {
                conds.push(StopCond::OnFailure);
                conds.push(StopCond::AfterReps { done: 0, max });
            }
        }

        if let Some(t) = &self.max_time {
            conds.push(StopCond::AfterTime {
                end_at: Instant::now() + *t,
            });
        }

        conds
    }
}

#[derive(Debug, Eq, PartialEq)]
enum StopCond {
    AfterReps { done: u64, max: u64 },
    AfterTime { end_at: Instant },
    OnFailure,
    OnSuccess,
}

impl StopCond {
    fn stop_here(&mut self, output: &Output) -> bool {
        match self {
            Self::AfterReps { done, max } => {
                *done += 1;
                done >= max
            }
            Self::AfterTime { end_at } => Instant::now() >= *end_at,
            Self::OnFailure => !output.status.success(),
            Self::OnSuccess => output.status.success(),
        }
    }
}

struct Progress {
    #[allow(dead_code)]
    bar: MultiProgress,
    attempt_bar: ProgressBar,
    previous_bar: ProgressBar,
    stderr_lines: Vec<ProgressBar>,
}

impl Progress {
    fn start_attempt(&mut self) {
        self.attempt_bar.inc(1);
    }

    fn attempt_output(&mut self, output: &Output) {
        let msg = if let Some(n) = output.status.code() {
            format!("exit code {n}")
        } else {
            String::from("?")
        };
        self.previous_bar.set_message(msg);

        for line_bar in self.stderr_lines.iter().skip(MAX_STDERR_LINES) {
            line_bar.set_message("");
        }
        let lines = String::from_utf8_lossy(&output.stderr).to_string();
        for (i, line) in lines.lines().take(MAX_STDERR_LINES).enumerate() {
            if let Some(line_bar) = self.stderr_lines.get(i) {
                let msg = format!("  {}", line);
                line_bar.set_message(msg);
            }
        }
    }

    fn finish(&self) {
        self.bar.clear().ok();
    }
}

impl From<&Args> for Progress {
    fn from(args: &Args) -> Self {
        let bar = MultiProgress::new();

        let attempt_bar = match (args.max_tries, args.max_time) {
            (None, None) => ProgressBar::no_length()
                .with_style(ProgressStyle::with_template("attempt {pos}").unwrap()),
            (Some(n), None) => ProgressBar::new(n).with_style(
                ProgressStyle::with_template("attempt {pos}/{len} {wide_bar}").unwrap(),
            ),
            (None, Some(_t)) => ProgressBar::no_length().with_style(
                ProgressStyle::with_template("elapsed {elapsed}, attempt {pos}").unwrap(),
            ),
            (Some(n), Some(_t)) => ProgressBar::new(n).with_style(
                ProgressStyle::with_template("elapsed {elapsed}, attempt {pos}/{len} {wide_bar}")
                    .unwrap(),
            ),
        };
        bar.add(attempt_bar.clone());

        let previous_bar = ProgressBar::no_length()
            .with_style(ProgressStyle::with_template("previous attempt: {msg}").unwrap());
        bar.add(previous_bar.clone());

        let mut stderr_lines = vec![];
        for _ in 0..MAX_STDERR_LINES {
            let line_bar =
                ProgressBar::no_length().with_style(ProgressStyle::with_template("{msg}").unwrap());
            bar.add(line_bar.clone());
            stderr_lines.push(line_bar);
        }

        Self {
            bar,
            attempt_bar,
            previous_bar,
            stderr_lines,
        }
    }
}

#[derive(Debug, thiserror::Error)]
enum Ouch {
    #[error("failed to run command {argv0:?} at all")]
    AtAll {
        argv0: OsString,
        source: std::io::Error,
    },
}

impl Ouch {
    fn at_all(argv0: &OsStr, source: std::io::Error) -> Self {
        Self::AtAll {
            argv0: argv0.to_os_string(),
            source,
        }
    }
}
