use anyhow::{Context, Result, bail};
use bmux_config::BmuxConfig;
use bmux_plugin::{PluginDeclaration, PluginRegistry};
use bmux_plugin_sdk::{PluginCommand, PluginCommandArgument, PluginCommandArgumentKind};
use clap::{Arg, ArgAction, ArgMatches, Command};
use std::collections::{BTreeMap, BTreeSet};

#[derive(Debug, Clone)]
pub struct ResolvedPluginCommand {
    pub plugin_id: String,
    pub command_name: String,
    pub arguments: Vec<String>,
    pub schema: PluginCommand,
}

#[derive(Debug, Clone)]
pub struct RegisteredPluginCommand {
    pub plugin_id: String,
    pub command_name: String,
    pub canonical_path: Vec<String>,
    pub aliases: Vec<Vec<String>>,
    pub schema: PluginCommand,
}

#[derive(Debug, Default, Clone)]
pub struct PluginCommandRegistry {
    commands: Vec<RegisteredPluginCommand>,
    resolved_by_path: BTreeMap<Vec<String>, ResolvedPathEntry>,
    owned_exact_paths: BTreeMap<Vec<String>, String>,
    owned_namespaces: BTreeMap<String, String>,
}

#[derive(Debug, Clone)]
struct ResolvedPathEntry {
    plugin_id: String,
    command_name: String,
    schema: PluginCommand,
}

impl PluginCommandRegistry {
    pub fn build(config: &BmuxConfig, plugins: &PluginRegistry) -> Result<Self> {
        let mut registry = Self::default();
        let enabled = config.plugins.enabled.iter().collect::<BTreeSet<_>>();
        let mut claimed: BTreeMap<Vec<String>, (String, String)> = BTreeMap::new();

        for plugin in plugins.iter() {
            let plugin_id = plugin.declaration.id.as_str().to_string();
            if !enabled.contains(&plugin_id) {
                continue;
            }
            register_plugin_ownership(
                &mut registry.owned_exact_paths,
                &mut registry.owned_namespaces,
                &plugin.declaration,
            )?;
        }

        for plugin in plugins.iter() {
            let plugin_id = plugin.declaration.id.as_str().to_string();
            if !enabled.contains(&plugin_id) {
                continue;
            }

            for command in plugin
                .declaration
                .commands
                .iter()
                .filter(|command| command.expose_in_cli)
            {
                let canonical = command.canonical_path();
                validate_plugin_owns_path(&plugin.declaration, &canonical, &command.name)?;
                validate_path_collision(
                    &canonical,
                    &claimed,
                    plugin.declaration.id.as_str(),
                    &command.name,
                )?;
                validate_prefix_collision(
                    &canonical,
                    &claimed,
                    plugin.declaration.id.as_str(),
                    &command.name,
                )?;
                claimed.insert(
                    canonical.clone(),
                    (
                        plugin.declaration.id.as_str().to_string(),
                        command.name.clone(),
                    ),
                );

                let mut aliases = Vec::new();
                for alias in &command.aliases {
                    validate_plugin_owns_path(&plugin.declaration, alias, &command.name)?;
                    validate_path_collision(
                        alias,
                        &claimed,
                        plugin.declaration.id.as_str(),
                        &command.name,
                    )?;
                    validate_prefix_collision(
                        alias,
                        &claimed,
                        plugin.declaration.id.as_str(),
                        &command.name,
                    )?;
                    claimed.insert(
                        alias.clone(),
                        (
                            plugin.declaration.id.as_str().to_string(),
                            command.name.clone(),
                        ),
                    );
                    aliases.push(alias.clone());
                }

                registry.commands.push(RegisteredPluginCommand {
                    plugin_id: plugin.declaration.id.as_str().to_string(),
                    command_name: command.name.clone(),
                    canonical_path: canonical,
                    aliases,
                    schema: command.clone(),
                });
                let registered = registry
                    .commands
                    .last()
                    .expect("just-pushed plugin command should exist");
                register_resolved_path_entry(
                    &mut registry.resolved_by_path,
                    &registered.canonical_path,
                    registered,
                );
                for alias in &registered.aliases {
                    register_resolved_path_entry(&mut registry.resolved_by_path, alias, registered);
                }
            }
        }

        Ok(registry)
    }

    #[must_use]
    pub fn owner_for_path(&self, path: &[String]) -> Option<String> {
        if let Some(owner) = self.owned_exact_paths.get(path) {
            return Some(owner.clone());
        }
        path.first()
            .and_then(|namespace| self.owner_for_namespace(namespace))
    }

    #[must_use]
    pub fn owner_for_namespace(&self, namespace: &str) -> Option<String> {
        self.owned_namespaces.get(namespace).cloned()
    }

    pub fn resolve(&self, raw: &[String]) -> Option<ResolvedPluginCommand> {
        for prefix_len in (1..=raw.len()).rev() {
            let candidate = raw[..prefix_len].to_vec();
            let Some(entry) = self.resolved_by_path.get(&candidate) else {
                continue;
            };
            return Some(ResolvedPluginCommand {
                plugin_id: entry.plugin_id.clone(),
                command_name: entry.command_name.clone(),
                arguments: raw[prefix_len..].to_vec(),
                schema: entry.schema.clone(),
            });
        }
        None
    }

    pub fn resolve_exact_path(&self, path: &[String]) -> Option<ResolvedPluginCommand> {
        self.resolved_by_path
            .get(path)
            .map(|entry| ResolvedPluginCommand {
                plugin_id: entry.plugin_id.clone(),
                command_name: entry.command_name.clone(),
                arguments: Vec::new(),
                schema: entry.schema.clone(),
            })
    }

    pub fn validate_arguments(
        command: &PluginCommand,
        arguments: &[String],
    ) -> Result<Vec<String>> {
        let mut clap_command =
            Command::new(leak_string(&command.name)).disable_help_subcommand(true);
        for argument in &command.arguments {
            clap_command = clap_command.arg(build_clap_arg(argument));
        }
        let mut argv = Vec::with_capacity(arguments.len() + 1);
        argv.push(command.name.clone());
        argv.extend(arguments.iter().cloned());
        let matches = clap_command.try_get_matches_from(argv).with_context(|| {
            format!(
                "invalid arguments for plugin command '{}': {}",
                command.name,
                arguments.join(" ")
            )
        })?;

        let mut normalized = Vec::new();
        for argument in &command.arguments {
            if let Some(long) = &argument.long {
                if matches.value_source(&argument.name).is_none() {
                    continue;
                }
                if matches!(argument.kind, PluginCommandArgumentKind::Boolean) {
                    if matches.get_flag(&argument.name) {
                        normalized.push(format!("--{long}"));
                    }
                    continue;
                }
                if argument.multiple {
                    if let Some(values) = matches.get_many::<String>(&argument.name) {
                        for value in values {
                            normalized.push(format!("--{long}"));
                            normalized.push(value.clone());
                        }
                    }
                } else if let Some(value) = matches.get_one::<String>(&argument.name) {
                    normalized.push(format!("--{long}"));
                    normalized.push(value.clone());
                }
                continue;
            }

            if argument.multiple {
                if let Some(values) = matches.get_many::<String>(&argument.name) {
                    normalized.extend(values.cloned());
                }
            } else if let Some(value) = matches.get_one::<String>(&argument.name) {
                normalized.push(value.clone());
            }
        }

        Ok(normalized)
    }

    pub fn normalize_arguments_from_matches(
        command: &PluginCommand,
        matches: &ArgMatches,
    ) -> Vec<String> {
        let mut normalized = Vec::new();
        for argument in &command.arguments {
            if let Some(long) = &argument.long {
                if matches.value_source(&argument.name).is_none() {
                    continue;
                }
                if matches!(argument.kind, PluginCommandArgumentKind::Boolean) {
                    if matches.get_flag(&argument.name) {
                        normalized.push(format!("--{long}"));
                    }
                    continue;
                }
                if argument.multiple {
                    if let Some(values) = matches.get_many::<String>(&argument.name) {
                        for value in values {
                            normalized.push(format!("--{long}"));
                            normalized.push(value.clone());
                        }
                    }
                } else if let Some(value) = matches.get_one::<String>(&argument.name) {
                    normalized.push(format!("--{long}"));
                    normalized.push(value.clone());
                }
                continue;
            }

            if argument.multiple {
                if let Some(values) = matches.get_many::<String>(&argument.name) {
                    normalized.extend(values.cloned());
                }
            } else if let Some(value) = matches.get_one::<String>(&argument.name) {
                normalized.push(value.clone());
            }
        }
        normalized
    }

    pub fn augment_clap_command(&self, root: Command) -> Result<Command> {
        let mut root = root;
        for command in &self.commands {
            for path in std::iter::once(&command.canonical_path).chain(command.aliases.iter()) {
                if clap_path_exists(&root, path) {
                    continue;
                }
                insert_plugin_path(&mut root, path, &command.schema)?;
            }
        }
        Ok(root)
    }
}

fn register_resolved_path_entry(
    index: &mut BTreeMap<Vec<String>, ResolvedPathEntry>,
    path: &[String],
    command: &RegisteredPluginCommand,
) {
    let previous = index.insert(
        path.to_vec(),
        ResolvedPathEntry {
            plugin_id: command.plugin_id.clone(),
            command_name: command.command_name.clone(),
            schema: command.schema.clone(),
        },
    );
    debug_assert!(
        previous.is_none(),
        "path collisions should be rejected before indexing"
    );
}

pub fn selected_subcommand_path(matches: &ArgMatches) -> (Vec<String>, &ArgMatches) {
    let mut path = Vec::new();
    let mut current = matches;
    while let Some((name, next)) = current.subcommand() {
        path.push(name.to_string());
        current = next;
    }
    (path, current)
}

fn insert_plugin_path(root: &mut Command, path: &[String], schema: &PluginCommand) -> Result<()> {
    if path.is_empty() {
        bail!("plugin command path cannot be empty");
    }

    if path.len() == 1 {
        let updated = std::mem::replace(root, Command::new("bmux-temp-root")).subcommand(
            build_plugin_leaf_command(path.last().expect("leaf exists"), schema),
        );
        *root = updated;
        return Ok(());
    }

    let head = &path[0];
    let tail = &path[1..];
    if root.find_subcommand(head).is_none() {
        let updated = std::mem::replace(root, Command::new("bmux-temp-root"))
            .subcommand(build_plugin_namespace_command(head));
        *root = updated;
    }
    let child = root.find_subcommand_mut(head).with_context(|| {
        format!(
            "missing clap namespace for plugin path '{}' after namespace creation",
            path.join(" ")
        )
    })?;
    if tail.len() == 1 {
        let updated = std::mem::replace(child, Command::new("bmux-temp-child")).subcommand(
            build_plugin_leaf_command(tail.last().expect("leaf exists"), schema),
        );
        *child = updated;
        return Ok(());
    }

    insert_plugin_path(child, tail, schema)
}

fn build_plugin_namespace_command(name: &str) -> Command {
    Command::new(leak_string(name))
        .disable_help_subcommand(true)
        .arg_required_else_help(true)
}

fn build_plugin_leaf_command(name: &str, schema: &PluginCommand) -> Command {
    let mut command = Command::new(leak_string(name))
        .about(leak_string(&schema.summary))
        .disable_help_subcommand(true);
    if let Some(description) = &schema.description {
        command = command.long_about(leak_string(description));
    }
    for argument in &schema.arguments {
        command = command.arg(build_clap_arg(argument));
    }
    command
}

fn build_clap_arg(argument: &PluginCommandArgument) -> Arg {
    let mut arg = Arg::new(leak_string(&argument.name)).required(argument.required);
    if let Some(position) = argument.position {
        arg = arg.index(position + 1);
    }
    if let Some(long) = &argument.long {
        arg = arg.long(leak_string(long));
    }
    if let Some(short) = argument.short {
        arg = arg.short(short);
    }
    if let Some(summary) = &argument.summary {
        arg = arg.help(summary);
    }
    if let Some(value_name) = &argument.value_name {
        arg = arg.value_name(leak_string(value_name));
    }
    if argument.multiple {
        arg = arg.action(ArgAction::Append);
    } else if matches!(argument.kind, PluginCommandArgumentKind::Boolean) {
        arg = arg.action(ArgAction::SetTrue);
    } else {
        arg = arg.action(ArgAction::Set);
    }
    if argument.trailing_var_arg {
        arg = arg.trailing_var_arg(true);
    }
    if argument.allow_hyphen_values {
        arg = arg.allow_hyphen_values(true);
    }
    match &argument.kind {
        PluginCommandArgumentKind::Integer => {
            arg = arg.value_parser(clap::value_parser!(i64));
        }
        PluginCommandArgumentKind::Choice => {
            let leaked = argument
                .choice_values
                .iter()
                .map(|value| Box::leak(value.clone().into_boxed_str()) as &'static str)
                .collect::<Vec<_>>();
            arg = arg.value_parser(leaked);
        }
        PluginCommandArgumentKind::String
        | PluginCommandArgumentKind::Boolean
        | PluginCommandArgumentKind::Path => {}
    }
    arg
}

fn leak_string(value: &str) -> &'static str {
    Box::leak(value.to_string().into_boxed_str())
}

fn validate_path_collision(
    path: &[String],
    claimed: &BTreeMap<Vec<String>, (String, String)>,
    plugin_id: &str,
    command_name: &str,
) -> Result<()> {
    if let Some((owner_plugin, owner_command)) = claimed.get(path) {
        bail!(
            "plugin '{plugin_id}' command '{command_name}' collides with plugin '{owner_plugin}' command '{owner_command}' on CLI path '{}'",
            path.join(" ")
        );
    }
    Ok(())
}

fn validate_prefix_collision(
    path: &[String],
    claimed: &BTreeMap<Vec<String>, (String, String)>,
    plugin_id: &str,
    command_name: &str,
) -> Result<()> {
    for (claimed_path, (owner_plugin, owner_command)) in claimed {
        if is_prefix_collision(path, claimed_path) {
            bail!(
                "plugin '{plugin_id}' command '{command_name}' creates ambiguous CLI nesting with plugin '{owner_plugin}' command '{owner_command}' on path '{}'",
                claimed_path.join(" ")
            );
        }
    }
    Ok(())
}

fn clap_path_exists(root: &Command, path: &[String]) -> bool {
    if path.is_empty() {
        return false;
    }
    let mut current = root;
    for segment in path {
        let Some(next) = current.find_subcommand(segment) else {
            return false;
        };
        current = next;
    }
    true
}

#[allow(clippy::suspicious_operation_groupings)] // Intentional: checks two distinct slices aren't equal AND one is a prefix
fn is_prefix_collision(left: &[String], right: &[String]) -> bool {
    left != right && (left.starts_with(right) || right.starts_with(left))
}

fn register_plugin_ownership(
    owned_exact_paths: &mut BTreeMap<Vec<String>, String>,
    owned_namespaces: &mut BTreeMap<String, String>,
    declaration: &PluginDeclaration,
) -> Result<()> {
    let plugin_id = declaration.id.as_str().to_string();
    for namespace in &declaration.owns_namespaces {
        if let Some(existing) = owned_namespaces.get(namespace)
            && existing != &plugin_id
        {
            bail!(
                "plugin '{plugin_id}' ownership conflict: namespace '{namespace}' already owned by plugin '{existing}'"
            );
        }
        owned_namespaces.insert(namespace.clone(), plugin_id.clone());
    }

    for path in &declaration.owns_paths {
        if let Some(existing) = owned_exact_paths.get(&path.0)
            && existing != &plugin_id
        {
            bail!(
                "plugin '{plugin_id}' ownership conflict: path '{}' already owned by plugin '{existing}'",
                path.0.join(" ")
            );
        }
        owned_exact_paths.insert(path.0.clone(), plugin_id.clone());
    }
    Ok(())
}

fn validate_plugin_owns_path(
    declaration: &PluginDeclaration,
    path: &[String],
    command_name: &str,
) -> Result<()> {
    let exact = declaration
        .owns_paths
        .iter()
        .any(|candidate| candidate.matches(path));
    let namespace_owned = path
        .first()
        .is_some_and(|segment| declaration.owns_namespaces.contains(segment));
    if exact || namespace_owned {
        return Ok(());
    }
    bail!(
        "plugin '{}' command '{}' path '{}' is not within declared ownership (owns_paths/owns_namespaces)",
        declaration.id.as_str(),
        command_name,
        path.join(" ")
    )
}

#[cfg(test)]
mod tests {
    use super::PluginCommandRegistry;
    use bmux_cli_schema::Cli;
    use bmux_config::BmuxConfig;
    use bmux_plugin::{PluginManifest, PluginRegistry};
    use clap::{Command, CommandFactory};
    use std::path::Path;

    fn config_with_enabled(plugin_id: &str) -> BmuxConfig {
        let mut config = BmuxConfig::default();
        config.plugins.enabled.push(plugin_id.to_string());
        config
    }

    #[test]
    fn resolves_nested_aliases_by_longest_prefix() {
        let manifest = PluginManifest::from_toml_str(
            r#"
id = "example.plugin"
name = "Example"
version = "0.1.0"
entry = "plugin.dylib"
owns_namespaces = ["acl"]

[[commands]]
name = "roles"
path = ["acl", "list"]
aliases = [["acl", "roles"]]
summary = "list"
expose_in_cli = true

[plugin_api]
minimum = "1.0"

[native_abi]
minimum = "1.0"
"#,
        )
        .expect("manifest should parse");
        let mut registry = PluginRegistry::new();
        registry
            .register_manifest_from_root(
                Path::new("/plugins"),
                Path::new("/plugins/plugin.toml"),
                manifest,
            )
            .expect("manifest should register");
        let commands =
            PluginCommandRegistry::build(&config_with_enabled("example.plugin"), &registry)
                .expect("command registry should build");
        let resolved = commands
            .resolve(&["acl".into(), "roles".into(), "dev".into()])
            .expect("command should resolve");
        assert_eq!(resolved.command_name, "roles");
        assert_eq!(resolved.arguments, vec!["dev"]);
    }

    #[test]
    fn plugin_aliases_build_without_collision_for_session_namespace() {
        let manifest = PluginManifest::from_toml_str(
            r#"
id = "policy.plugin"
name = "Policy"
version = "0.1.0"
entry = "plugin.dylib"
required_capabilities = ["bmux.commands"]
owns_namespaces = ["roles", "assign", "session"]

[[commands]]
name = "roles"
path = ["roles"]
aliases = [["session", "roles"]]
summary = "list"
execution = "provider_exec"
expose_in_cli = true

[[commands]]
name = "assign"
path = ["assign"]
aliases = [["session", "assign"]]
summary = "assign"
execution = "provider_exec"
expose_in_cli = true

[plugin_api]
minimum = "1.0"

[native_abi]
minimum = "1.0"
"#,
        )
        .expect("manifest should parse");
        let mut registry = PluginRegistry::new();
        registry
            .register_manifest_from_root(
                Path::new("/plugins"),
                Path::new("/plugins/plugin.toml"),
                manifest,
            )
            .expect("manifest should register");
        PluginCommandRegistry::build(&config_with_enabled("policy.plugin"), &registry)
            .expect("policy command registry should build");
    }

    #[test]
    fn plugin_aliases_build_without_collision_for_dynamic_namespace() {
        let manifest = PluginManifest::from_toml_str(
            r#"
id = "workspace.plugin"
name = "Workspace"
version = "0.1.0"
entry = "plugin.dylib"
required_capabilities = ["bmux.commands"]
owns_namespaces = ["item", "item-open", "item-focus"]

[[commands]]
name = "item-open"
path = ["item-open"]
aliases = [["item", "open"]]
summary = "open"
execution = "provider_exec"
expose_in_cli = true

[[commands]]
name = "item-focus"
path = ["item-focus"]
aliases = [["item", "focus"]]
summary = "focus"
execution = "provider_exec"
expose_in_cli = true

[plugin_api]
minimum = "1.0"

[native_abi]
minimum = "1.0"
"#,
        )
        .expect("manifest should parse");
        let mut registry = PluginRegistry::new();
        registry
            .register_manifest_from_root(
                Path::new("/plugins"),
                Path::new("/plugins/plugin.toml"),
                manifest,
            )
            .expect("manifest should register");
        PluginCommandRegistry::build(&config_with_enabled("workspace.plugin"), &registry)
            .expect("workspace command registry should build");
    }

    #[test]
    fn plugin_can_claim_current_static_core_command_path_when_owned() {
        let manifest = PluginManifest::from_toml_str(
            r#"
id = "example.plugin"
name = "Example"
version = "0.1.0"
entry = "plugin.dylib"
required_capabilities = ["bmux.commands"]
owns_namespaces = ["new-session"]

[[commands]]
name = "new-session"
path = ["new-session"]
summary = "new"
execution = "provider_exec"
expose_in_cli = true

[plugin_api]
minimum = "1.0"

[native_abi]
minimum = "1.0"
"#,
        )
        .expect("manifest should parse");
        let mut registry = PluginRegistry::new();
        registry
            .register_manifest_from_root(
                Path::new("/plugins"),
                Path::new("/plugins/plugin.toml"),
                manifest,
            )
            .expect("manifest should register");

        let built = PluginCommandRegistry::build(&config_with_enabled("example.plugin"), &registry)
            .expect("plugin should be allowed to own built-in command path");
        let resolved = built
            .resolve_exact_path(&["new-session".to_string()])
            .expect("plugin command should resolve on owned built-in path");
        assert_eq!(resolved.plugin_id, "example.plugin");
    }

    #[test]
    fn augment_clap_command_creates_missing_namespace_roots() {
        let manifest = PluginManifest::from_toml_str(
            r#"
id = "workspace.plugin"
name = "Workspace"
version = "0.1.0"
entry = "plugin.dylib"
required_capabilities = ["bmux.commands"]
owns_namespaces = ["item", "item-open"]

[[commands]]
name = "item-open"
path = ["item-open"]
aliases = [["item", "open"]]
summary = "open"
execution = "provider_exec"
expose_in_cli = true

[plugin_api]
minimum = "1.0"

[native_abi]
minimum = "1.0"
"#,
        )
        .expect("manifest should parse");
        let mut registry = PluginRegistry::new();
        registry
            .register_manifest_from_root(
                Path::new("/plugins"),
                Path::new("/plugins/plugin.toml"),
                manifest,
            )
            .expect("manifest should register");
        let commands =
            PluginCommandRegistry::build(&config_with_enabled("workspace.plugin"), &registry)
                .expect("workspace command registry should build");

        let clap = commands
            .augment_clap_command(Command::new("bmux"))
            .expect("dynamic namespace should be created");
        let matches = clap
            .try_get_matches_from(["bmux", "item", "open"])
            .expect("dynamic namespace path should parse");
        let (path, _) = super::selected_subcommand_path(&matches);
        assert_eq!(path, vec!["item".to_string(), "open".to_string()]);
    }

    #[test]
    fn augment_clap_command_extends_existing_session_namespace() {
        let manifest = PluginManifest::from_toml_str(
            r#"
id = "policy.plugin"
name = "Policy"
version = "0.1.0"
entry = "plugin.dylib"
required_capabilities = ["bmux.commands"]
owns_namespaces = ["roles", "session"]

[[commands]]
name = "roles"
path = ["roles"]
aliases = [["session", "roles"]]
summary = "list"
execution = "provider_exec"
expose_in_cli = true

[plugin_api]
minimum = "1.0"

[native_abi]
minimum = "1.0"
"#,
        )
        .expect("manifest should parse");
        let mut registry = PluginRegistry::new();
        registry
            .register_manifest_from_root(
                Path::new("/plugins"),
                Path::new("/plugins/plugin.toml"),
                manifest,
            )
            .expect("manifest should register");
        let commands =
            PluginCommandRegistry::build(&config_with_enabled("policy.plugin"), &registry)
                .expect("policy command registry should build");

        let clap = commands
            .augment_clap_command(Cli::command())
            .expect("existing session namespace should be extended");
        let matches = clap
            .try_get_matches_from(["bmux", "session", "roles"])
            .expect("plugin session alias should parse under mixed namespace");
        let (path, _) = super::selected_subcommand_path(&matches);
        assert_eq!(path, vec!["session".to_string(), "roles".to_string()]);
    }

    #[test]
    fn ownership_exact_path_takes_precedence_over_namespace() {
        let manifest = PluginManifest::from_toml_str(
            r#"
id = "owner.plugin"
name = "Owner"
version = "0.1.0"
entry = "owner.dylib"
owns_namespaces = ["workspace"]
owns_paths = [["workspace", "doctor"]]
required_capabilities = ["bmux.commands"]

[[commands]]
name = "doctor"
path = ["workspace", "doctor"]
summary = "doctor"
execution = "provider_exec"
expose_in_cli = true

[[commands]]
name = "probe"
path = ["workspace", "probe"]
summary = "probe"
execution = "provider_exec"
expose_in_cli = true
"#,
        )
        .expect("manifest should parse");
        let mut plugins = PluginRegistry::new();
        plugins
            .register_manifest_from_root(
                Path::new("/plugins"),
                Path::new("/plugins/plugin.toml"),
                manifest,
            )
            .expect("manifest should register");
        let registry = PluginCommandRegistry::build(&config_with_enabled("owner.plugin"), &plugins)
            .expect("registry should build");
        assert_eq!(
            registry.owner_for_path(&["workspace".to_string(), "doctor".to_string()]),
            Some("owner.plugin".to_string())
        );
        assert_eq!(
            registry.owner_for_path(&["workspace".to_string(), "probe".to_string()]),
            Some("owner.plugin".to_string())
        );
        assert_eq!(
            registry.owner_for_path(&["workspace".to_string(), "doctor".to_string()]),
            Some("owner.plugin".to_string())
        );
    }

    #[test]
    fn build_rejects_command_outside_declared_ownership() {
        let manifest = PluginManifest::from_toml_str(
            r#"
id = "invalid.plugin"
name = "Invalid"
version = "0.1.0"
entry = "invalid.dylib"
owns_namespaces = ["window"]
required_capabilities = ["bmux.commands"]

[[commands]]
name = "bad"
path = ["logs", "tail"]
summary = "bad"
execution = "provider_exec"
expose_in_cli = true
"#,
        )
        .expect("manifest should parse");
        let mut plugins = PluginRegistry::new();
        plugins
            .register_manifest_from_root(
                Path::new("/plugins"),
                Path::new("/plugins/plugin.toml"),
                manifest,
            )
            .expect("manifest should register");
        let error = PluginCommandRegistry::build(&config_with_enabled("invalid.plugin"), &plugins)
            .expect_err("registry build should fail for unowned command path");
        assert!(error.to_string().contains("not within declared ownership"));
    }

    #[test]
    fn build_rejects_namespace_ownership_conflicts() {
        let first = PluginManifest::from_toml_str(
            r#"
id = "first.plugin"
name = "First"
version = "0.1.0"
entry = "first.dylib"
owns_namespaces = ["logs"]
required_capabilities = ["bmux.commands"]

[[commands]]
name = "list"
path = ["logs", "list"]
summary = "list"
execution = "provider_exec"
expose_in_cli = true
"#,
        )
        .expect("first manifest should parse");
        let second = PluginManifest::from_toml_str(
            r#"
id = "second.plugin"
name = "Second"
version = "0.1.0"
entry = "second.dylib"
owns_namespaces = ["logs"]
required_capabilities = ["bmux.commands"]

[[commands]]
name = "tail"
path = ["logs", "tail"]
summary = "tail"
execution = "provider_exec"
expose_in_cli = true
"#,
        )
        .expect("second manifest should parse");

        let mut plugins = PluginRegistry::new();
        plugins
            .register_manifest_from_root(
                Path::new("/plugins"),
                Path::new("/plugins/first.toml"),
                first,
            )
            .expect("first should register");
        plugins
            .register_manifest_from_root(
                Path::new("/plugins"),
                Path::new("/plugins/second.toml"),
                second,
            )
            .expect("second should register");

        let mut config = BmuxConfig::default();
        config.plugins.enabled = vec!["first.plugin".to_string(), "second.plugin".to_string()];
        let error = PluginCommandRegistry::build(&config, &plugins)
            .expect_err("registry build should fail for namespace ownership conflict");
        assert!(error.to_string().contains("ownership conflict"));
    }
}
