//! Side-effect handlers for `/plugins` modal outcomes.
//!
//! Called from the async main loop. Each `apply_*` function mutates the
//! modal state (`PluginsModalState`) to reflect success/failure, and
//! performs filesystem/network/registry side-effects. On failure, the
//! error is surfaced via `state.row_error` (or the editor's `error`
//! field for `AddMarketplace`).

use std::path::PathBuf;
use std::sync::{Arc, Mutex};

use synaps_cli::skills::{
    install,
    marketplace::{
        derive_git_clone_url, fetch_manifest, fetch_marketplace, is_safe_plugin_name, is_trusted,
        trust_host_for_source,
    },
    manifest::PluginManifest,
    plugin_index::{
        PluginIndexCapabilities, PluginIndexChecksum, PluginIndexCompatibility, PluginIndexEntry,
        PluginIndexTrust,
    },
    post_install,
    reload_registry,
    registry::CommandRegistry,
    state::{CachedPluginIndexMetadata, InstalledPlugin, Marketplace, PluginsState, SetupStatus},
    update_diff::diff_plugin_manifests,
};

use super::progress::{parse_progress_line, InstallProgress, InstallProgressHandle};
use super::state::{PendingInstallTask, PluginsModalState, RightMode};

/// Persist plugins state to its canonical path. Helper used by every mutator.
fn commit_plugins_state(file: &PluginsState) -> std::io::Result<()> {
    file.save_to(&PluginsState::default_path())
}

/// Resolve `~/.synaps-cli/plugins/<name>` (profile-aware).
///
/// Defense-in-depth: rejects unsafe names via [`is_safe_plugin_name`]. This
/// should never fire in practice because `validate_manifest` already rejects
/// such names upstream during marketplace fetch.
fn install_dir_for(name: &str) -> Result<PathBuf, String> {
    if !is_safe_plugin_name(name) {
        return Err("refused to install plugin with unsafe name".into());
    }
    Ok(synaps_cli::config::resolve_write_path("plugins").join(name))
}

fn now_rfc3339() -> String {
    chrono::Utc::now().to_rfc3339()
}

fn cached_index_metadata(entry: &PluginIndexEntry) -> CachedPluginIndexMetadata {
    CachedPluginIndexMetadata {
        repository: entry.repository.clone(),
        subdir: entry.subdir.clone(),
        checksum_algorithm: entry.checksum.algorithm.clone(),
        checksum_value: entry.checksum.value.clone(),
        compatibility_synaps: entry.compatibility.synaps.clone(),
        compatibility_extension_protocol: entry.compatibility.extension_protocol.clone(),
        has_extension: entry.capabilities.has_extension,
        skills: entry.capabilities.skills.clone(),
        permissions: entry.capabilities.permissions.clone(),
        hooks: entry.capabilities.hooks.clone(),
        commands: entry.capabilities.commands.clone(),
        providers: entry.capabilities.providers.clone(),
        trust_publisher: entry.trust.as_ref().and_then(|t| t.publisher.clone()),
        trust_homepage: entry.trust.as_ref().and_then(|t| t.homepage.clone()),
    }
}

/// Resolve a cached plugin's `source` into the concrete info needed to
/// install: the clone URL, and the optional repo subdir when the plugin
/// lives inside the marketplace repo (Claude-Code-style `./<name>` sources).
///
/// Invariants:
/// - For absolute https sources, returns the source unchanged, subdir=None.
/// - For `./<name>` sources, returns the marketplace's repo_url (falling
///   back to one derived from the marketplace URL if not stored), and
///   subdir=Some(name).
fn resolve_install_target(
    cached_source: &str,
    marketplace: &Marketplace,
) -> Result<(String, Option<String>), String> {
    let s = cached_source.trim();
    if let Some(subdir) = s.strip_prefix("./") {
        if !is_safe_plugin_name(subdir) {
            return Err(format!("refusing unsafe relative source '{}'", s));
        }
        let clone_url = marketplace
            .repo_url
            .clone()
            .or_else(|| derive_git_clone_url(&marketplace.url).ok())
            .ok_or_else(|| {
                format!(
                    "marketplace '{}' has a repo-relative source but no clone URL is known",
                    marketplace.name
                )
            })?;
        Ok((clone_url, Some(subdir.to_string())))
    } else {
        Ok((s.to_string(), None))
    }
}

/// Add-marketplace flow: normalize (probing both `.synaps-plugin/` and
/// `.claude-plugin/` for GitHub URLs), fetch manifest, push Marketplace, save.
/// On error, stash in editor's `error` field (if still in editor) or row_error.
pub(crate) async fn apply_add_marketplace(state: &mut PluginsModalState, url: String) {
    let (manifest, used_url) = match fetch_marketplace(&url).await {
        Ok(v) => v,
        Err(e) => {
            set_editor_or_row_error(state, e);
            return;
        }
    };
    // Derive git clone URL so plugins with "./subdir" sources can clone the
    // marketplace repo later. Only GitHub URLs have a known scheme for this;
    // non-GitHub marketplaces simply won't support relative sources.
    let repo_url = derive_git_clone_url(&url).ok();
    let cached_plugins = manifest
        .plugins
        .iter()
        .map(|p| synaps_cli::skills::state::CachedPlugin {
            name: p.name.clone(),
            source: p.source.clone().unwrap_or_else(|| p.index.as_ref().map(|idx| idx.repository.clone()).unwrap_or_default()),
            version: p.version.clone(),
            description: p.description.clone(),
            index: p.index.as_ref().map(cached_index_metadata),
        })
        .collect();
    let new_m = Marketplace {
        name: manifest.name.clone(),
        url: used_url,
        description: manifest.description.clone(),
        last_refreshed: Some(now_rfc3339()),
        cached_plugins,
        repo_url,
    };
    // Replace existing marketplace with same name, or push.
    if let Some(slot) = state.file.marketplaces.iter_mut().find(|m| m.name == new_m.name) {
        *slot = new_m;
    } else {
        state.file.marketplaces.push(new_m);
    }
    if let Err(e) = commit_plugins_state(&state.file) {
        set_editor_or_row_error(state, format!("save failed: {}", e));
        return;
    }
    state.mode = RightMode::List;
    state.row_error = None;
}

/// Install-or-trust-prompt flow. If host not trusted, switches state to
/// TrustPrompt and returns without side-effects. Otherwise runs install flow.
pub(crate) async fn apply_install(
    state: &mut PluginsModalState,
    marketplace_name: String,
    plugin_name: String,
    registry: &Arc<CommandRegistry>,
    config: &synaps_cli::SynapsConfig,
) {
    let Some(marketplace) = state
        .file
        .marketplaces
        .iter()
        .find(|m| m.name == marketplace_name)
        .cloned()
    else {
        state.row_error = Some(format!("marketplace '{}' not found", marketplace_name));
        return;
    };
    let Some(cached) = marketplace
        .cached_plugins
        .iter()
        .find(|p| p.name == plugin_name)
        .cloned()
    else {
        state.row_error = Some(format!("plugin '{}' not found in '{}'", plugin_name, marketplace_name));
        return;
    };
    let summary = permission_summary_for_plugin_name(&plugin_name)
        .unwrap_or_else(|| vec![format!("source: {}", cached.source)]);
    let (effective_source, subdir) = match resolve_install_target(&cached.source, &marketplace) {
        Ok(v) => v,
        Err(e) => {
            state.row_error = Some(e);
            return;
        }
    };
    let host = match trust_host_for_source(&effective_source) {
        Ok(h) => h,
        Err(e) => {
            state.row_error = Some(e);
            return;
        }
    };
    if !is_trusted(&effective_source, &state.file.trusted_hosts) {
        state.mode = RightMode::TrustPrompt {
            plugin_name: plugin_name.clone(),
            host,
            pending_source: effective_source,
            summary,
        };
        return;
    }
    if let Some(index) = cached.index.clone() {
        let entry = plugin_index_entry_from_cached(&cached.name, &cached, index);
        apply_install_from_index_entry(state, marketplace_name, entry, registry, config).await;
        return;
    }
    run_install_flow(
        state, plugin_name, effective_source, subdir, Some(marketplace_name), None,
        registry, config,
    );
}

fn plugin_index_entry_from_cached(
    fallback_name: &str,
    cached: &synaps_cli::skills::state::CachedPlugin,
    index: CachedPluginIndexMetadata,
) -> PluginIndexEntry {
    PluginIndexEntry {
        id: fallback_name.to_string(),
        name: cached.name.clone(),
        version: cached.version.clone().unwrap_or_else(|| "0.0.0".to_string()),
        description: cached.description.clone().unwrap_or_default(),
        repository: index.repository,
        subdir: index.subdir,
        license: None,
        categories: vec![],
        keywords: vec![],
        checksum: PluginIndexChecksum {
            algorithm: index.checksum_algorithm,
            value: index.checksum_value,
        },
        compatibility: PluginIndexCompatibility {
            synaps: index.compatibility_synaps,
            extension_protocol: index.compatibility_extension_protocol,
        },
        capabilities: PluginIndexCapabilities {
            skills: index.skills,
            has_extension: index.has_extension,
            permissions: index.permissions,
            hooks: index.hooks,
            commands: index.commands,
            providers: index.providers,
        },
        trust: if index.trust_publisher.is_some() || index.trust_homepage.is_some() {
            Some(PluginIndexTrust {
                publisher: index.trust_publisher,
                homepage: index.trust_homepage,
            })
        } else {
            None
        },
    }
}

pub(crate) async fn apply_install_from_index_entry(
    state: &mut PluginsModalState,
    marketplace_name: String,
    entry: PluginIndexEntry,
    registry: &Arc<CommandRegistry>,
    config: &synaps_cli::SynapsConfig,
) {
    let mut summary = vec![
        format!("index plugin: {} {}", entry.id, entry.version),
        format!("repository: {}", entry.repository),
        format!("checksum: {}:{}", entry.checksum.algorithm, entry.checksum.value),
        format!("executable extension: {}", if entry.capabilities.has_extension { "yes" } else { "no" }),
        format!("permissions: {}", if entry.capabilities.permissions.is_empty() { "none".to_string() } else { entry.capabilities.permissions.join(", ") }),
        format!("hooks: {}", if entry.capabilities.hooks.is_empty() { "none".to_string() } else { entry.capabilities.hooks.join(", ") }),
    ];
    if !entry.capabilities.providers.is_empty() {
        summary.push(format!(
            "providers: {}",
            entry.capabilities.providers.iter().map(|p| format!("{} ({})", p.id, p.models.join(", "))).collect::<Vec<_>>().join("; ")
        ));
    }
    if entry.capabilities.permissions.iter().any(|permission| permission == "providers.register") {
        summary.push("HIGH IMPACT: selected provider models receive conversation content".to_string());
    }
    summary.push("fetched plugin manifest will be re-inspected before final install".to_string());
    let host = match trust_host_for_source(&entry.repository) {
        Ok(h) => h,
        Err(e) => {
            state.row_error = Some(e);
            return;
        }
    };
    if !is_trusted(&entry.repository, &state.file.trusted_hosts) {
        state.mode = RightMode::TrustPrompt {
            plugin_name: entry.id,
            host,
            pending_source: entry.repository,
            summary,
        };
        return;
    }
    let install_source = state
        .file
        .marketplaces
        .iter()
        .find(|m| m.name == marketplace_name)
        .and_then(|m| m.cached_plugins.iter().find(|p| p.name == entry.id))
        .map(|p| p.source.clone())
        .unwrap_or_else(|| entry.repository.clone());
    let checksum = Some((entry.checksum.algorithm.clone(), entry.checksum.value.clone()));
    run_install_flow(
        state,
        entry.id,
        install_source,
        entry.subdir,
        Some(marketplace_name),
        checksum,
        registry,
        config,
    );
}

/// Trust the host, then install. Used for `TrustAndInstall` outcome.
pub(crate) async fn apply_trust_and_install(
    state: &mut PluginsModalState,
    plugin_name: String,
    host: String,
    source: String,
    _summary: Vec<String>,
    registry: &Arc<CommandRegistry>,
    config: &synaps_cli::SynapsConfig,
) {
    if !state.file.trusted_hosts.iter().any(|h| h == &host) {
        state.file.trusted_hosts.push(host);
    }
    // Find marketplace owning this source (if any) so installed entry links back.
    // Also resolve subdir for Claude-Code-style plugins.
    let mut marketplace_name: Option<String> = None;
    let mut subdir: Option<String> = None;
    for m in &state.file.marketplaces {
        for p in &m.cached_plugins {
            if p.name != plugin_name { continue; }
            marketplace_name = Some(m.name.clone());
            if let Some(rest) = p.source.strip_prefix("./") {
                subdir = Some(rest.to_string());
            }
            break;
        }
    }
    run_install_flow(state, plugin_name, source, subdir, marketplace_name, None, registry, config);
}

/// Clone/snapshot into a temporary sibling directory. Returns (HEAD sha, temp dir path).
#[allow(dead_code)] // convenience wrapper — callers currently use _with_progress directly
fn install_plugin_to_temp_with_checksum(
    plugin_name: &str,
    source_url: &str,
    subdir: Option<String>,
    final_dest: &std::path::Path,
    expected_checksum: Option<(String, String)>,
) -> Result<(String, PathBuf), String> {
    install_plugin_to_temp_with_progress(
        plugin_name,
        source_url,
        subdir,
        final_dest,
        expected_checksum,
        |_| {},
    )
}

/// Same as [`install_plugin_to_temp_with_checksum`] but forwards every
/// chunk of `git clone --progress` stderr to `on_chunk` so the chatui
/// modal can drive a live progress gauge.
fn install_plugin_to_temp_with_progress(
    plugin_name: &str,
    source_url: &str,
    subdir: Option<String>,
    final_dest: &std::path::Path,
    expected_checksum: Option<(String, String)>,
    on_chunk: impl FnMut(&str),
) -> Result<(String, PathBuf), String> {
    let parent = final_dest.parent().ok_or_else(|| "dest has no parent directory".to_string())?;
    std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {}", parent.display(), e))?;
    let tmp = parent.join(format!(".{}-pending-install", plugin_name));
    let _ = std::fs::remove_dir_all(&tmp);
    let sha = match subdir {
        Some(s) => install::install_plugin_from_subdir_with_progress(source_url, &s, &tmp, on_chunk),
        None => install::install_plugin_with_progress(source_url, &tmp, on_chunk),
    }?;
    if let Some((algorithm, expected)) = expected_checksum {
        if let Err(e) = install::verify_plugin_dir_checksum(&tmp, &algorithm, &expected) {
            let _ = std::fs::remove_dir_all(&tmp);
            return Err(e);
        }
    }
    Ok((sha, tmp))
}

fn finalize_pending_install(temp_dir: &std::path::Path, final_dir: &std::path::Path) -> Result<(), String> {
    if final_dir.exists() {
        let _ = std::fs::remove_dir_all(temp_dir);
        return Err(format!("{} already exists on disk; uninstall first", final_dir.display()));
    }
    std::fs::rename(temp_dir, final_dir)
        .map_err(|e| format!("finalize install {} -> {}: {}", temp_dir.display(), final_dir.display(), e))
}

fn cancel_pending_temp(temp_dir: &std::path::Path) {
    let _ = std::fs::remove_dir_all(temp_dir);
}

/// If the manifest at `final_dir/.synaps-plugin/plugin.json` declares
/// `provides.sidecar.setup`, run it. Returns:
/// - `Ok(None)` if no setup is declared (nothing to do)
/// - `Ok(Some(log_path))` if setup ran successfully (caller may surface
///   the log path as informational)
/// - `Err(message)` describing the failure plus a pointer to the log
///   file. The caller should set `state.row_error = Some(message)` but
///   still proceed to record the install (source is on disk; user can
///   rerun setup manually).
#[derive(Debug, Clone)]
enum PostInstallOutcome {
    NotRequired,
    BinaryAlreadyPresent,
    PrebuiltInstalled,
    SetupSucceeded(PathBuf),
}

impl PostInstallOutcome {
    fn setup_status(&self) -> SetupStatus {
        match self {
            PostInstallOutcome::SetupSucceeded(path) => SetupStatus::Succeeded {
                log_path: Some(path.display().to_string()),
            },
            _ => SetupStatus::NotRequired,
        }
    }
}

#[derive(Debug, thiserror::Error)]
enum PostInstallError {
    #[error("{0}")]
    Hard(String),
    #[error("{0}")]
    SoftSetup(String),
}

impl PostInstallError {
    fn setup_status(&self) -> SetupStatus {
        SetupStatus::Failed {
            message: self.to_string(),
            log_path: None,
        }
    }
}

async fn run_post_install_setup_for_dir(
    plugin_name: &str,
    final_dir: &std::path::Path,
) -> Result<PostInstallOutcome, PostInstallError> {
    let manifest = match read_plugin_manifest(final_dir) {
        Ok(m) => m,
        Err(e) => {
            return Err(PostInstallError::Hard(format!(
                "installed but could not read manifest to check for setup script: {e}"
            )));
        }
    };

    // Fast-path (slice D): if the manifest declares an
    // `extension.command` and that binary already exists and is
    // executable inside the freshly-cloned plugin dir, skip the setup
    // script entirely. This handles two cases:
    //   1. Source ships a prebuilt binary in the repo
    //   2. User reinstalled and the build artifact survived in place
    if let Ok(Some(_resolved)) = post_install::verify_extension_command(&manifest, final_dir) {
        return Ok(PostInstallOutcome::BinaryAlreadyPresent);
    }

    // Slice E: try a per-host prebuilt download before falling back to
    // the (potentially slow) source build. Only `NoMatchingAsset`
    // falls through to setup — any other error (checksum mismatch,
    // network failure, extract failure) surfaces immediately so we
    // never silently mask a security or infrastructure problem with
    // an opaque rebuild.
    match post_install::try_install_from_prebuilt(&manifest, final_dir).await {
        Ok(_resolved) => return Ok(PostInstallOutcome::PrebuiltInstalled),
        Err(post_install::PrebuiltError::NoMatchingAsset) => {}
        Err(e) => {
            return Err(PostInstallError::Hard(format!("prebuilt asset install failed: {e}")));
        }
    }

    let resolved = post_install::resolve_setup_script(&manifest, final_dir)
        .map_err(|e| PostInstallError::Hard(e.to_string()))?;
    let Some(script) = resolved else {
        // No setup script declared. If the manifest declares an
        // extension.command, verify it now — otherwise we'd silently
        // accept an install with a dangling binary path.
        if manifest.extension.is_some() {
            post_install::verify_extension_command(&manifest, final_dir)
                .map_err(|e| PostInstallError::Hard(format!("extension command verification failed: {e}")))?;
        }
        return Ok(PostInstallOutcome::NotRequired);
    };
    let logs_root = synaps_cli::config::resolve_write_path("logs");
    let log_path = post_install::install_log_path(&logs_root, plugin_name, &now_rfc3339());
    let setup_log = match post_install::run_setup_script(
        &script,
        final_dir,
        &log_path,
        post_install::SETUP_TIMEOUT,
    )
    .await
    {
        Ok(outcome) => outcome.log_path,
        Err(e) => return Err(PostInstallError::SoftSetup(e.to_string())),
    };

    // Slice C / F: post-condition check. If the manifest declares an
    // `extension.command`, the setup script's job was to produce it.
    // Surface a clear error if it didn't, instead of silently breaking
    // spawn at runtime.
    if manifest.extension.is_some() {
        if let Err(e) = post_install::verify_extension_command(&manifest, final_dir) {
            return Err(PostInstallError::SoftSetup(format!(
                "setup script ran (see {}) but did not produce the declared extension binary: {}",
                setup_log.display(),
                e
            )));
        }
    }

    Ok(PostInstallOutcome::SetupSucceeded(setup_log))
}

fn read_plugin_manifest(path: &std::path::Path) -> Result<PluginManifest, String> {
    let manifest_path = path.join(".synaps-plugin/plugin.json");
    let body = std::fs::read_to_string(&manifest_path)
        .map_err(|e| format!("read {}: {}", manifest_path.display(), e))?;
    serde_json::from_str(&body)
        .map_err(|e| format!("parse {}: {}", manifest_path.display(), e))
}

fn summarize_plugin_dir(path: &std::path::Path) -> Vec<String> {
    let Some(parent) = path.parent() else {
        return vec!["plugin manifest could not be inspected before install".to_string()];
    };
    let Ok(path_abs) = path.canonicalize() else {
        return vec!["plugin manifest could not be inspected before install".to_string()];
    };
    let (plugins, _) = synaps_cli::skills::loader::load_all(&[parent.to_path_buf()]);
    if let Some(plugin) = plugins.into_iter().find(|plugin| plugin.root == path_abs) {
        synaps_cli::skills::trust::summarize_plugin_permissions(&plugin).lines()
    } else {
        vec!["plugin manifest could not be inspected before install".to_string()]
    }
}

fn record_installed_plugin(
    state: &mut PluginsModalState,
    plugin_name: String,
    marketplace_name: Option<String>,
    source_url: String,
    installed_commit: String,
    source_subdir: Option<String>,
    checksum: Option<(String, String)>,
    setup_status: SetupStatus,
) {
    state.file.installed.push(InstalledPlugin {
        name: plugin_name,
        marketplace: marketplace_name,
        source_url,
        installed_commit,
        latest_commit: None,
        installed_at: now_rfc3339(),
        source_subdir,
        checksum_algorithm: checksum.as_ref().map(|(algorithm, _)| algorithm.clone()),
        checksum_value: checksum.map(|(_, value)| value),
        setup_status,
    });
}

fn expected_update_checksum(state: &PluginsState, installed: &InstalledPlugin) -> Option<(String, String)> {
    let marketplace_name = installed.marketplace.as_deref()?;
    state
        .marketplaces
        .iter()
        .find(|m| m.name == marketplace_name)
        .and_then(|m| m.cached_plugins.iter().find(|p| p.name == installed.name))
        .and_then(|p| p.index.as_ref())
        .map(|index| (index.checksum_algorithm.clone(), index.checksum_value.clone()))
}

/// Kick off a background `git clone --progress` for `plugin_name` and
/// return immediately. The actual install is finished later by
/// [`complete_pending_install_clone`] which is polled from the main loop's
/// animation tick when [`PluginsModalState::install_task_finished`]
/// returns true.
///
/// While the task runs:
/// - `state.mode` is set to `RightMode::Installing { progress }`, so the
///   modal renders an animated gauge.
/// - `state.pending_install` holds the JoinHandle and the metadata we
///   need to resume the install pipeline once the clone finishes.
fn run_install_flow(
    state: &mut PluginsModalState,
    plugin_name: String,
    source_url: String,
    subdir: Option<String>,
    marketplace_name: Option<String>,
    expected_checksum: Option<(String, String)>,
    _registry: &Arc<CommandRegistry>,
    _config: &synaps_cli::SynapsConfig,
) {
    let dest = match install_dir_for(&plugin_name) {
        Ok(d) => d,
        Err(e) => {
            state.row_error = Some(e);
            return;
        }
    };
    // Don't kick off a second clone if one is already running — the modal
    // gates 'i' while in Installing mode, but defend in depth.
    if state.pending_install.is_some() {
        return;
    }

    let progress: InstallProgressHandle =
        Arc::new(Mutex::new(InstallProgress::new(plugin_name.clone())));
    let progress_for_task = Arc::clone(&progress);

    let src = source_url.clone();
    let dest_clone = dest.clone();
    let subdir_for_install = subdir.clone();
    let plugin_name_for_task = plugin_name.clone();
    let expected_for_task = expected_checksum.clone();

    let join = tokio::task::spawn_blocking(move || {
        install_plugin_to_temp_with_progress(
            &plugin_name_for_task,
            &src,
            subdir_for_install,
            &dest_clone,
            expected_for_task,
            |chunk| {
                if let Some(snap) = parse_progress_line(chunk) {
                    if let Ok(mut p) = progress_for_task.lock() {
                        p.apply(snap);
                    }
                }
            },
        )
    });

    state.mode = RightMode::Installing {
        progress: Arc::clone(&progress),
    };
    state.pending_install = Some(PendingInstallTask {
        join,
        progress,
        plugin_name,
        source_url,
        subdir,
        marketplace_name,
        expected_checksum,
        final_dir: dest,
    });
    state.row_error = None;
}

/// Reap a finished background install task and run the post-clone half of
/// the install pipeline (executable-extension confirm prompt, finalize,
/// post-install setup, persist). Called from the main loop's animation
/// tick once [`PluginsModalState::install_task_finished`] returns true.
///
/// Safe to call when no install is in flight — returns immediately.
pub(crate) async fn complete_pending_install_clone(
    state: &mut PluginsModalState,
    registry: &Arc<CommandRegistry>,
    config: &synaps_cli::SynapsConfig,
) {
    let Some(task) = state.pending_install.take() else {
        return;
    };
    // Drop the Installing overlay before we render the next frame — the
    // PendingInstallConfirm / List transitions below will set the right mode.
    state.mode = RightMode::List;

    let PendingInstallTask {
        join,
        progress,
        plugin_name,
        source_url,
        subdir,
        marketplace_name,
        expected_checksum,
        final_dir: dest,
    } = task;

    let install_res = join.await;

    let (sha, temp_dir) = match install_res {
        Ok(Ok(v)) => {
            if let Ok(mut p) = progress.lock() {
                p.finish_clone();
            }
            v
        }
        Ok(Err(e)) => {
            if let Ok(mut p) = progress.lock() {
                p.fail(e.clone());
            }
            state.row_error = Some(e);
            return;
        }
        Err(e) => {
            let msg = format!("install task join error: {}", e);
            if let Ok(mut p) = progress.lock() {
                p.fail(msg.clone());
            }
            state.row_error = Some(msg);
            return;
        }
    };
    let summary = summarize_plugin_dir(&temp_dir);
    let has_executable_extension = read_plugin_manifest(&temp_dir)
        .map(|manifest| manifest.extension.is_some())
        .unwrap_or(true);
    if has_executable_extension {
        state.mode = RightMode::PendingInstallConfirm {
            plugin_name,
            source_url,
            subdir,
            marketplace_name,
            summary,
            installed_commit: sha,
            checksum_algorithm: expected_checksum
                .as_ref()
                .map(|(algorithm, _)| algorithm.clone()),
            checksum_value: expected_checksum
                .as_ref()
                .map(|(_, value)| value.clone()),
            temp_dir,
            final_dir: dest,
        };
        state.row_error = None;
        return;
    }
    if let Err(e) = finalize_pending_install(&temp_dir, &dest) {
        state.row_error = Some(e);
        return;
    }
    if let Ok(mut p) = progress.lock() {
        p.set_setup_running();
    }
    let setup_outcome = run_post_install_setup_for_dir(&plugin_name, &dest).await;
    let setup_status = setup_outcome.as_ref().map(|o| o.setup_status()).unwrap_or_else(|e| e.setup_status());
    let plugin_name_for_msg = plugin_name.clone();
    record_installed_plugin(
        state,
        plugin_name,
        marketplace_name,
        source_url,
        sha,
        subdir,
        expected_checksum,
        setup_status,
    );
    if let Err(e) = commit_plugins_state(&state.file) {
        state.row_error = Some(format!(
            "installed '{}' but failed to save state: {}. Restart may lose this install.",
            plugin_name_for_msg, e
        ));
        return;
    }
    reload_registry(registry, config);
    state.mode = RightMode::List;
    state.row_error = match setup_outcome {
        Ok(_) => None,
        Err(msg) => Some(format!(
            "installed '{plugin_name_for_msg}' but setup script failed: {msg}"
        )),
    };
}

pub(crate) async fn apply_confirm_pending_install(
    state: &mut PluginsModalState,
    registry: &Arc<CommandRegistry>,
    config: &synaps_cli::SynapsConfig,
) {
    let RightMode::PendingInstallConfirm {
        plugin_name,
        source_url,
        subdir,
        marketplace_name,
        installed_commit,
        checksum_algorithm,
        checksum_value,
        temp_dir,
        final_dir,
        ..
    } = std::mem::replace(&mut state.mode, RightMode::List) else {
        return;
    };

    let final_dir_for_task = final_dir.clone();
    let temp_dir_for_task = temp_dir.clone();
    let finalize_res = tokio::task::spawn_blocking(move || {
        finalize_pending_install(&temp_dir_for_task, &final_dir_for_task)
    }).await;
    match finalize_res {
        Ok(Ok(())) => {}
        Ok(Err(e)) => {
            state.row_error = Some(e);
            return;
        }
        Err(e) => {
            state.row_error = Some(format!("install finalize task join error: {}", e));
            return;
        }
    }

    let setup_outcome = run_post_install_setup_for_dir(&plugin_name, &final_dir).await;
    let setup_status = setup_outcome.as_ref().map(|o| o.setup_status()).unwrap_or_else(|e| e.setup_status());

    record_installed_plugin(
        state,
        plugin_name.clone(),
        marketplace_name,
        source_url,
        installed_commit,
        subdir,
        checksum_algorithm.zip(checksum_value),
        setup_status,
    );
    if let Err(e) = commit_plugins_state(&state.file) {
        state.row_error = Some(format!(
            "installed '{}' but failed to save state: {}. Restart may lose this install.",
            plugin_name, e
        ));
        return;
    }
    reload_registry(registry, config);
    state.row_error = match setup_outcome {
        Ok(_) => None,
        Err(msg) => Some(format!(
            "installed '{plugin_name}' but setup script failed: {msg}"
        )),
    };
}

pub(crate) fn apply_cancel_pending_install(state: &mut PluginsModalState) {
    let old = std::mem::replace(&mut state.mode, RightMode::List);
    if let RightMode::PendingInstallConfirm { temp_dir, .. } = old {
        cancel_pending_temp(&temp_dir);
    }
    state.row_error = None;
}

pub(crate) async fn apply_uninstall(
    state: &mut PluginsModalState,
    name: String,
    registry: &Arc<CommandRegistry>,
    config: &synaps_cli::SynapsConfig,
) {
    let dir = match install_dir_for(&name) {
        Ok(d) => d,
        Err(e) => {
            state.row_error = Some(e);
            return;
        }
    };
    let uninstall_res = tokio::task::spawn_blocking(move || install::uninstall_plugin(&dir)).await;
    match uninstall_res {
        Ok(Ok(())) => {}
        Ok(Err(e)) => {
            state.row_error = Some(e);
            return;
        }
        Err(e) => {
            state.row_error = Some(format!("uninstall task join error: {}", e));
            return;
        }
    }
    state.file.installed.retain(|p| p.name != name);
    if let Err(e) = commit_plugins_state(&state.file) {
        state.row_error = Some(format!(
            "uninstalled '{}' but failed to save state: {}. State may be stale.",
            name, e
        ));
        return;
    }
    reload_registry(registry, config);
    state.row_error = None;
}

pub(crate) async fn apply_update(
    state: &mut PluginsModalState,
    name: String,
    _registry: &Arc<CommandRegistry>,
    _config: &synaps_cli::SynapsConfig,
) {
    let final_dir = match install_dir_for(&name) {
        Ok(d) => d,
        Err(e) => {
            state.row_error = Some(e);
            return;
        }
    };
    let installed = state.file.installed.iter().find(|p| p.name == name).cloned();
    let Some(installed) = installed else {
        state.row_error = Some(format!("plugin '{}' is not installed", name));
        return;
    };
    let old_manifest = match read_plugin_manifest(&final_dir) {
        Ok(m) => m,
        Err(e) => {
            state.row_error = Some(e);
            return;
        }
    };
    let parent = match final_dir.parent() {
        Some(p) => p.to_path_buf(),
        None => {
            state.row_error = Some("plugin install path has no parent".to_string());
            return;
        }
    };
    let temp_dir = parent.join(format!(".{}-pending-update", name));
    let source = installed.source_url.clone();
    let subdir = installed.source_subdir.clone();
    let expected_checksum = expected_update_checksum(&state.file, &installed)
        .or_else(|| installed.checksum_algorithm.clone().zip(installed.checksum_value.clone()));
    let temp_for_task = temp_dir.clone();
    let update_res = tokio::task::spawn_blocking(move || {
        let _ = std::fs::remove_dir_all(&temp_for_task);
        let sha = match subdir {
            Some(subdir) => install::install_plugin_from_subdir(&source, &subdir, &temp_for_task),
            None => install::install_plugin(&source, &temp_for_task),
        }?;
        if let Some((algorithm, expected)) = expected_checksum {
            if let Err(e) = install::verify_plugin_dir_checksum(&temp_for_task, &algorithm, &expected) {
                let _ = std::fs::remove_dir_all(&temp_for_task);
                return Err(e);
            }
        }
        Ok(sha)
    }).await;
    let sha = match update_res {
        Ok(Ok(sha)) => sha,
        Ok(Err(e)) => {
            state.row_error = Some(e);
            return;
        }
        Err(e) => {
            state.row_error = Some(format!("update preview task join error: {}", e));
            return;
        }
    };
    let new_manifest = match read_plugin_manifest(&temp_dir) {
        Ok(m) => m,
        Err(e) => {
            cancel_pending_temp(&temp_dir);
            state.row_error = Some(e);
            return;
        }
    };
    let diff = diff_plugin_manifests(&old_manifest, &new_manifest);
    state.mode = RightMode::PendingUpdateConfirm {
        plugin_name: name,
        summary: diff.lines(),
        installed_commit: sha,
        temp_dir,
        final_dir,
    };
    state.row_error = None;
}

pub(crate) async fn apply_confirm_pending_update(
    state: &mut PluginsModalState,
    registry: &Arc<CommandRegistry>,
    config: &synaps_cli::SynapsConfig,
) {
    let RightMode::PendingUpdateConfirm {
        plugin_name,
        installed_commit,
        temp_dir,
        final_dir,
        ..
    } = std::mem::replace(&mut state.mode, RightMode::List) else {
        return;
    };
    let final_for_task = final_dir.clone();
    let temp_for_task = temp_dir.clone();
    let apply_res = tokio::task::spawn_blocking(move || -> Result<(), String> {
        let backup = final_for_task.with_extension("update-backup");
        let _ = std::fs::remove_dir_all(&backup);
        std::fs::rename(&final_for_task, &backup)
            .map_err(|e| format!("backup existing plugin {}: {}", final_for_task.display(), e))?;
        if let Err(e) = std::fs::rename(&temp_for_task, &final_for_task) {
            let _ = std::fs::rename(&backup, &final_for_task);
            return Err(format!("apply update {} -> {}: {}", temp_for_task.display(), final_for_task.display(), e));
        }
        let _ = std::fs::remove_dir_all(&backup);
        Ok(())
    }).await;
    match apply_res {
        Ok(Ok(())) => {}
        Ok(Err(e)) => {
            state.row_error = Some(e);
            return;
        }
        Err(e) => {
            state.row_error = Some(format!("update apply task join error: {}", e));
            return;
        }
    }
    let setup_outcome = run_post_install_setup_for_dir(&plugin_name, &final_dir).await;
    let setup_status = setup_outcome.as_ref().map(|o| o.setup_status()).unwrap_or_else(|e| e.setup_status());
    if let Some(p) = state.file.installed.iter_mut().find(|p| p.name == plugin_name) {
        p.installed_commit = installed_commit;
        p.latest_commit = None;
        p.setup_status = setup_status;
    }
    if let Err(e) = commit_plugins_state(&state.file) {
        state.row_error = Some(format!(
            "updated '{}' but failed to save state: {}. State may be stale.",
            plugin_name, e
        ));
        return;
    }
    reload_registry(registry, config);
    state.row_error = match setup_outcome {
        Ok(_) => None,
        Err(msg) => Some(format!(
            "updated '{plugin_name}' but setup script failed: {msg}"
        )),
    };
}

pub(crate) fn apply_cancel_pending_update(state: &mut PluginsModalState) {
    let old = std::mem::replace(&mut state.mode, RightMode::List);
    if let RightMode::PendingUpdateConfirm { temp_dir, .. } = old {
        cancel_pending_temp(&temp_dir);
    }
    state.row_error = None;
}

pub(crate) async fn apply_refresh_marketplace(state: &mut PluginsModalState, name: String) {
    let Some(url) = state
        .file
        .marketplaces
        .iter()
        .find(|m| m.name == name)
        .map(|m| m.url.clone())
    else {
        state.row_error = Some(format!("marketplace '{}' not found", name));
        return;
    };
    let manifest = match fetch_manifest(&url).await {
        Ok(m) => m,
        Err(e) => {
            state.row_error = Some(e);
            return;
        }
    };
    // Collect fresh sources for ls-remote (only for installed plugins from this marketplace).
    let installed_sources: Vec<(String, String)> = state
        .file
        .installed
        .iter()
        .filter(|p| p.marketplace.as_deref() == Some(name.as_str()))
        .map(|p| (p.name.clone(), p.source_url.clone()))
        .collect();
    let ls_results = tokio::task::spawn_blocking(move || {
        installed_sources
            .into_iter()
            .map(|(n, s)| (n, install::ls_remote_head(&s)))
            .collect::<Vec<_>>()
    })
    .await
    .unwrap_or_default();

    // Apply to state.
    if let Some(m) = state.file.marketplaces.iter_mut().find(|m| m.name == name) {
        m.cached_plugins = manifest
            .plugins
            .iter()
            .map(|p| synaps_cli::skills::state::CachedPlugin {
                name: p.name.clone(),
                source: p.source.clone().unwrap_or_else(|| p.index.as_ref().map(|idx| idx.repository.clone()).unwrap_or_default()),
                version: p.version.clone(),
                description: p.description.clone(),
                index: p.index.as_ref().map(cached_index_metadata),
            })
            .collect();
        m.last_refreshed = Some(now_rfc3339());
    }
    let mut failed: Vec<String> = Vec::new();
    for (plugin_name, res) in ls_results {
        match res {
            Ok(sha) => {
                if let Some(p) = state.file.installed.iter_mut().find(|p| p.name == plugin_name) {
                    p.latest_commit = Some(sha);
                }
            }
            Err(_) => {
                failed.push(plugin_name);
            }
        }
    }
    if let Err(e) = commit_plugins_state(&state.file) {
        state.row_error = Some(format!("save failed: {}", e));
        return;
    }
    if !failed.is_empty() {
        state.row_error = Some(format!(
            "refreshed '{}', but could not check updates for: {}",
            name,
            failed.join(", ")
        ));
    } else {
        state.row_error = None;
    }
    state.mode = RightMode::List;
}

pub(crate) async fn apply_remove_marketplace(
    state: &mut PluginsModalState,
    name: String,
    registry: &Arc<CommandRegistry>,
    config: &synaps_cli::SynapsConfig,
) {
    // Cascade uninstall: anything installed from this marketplace goes with it.
    let to_uninstall: Vec<String> = state
        .file
        .installed
        .iter()
        .filter(|p| p.marketplace.as_deref() == Some(name.as_str()))
        .map(|p| p.name.clone())
        .collect();

    let mut failed: Vec<String> = Vec::new();
    for plugin_name in &to_uninstall {
        let dir = match install_dir_for(plugin_name) {
            Ok(d) => d,
            Err(e) => { failed.push(format!("{}: {}", plugin_name, e)); continue; }
        };
        let res = tokio::task::spawn_blocking(move || install::uninstall_plugin(&dir)).await;
        match res {
            Ok(Ok(())) => {}
            Ok(Err(e)) => failed.push(format!("{}: {}", plugin_name, e)),
            Err(e) => failed.push(format!("{}: join error: {}", plugin_name, e)),
        }
    }

    state.file.installed.retain(|p| p.marketplace.as_deref() != Some(name.as_str()));
    state.file.marketplaces.retain(|m| m.name != name);

    if let Err(e) = commit_plugins_state(&state.file) {
        state.row_error = Some(format!("save failed: {}", e));
        return;
    }
    reload_registry(registry, config);

    let n = state.left_rows().len();
    if state.selected_left >= n && n > 0 {
        state.selected_left = n - 1;
    }

    state.row_error = if failed.is_empty() {
        None
    } else {
        Some(format!(
            "removed marketplace '{}', but failed to fully uninstall: {}",
            name,
            failed.join("; ")
        ))
    };
}

/// Core config mutation for toggling a plugin enabled/disabled.
///
/// Shared between the `/plugins` modal (`apply_toggle_plugin`) and the
/// settings modal's TogglePlugin handler. On success: updates `config`
/// and reloads the registry. On failure: returns the error string
/// without mutating `config`.
pub(crate) fn toggle_plugin_config(
    name: &str,
    enabled: bool,
    config: &mut synaps_cli::SynapsConfig,
    registry: &Arc<CommandRegistry>,
) -> Result<(), String> {
    let mut new_disabled = config.disabled_plugins.clone();
    if enabled {
        new_disabled.retain(|p| p != name);
    } else if !new_disabled.iter().any(|p| p == name) {
        new_disabled.push(name.to_string());
    }
    let csv = new_disabled.join(", ");
    synaps_cli::config::write_config_value("disabled_plugins", &csv)
        .map_err(|e| e.to_string())?;
    config.disabled_plugins = new_disabled;
    reload_registry(registry, config);
    Ok(())
}

pub(crate) fn apply_toggle_plugin(
    state: &mut PluginsModalState,
    name: String,
    enabled: bool,
    registry: &Arc<CommandRegistry>,
    config: &mut synaps_cli::SynapsConfig,
) {
    match toggle_plugin_config(&name, enabled, config, registry) {
        Ok(()) => {
            state.row_error = None;
        }
        Err(e) => {
            state.row_error = Some(e);
        }
    }
}

fn permission_summary_for_plugin_name(name: &str) -> Option<Vec<String>> {
    let roots = synaps_cli::skills::loader::default_roots();
    let (plugins, _) = synaps_cli::skills::loader::load_all(&roots);
    plugins
        .into_iter()
        .find(|plugin| plugin.name == name)
        .map(|plugin| synaps_cli::skills::trust::summarize_plugin_permissions(&plugin).lines())
}

pub(crate) fn confirm_enable_plugin(
    state: &mut PluginsModalState,
    name: String,
) {
    let summary = permission_summary_for_plugin_name(&name).unwrap_or_else(|| {
        vec!["plugin manifest not found; enabling will reload available plugin content".to_string()]
    });
    state.mode = RightMode::Confirm {
        prompt: format!("Enable plugin '{}'? y/n", name),
        on_yes: super::state::ConfirmAction::EnablePlugin(name),
        summary,
    };
}

fn set_editor_or_row_error(state: &mut PluginsModalState, msg: String) {
    if let RightMode::AddMarketplaceEditor { error, .. } = &mut state.mode {
        *error = Some(msg);
    } else {
        state.row_error = Some(msg);
    }
}


#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::{Path, PathBuf};
    use std::process::Command;
    use std::sync::{Arc, Mutex};
    use synaps_cli::skills::registry::CommandRegistry;
    use synaps_cli::skills::state::{CachedPlugin, Marketplace, PluginsState};
    use synaps_cli::skills::plugin_index::{
        PluginIndexCapabilities, PluginIndexChecksum, PluginIndexCompatibility, PluginIndexEntry,
    };

    static BASE_DIR_TEST_LOCK: Mutex<()> = Mutex::new(());

    struct EnvGuard {
        old_base_dir: Option<String>,
        old_git_config_global: Option<String>,
    }

    impl EnvGuard {
        fn set_base_dir(path: &Path, git_config_global: &Path) -> Self {
            let old_base_dir = std::env::var("SYNAPS_BASE_DIR").ok();
            let old_git_config_global = std::env::var("GIT_CONFIG_GLOBAL").ok();
            synaps_cli::config::set_base_dir_for_tests(path.to_path_buf());
            std::env::set_var("GIT_CONFIG_GLOBAL", git_config_global);
            Self { old_base_dir, old_git_config_global }
        }
    }

    impl Drop for EnvGuard {
        fn drop(&mut self) {
            if let Some(old) = &self.old_base_dir {
                std::env::set_var("SYNAPS_BASE_DIR", old);
            } else {
                std::env::remove_var("SYNAPS_BASE_DIR");
            }
            if let Some(old) = &self.old_git_config_global {
                std::env::set_var("GIT_CONFIG_GLOBAL", old);
            } else {
                std::env::remove_var("GIT_CONFIG_GLOBAL");
            }
        }
    }

    fn git(args: &[&str], cwd: &Path) {
        let status = Command::new("git")
            .args(args)
            .current_dir(cwd)
            .status()
            .unwrap();
        assert!(status.success(), "git {:?} failed", args);
    }

    fn fixture_plugin_repo() -> (tempfile::TempDir, PathBuf) {
        let dir = tempfile::tempdir().unwrap();
        let work = dir.path().join("work");
        fs::create_dir_all(work.join(".synaps-plugin")).unwrap();
        fs::write(
            work.join(".synaps-plugin/plugin.json"),
            r#"{
  "name": "policy-test",
  "description": "test plugin with an executable extension",
  "extension": {
    "protocol_version": 1,
    "runtime": "process",
    "command": "python3",
    "args": ["extension.py"],
    "permissions": ["tools.intercept"],
    "hooks": [{"hook": "before_tool_call", "tool": "bash"}]
  }
}"#,
        )
        .unwrap();
        fs::write(work.join("extension.py"), "#!/usr/bin/env python3\n").unwrap();

        git(&["init", "-q"], &work);
        git(&["config", "user.email", "t@t"], &work);
        git(&["config", "user.name", "t"], &work);
        git(&["add", "."], &work);
        git(&["commit", "-q", "-m", "init"], &work);

        let bare = dir.path().join("plugin.git");
        let work_s = work.to_string_lossy().to_string();
        let bare_s = bare.to_string_lossy().to_string();
        git(&["clone", "--bare", "-q", &work_s, &bare_s], dir.path());
        (dir, bare)
    }

    fn push_manifest_update(work: &Path, version: &str, extra_permission: Option<&str>) {
        let permissions = match extra_permission {
            Some(permission) => format!("[\"tools.intercept\",\"{}\"]", permission),
            None => "[\"tools.intercept\"]".to_string(),
        };
        fs::write(
            work.join(".synaps-plugin/plugin.json"),
            format!(r#"{{
  "name": "policy-test",
  "version": "{}",
  "description": "test plugin with an executable extension",
  "extension": {{
    "protocol_version": 1,
    "runtime": "process",
    "command": "python3",
    "args": ["extension.py"],
    "permissions": {},
    "hooks": [{{"hook": "before_tool_call", "tool": "bash"}}]
  }}
}}"#, version, permissions),
        )
        .unwrap();
        git(&["add", "."], work);
        git(&["commit", "-q", "-m", "update"], work);
    }

    fn fixture_plugin_repo_with_work() -> (tempfile::TempDir, PathBuf, PathBuf) {
        let dir = tempfile::tempdir().unwrap();
        let work = dir.path().join("work");
        fs::create_dir_all(work.join(".synaps-plugin")).unwrap();
        fs::write(work.join("extension.py"), "#!/usr/bin/env python3\n").unwrap();

        git(&["init", "-q"], &work);
        git(&["config", "user.email", "t@t"], &work);
        git(&["config", "user.name", "t"], &work);
        push_manifest_update(&work, "0.1.0", None);
        git(&["branch", "-M", "main"], &work);

        let bare = dir.path().join("plugin.git");
        let work_s = work.to_string_lossy().to_string();
        let bare_s = bare.to_string_lossy().to_string();
        git(&["clone", "--bare", "-q", &work_s, &bare_s], dir.path());
        (dir, bare, work)
    }

    fn cloned_plugin_checksum(source: &str) -> String {
        let clone_parent = tempfile::tempdir().unwrap();
        let clone = clone_parent.path().join("clone");
        install::install_plugin(source, &clone).unwrap();
        install::plugin_dir_sha256(&clone).unwrap()
    }

    fn state_for_marketplace(source_url: String) -> PluginsModalState {
        PluginsModalState::new(PluginsState {
            marketplaces: vec![Marketplace {
                name: "local-market".into(),
                url: "https://example.invalid/marketplace.json".into(),
                description: None,
                last_refreshed: None,
                cached_plugins: vec![CachedPlugin {
                    name: "policy-test".into(),
                    source: source_url,
                    version: None,
                    description: Some("fixture".into()),
                    index: None,
                }],
                repo_url: None,
            }],
            installed: vec![],
            trusted_hosts: vec!["example.invalid/owner".into()],
        })
    }

    fn index_entry(repository: String) -> PluginIndexEntry {
        PluginIndexEntry {
            id: "policy-test".into(),
            name: "policy-test".into(),
            version: "0.1.0".into(),
            description: "fixture".into(),
            repository,
            subdir: None,
            license: None,
            categories: vec![],
            keywords: vec![],
            checksum: PluginIndexChecksum {
                algorithm: "sha256".into(),
                value: "abc123".into(),
            },
            compatibility: PluginIndexCompatibility {
                synaps: Some(">=0.1.0".into()),
                extension_protocol: Some("1".into()),
            },
            capabilities: PluginIndexCapabilities {
                skills: vec![],
                has_extension: true,
                permissions: vec!["tools.intercept".into()],
                hooks: vec!["before_tool_call".into()],
                commands: vec![],
                providers: vec![],
            },
            trust: None,
        }
    }

    #[tokio::test(flavor = "current_thread")]
    async fn install_from_index_entry_uses_pending_install_and_reinspects_manifest() {
        let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let home = tempfile::tempdir().unwrap();
        let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));
        let (_repo_tmp, bare) = fixture_plugin_repo();
        let source = format!("file://{}", bare.display());
        let checksum = cloned_plugin_checksum(&source);
        let repository = "https://example.invalid/owner/policy-test.git".to_string();
        let mut state = PluginsModalState::new(PluginsState {
            marketplaces: vec![Marketplace {
                name: "local-index".into(),
                url: "file:///tmp/plugin-index.json".into(),
                description: None,
                last_refreshed: None,
                cached_plugins: vec![CachedPlugin {
                    name: "policy-test".into(),
                    source: source.clone(),
                    version: Some("0.1.0".into()),
                    description: Some("fixture".into()),
                    index: Some(CachedPluginIndexMetadata {
                        repository: repository.clone(),
                        subdir: None,
                        checksum_algorithm: "sha256".into(),
                        checksum_value: checksum.clone(),
                        compatibility_synaps: Some(">=0.1.0".into()),
                        compatibility_extension_protocol: Some("1".into()),
                        has_extension: true,
                        skills: vec![],
                        permissions: vec!["tools.intercept".into()],
                        hooks: vec!["before_tool_call".into()],
                        commands: vec![],
                        providers: vec![],
                        trust_publisher: Some("Example".into()),
                        trust_homepage: Some("https://example.com".into()),
                    }),
                }],
                repo_url: None,
            }],
            installed: vec![],
            trusted_hosts: vec!["example.invalid/owner".into()],
        });
        let registry = Arc::new(CommandRegistry::new(&[], vec![]));
        let config = synaps_cli::SynapsConfig::default();

        apply_install_from_index_entry(
            &mut state,
            "local-index".into(),
            PluginIndexEntry {
            repository: repository.clone(),
            checksum: PluginIndexChecksum {
                algorithm: "sha256".into(),
                value: checksum.clone(),
            },
            ..index_entry(source.clone())
            },
            &registry,
            &config,
        )
        .await;
        // Background clone now runs to completion; drain it before asserting.
        complete_pending_install_clone(&mut state, &registry, &config).await;

        let RightMode::PendingInstallConfirm {
            source_url,
            marketplace_name,
            summary,
            temp_dir,
            final_dir,
            ..
        } = &state.mode else {
            panic!("expected pending install confirmation, got {:?}; error {:?}", state.mode, state.row_error);
        };
        assert_eq!(source_url, &source);
        assert_eq!(marketplace_name.as_deref(), Some("local-index"));
        assert!(summary.iter().any(|line| line == "executable extension: yes"));
        assert!(temp_dir.exists());
        assert!(!final_dir.exists());
    }

    #[tokio::test(flavor = "current_thread")]
    async fn update_rejects_index_checksum_mismatch_before_preview() {
        let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let home = tempfile::tempdir().unwrap();
        let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));
        let (_repo_tmp, bare) = fixture_plugin_repo();
        let source = format!("file://{}", bare.display());
        let mut state = PluginsModalState::new(PluginsState {
            marketplaces: vec![],
            installed: vec![InstalledPlugin {
                name: "policy-test".into(),
                marketplace: None,
                source_url: source.clone(),
                installed_commit: "abc".into(),
                latest_commit: Some("def".into()),
                installed_at: "now".into(),
                source_subdir: None,
                checksum_algorithm: Some("sha256".into()),
                checksum_value: Some("0000000000000000000000000000000000000000000000000000000000000000".into()),
                setup_status: Default::default(),
            }],
            trusted_hosts: vec![],
        });
        let registry = Arc::new(CommandRegistry::new(&[], vec![]));
        let config = synaps_cli::SynapsConfig::default();

        let final_dir = install_dir_for("policy-test").unwrap();
        install::install_plugin(&source, &final_dir).unwrap();

        apply_update(&mut state, "policy-test".into(), &registry, &config).await;

        assert!(matches!(state.mode, RightMode::List));
        assert!(state.row_error.as_deref().unwrap_or_default().contains("checksum mismatch"));
        assert!(!final_dir.parent().unwrap().join(".policy-test-pending-update").exists());
    }

    #[tokio::test(flavor = "current_thread")]
    async fn update_preview_uses_refreshed_index_checksum_and_shows_manifest_diff() {
        let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let home = tempfile::tempdir().unwrap();
        let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));
        let (_repo_tmp, bare, work) = fixture_plugin_repo_with_work();
        let source = format!("file://{}", bare.display());
        let old_checksum = cloned_plugin_checksum(&source);
        let final_dir = install_dir_for("policy-test").unwrap();
        let installed_sha = install::install_plugin(&source, &final_dir).unwrap();

        push_manifest_update(&work, "0.2.0", Some("privacy.llm_content"));
        git(&["push", "-q", bare.to_str().unwrap(), "HEAD:main"], &work);
        let new_checksum = cloned_plugin_checksum(&source);

        let mut state = PluginsModalState::new(PluginsState {
            marketplaces: vec![Marketplace {
                name: "local-index".into(),
                url: "file:///tmp/plugin-index.json".into(),
                description: None,
                last_refreshed: None,
                cached_plugins: vec![CachedPlugin {
                    name: "policy-test".into(),
                    source: source.clone(),
                    version: Some("0.2.0".into()),
                    description: Some("fixture".into()),
                    index: Some(CachedPluginIndexMetadata {
                        repository: source.clone(),
                        subdir: None,
                        checksum_algorithm: "sha256".into(),
                        checksum_value: new_checksum,
                        compatibility_synaps: Some(">=0.1.0".into()),
                        compatibility_extension_protocol: Some("1".into()),
                        has_extension: true,
                        skills: vec![],
                        permissions: vec!["tools.intercept".into(), "privacy.llm_content".into()],
                        hooks: vec!["before_tool_call".into()],
                        commands: vec![],
                        providers: vec![],
                        trust_publisher: None,
                        trust_homepage: None,
                    }),
                }],
                repo_url: None,
            }],
            installed: vec![InstalledPlugin {
                name: "policy-test".into(),
                marketplace: Some("local-index".into()),
                source_url: source.clone(),
                installed_commit: installed_sha,
                latest_commit: Some("updated".into()),
                installed_at: "now".into(),
                source_subdir: None,
                checksum_algorithm: Some("sha256".into()),
                checksum_value: Some(old_checksum),
                setup_status: Default::default(),
            }],
            trusted_hosts: vec![],
        });
        let registry = Arc::new(CommandRegistry::new(&[], vec![]));
        let config = synaps_cli::SynapsConfig::default();

        apply_update(&mut state, "policy-test".into(), &registry, &config).await;

        let RightMode::PendingUpdateConfirm { summary, temp_dir, final_dir: pending_final, .. } = &state.mode else {
            panic!("expected pending update confirmation, got {:?}; error {:?}", state.mode, state.row_error);
        };
        assert_eq!(pending_final, &final_dir);
        assert!(temp_dir.exists());
        assert!(summary.iter().any(|line| line == "version: 0.1.0 -> 0.2.0"));
        assert!(summary.iter().any(|line| line == "added permissions: privacy.llm_content"));
    }

    #[tokio::test(flavor = "current_thread")]
    async fn pending_extension_install_can_be_cancelled_without_touching_real_state() {
        let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let home = tempfile::tempdir().unwrap();
        let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));
        let (_repo_tmp, bare) = fixture_plugin_repo();
        let source = format!("file://{}", bare.display());
        let mut state = state_for_marketplace(source);
        let registry = Arc::new(CommandRegistry::new(&[], vec![]));
        let config = synaps_cli::SynapsConfig::default();

        run_install_flow(
            &mut state,
            "policy-test".into(),
            format!("file://{}", bare.display()),
            None,
            Some("local-market".into()),
            None,
            &registry,
            &config,
        );
        complete_pending_install_clone(&mut state, &registry, &config).await;

        let (temp_dir, final_dir) = match &state.mode {
            RightMode::PendingInstallConfirm { temp_dir, final_dir, summary, .. } => {
                assert!(summary.iter().any(|line| line == "executable extension: yes"));
                (temp_dir.clone(), final_dir.clone())
            }
            other => panic!("expected pending install confirmation, got {other:?}"),
        };
        assert!(temp_dir.exists());
        assert!(temp_dir.ends_with(".policy-test-pending-install"));
        assert!(!final_dir.exists());
        assert!(state.file.installed.is_empty());

        apply_cancel_pending_install(&mut state);

        assert!(matches!(state.mode, RightMode::List));
        assert!(!temp_dir.exists());
        assert!(!final_dir.exists());
        assert!(state.file.installed.is_empty());
        assert!(!home.path().join("plugins.json").exists());
    }

    #[tokio::test(flavor = "current_thread")]
    async fn pending_extension_install_confirm_moves_temp_and_records_plugin() {
        let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let home = tempfile::tempdir().unwrap();
        let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));
        let (_repo_tmp, bare) = fixture_plugin_repo();
        let source = format!("file://{}", bare.display());
        let mut state = state_for_marketplace(source.clone());
        let registry = Arc::new(CommandRegistry::new(&[], vec![]));
        let config = synaps_cli::SynapsConfig::default();

        run_install_flow(
            &mut state,
            "policy-test".into(),
            source.clone(),
            None,
            Some("local-market".into()),
            None,
            &registry,
            &config,
        );
        complete_pending_install_clone(&mut state, &registry, &config).await;

        let (temp_dir, final_dir) = match &state.mode {
            RightMode::PendingInstallConfirm { temp_dir, final_dir, .. } => {
                (temp_dir.clone(), final_dir.clone())
            }
            other => panic!("expected pending install confirmation, got {other:?}"),
        };
        assert!(temp_dir.exists());
        assert!(!final_dir.exists());

        apply_confirm_pending_install(&mut state, &registry, &config).await;

        assert!(matches!(state.mode, RightMode::List));
        assert!(!temp_dir.exists());
        assert!(final_dir.join(".synaps-plugin/plugin.json").exists());
        assert_eq!(state.file.installed.len(), 1);
        let installed = &state.file.installed[0];
        assert_eq!(installed.name, "policy-test");
        assert_eq!(installed.marketplace.as_deref(), Some("local-market"));
        assert_eq!(installed.source_url, source);
        assert_eq!(installed.installed_commit.len(), 40);

        let saved = PluginsState::load_from(&home.path().join("plugins.json")).unwrap();
        assert_eq!(saved.installed.len(), 1);
        assert_eq!(saved.installed[0].name, "policy-test");
    }

    /// Slice B: post-install setup hook runs the manifest's
    /// `provides.sidecar.setup` script after `finalize_pending_install`.
    /// Test it via the helper directly (`run_post_install_setup_for_dir`)
    /// so we don't have to spin up a whole install flow with a real git
    /// fixture just to assert the hook fires.
    #[tokio::test(flavor = "current_thread")]
    async fn post_install_setup_runs_declared_script() {
        let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let home = tempfile::tempdir().unwrap();
        let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));

        let plugin_dir = tempfile::tempdir().unwrap();
        let synaps_dir = plugin_dir.path().join(".synaps-plugin");
        std::fs::create_dir_all(&synaps_dir).unwrap();
        std::fs::write(
            synaps_dir.join("plugin.json"),
            r#"{"name":"hook-test","provides":{"sidecar":{"command":"bin/x","setup":"scripts/setup.sh","protocol_version":1}}}"#,
        ).unwrap();
        let scripts = plugin_dir.path().join("scripts");
        std::fs::create_dir(&scripts).unwrap();
        let setup = scripts.join("setup.sh");
        let marker = plugin_dir.path().join("ran.marker");
        std::fs::write(
            &setup,
            format!("#!/bin/bash\ntouch {}\necho ok\n", marker.display()),
        ).unwrap();
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&setup, std::fs::Permissions::from_mode(0o755)).unwrap();

        let res = run_post_install_setup_for_dir("hook-test", plugin_dir.path()).await;
        assert!(res.is_ok(), "got {res:?}");
        let log = match res.unwrap() { PostInstallOutcome::SetupSucceeded(p) => p, other => panic!("expected setup success, got {other:?}") };
        assert!(marker.exists(), "setup script should have created the marker");
        assert!(log.starts_with(home.path().join("logs/install")));
    }

    #[tokio::test(flavor = "current_thread")]
    async fn post_install_setup_returns_ok_none_when_no_setup_declared() {
        let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let home = tempfile::tempdir().unwrap();
        let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));

        let plugin_dir = tempfile::tempdir().unwrap();
        let synaps_dir = plugin_dir.path().join(".synaps-plugin");
        std::fs::create_dir_all(&synaps_dir).unwrap();
        std::fs::write(
            synaps_dir.join("plugin.json"),
            r#"{"name":"no-setup-test"}"#,
        ).unwrap();

        let res = run_post_install_setup_for_dir("no-setup-test", plugin_dir.path()).await;
        match res {
            Ok(PostInstallOutcome::NotRequired | PostInstallOutcome::BinaryAlreadyPresent | PostInstallOutcome::PrebuiltInstalled) => {}
            other => panic!("expected Ok(None), got {other:?}"),
        }
    }

    #[tokio::test(flavor = "current_thread")]
    async fn post_install_setup_propagates_non_zero_exit() {
        let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let home = tempfile::tempdir().unwrap();
        let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));

        let plugin_dir = tempfile::tempdir().unwrap();
        let synaps_dir = plugin_dir.path().join(".synaps-plugin");
        std::fs::create_dir_all(&synaps_dir).unwrap();
        std::fs::write(
            synaps_dir.join("plugin.json"),
            r#"{"name":"fail-test","provides":{"sidecar":{"command":"bin/x","setup":"fail.sh","protocol_version":1}}}"#,
        ).unwrap();
        let setup = plugin_dir.path().join("fail.sh");
        std::fs::write(&setup, "#!/bin/bash\necho boom\nexit 13\n").unwrap();
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&setup, std::fs::Permissions::from_mode(0o755)).unwrap();

        let res = run_post_install_setup_for_dir("fail-test", plugin_dir.path()).await;
        let err = res.expect_err("non-zero exit should propagate");
        assert!(err.to_string().contains("13"), "error should mention exit code 13: {err}");
        assert!(err.to_string().contains(".log"), "error should point at log path: {err}");
    }

    /// Slice D: when the manifest's `extension.command` already exists
    /// and is executable inside the freshly-cloned plugin dir, skip
    /// the setup script entirely (fast-path for source-shipped binaries
    /// or reinstalls where the artifact survived in place).
    #[tokio::test(flavor = "current_thread")]
    async fn post_install_setup_skipped_when_extension_binary_already_present() {
        let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let home = tempfile::tempdir().unwrap();
        let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));

        let plugin_dir = tempfile::tempdir().unwrap();
        let synaps_dir = plugin_dir.path().join(".synaps-plugin");
        std::fs::create_dir_all(&synaps_dir).unwrap();
        std::fs::write(
            synaps_dir.join("plugin.json"),
            r#"{"name":"skip-test","extension":{"runtime":"process","command":"bin/ext","setup":"scripts/setup.sh"}}"#,
        ).unwrap();
        // Pre-create the binary so the fast-path triggers.
        let bin = plugin_dir.path().join("bin/ext");
        std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
        std::fs::write(&bin, "#!/bin/sh\necho prebuilt").unwrap();
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
        // Setup script that, if invoked, would create a marker. We
        // assert it's NOT created.
        let scripts = plugin_dir.path().join("scripts");
        std::fs::create_dir(&scripts).unwrap();
        let marker = plugin_dir.path().join("setup-ran.marker");
        std::fs::write(
            scripts.join("setup.sh"),
            format!("#!/bin/bash\ntouch {}\necho ran\n", marker.display()),
        ).unwrap();
        std::fs::set_permissions(scripts.join("setup.sh"), std::fs::Permissions::from_mode(0o755)).unwrap();

        let res = run_post_install_setup_for_dir("skip-test", plugin_dir.path()).await;
        match res {
            Ok(PostInstallOutcome::NotRequired | PostInstallOutcome::BinaryAlreadyPresent | PostInstallOutcome::PrebuiltInstalled) => {}
            other => panic!("expected Ok(None) (fast-path skip), got {other:?}"),
        }
        assert!(!marker.exists(), "setup script must NOT have run on fast-path");
    }

    /// Slice C/F: setup script exits 0 but the declared
    /// `extension.command` doesn't exist after — surface a clear
    /// error instead of silently proceeding with a broken install.
    #[tokio::test(flavor = "current_thread")]
    async fn post_install_setup_fails_when_setup_does_not_produce_extension_binary() {
        let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let home = tempfile::tempdir().unwrap();
        let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));

        let plugin_dir = tempfile::tempdir().unwrap();
        let synaps_dir = plugin_dir.path().join(".synaps-plugin");
        std::fs::create_dir_all(&synaps_dir).unwrap();
        std::fs::write(
            synaps_dir.join("plugin.json"),
            r#"{"name":"verify-test","extension":{"runtime":"process","command":"bin/missing","setup":"scripts/setup.sh"}}"#,
        ).unwrap();
        let scripts = plugin_dir.path().join("scripts");
        std::fs::create_dir(&scripts).unwrap();
        // Setup that "succeeds" but doesn't produce bin/missing.
        std::fs::write(scripts.join("setup.sh"), "#!/bin/bash\necho lying setup\nexit 0\n").unwrap();
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(scripts.join("setup.sh"), std::fs::Permissions::from_mode(0o755)).unwrap();

        let res = run_post_install_setup_for_dir("verify-test", plugin_dir.path()).await;
        let err = res.expect_err("missing post-build artifact must error");
        assert!(
            err.to_string().contains("did not produce"),
            "error should explain build didn't produce binary: {err}"
        );
        assert!(err.to_string().contains("bin/missing"), "error should name the missing path: {err}");
    }

    /// Slice F: no setup script declared but extension.command is also
    /// missing → must error (don't silently accept a broken install).
    #[tokio::test(flavor = "current_thread")]
    async fn post_install_setup_fails_when_no_setup_and_extension_binary_missing() {
        let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let home = tempfile::tempdir().unwrap();
        let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));

        let plugin_dir = tempfile::tempdir().unwrap();
        let synaps_dir = plugin_dir.path().join(".synaps-plugin");
        std::fs::create_dir_all(&synaps_dir).unwrap();
        std::fs::write(
            synaps_dir.join("plugin.json"),
            r#"{"name":"no-setup-no-bin","extension":{"runtime":"process","command":"bin/never-built"}}"#,
        ).unwrap();
        let res = run_post_install_setup_for_dir("no-setup-no-bin", plugin_dir.path()).await;
        let err = res.expect_err("missing binary with no setup must error");
        assert!(err.to_string().contains("verification failed"), "expected verify msg: {err}");
    }

    /// file:// prebuilt assets are not accepted through the production post-install
    /// path; src/skills/post_install.rs has cfg(test)-local unit coverage for file archives.
    #[cfg(all(unix, any(target_arch = "x86_64", target_arch = "aarch64")))]
    #[tokio::test(flavor = "current_thread")]
    async fn post_install_rejects_file_prebuilt_url_in_production_path() {
        let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let home = tempfile::tempdir().unwrap();
        let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));
        // file:// asset URLs are accepted only in cfg(test) code paths.

        // Stage a tarball containing bin/ext.
        let staging = tempfile::tempdir().unwrap();
        let work = staging.path().join("staging");
        std::fs::create_dir_all(work.join("bin")).unwrap();
        std::fs::write(work.join("bin/ext"), "#!/bin/sh\necho prebuilt\n").unwrap();
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(
            work.join("bin/ext"),
            std::fs::Permissions::from_mode(0o755),
        )
        .unwrap();
        let archive = staging.path().join("ext.tar.gz");
        let out = std::process::Command::new("tar")
            .arg("-czf")
            .arg(&archive)
            .arg("-C")
            .arg(&work)
            .arg("bin/ext")
            .output()
            .expect("system tar must be present");
        assert!(out.status.success());
        let bytes = std::fs::read(&archive).unwrap();
        use sha2::{Digest, Sha256};
        let mut h = Sha256::new();
        h.update(&bytes);
        let sha: String = h.finalize().iter().map(|b| format!("{:02x}", b)).collect();
        let triple = synaps_cli::skills::post_install::host_triple()
            .expect("supported test host");

        // Plugin dir with a manifest that declares both prebuilt AND
        // setup — prebuilt wins, setup must NOT run.
        let plugin_dir = tempfile::tempdir().unwrap();
        let synaps_plugin_dir = plugin_dir.path().join(".synaps-plugin");
        std::fs::create_dir_all(&synaps_plugin_dir).unwrap();
        let manifest_json = format!(
            r#"{{
                "name":"prebuilt-test",
                "extension":{{
                    "runtime":"process",
                    "command":"bin/ext",
                    "setup":"scripts/setup.sh",
                    "prebuilt":{{
                        "{triple}":{{
                            "url":"file://{archive}",
                            "sha256":"{sha}"
                        }}
                    }}
                }}
            }}"#,
            triple = triple,
            archive = archive.display(),
            sha = sha,
        );
        std::fs::write(synaps_plugin_dir.join("plugin.json"), &manifest_json).unwrap();
        // Setup script that would create a marker if invoked.
        let scripts = plugin_dir.path().join("scripts");
        std::fs::create_dir(&scripts).unwrap();
        let marker = plugin_dir.path().join("setup-ran.marker");
        std::fs::write(
            scripts.join("setup.sh"),
            format!("#!/bin/bash\ntouch {}\n", marker.display()),
        )
        .unwrap();
        std::fs::set_permissions(scripts.join("setup.sh"), std::fs::Permissions::from_mode(0o755))
            .unwrap();

        let res = run_post_install_setup_for_dir("prebuilt-test", plugin_dir.path()).await;
        let err = res.expect_err("file:// prebuilt should be rejected outside post_install cfg(test) unit tests");
        assert!(err.to_string().contains("refusing non-https prebuilt url"), "got: {err}");
        assert!(!marker.exists(), "setup script must not run after unsafe prebuilt URL");
    }
}
