pub(crate) mod command;
pub(crate) mod fake;
pub(crate) mod file;
pub(crate) mod git;
pub(crate) mod installercargo;
pub(crate) mod installergo;
pub(crate) mod installernpm;

use std::{cell::Cell, convert::TryFrom, fmt, time::Instant};

use serde::{Deserialize, Serialize};
use thiserror::Error as ThisError;
use uuid::Uuid;

use crate::status::Status;
use command::Command;
use fake::Fake;
use file::File;
use git::Git;
use installercargo::InstallerCargo;
use installergo::InstallerGo;
use installernpm::InstallerNpm;

#[derive(Debug, ThisError)]
pub(crate) enum Error {
    #[error(transparent)]
    CommandJob {
        #[from]
        source: command::Error,
    },
    #[error(transparent)]
    FakeJob {
        #[from]
        source: fake::Error,
    },
    #[error(transparent)]
    FileJob {
        #[from]
        source: file::Error,
    },
    #[error(transparent)]
    GitJob {
        #[from]
        source: git::Error,
    },
    #[error(transparent)]
    InstallerCargoJob {
        #[from]
        source: installercargo::Error,
    },
    #[error(transparent)]
    InstallerGoJob {
        #[from]
        source: installergo::Error,
    },
    #[error(transparent)]
    InstallerNpmJob {
        #[from]
        source: installernpm::Error,
    },
    #[allow(dead_code)] // TODO: fake test-only errors should not be here
    #[error("fake test-only error")]
    SomethingBad,
    #[error(transparent)]
    TomlParse {
        #[from]
        source: toml::de::Error,
    },
    #[error(transparent)]
    TomlStringify {
        #[from]
        source: toml::ser::Error,
    },
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct History {
    pub created: Instant,
    pub updated: Cell<Instant>,
}
impl Default for History {
    fn default() -> Self {
        let now = Instant::now();
        Self {
            created: now,
            updated: Cell::new(now),
        }
    }
}

#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, rename_all = "lowercase", tag = "type")]
pub(crate) struct Job {
    #[serde(skip)]
    pub history: History,

    #[serde(flatten)]
    pub metadata: Metadata,

    #[serde(flatten)]
    pub spec: Spec,
}
impl Default for Job {
    fn default() -> Self {
        Self {
            history: History::default(),
            metadata: Metadata::default(),
            spec: Spec::Fake(Fake::default()),
        }
    }
}
impl Job {
    pub(crate) async fn execute(&self) -> Result {
        let result = match &self.spec {
            Spec::Command(j) => j.execute().await.map_err(Error::from),
            Spec::Fake(j) => j.execute().await.map_err(Error::from),
            Spec::File(j) => j.execute().await.map_err(Error::from),
            Spec::Git(j) => j.execute().await.map_err(Error::from),
            Spec::InstallerCargo(j) => j.execute().await.map_err(Error::from),
            Spec::InstallerGo(j) => j.execute().await.map_err(Error::from),
            Spec::InstallerNpm(j) => j.execute().await.map_err(Error::from),
        };
        self.history.updated.set(Instant::now());
        result
    }

    pub(crate) fn when(&self) -> bool {
        self.metadata.when
    }
}
impl fmt::Display for Job {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let name = self.metadata.name.clone();
        write!(f, "{name}")
    }
}
impl TryFrom<&str> for Job {
    type Error = Error;
    fn try_from(source: &str) -> std::result::Result<Self, Error> {
        toml::from_str(source).map_err(Error::from)
    }
}

#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default)]
pub(crate) struct Metadata {
    pub name: String,
    pub needs: Vec<String>,
    pub tags: Vec<String>,

    #[serde(deserialize_with = "crate::convert::into_bool")]
    pub when: bool,
}
impl Default for Metadata {
    fn default() -> Self {
        Self {
            name: generate_name(),
            needs: vec![],
            tags: vec![],
            when: true,
        }
    }
}

#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase", tag = "type")]
pub(crate) enum Spec {
    Command(Command),
    Fake(Fake),
    File(File),
    Git(Git),
    InstallerCargo(InstallerCargo),
    InstallerGo(InstallerGo),
    InstallerNpm(InstallerNpm),
}
impl fmt::Display for Spec {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match &self {
            Spec::Command(j) => write!(f, "{j}"),
            Spec::Fake(j) => write!(f, "{j}"),
            Spec::File(j) => write!(f, "{j}"),
            Spec::Git(j) => write!(f, "{j}"),
            Spec::InstallerCargo(j) => write!(f, "{j}"),
            Spec::InstallerGo(j) => write!(f, "{j}"),
            Spec::InstallerNpm(j) => write!(f, "{j}"),
        }
    }
}

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

fn generate_name() -> String {
    format!("{}", Uuid::new_v4().hyphenated())
}

#[cfg(test)]
mod tests {
    use std::{collections::HashMap, num::NonZeroU32, time::Duration};

    use camino::Utf8PathBuf;
    use gix_url::Url;

    use file::FileState;
    use installercargo::DesiredState as DesiredStateCargo;
    use installergo::DesiredState as DesiredStateGo;
    use installernpm::DesiredState as DesiredStateNpm;

    use super::*;

    #[test]
    fn command_toml() -> std::result::Result<(), Error> {
        let input = r#"
            name = "run something"
            type = "command"
            argv = [ "foo" ]
            command = "something"
            env = { VARIABLE = "value" }
            "#;

        let got = Job::try_from(input)?;

        let want = Job {
            metadata: Metadata {
                name: String::from("run something"),
                ..Default::default()
            },
            spec: Spec::Command(Command {
                argv: vec![String::from("foo")],
                env: HashMap::from([(String::from("VARIABLE"), String::from("value"))]),
                command: String::from("something"),
                ..Default::default()
            }),
            ..Default::default()
        };

        // TODO: find an approach that enables reliable `history` comparison
        assert_eq!(got.metadata, want.metadata);
        assert_eq!(got.spec, want.spec);

        Ok(())
    }

    #[test]
    fn fake_toml() -> std::result::Result<(), Error> {
        let input = r#"
            name = "fake"
            type = "fake"
            sleep_ms = 5000
            status = "changed"
            "#;

        let got = Job::try_from(input)?;

        let want = Job {
            metadata: Metadata {
                name: String::from("fake"),
                ..Default::default()
            },
            spec: Spec::Fake(Fake {
                sleep_ms: Some(Duration::from_millis(5000)),
                status: Some(Status::Satisfying(crate::status::Satisfying::Changed(
                    String::new(),
                    String::new(),
                ))),
            }),
            ..Default::default()
        };

        // TODO: find an approach that enables reliable `history` comparison
        assert_eq!(got.metadata, want.metadata);
        assert_eq!(got.spec, want.spec);

        Ok(())
    }

    #[test]
    fn fake_toml_with_strings_to_convert() -> std::result::Result<(), Error> {
        let input = r#"
            name = "fake"
            type = "fake"
            sleep_ms = "5000"
            when = "false"
            "#;

        let got = Job::try_from(input)?;

        let want = Job {
            metadata: Metadata {
                name: String::from("fake"),
                when: false,
                ..Default::default()
            },
            spec: Spec::Fake(Fake {
                sleep_ms: Some(Duration::from_millis(5000)),
                ..Default::default()
            }),
            ..Default::default()
        };

        // TODO: find an approach that enables reliable `history` comparison
        assert_eq!(got.metadata, want.metadata);
        assert_eq!(got.spec, want.spec);

        Ok(())
    }

    #[test]
    fn file_toml() -> std::result::Result<(), Error> {
        let input = r#"
            name = "mkdir /tmp"
            type = "file"
            path = "/tmp"
            state = "directory"
            "#;

        let got = Job::try_from(input)?;

        let want = Job {
            metadata: Metadata {
                name: String::from("mkdir /tmp"),
                ..Default::default()
            },
            spec: Spec::File(File {
                path: Utf8PathBuf::from("/tmp"),
                state: FileState::Directory,
                ..Default::default()
            }),
            ..Default::default()
        };

        // TODO: find an approach that enables reliable `history` comparison
        assert_eq!(got.metadata, want.metadata);
        assert_eq!(got.spec, want.spec);

        Ok(())
    }

    #[test]
    fn git_toml() -> std::result::Result<(), Error> {
        let input = r#"
            name = "clone tuning source repo"
            type = "git"
            dest = "/usr/src/tuning"
            repo = "https://gitlab.com/jokeyrhyme/tuning.git"
            "#;

        let got = Job::try_from(input)?;

        let want = Job {
            metadata: Metadata {
                name: String::from("clone tuning source repo"),
                ..Default::default()
            },
            spec: Spec::Git(Git {
                dest: Utf8PathBuf::from("/usr/src/tuning"),
                repo: Url::try_from(String::from("https://gitlab.com/jokeyrhyme/tuning.git"))
                    .expect("unable to parse test URL"),
                ..Default::default()
            }),
            ..Default::default()
        };

        // TODO: find an approach that enables reliable `history` comparison
        assert_eq!(got.metadata, want.metadata);
        assert_eq!(got.spec, want.spec);

        Ok(())
    }

    #[test]
    fn git_toml_with_strings_to_convert() -> std::result::Result<(), Error> {
        let input = r#"
            name = "clone tuning source repo"
            type = "git"
            clone = "false"
            depth = "7"
            dest = "/usr/src/tuning"
            force = "true"
            repo = "https://gitlab.com/jokeyrhyme/tuning.git"
            update = "false"
            when = "true"
            "#;

        let got = Job::try_from(input)?;

        let want = Job {
            metadata: Metadata {
                name: String::from("clone tuning source repo"),
                when: true,
                ..Default::default()
            },
            spec: Spec::Git(Git {
                clone: Some(false),
                depth: Some(NonZeroU32::new(7).expect("cannot convert to positive number")),
                dest: Utf8PathBuf::from("/usr/src/tuning"),
                force: Some(true),
                repo: Url::try_from(String::from("https://gitlab.com/jokeyrhyme/tuning.git"))
                    .expect("unable to parse test URL"),
                update: Some(false),
            }),
            ..Default::default()
        };

        // TODO: find an approach that enables reliable `history` comparison
        assert_eq!(got.metadata, want.metadata);
        assert_eq!(got.spec, want.spec);

        Ok(())
    }

    #[test]
    fn installercargo_toml() -> std::result::Result<(), Error> {
        let input = r#"
            type = "installercargo"
            crates = [ "ssh-sensible" ]
            state = "present"
            "#;

        let got = Job::try_from(input)?;

        let want = Job {
            metadata: Metadata {
                name: got.metadata.name.clone(), // random, so copy it in to help assert_eq
                ..Default::default()
            },
            spec: Spec::InstallerCargo(InstallerCargo {
                crates: vec![String::from("ssh-sensible")],
                state: DesiredStateCargo::Present,
                ..Default::default()
            }),
            ..Default::default()
        };

        // TODO: find an approach that enables reliable `history` comparison
        assert_eq!(got.metadata, want.metadata);
        assert_eq!(got.spec, want.spec);

        Ok(())
    }

    #[test]
    fn installergo_toml() -> std::result::Result<(), Error> {
        let input = r#"
            type = "installergo"
            packages = [ "gitlab.com/jokeyrhyme/dotfiles/barista" ]
            state = "latest"
            "#;

        let got = Job::try_from(input)?;

        let want = Job {
            metadata: Metadata {
                name: got.metadata.name.clone(), // random, so copy it in to help assert_eq
                ..Default::default()
            },
            spec: Spec::InstallerGo(InstallerGo {
                packages: vec![String::from("gitlab.com/jokeyrhyme/dotfiles/barista")],
                state: DesiredStateGo::Latest,
                ..Default::default()
            }),
            ..Default::default()
        };

        // TODO: find an approach that enables reliable `history` comparison
        assert_eq!(got.metadata, want.metadata);
        assert_eq!(got.spec, want.spec);

        Ok(())
    }

    #[test]
    fn installernpm_toml() -> std::result::Result<(), Error> {
        let input = r#"
            type = "installernpm"
            packages = [ "open-cli" ]
            state = "present"
            "#;

        let got = Job::try_from(input)?;

        let want = Job {
            metadata: Metadata {
                name: got.metadata.name.clone(), // random, so copy it in to help assert_eq
                ..Default::default()
            },
            spec: Spec::InstallerNpm(InstallerNpm {
                packages: vec![String::from("open-cli")],
                state: DesiredStateNpm::Present,
                ..Default::default()
            }),
            ..Default::default()
        };

        // TODO: find an approach that enables reliable `history` comparison
        assert_eq!(got.metadata, want.metadata);
        assert_eq!(got.spec, want.spec);

        Ok(())
    }

    #[test]
    fn absent_when_defaults_to_true() -> std::result::Result<(), Error> {
        let input = r#"
            name = "run something"
            type = "command"
            command = "something"
            "#;

        let got = Job::try_from(input)?;

        let want = Job {
            metadata: Metadata {
                name: String::from("run something"),
                when: true,
                ..Default::default()
            },
            spec: Spec::Command(Command {
                command: String::from("something"),
                ..Default::default()
            }),
            ..Default::default()
        };

        // TODO: find an approach that enables reliable `history` comparison
        assert_eq!(got.metadata, want.metadata);
        assert_eq!(got.spec, want.spec);

        Ok(())
    }
}
