use log::warn;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

use crate::dirs::home_dir;
use crate::subcommand::{SubcommandSet, TomlSubcommand};
use crate::{AliasDetail, AliasName, AliasSet, TomlAlias};

pub const ALIASES_FILE: &str = ".aliases";

#[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub struct ProjectAliases {
    #[serde(default)]
    pub aliases: AliasSet,
    #[serde(default, skip_serializing_if = "SubcommandSet::is_empty")]
    pub subcommands: SubcommandSet,
    #[serde(default, skip_serializing_if = "crate::vars::VarSet::is_empty")]
    pub vars: crate::vars::VarSet,
}

impl ProjectAliases {
    /// Walk up from `start_dir` looking for a `.aliases` file.
    /// Stops before the user's home directory.
    pub fn find(start_dir: &Path) -> crate::Result<Option<Self>> {
        match Self::find_path(start_dir)? {
            Some(path) => {
                let data = std::fs::read_to_string(&path)?;
                match toml::from_str::<ProjectAliases>(&data) {
                    Ok(project) => Ok(Some(project)),
                    Err(e) => {
                        warn!("Skipping {}: {e}", path.display());
                        Ok(None)
                    }
                }
            }
            None => Ok(None),
        }
    }

    /// Walk up from `start_dir` looking for a `.aliases` file path.
    /// Returns the path without parsing.
    pub fn find_path(start_dir: &Path) -> crate::Result<Option<PathBuf>> {
        let home = home_dir();
        let mut dir = start_dir.to_path_buf();
        loop {
            if home.as_ref().is_some_and(|h| h == &dir) {
                return Ok(None);
            }
            let candidate = dir.join(ALIASES_FILE);
            if candidate.exists() {
                return Ok(Some(candidate));
            }
            if !dir.pop() {
                return Ok(None);
            }
        }
    }

    /// Load from a specific path.
    pub fn load(path: &Path) -> crate::Result<Self> {
        let data = std::fs::read_to_string(path)?;
        let project = toml::from_str(&data)?;
        Ok(project)
    }

    /// Save to a specific path.
    pub fn save(&self, path: &Path) -> crate::Result<()> {
        let data = toml::to_string(self)?;
        std::fs::write(path, data)?;
        Ok(())
    }

    pub fn remove_alias(&mut self, name: &str) -> crate::Result<()> {
        let key: AliasName = name.into();
        self.aliases
            .remove(&key)
            .ok_or_else(|| anyhow::anyhow!("Alias '{name}' not found in {ALIASES_FILE}"))?;
        Ok(())
    }

    /// Find the nearest `.aliases` file starting from the given directory.
    /// Checks `cwd` first, then walks up parent directories.
    pub fn find_local_path_in(cwd: &Path) -> Option<PathBuf> {
        let local = cwd.join(ALIASES_FILE);
        if local.exists() {
            return Some(local);
        }
        cwd.parent().and_then(|p| Self::find_path(p).ok().flatten())
    }

    /// Find the nearest `.aliases` file starting from the current directory.
    pub fn find_local_path() -> Option<PathBuf> {
        let cwd = std::env::current_dir().ok()?;
        Self::find_local_path_in(&cwd)
    }

    /// Remove an alias from the nearest local `.aliases` file.
    /// Returns the path of the modified file.
    pub fn remove_from_local(name: &str) -> crate::Result<PathBuf> {
        let path =
            Self::find_local_path().ok_or_else(|| anyhow::anyhow!("No {ALIASES_FILE} found"))?;
        let mut project = Self::load(&path)?;
        project.remove_alias(name)?;
        project.save(&path)?;
        Ok(path)
    }

    /// Merge incoming aliases into project aliases (union/overwrite).
    pub fn merge_aliases(&mut self, incoming: AliasSet) {
        for (name, alias) in incoming.iter() {
            self.aliases.insert(name.clone(), alias.clone());
        }
    }

    /// Merge incoming variables into project variables (union/overwrite).
    pub fn merge_vars(&mut self, incoming: crate::vars::VarSet) {
        for (name, value) in incoming.iter() {
            self.vars.insert(name.clone(), value.clone());
        }
    }

    pub fn add_alias(
        &mut self,
        name: String,
        command: String,
        raw: bool,
        description: Option<String>,
    ) {
        let key: AliasName = name.into();
        let alias = if description.is_some() || raw {
            TomlAlias::Detailed(AliasDetail {
                command,
                description,
                raw,
            })
        } else {
            TomlAlias::Command(command)
        };
        self.aliases.insert(key, alias);
    }

    pub fn add_subcommand(
        &mut self,
        key: String,
        long_subcommands: Vec<String>,
        description: Option<String>,
    ) {
        let value = match description {
            Some(d) => TomlSubcommand::Detailed(crate::subcommand::SubcommandDetail {
                expansions: long_subcommands,
                description: Some(d),
            }),
            None => TomlSubcommand::Expansion(long_subcommands),
        };
        self.subcommands.as_mut().insert(key, value);
    }

    pub fn remove_subcommand(&mut self, key: &str) -> crate::Result<()> {
        self.subcommands.as_mut().remove(key).ok_or_else(|| {
            anyhow::anyhow!("Subcommand alias '{key}' not found in {ALIASES_FILE}")
        })?;
        Ok(())
    }

    pub fn set_var(&mut self, name: crate::vars::VarName, value: String) {
        self.vars.insert(name, value);
    }

    pub fn unset_var(&mut self, name: &crate::vars::VarName) -> Option<String> {
        self.vars.remove(name)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::TomlAlias;
    use std::fs;

    #[test]
    fn test_merge_aliases_into_project() {
        let mut project = ProjectAliases::default();
        project.add_alias("t".into(), "cargo test".into(), false, None);
        let mut incoming = AliasSet::default();
        incoming.insert("b".into(), TomlAlias::Command("cargo build".into()));
        project.merge_aliases(incoming);
        assert_eq!(project.aliases.len(), 2);
    }

    #[test]
    fn test_load_aliases_file() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(
            dir.path().join(".aliases"),
            "[aliases]\nb = \"./x.py build\"\nt = \"./x.py test\"\n",
        )
        .unwrap();

        let project = ProjectAliases::find(dir.path()).unwrap();
        assert!(project.is_some());
        let project = project.unwrap();
        assert_eq!(project.aliases.iter().count(), 2);
    }

    #[test]
    fn test_no_aliases_file() {
        let dir = tempfile::tempdir().unwrap();
        let project = ProjectAliases::find(dir.path()).unwrap();
        assert!(project.is_none());
    }

    #[test]
    fn test_invalid_toml_is_skipped() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(
            dir.path().join(".aliases"),
            "alias foo=\"bar\"\nthis is not toml",
        )
        .unwrap();

        let project = ProjectAliases::find(dir.path()).unwrap();
        assert!(project.is_none());
    }

    #[test]
    fn test_find_aliases_in_parent_directory() {
        let dir = tempfile::tempdir().unwrap();
        let sub = dir.path().join("src").join("deep");
        fs::create_dir_all(&sub).unwrap();
        fs::write(
            dir.path().join(".aliases"),
            "[aliases]\nb = \"make build\"\n",
        )
        .unwrap();

        let project = ProjectAliases::find(&sub).unwrap();
        assert!(project.is_some());
    }

    #[test]
    fn test_find_path_returns_location() {
        let dir = tempfile::tempdir().unwrap();
        let sub = dir.path().join("src");
        fs::create_dir_all(&sub).unwrap();
        fs::write(
            dir.path().join(".aliases"),
            "[aliases]\nb = \"make build\"\n",
        )
        .unwrap();

        let path = ProjectAliases::find_path(&sub).unwrap();
        assert_eq!(path.unwrap(), dir.path().join(".aliases"));
    }

    #[test]
    fn test_add_alias_and_save_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(".aliases");

        let mut project = ProjectAliases::default();
        project.add_alias("t".to_string(), "cargo test".to_string(), false, None);
        project.save(&path).unwrap();

        let loaded = ProjectAliases::load(&path).unwrap();
        assert_eq!(loaded.aliases.iter().count(), 1);
    }

    #[test]
    fn project_vars_default_empty_and_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(".aliases");

        let mut project = ProjectAliases::default();
        project.set_var(crate::vars::VarName::parse("path").unwrap(), "/v1".into());
        project.save(&path).unwrap();

        let loaded = ProjectAliases::load(&path).unwrap();
        assert_eq!(loaded.vars.len(), 1);
    }

    #[test]
    fn project_load_with_vars_section() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(
            dir.path().join(".aliases"),
            "[vars]\npath = \"/v1\"\n[aliases]\nrun = \"sh {{path}}/run.sh\"\n",
        )
        .unwrap();

        let project = ProjectAliases::find(dir.path()).unwrap().unwrap();
        assert_eq!(project.vars.len(), 1);
        assert_eq!(project.aliases.iter().count(), 1);
    }

    #[test]
    fn project_unset_var_returns_value() {
        let mut project = ProjectAliases::default();
        project.set_var(crate::vars::VarName::parse("x").unwrap(), "y".into());
        let prev = project.unset_var(&crate::vars::VarName::parse("x").unwrap());
        assert_eq!(prev.as_deref(), Some("y"));
    }

    #[test]
    fn test_project_with_subcommands_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(".aliases");

        let mut project = ProjectAliases::default();
        project.subcommands.as_mut().insert(
            "jj:ab".into(),
            TomlSubcommand::Expansion(vec!["abandon".into()]),
        );
        project.save(&path).unwrap();

        let loaded = ProjectAliases::load(&path).unwrap();
        assert_eq!(loaded.subcommands.as_ref().len(), 1);
        assert_eq!(
            loaded.subcommands.as_ref()["jj:ab"],
            TomlSubcommand::Expansion(vec!["abandon".to_string()])
        );
    }
}
