//! OpenCode framework adapter.
//!
//! This adapter handles integration with OpenCode, an open-source AI coding agent.
//! mi6 integrates via OpenCode's plugin system, which provides rich event coverage.
//!
//! # Installation
//!
//! `mi6 enable opencode` installs the mi6 plugin to `~/.config/opencode/plugin/mi6.ts`.
//! The plugin captures various events and forwards them to mi6 via the CLI.
//!
//! # OpenTelemetry Support
//!
//! The plugin automatically starts the mi6 OTEL server on session start to receive
//! telemetry data from OpenCode's experimental OpenTelemetry support.
//!
//! Environment variables:
//! - `MI6_OTEL_PORT`: OTEL server port (default: 4318)
//!
//! To enable OTEL in OpenCode, add to your OpenCode config:
//! ```json
//! { "experimental": { "openTelemetry": true } }
//! ```
//!
//! # Supported Events
//!
//! The plugin captures these events:
//! - `session.created` → SessionStart
//! - `session.idle` → Stop
//! - `session.compacted` → PreCompact
//! - `session.deleted` → SessionEnd
//! - `tool.execute.before` → PreToolUse
//! - `tool.execute.after` → PostToolUse
//! - `permission.updated` → PermissionRequest
//! - `file.edited` → FileEdited (custom)
//! - `command.executed` → CommandExecuted (custom)
//! - `session.error` → SessionError (custom)
//!
//! # Uninstallation
//!
//! `mi6 disable opencode` removes the plugin file.

use super::{
    FrameworkAdapter, InstallHooksResult, ParsedHookInput, ParsedHookInputBuilder,
    UninstallHooksResult, common,
};
use crate::model::EventType;
use crate::model::error::InitError;
use std::path::{Path, PathBuf};

/// Embedded mi6 plugin TypeScript source.
const PLUGIN_TS: &str = include_str!("opencode-plugin/mi6.ts");

/// Plugin filename.
const PLUGIN_FILENAME: &str = "mi6.ts";

/// OpenCode framework adapter.
///
/// Handles plugin installation and event parsing for OpenCode.
pub struct OpenCodeAdapter;

impl OpenCodeAdapter {
    /// Get the path to the OpenCode plugin directory.
    ///
    /// OpenCode follows XDG conventions and expects plugins at `~/.config/opencode/plugin/`
    /// regardless of platform. We use `dirs::home_dir()` instead of `dirs::config_dir()`
    /// because the latter returns `~/Library/Application Support/` on macOS.
    fn plugin_dir() -> Option<PathBuf> {
        dirs::home_dir().map(|h| h.join(".config/opencode/plugin"))
    }

    /// Get the path to the mi6 plugin file.
    fn plugin_path() -> Option<PathBuf> {
        Self::plugin_dir().map(|d| d.join(PLUGIN_FILENAME))
    }

    /// Check if the mi6 plugin is installed.
    fn is_plugin_installed() -> bool {
        Self::plugin_path().is_some_and(|p| p.exists())
    }
}

impl FrameworkAdapter for OpenCodeAdapter {
    fn name(&self) -> &'static str {
        "opencode"
    }

    fn display_name(&self) -> &'static str {
        "OpenCode"
    }

    fn project_config_path(&self) -> PathBuf {
        // For plugin-based installation, return the plugin path
        PathBuf::from(".opencode/plugin/mi6.ts")
    }

    fn user_config_path(&self) -> Option<PathBuf> {
        Self::plugin_path()
    }

    fn generate_hooks_config(
        &self,
        _enabled_events: &[EventType],
        _mi6_bin: &str,
        otel_enabled: bool,
        otel_port: u16,
    ) -> serde_json::Value {
        // For --print mode, show information about the plugin
        let mut config = serde_json::json!({
            "plugin": {
                "path": Self::plugin_path().map(|p| p.to_string_lossy().to_string()),
                "events": self.supported_events()
            }
        });

        // Include OTEL info if enabled
        if otel_enabled {
            config["otel"] = serde_json::json!({
                "enabled": true,
                "port": otel_port,
                "env_var": "MI6_OTEL_PORT",
                "note": "Set MI6_OTEL_PORT env var to configure. Plugin starts OTEL server on session start."
            });
        }

        config
    }

    fn merge_config(
        &self,
        generated: serde_json::Value,
        _existing: Option<serde_json::Value>,
    ) -> serde_json::Value {
        // Plugin-based: no config merging needed
        generated
    }

    fn parse_hook_input(
        &self,
        event_type: &str,
        stdin_json: &serde_json::Value,
    ) -> ParsedHookInput {
        // OpenCode plugin event payloads vary by event type.
        // We extract common fields using fallback paths since the plugin may
        // pass different field names depending on OpenCode's internal event structure.
        let mut parsed = ParsedHookInputBuilder::new(stdin_json)
            .session_id_or(&["sessionId", "session_id", "id"])
            .cwd_or(&["cwd", "workingDirectory", "directory"])
            .tool_use_id_or(&["toolUseId", "tool_use_id", "executionId"])
            .model_or(&["model", "modelID"])
            .tokens_input("tokens_input")
            .tokens_output("tokens_output")
            .tokens_cache_read("tokens_cache_read")
            .tokens_cache_write("tokens_cache_write")
            .cost_usd("cost_usd")
            .prompt("prompt")
            .build();

        // Only extract tool_name for tool-related events to avoid false positives
        // from generic "name" fields in non-tool event payloads
        if event_type.contains("tool") {
            parsed.tool_name = ParsedHookInputBuilder::new(stdin_json)
                .tool_name_or(&["toolName", "tool_name", "name"])
                .build()
                .tool_name;
        }

        parsed
        // Note: OpenCode doesn't provide subagent_type, spawned_agent_id, permission_mode,
        // transcript_path, session_source, agent_id, agent_transcript_path, compact_trigger,
        // or duration_ms via hooks
    }

    fn map_event_type(&self, framework_event: &str) -> EventType {
        match framework_event {
            // Canonical event mappings (from plugin)
            "SessionStart" => EventType::SessionStart,
            "SessionEnd" => EventType::SessionEnd,
            "Stop" => EventType::Stop,
            "UserPromptSubmit" => EventType::UserPromptSubmit,
            "ApiRequest" => EventType::ApiRequest,
            "PreToolUse" => EventType::PreToolUse,
            "PostToolUse" => EventType::PostToolUse,
            "PreCompact" => EventType::PreCompact,
            "PermissionRequest" => EventType::PermissionRequest,

            // Custom events (OpenCode-specific)
            "FileEdited" => EventType::Custom("FileEdited".to_string()),
            "CommandExecuted" => EventType::Custom("CommandExecuted".to_string()),
            "SessionError" => EventType::Custom("SessionError".to_string()),

            // Legacy experimental hook events (for backward compatibility)
            "session_completed" => EventType::SessionEnd,
            "file_edited" => EventType::Custom("FileEdited".to_string()),

            // Try canonical parsing, fall back to custom
            other => other
                .parse()
                .unwrap_or_else(|_| EventType::Custom(other.to_string())),
        }
    }

    fn supported_events(&self) -> Vec<&'static str> {
        // All events captured by the plugin
        vec![
            "SessionStart",
            "SessionEnd",
            "Stop",
            "UserPromptSubmit",
            "ApiRequest",
            "PreToolUse",
            "PostToolUse",
            "PreCompact",
            "PermissionRequest",
            "FileEdited",
            "CommandExecuted",
            "SessionError",
        ]
    }

    fn framework_specific_events(&self) -> Vec<&'static str> {
        // OpenCode-specific events with no canonical equivalent
        vec!["FileEdited", "CommandExecuted", "SessionError"]
    }

    fn detection_env_vars(&self) -> &[&'static str] {
        // Environment variables that OpenCode may set during plugin execution.
        // Note: These are inferred and may not be set. If detection doesn't work,
        // users should specify --framework opencode explicitly.
        &["OPENCODE_CONFIG", "OPENCODE_CONFIG_DIR"]
    }

    fn is_installed(&self) -> bool {
        // OpenCode uses ~/.config/opencode/ following XDG conventions
        common::is_framework_installed(
            dirs::home_dir().map(|h| h.join(".config/opencode")),
            "opencode",
        )
    }

    fn remove_hooks(&self, _existing: serde_json::Value) -> Option<serde_json::Value> {
        // Plugin-based: no config modification needed
        None
    }

    // ========================================================================
    // Plugin-based installation
    // ========================================================================

    fn settings_path(&self, _local: bool, _settings_local: bool) -> Result<PathBuf, InitError> {
        // Return the plugin path
        Self::plugin_path()
            .ok_or_else(|| InitError::Config("could not determine plugin path".to_string()))
    }

    fn has_mi6_hooks(&self, _local: bool, _settings_local: bool) -> bool {
        Self::is_plugin_installed()
    }

    fn install_hooks(
        &self,
        _path: &Path,
        _hooks: &serde_json::Value,
        _otel_env: Option<serde_json::Value>,
        _remove_otel: bool,
    ) -> Result<InstallHooksResult, InitError> {
        // Get the plugin directory
        let plugin_dir = Self::plugin_dir()
            .ok_or_else(|| InitError::Config("could not determine plugin directory".to_string()))?;

        // Create the plugin directory if it doesn't exist
        if !plugin_dir.exists() {
            std::fs::create_dir_all(&plugin_dir).map_err(|e| {
                InitError::Config(format!(
                    "failed to create plugin directory {}: {}",
                    plugin_dir.display(),
                    e
                ))
            })?;
        }

        // Write the plugin file
        let plugin_path = plugin_dir.join(PLUGIN_FILENAME);
        std::fs::write(&plugin_path, PLUGIN_TS).map_err(|e| {
            InitError::Config(format!(
                "failed to write plugin file {}: {}",
                plugin_path.display(),
                e
            ))
        })?;

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

    fn uninstall_hooks(
        &self,
        _local: bool,
        _settings_local: bool,
    ) -> Result<UninstallHooksResult, InitError> {
        let Some(plugin_path) = Self::plugin_path() else {
            return Ok(UninstallHooksResult {
                hooks_removed: false,
                commands_run: vec![],
            });
        };

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

        // Remove the plugin file
        std::fs::remove_file(&plugin_path).map_err(|e| {
            InitError::Config(format!(
                "failed to remove plugin file {}: {}",
                plugin_path.display(),
                e
            ))
        })?;

        Ok(UninstallHooksResult {
            hooks_removed: true,
            commands_run: vec![],
        })
    }
}

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

    #[test]
    fn test_name() {
        let adapter = OpenCodeAdapter;
        assert_eq!(adapter.name(), "opencode");
        assert_eq!(adapter.display_name(), "OpenCode");
    }

    #[test]
    fn test_project_config_path() {
        let adapter = OpenCodeAdapter;
        assert_eq!(
            adapter.project_config_path(),
            PathBuf::from(".opencode/plugin/mi6.ts")
        );
    }

    #[test]
    fn test_map_event_type() {
        let adapter = OpenCodeAdapter;

        // Canonical events from plugin
        assert_eq!(
            adapter.map_event_type("SessionStart"),
            EventType::SessionStart
        );
        assert_eq!(adapter.map_event_type("SessionEnd"), EventType::SessionEnd);
        assert_eq!(adapter.map_event_type("Stop"), EventType::Stop);
        assert_eq!(adapter.map_event_type("PreToolUse"), EventType::PreToolUse);
        assert_eq!(
            adapter.map_event_type("PostToolUse"),
            EventType::PostToolUse
        );
        assert_eq!(adapter.map_event_type("PreCompact"), EventType::PreCompact);
        assert_eq!(
            adapter.map_event_type("PermissionRequest"),
            EventType::PermissionRequest
        );

        // OpenCode-specific events
        assert_eq!(
            adapter.map_event_type("FileEdited"),
            EventType::Custom("FileEdited".to_string())
        );
        assert_eq!(
            adapter.map_event_type("CommandExecuted"),
            EventType::Custom("CommandExecuted".to_string())
        );
        assert_eq!(
            adapter.map_event_type("SessionError"),
            EventType::Custom("SessionError".to_string())
        );

        // Legacy experimental hook events
        assert_eq!(
            adapter.map_event_type("session_completed"),
            EventType::SessionEnd
        );
        assert_eq!(
            adapter.map_event_type("file_edited"),
            EventType::Custom("FileEdited".to_string())
        );

        // Unknown events become Custom
        assert_eq!(
            adapter.map_event_type("unknown_event"),
            EventType::Custom("unknown_event".to_string())
        );
    }

    #[test]
    fn test_parse_hook_input_session_event() {
        let adapter = OpenCodeAdapter;
        let input = serde_json::json!({
            "sessionId": "opencode-session-123",
            "cwd": "/projects/test"
        });

        let parsed = adapter.parse_hook_input("SessionStart", &input);

        assert_eq!(parsed.session_id, Some("opencode-session-123".to_string()));
        assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
        assert_eq!(parsed.tool_name, None);
        assert_eq!(parsed.tool_use_id, None);
    }

    #[test]
    fn test_parse_hook_input_tool_event() {
        let adapter = OpenCodeAdapter;
        let input = serde_json::json!({
            "sessionId": "opencode-session-123",
            "toolName": "shell",
            "executionId": "exec-456",
            "cwd": "/projects/test"
        });

        let parsed = adapter.parse_hook_input("tool.execute.before", &input);

        assert_eq!(parsed.session_id, Some("opencode-session-123".to_string()));
        assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
        assert_eq!(parsed.tool_name, Some("shell".to_string()));
        assert_eq!(parsed.tool_use_id, Some("exec-456".to_string()));
    }

    #[test]
    fn test_parse_hook_input_alternative_keys() {
        let adapter = OpenCodeAdapter;
        let input = serde_json::json!({
            "session_id": "opencode-456",
            "workingDirectory": "/home/user/project",
            "tool_name": "read",
            "tool_use_id": "tool-789"
        });

        let parsed = adapter.parse_hook_input("tool.execute.after", &input);

        assert_eq!(parsed.session_id, Some("opencode-456".to_string()));
        assert_eq!(parsed.cwd, Some("/home/user/project".to_string()));
        assert_eq!(parsed.tool_name, Some("read".to_string()));
        assert_eq!(parsed.tool_use_id, Some("tool-789".to_string()));
    }

    #[test]
    fn test_supported_events() {
        let adapter = OpenCodeAdapter;
        let events = adapter.supported_events();

        assert!(events.contains(&"SessionStart"));
        assert!(events.contains(&"SessionEnd"));
        assert!(events.contains(&"Stop"));
        assert!(events.contains(&"PreToolUse"));
        assert!(events.contains(&"PostToolUse"));
        assert!(events.contains(&"UserPromptSubmit"));
        assert!(events.contains(&"PreCompact"));
        assert!(events.contains(&"PermissionRequest"));
        assert!(events.contains(&"FileEdited"));
        assert!(events.contains(&"CommandExecuted"));
        assert!(events.contains(&"SessionError"));
        assert!(events.contains(&"ApiRequest"));
        assert_eq!(events.len(), 12);
    }

    #[test]
    fn test_framework_specific_events() {
        let adapter = OpenCodeAdapter;
        let events = adapter.framework_specific_events();

        assert!(events.contains(&"FileEdited"));
        assert!(events.contains(&"CommandExecuted"));
        assert!(events.contains(&"SessionError"));
        assert_eq!(events.len(), 3);
    }

    #[test]
    fn test_detection_env_vars() {
        let adapter = OpenCodeAdapter;
        let vars = adapter.detection_env_vars();

        assert!(vars.contains(&"OPENCODE_CONFIG"));
        assert!(vars.contains(&"OPENCODE_CONFIG_DIR"));
    }

    #[test]
    fn test_embedded_plugin() {
        // Verify the embedded plugin is valid TypeScript (basic check)
        assert!(!PLUGIN_TS.is_empty());
        // Uses named export per OpenCode plugin API
        assert!(PLUGIN_TS.contains("export const Mi6Plugin"));
        // Uses unified event handler pattern
        assert!(PLUGIN_TS.contains("event: async"));
        assert!(PLUGIN_TS.contains("session.created"));
        assert!(PLUGIN_TS.contains("tool.execute.before"));
        assert!(PLUGIN_TS.contains("ingest event"));
        assert!(PLUGIN_TS.contains("SessionStart"));
        assert!(PLUGIN_TS.contains("--framework opencode"));
        // Verify OTEL support
        assert!(PLUGIN_TS.contains("MI6_OTEL_PORT"));
        assert!(PLUGIN_TS.contains("otel start"));
    }

    #[test]
    fn test_plugin_path() {
        // Plugin path should be under ~/.config/opencode/plugin/ (XDG convention)
        if let Some(path) = OpenCodeAdapter::plugin_path() {
            assert!(path.to_string_lossy().contains(".config/opencode"));
            assert!(path.to_string_lossy().contains("plugin"));
            assert!(path.to_string_lossy().ends_with("mi6.ts"));
        }
    }

    #[test]
    fn test_generate_hooks_config_shows_plugin_info() {
        let adapter = OpenCodeAdapter;
        let config = adapter.generate_hooks_config(&[], "mi6", false, 4318);

        assert!(config.get("plugin").is_some());
        assert!(config["plugin"].get("events").is_some());
        // No OTEL section when disabled
        assert!(config.get("otel").is_none());
    }

    #[test]
    fn test_generate_hooks_config_with_otel() {
        let adapter = OpenCodeAdapter;
        let config = adapter.generate_hooks_config(&[], "mi6", true, 9999);

        // Plugin info should still be present
        assert!(config.get("plugin").is_some());

        // OTEL section should be present when enabled
        let otel = config.get("otel").expect("otel section should exist");
        assert_eq!(otel["enabled"], true);
        assert_eq!(otel["port"], 9999);
        assert_eq!(otel["env_var"], "MI6_OTEL_PORT");
    }
}
