use anyhow::{Context, anyhow};

use crate::{
    client::{CachedRecipe, CookbookClient, CookbookTrait},
    commands::adapter::{EnwiroAdapterExternal, EnwiroAdapterNone, EnwiroAdapterTrait},
    config::ConfigurationValues,
    daemon,
    environments::Environment,
    notifier::{DesktopNotifier, Notifier},
    plugin::{PluginKind, get_plugins},
};
use std::{collections::HashMap, io::Write, os::unix::fs::symlink, path::Path, path::PathBuf};

pub struct CommandContext<W: Write> {
    pub config: ConfigurationValues,
    pub writer: W,
    pub adapter: Box<dyn EnwiroAdapterTrait>,
    pub notifier: Box<dyn Notifier>,
    pub cookbooks: Vec<Box<dyn CookbookTrait>>,
    pub cache_dir: Option<PathBuf>,
}

impl<W: Write> CommandContext<W> {
    pub fn new(config: ConfigurationValues, writer: W) -> anyhow::Result<Self> {
        let adapter: Box<dyn EnwiroAdapterTrait> = match &config.adapter {
            None => {
                tracing::debug!("No adapter configured");
                Box::new(EnwiroAdapterNone {})
            }
            Some(adapter_name) => {
                tracing::debug!(adapter = %adapter_name, "Using adapter");
                Box::new(EnwiroAdapterExternal::new(adapter_name)?)
            }
        };

        let plugins = get_plugins(PluginKind::Cookbook);
        let mut cookbooks: Vec<Box<dyn CookbookTrait>> = plugins
            .into_iter()
            .map(|p| Box::new(CookbookClient::new(p)) as Box<dyn CookbookTrait>)
            .collect();
        crate::client::sort_cookbooks(&mut cookbooks);

        tracing::debug!(count = cookbooks.len(), "Cookbooks loaded");

        let notifier: Box<dyn Notifier> = Box::new(DesktopNotifier);

        Ok(Self {
            config,
            writer,
            adapter,
            notifier,
            cookbooks,
            cache_dir: None,
        })
    }

    pub fn cook_environment(&self, name: &str) -> anyhow::Result<Environment> {
        let (cookbook_name, description) = self.find_recipe_in_cache(name).ok_or_else(|| {
            tracing::error!(name = %name, "Recipe not in daemon cache");
            anyhow!(
                "No recipe '{}' in the daemon cache. \
                 Check: systemctl --user status enwiro-daemon.service",
                name
            )
        })?;

        let cookbook = self
            .cookbooks
            .iter()
            .find(|c| c.name() == cookbook_name)
            .ok_or_else(|| {
                anyhow!(
                    "Cache lists recipe '{}' under cookbook '{}', which is not installed",
                    name,
                    cookbook_name
                )
            })?;

        tracing::debug!(name = %name, cookbook = %cookbook_name, "Found recipe in cache");
        let env_path = cookbook.cook(name)?;
        let env = self.create_environment_symlink(name, &env_path)?;
        let flat_name = name.replace('/', "-");
        self.save_cook_metadata(&flat_name, &cookbook_name, description.as_deref());
        self.write_gear_if_present(cookbook.as_ref(), name, &flat_name);
        Ok(env)
    }

    /// Look up a recipe in the daemon cache.
    /// Returns `Some((cookbook, description))` if the cache contains the recipe,
    /// `None` for any miss (cache absent, stale, or recipe not listed).
    fn find_recipe_in_cache(&self, recipe_name: &str) -> Option<(String, Option<String>)> {
        let runtime_dir = match &self.cache_dir {
            Some(dir) => dir.clone(),
            None => daemon::runtime_dir().ok()?,
        };
        let cached = daemon::read_cached_recipes(&runtime_dir).ok()??;
        for line in cached.lines() {
            if line.is_empty() {
                continue;
            }
            if let Ok(entry) = serde_json::from_str::<CachedRecipe>(line)
                && entry.name == recipe_name
            {
                return Some((entry.cookbook, entry.description));
            }
        }
        None
    }

    fn save_cook_metadata(&self, env_name: &str, cookbook: &str, description: Option<&str>) {
        let env_dir = Path::new(&self.config.workspaces_directory).join(env_name);
        crate::usage_stats::record_cook_metadata_per_env(&env_dir, cookbook, description);
    }

    fn write_gear_if_present(&self, cookbook: &dyn CookbookTrait, recipe: &str, flat_name: &str) {
        match cookbook.gear(recipe) {
            Ok(Some(json)) => {
                let env_dir = Path::new(&self.config.workspaces_directory).join(flat_name);
                let gear_path = enwiro_sdk::gear::gear_dir(&env_dir)
                    .join(enwiro_sdk::gear::gear_filename(cookbook.name()));
                match serde_json::to_vec(&json) {
                    Ok(bytes) => {
                        if let Err(e) = crate::usage_stats::atomic_write(&gear_path, &bytes) {
                            tracing::debug!(error = %e, "Failed to write gear file, continuing");
                        }
                    }
                    Err(e) => {
                        tracing::debug!(error = %e, "Failed to serialise gear JSON, continuing");
                    }
                }
            }
            Ok(None) => {}
            Err(e) => {
                tracing::debug!(error = %e, "Cookbook gear() returned error, continuing");
            }
        }
    }

    fn create_environment_symlink(
        &self,
        name: &str,
        env_path: &str,
    ) -> anyhow::Result<Environment> {
        let flat_name = name.replace('/', "-");
        let env_dir = Path::new(&self.config.workspaces_directory).join(&flat_name);
        std::fs::create_dir_all(&env_dir)?;
        let inner_symlink = env_dir.join(&flat_name);
        tracing::info!(name = %name, target = %env_path, "Creating environment symlink");
        if inner_symlink.is_symlink() || inner_symlink.exists() {
            std::fs::remove_file(&inner_symlink)?;
        }
        symlink(Path::new(env_path), &inner_symlink)?;
        self.notifier
            .notify_success(&format!("Created environment: {}", name));
        Environment::get_one(&self.config.workspaces_directory, &flat_name)
    }

    fn resolve_environment_name(&self, name: &Option<String>) -> anyhow::Result<String> {
        match name {
            Some(n) => Ok(n.clone()),
            None => self
                .adapter
                .get_active_environment_name()
                .context("Could not determine active environment"),
        }
    }

    pub fn get_or_cook_environment(&self, name: &Option<String>) -> anyhow::Result<Environment> {
        let resolved = self.resolve_environment_name(name)?;
        let flat_name = resolved.replace('/', "-");
        match Environment::get_one(&self.config.workspaces_directory, &flat_name) {
            Ok(env) => Ok(env),
            Err(_) if name.is_some() => self
                .cook_environment(&resolved)
                .context("Could not cook environment"),
            Err(e) => Err(e),
        }
    }

    pub fn get_all_environments(&self) -> anyhow::Result<HashMap<String, Environment>> {
        Environment::get_all(&self.config.workspaces_directory)
    }
}

#[cfg(test)]
mod tests {
    use rstest::rstest;
    use std::fs;

    use crate::test_utils::test_utilities::{
        AdapterLog, FailingCookbook, FakeContext, FakeCookbook, NotificationLog, context_object,
    };

    #[rstest]
    fn test_cook_environment_creates_symlink_for_matching_recipe(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut context_object, _, _) = context_object;

        // Create a real directory that the cookbook will "cook" (point to)
        let cooked_dir = temp_dir.path().join("cooked-target");
        fs::create_dir(&cooked_dir).unwrap();

        context_object.write_cache_entry("git", "my-project");
        context_object.cookbooks = vec![Box::new(FakeCookbook::new(
            "git",
            vec!["my-project"],
            vec![("my-project", cooked_dir.to_str().unwrap())],
        ))];

        let env = context_object.cook_environment("my-project").unwrap();
        assert_eq!(env.name, "my-project");

        // Verify directory with inner symlink was created
        let env_dir = temp_dir.path().join("my-project");
        assert!(env_dir.is_dir());
        let inner_link = env_dir.join("my-project");
        assert!(inner_link.is_symlink());
    }

    #[rstest]
    fn test_cook_environment_with_slash_in_name(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut context_object, _, _) = context_object;

        let cooked_dir = temp_dir.path().join("cooked-target");
        fs::create_dir(&cooked_dir).unwrap();

        let recipe_name = "my-project@feature/my-thing";
        context_object.write_cache_entry("git", recipe_name);
        context_object.cookbooks = vec![Box::new(FakeCookbook::new(
            "git",
            vec![recipe_name],
            vec![(recipe_name, cooked_dir.to_str().unwrap())],
        ))];

        let env = context_object.cook_environment(recipe_name).unwrap();
        assert_eq!(env.name, "my-project@feature-my-thing");

        // Verify directory with inner symlink was created
        let env_dir = temp_dir.path().join("my-project@feature-my-thing");
        assert!(env_dir.is_dir());
        let inner_link = env_dir.join("my-project@feature-my-thing");
        assert!(inner_link.is_symlink());
    }

    #[rstest]
    fn test_get_or_cook_finds_existing_environment_with_slash_in_name(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut context_object, _, _) = context_object;

        let cooked_dir = temp_dir.path().join("cooked-target");
        fs::create_dir(&cooked_dir).unwrap();

        let recipe_name = "my-project@feature/my-thing";
        context_object.write_cache_entry("git", recipe_name);
        context_object.cookbooks = vec![Box::new(FakeCookbook::new(
            "git",
            vec![recipe_name],
            vec![(recipe_name, cooked_dir.to_str().unwrap())],
        ))];

        // First call creates the environment
        let env1 = context_object
            .get_or_cook_environment(&Some(recipe_name.to_string()))
            .unwrap();

        // Second call should find the existing environment, not try to cook again
        let env2 = context_object
            .get_or_cook_environment(&Some(recipe_name.to_string()))
            .unwrap();

        assert_eq!(env1.name, env2.name);
    }

    #[rstest]
    fn test_cook_environment_errors_when_recipe_not_in_cache(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (_temp_dir, mut context_object, _, _) = context_object;
        // Cookbook can produce the recipe, but the cache does not list it.
        context_object.cookbooks = vec![Box::new(FakeCookbook::new(
            "git",
            vec!["my-project"],
            vec![],
        ))];

        let result = context_object.cook_environment("my-project");
        assert!(result.is_err());
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("daemon"),
            "Error should point at the daemon, got: {err}"
        );
    }

    #[rstest]
    fn test_cook_environment_uses_cache_to_skip_slow_cookbooks(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut context_object, _, _) = context_object;

        let cooked_dir = temp_dir.path().join("cooked-target");
        fs::create_dir(&cooked_dir).unwrap();

        // Write a cache that knows about the recipe
        let cache_dir = context_object.cache_dir.as_ref().unwrap();
        fs::create_dir_all(cache_dir).unwrap();
        fs::write(
            cache_dir.join("recipes.cache"),
            "{\"cookbook\":\"git\",\"name\":\"my-project\"}\n",
        )
        .unwrap();

        // FailingCookbook simulates a slow cookbook (like GitHub) that would
        // block or error if list_recipes() is called. The working cookbook is second.
        context_object.cookbooks = vec![
            Box::new(FailingCookbook {
                cookbook_name: "github".into(),
            }),
            Box::new(FakeCookbook::new(
                "git",
                vec!["my-project"],
                vec![("my-project", cooked_dir.to_str().unwrap())],
            )),
        ];

        // With the cache available, cook_environment should find the recipe
        // without calling list_recipes() on the failing cookbook
        let env = context_object.cook_environment("my-project").unwrap();
        assert_eq!(env.name, "my-project");
    }

    #[rstest]
    fn test_cook_environment_cache_hit_with_description(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut context_object, _, _) = context_object;

        let cooked_dir = temp_dir.path().join("cooked-target");
        fs::create_dir(&cooked_dir).unwrap();

        context_object.write_cache_entries(&[("github", "owner/repo#42", Some("Fix auth bug"))]);
        context_object.cookbooks = vec![Box::new(FakeCookbook::new(
            "github",
            vec!["owner/repo#42"],
            vec![("owner/repo#42", cooked_dir.to_str().unwrap())],
        ))];

        let env = context_object.cook_environment("owner/repo#42").unwrap();
        assert_eq!(env.name, "owner-repo#42");
    }

    #[rstest]
    fn test_cook_environment_errors_when_cache_references_uninstalled_cookbook(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut context_object, _, _) = context_object;

        let cooked_dir = temp_dir.path().join("cooked-target");
        fs::create_dir(&cooked_dir).unwrap();

        // Cache says recipe belongs to "npm" cookbook, but only "git" is installed.
        context_object.write_cache_entry("npm", "my-project");
        context_object.cookbooks = vec![Box::new(FakeCookbook::new(
            "git",
            vec!["my-project"],
            vec![("my-project", cooked_dir.to_str().unwrap())],
        ))];

        let result = context_object.cook_environment("my-project");
        assert!(result.is_err());
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("npm") && err.contains("not installed"),
            "Error should name the missing cookbook, got: {err}"
        );
    }

    #[rstest]
    fn test_get_or_cook_returns_existing_environment(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (_temp_dir, mut context_object, _, _) = context_object;
        context_object.create_mock_environment("my-env");

        let env = context_object
            .get_or_cook_environment(&Some("my-env".to_string()))
            .unwrap();
        assert_eq!(env.name, "my-env");
    }

    #[rstest]
    fn test_get_or_cook_falls_back_to_cooking(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut context_object, _, _) = context_object;

        let cooked_dir = temp_dir.path().join("cooked-target");
        fs::create_dir(&cooked_dir).unwrap();

        context_object.write_cache_entry("git", "new-project");
        context_object.cookbooks = vec![Box::new(FakeCookbook::new(
            "git",
            vec!["new-project"],
            vec![("new-project", cooked_dir.to_str().unwrap())],
        ))];

        // "new-project" doesn't exist as an environment, so it should be cooked
        let env = context_object
            .get_or_cook_environment(&Some("new-project".to_string()))
            .unwrap();
        assert_eq!(env.name, "new-project");
    }

    #[rstest]
    fn test_get_or_cook_does_not_cook_when_name_from_adapter(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (_temp_dir, mut context_object, _, _) = context_object;

        // No matching environment for "foobaz" (adapter's workspace name).
        // FailingCookbook would error if cook_environment tries list_recipes().
        context_object.cookbooks = vec![Box::new(FailingCookbook {
            cookbook_name: "github".into(),
        })];

        // Name is None → resolved from adapter → should not try to cook
        let result = context_object.get_or_cook_environment(&None);
        assert!(result.is_err());
        // Error should NOT be from FailingCookbook ("simulated failure").
        // Use Debug format to see the full anyhow error chain.
        let err_debug = format!("{:?}", result.unwrap_err());
        assert!(
            !err_debug.contains("simulated failure"),
            "Should not have called cook_environment, but got: {}",
            err_debug
        );
    }

    #[rstest]
    fn test_cook_environment_saves_cookbook_to_stats(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut context_object, _, _) = context_object;

        let cooked_dir = temp_dir.path().join("cooked-target");
        fs::create_dir(&cooked_dir).unwrap();

        context_object.write_cache_entry("git", "my-project");
        context_object.cookbooks = vec![Box::new(FakeCookbook::new(
            "git",
            vec!["my-project"],
            vec![("my-project", cooked_dir.to_str().unwrap())],
        ))];

        context_object.cook_environment("my-project").unwrap();

        let env_dir = temp_dir.path().join("my-project");
        let meta = crate::usage_stats::load_env_meta(&env_dir);
        assert_eq!(meta.cookbook.as_deref(), Some("git"));
    }

    #[rstest]
    fn test_cook_environment_stats_keyed_by_flat_name(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut context_object, _, _) = context_object;

        let cooked_dir = temp_dir.path().join("cooked-target");
        fs::create_dir(&cooked_dir).unwrap();

        context_object.write_cache_entries(&[("github", "owner/repo#42", Some("Fix auth bug"))]);
        context_object.cookbooks = vec![Box::new(FakeCookbook::new_with_descriptions(
            "github",
            vec![("owner/repo#42", Some("Fix auth bug"))],
            vec![("owner/repo#42", cooked_dir.to_str().unwrap())],
        ))];

        context_object.cook_environment("owner/repo#42").unwrap();

        // Meta should be stored in the flat-named env directory
        let env_dir = temp_dir.path().join("owner-repo#42");
        assert!(
            env_dir.is_dir(),
            "Env directory should exist with flat name"
        );
        let meta = crate::usage_stats::load_env_meta(&env_dir);
        assert_eq!(meta.description.as_deref(), Some("Fix auth bug"));
        assert_eq!(meta.cookbook.as_deref(), Some("github"));
    }

    #[rstest]
    fn test_cook_environment_sends_notification(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut context_object, _, notifications) = context_object;

        let cooked_dir = temp_dir.path().join("cooked-target");
        fs::create_dir(&cooked_dir).unwrap();

        context_object.write_cache_entry("git", "my-project");
        context_object.cookbooks = vec![Box::new(FakeCookbook::new(
            "git",
            vec!["my-project"],
            vec![("my-project", cooked_dir.to_str().unwrap())],
        ))];

        let result = context_object.cook_environment("my-project");
        assert!(result.is_ok());

        let logs = notifications.borrow();
        assert_eq!(logs.len(), 1);
        assert!(logs[0].starts_with("SUCCESS:"));
        assert!(logs[0].contains("my-project"));
    }

    #[rstest]
    fn test_cook_environment_no_notification_on_failure(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (_temp_dir, mut context_object, _, notifications) = context_object;

        context_object.cookbooks = vec![Box::new(FakeCookbook::new(
            "git",
            vec!["other-project"],
            vec![],
        ))];

        let result = context_object.cook_environment("my-project");
        assert!(result.is_err());

        let logs = notifications.borrow();
        assert_eq!(logs.len(), 0);
    }

    #[rstest]
    fn test_cook_environment_writes_gear_file_to_cookbook_named_path(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut context_object, _, _) = context_object;

        let cooked_dir = temp_dir.path().join("cooked-target");
        fs::create_dir(&cooked_dir).unwrap();

        context_object.write_cache_entry("git", "my-project");
        let gear_data = serde_json::json!({"tool": "nvim", "lsp": "rust-analyzer"});
        context_object.cookbooks = vec![Box::new(
            FakeCookbook::new(
                "git",
                vec!["my-project"],
                vec![("my-project", cooked_dir.to_str().unwrap())],
            )
            .with_gear(gear_data.clone()),
        )];

        context_object.cook_environment("my-project").unwrap();

        let gear_path = temp_dir
            .path()
            .join("my-project")
            .join("gear.d")
            .join("cookbook-git.json");
        assert!(
            gear_path.exists(),
            "gear file should exist at gear.d/cookbook-<name>.json, expected {}",
            gear_path.display()
        );
        let written: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(&gear_path).unwrap()).unwrap();
        assert_eq!(written, gear_data);
    }

    #[rstest]
    fn test_cook_environment_does_not_create_gear_dir_when_cookbook_returns_none(
        context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
    ) {
        let (temp_dir, mut context_object, _, _) = context_object;

        let cooked_dir = temp_dir.path().join("cooked-target");
        fs::create_dir(&cooked_dir).unwrap();

        context_object.write_cache_entry("git", "my-project");
        // FakeCookbook with no gear (default behaviour)
        context_object.cookbooks = vec![Box::new(FakeCookbook::new(
            "git",
            vec!["my-project"],
            vec![("my-project", cooked_dir.to_str().unwrap())],
        ))];

        context_object.cook_environment("my-project").unwrap();

        let gear_dir = temp_dir.path().join("my-project").join("gear.d");
        assert!(
            !gear_dir.exists(),
            "gear.d/ should NOT exist when cookbook returns no gear"
        );
    }
}
