//! Hook installation logic.
//!
//! This module provides programmatic access to hook installation,
//! allowing Rust code to install mi6 hooks without going through the CLI.
//!
//! # Example
//!
//! ```ignore
//! use mi6_core::{Config, framework::{InitOptions, initialize}};
//!
//! let config = Config::load()?;
//!
//! // Initialize for a single framework
//! let options = InitOptions::for_framework("claude")
//!     .otel(true)
//!     .otel_port(4318);
//!
//! let result = initialize(&config, options)?;
//! println!("Hooks installed to: {}", result.settings_path.display());
//! ```

use std::path::{Path, PathBuf};

use crate::config::{Config, DEFAULT_OTEL_PORT, OTEL_ENV_KEYS, generate_otel_env};
use crate::model::error::InitError;

use super::{
    ConfigFormat, FrameworkAdapter, InstallHooksResult, UninstallHooksResult, get_adapter,
};

/// Write content to a file atomically using a temp file and rename.
///
/// This prevents race conditions where concurrent processes reading/writing
/// the same file could see partial writes or clobber each other's changes.
/// The rename operation is atomic on POSIX systems when source and destination
/// are on the same filesystem.
fn atomic_write(path: &Path, content: impl AsRef<[u8]>) -> std::io::Result<()> {
    use std::io::Write;

    // Create temp file in the same directory to ensure same filesystem for atomic rename
    let parent = path.parent().unwrap_or(Path::new("."));
    let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
    temp_file.write_all(content.as_ref())?;
    temp_file.flush()?;

    // persist_noclobber would fail if target exists, so we use persist which overwrites
    temp_file.persist(path).map_err(std::io::Error::other)?;

    Ok(())
}

/// Options for hook initialization and activation.
///
/// This struct contains all the options needed to initialize mi6 programmatically.
#[derive(Debug, Clone)]
pub struct InitOptions {
    /// Frameworks to install hooks for (e.g., `["claude", "gemini", "codex"]`).
    /// If empty, auto-detection will be used.
    pub frameworks: Vec<String>,
    /// Install hooks to project config instead of global.
    pub local: bool,
    /// Install hooks to project local config (not committed to git).
    pub settings_local: bool,
    /// Configure OpenTelemetry env vars for token tracking.
    pub otel: bool,
    /// Port for OTel server.
    pub otel_port: u16,
    /// Remove OTel configuration (disables token tracking).
    pub remove_otel: bool,
    /// Only initialize database, skip hook installation (high-level API only).
    pub db_only: bool,
    /// Only install hooks, skip database initialization (high-level API only).
    pub hooks_only: bool,
}

impl Default for InitOptions {
    fn default() -> Self {
        Self {
            frameworks: vec![],
            local: false,
            settings_local: false,
            otel: false,
            otel_port: DEFAULT_OTEL_PORT,
            remove_otel: false,
            db_only: false,
            hooks_only: false,
        }
    }
}

impl InitOptions {
    /// Create new InitOptions with default values.
    pub fn new() -> Self {
        Self::default()
    }

    /// Create InitOptions for a single framework.
    pub fn for_framework(framework: impl Into<String>) -> Self {
        Self {
            frameworks: vec![framework.into()],
            ..Default::default()
        }
    }

    /// Create InitOptions for multiple frameworks.
    pub fn for_frameworks(frameworks: Vec<String>) -> Self {
        Self {
            frameworks,
            ..Default::default()
        }
    }

    /// Get the list of frameworks to initialize.
    pub fn get_frameworks(&self) -> Vec<String> {
        self.frameworks.clone()
    }

    /// Set install location to project config instead of global.
    pub fn local(mut self, local: bool) -> Self {
        self.local = local;
        self
    }

    /// Set install location to project local config (not committed to git).
    pub fn settings_local(mut self, settings_local: bool) -> Self {
        self.settings_local = settings_local;
        self
    }

    /// Enable OpenTelemetry configuration for token tracking.
    pub fn otel(mut self, otel: bool) -> Self {
        self.otel = otel;
        self
    }

    /// Set the port for OTel server.
    pub fn otel_port(mut self, port: u16) -> Self {
        self.otel_port = port;
        self
    }

    /// Remove OTel configuration (disables token tracking).
    pub fn remove_otel(mut self, remove: bool) -> Self {
        self.remove_otel = remove;
        self
    }

    /// Only initialize database, skip hook installation.
    pub fn db_only(mut self, db_only: bool) -> Self {
        self.db_only = db_only;
        self
    }

    /// Only install hooks, skip database initialization.
    pub fn hooks_only(mut self, hooks_only: bool) -> Self {
        self.hooks_only = hooks_only;
        self
    }
}

/// Result of initialization.
///
/// Contains information about what was initialized and where.
#[derive(Debug, Clone)]
pub struct InitResult {
    /// Path to the database (if database initialization was requested).
    pub db_path: Option<PathBuf>,
    /// Path where settings/hooks were installed.
    pub settings_path: PathBuf,
    /// The hooks configuration that was generated.
    pub hooks_config: serde_json::Value,
    /// Shell commands that were executed during installation.
    pub commands_run: Vec<String>,
}

/// Convert a serde_json::Value to a TOML string.
///
/// Useful for frameworks that use TOML configuration files.
pub fn json_to_toml_string(json: &serde_json::Value) -> Result<String, InitError> {
    let toml_value: toml::Value = serde_json::from_value(json.clone())
        .map_err(|e| InitError::Config(format!("failed to convert JSON to TOML: {e}")))?;
    toml::to_string_pretty(&toml_value)
        .map_err(|e| InitError::Config(format!("failed to serialize TOML: {e}")))
}

/// Initialize mi6 for a single framework.
///
/// This is the main entry point for programmatic initialization of a single
/// framework. It:
/// 1. Resolves the framework adapter
/// 2. Generates the hooks configuration
/// 3. Installs the configuration to the appropriate settings file
pub fn initialize(config: &Config, options: InitOptions) -> Result<InitResult, InitError> {
    let frameworks = options.get_frameworks();
    let framework = frameworks
        .first()
        .cloned()
        .unwrap_or_else(|| "claude".to_string());

    initialize_framework(config, &framework, &options)
}

/// Initialize mi6 for multiple frameworks.
///
/// This function initializes mi6 for all specified frameworks, returning
/// a result for each one.
pub fn initialize_all(config: &Config, options: InitOptions) -> Result<Vec<InitResult>, InitError> {
    let frameworks = options.get_frameworks();
    let mut results = Vec::with_capacity(frameworks.len());

    for framework in &frameworks {
        let result = initialize_framework(config, framework, &options)?;
        results.push(result);
    }

    Ok(results)
}

/// Initialize mi6 for a specific framework.
pub fn initialize_framework(
    config: &Config,
    framework: &str,
    options: &InitOptions,
) -> Result<InitResult, InitError> {
    let adapter =
        get_adapter(framework).ok_or_else(|| InitError::UnknownFramework(framework.to_string()))?;

    let settings_path = adapter.settings_path(options.local, options.settings_local)?;
    let enabled_events = config.hooks.enabled_hooks();
    let hooks_json =
        adapter.generate_hooks_config(&enabled_events, "mi6", options.otel, options.otel_port);

    let otel_env = if options.otel {
        Some(generate_otel_env(options.otel_port))
    } else {
        None
    };

    let install_result = adapter.install_hooks(
        &settings_path,
        &hooks_json,
        otel_env.clone(),
        options.remove_otel,
    )?;

    // Build final hooks_config for return value (include env if otel enabled)
    let mut hooks_config = hooks_json;
    if let Some(env) = otel_env {
        hooks_config["env"] = env;
    }

    Ok(InitResult {
        db_path: Config::db_path().ok(),
        settings_path,
        hooks_config,
        commands_run: install_result.commands_run,
    })
}

/// Generate hooks configuration without writing to disk.
///
/// This is useful for previewing what would be installed (e.g., `mi6 enable --print`).
pub fn generate_config(
    adapter: &dyn FrameworkAdapter,
    config: &Config,
    options: &InitOptions,
) -> serde_json::Value {
    let enabled_events = config.hooks.enabled_hooks();
    let mut hooks_json =
        adapter.generate_hooks_config(&enabled_events, "mi6", options.otel, options.otel_port);

    if options.otel {
        hooks_json["env"] = generate_otel_env(options.otel_port);
    }

    hooks_json
}

// ============================================================================
// Default trait method implementations
// ============================================================================

/// Resolve the settings path for a framework.
pub(crate) fn default_settings_path<A: FrameworkAdapter + ?Sized>(
    adapter: &A,
    local: bool,
    settings_local: bool,
) -> Result<PathBuf, InitError> {
    if settings_local {
        let project_path = adapter.project_config_path();
        let file_name = project_path
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or("settings");
        let extension = project_path
            .extension()
            .and_then(|s| s.to_str())
            .unwrap_or("json");
        let local_name = format!("{file_name}.local.{extension}");
        Ok(project_path.with_file_name(local_name))
    } else if local {
        Ok(adapter.project_config_path())
    } else {
        adapter
            .user_config_path()
            .ok_or_else(|| InitError::SettingsPath("failed to determine home directory".into()))
    }
}

/// Check if a framework has mi6 hooks active in its config file.
pub(crate) fn default_has_mi6_hooks<A: FrameworkAdapter + ?Sized>(
    adapter: &A,
    local: bool,
    settings_local: bool,
) -> bool {
    let Ok(settings_path) = adapter.settings_path(local, settings_local) else {
        return false;
    };

    if !settings_path.exists() {
        return false;
    }

    match adapter.config_format() {
        ConfigFormat::Json => {
            let Ok(contents) = std::fs::read_to_string(&settings_path) else {
                return false;
            };
            let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) else {
                return false;
            };
            adapter.remove_hooks(json).is_some()
        }
        ConfigFormat::Toml => {
            let Ok(contents) = std::fs::read_to_string(&settings_path) else {
                return false;
            };
            let Ok(toml_val) = toml::from_str::<toml::Value>(&contents) else {
                return false;
            };
            let Ok(json) = serde_json::to_value(toml_val) else {
                return false;
            };
            adapter.remove_hooks(json).is_some()
        }
    }
}

/// Install hooks configuration to a settings file.
pub(crate) fn default_install_hooks<A: FrameworkAdapter + ?Sized>(
    adapter: &A,
    path: &Path,
    hooks: &serde_json::Value,
    otel_env: Option<serde_json::Value>,
    remove_otel: bool,
) -> Result<InstallHooksResult, InitError> {
    // Create parent directory if needed
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }

    match adapter.config_format() {
        ConfigFormat::Json => install_json_settings(adapter, path, hooks, otel_env, remove_otel),
        ConfigFormat::Toml => install_toml_settings(adapter, path, hooks, remove_otel),
    }
}

/// Install settings to a JSON configuration file.
fn install_json_settings<A: FrameworkAdapter + ?Sized>(
    adapter: &A,
    settings_path: &Path,
    new_hooks: &serde_json::Value,
    otel_env: Option<serde_json::Value>,
    remove_otel: bool,
) -> Result<InstallHooksResult, InitError> {
    let existing: Option<serde_json::Value> = if settings_path.exists() {
        let contents = std::fs::read_to_string(settings_path)?;
        match serde_json::from_str(&contents) {
            Ok(v) => Some(v),
            Err(e) => {
                return Err(InitError::InvalidSettings {
                    path: settings_path.to_path_buf(),
                    format: "JSON",
                    error: e.to_string(),
                });
            }
        }
    } else {
        None
    };

    let mut settings = adapter.merge_config(new_hooks.clone(), existing);

    if remove_otel {
        if let Some(existing_env) = settings.get_mut("env")
            && let Some(env_obj) = existing_env.as_object_mut()
        {
            for key in OTEL_ENV_KEYS {
                env_obj.remove(*key);
            }
        }
    } else if let Some(new_env) = otel_env {
        if let Some(existing_env) = settings.get_mut("env") {
            if let (Some(existing_obj), Some(new_obj)) =
                (existing_env.as_object_mut(), new_env.as_object())
            {
                for (key, value) in new_obj {
                    existing_obj.insert(key.clone(), value.clone());
                }
            }
        } else {
            settings["env"] = new_env;
        }
    }

    let output = serde_json::to_string_pretty(&settings)
        .map_err(|e| InitError::Config(format!("failed to serialize JSON: {e}")))?;
    atomic_write(settings_path, output)?;

    // Config-based installation doesn't run any shell commands
    Ok(InstallHooksResult::default())
}

/// Install settings to a TOML configuration file.
fn install_toml_settings<A: FrameworkAdapter + ?Sized>(
    adapter: &A,
    settings_path: &Path,
    new_config: &serde_json::Value,
    remove_otel: bool,
) -> Result<InstallHooksResult, InitError> {
    let existing: Option<serde_json::Value> =
        if settings_path.exists() {
            let contents = std::fs::read_to_string(settings_path)?;
            match toml::from_str::<toml::Value>(&contents) {
                Ok(toml_val) => Some(serde_json::to_value(toml_val).map_err(|e| {
                    InitError::Config(format!("failed to convert TOML to JSON: {e}"))
                })?),
                Err(e) => {
                    return Err(InitError::InvalidSettings {
                        path: settings_path.to_path_buf(),
                        format: "TOML",
                        error: e.to_string(),
                    });
                }
            }
        } else {
            None
        };

    let mut settings = adapter.merge_config(new_config.clone(), existing);

    if remove_otel && let Some(obj) = settings.as_object_mut() {
        obj.remove("otel");
    }

    let toml_str = json_to_toml_string(&settings)?;
    atomic_write(settings_path, toml_str)?;

    // Config-based installation doesn't run any shell commands
    Ok(InstallHooksResult::default())
}

/// Serialize configuration to string in the appropriate format.
pub(crate) fn default_serialize_config<A: FrameworkAdapter + ?Sized>(
    adapter: &A,
    config: &serde_json::Value,
) -> Result<String, InitError> {
    match adapter.config_format() {
        ConfigFormat::Json => serde_json::to_string_pretty(config)
            .map_err(|e| InitError::Config(format!("failed to serialize JSON: {e}"))),
        ConfigFormat::Toml => json_to_toml_string(config),
    }
}

/// Uninstall mi6 hooks from a framework's configuration.
///
/// This is the default implementation that removes mi6 hooks from a settings file.
/// Frameworks with plugin-based installation (like Claude) should override this.
pub(crate) fn default_uninstall_hooks<A: FrameworkAdapter + ?Sized>(
    adapter: &A,
    local: bool,
    settings_local: bool,
) -> Result<UninstallHooksResult, InitError> {
    let settings_path = adapter.settings_path(local, settings_local)?;

    if !settings_path.exists() {
        return Ok(UninstallHooksResult {
            hooks_removed: false,
            commands_run: vec![],
        });
    }

    let contents = std::fs::read_to_string(&settings_path)?;

    let existing: serde_json::Value = match adapter.config_format() {
        ConfigFormat::Json => serde_json::from_str(&contents)
            .map_err(|e| InitError::Config(format!("failed to parse JSON: {e}")))?,
        ConfigFormat::Toml => {
            let toml_val: toml::Value = toml::from_str(&contents)
                .map_err(|e| InitError::Config(format!("failed to parse TOML: {e}")))?;
            serde_json::to_value(toml_val)
                .map_err(|e| InitError::Config(format!("failed to convert TOML to JSON: {e}")))?
        }
    };

    // Try to remove mi6 hooks
    if let Some(modified) = adapter.remove_hooks(existing) {
        // Write back the modified config
        let output = match adapter.config_format() {
            ConfigFormat::Json => serde_json::to_string_pretty(&modified)
                .map_err(|e| InitError::Config(format!("failed to serialize JSON: {e}")))?,
            ConfigFormat::Toml => json_to_toml_string(&modified)?,
        };
        atomic_write(&settings_path, output)?;
        Ok(UninstallHooksResult {
            hooks_removed: true,
            commands_run: vec![],
        })
    } else {
        Ok(UninstallHooksResult {
            hooks_removed: false,
            commands_run: vec![],
        })
    }
}

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

    #[test]
    fn test_init_options_default() {
        let opts = InitOptions::default();
        assert!(opts.frameworks.is_empty());
        assert!(!opts.local);
        assert!(!opts.settings_local);
        assert!(!opts.otel);
        assert_eq!(opts.otel_port, 4318);
        assert!(!opts.remove_otel);
    }

    #[test]
    fn test_init_options_for_framework() {
        let opts = InitOptions::for_framework("claude");
        assert_eq!(opts.get_frameworks(), vec!["claude"]);
        assert!(!opts.local);
        assert!(!opts.otel);
    }

    #[test]
    fn test_init_options_for_frameworks() {
        let opts = InitOptions::for_frameworks(vec!["claude".into(), "gemini".into()]);
        assert_eq!(opts.get_frameworks(), vec!["claude", "gemini"]);
    }

    #[test]
    fn test_init_options_builder() {
        let opts = InitOptions::for_framework("claude")
            .local(true)
            .otel(true)
            .otel_port(9999);
        assert_eq!(opts.get_frameworks(), vec!["claude"]);
        assert!(opts.local);
        assert!(opts.otel);
        assert_eq!(opts.otel_port, 9999);
    }

    #[test]
    fn test_init_error_unknown_framework() {
        let config = Config::default();
        let options = InitOptions::for_framework("unknown");

        let result = initialize(&config, options);
        assert!(matches!(result, Err(InitError::UnknownFramework(_))));
    }

    #[test]
    fn test_settings_path_global() -> Result<(), String> {
        let adapter = ClaudeAdapter;
        let path = adapter
            .settings_path(false, false)
            .map_err(|e| e.to_string())?;
        // Claude returns the plugin directory path
        assert!(
            path.to_string_lossy().contains(".mi6/claude-plugin"),
            "expected .mi6/claude-plugin path, got: {}",
            path.display()
        );
        Ok(())
    }

    #[test]
    fn test_settings_path_local() -> Result<(), String> {
        let adapter = ClaudeAdapter;
        let path = adapter
            .settings_path(true, false)
            .map_err(|e| e.to_string())?;
        // Claude marketplace installation doesn't distinguish local vs global
        assert!(
            path.to_string_lossy().contains(".mi6/claude-plugin"),
            "expected .mi6/claude-plugin path, got: {}",
            path.display()
        );
        Ok(())
    }

    #[test]
    fn test_settings_path_settings_local() -> Result<(), String> {
        // For Claude plugins, settings_local is ignored (plugins don't have a .local variant)
        // Test with Codex which still uses config files
        let adapter = crate::CodexAdapter;
        let path = adapter
            .settings_path(false, true)
            .map_err(|e| e.to_string())?;
        assert!(path.to_string_lossy().contains(".local."));
        Ok(())
    }

    #[test]
    fn test_json_to_toml_string() -> Result<(), String> {
        let json = serde_json::json!({
            "key": "value",
            "number": 42
        });
        let toml_str = json_to_toml_string(&json).map_err(|e| e.to_string())?;
        assert!(toml_str.contains("key = \"value\""));
        assert!(toml_str.contains("number = 42"));
        Ok(())
    }

    #[test]
    fn test_generate_config() {
        let config = Config::default();
        let adapter = ClaudeAdapter;
        let options = InitOptions::for_framework("claude")
            .otel(true)
            .otel_port(4318);

        let config_json = generate_config(&adapter, &config, &options);
        assert!(config_json["hooks"].is_object());
        assert!(config_json["env"].is_object());
    }

    #[test]
    fn test_serialize_config_json() -> Result<(), String> {
        let adapter = ClaudeAdapter;
        let config = serde_json::json!({"key": "value"});

        let output = adapter
            .serialize_config(&config)
            .map_err(|e| e.to_string())?;
        assert!(output.contains("\"key\""));
        assert!(output.contains("\"value\""));
        Ok(())
    }

    #[test]
    fn test_serialize_config_toml() -> Result<(), String> {
        let adapter = crate::CodexAdapter;
        let config = serde_json::json!({"key": "value"});

        let output = adapter
            .serialize_config(&config)
            .map_err(|e| e.to_string())?;
        assert!(output.contains("key = \"value\""));
        Ok(())
    }

    #[test]
    fn test_install_json_settings_invalid_json_error() {
        let temp_dir = tempfile::tempdir().unwrap();
        let settings_path = temp_dir.path().join("settings.json");

        // Write invalid JSON (missing comma)
        std::fs::write(&settings_path, r#"{"key": "value" "another": "bad"}"#).unwrap();

        let adapter = ClaudeAdapter;
        let hooks = serde_json::json!({"hooks": {}});

        let result = install_json_settings(&adapter, &settings_path, &hooks, None, false);

        match result {
            Err(InitError::InvalidSettings {
                path,
                format,
                error,
            }) => {
                assert_eq!(path, settings_path);
                assert_eq!(format, "JSON");
                assert!(!error.is_empty());
            }
            other => panic!("expected InvalidSettings error, got: {other:?}"),
        }
    }

    #[test]
    fn test_install_json_settings_valid_json_succeeds() {
        let temp_dir = tempfile::tempdir().unwrap();
        let settings_path = temp_dir.path().join("settings.json");

        // Write valid JSON
        std::fs::write(&settings_path, r#"{"existing": "config"}"#).unwrap();

        // Use GeminiAdapter which properly merges existing config
        let adapter = crate::GeminiAdapter;
        let hooks = serde_json::json!({"hooks": {"PreToolUse": "mi6 ingest event"}});

        let result = install_json_settings(&adapter, &settings_path, &hooks, None, false);
        assert!(result.is_ok(), "expected success, got: {result:?}");

        // Verify existing config is preserved
        let contents = std::fs::read_to_string(&settings_path).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
        assert_eq!(parsed["existing"], "config");
        assert!(parsed["hooks"].is_object());
    }

    #[test]
    fn test_install_toml_settings_invalid_toml_error() {
        let temp_dir = tempfile::tempdir().unwrap();
        let settings_path = temp_dir.path().join("config.toml");

        // Write invalid TOML (unquoted string value)
        std::fs::write(&settings_path, "key = bad value without quotes").unwrap();

        let adapter = crate::CodexAdapter;
        let config = serde_json::json!({"hooks": {}});

        let result = install_toml_settings(&adapter, &settings_path, &config, false);

        match result {
            Err(InitError::InvalidSettings {
                path,
                format,
                error,
            }) => {
                assert_eq!(path, settings_path);
                assert_eq!(format, "TOML");
                assert!(!error.is_empty());
            }
            other => panic!("expected InvalidSettings error, got: {other:?}"),
        }
    }

    #[test]
    fn test_install_toml_settings_valid_toml_succeeds() {
        let temp_dir = tempfile::tempdir().unwrap();
        let settings_path = temp_dir.path().join("config.toml");

        // Write valid TOML
        std::fs::write(&settings_path, r#"existing = "config""#).unwrap();

        let adapter = crate::CodexAdapter;
        let config = serde_json::json!({"notify": "command"});

        let result = install_toml_settings(&adapter, &settings_path, &config, false);
        assert!(result.is_ok(), "expected success, got: {result:?}");

        // Verify file was updated
        let contents = std::fs::read_to_string(&settings_path).unwrap();
        assert!(contents.contains("existing"));
        assert!(contents.contains("notify"));
    }

    #[test]
    fn test_install_json_settings_nonexistent_file_succeeds() {
        let temp_dir = tempfile::tempdir().unwrap();
        let settings_path = temp_dir.path().join("settings.json");

        // Don't create the file - it shouldn't exist

        let adapter = ClaudeAdapter;
        let hooks = serde_json::json!({"hooks": {"PreToolUse": "mi6 ingest event"}});

        let result = install_json_settings(&adapter, &settings_path, &hooks, None, false);
        assert!(
            result.is_ok(),
            "expected success for nonexistent file, got: {result:?}"
        );

        // Verify file was created
        assert!(settings_path.exists());
    }

    #[test]
    fn test_install_toml_settings_nonexistent_file_succeeds() {
        let temp_dir = tempfile::tempdir().unwrap();
        let settings_path = temp_dir.path().join("config.toml");

        // Don't create the file - it shouldn't exist

        let adapter = crate::CodexAdapter;
        let config = serde_json::json!({"notify": "command"});

        let result = install_toml_settings(&adapter, &settings_path, &config, false);
        assert!(
            result.is_ok(),
            "expected success for nonexistent file, got: {result:?}"
        );

        // Verify file was created
        assert!(settings_path.exists());
    }
}
