use std::{collections::HashMap, fmt, io, sync::OnceLock};

use camino::Utf8PathBuf;
use serde::{Deserialize, Serialize};
use thiserror::Error as ThisError;
use tokio::{process, sync::Mutex};

use crate::status::{Satisfying, Status};

static MUTEX: OnceLock<Mutex<()>> = OnceLock::new();

#[derive(Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, rename_all = "lowercase", tag = "type")]
pub(crate) struct Command {
    pub argv: Vec<String>,
    pub env: HashMap<String, String>,
    pub chdir: Option<Utf8PathBuf>,
    #[allow(clippy::struct_field_names)]
    pub command: String,
    pub creates: Option<Utf8PathBuf>,
    pub removes: Option<Utf8PathBuf>,
}
impl fmt::Display for Command {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let mut parts = Vec::<String>::new();
        if let Some(c) = &self.creates {
            parts.push(format!("[ ! -e {c} ] &&"));
        }
        if let Some(r) = &self.removes {
            parts.push(format!("[ -e {r} ] &&"));
        }
        if let Some(c) = &self.chdir {
            parts.push(format!("cd {c} &&"));
        }
        parts.push(self.command.clone());
        if !self.argv.is_empty() {
            parts.extend(self.argv.clone());
        }
        write!(f, "{}", parts.join(" "))
    }
}
impl Command {
    pub async fn execute(&self) -> Result {
        if let Some(p) = &self.creates
            && p.exists()
        {
            return Ok(Status::no_change(format!("{p:?} already created")));
        }
        if let Some(p) = &self.removes
            && !p.exists()
        {
            return Ok(Status::no_change(format!("{p:?} already removed")));
        }

        // we want exactly one "command" to use stdout at a time,
        // at least until we decide how sharing stdout should work
        let mutex = MUTEX.get_or_init(|| Mutex::new(()));
        let _lock = mutex.lock().await;

        let args = if self.argv.is_empty() {
            Vec::<String>::new()
        } else {
            self.argv.clone()
        };
        let cwd = match &self.chdir {
            Some(c) => c.clone(),
            None => crate::env::current_dir(),
        };
        let mut p = process::Command::new(&self.command)
            .args(&args)
            .current_dir(&cwd)
            .envs(self.env.clone())
            .spawn()
            .map_err(|e| Error::CommandBegin {
                cmd: self.command.clone(),
                source: e,
            })?;
        let status = p.wait().await.map_err(|e| Error::CommandWait {
            cmd: self.command.clone(),
            source: e,
        })?;
        if status.success() {
            // for now, assume all successful commands "changed" system state in some way
            Ok(Status::Satisfying(Satisfying::Changed(
                String::from("unknown"),
                format!("{} {}", self.command, args.join(" ")),
            )))
        } else {
            Err(Error::NonZeroExitStatus {
                cmd: self.command.clone(),
            })
        }
    }
}

#[derive(Debug, ThisError)]
pub(crate) enum Error {
    #[error("`{}` could not begin: {}", cmd, source)]
    CommandBegin { cmd: String, source: io::Error },
    #[error("`{}` could not continue: {}", cmd, source)]
    CommandWait { cmd: String, source: io::Error },
    #[error("`{}` exited with non-zero status code", cmd)]
    NonZeroExitStatus { cmd: String },
}

pub(crate) type Result = std::result::Result<Status, Error>;

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

    #[tokio::test]
    async fn changed_after_running_command() {
        let cmd = Command {
            argv: vec![String::from("--version")],
            command: String::from("cargo"),
            ..Default::default()
        };
        match cmd.execute().await {
            Ok(s) => assert_eq!(
                s,
                Status::Satisfying(Satisfying::Changed(
                    String::from("unknown"),
                    String::from("cargo --version")
                ))
            ),
            Err(_) => unreachable!(), // fail
        }
        // TODO: should also test stdout/stderr
    }

    #[tokio::test]
    async fn error_after_running_failed_command() {
        let cmd = Command {
            argv: vec![String::from("--flag-does-not-exist")],
            command: String::from("cargo"),
            ..Default::default()
        };
        if cmd.execute().await.is_ok() {
            unreachable!(); // fail
        }
    }

    #[tokio::test]
    async fn skips_when_creates_file_already_exists() {
        let cmd = Command {
            command: String::from("./throw_if_attempt_to_execute"),
            creates: Some(Utf8PathBuf::from("Cargo.toml")),
            ..Default::default()
        };
        match cmd.execute().await {
            Ok(s) => assert_eq!(
                s,
                Status::Satisfying(Satisfying::NoChange(String::from(
                    r#""Cargo.toml" already created"#
                )))
            ),
            Err(_) => unreachable!(), // fail
        }
    }

    #[tokio::test]
    async fn skips_when_removes_file_already_gone() {
        let cmd = Command {
            command: String::from("./throw_if_attempt_to_execute"),
            removes: Some(Utf8PathBuf::from("does_not_exist.toml")),
            ..Default::default()
        };
        match cmd.execute().await {
            Ok(s) => assert_eq!(
                s,
                Status::Satisfying(Satisfying::NoChange(String::from(
                    r#""does_not_exist.toml" already removed"#
                )))
            ),
            Err(_) => unreachable!(), // fail
        }
    }

    #[tokio::test]
    async fn name_with_command() {
        let cmd = Command {
            command: String::from("foo"),
            ..Default::default()
        };
        let got = format!("{cmd}");
        let want = "foo";
        assert_eq!(got, want);
    }

    #[tokio::test]
    async fn name_with_command_and_argv() {
        let cmd = Command {
            argv: vec![String::from("--bar"), String::from("baz")],
            command: String::from("foo"),
            ..Default::default()
        };
        let got = format!("{cmd}");
        let want = "foo --bar baz";
        assert_eq!(got, want);
    }

    #[tokio::test]
    async fn name_with_command_and_chdir() {
        let cmd = Command {
            chdir: Some(Utf8PathBuf::from("bar")),
            command: String::from("foo"),
            ..Default::default()
        };
        let got = format!("{cmd}");
        let want = "cd bar && foo";
        assert_eq!(got, want);
    }

    #[tokio::test]
    async fn name_with_command_and_creates() {
        let cmd = Command {
            command: String::from("foo"),
            creates: Some(Utf8PathBuf::from("bar")),
            ..Default::default()
        };
        let got = format!("{cmd}");
        let want = "[ ! -e bar ] && foo";
        assert_eq!(got, want);
    }

    #[tokio::test]
    async fn name_with_command_and_removes() {
        let cmd = Command {
            command: String::from("foo"),
            removes: Some(Utf8PathBuf::from("bar")),
            ..Default::default()
        };
        let got = format!("{cmd}");
        let want = "[ -e bar ] && foo";
        assert_eq!(got, want);
    }
}
