use normalize_path::NormalizePath;
use std::{
    collections::{HashMap, HashSet},
    path::{Path, PathBuf},
    process::Command,
    str::FromStr,
};

use miette::{miette, IntoDiagnostic as _};
use rhai::Dynamic;

use crate::{script::rhai_error::RhaiError, util::PathExt as _};

macro_rules! rhai_error {
    ($($tt:tt)*) => {
        RhaiError::Other(miette!($($tt)*))
    };
}

/// How the contents of an egg should be deployed.
#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)]
pub enum DeploymentStrategy {
    /// Recursively traverse the directory structure until a directory / file doesn't exist yet, then symlink there.
    /// This allows stow-like behavior.
    Merge,
    /// Simply deploy to the given target, or fail.
    #[default]
    Put,
}

impl FromStr for DeploymentStrategy {
    type Err = miette::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "merge" => Ok(DeploymentStrategy::Merge),
            "put" => Ok(DeploymentStrategy::Put),
            _ => miette::bail!(
                help = "strategy must be one of 'merge' or 'put'",
                "Invalid deployment strategy {}",
                s
            ),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ShellHooks {
    pub post_deploy: Option<String>,
    pub post_undeploy: Option<String>,
    pub pre_deploy: Option<String>,
    pub pre_undeploy: Option<String>,
    // pub post_sync: Option<String>,
}

impl ShellHooks {
    pub fn run_post_deploy(&self) -> miette::Result<()> {
        if let Some(command) = &self.post_deploy {
            tracing::debug!("Running post-deploy script");
            run_hook(command)?;
        }
        Ok(())
    }

    pub fn run_post_undeploy(&self) -> miette::Result<()> {
        if let Some(command) = &self.post_undeploy {
            tracing::debug!("Running post-undeploy script");
            run_hook(command)?;
        }
        Ok(())
    }
    pub fn run_pre_deploy(&self) -> miette::Result<()> {
        if let Some(command) = &self.pre_deploy {
            tracing::debug!("Running pre-deploy script");
            run_hook(command)?;
        }
        Ok(())
    }
    pub fn run_pre_undeploy(&self) -> miette::Result<()> {
        if let Some(command) = &self.pre_undeploy {
            tracing::debug!("Running pre-undeploy script");
            run_hook(command)?;
        }
        Ok(())
    }

    // pub fn run_post_sync(&self) -> miette::Result<()> {
    //     if let Some(command) = &self.post_sync {
    //         tracing::debug!("Running post-sync script");
    //         run_hook(command)?;
    //     }
    //     Ok(())
    // }
}

fn run_hook(command: &str) -> miette::Result<()> {
    let status = Command::new("sh")
        .arg("-c")
        .arg(command)
        .status()
        .map_err(|e| miette::miette!("Failed to run post-deploy hook: {}", e))?;

    if !status.success() {
        miette::bail!(
            "Post-deploy hook failed with status {}",
            status.code().unwrap_or(-1)
        );
    }
    Ok(())
}

#[derive(Debug, PartialEq, Eq, Clone)]
pub struct EggConfig {
    /// The targets map is a map from `path-relative-to-egg-dir` -> `path-where-it-should-go`.
    pub targets: HashMap<PathBuf, PathBuf>,
    pub enabled: bool,
    pub templates: HashSet<PathBuf>,
    /// The "main" file of this egg -- currently used to determine which path should be opened by `yolk edit`.
    pub main_file: Option<PathBuf>,
    pub strategy: DeploymentStrategy,
    pub unsafe_shell_hooks: ShellHooks,
}

#[derive(Debug, PartialEq, Eq, Hash)]
enum EggConfigKey {
    Targets,
    MainFile,
    Strategy,
    Templates,
    Enabled,
    UnsafeShellHooks,
}

impl EggConfigKey {
    fn from_str(s: &str) -> Option<Self> {
        match s {
            "targets" => Some(EggConfigKey::Targets),
            "main_file" => Some(EggConfigKey::MainFile),
            "strategy" => Some(EggConfigKey::Strategy),
            "templates" => Some(EggConfigKey::Templates),
            "enabled" => Some(EggConfigKey::Enabled),
            "unsafe_shell_hooks" => Some(EggConfigKey::UnsafeShellHooks),
            _ => None,
        }
    }
}

#[derive(Debug, PartialEq, Eq, Hash)]
enum ShellHookKey {
    PostDeploy,
    PostUndeploy,
    PreDeploy,
    PreUndeploy,
}

impl ShellHookKey {
    fn from_str(s: &str) -> Option<Self> {
        match s {
            "post_deploy" => Some(ShellHookKey::PostDeploy),
            "post_undeploy" => Some(ShellHookKey::PostUndeploy),
            "pre_deploy" => Some(ShellHookKey::PreDeploy),
            "pre_undeploy" => Some(ShellHookKey::PreUndeploy),
            _ => None,
        }
    }
}

impl Default for EggConfig {
    fn default() -> Self {
        EggConfig {
            enabled: true,
            targets: HashMap::new(),
            templates: HashSet::new(),
            main_file: None,
            strategy: Default::default(),
            unsafe_shell_hooks: ShellHooks {
                post_deploy: None,
                post_undeploy: None,
                pre_deploy: None,
                pre_undeploy: None,
            },
        }
    }
}

impl EggConfig {
    pub fn new(in_egg: impl AsRef<Path>, deployed_to: impl AsRef<Path>) -> Self {
        let in_egg = in_egg.as_ref();
        EggConfig {
            enabled: true,
            targets: maplit::hashmap! {
                in_egg.to_path_buf() => deployed_to.as_ref().to_path_buf()
            },
            templates: HashSet::new(),
            main_file: None,
            strategy: DeploymentStrategy::default(),
            unsafe_shell_hooks: ShellHooks {
                post_deploy: None,
                post_undeploy: None,
                pre_deploy: None,
                pre_undeploy: None,
            },
        }
    }

    pub fn new_merge(in_egg: impl AsRef<Path>, deployed_to: impl AsRef<Path>) -> Self {
        Self::new(in_egg, deployed_to).with_strategy(DeploymentStrategy::Merge)
    }

    pub fn with_unsafe_hooks(mut self, unsafe_shell_hooks: ShellHooks) -> Self {
        self.unsafe_shell_hooks = unsafe_shell_hooks;
        self
    }

    pub fn with_enabled(mut self, enabled: bool) -> Self {
        self.enabled = enabled;
        self
    }

    pub fn with_template(mut self, template: impl AsRef<Path>) -> Self {
        self.templates.insert(template.as_ref().to_path_buf());
        self
    }

    pub fn with_strategy(mut self, strategy: DeploymentStrategy) -> Self {
        self.strategy = strategy;
        self
    }

    pub fn with_main_file(mut self, main_file: impl AsRef<Path>) -> Self {
        self.main_file = Some(main_file.as_ref().to_path_buf());
        self
    }

    /// Add a new target from a path inside the egg dir to the path it should be deployed as.
    pub fn with_target(mut self, in_egg: impl AsRef<Path>, deploy_to: impl AsRef<Path>) -> Self {
        self.targets.insert(
            in_egg.as_ref().to_path_buf(),
            deploy_to.as_ref().to_path_buf(),
        );
        self
    }

    /// Returns the targets map, but with any `~` expanded to the home directory.
    ///
    /// The targets map is a map from `path-relative-to-egg-dir` -> `path-where-it-should-go`.
    pub fn targets_expanded(
        &self,
        home: impl AsRef<Path>,
        egg_root: impl AsRef<Path>,
    ) -> miette::Result<HashMap<PathBuf, PathBuf>> {
        let egg_root = egg_root.as_ref();
        self.targets
            .iter()
            .map(|(source, target)| {
                let source = egg_root.canonical()?.join(source);
                let target = target.expanduser();
                let target = if target.is_absolute() {
                    target
                } else {
                    home.as_ref().join(target)
                };
                Ok((source.normalize(), target.normalize()))
            })
            .collect()
    }

    /// Expand the glob patterns in the `templates` field to a list of paths.
    /// The globbed paths are considered relative to `in_dir`. The resulting list of paths will contain absolute paths.
    pub fn templates_globexpanded(&self, in_dir: impl AsRef<Path>) -> miette::Result<Vec<PathBuf>> {
        let in_dir = in_dir.as_ref();
        let mut paths = Vec::new();
        for globbed in &self.templates {
            let expanded = glob::glob(&in_dir.join(globbed).to_string_lossy()).into_diagnostic()?;
            for path in expanded {
                paths.push(path.into_diagnostic()?);
            }
        }
        Ok(paths)
    }

    pub fn from_dynamic(value: Dynamic) -> Result<Self, RhaiError> {
        if let Ok(target_path) = value.as_immutable_string_ref() {
            return Ok(EggConfig::new(".", target_path.to_string()));
        }
        let Ok(map) = value.as_map_ref() else {
            return Err(rhai_error!("egg value must be a string or a map"));
        };

        for (k, _v) in map.iter() {
            let k: &str = &*k;
            if EggConfigKey::from_str(k).is_none() {
                tracing::warn!("unknown egg config key: {}", k);
            }
        }

        let empty_map = Dynamic::from(rhai::Map::new());
        let targets = map.get("targets").unwrap_or(&empty_map);

        let targets = if let Ok(targets) = targets.as_immutable_string_ref() {
            maplit::hashmap! { PathBuf::from(".") => PathBuf::from(targets.to_string()) }
        } else if let Ok(targets) = targets.as_map_ref() {
            targets
                .clone()
                .into_iter()
                .map(|(k, v)| {
                    Ok::<_, RhaiError>((
                        PathBuf::from(&*k),
                        PathBuf::from(&v.into_string().map_err(|e| {
                            rhai_error!("target file value must be a path, but got {e}")
                        })?),
                    ))
                })
                .collect::<Result<_, _>>()?
        } else {
            return Err(rhai_error!("egg `targets` must be a string or a map"));
        };

        let main_file = match map.get("main_file") {
            Some(path) => Some(
                path.as_immutable_string_ref()
                    .map_err(|e| rhai_error!("main_file must be a path, but got {e}"))?
                    .to_string()
                    .into(),
            ),
            None => None,
        };

        let strategy = match map.get("strategy") {
            Some(strategy) => {
                DeploymentStrategy::from_str(&strategy.to_string()).map_err(RhaiError::Other)?
            }
            None => DeploymentStrategy::default(),
        };

        let templates =
            if let Some(templates) = map.get("templates") {
                templates
                    .as_array_ref()
                    .map_err(|t| rhai_error!("`templates` must be a list, but got {t}"))?
                    .iter()
                    .map(|x| {
                        Ok::<_, RhaiError>(PathBuf::from(x.clone().into_string().map_err(|e| {
                            rhai_error!("template entry must be a path, but got {e}")
                        })?))
                    })
                    .collect::<Result<HashSet<_>, _>>()?
            } else {
                HashSet::new()
            };

        let enabled = if let Some(x) = map.get("enabled") {
            x.as_bool()
                .map_err(|t| rhai_error!("`enabled` must be a list, but got {t}"))?
        } else {
            true
        };

        let unsafe_shell_hooks = if let Some(x) = map.get("unsafe_shell_hooks") {
            let shell_hooks = x
                .as_map_ref()
                .map_err(|t| rhai_error!("`unsafe_shell_hooks` must be a map, but got {t}"))?;

            for (k, _v) in shell_hooks.iter() {
                let k: &str = &*k;
                if ShellHookKey::from_str(k).is_none() {
                    tracing::warn!("unknown key: {}", k);
                }
            }
            ShellHooks {
                post_deploy: shell_hooks.get("post_deploy").map(|v| v.to_string()),
                post_undeploy: shell_hooks.get("post_undeploy").map(|v| v.to_string()),
                pre_deploy: shell_hooks.get("pre_deploy").map(|v| v.to_string()),
                pre_undeploy: shell_hooks.get("pre_undeploy").map(|v| v.to_string()),
            }
        } else {
            ShellHooks::default()
        };

        Ok(EggConfig {
            targets,
            enabled,
            templates,
            main_file,
            strategy,
            unsafe_shell_hooks,
        })
    }
}

#[cfg(test)]
mod test {
    use std::collections::HashSet;

    use assert_fs::{
        prelude::{FileWriteStr as _, PathChild as _},
        TempDir,
    };
    use maplit::hashset;
    use miette::IntoDiagnostic as _;
    use pretty_assertions::assert_eq;

    use crate::{
        eggs_config::{DeploymentStrategy, EggConfig, ShellHooks},
        util::test_util::TestResult,
    };

    use rstest::rstest;
    #[rstest]
    #[case(
        indoc::indoc! {r#"
            #{
                enabled: false,
                targets: #{ "foo": "~/bar" },
                templates: ["foo"],
                main_file: "foo",
                strategy: "merge",
                unsafe_shell_hooks: #{
                    post_deploy: "run after deploy",
                    post_undeploy: "run after undeploy",
                    pre_deploy: "run before deploy",
                    pre_undeploy: "run before undeploy",
                }
            }
        "#},
        EggConfig::new_merge("foo", "~/bar")
            .with_enabled(false)
            .with_template("foo")
            .with_strategy(DeploymentStrategy::Merge)
            .with_main_file("foo")
            .with_unsafe_hooks(ShellHooks {
                post_deploy: Some("run after deploy".to_string()),
                post_undeploy: Some("run after undeploy".to_string()),
                pre_deploy: Some("run before deploy".to_string()),
                pre_undeploy: Some("run before undeploy".to_string()),
            })
    )]
    #[case(r#"#{ targets: "~/bar" }"#, EggConfig::new(".", "~/bar"))]
    #[case(r#""~/bar""#, EggConfig::new(".", "~/bar"))]
    fn test_read_eggs_config(#[case] input: &str, #[case] expected: EggConfig) -> TestResult {
        let result = rhai::Engine::new().eval(input)?;
        assert_eq!(EggConfig::from_dynamic(result)?, expected);
        Ok(())
    }

    #[test]
    fn test_template_globbed() -> TestResult {
        let home = TempDir::new().into_diagnostic()?;
        let config = EggConfig::new_merge(home.to_str().unwrap(), ".")
            .with_template("foo")
            .with_template("**/*.foo");
        home.child("foo").write_str("a")?;
        home.child("bar/baz/a.foo").write_str("a")?;
        home.child("bar/a.foo").write_str("a")?;
        home.child("bar/foo").write_str("a")?;
        let result = config.templates_globexpanded(&home)?;

        assert_eq!(
            result.into_iter().collect::<HashSet<_>>(),
            hashset![
                home.child("foo").path().to_path_buf(),
                home.child("bar/baz/a.foo").path().to_path_buf(),
                home.child("bar/a.foo").path().to_path_buf(),
            ]
        );
        Ok(())
    }

    #[test]
    fn test_invalid_key_warns_and_parses() {
        let input = r#"#{ unknown_key: "value" }"#;
        let result = rhai::Engine::new().eval(input).unwrap();
        let parsed = EggConfig::from_dynamic(result);
        assert!(
            parsed.is_ok(),
            "Expected parsing to succeed (unknown keys are warnings)"
        );
        let cfg = parsed.unwrap();
        assert_eq!(cfg, EggConfig::default());
    }

    #[test]
    fn test_invalid_unsafe_shell_hooks_key_warns_and_parses() {
        let input = indoc::indoc! {r#"
            #{
                unsafe_shell_hooks: #{
                    not_a_real_hook: "do something"
                }
            }
        "#};
        let result = rhai::Engine::new().eval(input).unwrap();
        let parsed = EggConfig::from_dynamic(result);
        assert!(
            parsed.is_ok(),
            "Expected parsing to succeed (unknown hooks are warnings)"
        );
        let cfg = parsed.unwrap();
        assert_eq!(cfg.unsafe_shell_hooks, ShellHooks::default());
    }
}
