//! Plugin application use cases.

use std::collections::HashSet;
use std::sync::Arc;

use domain::ports::{
    EventBus, LocalPath, PluginManifestBytes, PluginManifestError, PluginManifestValidator,
    PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError,
    PluginRegistryStore, PluginStoreError,
};
use domain::{
    ContentHash, DomainEvent, PluginContributionSet, PluginDescriptor, PluginId,
    PluginInstallSource, PluginLifecycleState, PluginManifest, PluginMcpServerSpec,
    PluginRegistryEntry, PluginTrustLevel, RemovalOutcome, StagedPluginPackage,
};
use serde::{Deserialize, Serialize};

use crate::AppError;

/// Contribution counts for admin display.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginContributionSummary {
    /// Top-level menu count.
    pub top_level_menus: usize,
    /// Menu item count.
    pub menu_items: usize,
    /// Layout count.
    pub layouts: usize,
    /// MCP server count.
    pub mcp_servers: usize,
}

impl From<&PluginContributionSet> for PluginContributionSummary {
    fn from(c: &PluginContributionSet) -> Self {
        Self {
            top_level_menus: c.menus.len(),
            menu_items: c.menu_items.len(),
            layouts: c.layouts.len(),
            mcp_servers: c.mcp_servers.len(),
        }
    }
}

/// Admin plugin view.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginAdmin {
    /// Plugin id.
    pub id: String,
    /// Display name.
    pub display_name: String,
    /// Publisher.
    pub publisher: Option<String>,
    /// Version.
    pub version: String,
    /// Description.
    pub description: Option<String>,
    /// Icon URL.
    pub icon_url: Option<String>,
    /// Source kind.
    pub source_kind: String,
    /// Source label.
    pub source_label: Option<String>,
    /// Lifecycle state.
    pub lifecycle_state: PluginLifecycleState,
    /// Enabled projection.
    pub enabled: bool,
    /// Pending enable state.
    pub pending_enable_state: Option<bool>,
    /// Pending uninstall flag.
    pub pending_uninstall: bool,
    /// Restart required flag.
    pub restart_required: bool,
    /// Trust level.
    pub trust_level: PluginTrustLevel,
    /// Summary.
    pub contribution_summary: PluginContributionSummary,
    /// Error.
    pub error: Option<String>,
}

/// Pre-install package review.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginReview {
    /// Manifest.
    pub manifest: PluginManifest,
    /// Source.
    pub source: PluginInstallSource,
    /// Content hash.
    pub content_hash: String,
    /// Summary.
    pub contribution_summary: PluginContributionSummary,
    /// Full-trust marker.
    pub trust_level: PluginTrustLevel,
}

/// Install result.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginInstallResult {
    /// Installed plugin.
    pub plugin: PluginAdmin,
    /// Review used for installation.
    pub review: PluginReview,
    /// Restart required flag.
    pub restart_required: bool,
}

/// Uninstall result.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UninstallPluginResult {
    /// Plugin id.
    pub plugin_id: String,
    /// Removal outcome.
    pub removal_outcome: RemovalOutcome,
    /// Restart required flag.
    pub restart_required: bool,
}

/// Runtime catalog.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginRuntimeCatalog {
    /// Runtime plugins.
    pub plugins: Vec<PluginRuntimePlugin>,
}

/// Runtime plugin entry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginRuntimePlugin {
    /// Plugin id.
    pub id: String,
    /// Display name.
    pub display_name: String,
    /// Publisher.
    pub publisher: Option<String>,
    /// Version.
    pub version: String,
    /// Bundle URL.
    pub bundle_url: String,
    /// Icon URL.
    pub icon_url: Option<String>,
    /// Content hash.
    pub content_hash: String,
    /// Contributions.
    pub contributes: PluginContributionSet,
}

/// Input for reviewing a package.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReviewPluginPackageInput {
    /// Review local archive.
    Archive {
        /// Path.
        path: String,
    },
    /// Review local directory.
    Directory {
        /// Path.
        path: String,
    },
}

fn map_store(e: PluginStoreError) -> AppError {
    match e {
        PluginStoreError::NotFound => AppError::NotFound("plugin package".to_owned()),
        PluginStoreError::Invalid(m) | PluginStoreError::Format(m) => AppError::Invalid(m),
        PluginStoreError::Io(m) => AppError::FileSystem(m),
    }
}

fn map_registry(e: PluginRegistryError) -> AppError {
    match e {
        PluginRegistryError::Io(m) => AppError::Store(m),
        PluginRegistryError::Serialization(m) => AppError::Store(m),
    }
}

fn map_manifest(e: PluginManifestError) -> AppError {
    match e {
        PluginManifestError::Json(m)
        | PluginManifestError::Invalid(m)
        | PluginManifestError::IncompatibleEngine(m) => AppError::Invalid(m),
    }
}

fn map_mcp(e: PluginMcpError) -> AppError {
    AppError::Process(e.to_string())
}

fn plugin_package_ref(id: &PluginId) -> domain::PluginPackageRef {
    domain::PluginPackageRef {
        plugin_id: Some(id.clone()),
        root: id.as_str().to_owned(),
    }
}

fn admin_from_descriptor(
    d: PluginDescriptor,
    _packages: &dyn PluginPackageStore,
) -> Result<PluginAdmin, AppError> {
    let icon_url = match &d.manifest.icon {
        Some(icon) => Some(plugin_asset_url(
            &d.manifest.id,
            d.manifest.version.as_str(),
            &d.registry.content_hash,
            icon,
        )),
        None => None,
    };
    Ok(PluginAdmin {
        id: d.manifest.id.as_str().to_owned(),
        display_name: d.manifest.display_name,
        publisher: d.manifest.publisher,
        version: d.manifest.version.as_str().to_owned(),
        description: d.manifest.description,
        icon_url,
        source_kind: d.registry.source.kind().to_owned(),
        source_label: Some(d.registry.source.label().to_owned()),
        lifecycle_state: d.registry.lifecycle_state,
        enabled: matches!(
            d.registry.lifecycle_state,
            PluginLifecycleState::Enabled | PluginLifecycleState::PendingEnable
        ),
        pending_enable_state: match d.registry.lifecycle_state {
            PluginLifecycleState::PendingEnable => Some(true),
            PluginLifecycleState::PendingDisable => Some(false),
            _ => None,
        },
        pending_uninstall: d.registry.lifecycle_state == PluginLifecycleState::PendingUninstall,
        restart_required: d.registry.restart_required,
        trust_level: d.manifest.trust_level,
        contribution_summary: PluginContributionSummary::from(&d.manifest.contributes),
        error: d.registry.error,
    })
}

fn plugin_asset_url(
    plugin_id: &PluginId,
    version: &str,
    hash: &ContentHash,
    path: &domain::RelativePath,
) -> String {
    format!(
        "idea-plugin://{}/{}/{}/{}",
        plugin_id.as_str(),
        version,
        hash.as_str(),
        path.as_str()
    )
}

async fn descriptor_for(
    packages: &dyn PluginPackageStore,
    validator: &dyn PluginManifestValidator,
    entry: PluginRegistryEntry,
) -> Result<PluginDescriptor, AppError> {
    let bytes = packages
        .read_manifest(&plugin_package_ref(&entry.id))
        .await
        .map_err(map_store)?;
    let manifest = validator
        .validate(&bytes.bytes, &plugin_package_ref(&entry.id))
        .map_err(map_manifest)?;
    Ok(PluginDescriptor {
        manifest,
        registry: entry,
    })
}

/// Lists admin plugins.
pub struct ListPlugins {
    packages: Arc<dyn PluginPackageStore>,
    registry: Arc<dyn PluginRegistryStore>,
    validator: Arc<dyn PluginManifestValidator>,
}

impl ListPlugins {
    /// Builds the use case.
    #[must_use]
    pub fn new(
        packages: Arc<dyn PluginPackageStore>,
        registry: Arc<dyn PluginRegistryStore>,
        validator: Arc<dyn PluginManifestValidator>,
    ) -> Self {
        Self {
            packages,
            registry,
            validator,
        }
    }

    /// Executes the use case.
    pub async fn execute(&self) -> Result<Vec<PluginAdmin>, AppError> {
        let registry = self.registry.load_registry().await.map_err(map_registry)?;
        let mut out = Vec::new();
        for entry in registry.plugins {
            match descriptor_for(
                self.packages.as_ref(),
                self.validator.as_ref(),
                entry.clone(),
            )
            .await
            {
                Ok(d) => out.push(admin_from_descriptor(d, self.packages.as_ref())?),
                Err(err) => {
                    let invalid = PluginRegistryEntry {
                        lifecycle_state: PluginLifecycleState::Invalid,
                        error: Some(err.to_string()),
                        ..entry
                    };
                    let placeholder = PluginManifest {
                        idea_plugin_manifest_version: 1,
                        id: invalid.id.clone(),
                        display_name: invalid.id.as_str().to_owned(),
                        publisher: None,
                        version: domain::PluginVersion::new("0.0.0")
                            .expect("literal semver is valid"),
                        description: None,
                        engine_idea: None,
                        main: domain::RelativePath::new("dist/index.js")
                            .expect("literal path is valid"),
                        icon: None,
                        trust_level: PluginTrustLevel::Full,
                        capabilities: Vec::new(),
                        contributes: PluginContributionSet::default(),
                    };
                    out.push(admin_from_descriptor(
                        PluginDescriptor {
                            manifest: placeholder,
                            registry: invalid,
                        },
                        self.packages.as_ref(),
                    )?);
                }
            }
        }
        out.sort_by(|a, b| a.display_name.cmp(&b.display_name).then(a.id.cmp(&b.id)));
        Ok(out)
    }
}

/// Reviews a plugin package without committing it.
pub struct ReviewPluginPackage {
    packages: Arc<dyn PluginPackageStore>,
    validator: Arc<dyn PluginManifestValidator>,
}

impl ReviewPluginPackage {
    /// Builds the use case.
    #[must_use]
    pub fn new(
        packages: Arc<dyn PluginPackageStore>,
        validator: Arc<dyn PluginManifestValidator>,
    ) -> Self {
        Self {
            packages,
            validator,
        }
    }

    /// Executes the use case.
    pub async fn execute(&self, input: ReviewPluginPackageInput) -> Result<PluginReview, AppError> {
        let staged = match input {
            ReviewPluginPackageInput::Archive { path } => self
                .packages
                .install_from_archive(&LocalPath::new(path))
                .await
                .map_err(map_store)?,
            ReviewPluginPackageInput::Directory { path } => self
                .packages
                .install_from_directory(&LocalPath::new(path))
                .await
                .map_err(map_store)?,
        };
        review_staged(self.packages.as_ref(), self.validator.as_ref(), &staged).await
    }
}

async fn review_staged(
    packages: &dyn PluginPackageStore,
    validator: &dyn PluginManifestValidator,
    staged: &StagedPluginPackage,
) -> Result<PluginReview, AppError> {
    let package = domain::PluginPackageRef {
        plugin_id: None,
        root: staged.root.clone(),
    };
    let PluginManifestBytes { bytes } =
        packages.read_manifest(&package).await.map_err(map_store)?;
    let manifest = validator.validate(&bytes, &package).map_err(map_manifest)?;
    Ok(PluginReview {
        contribution_summary: PluginContributionSummary::from(&manifest.contributes),
        trust_level: manifest.trust_level,
        manifest,
        source: staged.source.clone(),
        content_hash: staged.content_hash.as_str().to_owned(),
    })
}

/// Installs from archive.
pub struct InstallPluginFromArchive {
    packages: Arc<dyn PluginPackageStore>,
    registry: Arc<dyn PluginRegistryStore>,
    validator: Arc<dyn PluginManifestValidator>,
    events: Arc<dyn EventBus>,
    mcp: Arc<dyn PluginMcpSupervisor>,
}

impl InstallPluginFromArchive {
    /// Builds the use case.
    #[must_use]
    pub fn new(
        packages: Arc<dyn PluginPackageStore>,
        registry: Arc<dyn PluginRegistryStore>,
        validator: Arc<dyn PluginManifestValidator>,
        events: Arc<dyn EventBus>,
        mcp: Arc<dyn PluginMcpSupervisor>,
    ) -> Self {
        Self {
            packages,
            registry,
            validator,
            events,
            mcp,
        }
    }

    /// Executes the use case.
    pub async fn execute(&self, path: String) -> Result<PluginInstallResult, AppError> {
        install_from_staged(
            self.packages.as_ref(),
            self.registry.as_ref(),
            self.validator.as_ref(),
            self.events.as_ref(),
            self.mcp.as_ref(),
            self.packages
                .install_from_archive(&LocalPath::new(path))
                .await
                .map_err(map_store)?,
        )
        .await
    }
}

/// Installs from directory.
pub struct InstallPluginFromDirectory {
    packages: Arc<dyn PluginPackageStore>,
    registry: Arc<dyn PluginRegistryStore>,
    validator: Arc<dyn PluginManifestValidator>,
    events: Arc<dyn EventBus>,
    mcp: Arc<dyn PluginMcpSupervisor>,
}

impl InstallPluginFromDirectory {
    /// Builds the use case.
    #[must_use]
    pub fn new(
        packages: Arc<dyn PluginPackageStore>,
        registry: Arc<dyn PluginRegistryStore>,
        validator: Arc<dyn PluginManifestValidator>,
        events: Arc<dyn EventBus>,
        mcp: Arc<dyn PluginMcpSupervisor>,
    ) -> Self {
        Self {
            packages,
            registry,
            validator,
            events,
            mcp,
        }
    }

    /// Executes the use case.
    pub async fn execute(&self, path: String) -> Result<PluginInstallResult, AppError> {
        install_from_staged(
            self.packages.as_ref(),
            self.registry.as_ref(),
            self.validator.as_ref(),
            self.events.as_ref(),
            self.mcp.as_ref(),
            self.packages
                .install_from_directory(&LocalPath::new(path))
                .await
                .map_err(map_store)?,
        )
        .await
    }
}

async fn install_from_staged(
    packages: &dyn PluginPackageStore,
    registry_store: &dyn PluginRegistryStore,
    validator: &dyn PluginManifestValidator,
    events: &dyn EventBus,
    mcp: &dyn PluginMcpSupervisor,
    staged: StagedPluginPackage,
) -> Result<PluginInstallResult, AppError> {
    let review = review_staged(packages, validator, &staged).await?;
    let plugin_id = review.manifest.id.clone();
    packages
        .commit_install(staged, &plugin_id)
        .await
        .map_err(map_store)?;
    let mut registry = registry_store.load_registry().await.map_err(map_registry)?;
    let entry = PluginRegistryEntry {
        id: plugin_id.clone(),
        lifecycle_state: PluginLifecycleState::Enabled,
        source: review.source.clone(),
        content_hash: ContentHash::new(review.content_hash.clone())
            .map_err(|e| AppError::Invalid(e.to_string()))?,
        restart_required: true,
        error: None,
    };
    registry.upsert(entry.clone());
    registry_store
        .save_registry(&registry)
        .await
        .map_err(map_registry)?;
    events.publish(DomainEvent::PluginInstalled {
        plugin_id: plugin_id.clone(),
        version: review.manifest.version.clone(),
    });
    let _ = mcp
        .reconcile(active_mcp_specs(packages, validator, &registry).await?)
        .await;
    let admin = admin_from_descriptor(
        PluginDescriptor {
            manifest: review.manifest.clone(),
            registry: entry,
        },
        packages,
    )?;
    Ok(PluginInstallResult {
        plugin: admin,
        review,
        restart_required: true,
    })
}

/// Enable/disable input.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetPluginEnabledInput {
    /// Plugin id.
    pub plugin_id: String,
    /// Desired enabled state.
    pub enabled: bool,
}

/// Enables or disables a plugin.
pub struct SetPluginEnabled {
    packages: Arc<dyn PluginPackageStore>,
    registry: Arc<dyn PluginRegistryStore>,
    validator: Arc<dyn PluginManifestValidator>,
    events: Arc<dyn EventBus>,
    mcp: Arc<dyn PluginMcpSupervisor>,
}

impl SetPluginEnabled {
    /// Builds the use case.
    #[must_use]
    pub fn new(
        packages: Arc<dyn PluginPackageStore>,
        registry: Arc<dyn PluginRegistryStore>,
        validator: Arc<dyn PluginManifestValidator>,
        events: Arc<dyn EventBus>,
        mcp: Arc<dyn PluginMcpSupervisor>,
    ) -> Self {
        Self {
            packages,
            registry,
            validator,
            events,
            mcp,
        }
    }

    /// Executes the use case.
    pub async fn execute(&self, input: SetPluginEnabledInput) -> Result<PluginAdmin, AppError> {
        let plugin_id =
            PluginId::new(input.plugin_id).map_err(|e| AppError::Invalid(e.to_string()))?;
        let mut registry = self.registry.load_registry().await.map_err(map_registry)?;
        let entry = registry
            .plugins
            .iter_mut()
            .find(|p| p.id == plugin_id)
            .ok_or_else(|| AppError::NotFound("plugin".to_owned()))?;
        entry.lifecycle_state = if input.enabled {
            PluginLifecycleState::Enabled
        } else {
            PluginLifecycleState::Disabled
        };
        entry.restart_required = true;
        let saved = entry.clone();
        self.registry
            .save_registry(&registry)
            .await
            .map_err(map_registry)?;
        if input.enabled {
            self.events.publish(DomainEvent::PluginEnabled {
                plugin_id: plugin_id.clone(),
            });
        } else {
            self.mcp.stop_plugin(&plugin_id).await.map_err(map_mcp)?;
            self.events.publish(DomainEvent::PluginDisabled {
                plugin_id: plugin_id.clone(),
                restart_required: true,
            });
        }
        let _ = self
            .mcp
            .reconcile(
                active_mcp_specs(self.packages.as_ref(), self.validator.as_ref(), &registry)
                    .await?,
            )
            .await;
        let descriptor =
            descriptor_for(self.packages.as_ref(), self.validator.as_ref(), saved).await?;
        admin_from_descriptor(descriptor, self.packages.as_ref())
    }
}

/// Uninstall input.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UninstallPluginInput {
    /// Plugin id.
    pub plugin_id: String,
}

/// Uninstalls a plugin.
pub struct UninstallPlugin {
    packages: Arc<dyn PluginPackageStore>,
    registry: Arc<dyn PluginRegistryStore>,
    events: Arc<dyn EventBus>,
    mcp: Arc<dyn PluginMcpSupervisor>,
}

impl UninstallPlugin {
    /// Builds the use case.
    #[must_use]
    pub fn new(
        packages: Arc<dyn PluginPackageStore>,
        registry: Arc<dyn PluginRegistryStore>,
        events: Arc<dyn EventBus>,
        mcp: Arc<dyn PluginMcpSupervisor>,
    ) -> Self {
        Self {
            packages,
            registry,
            events,
            mcp,
        }
    }

    /// Executes the use case.
    pub async fn execute(
        &self,
        input: UninstallPluginInput,
    ) -> Result<UninstallPluginResult, AppError> {
        let plugin_id =
            PluginId::new(input.plugin_id).map_err(|e| AppError::Invalid(e.to_string()))?;
        self.mcp.stop_plugin(&plugin_id).await.map_err(map_mcp)?;
        let mut registry = self.registry.load_registry().await.map_err(map_registry)?;
        registry
            .remove(&plugin_id)
            .ok_or_else(|| AppError::NotFound("plugin".to_owned()))?;
        self.registry
            .save_registry(&registry)
            .await
            .map_err(map_registry)?;
        let removal = self
            .packages
            .remove_package(&plugin_id)
            .await
            .map_err(map_store)?;
        self.events.publish(DomainEvent::PluginUninstalled {
            plugin_id: plugin_id.clone(),
            restart_required: true,
        });
        Ok(UninstallPluginResult {
            plugin_id: plugin_id.as_str().to_owned(),
            removal_outcome: removal,
            restart_required: true,
        })
    }
}

/// Lists runtime contributions.
pub struct ListPluginRuntimeContributions {
    packages: Arc<dyn PluginPackageStore>,
    registry: Arc<dyn PluginRegistryStore>,
    validator: Arc<dyn PluginManifestValidator>,
}

impl ListPluginRuntimeContributions {
    /// Builds the use case.
    #[must_use]
    pub fn new(
        packages: Arc<dyn PluginPackageStore>,
        registry: Arc<dyn PluginRegistryStore>,
        validator: Arc<dyn PluginManifestValidator>,
    ) -> Self {
        Self {
            packages,
            registry,
            validator,
        }
    }

    /// Executes the use case.
    pub async fn execute(&self) -> Result<PluginRuntimeCatalog, AppError> {
        let registry = self.registry.load_registry().await.map_err(map_registry)?;
        let mut plugins = Vec::new();
        for entry in registry.plugins {
            if !entry.lifecycle_state.is_runtime_active() {
                continue;
            }
            let descriptor =
                descriptor_for(self.packages.as_ref(), self.validator.as_ref(), entry).await?;
            let bundle = plugin_asset_url(
                &descriptor.manifest.id,
                descriptor.manifest.version.as_str(),
                &descriptor.registry.content_hash,
                &descriptor.manifest.main,
            );
            let icon_url = match &descriptor.manifest.icon {
                Some(icon) => Some(plugin_asset_url(
                    &descriptor.manifest.id,
                    descriptor.manifest.version.as_str(),
                    &descriptor.registry.content_hash,
                    icon,
                )),
                None => None,
            };
            plugins.push(PluginRuntimePlugin {
                id: descriptor.manifest.id.as_str().to_owned(),
                display_name: descriptor.manifest.display_name,
                publisher: descriptor.manifest.publisher,
                version: descriptor.manifest.version.as_str().to_owned(),
                bundle_url: bundle,
                icon_url,
                content_hash: descriptor.registry.content_hash.as_str().to_owned(),
                contributes: descriptor.manifest.contributes,
            });
        }
        Ok(PluginRuntimeCatalog { plugins })
    }
}

/// Reconciles plugin MCP servers.
pub struct ReconcilePluginMcpServers {
    packages: Arc<dyn PluginPackageStore>,
    registry: Arc<dyn PluginRegistryStore>,
    validator: Arc<dyn PluginManifestValidator>,
    mcp: Arc<dyn PluginMcpSupervisor>,
}

impl ReconcilePluginMcpServers {
    /// Builds the use case.
    #[must_use]
    pub fn new(
        packages: Arc<dyn PluginPackageStore>,
        registry: Arc<dyn PluginRegistryStore>,
        validator: Arc<dyn PluginManifestValidator>,
        mcp: Arc<dyn PluginMcpSupervisor>,
    ) -> Self {
        Self {
            packages,
            registry,
            validator,
            mcp,
        }
    }

    /// Executes the use case.
    pub async fn execute(&self) -> Result<domain::PluginMcpStatusSet, AppError> {
        let registry = self.registry.load_registry().await.map_err(map_registry)?;
        let specs =
            active_mcp_specs(self.packages.as_ref(), self.validator.as_ref(), &registry).await?;
        self.mcp.reconcile(specs).await.map_err(map_mcp)
    }
}

async fn active_mcp_specs(
    packages: &dyn PluginPackageStore,
    validator: &dyn PluginManifestValidator,
    registry: &domain::PluginRegistry,
) -> Result<Vec<PluginMcpServerSpec>, AppError> {
    let installed_roots = packages
        .list_installed()
        .await
        .map_err(map_store)?
        .into_iter()
        .filter_map(|p| p.plugin_id.clone().map(|id| (id, p.root)))
        .collect::<std::collections::HashMap<_, _>>();
    let app_data_dir = packages.app_data_dir_label();
    let mut specs = Vec::new();
    for entry in &registry.plugins {
        if !entry.lifecycle_state.is_runtime_active() {
            continue;
        }
        let descriptor = descriptor_for(packages, validator, entry.clone()).await?;
        let plugin_root = installed_roots
            .get(&descriptor.manifest.id)
            .cloned()
            .unwrap_or_else(|| plugin_package_ref(&descriptor.manifest.id).root);
        for server in descriptor.manifest.contributes.mcp_servers {
            if !server.auto_start {
                continue;
            }
            let command = substitute_vars(&server.command, &plugin_root, app_data_dir.as_deref());
            let command = if server.allow_absolute_command || looks_absolute(&command) {
                command
            } else {
                format!("{}/{}", plugin_root.trim_end_matches(['/', '\\']), command)
            };
            specs.push(PluginMcpServerSpec {
                identity: format!(
                    "plugin:{}:{}",
                    descriptor.manifest.id.as_str(),
                    server.id.as_str()
                ),
                plugin_id: descriptor.manifest.id.clone(),
                server_id: server.id,
                display_name: server.display_name,
                command,
                args: server
                    .args
                    .into_iter()
                    .map(|a| substitute_vars(&a, &plugin_root, app_data_dir.as_deref()))
                    .collect(),
                env: server
                    .env
                    .into_iter()
                    .map(|(k, v)| {
                        (
                            k,
                            substitute_vars(&v, &plugin_root, app_data_dir.as_deref()),
                        )
                    })
                    .collect(),
                cwd: substitute_vars(
                    server.cwd.as_deref().unwrap_or("${pluginRoot}"),
                    &plugin_root,
                    app_data_dir.as_deref(),
                ),
                transport: server.transport,
            });
        }
    }
    Ok(specs)
}

fn substitute_vars(raw: &str, plugin_root: &str, app_data_dir: Option<&str>) -> String {
    let value = raw.replace("${pluginRoot}", plugin_root);
    match app_data_dir {
        Some(app_data_dir) => value.replace("${appDataDir}", app_data_dir),
        None => value,
    }
}

/// JSON manifest validator.
#[derive(Debug, Clone)]
pub struct JsonPluginManifestValidator {
    idea_version: String,
}

impl JsonPluginManifestValidator {
    /// Builds a validator for the current app version.
    #[must_use]
    pub fn new(idea_version: impl Into<String>) -> Self {
        Self {
            idea_version: idea_version.into(),
        }
    }
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawManifest {
    idea_plugin_manifest_version: u32,
    id: String,
    display_name: String,
    #[serde(default)]
    publisher: Option<String>,
    version: String,
    #[serde(default)]
    description: Option<String>,
    #[serde(default)]
    engines: RawEngines,
    main: String,
    #[serde(default)]
    icon: Option<String>,
    trust_level: String,
    #[serde(default)]
    capabilities: Vec<String>,
    contributes: RawContributes,
}

#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawEngines {
    #[serde(default)]
    idea: Option<String>,
}

#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawContributes {
    #[serde(default)]
    menus: Vec<RawMenu>,
    #[serde(default)]
    menu_items: Vec<RawMenuItem>,
    #[serde(default)]
    layouts: Vec<RawLayout>,
    #[serde(default)]
    mcp_servers: Vec<RawMcpServer>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawMenu {
    id: String,
    label: String,
    top_level: bool,
    #[serde(default)]
    order: Option<i32>,
    #[serde(default)]
    icon: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawMenuItem {
    id: String,
    target_menu_id: String,
    label: String,
    command: String,
    #[serde(default)]
    order: Option<i32>,
    #[serde(default)]
    icon: Option<String>,
    #[serde(default)]
    when: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawLayout {
    #[serde(rename = "type")]
    layout_type: String,
    label: String,
    component: String,
    #[serde(default)]
    order: Option<i32>,
    #[serde(default)]
    icon: Option<String>,
    #[serde(default)]
    when: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawMcpServer {
    id: String,
    display_name: String,
    command: String,
    #[serde(default)]
    args: Vec<String>,
    #[serde(default)]
    env: std::collections::BTreeMap<String, String>,
    #[serde(default)]
    cwd: Option<String>,
    transport: String,
    #[serde(default)]
    auto_start: bool,
    #[serde(default)]
    allow_absolute_command: bool,
}

impl PluginManifestValidator for JsonPluginManifestValidator {
    fn validate(
        &self,
        bytes: &[u8],
        _package_root: &domain::PluginPackageRef,
    ) -> Result<PluginManifest, PluginManifestError> {
        let raw: RawManifest =
            serde_json::from_slice(bytes).map_err(|e| PluginManifestError::Json(e.to_string()))?;
        if raw.idea_plugin_manifest_version != 1 {
            return Err(PluginManifestError::Invalid(
                "ideaPluginManifestVersion must be 1".to_owned(),
            ));
        }
        if raw.display_name.trim().is_empty() {
            return Err(PluginManifestError::Invalid(
                "displayName is required".to_owned(),
            ));
        }
        if raw.trust_level != "full" {
            return Err(PluginManifestError::Invalid(
                "trustLevel must be full in v1".to_owned(),
            ));
        }
        if let Some(range) = &raw.engines.idea {
            if !engine_allows(range, &self.idea_version) {
                return Err(PluginManifestError::IncompatibleEngine(range.clone()));
            }
        }
        let id = PluginId::new(raw.id).map_err(|e| PluginManifestError::Invalid(e.to_string()))?;
        let version = domain::PluginVersion::new(raw.version)
            .map_err(|e| PluginManifestError::Invalid(e.to_string()))?;
        let main = domain::RelativePath::new(raw.main)
            .map_err(|e| PluginManifestError::Invalid(e.to_string()))?;
        if !(main.as_str().ends_with(".js") || main.as_str().ends_with(".mjs")) {
            return Err(PluginManifestError::Invalid(
                "main must point to a .js or .mjs file".to_owned(),
            ));
        }
        let icon = raw
            .icon
            .map(domain::RelativePath::new)
            .transpose()
            .map_err(|e| PluginManifestError::Invalid(e.to_string()))?;
        let capabilities = raw
            .capabilities
            .into_iter()
            .map(|c| match c.as_str() {
                "ui" => Ok(domain::PluginCapability::Ui),
                "mcp" => Ok(domain::PluginCapability::Mcp),
                _ => Err(PluginManifestError::Invalid(format!(
                    "unknown capability: {c}"
                ))),
            })
            .collect::<Result<Vec<_>, _>>()?;
        let contributes = validate_contributes(raw.contributes)?;
        Ok(PluginManifest {
            idea_plugin_manifest_version: 1,
            id,
            display_name: raw.display_name,
            publisher: raw.publisher,
            version,
            description: raw.description,
            engine_idea: raw.engines.idea,
            main,
            icon,
            trust_level: PluginTrustLevel::Full,
            capabilities,
            contributes,
        })
    }
}

fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, PluginManifestError> {
    let mut seen = HashSet::new();
    let mut insert = |id: &str| {
        if !seen.insert(id.to_owned()) {
            Err(PluginManifestError::Invalid(format!(
                "duplicate contribution id: {id}"
            )))
        } else {
            Ok(())
        }
    };
    let menus = raw
        .menus
        .into_iter()
        .map(|m| {
            insert(&m.id)?;
            if !m.top_level {
                return Err(PluginManifestError::Invalid(
                    "menus[].topLevel must be true".to_owned(),
                ));
            }
            Ok(domain::PluginTopLevelMenuContribution {
                id: m.id,
                label: m.label,
                top_level: true,
                order: m.order,
                icon: m
                    .icon
                    .map(domain::RelativePath::new)
                    .transpose()
                    .map_err(|e| PluginManifestError::Invalid(e.to_string()))?,
            })
        })
        .collect::<Result<Vec<_>, _>>()?;
    let menu_items = raw
        .menu_items
        .into_iter()
        .map(|m| {
            insert(&m.id)?;
            Ok(domain::PluginMenuItemContribution {
                id: m.id,
                target_menu_id: m.target_menu_id,
                label: m.label,
                command: domain::PluginCommandId::new(m.command)
                    .map_err(|e| PluginManifestError::Invalid(e.to_string()))?,
                order: m.order,
                icon: m
                    .icon
                    .map(domain::RelativePath::new)
                    .transpose()
                    .map_err(|e| PluginManifestError::Invalid(e.to_string()))?,
                when: m.when,
            })
        })
        .collect::<Result<Vec<_>, _>>()?;
    let layouts = raw
        .layouts
        .into_iter()
        .map(|l| {
            insert(&l.layout_type)?;
            if l.component.trim().is_empty() {
                return Err(PluginManifestError::Invalid(
                    "layouts[].component is required".to_owned(),
                ));
            }
            Ok(domain::PluginLayoutContribution {
                layout_type: domain::PluginLayoutType::new(l.layout_type)
                    .map_err(|e| PluginManifestError::Invalid(e.to_string()))?,
                label: l.label,
                component: l.component,
                order: l.order,
                icon: l
                    .icon
                    .map(domain::RelativePath::new)
                    .transpose()
                    .map_err(|e| PluginManifestError::Invalid(e.to_string()))?,
                when: l.when,
            })
        })
        .collect::<Result<Vec<_>, _>>()?;
    let mcp_servers = raw
        .mcp_servers
        .into_iter()
        .map(|s| {
            insert(&s.id)?;
            if s.transport != "stdio" {
                return Err(PluginManifestError::Invalid(
                    "mcpServers[].transport must be stdio".to_owned(),
                ));
            }
            if !s.allow_absolute_command && looks_absolute(&s.command) {
                return Err(PluginManifestError::Invalid(
                    "absolute MCP command requires allowAbsoluteCommand=true".to_owned(),
                ));
            }
            if !looks_absolute(&s.command) {
                domain::RelativePath::new(s.command.clone())
                    .map_err(|e| PluginManifestError::Invalid(e.to_string()))?;
            }
            if let Some(cwd) = &s.cwd {
                if cwd != "${pluginRoot}"
                    && !cwd.contains("${appDataDir}")
                    && !cwd.contains("${pluginRoot}")
                {
                    domain::RelativePath::new(cwd.clone())
                        .map_err(|e| PluginManifestError::Invalid(e.to_string()))?;
                }
            }
            Ok(domain::PluginMcpServerContribution {
                id: domain::PluginMcpServerId::new(s.id)
                    .map_err(|e| PluginManifestError::Invalid(e.to_string()))?,
                display_name: s.display_name,
                command: s.command,
                args: s.args,
                env: s.env.into_iter().collect(),
                cwd: s.cwd,
                transport: s.transport,
                auto_start: s.auto_start,
                allow_absolute_command: s.allow_absolute_command,
            })
        })
        .collect::<Result<Vec<_>, _>>()?;
    Ok(PluginContributionSet {
        menus,
        menu_items,
        layouts,
        mcp_servers,
    })
}

fn looks_absolute(path: &str) -> bool {
    path.starts_with('/') || path.starts_with('\\') || path.as_bytes().get(1) == Some(&b':')
}

fn engine_allows(range: &str, current: &str) -> bool {
    let cur = parse_version_tuple(current).unwrap_or((0, 0, 0));
    range.split_whitespace().all(|part| {
        if let Some(v) = part.strip_prefix(">=") {
            parse_version_tuple(v).is_some_and(|min| cur >= min)
        } else if let Some(v) = part.strip_prefix('>') {
            parse_version_tuple(v).is_some_and(|min| cur > min)
        } else if let Some(v) = part.strip_prefix("<=") {
            parse_version_tuple(v).is_some_and(|max| cur <= max)
        } else if let Some(v) = part.strip_prefix('<') {
            parse_version_tuple(v).is_some_and(|max| cur < max)
        } else if let Some(v) = part.strip_prefix('=') {
            parse_version_tuple(v).is_some_and(|eq| cur == eq)
        } else {
            true
        }
    })
}

fn parse_version_tuple(raw: &str) -> Option<(u64, u64, u64)> {
    let core = raw.split_once('-').map_or(raw, |(a, _)| a);
    let mut parts = core.split('.');
    Some((
        parts.next()?.parse().ok()?,
        parts.next()?.parse().ok()?,
        parts.next()?.parse().ok()?,
    ))
}

#[cfg(test)]
mod tests {
    use super::*;
    use domain::ports::{EventStream, PluginPackageStore, PluginRegistryStore, PluginStoreError};
    use std::collections::HashMap;
    use std::sync::Mutex;

    fn validator() -> JsonPluginManifestValidator {
        JsonPluginManifestValidator::new("0.3.0")
    }

    fn valid_manifest() -> Vec<u8> {
        br#"{
          "ideaPluginManifestVersion": 1,
          "id": "dev.acme.gitgraph",
          "displayName": "Git Graph",
          "publisher": "Acme",
          "version": "1.2.3",
          "engines": {"idea": ">=0.1.0 <1.0.0"},
          "main": "dist/index.js",
          "trustLevel": "full",
          "capabilities": ["ui", "mcp"],
          "contributes": {
            "menus": [{"id":"dev.acme.menu","label":"Graph","topLevel":true}],
            "menuItems": [{"id":"dev.acme.open","targetMenuId":"panels","label":"Open","command":"dev.acme.open"}],
            "layouts": [{"type":"dev.acme.layout","label":"Graph","component":"Graph"}],
            "mcpServers": [{"id":"dev.acme.mcp","displayName":"Tools","command":"servers/tool","transport":"stdio","autoStart":true}]
          }
        }"#.to_vec()
    }

    fn plugin_id() -> PluginId {
        PluginId::new("dev.acme.gitgraph").unwrap()
    }

    fn content_hash(raw: &str) -> ContentHash {
        ContentHash::new(raw).unwrap()
    }

    struct FakePackages {
        manifests: Mutex<HashMap<String, Vec<u8>>>,
        staged: Mutex<Option<StagedPluginPackage>>,
        removed: Mutex<Vec<String>>,
    }

    impl FakePackages {
        fn with_manifest(bytes: Vec<u8>) -> Self {
            let mut manifests = HashMap::new();
            manifests.insert("dev.acme.gitgraph".to_owned(), bytes);
            Self {
                manifests: Mutex::new(manifests),
                staged: Mutex::new(Some(StagedPluginPackage {
                    root: "/stage/plugin".to_owned(),
                    source: PluginInstallSource::Directory {
                        path_label: "/source/plugin".to_owned(),
                    },
                    content_hash: content_hash("abc123"),
                })),
                removed: Mutex::new(Vec::new()),
            }
        }
    }

    #[async_trait::async_trait]
    impl PluginPackageStore for FakePackages {
        async fn list_installed(&self) -> Result<Vec<domain::PluginPackageRef>, PluginStoreError> {
            Ok(self
                .manifests
                .lock()
                .unwrap()
                .keys()
                .map(|id| domain::PluginPackageRef {
                    plugin_id: Some(PluginId::new(id.clone()).unwrap()),
                    root: format!("/installed/{id}"),
                })
                .collect())
        }

        async fn read_manifest(
            &self,
            package: &domain::PluginPackageRef,
        ) -> Result<PluginManifestBytes, PluginStoreError> {
            let key = package
                .plugin_id
                .as_ref()
                .map_or("dev.acme.gitgraph", PluginId::as_str);
            self.manifests
                .lock()
                .unwrap()
                .get(key)
                .cloned()
                .map(|bytes| PluginManifestBytes { bytes })
                .ok_or(PluginStoreError::NotFound)
        }

        async fn install_from_archive(
            &self,
            _archive: &LocalPath,
        ) -> Result<StagedPluginPackage, PluginStoreError> {
            self.staged
                .lock()
                .unwrap()
                .take()
                .ok_or_else(|| PluginStoreError::Invalid("missing staged package".to_owned()))
        }

        async fn install_from_directory(
            &self,
            _dir: &LocalPath,
        ) -> Result<StagedPluginPackage, PluginStoreError> {
            self.staged
                .lock()
                .unwrap()
                .take()
                .ok_or_else(|| PluginStoreError::Invalid("missing staged package".to_owned()))
        }

        async fn commit_install(
            &self,
            staged: StagedPluginPackage,
            plugin_id: &PluginId,
        ) -> Result<domain::PluginPackageRef, PluginStoreError> {
            self.manifests
                .lock()
                .unwrap()
                .insert(plugin_id.as_str().to_owned(), valid_manifest());
            Ok(domain::PluginPackageRef {
                plugin_id: Some(plugin_id.clone()),
                root: staged.root,
            })
        }

        async fn remove_package(
            &self,
            plugin_id: &PluginId,
        ) -> Result<RemovalOutcome, PluginStoreError> {
            self.removed
                .lock()
                .unwrap()
                .push(plugin_id.as_str().to_owned());
            Ok(RemovalOutcome::Removed)
        }

        fn bundle_url(
            &self,
            plugin_id: &PluginId,
            entry: &domain::RelativePath,
            hash: &ContentHash,
        ) -> Result<domain::PluginBundleUrl, PluginStoreError> {
            Ok(domain::PluginBundleUrl::new(format!(
                "idea-plugin://{}/current/{}/{}",
                plugin_id.as_str(),
                hash.as_str(),
                entry.as_str()
            )))
        }

        fn app_data_dir_label(&self) -> Option<String> {
            Some("/app-data".to_owned())
        }
    }

    #[derive(Default)]
    struct FakeRegistry {
        registry: Mutex<domain::PluginRegistry>,
    }

    #[async_trait::async_trait]
    impl PluginRegistryStore for FakeRegistry {
        async fn load_registry(&self) -> Result<domain::PluginRegistry, PluginRegistryError> {
            Ok(self.registry.lock().unwrap().clone())
        }

        async fn save_registry(
            &self,
            registry: &domain::PluginRegistry,
        ) -> Result<(), PluginRegistryError> {
            *self.registry.lock().unwrap() = registry.clone();
            Ok(())
        }
    }

    #[derive(Default)]
    struct FakeEvents {
        events: Mutex<Vec<DomainEvent>>,
    }

    impl EventBus for FakeEvents {
        fn publish(&self, event: DomainEvent) {
            self.events.lock().unwrap().push(event);
        }

        fn subscribe(&self) -> EventStream {
            Box::new(std::iter::empty())
        }
    }

    #[derive(Default)]
    struct FakeMcp {
        reconciles: Mutex<Vec<Vec<PluginMcpServerSpec>>>,
        stops: Mutex<Vec<String>>,
    }

    #[async_trait::async_trait]
    impl PluginMcpSupervisor for FakeMcp {
        async fn reconcile(
            &self,
            active_servers: Vec<PluginMcpServerSpec>,
        ) -> Result<domain::PluginMcpStatusSet, PluginMcpError> {
            self.reconciles.lock().unwrap().push(active_servers.clone());
            Ok(domain::PluginMcpStatusSet {
                servers: active_servers
                    .into_iter()
                    .map(|spec| domain::PluginMcpStatus {
                        identity: spec.identity,
                        running: true,
                        error: None,
                    })
                    .collect(),
            })
        }

        async fn stop_plugin(&self, plugin_id: &PluginId) -> Result<(), PluginMcpError> {
            self.stops
                .lock()
                .unwrap()
                .push(plugin_id.as_str().to_owned());
            Ok(())
        }
    }

    fn registry_with(state: PluginLifecycleState) -> domain::PluginRegistry {
        domain::PluginRegistry {
            version: 1,
            plugins: vec![PluginRegistryEntry {
                id: plugin_id(),
                lifecycle_state: state,
                source: PluginInstallSource::Directory {
                    path_label: "/source/plugin".to_owned(),
                },
                content_hash: content_hash("abc123"),
                restart_required: false,
                error: None,
            }],
        }
    }

    #[test]
    fn validates_manifest_v1() {
        let m = validator()
            .validate(
                &valid_manifest(),
                &domain::PluginPackageRef {
                    plugin_id: None,
                    root: "x".into(),
                },
            )
            .unwrap();
        assert_eq!(m.id.as_str(), "dev.acme.gitgraph");
        assert_eq!(m.contributes.layouts.len(), 1);
        assert_eq!(m.contributes.mcp_servers.len(), 1);
    }

    #[test]
    fn rejects_unsafe_main_path_and_non_full_trust() {
        let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
        value["main"] = serde_json::json!("../dist/index.js");
        assert!(validator()
            .validate(
                &serde_json::to_vec(&value).unwrap(),
                &domain::PluginPackageRef {
                    plugin_id: None,
                    root: "x".into()
                }
            )
            .is_err());
        value["main"] = serde_json::json!("dist/index.js");
        value["trustLevel"] = serde_json::json!("sandbox");
        assert!(validator()
            .validate(
                &serde_json::to_vec(&value).unwrap(),
                &domain::PluginPackageRef {
                    plugin_id: None,
                    root: "x".into()
                }
            )
            .is_err());
    }

    #[test]
    fn rejects_duplicate_contribution_ids() {
        let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
        value["contributes"]["layouts"][0]["type"] = serde_json::json!("dev.acme.open");
        assert!(validator()
            .validate(
                &serde_json::to_vec(&value).unwrap(),
                &domain::PluginPackageRef {
                    plugin_id: None,
                    root: "x".into()
                }
            )
            .is_err());
    }

    #[tokio::test]
    async fn runtime_catalog_excludes_disabled_and_pending_uninstall_plugins() {
        for state in [
            PluginLifecycleState::Disabled,
            PluginLifecycleState::PendingUninstall,
            PluginLifecycleState::Invalid,
        ] {
            let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
            let registry = Arc::new(FakeRegistry {
                registry: Mutex::new(registry_with(state)),
            });
            let usecase =
                ListPluginRuntimeContributions::new(packages, registry, Arc::new(validator()));

            let catalog = usecase.execute().await.unwrap();

            assert!(catalog.plugins.is_empty(), "{state:?} must not be active");
        }
    }

    #[tokio::test]
    async fn reconcile_mcp_uses_only_enabled_auto_start_servers_with_plugin_identity() {
        let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
        let registry = Arc::new(FakeRegistry {
            registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
        });
        let mcp = Arc::new(FakeMcp::default());
        let usecase =
            ReconcilePluginMcpServers::new(packages, registry, Arc::new(validator()), mcp.clone());

        let statuses = usecase.execute().await.unwrap();

        assert_eq!(statuses.servers.len(), 1);
        assert_eq!(
            statuses.servers[0].identity,
            "plugin:dev.acme.gitgraph:dev.acme.mcp"
        );
        let reconciles = mcp.reconciles.lock().unwrap();
        assert_eq!(reconciles.len(), 1);
        assert_eq!(
            reconciles[0][0].command,
            "/installed/dev.acme.gitgraph/servers/tool"
        );
        assert_eq!(reconciles[0][0].cwd, "/installed/dev.acme.gitgraph");
    }

    #[tokio::test]
    async fn reconcile_mcp_does_not_spawn_pending_uninstall_plugin_servers() {
        let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
        let registry = Arc::new(FakeRegistry {
            registry: Mutex::new(registry_with(PluginLifecycleState::PendingUninstall)),
        });
        let mcp = Arc::new(FakeMcp::default());
        let usecase =
            ReconcilePluginMcpServers::new(packages, registry, Arc::new(validator()), mcp.clone());

        let statuses = usecase.execute().await.unwrap();

        assert!(statuses.servers.is_empty());
        let reconciles = mcp.reconciles.lock().unwrap();
        assert_eq!(reconciles.len(), 1);
        assert!(reconciles[0].is_empty());
    }

    #[tokio::test]
    async fn reconcile_mcp_substitutes_app_data_dir_in_plugin_server_specs() {
        let mut manifest: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
        manifest["contributes"]["mcpServers"][0]["command"] =
            serde_json::json!("${appDataDir}/plugin-tools/gitgraph");
        manifest["contributes"]["mcpServers"][0]["args"] =
            serde_json::json!(["--cache", "${appDataDir}/cache", "--root", "${pluginRoot}"]);
        manifest["contributes"]["mcpServers"][0]["env"] = serde_json::json!({
            "PLUGIN_CACHE": "${appDataDir}/cache/dev.acme.gitgraph",
            "PLUGIN_ROOT": "${pluginRoot}"
        });
        manifest["contributes"]["mcpServers"][0]["cwd"] =
            serde_json::json!("${appDataDir}/work/dev.acme.gitgraph");
        let packages = Arc::new(FakePackages::with_manifest(
            serde_json::to_vec(&manifest).unwrap(),
        ));
        let registry = Arc::new(FakeRegistry {
            registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
        });
        let mcp = Arc::new(FakeMcp::default());
        let usecase =
            ReconcilePluginMcpServers::new(packages, registry, Arc::new(validator()), mcp.clone());

        usecase.execute().await.unwrap();

        let reconciles = mcp.reconciles.lock().unwrap();
        let spec = &reconciles[0][0];
        assert_eq!(spec.command, "/app-data/plugin-tools/gitgraph");
        assert_eq!(
            spec.args,
            vec![
                "--cache".to_owned(),
                "/app-data/cache".to_owned(),
                "--root".to_owned(),
                "/installed/dev.acme.gitgraph".to_owned()
            ]
        );
        assert!(spec.env.contains(&(
            "PLUGIN_CACHE".to_owned(),
            "/app-data/cache/dev.acme.gitgraph".to_owned()
        )));
        assert!(spec.env.contains(&(
            "PLUGIN_ROOT".to_owned(),
            "/installed/dev.acme.gitgraph".to_owned()
        )));
        assert_eq!(spec.cwd, "/app-data/work/dev.acme.gitgraph");
    }

    #[tokio::test]
    async fn disable_stops_plugin_and_removes_it_from_runtime_catalog() {
        let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
        let registry = Arc::new(FakeRegistry {
            registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
        });
        let events = Arc::new(FakeEvents::default());
        let mcp = Arc::new(FakeMcp::default());
        let disable = SetPluginEnabled::new(
            packages.clone(),
            registry.clone(),
            Arc::new(validator()),
            events.clone(),
            mcp.clone(),
        );

        let admin = disable
            .execute(SetPluginEnabledInput {
                plugin_id: "dev.acme.gitgraph".to_owned(),
                enabled: false,
            })
            .await
            .unwrap();

        assert!(!admin.enabled);
        assert_eq!(admin.lifecycle_state, PluginLifecycleState::Disabled);
        assert_eq!(&*mcp.stops.lock().unwrap(), &["dev.acme.gitgraph"]);
        assert!(events.events.lock().unwrap().iter().any(|event| matches!(
            event,
            DomainEvent::PluginDisabled {
                restart_required: true,
                ..
            }
        )));

        let runtime =
            ListPluginRuntimeContributions::new(packages, registry, Arc::new(validator()))
                .execute()
                .await
                .unwrap();
        assert!(runtime.plugins.is_empty());
    }

    #[tokio::test]
    async fn uninstall_removes_registry_package_and_stops_mcp() {
        let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
        let registry = Arc::new(FakeRegistry {
            registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
        });
        let events = Arc::new(FakeEvents::default());
        let mcp = Arc::new(FakeMcp::default());
        let uninstall = UninstallPlugin::new(
            packages.clone(),
            registry.clone(),
            events.clone(),
            mcp.clone(),
        );

        let result = uninstall
            .execute(UninstallPluginInput {
                plugin_id: "dev.acme.gitgraph".to_owned(),
            })
            .await
            .unwrap();

        assert_eq!(result.removal_outcome, RemovalOutcome::Removed);
        assert!(result.restart_required);
        assert!(registry.load_registry().await.unwrap().plugins.is_empty());
        assert_eq!(&*packages.removed.lock().unwrap(), &["dev.acme.gitgraph"]);
        assert_eq!(&*mcp.stops.lock().unwrap(), &["dev.acme.gitgraph"]);
        assert!(events.events.lock().unwrap().iter().any(|event| matches!(
            event,
            DomainEvent::PluginUninstalled {
                restart_required: true,
                ..
            }
        )));
    }
}
