//! Plugin domain model and validated manifest image.

use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;

/// Plugin domain validation error.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum PluginError {
    /// A required field is empty or malformed.
    #[error("invalid plugin field {field}: {reason}")]
    InvalidField {
        /// Field name.
        field: &'static str,
        /// Human-readable reason.
        reason: String,
    },
    /// A path escaped the plugin root.
    #[error("invalid plugin path {field}: {path}")]
    InvalidPath {
        /// Field name.
        field: &'static str,
        /// Offending path.
        path: String,
    },
    /// A contribution id is duplicated within the same plugin.
    #[error("duplicate contribution id: {0}")]
    DuplicateContribution(String),
}

/// Stable plugin identifier.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PluginId(String);

impl PluginId {
    /// Validates and creates a plugin id.
    pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
        let raw = raw.into();
        let valid_len = (3..=128).contains(&raw.len());
        let mut chars = raw.chars();
        let first = chars.next().unwrap_or('\0');
        let valid_first = first.is_ascii_lowercase() || first.is_ascii_digit();
        let valid_rest =
            chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '-');
        if valid_len && valid_first && valid_rest {
            Ok(Self(raw))
        } else {
            Err(PluginError::InvalidField {
                field: "id",
                reason: "expected [a-z0-9][a-z0-9.-]{2,127}".to_owned(),
            })
        }
    }

    /// Returns the raw id.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl std::fmt::Display for PluginId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        self.0.fmt(f)
    }
}

/// SemVer-like plugin version.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PluginVersion(String);

impl PluginVersion {
    /// Validates and creates a version.
    pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
        let raw = raw.into();
        let core = raw.split_once('-').map_or(raw.as_str(), |(a, _)| a);
        let parts: Vec<&str> = core.split('.').collect();
        let ok = parts.len() == 3
            && parts.iter().all(|p| {
                !p.is_empty()
                    && p.chars().all(|c| c.is_ascii_digit())
                    && (p == &"0" || !p.starts_with('0'))
            });
        if ok {
            Ok(Self(raw))
        } else {
            Err(PluginError::InvalidField {
                field: "version",
                reason: "expected semantic version MAJOR.MINOR.PATCH".to_owned(),
            })
        }
    }

    /// Returns the raw version.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// Relative path inside a plugin package.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RelativePath(String);

impl RelativePath {
    /// Validates a manifest path as relative, normalized, and root-confined.
    pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
        let raw = raw.into();
        if is_safe_relative_path(&raw) {
            Ok(Self(raw.replace('\\', "/")))
        } else {
            Err(PluginError::InvalidPath {
                field: "path",
                path: raw,
            })
        }
    }

    /// Returns the relative path.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

fn is_safe_relative_path(raw: &str) -> bool {
    if raw.is_empty()
        || raw.starts_with('/')
        || raw.starts_with('\\')
        || raw.contains('\0')
        || raw.contains(':')
    {
        return false;
    }
    raw.replace('\\', "/")
        .split('/')
        .all(|p| !p.is_empty() && p != "." && p != "..")
}

/// Content hash of a plugin bundle/package.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ContentHash(String);

impl ContentHash {
    /// Creates a content hash value.
    pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
        let raw = raw.into();
        if !raw.is_empty() && raw.chars().all(|c| c.is_ascii_hexdigit()) {
            Ok(Self(raw))
        } else {
            Err(PluginError::InvalidField {
                field: "contentHash",
                reason: "expected non-empty hexadecimal hash".to_owned(),
            })
        }
    }

    /// Returns the hash.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// URL exposed to the frontend for a plugin bundle or asset.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PluginBundleUrl(String);

impl PluginBundleUrl {
    /// Creates a bundle URL.
    #[must_use]
    pub fn new(raw: impl Into<String>) -> Self {
        Self(raw.into())
    }

    /// Returns the URL.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// Package reference managed by the store adapter.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginPackageRef {
    /// Plugin id if the package is already committed.
    pub plugin_id: Option<PluginId>,
    /// Opaque adapter-owned root path label.
    pub root: String,
}

/// Staged package produced by install/review.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StagedPluginPackage {
    /// Opaque staging root path.
    pub root: String,
    /// Original source label.
    pub source: PluginInstallSource,
    /// Hash of package contents at staging time.
    pub content_hash: ContentHash,
}

/// Plugin install source persisted for admin display.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum PluginInstallSource {
    /// Local archive source.
    Archive {
        /// Human-readable path label.
        path_label: String,
    },
    /// Local directory source.
    Directory {
        /// Human-readable path label.
        path_label: String,
    },
}

impl PluginInstallSource {
    /// Returns the stable DTO source kind.
    #[must_use]
    pub fn kind(&self) -> &'static str {
        match self {
            Self::Archive { .. } => "archive",
            Self::Directory { .. } => "directory",
        }
    }

    /// Returns the optional label.
    #[must_use]
    pub fn label(&self) -> &str {
        match self {
            Self::Archive { path_label } | Self::Directory { path_label } => path_label,
        }
    }
}

/// Plugin lifecycle state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PluginLifecycleState {
    /// Installed and enabled.
    Enabled,
    /// Installed and disabled.
    Disabled,
    /// Enable requested.
    PendingEnable,
    /// Disable requested.
    PendingDisable,
    /// Uninstall requested.
    PendingUninstall,
    /// Invalid manifest/incompatible engine.
    Invalid,
}

impl PluginLifecycleState {
    /// Whether the plugin can expose runtime contributions.
    #[must_use]
    pub fn is_runtime_active(self) -> bool {
        matches!(self, Self::Enabled | Self::PendingEnable)
    }
}

/// Trust level supported by v1.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PluginTrustLevel {
    /// Full-trust plugin.
    Full,
}

/// Declared plugin capability.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PluginCapability {
    /// UI bundle/contributions.
    Ui,
    /// External MCP server declarations.
    Mcp,
    /// Public tooling/build/debug services.
    Tooling,
}

/// Manifest-declared runtime activation scope.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PluginActivationScope {
    /// Activate at app bootstrap, without requiring a focused project.
    App,
    /// Wait until a project is focused before the first activation.
    Project,
}

impl Default for PluginActivationScope {
    fn default() -> Self {
        Self::App
    }
}

/// Plugin command id.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PluginCommandId(String);

impl PluginCommandId {
    /// Validates and creates a command id.
    pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
        let raw = raw.into();
        if raw.len() >= 3
            && raw
                .chars()
                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':'))
        {
            Ok(Self(raw))
        } else {
            Err(PluginError::InvalidField {
                field: "command",
                reason: "invalid command id".to_owned(),
            })
        }
    }

    /// Returns the raw id.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// Plugin layout type.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PluginLayoutType(String);

impl PluginLayoutType {
    /// Validates and creates a layout type.
    pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
        PluginCommandId::new(raw).map(|v| Self(v.0))
    }

    /// Returns the raw type.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// Plugin MCP server id.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PluginMcpServerId(String);

impl PluginMcpServerId {
    /// Validates and creates a server id.
    pub fn new(raw: impl Into<String>) -> Result<Self, PluginError> {
        PluginCommandId::new(raw).map(|v| Self(v.0))
    }

    /// Returns the raw id.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// Top-level menu contribution.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginTopLevelMenuContribution {
    /// Menu id.
    pub id: String,
    /// Display label.
    pub label: String,
    /// Must be true for this contribution kind.
    pub top_level: bool,
    /// Sort order.
    #[serde(default)]
    pub order: Option<i32>,
    /// Optional icon path.
    #[serde(default)]
    pub icon: Option<RelativePath>,
}

/// Menu item contribution.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginMenuItemContribution {
    /// Item id.
    pub id: String,
    /// Target menu id.
    pub target_menu_id: String,
    /// Display label.
    pub label: String,
    /// Command id.
    pub command: PluginCommandId,
    /// Sort order.
    #[serde(default)]
    pub order: Option<i32>,
    /// Optional icon path.
    #[serde(default)]
    pub icon: Option<RelativePath>,
    /// Optional declarative condition.
    #[serde(default)]
    pub when: Option<String>,
}

/// Layout contribution.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginLayoutContribution {
    /// Persisted layout type.
    #[serde(rename = "type")]
    pub layout_type: PluginLayoutType,
    /// Display label.
    pub label: String,
    /// Component export name.
    pub component: String,
    /// Sort order.
    #[serde(default)]
    pub order: Option<i32>,
    /// Optional icon path.
    #[serde(default)]
    pub icon: Option<RelativePath>,
    /// Optional declarative condition.
    #[serde(default)]
    pub when: Option<String>,
}

/// MCP server contribution.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginMcpServerContribution {
    /// Server id.
    pub id: PluginMcpServerId,
    /// Display name.
    pub display_name: String,
    /// Executable command path or absolute command when allowed.
    pub command: String,
    /// Arguments.
    #[serde(default)]
    pub args: Vec<String>,
    /// Environment variables.
    #[serde(default)]
    pub env: Vec<(String, String)>,
    /// Working directory.
    #[serde(default)]
    pub cwd: Option<String>,
    /// Transport, v1 only `stdio`.
    pub transport: String,
    /// Auto start flag.
    #[serde(default)]
    pub auto_start: bool,
    /// Development-only escape hatch for absolute commands.
    #[serde(default)]
    pub allow_absolute_command: bool,
}

/// Validated contribution set.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginContributionSet {
    /// Top-level menus.
    #[serde(default)]
    pub menus: Vec<PluginTopLevelMenuContribution>,
    /// Menu items.
    #[serde(default)]
    pub menu_items: Vec<PluginMenuItemContribution>,
    /// Layout contributions.
    #[serde(default)]
    pub layouts: Vec<PluginLayoutContribution>,
    /// MCP server contributions.
    #[serde(default)]
    pub mcp_servers: Vec<PluginMcpServerContribution>,
}

/// Validated plugin manifest.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginManifest {
    /// Manifest schema version.
    pub idea_plugin_manifest_version: u32,
    /// Plugin id.
    pub id: PluginId,
    /// Display name.
    pub display_name: String,
    /// Publisher.
    #[serde(default)]
    pub publisher: Option<String>,
    /// Version.
    pub version: PluginVersion,
    /// Description.
    #[serde(default)]
    pub description: Option<String>,
    /// Engine constraint for IdeA.
    #[serde(default)]
    pub engine_idea: Option<String>,
    /// Main ESM bundle.
    pub main: RelativePath,
    /// Optional icon path.
    #[serde(default)]
    pub icon: Option<RelativePath>,
    /// Trust level.
    pub trust_level: PluginTrustLevel,
    /// Capabilities.
    #[serde(default)]
    pub capabilities: Vec<PluginCapability>,
    /// Activation scope. Missing in older manifests means app-level activation.
    #[serde(default)]
    pub activation_scope: PluginActivationScope,
    /// Contributions.
    pub contributes: PluginContributionSet,
}

/// Persisted registry entry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginRegistryEntry {
    /// Plugin id.
    pub id: PluginId,
    /// Lifecycle state.
    pub lifecycle_state: PluginLifecycleState,
    /// Source.
    pub source: PluginInstallSource,
    /// Content hash.
    pub content_hash: ContentHash,
    /// Restart required marker.
    #[serde(default)]
    pub restart_required: bool,
    /// Optional error.
    #[serde(default)]
    pub error: Option<String>,
}

/// Persisted global registry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginRegistry {
    /// Schema version.
    pub version: u32,
    /// Registry entries.
    pub plugins: Vec<PluginRegistryEntry>,
}

impl Default for PluginRegistry {
    fn default() -> Self {
        Self {
            version: 1,
            plugins: Vec::new(),
        }
    }
}

impl PluginRegistry {
    /// Finds a plugin entry.
    #[must_use]
    pub fn find(&self, id: &PluginId) -> Option<&PluginRegistryEntry> {
        self.plugins.iter().find(|p| &p.id == id)
    }

    /// Upserts a registry entry, preserving id uniqueness.
    pub fn upsert(&mut self, entry: PluginRegistryEntry) {
        if let Some(slot) = self.plugins.iter_mut().find(|p| p.id == entry.id) {
            *slot = entry;
        } else {
            self.plugins.push(entry);
        }
    }

    /// Removes an entry.
    pub fn remove(&mut self, id: &PluginId) -> Option<PluginRegistryEntry> {
        let index = self.plugins.iter().position(|p| &p.id == id)?;
        Some(self.plugins.remove(index))
    }
}

/// Admin descriptor assembled from manifest + registry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginDescriptor {
    /// Manifest.
    pub manifest: PluginManifest,
    /// Registry entry.
    pub registry: PluginRegistryEntry,
}

impl PluginDescriptor {
    /// Returns true if runtime contributions are active.
    #[must_use]
    pub fn exposes_runtime_contributions(&self) -> bool {
        self.registry.lifecycle_state.is_runtime_active()
    }
}

/// Result of removing package files.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RemovalOutcome {
    /// Files were removed.
    Removed,
    /// Files were moved to trash for later cleanup.
    Tombstoned,
    /// No package files existed.
    NotFound,
}

/// Resolved MCP server spec passed to the supervisor.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginMcpServerSpec {
    /// Plugin id.
    pub plugin_id: PluginId,
    /// Manifest server id.
    pub server_id: PluginMcpServerId,
    /// Stable external identity `plugin:<pluginId>:<serverId>`.
    pub identity: String,
    /// Display name.
    pub display_name: String,
    /// Command.
    pub command: String,
    /// Args.
    pub args: Vec<String>,
    /// Env.
    pub env: Vec<(String, String)>,
    /// Cwd.
    pub cwd: String,
    /// Transport.
    pub transport: String,
}

/// MCP server status.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginMcpStatus {
    /// Identity.
    pub identity: String,
    /// Running flag.
    pub running: bool,
    /// Optional error.
    #[serde(default)]
    pub error: Option<String>,
}

/// MCP status set.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginMcpStatusSet {
    /// Statuses.
    pub servers: Vec<PluginMcpStatus>,
}

/// Custom plugin layout persisted in a layout leaf's opaque content.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomPluginLayout {
    /// Provider plugin id.
    pub plugin_id: PluginId,
    /// Provider display name if known.
    #[serde(default)]
    pub provider_plugin_display_name: Option<String>,
    /// Layout type.
    pub layout_type: PluginLayoutType,
    /// Opaque plugin state.
    #[serde(default)]
    pub state: Value,
}

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

    #[test]
    fn plugin_id_pattern_is_enforced() {
        assert!(PluginId::new("dev.acme.gitgraph").is_ok());
        assert!(PluginId::new("De.acme").is_err());
        assert!(PluginId::new("a").is_err());
    }

    #[test]
    fn relative_paths_cannot_escape_root() {
        assert_eq!(
            RelativePath::new("dist/index.js").unwrap().as_str(),
            "dist/index.js"
        );
        assert!(RelativePath::new("../dist/index.js").is_err());
        assert!(RelativePath::new("/tmp/index.js").is_err());
        assert!(RelativePath::new("dist/../index.js").is_err());
    }

    #[test]
    fn disabled_and_pending_uninstall_are_not_runtime_active() {
        assert!(!PluginLifecycleState::Disabled.is_runtime_active());
        assert!(!PluginLifecycleState::PendingUninstall.is_runtime_active());
        assert!(PluginLifecycleState::Enabled.is_runtime_active());
    }

    #[test]
    fn plugin_capabilities_serialize_public_manifest_names() {
        assert_eq!(
            serde_json::to_value([
                PluginCapability::Ui,
                PluginCapability::Mcp,
                PluginCapability::Tooling
            ])
            .unwrap(),
            serde_json::json!(["ui", "mcp", "tooling"])
        );
    }

    #[test]
    fn plugin_activation_scope_defaults_to_app_and_serializes_public_names() {
        assert_eq!(PluginActivationScope::default(), PluginActivationScope::App);
        assert_eq!(
            serde_json::to_value(PluginActivationScope::Project).unwrap(),
            serde_json::json!("project")
        );
    }
}
