//! preflight: parsing/planning work we do before we run any jobs
#![allow(dead_code)]
use std::{
    collections::HashMap,
    sync::{Arc, Mutex},
};

use camino::Utf8PathBuf;
use serde::{Deserialize, Serialize};
use tera::{Context, Tera};
use thiserror::Error as ThisError;
use toml::{map::Map, value::Value};
use uuid::Uuid;

use crate::{
    jobs::{self, Job},
    template,
};

#[derive(Debug, ThisError)]
pub(crate) enum Error {
    #[error(transparent)]
    Job {
        #[from]
        source: jobs::Error,
    },
    #[error("invalid entry in 'needs', no job named '{0}' exists")]
    NeedsMissingJob(String),
    #[error(transparent)]
    ParseToml {
        #[from]
        source: toml::de::Error,
    },
    #[error(transparent)]
    SerializeToml {
        #[from]
        source: toml::ser::Error,
    },
    #[error(transparent)]
    Template {
        #[from]
        source: template::Error,
    },
    #[error(transparent)]
    Tera {
        #[from]
        source: tera::Error, // 3rd-party Error that doesn't implement PartialEq
    },
}
impl PartialEq for Error {
    fn eq(&self, other: &Self) -> bool {
        // TODO: come up with something better
        format!("{self:?}") == format!("{other:?}")
    }
}

#[derive(Debug, Deserialize, PartialEq, Serialize)]
pub(crate) struct Include {
    pub src: Utf8PathBuf,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub(crate) struct RawJob {
    #[serde(default = "generate_name")]
    pub name: String,

    #[serde(default)]
    pub needs: Vec<String>,

    #[serde(flatten)]
    pub spec: HashMap<String, Value>,
}
impl RawJob {
    pub fn try_into_job(
        &mut self,
        renderer_and_context: &Arc<Mutex<(Tera, Context)>>,
    ) -> Result<Job> {
        self.evaluate_spec(renderer_and_context)
            .and_then(|()| {
                // we do a round-trip through serde to translate to a Job,
                // which _might_ be wasteful compared to a custom TryFrom,
                // but serde has far better error handling/reporting
                // TODO: see if there's a better compromise here
                toml::to_string(&self).map_err(|e| Error::SerializeToml { source: e })
            })
            .and_then(|text| Job::try_from(text.as_str()).map_err(|e| Error::Job { source: e }))
    }

    fn evaluate_spec(&mut self, renderer_and_context: &Arc<Mutex<(Tera, Context)>>) -> Result<()> {
        let mut renderer_and_context = renderer_and_context
            .lock()
            .expect("other thread should not panic while mutex is locked");
        let (renderer, context) = &mut *renderer_and_context;
        for v in self.spec.values_mut() {
            *v = visit_and_evaluate(v, renderer, context)?;
        }
        Ok(())
    }
}

#[derive(Debug, Default, Deserialize, PartialEq, Serialize)]
pub(crate) struct RawMain {
    #[serde(default)]
    pub includes: Vec<Include>,

    #[serde(default)]
    pub jobs: Vec<RawJob>,
}
impl TryFrom<&str> for RawMain {
    type Error = Error;
    fn try_from(s: &str) -> std::result::Result<Self, Self::Error> {
        toml::from_str(s).map_err(|e| Error::ParseToml { source: e })
    }
}
impl RawMain {
    pub fn validate(&self) -> Result<()> {
        // TODO: consider finding all errors, not just the first one
        for rj in &self.jobs {
            for needed in &rj.needs {
                if !self
                    .jobs
                    .iter()
                    .map(|rj| rj.name.as_str())
                    .any(|x| x == needed.as_str())
                {
                    return Err(Error::NeedsMissingJob(needed.clone()));
                }
            }
        }

        Ok(())
    }
}

type Result<T> = std::result::Result<T, Error>;

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

fn visit_and_evaluate(value: &Value, renderer: &mut Tera, context: &Context) -> Result<Value> {
    match value {
        Value::Array(a) => {
            let mut value = Vec::<Value>::with_capacity(a.len());
            for v in a {
                value.push(visit_and_evaluate(v, renderer, context)?);
            }
            return Ok(Value::Array(value));
        }
        Value::String(s) => {
            let value = renderer.render_str(s, context)?;
            return Ok(Value::String(value));
        }
        Value::Table(m) => {
            let mut value = Map::<String, Value>::new();
            for (k, v) in m {
                value.insert(String::from(k), visit_and_evaluate(v, renderer, context)?);
            }
            return Ok(Value::Table(value));
        }
        _ => {}
    }
    Ok(value.clone())
}

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

    use crate::facts::Facts;

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

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

        let mut env: Map<String, Value> = Map::new();
        env.insert(
            String::from("VARIABLE"),
            Value::String(String::from("value")),
        );

        let mut spec: HashMap<String, Value> = HashMap::new();
        spec.insert(String::from("type"), Value::String(String::from("command")));
        spec.insert(
            String::from("argv"),
            Value::Array(vec![Value::String(String::from("foo"))]),
        );
        spec.insert(
            String::from("command"),
            Value::String(String::from("something")),
        );
        spec.insert(String::from("env"), Value::Table(env));
        spec.insert(String::from("when"), Value::Boolean(false));

        let want = RawMain {
            jobs: vec![RawJob {
                name: String::from("run something"),
                needs: vec![String::from("other thing")],
                spec,
            }],
            ..Default::default()
        };

        assert_eq!(got, want);

        Ok(())
    }

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

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

        let mut want = RawMain {
            jobs: vec![RawJob {
                name: String::from("random UUID, tricky to assert"),
                needs: vec![],
                spec: HashMap::from([(
                    String::from("type"),
                    Value::String(String::from("command")),
                )]),
            }],
            ..Default::default()
        };

        assert_eq!(got.jobs[0].name.len(), 36);
        want.jobs[0].name.clone_from(&got.jobs[0].name);
        assert_eq!(got, want);

        Ok(())
    }

    #[test]
    fn parse_command_toml_to_rawjob_with_expressions() -> std::result::Result<(), Error> {
        let facts = Facts::default();
        let input = r#"
            [[jobs]]
            array = [ "{{ 'foo' | upper }}", { nested = [ "{{ 'HAHA' | lower }}" ] } ]
            string = "{{ 'hello, world' | replace(from='world', to='goodbye') }}"
            table = { key = "{{ '1,2,3' | split(pat=',') | join(sep='-') }}" }
            type = "fake"
            "#;
        let mut nested = Map::<String, Value>::new();
        nested.insert(
            String::from("nested"),
            Value::Array(vec![Value::String(String::from("haha"))]),
        );
        let mut table = Map::<String, Value>::new();
        table.insert(String::from("key"), Value::String(String::from("1-2-3")));
        let mut want = HashMap::<String, Value>::new();
        want.insert(
            String::from("array"),
            Value::Array(vec![
                Value::String(String::from("FOO")),
                Value::Table(nested),
            ]),
        );
        want.insert(
            String::from("string"),
            Value::String(String::from("hello, goodbye")),
        );
        want.insert(String::from("table"), Value::Table(table));
        want.insert(String::from("type"), Value::String(String::from("fake")));

        let mut got = RawMain::try_from(input)?;
        let renderer_and_context = Arc::new(Mutex::new(template::make_tera(&facts)?));
        got.jobs[0].evaluate_spec(&renderer_and_context)?;

        // compare key-by-key because `assert_eq!` on HashMaps does not handle random key order
        for (key, got_value) in &got.jobs[0].spec {
            assert_eq!(Some(got_value), want.get(key));
        }
        assert_eq!(got.jobs[0].spec.len(), want.len());

        Ok(())
    }

    #[test]
    fn rawmain_validate() {
        let input = RawMain {
            jobs: vec![
                RawJob {
                    name: String::from("first"),
                    needs: vec![],
                    spec: HashMap::new(),
                },
                RawJob {
                    name: String::from("second"),
                    needs: vec![String::from("first")],
                    spec: HashMap::new(),
                },
            ],
            ..Default::default()
        };

        let got = input.validate();

        assert!(got.is_ok());
    }

    #[test]
    fn rawmain_validate_with_needs_missing_job() {
        let input = RawMain {
            jobs: vec![
                RawJob {
                    name: String::from("second"),
                    needs: vec![String::from("first")],
                    spec: HashMap::new(),
                },
                RawJob {
                    name: String::from("third"),
                    needs: vec![],
                    spec: HashMap::new(),
                },
            ],
            ..Default::default()
        };

        let got = input.validate();

        assert_eq!(got, Err(Error::NeedsMissingJob(String::from("first"))));
    }
}
