use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

const SUPPORTED_PLUGIN_API_VERSION: u32 = 1;
const ZIP_EOCD: u32 = 0x0605_4b50;
const ZIP_CENTRAL_DIRECTORY: u32 = 0x0201_4b50;
const ZIP_LOCAL_FILE: u32 = 0x0403_4b50;
const ZIP_FLAG_ENCRYPTED: u16 = 0x0001;
const ZIP_COMPRESSION_STORED: u16 = 0;
const ZIP_UNIX_SYMLINK_TYPE: u32 = 0o120000;
const ZIP_UNIX_FILE_TYPE_MASK: u32 = 0o170000;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PluginTemplateResource {
    pub path: &'static str,
    pub contents: &'static str,
}

/// Embedded starter template for Rust Component Model Tool Plugins.
///
/// The template is data only: it performs no filesystem/network operations and
/// grants no authority. Future authoring CLI commands can materialize these
/// files into a chosen destination after applying their own overwrite policy.
pub const RUST_COMPONENT_TOOL_TEMPLATE: &[PluginTemplateResource] = &[
    PluginTemplateResource {
        path: "Cargo.toml",
        contents: include_str!(
            "../../../resources/plugin/templates/rust-component-tool/Cargo.toml"
        ),
    },
    PluginTemplateResource {
        path: "src/lib.rs",
        contents: include_str!(
            "../../../resources/plugin/templates/rust-component-tool/src/lib.rs"
        ),
    },
    PluginTemplateResource {
        path: "plugin.toml",
        contents: include_str!(
            "../../../resources/plugin/templates/rust-component-tool/plugin.toml"
        ),
    },
    PluginTemplateResource {
        path: "plugin.component.wasm",
        contents: include_str!(
            "../../../resources/plugin/templates/rust-component-tool/plugin.component.wasm"
        ),
    },
    PluginTemplateResource {
        path: "README.md",
        contents: include_str!("../../../resources/plugin/templates/rust-component-tool/README.md"),
    },
];

/// Embedded starter template for Rust Component Model instance Plugins.
pub const RUST_COMPONENT_INSTANCE_TEMPLATE: &[PluginTemplateResource] = &[
    PluginTemplateResource {
        path: "Cargo.toml",
        contents: include_str!(
            "../../../resources/plugin/templates/rust-component-instance/Cargo.toml"
        ),
    },
    PluginTemplateResource {
        path: "src/lib.rs",
        contents: include_str!(
            "../../../resources/plugin/templates/rust-component-instance/src/lib.rs"
        ),
    },
    PluginTemplateResource {
        path: "plugin.toml",
        contents: include_str!(
            "../../../resources/plugin/templates/rust-component-instance/plugin.toml"
        ),
    },
    PluginTemplateResource {
        path: "plugin.component.wasm",
        contents: include_str!(
            "../../../resources/plugin/templates/rust-component-instance/plugin.component.wasm"
        ),
    },
    PluginTemplateResource {
        path: "README.md",
        contents: include_str!(
            "../../../resources/plugin/templates/rust-component-instance/README.md"
        ),
    },
];

#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PluginConfig {
    pub enabled: Vec<PluginEnablementConfig>,
    /// Runtime restore metadata. Fresh resolution fills this from discovered packages;
    /// restore uses it without selecting newer mutable-store contents.
    pub resolved: Vec<ResolvedPluginRecord>,
    /// Safe bounded discovery/resolution diagnostics recorded with the resolved plan.
    pub diagnostics: Vec<PluginDiagnostic>,
}

impl PluginConfig {
    pub fn is_empty(&self) -> bool {
        self.enabled.is_empty() && self.resolved.is_empty()
    }

    pub fn has_resolved_plan(&self) -> bool {
        !self.resolved.is_empty() || !self.diagnostics.is_empty()
    }
}

#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PluginEnablementConfig {
    /// Source-qualified plugin id such as `user:example`, `project:example`, or `builtin:example`.
    pub id: String,
    /// Optional exact package version requirement. Rich version constraints are deferred.
    pub version: Option<PluginExactVersion>,
    /// Optional deterministic digest pin in `sha256:<hex>` form.
    pub digest: Option<String>,
    /// Optional explicit surface subset. When omitted, all declared package surfaces are selected.
    pub surfaces: Vec<PluginSurface>,
    /// Requested plugin grants. Non-empty authority-bearing grants currently fail closed.
    pub grants: PluginGrantConfig,
    /// Opaque plugin-local configuration copied into resolved metadata without interpretation.
    pub config: Option<toml::Value>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PluginExactVersion(pub String);

impl PluginExactVersion {
    pub fn matches(&self, version: &str) -> bool {
        self.0 == version
    }
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PluginGrantConfig {
    /// Source-qualified package id this grant is pinned to, for example `project:example`.
    pub id: Option<String>,
    /// Exact package version this grant is pinned to.
    pub version: Option<PluginExactVersion>,
    /// Deterministic package digest this grant is pinned to.
    pub digest: Option<String>,
    /// Explicit capabilities granted for the pinned package identity/version/digest.
    pub permissions: Vec<PluginPermission>,
    /// Bounded outbound request allowlist entries for `host_api.request`.
    pub request: Vec<PluginRequestGrant>,
    /// Bounded outbound WebSocket target allowlist entries for `host_api.websocket`.
    pub websocket: Vec<PluginWebSocketGrant>,
    /// Scoped filesystem allowlist entries for `host_api.fs`.
    pub fs: Vec<PluginFsGrant>,
}

impl PluginGrantConfig {
    pub fn is_empty(&self) -> bool {
        self.permissions.is_empty()
            && self.request.is_empty()
            && self.websocket.is_empty()
            && self.fs.is_empty()
    }

    pub fn binding_error(
        &self,
        identity: &SourceQualifiedPluginId,
        digest: &str,
        version: &str,
    ) -> Option<&'static str> {
        if self.is_empty() {
            return None;
        }
        let Some(grant_id) = &self.id else {
            return Some("plugin grant is missing a source-qualified package id binding");
        };
        match SourceQualifiedPluginId::parse(grant_id) {
            Ok(grant_identity) if &grant_identity == identity => {}
            Ok(_) => return Some("plugin grant package id binding does not match enabled package"),
            Err(_) => {
                return Some(
                    "plugin grant package id binding is not a valid source-qualified plugin id",
                );
            }
        }
        let Some(grant_digest) = &self.digest else {
            return Some("plugin grant is missing a deterministic digest binding");
        };
        if !digest_matches(grant_digest, digest) {
            return Some("plugin grant digest binding does not match enabled package digest");
        }
        let Some(grant_version) = &self.version else {
            return Some("plugin grant is missing an exact package version binding");
        };
        if !grant_version.matches(version) {
            return Some("plugin grant version binding does not match enabled package version");
        }
        None
    }
}

#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginPermission {
    Surface { surface: PluginSurface },
    Tool { name: String },
    ToolNamespace { namespace: String },
    Service { name: String },
    Ingress { name: String },
    ExternalWrite,
    HostApi { api: PluginHostApi },
}

#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PluginRequestGrant {
    /// Exact URL scheme allowed by this target, for example `https` or `http`; `*` is broad.
    pub scheme: String,
    /// Exact request host allowed by this target. `*` is broad and must be surfaced in diagnostics.
    pub host: String,
    /// Optional exact port. `None` means the scheme default or any explicit port for that host.
    pub port: Option<u16>,
    /// Uppercase HTTP methods allowed for this target, for example `GET` or `POST`.
    pub methods: Vec<String>,
    /// Optional path prefixes allowed for this target. Empty means any absolute path on the host.
    pub path_prefixes: Vec<String>,
}

impl PluginRequestGrant {
    pub fn label(&self) -> String {
        let scheme = if self.scheme.trim().is_empty() {
            "<no-scheme>"
        } else {
            self.scheme.as_str()
        };
        let host = if self.host.trim().is_empty() {
            "<no-host>"
        } else {
            self.host.as_str()
        };
        let port = self.port.map(|port| format!(":{port}")).unwrap_or_default();
        let methods = if self.methods.is_empty() {
            "<no-methods>".to_string()
        } else {
            self.methods.join(",")
        };
        let paths = if self.path_prefixes.is_empty() {
            "*".to_string()
        } else {
            self.path_prefixes.join(",")
        };
        let broad = if self.is_broad() {
            " [broad-request]"
        } else {
            ""
        };
        format!("{scheme}://{host}{port} {methods} {paths}{broad}")
    }

    pub fn is_broad(&self) -> bool {
        self.scheme.trim() == "*" || self.host.trim() == "*" || self.path_prefixes.is_empty()
    }
}

#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PluginWebSocketGrant {
    /// Exact URL scheme allowed by this WebSocket target: `wss` or `ws`; `*` is broad.
    pub scheme: String,
    /// Exact WebSocket host allowed by this target. `*` is broad and must be surfaced in diagnostics.
    pub host: String,
    /// Optional exact port. `None` means the scheme default or any explicit port for that host.
    pub port: Option<u16>,
    /// Optional path prefixes allowed for this target. Empty means any absolute path on the host.
    pub path_prefixes: Vec<String>,
}

impl PluginWebSocketGrant {
    pub fn label(&self) -> String {
        let scheme = if self.scheme.trim().is_empty() {
            "<no-scheme>"
        } else {
            self.scheme.as_str()
        };
        let host = if self.host.trim().is_empty() {
            "<no-host>"
        } else {
            self.host.as_str()
        };
        let port = self.port.map(|port| format!(":{port}")).unwrap_or_default();
        let paths = if self.path_prefixes.is_empty() {
            "*".to_string()
        } else {
            self.path_prefixes.join(",")
        };
        let broad = if self.is_broad() {
            " [broad-websocket]"
        } else {
            ""
        };
        format!("{scheme}://{host}{port} {paths}{broad}")
    }

    pub fn is_broad(&self) -> bool {
        self.scheme.trim() == "*" || self.host.trim() == "*" || self.path_prefixes.is_empty()
    }
}

#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PluginFsGrant {
    /// Absolute host path that bounds every relative `host_api.fs` request.
    pub root: String,
    /// Explicit operation kinds allowed below `root`; write does not imply read/list.
    pub operations: Vec<PluginFsOperation>,
}

impl PluginFsGrant {
    pub fn label(&self) -> String {
        let operations = if self.operations.is_empty() {
            "<no-operations>".to_string()
        } else {
            self.operations
                .iter()
                .map(ToString::to_string)
                .collect::<Vec<_>>()
                .join(",")
        };
        format!("{} {}", self.root, operations)
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginFsOperation {
    Read,
    List,
    Write,
}

impl fmt::Display for PluginFsOperation {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Read => f.write_str("read"),
            Self::List => f.write_str("list"),
            Self::Write => f.write_str("write"),
        }
    }
}

impl PluginPermission {
    pub fn label(&self) -> String {
        match self {
            Self::Surface { surface } => format!("surfaces.{surface}"),
            Self::Tool { name } => format!("tool.{name}"),
            Self::ToolNamespace { namespace } => format!("tool_namespace.{namespace}"),
            Self::Service { name } => format!("service.{name}"),
            Self::Ingress { name } => format!("ingress.{name}"),
            Self::ExternalWrite => "external_write".to_string(),
            Self::HostApi { api } => format!("host_api.{api}"),
        }
    }

    pub fn surface(surface: PluginSurface) -> Self {
        Self::Surface { surface }
    }

    pub fn tool(name: impl Into<String>) -> Self {
        Self::Tool { name: name.into() }
    }

    pub fn tool_namespace(namespace: impl Into<String>) -> Self {
        Self::ToolNamespace {
            namespace: namespace.into(),
        }
    }

    pub fn service(name: impl Into<String>) -> Self {
        Self::Service { name: name.into() }
    }

    pub fn ingress(name: impl Into<String>) -> Self {
        Self::Ingress { name: name.into() }
    }

    pub fn host_api(api: PluginHostApi) -> Self {
        Self::HostApi { api }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginHostApi {
    Request,
    #[serde(rename = "websocket")]
    WebSocket,
    Fs,
}

impl fmt::Display for PluginHostApi {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Request => f.write_str("request"),
            Self::WebSocket => f.write_str("websocket"),
            Self::Fs => f.write_str("fs"),
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginSurface {
    Hook,
    Tool,
    Service,
    Ingress,
    Wasm,
}

impl fmt::Display for PluginSurface {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            PluginSurface::Hook => f.write_str("hook"),
            PluginSurface::Tool => f.write_str("tool"),
            PluginSurface::Service => f.write_str("service"),
            PluginSurface::Ingress => f.write_str("ingress"),
            PluginSurface::Wasm => f.write_str("wasm"),
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginSourceKind {
    User,
    Project,
    Builtin,
}

impl PluginSourceKind {
    fn qualifier(self) -> &'static str {
        match self {
            PluginSourceKind::User => "user",
            PluginSourceKind::Project => "project",
            PluginSourceKind::Builtin => "builtin",
        }
    }
}

impl fmt::Display for PluginSourceKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.qualifier())
    }
}

#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct SourceQualifiedPluginId {
    pub source: PluginSourceKind,
    pub local_id: String,
}

impl SourceQualifiedPluginId {
    pub fn new(source: PluginSourceKind, local_id: impl Into<String>) -> Self {
        Self {
            source,
            local_id: local_id.into(),
        }
    }

    pub fn parse(value: &str) -> Result<Self, PluginIdParseError> {
        let Some((source, local_id)) = value.split_once(':') else {
            return Err(PluginIdParseError::Unqualified);
        };
        if local_id.is_empty() || local_id.contains(':') || !is_safe_id(local_id) {
            return Err(PluginIdParseError::InvalidLocalId);
        }
        let source = match source {
            "user" => PluginSourceKind::User,
            "project" => PluginSourceKind::Project,
            "builtin" => PluginSourceKind::Builtin,
            _ => return Err(PluginIdParseError::InvalidSource),
        };
        Ok(Self {
            source,
            local_id: local_id.to_string(),
        })
    }
}

impl fmt::Display for SourceQualifiedPluginId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}:{}", self.source, self.local_id)
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PluginIdParseError {
    Unqualified,
    InvalidSource,
    InvalidLocalId,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginPackageManifest {
    pub schema_version: u32,
    pub id: String,
    pub name: String,
    pub version: String,
    pub description: Option<String>,
    #[serde(default)]
    pub surfaces: Vec<PluginSurface>,
    #[serde(default)]
    pub runtime: Option<PluginRuntimeManifest>,
    #[serde(default)]
    pub hooks: Vec<PluginHookManifest>,
    #[serde(default)]
    pub tools: Vec<PluginToolManifest>,
    #[serde(default)]
    pub services: Vec<PluginServiceManifest>,
    #[serde(default)]
    pub ingresses: Vec<PluginIngressManifest>,
    /// Permission requests declared by the package. These are requests only;
    /// enablement grants must match them before runtime surfaces are exposed.
    #[serde(default)]
    pub permissions: Vec<PluginPermission>,
    /// Manifest-declared URL targets for `host_api.request`. These are static permission requests;
    /// enablement grants must explicitly approve matching targets.
    #[serde(default)]
    pub request: Vec<PluginRequestGrant>,
    /// Manifest-declared URL targets for `host_api.websocket`. These are independent from
    /// `host_api.request` targets and require independent enablement grants.
    #[serde(default)]
    pub websocket: Vec<PluginWebSocketGrant>,
}

impl PluginPackageManifest {
    pub fn declared_surfaces(&self) -> BTreeSet<PluginSurface> {
        let mut surfaces: BTreeSet<_> = self.surfaces.iter().copied().collect();
        if !self.hooks.is_empty() {
            surfaces.insert(PluginSurface::Hook);
        }
        if !self.tools.is_empty() {
            surfaces.insert(PluginSurface::Tool);
        }
        if !self.services.is_empty() {
            surfaces.insert(PluginSurface::Service);
        }
        if !self.ingresses.is_empty() {
            surfaces.insert(PluginSurface::Ingress);
        }
        if self.runtime.is_some() {
            surfaces.insert(PluginSurface::Wasm);
        }
        surfaces
    }
}

const LEGACY_PLUGIN_RUNTIME_WASM_KIND: &str = "wasm";
const LEGACY_PLUGIN_RUNTIME_WASM_ABI: &str = "yoi-plugin-wasm-1";
/// Manifest runtime kind for current WebAssembly Component Model packages.
///
/// Component runtime manifests must set `component` to the packaged component
/// artifact path and `world` to [`PLUGIN_COMPONENT_TOOL_WORLD`] or
/// [`PLUGIN_COMPONENT_INSTANCE_WORLD`]. Legacy raw core-Wasm manifests are
/// intentionally rejected by validation; `wasm-component` is the public runtime.
pub const PLUGIN_RUNTIME_COMPONENT_KIND: &str = "wasm-component";
pub const PLUGIN_COMPONENT_TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0";
pub const PLUGIN_COMPONENT_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0";

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginRuntimeManifest {
    pub kind: String,
    #[serde(default)]
    pub entry: Option<String>,
    #[serde(default)]
    pub abi: Option<String>,
    #[serde(default)]
    pub component: Option<String>,
    #[serde(default)]
    pub world: Option<String>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginHookManifest {
    pub id: String,
    pub file: String,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginToolManifest {
    pub name: String,
    pub description: String,
    pub input_schema: serde_json::Value,
    /// Whether this Tool declares side effects outside the model-visible result.
    /// The flag does not grant authority; it requires a matching external_write
    /// request and grant before registration or execution.
    #[serde(default)]
    pub external_write: bool,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginServiceManifest {
    pub name: String,
    pub description: String,
    #[serde(default)]
    pub lifecycle: String,
    #[serde(default)]
    pub status_schema: Option<serde_json::Value>,
    #[serde(default)]
    pub side_effects: Vec<String>,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginIngressManifest {
    pub name: String,
    pub description: String,
    #[serde(default)]
    pub event_kinds: Vec<String>,
    #[serde(default)]
    pub input_schema: Option<serde_json::Value>,
    #[serde(default)]
    pub sources: Vec<String>,
    #[serde(default)]
    pub side_effects: Vec<String>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PluginDiscoveryLimits {
    pub max_packages_per_store: usize,
    pub max_package_size_bytes: u64,
    pub max_manifest_size_bytes: usize,
    pub max_entries_per_package: usize,
    pub max_file_size_bytes: u64,
    pub max_expanded_size_bytes: u64,
}

impl Default for PluginDiscoveryLimits {
    fn default() -> Self {
        Self {
            max_packages_per_store: 128,
            max_package_size_bytes: 16 * 1024 * 1024,
            max_manifest_size_bytes: 64 * 1024,
            max_entries_per_package: 512,
            max_file_size_bytes: 8 * 1024 * 1024,
            max_expanded_size_bytes: 64 * 1024 * 1024,
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PluginDiscoveryOptions {
    pub workspace_root: PathBuf,
    pub user_data_home: Option<PathBuf>,
    pub limits: PluginDiscoveryLimits,
}

impl PluginDiscoveryOptions {
    pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
        Self {
            workspace_root: workspace_root.into(),
            user_data_home: None,
            limits: PluginDiscoveryLimits::default(),
        }
    }

    pub fn with_user_data_home(mut self, user_data_home: impl Into<PathBuf>) -> Self {
        self.user_data_home = Some(user_data_home.into());
        self
    }

    pub fn with_limits(mut self, limits: PluginDiscoveryLimits) -> Self {
        self.limits = limits;
        self
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct DiscoveredPluginPackage {
    pub identity: SourceQualifiedPluginId,
    pub package_path: PathBuf,
    pub package_label: String,
    pub digest: String,
    pub manifest: PluginPackageManifest,
    pub entries: BTreeSet<String>,
}

/// Fully materialized package content used by local authoring checks and pack.
///
/// This is data-only metadata and bytes. Constructing it parses manifests and
/// validates package/archive shape, but it does not load, instantiate, or
/// execute Plugin code.
#[derive(Clone, Debug, PartialEq)]
pub struct MaterializedPluginPackage {
    pub package: DiscoveredPluginPackage,
    pub files: BTreeMap<String, Vec<u8>>,
}

#[derive(Clone, Debug, PartialEq)]
pub struct PackedPluginPackage {
    pub output_path: PathBuf,
    pub package: DiscoveredPluginPackage,
}

#[derive(Clone, Debug, Default, PartialEq)]
pub struct PluginDiscoveryReport {
    pub packages: Vec<DiscoveredPluginPackage>,
    pub diagnostics: Vec<PluginDiagnostic>,
}

impl PluginDiscoveryReport {
    pub fn package(&self, identity: &SourceQualifiedPluginId) -> Vec<&DiscoveredPluginPackage> {
        self.packages
            .iter()
            .filter(|package| &package.identity == identity)
            .collect()
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct ResolvedPlugin {
    pub identity: SourceQualifiedPluginId,
    pub source: PluginSourceKind,
    pub package_path: PathBuf,
    pub package_label: String,
    pub digest: String,
    pub manifest: PluginPackageManifest,
    pub enabled_surfaces: Vec<PluginSurface>,
    pub grants: PluginGrantConfig,
    pub config: Option<toml::Value>,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ResolvedPluginRecord {
    pub identity: SourceQualifiedPluginId,
    pub source: PluginSourceKind,
    pub package_path: PathBuf,
    pub package_label: String,
    pub digest: String,
    pub version: String,
    pub manifest: PluginPackageManifest,
    pub enabled_surfaces: Vec<PluginSurface>,
    pub grants: PluginGrantConfig,
    pub config: Option<toml::Value>,
}

impl ResolvedPluginRecord {
    pub fn from_resolved(resolved: &ResolvedPlugin) -> Self {
        Self {
            identity: resolved.identity.clone(),
            source: resolved.source,
            package_path: resolved.package_path.clone(),
            package_label: resolved.package_label.clone(),
            digest: resolved.digest.clone(),
            version: resolved.manifest.version.clone(),
            manifest: resolved.manifest.clone(),
            enabled_surfaces: resolved.enabled_surfaces.clone(),
            grants: resolved.grants.clone(),
            config: resolved.config.clone(),
        }
    }
}

#[derive(Clone, Debug, Default, PartialEq)]
pub struct PluginResolution {
    pub resolved: Vec<ResolvedPlugin>,
    pub diagnostics: Vec<PluginDiagnostic>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginDiagnostic {
    pub kind: PluginDiagnosticKind,
    pub phase: PluginDiagnosticPhase,
    pub source: Option<PluginSourceKind>,
    pub identity: Option<String>,
    pub package: Option<String>,
    pub digest: Option<String>,
    pub message: String,
}

impl PluginDiagnostic {
    fn new(
        kind: PluginDiagnosticKind,
        phase: PluginDiagnosticPhase,
        message: impl Into<String>,
    ) -> Self {
        Self {
            kind,
            phase,
            source: None,
            identity: None,
            package: None,
            digest: None,
            message: message.into(),
        }
    }

    fn with_source(mut self, source: PluginSourceKind) -> Self {
        self.source = Some(source);
        self
    }

    fn with_identity(mut self, identity: impl ToString) -> Self {
        self.identity = Some(identity.to_string());
        self
    }

    fn with_package(mut self, package: impl Into<String>) -> Self {
        self.package = Some(package.into());
        self
    }

    fn with_digest(mut self, digest: impl Into<String>) -> Self {
        self.digest = Some(digest.into());
        self
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginDiagnosticKind {
    Missing,
    Duplicate,
    Ambiguous,
    Version,
    Digest,
    Api,
    Surface,
    Grant,
    Malformed,
    Traversal,
    Bounds,
    Io,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginDiagnosticPhase {
    Discovery,
    Manifest,
    Resolution,
}

pub fn discover_plugins(options: &PluginDiscoveryOptions) -> PluginDiscoveryReport {
    let mut report = PluginDiscoveryReport::default();
    let stores = plugin_stores(options);

    for store in stores {
        discover_store(&store, &options.limits, &mut report);
    }

    let mut counts: BTreeMap<SourceQualifiedPluginId, usize> = BTreeMap::new();
    for package in &report.packages {
        *counts.entry(package.identity.clone()).or_default() += 1;
    }
    for (identity, count) in counts {
        if count > 1 {
            report.diagnostics.push(
                PluginDiagnostic::new(
                    PluginDiagnosticKind::Duplicate,
                    PluginDiagnosticPhase::Discovery,
                    "duplicate plugin package identity in one source store",
                )
                .with_source(identity.source)
                .with_identity(identity),
            );
        }
    }

    report.packages.sort_by(|left, right| {
        left.identity
            .cmp(&right.identity)
            .then_with(|| left.digest.cmp(&right.digest))
            .then_with(|| left.package_label.cmp(&right.package_label))
    });
    report
}

pub fn resolve_enabled_plugins(
    config: &PluginConfig,
    discovery: &PluginDiscoveryReport,
) -> PluginResolution {
    let mut resolution = PluginResolution::default();

    for enablement in &config.enabled {
        let identity = match SourceQualifiedPluginId::parse(&enablement.id) {
            Ok(identity) => identity,
            Err(PluginIdParseError::Unqualified) => {
                resolution.diagnostics.push(
                    PluginDiagnostic::new(
                        PluginDiagnosticKind::Ambiguous,
                        PluginDiagnosticPhase::Resolution,
                        "plugin enablement id must be source-qualified as user:<id>, project:<id>, or builtin:<id>",
                    )
                    .with_identity(&enablement.id),
                );
                continue;
            }
            Err(PluginIdParseError::InvalidSource | PluginIdParseError::InvalidLocalId) => {
                resolution.diagnostics.push(
                    PluginDiagnostic::new(
                        PluginDiagnosticKind::Malformed,
                        PluginDiagnosticPhase::Resolution,
                        "plugin enablement id is not a valid source-qualified plugin id",
                    )
                    .with_identity(&enablement.id),
                );
                continue;
            }
        };

        let matches = discovery.package(&identity);
        let package = match matches.as_slice() {
            [] => {
                resolution.diagnostics.push(
                    PluginDiagnostic::new(
                        PluginDiagnosticKind::Missing,
                        PluginDiagnosticPhase::Resolution,
                        "enabled plugin package was not discovered",
                    )
                    .with_source(identity.source)
                    .with_identity(identity),
                );
                continue;
            }
            [package] => *package,
            _ => {
                resolution.diagnostics.push(
                    PluginDiagnostic::new(
                        PluginDiagnosticKind::Duplicate,
                        PluginDiagnosticPhase::Resolution,
                        "enabled plugin package identity resolved to multiple discovered packages",
                    )
                    .with_source(identity.source)
                    .with_identity(identity),
                );
                continue;
            }
        };

        if let Some(expected_digest) = &enablement.digest {
            if !digest_matches(expected_digest, &package.digest) {
                resolution.diagnostics.push(
                    PluginDiagnostic::new(
                        PluginDiagnosticKind::Digest,
                        PluginDiagnosticPhase::Resolution,
                        "enabled plugin digest pin does not match discovered package digest",
                    )
                    .with_source(identity.source)
                    .with_identity(&identity)
                    .with_package(&package.package_label)
                    .with_digest(&package.digest),
                );
                continue;
            }
        }

        if let Some(required_version) = &enablement.version {
            if !required_version.matches(&package.manifest.version) {
                resolution.diagnostics.push(
                    PluginDiagnostic::new(
                        PluginDiagnosticKind::Version,
                        PluginDiagnosticPhase::Resolution,
                        "enabled plugin exact version requirement does not match discovered package version",
                    )
                    .with_source(identity.source)
                    .with_identity(&identity)
                    .with_package(&package.package_label)
                    .with_digest(&package.digest),
                );
                continue;
            }
        }

        if let Some(message) =
            enablement
                .grants
                .binding_error(&identity, &package.digest, &package.manifest.version)
        {
            resolution.diagnostics.push(
                PluginDiagnostic::new(
                    PluginDiagnosticKind::Grant,
                    PluginDiagnosticPhase::Resolution,
                    message,
                )
                .with_source(identity.source)
                .with_identity(&identity)
                .with_package(&package.package_label)
                .with_digest(&package.digest),
            );
            continue;
        }

        let declared_surfaces = package.manifest.declared_surfaces();
        let selected_surfaces: BTreeSet<_> = if enablement.surfaces.is_empty() {
            declared_surfaces.clone()
        } else {
            enablement.surfaces.iter().copied().collect()
        };
        if let Some(surface) = selected_surfaces
            .iter()
            .find(|surface| !declared_surfaces.contains(surface))
        {
            resolution.diagnostics.push(
                PluginDiagnostic::new(
                    PluginDiagnosticKind::Surface,
                    PluginDiagnosticPhase::Resolution,
                    format!("enabled plugin requested undeclared surface `{surface}`"),
                )
                .with_source(identity.source)
                .with_identity(&identity)
                .with_package(&package.package_label)
                .with_digest(&package.digest),
            );
            continue;
        }

        resolution.resolved.push(ResolvedPlugin {
            identity: identity.clone(),
            source: identity.source,
            package_path: package.package_path.clone(),
            package_label: package.package_label.clone(),
            digest: package.digest.clone(),
            manifest: package.manifest.clone(),
            enabled_surfaces: selected_surfaces.into_iter().collect(),
            grants: enablement.grants.clone(),
            config: enablement.config.clone(),
        });
    }

    resolution
}

pub fn resolve_plugin_config_for_startup(
    config: &PluginConfig,
    options: &PluginDiscoveryOptions,
) -> PluginConfig {
    if config.enabled.is_empty() || config.has_resolved_plan() {
        return config.clone();
    }

    let discovery = discover_plugins(options);
    let resolution = resolve_enabled_plugins(config, &discovery);
    let mut snapshot = config.clone();
    snapshot.resolved = resolution
        .resolved
        .iter()
        .map(ResolvedPluginRecord::from_resolved)
        .collect();
    snapshot.diagnostics = discovery.diagnostics;
    snapshot.diagnostics.extend(resolution.diagnostics);
    snapshot
}

/// Reads the WebAssembly Component Model artifact selected by a resolved plugin
/// package manifest while preserving package digest pinning.
pub fn read_resolved_plugin_runtime_component(
    record: &ResolvedPluginRecord,
    limits: &PluginDiscoveryLimits,
) -> Result<Vec<u8>, PluginDiagnostic> {
    let runtime = record.manifest.runtime.as_ref().ok_or_else(|| {
        PluginDiagnostic::new(
            PluginDiagnosticKind::Missing,
            PluginDiagnosticPhase::Manifest,
            "resolved plugin package does not declare a component runtime",
        )
        .with_source(record.source)
        .with_identity(&record.identity)
        .with_package(&record.package_label)
        .with_digest(&record.digest)
    })?;

    if runtime.kind != PLUGIN_RUNTIME_COMPONENT_KIND {
        return Err(PluginDiagnostic::new(
            PluginDiagnosticKind::Api,
            PluginDiagnosticPhase::Manifest,
            "plugin runtime kind is unsupported",
        )
        .with_source(record.source)
        .with_identity(&record.identity)
        .with_package(&record.package_label)
        .with_digest(&record.digest));
    }
    if !matches!(
        runtime.world.as_deref(),
        Some(PLUGIN_COMPONENT_TOOL_WORLD) | Some(PLUGIN_COMPONENT_INSTANCE_WORLD)
    ) {
        return Err(PluginDiagnostic::new(
            PluginDiagnosticKind::Api,
            PluginDiagnosticPhase::Manifest,
            "plugin component world is unsupported",
        )
        .with_source(record.source)
        .with_identity(&record.identity)
        .with_package(&record.package_label)
        .with_digest(&record.digest));
    }
    let component = runtime.component.as_deref().ok_or_else(|| {
        PluginDiagnostic::new(
            PluginDiagnosticKind::Missing,
            PluginDiagnosticPhase::Manifest,
            "plugin component runtime artifact is required",
        )
        .with_source(record.source)
        .with_identity(&record.identity)
        .with_package(&record.package_label)
        .with_digest(&record.digest)
    })?;

    let metadata = fs::metadata(&record.package_path).map_err(|error| {
        PluginDiagnostic::new(
            PluginDiagnosticKind::Io,
            PluginDiagnosticPhase::Discovery,
            format!(
                "resolved plugin package metadata could not be read: {}",
                safe_io_error(&error)
            ),
        )
        .with_source(record.source)
        .with_identity(&record.identity)
        .with_package(&record.package_label)
        .with_digest(&record.digest)
    })?;
    if !metadata.is_file() {
        return Err(PluginDiagnostic::new(
            PluginDiagnosticKind::Malformed,
            PluginDiagnosticPhase::Discovery,
            "resolved plugin package is not a regular file",
        )
        .with_source(record.source)
        .with_identity(&record.identity)
        .with_package(&record.package_label)
        .with_digest(&record.digest));
    }
    if metadata.len() > limits.max_package_size_bytes {
        return Err(PluginDiagnostic::new(
            PluginDiagnosticKind::Bounds,
            PluginDiagnosticPhase::Discovery,
            "resolved plugin package exceeds the configured package size bound",
        )
        .with_source(record.source)
        .with_identity(&record.identity)
        .with_package(&record.package_label)
        .with_digest(&record.digest));
    }

    let bytes = fs::read(&record.package_path).map_err(|error| {
        PluginDiagnostic::new(
            PluginDiagnosticKind::Io,
            PluginDiagnosticPhase::Discovery,
            format!(
                "resolved plugin package content could not be read: {}",
                safe_io_error(&error)
            ),
        )
        .with_source(record.source)
        .with_identity(&record.identity)
        .with_package(&record.package_label)
        .with_digest(&record.digest)
    })?;
    let archive = parse_stored_zip(&bytes, &record.package_label, record.source, limits)?;
    let actual_digest = deterministic_digest(&archive.files);
    if !digest_matches(&record.digest, &actual_digest) {
        return Err(PluginDiagnostic::new(
            PluginDiagnosticKind::Digest,
            PluginDiagnosticPhase::Resolution,
            "resolved plugin package digest does not match current package content",
        )
        .with_source(record.source)
        .with_identity(&record.identity)
        .with_package(&record.package_label)
        .with_digest(actual_digest));
    }

    validate_manifest_path(
        component,
        &archive,
        &record.package_label,
        record.source,
        &record.manifest.id,
    )?;
    let normalized = normalize_archive_path(component).ok_or_else(|| {
        PluginDiagnostic::new(
            PluginDiagnosticKind::Traversal,
            PluginDiagnosticPhase::Manifest,
            "plugin manifest references a path outside the package root",
        )
        .with_source(record.source)
        .with_identity(&record.identity)
        .with_package(&record.package_label)
        .with_digest(&record.digest)
    })?;
    archive.files.get(&normalized).cloned().ok_or_else(|| {
        PluginDiagnostic::new(
            PluginDiagnosticKind::Missing,
            PluginDiagnosticPhase::Manifest,
            "plugin runtime component artifact is missing from the package",
        )
        .with_source(record.source)
        .with_identity(&record.identity)
        .with_package(&record.package_label)
        .with_digest(&record.digest)
    })
}

#[derive(Clone, Debug)]
struct PluginStore {
    source: PluginSourceKind,
    path: PathBuf,
}

fn plugin_stores(options: &PluginDiscoveryOptions) -> Vec<PluginStore> {
    let user_data_home = options
        .user_data_home
        .clone()
        .or_else(|| std::env::var_os("XDG_DATA_HOME").map(PathBuf::from))
        .or_else(|| {
            std::env::var_os("HOME")
                .map(PathBuf::from)
                .map(|home| home.join(".local/share"))
        });

    let mut stores = Vec::new();
    if let Some(user_data_home) = user_data_home {
        stores.push(PluginStore {
            source: PluginSourceKind::User,
            path: user_data_home.join("yoi/plugins"),
        });
    }
    stores.push(PluginStore {
        source: PluginSourceKind::Project,
        path: options.workspace_root.join(".yoi/plugins"),
    });
    stores
}

fn discover_store(
    store: &PluginStore,
    limits: &PluginDiscoveryLimits,
    report: &mut PluginDiscoveryReport,
) {
    let canonical_store = match fs::canonicalize(&store.path) {
        Ok(path) => path,
        Err(error) if error.kind() == io::ErrorKind::NotFound => return,
        Err(error) => {
            report.diagnostics.push(
                PluginDiagnostic::new(
                    PluginDiagnosticKind::Io,
                    PluginDiagnosticPhase::Discovery,
                    format!("plugin store could not be read: {}", safe_io_error(&error)),
                )
                .with_source(store.source),
            );
            return;
        }
    };

    let entries = match fs::read_dir(&canonical_store) {
        Ok(entries) => entries,
        Err(error) => {
            report.diagnostics.push(
                PluginDiagnostic::new(
                    PluginDiagnosticKind::Io,
                    PluginDiagnosticPhase::Discovery,
                    format!(
                        "plugin store could not be listed: {}",
                        safe_io_error(&error)
                    ),
                )
                .with_source(store.source),
            );
            return;
        }
    };

    let mut candidates = Vec::new();
    for entry in entries {
        let Ok(entry) = entry else {
            continue;
        };
        let path = entry.path();
        if path.extension().and_then(|extension| extension.to_str()) == Some("yoi-plugin") {
            candidates.push(path);
        }
    }
    candidates.sort();

    if candidates.len() > limits.max_packages_per_store {
        report.diagnostics.push(
            PluginDiagnostic::new(
                PluginDiagnosticKind::Bounds,
                PluginDiagnosticPhase::Discovery,
                "plugin store contains more packages than the configured discovery bound",
            )
            .with_source(store.source),
        );
        candidates.truncate(limits.max_packages_per_store);
    }

    for candidate in candidates {
        let label = package_label(&candidate);
        let canonical_candidate = match fs::canonicalize(&candidate) {
            Ok(path) => path,
            Err(error) => {
                report.diagnostics.push(
                    PluginDiagnostic::new(
                        PluginDiagnosticKind::Io,
                        PluginDiagnosticPhase::Discovery,
                        format!(
                            "plugin package could not be read: {}",
                            safe_io_error(&error)
                        ),
                    )
                    .with_source(store.source)
                    .with_package(label),
                );
                continue;
            }
        };
        if !canonical_candidate.starts_with(&canonical_store) {
            report.diagnostics.push(
                PluginDiagnostic::new(
                    PluginDiagnosticKind::Traversal,
                    PluginDiagnosticPhase::Discovery,
                    "plugin package path escapes its source store",
                )
                .with_source(store.source)
                .with_package(package_label(&candidate)),
            );
            continue;
        }
        let metadata = match fs::metadata(&canonical_candidate) {
            Ok(metadata) => metadata,
            Err(error) => {
                report.diagnostics.push(
                    PluginDiagnostic::new(
                        PluginDiagnosticKind::Io,
                        PluginDiagnosticPhase::Discovery,
                        format!(
                            "plugin package metadata could not be read: {}",
                            safe_io_error(&error)
                        ),
                    )
                    .with_source(store.source)
                    .with_package(label),
                );
                continue;
            }
        };
        if !metadata.is_file() {
            report.diagnostics.push(
                PluginDiagnostic::new(
                    PluginDiagnosticKind::Malformed,
                    PluginDiagnosticPhase::Discovery,
                    "plugin package candidate is not a regular file",
                )
                .with_source(store.source)
                .with_package(label),
            );
            continue;
        }
        if metadata.len() > limits.max_package_size_bytes {
            report.diagnostics.push(
                PluginDiagnostic::new(
                    PluginDiagnosticKind::Bounds,
                    PluginDiagnosticPhase::Discovery,
                    "plugin package exceeds the configured package size bound",
                )
                .with_source(store.source)
                .with_package(label),
            );
            continue;
        }

        match read_package(&canonical_candidate, &label, store.source, limits) {
            Ok(package) => report.packages.push(package),
            Err(diagnostic) => report.diagnostics.push(diagnostic),
        }
    }
}

fn read_package(
    path: &Path,
    label: &str,
    source: PluginSourceKind,
    limits: &PluginDiscoveryLimits,
) -> Result<DiscoveredPluginPackage, PluginDiagnostic> {
    let bytes = fs::read(path).map_err(|error| {
        PluginDiagnostic::new(
            PluginDiagnosticKind::Io,
            PluginDiagnosticPhase::Discovery,
            format!(
                "plugin package content could not be read: {}",
                safe_io_error(&error)
            ),
        )
        .with_source(source)
        .with_package(label)
    })?;
    materialize_archive(path, label, source, &bytes, limits)
        .map(|materialized| materialized.package)
}

pub fn read_plugin_package_file(
    path: &Path,
    source: PluginSourceKind,
    limits: &PluginDiscoveryLimits,
) -> Result<MaterializedPluginPackage, PluginDiagnostic> {
    let label = package_label(path);
    let metadata = fs::metadata(path).map_err(|error| {
        PluginDiagnostic::new(
            PluginDiagnosticKind::Io,
            PluginDiagnosticPhase::Discovery,
            format!(
                "plugin package metadata could not be read: {}",
                safe_io_error(&error)
            ),
        )
        .with_source(source)
        .with_package(label.clone())
    })?;
    if !metadata.is_file() {
        return Err(PluginDiagnostic::new(
            PluginDiagnosticKind::Malformed,
            PluginDiagnosticPhase::Discovery,
            "plugin package candidate is not a regular file",
        )
        .with_source(source)
        .with_package(label));
    }
    if metadata.len() > limits.max_package_size_bytes {
        return Err(PluginDiagnostic::new(
            PluginDiagnosticKind::Bounds,
            PluginDiagnosticPhase::Discovery,
            "plugin package exceeds the configured package size bound",
        )
        .with_source(source)
        .with_package(label));
    }
    let bytes = fs::read(path).map_err(|error| {
        PluginDiagnostic::new(
            PluginDiagnosticKind::Io,
            PluginDiagnosticPhase::Discovery,
            format!(
                "plugin package content could not be read: {}",
                safe_io_error(&error)
            ),
        )
        .with_source(source)
        .with_package(label.clone())
    })?;
    materialize_archive(path, &label, source, &bytes, limits)
}

pub fn read_plugin_directory(
    path: &Path,
    source: PluginSourceKind,
    limits: &PluginDiscoveryLimits,
) -> Result<MaterializedPluginPackage, PluginDiagnostic> {
    let label = package_label(path);
    let root = fs::canonicalize(path).map_err(|error| {
        PluginDiagnostic::new(
            PluginDiagnosticKind::Io,
            PluginDiagnosticPhase::Discovery,
            format!(
                "plugin directory could not be read: {}",
                safe_io_error(&error)
            ),
        )
        .with_source(source)
        .with_package(label.clone())
    })?;
    let metadata = fs::metadata(&root).map_err(|error| {
        PluginDiagnostic::new(
            PluginDiagnosticKind::Io,
            PluginDiagnosticPhase::Discovery,
            format!(
                "plugin directory metadata could not be read: {}",
                safe_io_error(&error)
            ),
        )
        .with_source(source)
        .with_package(label.clone())
    })?;
    if !metadata.is_dir() {
        return Err(PluginDiagnostic::new(
            PluginDiagnosticKind::Malformed,
            PluginDiagnosticPhase::Discovery,
            "plugin directory input is not a directory",
        )
        .with_source(source)
        .with_package(label));
    }

    let mut files = BTreeMap::new();
    collect_directory_files(&root, &root, &label, source, limits, &mut files)?;
    materialize_files(path, label, source, files, limits)
}

pub fn write_plugin_package_file(
    materialized: &MaterializedPluginPackage,
    output_path: &Path,
    limits: &PluginDiscoveryLimits,
) -> Result<PackedPluginPackage, PluginDiagnostic> {
    write_stored_zip_file(output_path, &materialized.files, limits)?;
    let package = read_plugin_package_file(output_path, materialized.package.source(), limits)?;
    Ok(PackedPluginPackage {
        output_path: output_path.to_path_buf(),
        package: package.package,
    })
}

impl DiscoveredPluginPackage {
    pub fn source(&self) -> PluginSourceKind {
        self.identity.source
    }
}

fn materialize_archive(
    path: &Path,
    label: &str,
    source: PluginSourceKind,
    bytes: &[u8],
    limits: &PluginDiscoveryLimits,
) -> Result<MaterializedPluginPackage, PluginDiagnostic> {
    let archive = parse_stored_zip(bytes, label, source, limits)?;
    materialize_files(path, label.to_string(), source, archive.files, limits)
}

fn materialize_files(
    path: &Path,
    label: String,
    source: PluginSourceKind,
    files: BTreeMap<String, Vec<u8>>,
    limits: &PluginDiscoveryLimits,
) -> Result<MaterializedPluginPackage, PluginDiagnostic> {
    let archive = StoredArchive {
        files: files.clone(),
    };
    let manifest_bytes = archive.files.get("plugin.toml").ok_or_else(|| {
        PluginDiagnostic::new(
            PluginDiagnosticKind::Missing,
            PluginDiagnosticPhase::Manifest,
            "plugin package is missing root plugin.toml",
        )
        .with_source(source)
        .with_package(label.clone())
    })?;
    if manifest_bytes.len() > limits.max_manifest_size_bytes {
        return Err(PluginDiagnostic::new(
            PluginDiagnosticKind::Bounds,
            PluginDiagnosticPhase::Manifest,
            "plugin.toml exceeds the configured manifest size bound",
        )
        .with_source(source)
        .with_package(label));
    }
    let manifest_text = std::str::from_utf8(manifest_bytes).map_err(|_| {
        PluginDiagnostic::new(
            PluginDiagnosticKind::Malformed,
            PluginDiagnosticPhase::Manifest,
            "plugin.toml is not valid UTF-8",
        )
        .with_source(source)
        .with_package(label.clone())
    })?;
    let manifest: PluginPackageManifest = toml::from_str(manifest_text).map_err(|error| {
        PluginDiagnostic::new(
            PluginDiagnosticKind::Malformed,
            PluginDiagnosticPhase::Manifest,
            safe_toml_parse_message(&error),
        )
        .with_source(source)
        .with_package(label.clone())
    })?;
    validate_manifest(&manifest, &archive, &label, source)?;
    let digest = deterministic_digest(&archive.files);
    let identity = SourceQualifiedPluginId::new(source, manifest.id.clone());
    let package = DiscoveredPluginPackage {
        identity,
        package_path: path.to_path_buf(),
        package_label: label,
        digest,
        manifest,
        entries: archive.files.keys().cloned().collect(),
    };
    Ok(MaterializedPluginPackage { package, files })
}

fn collect_directory_files(
    root: &Path,
    dir: &Path,
    label: &str,
    source: PluginSourceKind,
    limits: &PluginDiscoveryLimits,
    files: &mut BTreeMap<String, Vec<u8>>,
) -> Result<(), PluginDiagnostic> {
    let mut entries = fs::read_dir(dir)
        .map_err(|error| {
            PluginDiagnostic::new(
                PluginDiagnosticKind::Io,
                PluginDiagnosticPhase::Discovery,
                format!(
                    "plugin directory could not be listed: {}",
                    safe_io_error(&error)
                ),
            )
            .with_source(source)
            .with_package(label.to_string())
        })?
        .filter_map(|entry| entry.ok().map(|entry| entry.path()))
        .collect::<Vec<_>>();
    entries.sort();

    for path in entries {
        let metadata = fs::symlink_metadata(&path).map_err(|error| {
            PluginDiagnostic::new(
                PluginDiagnosticKind::Io,
                PluginDiagnosticPhase::Discovery,
                format!(
                    "plugin directory entry metadata could not be read: {}",
                    safe_io_error(&error)
                ),
            )
            .with_source(source)
            .with_package(label.to_string())
        })?;
        if metadata.file_type().is_symlink() {
            return Err(PluginDiagnostic::new(
                PluginDiagnosticKind::Traversal,
                PluginDiagnosticPhase::Discovery,
                "plugin directory contains a symlink entry",
            )
            .with_source(source)
            .with_package(label.to_string()));
        }
        if metadata.is_dir() {
            collect_directory_files(root, &path, label, source, limits, files)?;
            continue;
        }
        if !metadata.is_file() {
            continue;
        }
        if metadata.len() > limits.max_file_size_bytes {
            return Err(PluginDiagnostic::new(
                PluginDiagnosticKind::Bounds,
                PluginDiagnosticPhase::Discovery,
                "plugin directory file exceeds the configured per-file bound",
            )
            .with_source(source)
            .with_package(label.to_string()));
        }
        let canonical = fs::canonicalize(&path).map_err(|error| {
            PluginDiagnostic::new(
                PluginDiagnosticKind::Io,
                PluginDiagnosticPhase::Discovery,
                format!(
                    "plugin directory file could not be read: {}",
                    safe_io_error(&error)
                ),
            )
            .with_source(source)
            .with_package(label.to_string())
        })?;
        if !canonical.starts_with(root) {
            return Err(PluginDiagnostic::new(
                PluginDiagnosticKind::Traversal,
                PluginDiagnosticPhase::Discovery,
                "plugin directory file escapes the package root",
            )
            .with_source(source)
            .with_package(label.to_string()));
        }
        let relative = canonical.strip_prefix(root).map_err(|_| {
            PluginDiagnostic::new(
                PluginDiagnosticKind::Traversal,
                PluginDiagnosticPhase::Discovery,
                "plugin directory file escapes the package root",
            )
            .with_source(source)
            .with_package(label.to_string())
        })?;
        let normalized = relative
            .components()
            .map(|component| component.as_os_str().to_str())
            .collect::<Option<Vec<_>>>()
            .and_then(|parts| normalize_archive_path(&parts.join("/")))
            .ok_or_else(|| {
                PluginDiagnostic::new(
                    PluginDiagnosticKind::Traversal,
                    PluginDiagnosticPhase::Discovery,
                    "plugin directory contains an unsafe relative path",
                )
                .with_source(source)
                .with_package(label.to_string())
            })?;
        let content = fs::read(&path).map_err(|error| {
            PluginDiagnostic::new(
                PluginDiagnosticKind::Io,
                PluginDiagnosticPhase::Discovery,
                format!(
                    "plugin directory file could not be read: {}",
                    safe_io_error(&error)
                ),
            )
            .with_source(source)
            .with_package(label.to_string())
        })?;
        files.insert(normalized, content);
        if files.len() > limits.max_entries_per_package {
            return Err(PluginDiagnostic::new(
                PluginDiagnosticKind::Bounds,
                PluginDiagnosticPhase::Discovery,
                "plugin directory contains more files than the configured bound",
            )
            .with_source(source)
            .with_package(label.to_string()));
        }
        let expanded_size = files
            .values()
            .map(|content| content.len() as u64)
            .sum::<u64>();
        if expanded_size > limits.max_expanded_size_bytes {
            return Err(PluginDiagnostic::new(
                PluginDiagnosticKind::Bounds,
                PluginDiagnosticPhase::Discovery,
                "plugin directory expanded size exceeds the configured bound",
            )
            .with_source(source)
            .with_package(label.to_string()));
        }
    }
    Ok(())
}

fn write_stored_zip_file(
    output_path: &Path,
    files: &BTreeMap<String, Vec<u8>>,
    limits: &PluginDiscoveryLimits,
) -> Result<(), PluginDiagnostic> {
    if files.len() > limits.max_entries_per_package {
        return Err(PluginDiagnostic::new(
            PluginDiagnosticKind::Bounds,
            PluginDiagnosticPhase::Discovery,
            "plugin package contains more entries than the configured bound",
        ));
    }
    let mut bytes = Vec::new();
    let mut central = Vec::new();
    for (name, content) in files {
        let name = normalize_archive_path(name).ok_or_else(|| {
            PluginDiagnostic::new(
                PluginDiagnosticKind::Traversal,
                PluginDiagnosticPhase::Discovery,
                "plugin package entry path escapes the archive root",
            )
        })?;
        if content.len() as u64 > limits.max_file_size_bytes {
            return Err(PluginDiagnostic::new(
                PluginDiagnosticKind::Bounds,
                PluginDiagnosticPhase::Discovery,
                "plugin package entry exceeds the configured per-file bound",
            ));
        }
        let local_offset = bytes.len() as u32;
        write_u32_vec(&mut bytes, ZIP_LOCAL_FILE);
        write_u16_vec(&mut bytes, 20);
        write_u16_vec(&mut bytes, 0x0800);
        write_u16_vec(&mut bytes, ZIP_COMPRESSION_STORED);
        write_u16_vec(&mut bytes, 0);
        write_u16_vec(&mut bytes, 0);
        write_u32_vec(&mut bytes, 0);
        write_u32_vec(&mut bytes, content.len() as u32);
        write_u32_vec(&mut bytes, content.len() as u32);
        write_u16_vec(&mut bytes, name.len() as u16);
        write_u16_vec(&mut bytes, 0);
        bytes.extend_from_slice(name.as_bytes());
        bytes.extend_from_slice(content);

        write_u32_vec(&mut central, ZIP_CENTRAL_DIRECTORY);
        write_u16_vec(&mut central, 20);
        write_u16_vec(&mut central, 20);
        write_u16_vec(&mut central, 0x0800);
        write_u16_vec(&mut central, ZIP_COMPRESSION_STORED);
        write_u16_vec(&mut central, 0);
        write_u16_vec(&mut central, 0);
        write_u32_vec(&mut central, 0);
        write_u32_vec(&mut central, content.len() as u32);
        write_u32_vec(&mut central, content.len() as u32);
        write_u16_vec(&mut central, name.len() as u16);
        write_u16_vec(&mut central, 0);
        write_u16_vec(&mut central, 0);
        write_u16_vec(&mut central, 0);
        write_u16_vec(&mut central, 0);
        write_u32_vec(&mut central, 0);
        write_u32_vec(&mut central, local_offset);
        central.extend_from_slice(name.as_bytes());
    }
    let central_offset = bytes.len() as u32;
    bytes.extend_from_slice(&central);
    write_u32_vec(&mut bytes, ZIP_EOCD);
    write_u16_vec(&mut bytes, 0);
    write_u16_vec(&mut bytes, 0);
    write_u16_vec(&mut bytes, files.len() as u16);
    write_u16_vec(&mut bytes, files.len() as u16);
    write_u32_vec(&mut bytes, central.len() as u32);
    write_u32_vec(&mut bytes, central_offset);
    write_u16_vec(&mut bytes, 0);
    if bytes.len() as u64 > limits.max_package_size_bytes {
        return Err(PluginDiagnostic::new(
            PluginDiagnosticKind::Bounds,
            PluginDiagnosticPhase::Discovery,
            "plugin package exceeds the configured package size bound",
        ));
    }
    if let Some(parent) = output_path
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
    {
        fs::create_dir_all(parent).map_err(|error| {
            PluginDiagnostic::new(
                PluginDiagnosticKind::Io,
                PluginDiagnosticPhase::Discovery,
                format!(
                    "plugin package output directory could not be created: {}",
                    safe_io_error(&error)
                ),
            )
        })?;
    }
    fs::write(output_path, bytes).map_err(|error| {
        PluginDiagnostic::new(
            PluginDiagnosticKind::Io,
            PluginDiagnosticPhase::Discovery,
            format!(
                "plugin package output could not be written: {}",
                safe_io_error(&error)
            ),
        )
    })?;
    Ok(())
}

fn write_u16_vec(out: &mut Vec<u8>, value: u16) {
    out.extend_from_slice(&value.to_le_bytes());
}

fn write_u32_vec(out: &mut Vec<u8>, value: u32) {
    out.extend_from_slice(&value.to_le_bytes());
}

fn validate_manifest(
    manifest: &PluginPackageManifest,
    archive: &StoredArchive,
    label: &str,
    source: PluginSourceKind,
) -> Result<(), PluginDiagnostic> {
    if manifest.schema_version != SUPPORTED_PLUGIN_API_VERSION {
        return Err(PluginDiagnostic::new(
            PluginDiagnosticKind::Api,
            PluginDiagnosticPhase::Manifest,
            "plugin schema/API version is unsupported",
        )
        .with_source(source)
        .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
        .with_package(label));
    }
    if !is_safe_id(&manifest.id) {
        return Err(PluginDiagnostic::new(
            PluginDiagnosticKind::Malformed,
            PluginDiagnosticPhase::Manifest,
            "plugin manifest id is not a safe local id",
        )
        .with_source(source)
        .with_package(label));
    }
    if manifest.name.trim().is_empty() || manifest.version.trim().is_empty() {
        return Err(PluginDiagnostic::new(
            PluginDiagnosticKind::Malformed,
            PluginDiagnosticPhase::Manifest,
            "plugin manifest name and version are required",
        )
        .with_source(source)
        .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
        .with_package(label));
    }
    if let Some(runtime) = &manifest.runtime {
        match runtime.kind.as_str() {
            LEGACY_PLUGIN_RUNTIME_WASM_KIND => {
                return Err(PluginDiagnostic::new(
                    PluginDiagnosticKind::Api,
                    PluginDiagnosticPhase::Manifest,
                    format!(
                        "legacy raw wasm plugin runtime `{LEGACY_PLUGIN_RUNTIME_WASM_KIND}` / `{}` is retired; use `{PLUGIN_RUNTIME_COMPONENT_KIND}`",
                        runtime
                            .abi
                            .as_deref()
                            .unwrap_or(LEGACY_PLUGIN_RUNTIME_WASM_ABI)
                    ),
                )
                .with_source(source)
                .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
                .with_package(label));
            }
            PLUGIN_RUNTIME_COMPONENT_KIND => {
                if runtime.abi.is_some() || runtime.entry.is_some() {
                    return Err(PluginDiagnostic::new(
                        PluginDiagnosticKind::Malformed,
                        PluginDiagnosticPhase::Manifest,
                        "plugin component runtime must not declare raw WASM ABI metadata",
                    )
                    .with_source(source)
                    .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
                    .with_package(label));
                }
                if !matches!(
                    runtime.world.as_deref(),
                    Some(PLUGIN_COMPONENT_TOOL_WORLD) | Some(PLUGIN_COMPONENT_INSTANCE_WORLD)
                ) {
                    return Err(PluginDiagnostic::new(
                        PluginDiagnosticKind::Api,
                        PluginDiagnosticPhase::Manifest,
                        "plugin component world is unsupported",
                    )
                    .with_source(source)
                    .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
                    .with_package(label));
                }
                let Some(component) = runtime.component.as_deref() else {
                    return Err(PluginDiagnostic::new(
                        PluginDiagnosticKind::Missing,
                        PluginDiagnosticPhase::Manifest,
                        "plugin component runtime artifact is required",
                    )
                    .with_source(source)
                    .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
                    .with_package(label));
                };
                validate_manifest_path(component, archive, label, source, &manifest.id)?;
            }
            _ => {
                return Err(PluginDiagnostic::new(
                    PluginDiagnosticKind::Api,
                    PluginDiagnosticPhase::Manifest,
                    "plugin runtime kind is unsupported",
                )
                .with_source(source)
                .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
                .with_package(label));
            }
        }
    }
    let instance_capable = manifest.runtime.as_ref().is_some_and(|runtime| {
        runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND
            && runtime.world.as_deref() == Some(PLUGIN_COMPONENT_INSTANCE_WORLD)
    });
    if (!manifest.services.is_empty() || !manifest.ingresses.is_empty()) && !instance_capable {
        return Err(PluginDiagnostic::new(
            PluginDiagnosticKind::Surface,
            PluginDiagnosticPhase::Manifest,
            "plugin service/ingress declarations require the yoi:plugin/instance@1.0.0 component world",
        )
        .with_source(source)
        .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
        .with_package(label));
    }
    for service in &manifest.services {
        if !is_safe_id(&service.name) {
            return Err(PluginDiagnostic::new(
                PluginDiagnosticKind::Malformed,
                PluginDiagnosticPhase::Manifest,
                "plugin service name is not safe",
            )
            .with_source(source)
            .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
            .with_package(label));
        }
    }
    for ingress in &manifest.ingresses {
        if !is_safe_id(&ingress.name) {
            return Err(PluginDiagnostic::new(
                PluginDiagnosticKind::Malformed,
                PluginDiagnosticPhase::Manifest,
                "plugin ingress name is not safe",
            )
            .with_source(source)
            .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
            .with_package(label));
        }
    }
    for hook in &manifest.hooks {
        if !is_safe_id(&hook.id) {
            return Err(PluginDiagnostic::new(
                PluginDiagnosticKind::Malformed,
                PluginDiagnosticPhase::Manifest,
                "plugin hook id is not safe",
            )
            .with_source(source)
            .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
            .with_package(label));
        }
        validate_manifest_path(&hook.file, archive, label, source, &manifest.id)?;
    }
    Ok(())
}

fn validate_manifest_path(
    value: &str,
    archive: &StoredArchive,
    label: &str,
    source: PluginSourceKind,
    local_id: &str,
) -> Result<(), PluginDiagnostic> {
    let normalized = normalize_archive_path(value).ok_or_else(|| {
        PluginDiagnostic::new(
            PluginDiagnosticKind::Traversal,
            PluginDiagnosticPhase::Manifest,
            "plugin manifest references a path outside the package root",
        )
        .with_source(source)
        .with_identity(SourceQualifiedPluginId::new(source, local_id.to_string()))
        .with_package(label)
    })?;
    if !archive.files.contains_key(&normalized) {
        return Err(PluginDiagnostic::new(
            PluginDiagnosticKind::Missing,
            PluginDiagnosticPhase::Manifest,
            "plugin manifest references a path not present in the package",
        )
        .with_source(source)
        .with_identity(SourceQualifiedPluginId::new(source, local_id.to_string()))
        .with_package(label));
    }
    Ok(())
}

#[derive(Clone, Debug)]
struct StoredArchive {
    files: BTreeMap<String, Vec<u8>>,
}

#[derive(Clone, Debug)]
struct CentralDirectoryEntry {
    name: String,
    compressed_size: u32,
    uncompressed_size: u32,
    local_header_offset: u32,
    compression_method: u16,
    flags: u16,
    external_attributes: u32,
}

fn parse_stored_zip(
    bytes: &[u8],
    label: &str,
    source: PluginSourceKind,
    limits: &PluginDiscoveryLimits,
) -> Result<StoredArchive, PluginDiagnostic> {
    let eocd_offset = find_eocd(bytes).ok_or_else(|| {
        malformed_zip(label, source, "zip end-of-central-directory was not found")
    })?;
    let eocd = &bytes[eocd_offset..];
    let disk_number = read_u16(eocd, 4)
        .ok_or_else(|| malformed_zip(label, source, "zip end record is truncated"))?;
    let central_disk = read_u16(eocd, 6)
        .ok_or_else(|| malformed_zip(label, source, "zip end record is truncated"))?;
    let entry_count = read_u16(eocd, 10)
        .ok_or_else(|| malformed_zip(label, source, "zip end record is truncated"))?
        as usize;
    let central_size = read_u32(eocd, 12)
        .ok_or_else(|| malformed_zip(label, source, "zip end record is truncated"))?
        as usize;
    let central_offset = read_u32(eocd, 16)
        .ok_or_else(|| malformed_zip(label, source, "zip end record is truncated"))?
        as usize;
    if disk_number != 0 || central_disk != 0 {
        return Err(malformed_zip(
            label,
            source,
            "multi-disk zip packages are unsupported",
        ));
    }
    if entry_count > limits.max_entries_per_package {
        return Err(PluginDiagnostic::new(
            PluginDiagnosticKind::Bounds,
            PluginDiagnosticPhase::Discovery,
            "plugin package contains more entries than the configured bound",
        )
        .with_source(source)
        .with_package(label));
    }
    if central_offset
        .checked_add(central_size)
        .is_none_or(|end| end > bytes.len())
    {
        return Err(malformed_zip(
            label,
            source,
            "zip central directory points outside the package",
        ));
    }

    let mut cursor = central_offset;
    let mut entries = Vec::with_capacity(entry_count);
    for _ in 0..entry_count {
        if read_u32(bytes, cursor) != Some(ZIP_CENTRAL_DIRECTORY) {
            return Err(malformed_zip(
                label,
                source,
                "zip central directory entry is malformed",
            ));
        }
        let flags = read_u16(bytes, cursor + 8).ok_or_else(|| {
            malformed_zip(label, source, "zip central directory entry is truncated")
        })?;
        let compression_method = read_u16(bytes, cursor + 10).ok_or_else(|| {
            malformed_zip(label, source, "zip central directory entry is truncated")
        })?;
        let compressed_size = read_u32(bytes, cursor + 20).ok_or_else(|| {
            malformed_zip(label, source, "zip central directory entry is truncated")
        })?;
        let uncompressed_size = read_u32(bytes, cursor + 24).ok_or_else(|| {
            malformed_zip(label, source, "zip central directory entry is truncated")
        })?;
        let name_len = read_u16(bytes, cursor + 28).ok_or_else(|| {
            malformed_zip(label, source, "zip central directory entry is truncated")
        })? as usize;
        let extra_len = read_u16(bytes, cursor + 30).ok_or_else(|| {
            malformed_zip(label, source, "zip central directory entry is truncated")
        })? as usize;
        let comment_len = read_u16(bytes, cursor + 32).ok_or_else(|| {
            malformed_zip(label, source, "zip central directory entry is truncated")
        })? as usize;
        let external_attributes = read_u32(bytes, cursor + 38).ok_or_else(|| {
            malformed_zip(label, source, "zip central directory entry is truncated")
        })?;
        let local_header_offset = read_u32(bytes, cursor + 42).ok_or_else(|| {
            malformed_zip(label, source, "zip central directory entry is truncated")
        })?;
        let name_start = cursor + 46;
        let name_end = name_start
            .checked_add(name_len)
            .ok_or_else(|| malformed_zip(label, source, "zip entry name is too large"))?;
        if name_end > bytes.len() {
            return Err(malformed_zip(
                label,
                source,
                "zip entry name points outside the package",
            ));
        }
        let raw_name = std::str::from_utf8(&bytes[name_start..name_end])
            .map_err(|_| malformed_zip(label, source, "zip entry name is not UTF-8"))?;
        let name = normalize_archive_path(raw_name).ok_or_else(|| {
            PluginDiagnostic::new(
                PluginDiagnosticKind::Traversal,
                PluginDiagnosticPhase::Discovery,
                "plugin package entry path escapes the archive root",
            )
            .with_source(source)
            .with_package(label)
        })?;
        cursor = name_end
            .checked_add(extra_len)
            .and_then(|cursor| cursor.checked_add(comment_len))
            .ok_or_else(|| {
                malformed_zip(label, source, "zip central directory entry is too large")
            })?;
        entries.push(CentralDirectoryEntry {
            name,
            compressed_size,
            uncompressed_size,
            local_header_offset,
            compression_method,
            flags,
            external_attributes,
        });
    }

    let mut files = BTreeMap::new();
    let mut expanded_size = 0u64;
    for entry in entries {
        if entry.flags & ZIP_FLAG_ENCRYPTED != 0 {
            return Err(malformed_zip(
                label,
                source,
                "encrypted zip packages are unsupported",
            ));
        }
        if is_zip_symlink(entry.external_attributes) {
            return Err(PluginDiagnostic::new(
                PluginDiagnosticKind::Traversal,
                PluginDiagnosticPhase::Discovery,
                "plugin package contains a symlink entry",
            )
            .with_source(source)
            .with_package(label));
        }
        if entry.name.ends_with('/') {
            continue;
        }
        if entry.compression_method != ZIP_COMPRESSION_STORED {
            return Err(PluginDiagnostic::new(
                PluginDiagnosticKind::Api,
                PluginDiagnosticPhase::Discovery,
                "plugin package uses an unsupported zip compression method",
            )
            .with_source(source)
            .with_package(label));
        }
        if u64::from(entry.uncompressed_size) > limits.max_file_size_bytes {
            return Err(PluginDiagnostic::new(
                PluginDiagnosticKind::Bounds,
                PluginDiagnosticPhase::Discovery,
                "plugin package entry exceeds the configured per-file bound",
            )
            .with_source(source)
            .with_package(label));
        }
        expanded_size = expanded_size.saturating_add(u64::from(entry.uncompressed_size));
        if expanded_size > limits.max_expanded_size_bytes {
            return Err(PluginDiagnostic::new(
                PluginDiagnosticKind::Bounds,
                PluginDiagnosticPhase::Discovery,
                "plugin package expanded size exceeds the configured bound",
            )
            .with_source(source)
            .with_package(label));
        }
        let data = read_stored_entry(bytes, &entry, label, source)?;
        if data.len() != entry.uncompressed_size as usize
            || data.len() != entry.compressed_size as usize
        {
            return Err(malformed_zip(
                label,
                source,
                "zip stored entry size does not match central directory",
            ));
        }
        if files.insert(entry.name.clone(), data).is_some() {
            return Err(PluginDiagnostic::new(
                PluginDiagnosticKind::Duplicate,
                PluginDiagnosticPhase::Discovery,
                "plugin package contains duplicate normalized entry paths",
            )
            .with_source(source)
            .with_package(label));
        }
    }

    Ok(StoredArchive { files })
}

fn read_stored_entry(
    bytes: &[u8],
    entry: &CentralDirectoryEntry,
    label: &str,
    source: PluginSourceKind,
) -> Result<Vec<u8>, PluginDiagnostic> {
    let cursor = entry.local_header_offset as usize;
    if read_u32(bytes, cursor) != Some(ZIP_LOCAL_FILE) {
        return Err(malformed_zip(
            label,
            source,
            "zip local file header is malformed",
        ));
    }
    let local_flags = read_u16(bytes, cursor + 6)
        .ok_or_else(|| malformed_zip(label, source, "zip local file header is truncated"))?;
    let local_method = read_u16(bytes, cursor + 8)
        .ok_or_else(|| malformed_zip(label, source, "zip local file header is truncated"))?;
    let name_len = read_u16(bytes, cursor + 26)
        .ok_or_else(|| malformed_zip(label, source, "zip local file header is truncated"))?
        as usize;
    let extra_len = read_u16(bytes, cursor + 28)
        .ok_or_else(|| malformed_zip(label, source, "zip local file header is truncated"))?
        as usize;
    if local_flags != entry.flags || local_method != entry.compression_method {
        return Err(malformed_zip(
            label,
            source,
            "zip local header disagrees with central directory",
        ));
    }
    let data_start = cursor
        .checked_add(30)
        .and_then(|cursor| cursor.checked_add(name_len))
        .and_then(|cursor| cursor.checked_add(extra_len))
        .ok_or_else(|| malformed_zip(label, source, "zip local file header is too large"))?;
    let data_end = data_start
        .checked_add(entry.compressed_size as usize)
        .ok_or_else(|| malformed_zip(label, source, "zip entry data is too large"))?;
    if data_end > bytes.len() {
        return Err(malformed_zip(
            label,
            source,
            "zip entry data points outside the package",
        ));
    }
    Ok(bytes[data_start..data_end].to_vec())
}

fn find_eocd(bytes: &[u8]) -> Option<usize> {
    let min_len = 22;
    if bytes.len() < min_len {
        return None;
    }
    let search_start = bytes.len().saturating_sub(65_557);
    (search_start..=bytes.len() - min_len)
        .rev()
        .find(|offset| read_u32(bytes, *offset) == Some(ZIP_EOCD))
}

fn malformed_zip(
    label: &str,
    source: PluginSourceKind,
    message: impl Into<String>,
) -> PluginDiagnostic {
    PluginDiagnostic::new(
        PluginDiagnosticKind::Malformed,
        PluginDiagnosticPhase::Discovery,
        message,
    )
    .with_source(source)
    .with_package(label)
}

fn deterministic_digest(files: &BTreeMap<String, Vec<u8>>) -> String {
    let mut hasher = Sha256::new();
    hasher.update(b"yoi-plugin-package-digest-v1\0");
    for (path, content) in files {
        let mut file_hasher = Sha256::new();
        file_hasher.update(content);
        let file_digest = file_hasher.finalize();
        hasher.update(path.as_bytes());
        hasher.update([0]);
        hasher.update((content.len() as u64).to_be_bytes());
        hasher.update(file_digest);
    }
    format!("sha256:{}", hex_lower(&hasher.finalize()))
}

fn digest_matches(expected: &str, actual: &str) -> bool {
    if let Some(hex) = expected.strip_prefix("sha256:") {
        actual.strip_prefix("sha256:") == Some(hex)
    } else {
        false
    }
}

fn hex_lower(bytes: &[u8]) -> String {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    let mut out = String::with_capacity(bytes.len() * 2);
    for byte in bytes {
        out.push(HEX[(byte >> 4) as usize] as char);
        out.push(HEX[(byte & 0x0f) as usize] as char);
    }
    out
}

fn read_u16(bytes: &[u8], offset: usize) -> Option<u16> {
    let bytes = bytes.get(offset..offset + 2)?;
    Some(u16::from_le_bytes([bytes[0], bytes[1]]))
}

fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> {
    let bytes = bytes.get(offset..offset + 4)?;
    Some(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
}

fn is_zip_symlink(external_attributes: u32) -> bool {
    let unix_mode = external_attributes >> 16;
    unix_mode & ZIP_UNIX_FILE_TYPE_MASK == ZIP_UNIX_SYMLINK_TYPE
}

fn normalize_archive_path(raw: &str) -> Option<String> {
    if raw.is_empty() || raw.contains('\0') || raw.contains('\\') {
        return None;
    }
    if raw.starts_with('/') || raw.starts_with('~') || looks_like_windows_drive(raw) {
        return None;
    }
    let mut parts = Vec::new();
    for part in raw.split('/') {
        if part.is_empty() || part == "." || part == ".." {
            return None;
        }
        parts.push(part);
    }
    if parts.is_empty() {
        return None;
    }
    Some(parts.join("/"))
}

fn looks_like_windows_drive(raw: &str) -> bool {
    let bytes = raw.as_bytes();
    bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic()
}

fn package_label(path: &Path) -> String {
    path.file_name()
        .and_then(|name| name.to_str())
        .filter(|name| !name.is_empty())
        .unwrap_or("<plugin-package>")
        .to_string()
}

fn safe_io_error(error: &io::Error) -> &'static str {
    match error.kind() {
        io::ErrorKind::NotFound => "not found",
        io::ErrorKind::PermissionDenied => "permission denied",
        io::ErrorKind::AlreadyExists => "already exists",
        io::ErrorKind::InvalidData => "invalid data",
        io::ErrorKind::InvalidInput => "invalid input",
        _ => "I/O error",
    }
}

fn safe_toml_parse_message(error: &toml::de::Error) -> String {
    let mut message = String::from("plugin.toml could not be parsed");
    if let Some(span) = error.span() {
        message.push_str(&format!(" near byte span {}..{}", span.start, span.end));
    }
    bounded_message(message)
}

fn bounded_message(message: String) -> String {
    const MAX: usize = 240;
    if message.len() <= MAX {
        return message;
    }
    let end = message
        .char_indices()
        .map(|(index, _)| index)
        .take_while(|index| *index <= MAX)
        .last()
        .unwrap_or(0);
    format!("{}…", &message[..end])
}

fn is_safe_id(value: &str) -> bool {
    !value.is_empty()
        && value.len() <= 128
        && value
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
        && !value.starts_with('.')
        && !value.ends_with('.')
}

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

    #[test]
    fn embedded_rust_component_tool_template_is_valid_package_shape() {
        let paths: BTreeSet<_> = RUST_COMPONENT_TOOL_TEMPLATE
            .iter()
            .map(|file| file.path)
            .collect();
        assert_eq!(
            paths,
            BTreeSet::from([
                "Cargo.toml",
                "src/lib.rs",
                "plugin.toml",
                "plugin.component.wasm",
                "README.md",
            ])
        );
        assert!(
            RUST_COMPONENT_TOOL_TEMPLATE
                .iter()
                .all(|file| !file.path.starts_with('/') && !file.path.contains(".."))
        );

        let manifest_text = RUST_COMPONENT_TOOL_TEMPLATE
            .iter()
            .find(|file| file.path == "plugin.toml")
            .unwrap()
            .contents;
        let manifest: PluginPackageManifest = toml::from_str(manifest_text).unwrap();
        assert_eq!(manifest.schema_version, SUPPORTED_PLUGIN_API_VERSION);
        assert_eq!(
            manifest.runtime.as_ref().unwrap().kind,
            PLUGIN_RUNTIME_COMPONENT_KIND
        );
        assert_eq!(
            manifest.runtime.as_ref().unwrap().world.as_deref(),
            Some(PLUGIN_COMPONENT_TOOL_WORLD)
        );
        assert_eq!(manifest.tools.len(), 1);
    }

    #[test]
    fn request_host_api_manifest_and_grant_parse_with_request_names() {
        let manifest: PluginPackageManifest = toml::from_str(
            r#"
schema_version = 1
id = "example"
name = "Example"
version = "1.0.0"
description = "Example plugin"
surfaces = ["tool"]

[[permissions]]
kind = "host_api"
api = "request"

[[request]]
scheme = "https"
host = "api.example.com"
port = 443
methods = ["GET", "POST"]
path_prefixes = ["/v1/"]
"#,
        )
        .unwrap();
        assert_eq!(
            manifest.permissions,
            vec![PluginPermission::host_api(PluginHostApi::Request)]
        );
        assert_eq!(manifest.request.len(), 1);
        assert_eq!(manifest.request[0].scheme, "https");
        assert_eq!(manifest.request[0].host, "api.example.com");
        assert_eq!(manifest.request[0].port, Some(443));
        assert_eq!(
            manifest.request[0].label(),
            "https://api.example.com:443 GET,POST /v1/"
        );

        let grants: PluginGrantConfig = toml::from_str(
            r#"
permissions = [{ kind = "host_api", api = "request" }]

[[request]]
scheme = "http"
host = "localhost"
port = 8080
methods = ["GET"]
path_prefixes = ["/health"]
"#,
        )
        .unwrap();
        assert_eq!(
            grants.permissions,
            vec![PluginPermission::host_api(PluginHostApi::Request)]
        );
        assert_eq!(grants.request[0].scheme, "http");
        assert_eq!(grants.request[0].host, "localhost");
    }

    #[test]
    fn legacy_https_request_names_are_not_accepted() {
        let manifest_error = toml::from_str::<PluginPackageManifest>(
            r#"
schema_version = 1
id = "example"
name = "Example"
version = "1.0.0"
description = "Example plugin"
surfaces = ["tool"]

[[permissions]]
kind = "host_api"
api = "https"
"#,
        )
        .expect_err(concat!(
            "host_api.",
            "https",
            " must not be an active alias"
        ));
        assert!(manifest_error.to_string().contains("unknown variant"));

        let grant_error = toml::from_str::<PluginGrantConfig>(
            r#"
permissions = [{ kind = "host_api", api = "request" }]

[[https]]
host = "api.example.com"
methods = ["GET"]
"#,
        )
        .expect_err(concat!("grants.", "https", " must not be an active alias"));
        assert!(grant_error.to_string().contains("unknown field"));
    }

    #[test]
    fn embedded_rust_component_instance_template_is_valid_package_shape() {
        let paths: BTreeSet<_> = RUST_COMPONENT_INSTANCE_TEMPLATE
            .iter()
            .map(|file| file.path)
            .collect();
        assert_eq!(
            paths,
            BTreeSet::from([
                "Cargo.toml",
                "src/lib.rs",
                "plugin.toml",
                "plugin.component.wasm",
                "README.md"
            ])
        );
        assert!(
            RUST_COMPONENT_INSTANCE_TEMPLATE
                .iter()
                .all(|file| !file.path.starts_with('/') && !file.path.contains(".."))
        );
        let manifest_text = RUST_COMPONENT_INSTANCE_TEMPLATE
            .iter()
            .find(|file| file.path == "plugin.toml")
            .unwrap()
            .contents;
        let manifest: PluginPackageManifest = toml::from_str(manifest_text).unwrap();
        assert_eq!(
            manifest.runtime.as_ref().unwrap().world.as_deref(),
            Some(PLUGIN_COMPONENT_INSTANCE_WORLD)
        );
        assert_eq!(manifest.services.len(), 1);
        assert_eq!(manifest.ingresses.len(), 1);
        assert!(
            manifest
                .declared_surfaces()
                .contains(&PluginSurface::Service)
        );
        assert!(
            manifest
                .declared_surfaces()
                .contains(&PluginSurface::Ingress)
        );
    }

    #[test]
    fn service_ingress_require_instance_component_world() {
        let manifest: PluginPackageManifest = toml::from_str(
            r#"
schema_version = 1
id = "bad.service"
name = "Bad Service"
version = "0.1.0"
surfaces = ["service"]
permissions = [{ kind = "surface", surface = "service" }, { kind = "service", name = "svc" }]

[runtime]
kind = "wasm-component"
world = "yoi:plugin/tool@1.0.0"
component = "plugin.component.wasm"

[[services]]
name = "svc"
description = "bad"
"#,
        )
        .unwrap();
        let archive = StoredArchive {
            files: BTreeMap::from([("plugin.component.wasm".to_string(), b"placeholder".to_vec())]),
        };
        let err = validate_manifest(
            &manifest,
            &archive,
            "bad.service",
            PluginSourceKind::Project,
        )
        .unwrap_err();
        assert!(err.message.contains("service/ingress"));
    }

    #[test]
    fn legacy_raw_wasm_runtime_manifest_is_rejected() {
        let temp = TempDir::new().unwrap();
        let workspace = temp.path().join("workspace");
        let plugins = workspace.join(".yoi/plugins");
        fs::create_dir_all(&plugins).unwrap();
        let manifest = r#"
schema_version = 1
id = "legacy"
name = "Legacy"
version = "0.1.0"
surfaces = ["tool"]

[runtime]
kind = "wasm"
entry = "plugin.wasm"
abi = "yoi-plugin-wasm-1"

[[tools]]
name = "Echo"
description = "legacy"
input_schema = { type = "object" }
"#;
        write_stored_zip(
            &plugins.join("legacy.yoi-plugin"),
            &[
                ("plugin.toml", manifest.as_bytes().to_vec(), 0),
                ("plugin.wasm", b"not wasm".to_vec(), 0),
            ],
        );

        let report = discover_plugins(&PluginDiscoveryOptions::new(&workspace));

        assert!(report.packages.is_empty());
        let diagnostic = report
            .diagnostics
            .iter()
            .find(|diag| diag.kind == PluginDiagnosticKind::Api)
            .unwrap();
        assert_eq!(diagnostic.phase, PluginDiagnosticPhase::Manifest);
        assert_eq!(diagnostic.identity.as_deref(), Some("project:legacy"));
        assert!(diagnostic.message.contains("legacy raw wasm"));
        assert!(diagnostic.message.contains(PLUGIN_RUNTIME_COMPONENT_KIND));
    }

    #[test]
    fn discovers_valid_user_and_workspace_packages() {
        let temp = TempDir::new().unwrap();
        let workspace = temp.path().join("workspace");
        let user_data = temp.path().join("data");
        fs::create_dir_all(workspace.join(".yoi/plugins")).unwrap();
        fs::create_dir_all(user_data.join("yoi/plugins")).unwrap();
        write_plugin(
            &user_data.join("yoi/plugins/user-one.yoi-plugin"),
            "user_one",
            &[PluginSurface::Hook],
            &[("hooks/user.md", b"hello".as_slice())],
        );
        write_plugin(
            &workspace.join(".yoi/plugins/project-one.yoi-plugin"),
            "project_one",
            &[PluginSurface::Hook],
            &[("hooks/project.md", b"hello".as_slice())],
        );

        let report = discover_plugins(
            &PluginDiscoveryOptions::new(&workspace).with_user_data_home(&user_data),
        );

        assert_eq!(report.diagnostics, vec![]);
        let identities: BTreeSet<_> = report
            .packages
            .iter()
            .map(|package| package.identity.to_string())
            .collect();
        assert_eq!(
            identities,
            BTreeSet::from([
                "project:project_one".to_string(),
                "user:user_one".to_string()
            ])
        );
        assert!(
            report
                .packages
                .iter()
                .all(|package| package.digest.starts_with("sha256:"))
        );
    }

    #[test]
    fn discovery_only_does_not_activate_packages() {
        let (report, config) = fixture_with_enabled_plugin(false);

        let resolution = resolve_enabled_plugins(&config, &report);

        assert_eq!(report.packages.len(), 1);
        assert!(resolution.resolved.is_empty());
        assert!(resolution.diagnostics.is_empty());
    }

    #[test]
    fn explicit_enablement_resolves_typed_metadata() {
        let (report, config) = fixture_with_enabled_plugin(true);

        let resolution = resolve_enabled_plugins(&config, &report);

        assert_eq!(resolution.diagnostics, vec![]);
        assert_eq!(resolution.resolved.len(), 1);
        let resolved = &resolution.resolved[0];
        assert_eq!(resolved.identity.to_string(), "project:example");
        assert_eq!(resolved.enabled_surfaces, vec![PluginSurface::Hook]);
        assert!(resolved.grants.is_empty());
        assert_eq!(resolved.manifest.id, "example");
    }

    #[test]
    fn duplicate_and_unqualified_ids_fail_closed() {
        let temp = TempDir::new().unwrap();
        let workspace = temp.path().join("workspace");
        let plugins = workspace.join(".yoi/plugins");
        fs::create_dir_all(&plugins).unwrap();
        write_plugin(
            &plugins.join("one.yoi-plugin"),
            "dup",
            &[PluginSurface::Hook],
            &[("hooks/a.md", b"a")],
        );
        write_plugin(
            &plugins.join("two.yoi-plugin"),
            "dup",
            &[PluginSurface::Hook],
            &[("hooks/a.md", b"a")],
        );
        let report = discover_plugins(&PluginDiscoveryOptions::new(&workspace));

        assert!(
            report
                .diagnostics
                .iter()
                .any(|diag| diag.kind == PluginDiagnosticKind::Duplicate)
        );

        let resolution = resolve_enabled_plugins(
            &PluginConfig {
                enabled: vec![
                    PluginEnablementConfig {
                        id: "project:dup".to_string(),
                        ..PluginEnablementConfig::default()
                    },
                    PluginEnablementConfig {
                        id: "dup".to_string(),
                        ..PluginEnablementConfig::default()
                    },
                ],
                ..PluginConfig::default()
            },
            &report,
        );

        assert!(resolution.resolved.is_empty());
        assert!(
            resolution
                .diagnostics
                .iter()
                .any(|diag| diag.kind == PluginDiagnosticKind::Duplicate)
        );
        assert!(
            resolution
                .diagnostics
                .iter()
                .any(|diag| diag.kind == PluginDiagnosticKind::Ambiguous)
        );
    }

    #[test]
    fn digest_mismatch_fails_closed() {
        let (report, _) = fixture_with_enabled_plugin(false);
        let resolution = resolve_enabled_plugins(
            &PluginConfig {
                enabled: vec![PluginEnablementConfig {
                    id: "project:example".to_string(),
                    digest: Some("sha256:0000".to_string()),
                    ..PluginEnablementConfig::default()
                }],
                ..PluginConfig::default()
            },
            &report,
        );

        assert!(resolution.resolved.is_empty());
        assert_eq!(resolution.diagnostics[0].kind, PluginDiagnosticKind::Digest);
    }

    #[test]
    fn exact_version_mismatch_fails_closed_with_distinct_diagnostic() {
        let (report, _) = fixture_with_enabled_plugin(false);
        let resolution = resolve_enabled_plugins(
            &PluginConfig {
                enabled: vec![PluginEnablementConfig {
                    id: "project:example".to_string(),
                    version: Some(PluginExactVersion("9.9.9".to_string())),
                    ..PluginEnablementConfig::default()
                }],
                ..PluginConfig::default()
            },
            &report,
        );

        assert!(resolution.resolved.is_empty());
        assert_eq!(
            resolution.diagnostics[0].kind,
            PluginDiagnosticKind::Version
        );
        assert_eq!(
            resolution.diagnostics[0].phase,
            PluginDiagnosticPhase::Resolution
        );
        assert!(
            !resolution
                .diagnostics
                .iter()
                .any(|diag| diag.kind == PluginDiagnosticKind::Api)
        );
    }

    #[test]
    fn resolved_plan_pins_unpinned_enablement_for_restore() {
        let temp = TempDir::new().unwrap();
        let workspace = temp.path().join("workspace");
        let plugins = workspace.join(".yoi/plugins");
        fs::create_dir_all(&plugins).unwrap();
        let package = plugins.join("example.yoi-plugin");
        write_plugin_version(
            &package,
            "example",
            "0.1.0",
            &[PluginSurface::Hook],
            &[("hooks/example.md", b"v1")],
        );
        let options = PluginDiscoveryOptions::new(&workspace);
        let config = PluginConfig {
            enabled: vec![PluginEnablementConfig {
                id: "project:example".to_string(),
                ..PluginEnablementConfig::default()
            }],
            ..PluginConfig::default()
        };

        let startup_snapshot = resolve_plugin_config_for_startup(&config, &options);
        assert_eq!(startup_snapshot.resolved.len(), 1);
        let restored_digest = startup_snapshot.resolved[0].digest.clone();
        assert_eq!(startup_snapshot.resolved[0].version, "0.1.0");

        write_plugin_version(
            &package,
            "example",
            "0.2.0",
            &[PluginSurface::Hook],
            &[("hooks/example.md", b"v2")],
        );
        let fresh_snapshot = resolve_plugin_config_for_startup(&config, &options);
        assert_ne!(fresh_snapshot.resolved[0].digest, restored_digest);
        assert_eq!(fresh_snapshot.resolved[0].version, "0.2.0");

        let restored_snapshot = resolve_plugin_config_for_startup(&startup_snapshot, &options);
        assert_eq!(restored_snapshot.resolved[0].digest, restored_digest);
        assert_eq!(restored_snapshot.resolved[0].version, "0.1.0");
    }

    #[test]
    fn currently_documented_manifest_shape_is_accepted() {
        let temp = TempDir::new().unwrap();
        let workspace = temp.path().join("workspace");
        let plugins = workspace.join(".yoi/plugins");
        fs::create_dir_all(&plugins).unwrap();
        let manifest = r#"
schema_version = 1
id = "example.summarizer"
name = "Example Summarizer"
version = "0.1.0"
description = "Adds a custom summary command."
surfaces = ["hook"]

[[hooks]]
id = "summary"
file = "hooks/summary.md"
"#;
        write_stored_zip(
            &plugins.join("documented.yoi-plugin"),
            &[
                ("plugin.toml", manifest.as_bytes().to_vec(), 0),
                ("hooks/summary.md", b"summarize".to_vec(), 0),
            ],
        );

        let report = discover_plugins(&PluginDiscoveryOptions::new(&workspace));

        assert_eq!(report.diagnostics, vec![]);
        assert_eq!(report.packages.len(), 1);
        assert_eq!(
            report.packages[0].identity.to_string(),
            "project:example.summarizer"
        );
    }

    #[test]
    fn package_manifest_tool_surface_shape_is_accepted() {
        let manifest: PluginPackageManifest = toml::from_str(
            r#"
schema_version = 1
id = "example.tool"
name = "Example Tool"
version = "0.1.0"

[[tools]]
name = "ExampleTool"
description = "Runs a package-defined tool."
input_schema = { type = "object", properties = { query = { type = "string" } }, required = ["query"], additionalProperties = false }
"#,
        )
        .unwrap();

        assert_eq!(manifest.tools.len(), 1);
        assert!(manifest.declared_surfaces().contains(&PluginSurface::Tool));
        assert_eq!(manifest.tools[0].name, "ExampleTool");
        assert_eq!(manifest.tools[0].input_schema["type"], "object");
    }

    #[test]
    fn malformed_manifest_multibyte_diagnostic_is_bounded_and_redacted() {
        let temp = TempDir::new().unwrap();
        let workspace = temp.path().join("workspace");
        let plugins = workspace.join(".yoi/plugins");
        fs::create_dir_all(&plugins).unwrap();
        let malformed = format!("schema_version = [\n# {}", "機密".repeat(200));
        write_stored_zip(
            &plugins.join("bad-multibyte.yoi-plugin"),
            &[("plugin.toml", malformed.into_bytes(), 0)],
        );

        let report = discover_plugins(&PluginDiscoveryOptions::new(&workspace));

        assert!(report.packages.is_empty());
        let diagnostic = report
            .diagnostics
            .iter()
            .find(|diag| diag.kind == PluginDiagnosticKind::Malformed)
            .unwrap();
        assert!(diagnostic.message.len() <= 241);
        assert!(!diagnostic.message.contains("機密"));
    }

    #[test]
    fn traversal_root_escape_in_archive_fails_closed() {
        let temp = TempDir::new().unwrap();
        let workspace = temp.path().join("workspace");
        let plugins = workspace.join(".yoi/plugins");
        fs::create_dir_all(&plugins).unwrap();
        write_stored_zip(
            &plugins.join("escape.yoi-plugin"),
            &[
                (
                    "plugin.toml",
                    manifest("escape", "0.1.0", &[PluginSurface::Hook]).into_bytes(),
                    0,
                ),
                ("../evil", b"x".to_vec(), 0),
            ],
        );

        let report = discover_plugins(&PluginDiscoveryOptions::new(&workspace));

        assert!(report.packages.is_empty());
        assert_eq!(report.diagnostics[0].kind, PluginDiagnosticKind::Traversal);
    }

    #[cfg(unix)]
    #[test]
    fn package_symlink_store_escape_fails_closed() {
        use std::os::unix::fs::symlink;

        let temp = TempDir::new().unwrap();
        let workspace = temp.path().join("workspace");
        let plugins = workspace.join(".yoi/plugins");
        let outside = temp.path().join("outside");
        fs::create_dir_all(&plugins).unwrap();
        write_plugin(
            &outside,
            "outside",
            &[PluginSurface::Hook],
            &[("hooks/a.md", b"a")],
        );
        symlink(&outside, plugins.join("outside.yoi-plugin")).unwrap();

        let report = discover_plugins(&PluginDiscoveryOptions::new(&workspace));

        assert!(report.packages.is_empty());
        assert_eq!(report.diagnostics[0].kind, PluginDiagnosticKind::Traversal);
    }

    #[test]
    fn unsupported_api_and_malformed_manifest_fail_closed() {
        let temp = TempDir::new().unwrap();
        let workspace = temp.path().join("workspace");
        let plugins = workspace.join(".yoi/plugins");
        fs::create_dir_all(&plugins).unwrap();
        write_stored_zip(
            &plugins.join("bad-schema.yoi-plugin"),
            &[(
                "plugin.toml",
                manifest_with_schema("bad_schema", "0.1.0", 999).into_bytes(),
                0,
            )],
        );
        write_stored_zip(
            &plugins.join("bad-toml.yoi-plugin"),
            &[("plugin.toml", b"not = [valid".to_vec(), 0)],
        );

        let report = discover_plugins(&PluginDiscoveryOptions::new(&workspace));

        assert!(report.packages.is_empty());
        assert!(
            report
                .diagnostics
                .iter()
                .any(|diag| diag.kind == PluginDiagnosticKind::Api)
        );
        assert!(
            report
                .diagnostics
                .iter()
                .any(|diag| diag.kind == PluginDiagnosticKind::Malformed)
        );
    }

    #[test]
    fn typed_permission_grant_binding_resolves_only_exact_package_identity() {
        let (report, _) = fixture_with_enabled_plugin(false);
        let digest = report.packages[0].digest.clone();
        let exact_grants = PluginGrantConfig {
            id: Some("project:example".to_string()),
            version: Some(PluginExactVersion("0.1.0".to_string())),
            digest: Some(digest.clone()),
            permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
            request: Vec::new(),
            websocket: Vec::new(),
            fs: Vec::new(),
        };
        let resolution = resolve_enabled_plugins(
            &PluginConfig {
                enabled: vec![PluginEnablementConfig {
                    id: "project:example".to_string(),
                    grants: exact_grants,
                    ..PluginEnablementConfig::default()
                }],
                ..PluginConfig::default()
            },
            &report,
        );
        assert!(
            resolution.diagnostics.is_empty(),
            "{:#?}",
            resolution.diagnostics
        );
        assert_eq!(resolution.resolved.len(), 1);

        for grants in [
            PluginGrantConfig {
                id: Some("project:other".to_string()),
                version: Some(PluginExactVersion("0.1.0".to_string())),
                digest: Some(digest.clone()),
                permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
                request: Vec::new(),
                websocket: Vec::new(),
                fs: Vec::new(),
            },
            PluginGrantConfig {
                id: Some("project:example".to_string()),
                version: Some(PluginExactVersion("0.1.1".to_string())),
                digest: Some(digest.clone()),
                permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
                request: Vec::new(),
                websocket: Vec::new(),
                fs: Vec::new(),
            },
            PluginGrantConfig {
                id: Some("project:example".to_string()),
                version: Some(PluginExactVersion("0.1.0".to_string())),
                digest: Some("sha256:unrelated".to_string()),
                permissions: vec![PluginPermission::surface(PluginSurface::Hook)],
                request: Vec::new(),
                websocket: Vec::new(),
                fs: Vec::new(),
            },
        ] {
            let resolution = resolve_enabled_plugins(
                &PluginConfig {
                    enabled: vec![PluginEnablementConfig {
                        id: "project:example".to_string(),
                        grants,
                        ..PluginEnablementConfig::default()
                    }],
                    ..PluginConfig::default()
                },
                &report,
            );
            assert!(resolution.resolved.is_empty());
            assert!(
                resolution
                    .diagnostics
                    .iter()
                    .any(|diag| diag.kind == PluginDiagnosticKind::Grant),
                "{:#?}",
                resolution.diagnostics
            );
        }
    }

    #[test]
    fn unknown_permission_kind_fails_closed_at_manifest_parse_boundary() {
        let error = toml::from_str::<PluginPackageManifest>(
            r#"schema_version = 1
id = "example"
name = "Example"
version = "0.1.0"

[[permissions]]
kind = "ambient_shell"
"#,
        )
        .unwrap_err();
        assert!(error.to_string().contains("ambient_shell"), "{error}");
    }

    #[test]
    fn surface_and_grant_failures_do_not_resolve() {
        let (report, _) = fixture_with_enabled_plugin(false);
        let resolution = resolve_enabled_plugins(
            &PluginConfig {
                enabled: vec![
                    PluginEnablementConfig {
                        id: "project:example".to_string(),
                        surfaces: vec![PluginSurface::Tool],
                        ..PluginEnablementConfig::default()
                    },
                    PluginEnablementConfig {
                        id: "project:example".to_string(),
                        grants: PluginGrantConfig {
                            permissions: vec![PluginPermission::surface(PluginSurface::Tool)],
                            ..PluginGrantConfig::default()
                        },
                        ..PluginEnablementConfig::default()
                    },
                ],
                ..PluginConfig::default()
            },
            &report,
        );

        assert!(resolution.resolved.is_empty());
        assert!(
            resolution
                .diagnostics
                .iter()
                .any(|diag| diag.kind == PluginDiagnosticKind::Surface)
        );
        assert!(
            resolution
                .diagnostics
                .iter()
                .any(|diag| diag.kind == PluginDiagnosticKind::Grant)
        );
    }

    fn fixture_with_enabled_plugin(enabled: bool) -> (PluginDiscoveryReport, PluginConfig) {
        let temp = TempDir::new().unwrap();
        let workspace = temp.path().join("workspace");
        let plugins = workspace.join(".yoi/plugins");
        fs::create_dir_all(&plugins).unwrap();
        write_plugin(
            &plugins.join("example.yoi-plugin"),
            "example",
            &[PluginSurface::Hook],
            &[("hooks/example.md", b"hello")],
        );
        let report = discover_plugins(&PluginDiscoveryOptions::new(&workspace));
        let config = PluginConfig {
            enabled: if enabled {
                vec![PluginEnablementConfig {
                    id: "project:example".to_string(),
                    ..PluginEnablementConfig::default()
                }]
            } else {
                vec![]
            },
            ..PluginConfig::default()
        };
        (report, config)
    }

    fn write_plugin(
        path: &Path,
        id: &str,
        surfaces: &[PluginSurface],
        extra_files: &[(&str, &[u8])],
    ) {
        write_plugin_version(path, id, "0.1.0", surfaces, extra_files);
    }

    fn write_plugin_version(
        path: &Path,
        id: &str,
        version: &str,
        surfaces: &[PluginSurface],
        extra_files: &[(&str, &[u8])],
    ) {
        let mut entries = vec![(
            "plugin.toml",
            manifest(id, version, surfaces).into_bytes(),
            0,
        )];
        if surfaces.contains(&PluginSurface::Hook)
            && !extra_files
                .iter()
                .any(|(path, _)| *path == "hooks/example.md")
        {
            entries.push(("hooks/example.md", b"hook".to_vec(), 0));
        }
        entries.extend(
            extra_files
                .iter()
                .map(|(path, content)| (*path, content.to_vec(), 0)),
        );
        write_stored_zip(path, &entries);
    }

    fn manifest(id: &str, version: &str, surfaces: &[PluginSurface]) -> String {
        let mut manifest = manifest_with_schema(id, version, SUPPORTED_PLUGIN_API_VERSION);
        if surfaces.contains(&PluginSurface::Hook) {
            manifest.push_str("\n[[hooks]]\nid = \"startup\"\nfile = \"hooks/example.md\"\n");
        }
        manifest
    }

    fn manifest_with_schema(id: &str, version: &str, schema_version: u32) -> String {
        format!(
            "schema_version = {schema_version}\nid = \"{id}\"\nname = \"Example\"\nversion = \"{version}\"\n"
        )
    }

    fn write_stored_zip(path: &Path, entries: &[(&str, Vec<u8>, u32)]) {
        let mut bytes = Vec::new();
        let mut central = Vec::new();
        for (name, content, external_attributes) in entries {
            let local_offset = bytes.len() as u32;
            write_u32(&mut bytes, ZIP_LOCAL_FILE);
            write_u16(&mut bytes, 20);
            write_u16(&mut bytes, 0x0800);
            write_u16(&mut bytes, ZIP_COMPRESSION_STORED);
            write_u16(&mut bytes, 0);
            write_u16(&mut bytes, 0);
            write_u32(&mut bytes, 0);
            write_u32(&mut bytes, content.len() as u32);
            write_u32(&mut bytes, content.len() as u32);
            write_u16(&mut bytes, name.len() as u16);
            write_u16(&mut bytes, 0);
            bytes.extend_from_slice(name.as_bytes());
            bytes.extend_from_slice(content);

            write_u32(&mut central, ZIP_CENTRAL_DIRECTORY);
            write_u16(&mut central, 20);
            write_u16(&mut central, 20);
            write_u16(&mut central, 0x0800);
            write_u16(&mut central, ZIP_COMPRESSION_STORED);
            write_u16(&mut central, 0);
            write_u16(&mut central, 0);
            write_u32(&mut central, 0);
            write_u32(&mut central, content.len() as u32);
            write_u32(&mut central, content.len() as u32);
            write_u16(&mut central, name.len() as u16);
            write_u16(&mut central, 0);
            write_u16(&mut central, 0);
            write_u16(&mut central, 0);
            write_u16(&mut central, 0);
            write_u32(&mut central, *external_attributes);
            write_u32(&mut central, local_offset);
            central.extend_from_slice(name.as_bytes());
        }
        let central_offset = bytes.len() as u32;
        bytes.extend_from_slice(&central);
        write_u32(&mut bytes, ZIP_EOCD);
        write_u16(&mut bytes, 0);
        write_u16(&mut bytes, 0);
        write_u16(&mut bytes, entries.len() as u16);
        write_u16(&mut bytes, entries.len() as u16);
        write_u32(&mut bytes, central.len() as u32);
        write_u32(&mut bytes, central_offset);
        write_u16(&mut bytes, 0);
        fs::write(path, bytes).unwrap();
    }

    fn write_u16(out: &mut Vec<u8>, value: u16) {
        out.extend_from_slice(&value.to_le_bytes());
    }

    fn write_u32(out: &mut Vec<u8>, value: u32) {
        out.extend_from_slice(&value.to_le_bytes());
    }

    #[test]
    fn websocket_manifest_and_grants_parse_independently_from_request() {
        let manifest: PluginPackageManifest = toml::from_str(
            r#"
schema_version = 1
id = "project:example"
name = "example"
version = "1.0.0"
surfaces = ["tool"]

[runtime]
kind = "wasm-component"
component = "plugin.component.wasm"
world = "yoi:plugin/tool@1.0.0"

[[permissions]]
kind = "host_api"
api = "request"

[[permissions]]
kind = "host_api"
api = "websocket"

[[request]]
scheme = "https"
host = "api.example.com"
methods = ["GET"]
path_prefixes = ["/v1"]

[[websocket]]
scheme = "wss"
host = "gateway.example.com"
path_prefixes = ["/gateway"]
"#,
        )
        .unwrap();
        assert_eq!(manifest.request.len(), 1);
        assert_eq!(manifest.websocket.len(), 1);
        assert_eq!(
            manifest.request[0].label(),
            "https://api.example.com GET /v1"
        );
        assert_eq!(
            manifest.websocket[0].label(),
            "wss://gateway.example.com /gateway"
        );
        assert_eq!(
            manifest.permissions[1],
            PluginPermission::host_api(PluginHostApi::WebSocket)
        );

        let grants: PluginGrantConfig = toml::from_str(
            r#"
[[request]]
scheme = "https"
host = "api.example.com"
methods = ["GET"]
path_prefixes = ["/v1"]

[[websocket]]
scheme = "wss"
host = "gateway.example.com"
path_prefixes = ["/gateway"]
"#,
        )
        .unwrap();
        assert_eq!(grants.request.len(), 1);
        assert_eq!(grants.websocket.len(), 1);
        assert!(!grants.is_empty());
    }
}
