//! Agent permission model (cadrage « permissions des agents », lot LP0).
//!
//! A **pure, declarative** model of what an agent may do on its project root,
//! plus the single pure function that **resolves** a project-level and an
//! agent-level [`PermissionSet`] into the normalised [`EffectivePermissions`]
//! consumed by the (later) projectors that translate it to a concrete CLI
//! permission config (Claude `settings.json`, Codex sandbox…).
//!
//! This module is **pure** (ARCHITECTURE dependency rule): no `tokio`, no
//! `std::fs`, no `std::process`. It owns the value objects, their validating
//! constructors and invariants, and the [`resolve`] function. The store, the
//! OS-sandbox application and the per-CLI projection are **out of scope** here
//! (lots LP1/LP2/LP3) and live in `application`/`infrastructure`.
//!
//! ## The product invariant of [`resolve`]
//!
//! When **nothing** is posed (neither a project nor an agent set), [`resolve`]
//! returns [`None`]. A `None` result means «we project nothing» — the AI engine
//! keeps its own native prompting at run time. Any `Some` result means IdeA has
//! an explicit policy to project. This distinction is load-bearing: it is what
//! keeps an unconfigured project on the CLI's native behaviour instead of
//! silently locking it down.
//!
//! ## Deny-wins
//!
//! For a given *capability + concrete target*, a matching `Deny` (whether it
//! comes from the project set or the agent set, at the rule level or a command
//! level) **always** wins over any `Allow`. It is never overridable by a more
//! specific allow. The agent set may only **tighten** the inherited policy
//! (add denies, raise the fallback posture); it cannot loosen a project deny.

use serde::{Deserialize, Serialize};

use crate::ids::AgentId;
use crate::validation::relative_safe;

/// Current schema version for `.ideai/permissions.json`.
pub const PERMISSIONS_VERSION: u32 = 1;

/// What an agent may attempt. Closed set.
///
/// [`Capability::ExecuteBash`] is the **only** capability that carries
/// [`CommandRule`]s; the three file capabilities ([`Capability::Read`],
/// [`Capability::Write`], [`Capability::Delete`]) carry a [`PathScope`] instead.
/// This split is enforced by [`PermissionRule::new`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Capability {
    /// Read a file under the project root.
    Read,
    /// Write (create/modify) a file under the project root.
    Write,
    /// Delete a file under the project root.
    Delete,
    /// Run a shell command.
    ExecuteBash,
}

impl Capability {
    /// Whether this capability is one of the three **file** capabilities
    /// (scoped by paths, never by commands).
    #[must_use]
    pub const fn is_file(self) -> bool {
        matches!(self, Self::Read | Self::Write | Self::Delete)
    }

    /// Whether this capability is [`Capability::ExecuteBash`] (scoped by
    /// commands, never by paths).
    #[must_use]
    pub const fn is_bash(self) -> bool {
        matches!(self, Self::ExecuteBash)
    }
}

/// The verdict a [`PermissionRule`] or [`CommandRule`] carries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Effect {
    /// Grant the capability for the matched target.
    Allow,
    /// Forbid the capability for the matched target. Wins over any `Allow`.
    Deny,
}

/// The default stance applied when **no** rule yields a verdict for a target.
///
/// Unlike [`Effect`], a posture has a third, neutral value [`Posture::Ask`]:
/// «no decision posed, let the engine prompt». Postures are ordered by
/// restrictiveness (`Allow < Ask < Deny`) so an agent set can only **tighten**
/// an inherited fallback (cf. [`Posture::tighten`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Posture {
    /// Defer to the engine's native prompting.
    Ask,
    /// Allow by default.
    Allow,
    /// Deny by default.
    Deny,
}

impl Posture {
    /// Restrictiveness rank used by [`Posture::tighten`]: `Allow < Ask < Deny`.
    #[must_use]
    const fn restrictiveness(self) -> u8 {
        match self {
            Self::Allow => 0,
            Self::Ask => 1,
            Self::Deny => 2,
        }
    }

    /// Returns the **more restrictive** of `self` and `other`.
    ///
    /// Used to merge the project and agent fallbacks: the agent may resserrer
    /// (raise restrictiveness) but never loosen the project's stance.
    #[must_use]
    pub fn tighten(self, other: Self) -> Self {
        if other.restrictiveness() >= self.restrictiveness() {
            other
        } else {
            self
        }
    }
}

/// A single glob pattern, validated **non-empty and compilable**.
///
/// The supported syntax (pure, no external glob crate) is the usual shell glob:
/// `*` (any run of non-`/` chars), `**` (any run including `/`), `?` (one
/// non-`/` char), `[...]` character classes (with ranges `a-z` and negation via
/// a leading `!`/`^`), everything else literal. Compilability here means the
/// pattern is non-empty and every `[` opens a terminated character class.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Glob(String);

impl Glob {
    /// Builds a validated glob.
    ///
    /// # Errors
    /// - [`PermissionError::EmptyGlob`] if `pattern` is empty.
    /// - [`PermissionError::InvalidGlob`] if a character class is unterminated.
    pub fn new(pattern: impl Into<String>) -> Result<Self, PermissionError> {
        let pattern = pattern.into();
        if pattern.is_empty() {
            return Err(PermissionError::EmptyGlob);
        }
        validate_glob(&pattern).map_err(|reason| PermissionError::InvalidGlob {
            pattern: pattern.clone(),
            reason,
        })?;
        Ok(Self(pattern))
    }

    /// The raw pattern.
    #[must_use]
    pub fn pattern(&self) -> &str {
        &self.0
    }

    /// Whether this glob matches `text` in full.
    #[must_use]
    pub fn matches(&self, text: &str) -> bool {
        let pat: Vec<char> = self.0.chars().collect();
        let txt: Vec<char> = text.chars().collect();
        glob_match(&pat, &txt)
    }
}

/// A set of globs, **all relative to the project root**.
///
/// Every glob must be relative, must not escape the root via `..`, and must not
/// be an absolute path — the same guard [`crate::profile::ContextInjection`]
/// applies to its convention-file targets. An empty [`PathScope`] matches
/// nothing.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PathScope {
    globs: Vec<Glob>,
}

impl PathScope {
    /// Builds a validated path scope.
    ///
    /// # Errors
    /// - any error from [`Glob::new`] (empty / uncompilable pattern);
    /// - [`PermissionError::PathNotRelativeSafe`] if a pattern is absolute or
    ///   contains a `..` traversal component.
    pub fn new(patterns: impl IntoIterator<Item = String>) -> Result<Self, PermissionError> {
        let mut globs = Vec::new();
        for pattern in patterns {
            // Path-safety is a PathScope concern (commands are not paths), so we
            // check it here rather than inside `Glob`.
            relative_safe(&pattern).map_err(|_| PermissionError::PathNotRelativeSafe {
                pattern: pattern.clone(),
            })?;
            globs.push(Glob::new(pattern)?);
        }
        Ok(Self { globs })
    }

    /// An empty scope (matches nothing).
    #[must_use]
    pub fn empty() -> Self {
        Self { globs: Vec::new() }
    }

    /// The globs of this scope.
    #[must_use]
    pub fn globs(&self) -> &[Glob] {
        &self.globs
    }

    /// Whether the scope is empty.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.globs.is_empty()
    }

    /// Whether any glob matches `path`.
    #[must_use]
    pub fn matches(&self, path: &str) -> bool {
        self.globs.iter().any(|g| g.matches(path))
    }
}

/// How a [`CommandRule`] matches a candidate shell command.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
pub enum CommandMatcher {
    /// Matches the command verbatim.
    Exact(String),
    /// Matches when the command starts with this prefix.
    Prefix(String),
    /// Matches via a [`Glob`].
    Glob(Glob),
}

impl CommandMatcher {
    /// Builds an [`CommandMatcher::Exact`] matcher.
    ///
    /// # Errors
    /// [`PermissionError::EmptyCommandMatcher`] if `value` is empty.
    pub fn exact(value: impl Into<String>) -> Result<Self, PermissionError> {
        let value = value.into();
        if value.is_empty() {
            return Err(PermissionError::EmptyCommandMatcher);
        }
        Ok(Self::Exact(value))
    }

    /// Builds a [`CommandMatcher::Prefix`] matcher.
    ///
    /// # Errors
    /// [`PermissionError::EmptyCommandMatcher`] if `value` is empty.
    pub fn prefix(value: impl Into<String>) -> Result<Self, PermissionError> {
        let value = value.into();
        if value.is_empty() {
            return Err(PermissionError::EmptyCommandMatcher);
        }
        Ok(Self::Prefix(value))
    }

    /// Builds a [`CommandMatcher::Glob`] matcher.
    ///
    /// # Errors
    /// any error from [`Glob::new`].
    pub fn glob(pattern: impl Into<String>) -> Result<Self, PermissionError> {
        Ok(Self::Glob(Glob::new(pattern)?))
    }

    /// Whether this matcher matches `command`.
    #[must_use]
    pub fn matches(&self, command: &str) -> bool {
        match self {
            Self::Exact(s) => command == s,
            Self::Prefix(s) => command.starts_with(s.as_str()),
            Self::Glob(g) => g.matches(command),
        }
    }
}

/// A per-command verdict carried by a bash [`PermissionRule`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CommandRule {
    /// How the command is matched.
    pub matcher: CommandMatcher,
    /// The verdict applied to a matched command.
    pub effect: Effect,
}

impl CommandRule {
    /// Builds a command rule.
    #[must_use]
    pub fn new(matcher: CommandMatcher, effect: Effect) -> Self {
        Self { matcher, effect }
    }
}

/// One permission rule: a capability, a rule-level [`Effect`], a [`PathScope`]
/// (file capabilities) **or** a list of [`CommandRule`]s ([`Capability::ExecuteBash`]).
///
/// Built through [`PermissionRule::file`] / [`PermissionRule::bash`] which
/// enforce the structural invariants. The fields are read-only accessors.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionRule {
    capability: Capability,
    effect: Effect,
    #[serde(default)]
    paths: PathScope,
    #[serde(default)]
    commands: Vec<CommandRule>,
}

impl PermissionRule {
    /// Builds a **file** rule (`Read`/`Write`/`Delete`) scoped by `paths`.
    ///
    /// # Errors
    /// - [`PermissionError::NotAFileCapability`] if `capability` is
    ///   [`Capability::ExecuteBash`].
    pub fn file(
        capability: Capability,
        effect: Effect,
        paths: PathScope,
    ) -> Result<Self, PermissionError> {
        if !capability.is_file() {
            return Err(PermissionError::NotAFileCapability { capability });
        }
        Ok(Self {
            capability,
            effect,
            paths,
            commands: Vec::new(),
        })
    }

    /// Builds a **bash** rule ([`Capability::ExecuteBash`]).
    ///
    /// A rule with an **empty** `commands` list applies its `effect` as a
    /// blanket verdict to every command. A rule with a **non-empty** `commands`
    /// list contributes a verdict only for matched commands (the rule-level
    /// `effect` is not consulted for unmatched commands — they fall through to
    /// the resolved fallback).
    #[must_use]
    pub fn bash(effect: Effect, commands: Vec<CommandRule>) -> Self {
        Self {
            capability: Capability::ExecuteBash,
            effect,
            paths: PathScope::empty(),
            commands,
        }
    }

    /// Generic validating constructor used by deserialised input.
    ///
    /// # Errors
    /// - [`PermissionError::BashRuleHasPaths`] if a bash rule carries a
    ///   non-empty [`PathScope`];
    /// - [`PermissionError::FileRuleHasCommands`] if a file rule carries
    ///   commands.
    pub fn new(
        capability: Capability,
        effect: Effect,
        paths: PathScope,
        commands: Vec<CommandRule>,
    ) -> Result<Self, PermissionError> {
        if capability.is_bash() {
            if !paths.is_empty() {
                return Err(PermissionError::BashRuleHasPaths);
            }
        } else if !commands.is_empty() {
            return Err(PermissionError::FileRuleHasCommands);
        }
        Ok(Self {
            capability,
            effect,
            paths,
            commands,
        })
    }

    /// The capability this rule governs.
    #[must_use]
    pub fn capability(&self) -> Capability {
        self.capability
    }

    /// The rule-level effect.
    #[must_use]
    pub fn effect(&self) -> Effect {
        self.effect
    }

    /// The path scope (empty for bash rules).
    #[must_use]
    pub fn paths(&self) -> &PathScope {
        &self.paths
    }

    /// The command rules (empty for file rules).
    #[must_use]
    pub fn commands(&self) -> &[CommandRule] {
        &self.commands
    }
}

/// A bundle of [`PermissionRule`]s plus the default [`Posture`] applied when no
/// rule yields a verdict.
///
/// A set may legally contain **both** an `Allow` and a `Deny` rule for the same
/// capability (over different scopes): deny-wins is resolved per target, not per
/// capability.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionSet {
    rules: Vec<PermissionRule>,
    fallback: Posture,
}

impl PermissionSet {
    /// Builds a permission set from already-validated rules.
    #[must_use]
    pub fn new(rules: Vec<PermissionRule>, fallback: Posture) -> Self {
        Self { rules, fallback }
    }

    /// The rules of this set.
    #[must_use]
    pub fn rules(&self) -> &[PermissionRule] {
        &self.rules
    }

    /// The fallback posture of this set.
    #[must_use]
    pub fn fallback(&self) -> Posture {
        self.fallback
    }
}

/// One agent-specific permission override stored in `.ideai/permissions.json`.
///
/// The agent policy is intentionally separate from `agents.json`: permissions are
/// a project security concern, not part of the agent's identity/template link.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentPermissionOverride {
    /// The agent whose default project permissions are tightened.
    pub agent_id: AgentId,
    /// The agent-specific policy.
    pub permissions: PermissionSet,
}

impl AgentPermissionOverride {
    /// Builds an override for `agent_id`.
    #[must_use]
    pub const fn new(agent_id: AgentId, permissions: PermissionSet) -> Self {
        Self {
            agent_id,
            permissions,
        }
    }
}

/// Persisted project permission document (`.ideai/permissions.json`).
///
/// `project_defaults == None` means the project does not define an IdeA-level
/// policy; engines keep their legacy/native behavior unless an agent override is
/// present. Agent entries are sparse: only agents with a custom policy are listed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectPermissions {
    /// Schema version.
    pub version: u32,
    /// Project-level defaults inherited by every agent.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub project_defaults: Option<PermissionSet>,
    /// Sparse agent-specific overrides.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub agents: Vec<AgentPermissionOverride>,
}

impl Default for ProjectPermissions {
    fn default() -> Self {
        Self {
            version: PERMISSIONS_VERSION,
            project_defaults: None,
            agents: Vec::new(),
        }
    }
}

impl ProjectPermissions {
    /// Builds a document and normalises duplicate agent entries by keeping the
    /// last value for a given agent id.
    #[must_use]
    pub fn new(
        project_defaults: Option<PermissionSet>,
        agents: Vec<AgentPermissionOverride>,
    ) -> Self {
        let mut doc = Self {
            version: PERMISSIONS_VERSION,
            project_defaults,
            agents: Vec::new(),
        };
        for entry in agents {
            doc.set_agent_permissions(entry.agent_id, Some(entry.permissions));
        }
        doc
    }

    /// Returns the agent-specific override, if any.
    #[must_use]
    pub fn agent_permissions(&self, agent_id: AgentId) -> Option<&PermissionSet> {
        self.agents
            .iter()
            .find(|entry| entry.agent_id == agent_id)
            .map(|entry| &entry.permissions)
    }

    /// Replaces the project defaults.
    pub fn set_project_defaults(&mut self, defaults: Option<PermissionSet>) {
        self.project_defaults = defaults;
    }

    /// Replaces or removes one agent override.
    pub fn set_agent_permissions(&mut self, agent_id: AgentId, permissions: Option<PermissionSet>) {
        self.agents.retain(|entry| entry.agent_id != agent_id);
        if let Some(permissions) = permissions {
            self.agents
                .push(AgentPermissionOverride::new(agent_id, permissions));
        }
    }

    /// Resolves effective permissions for `agent_id`.
    #[must_use]
    pub fn resolve_for(&self, agent_id: AgentId) -> Option<EffectivePermissions> {
        resolve(
            self.project_defaults.as_ref(),
            self.agent_permissions(agent_id),
        )
    }

    /// Reports agent-level blanket allows that are shadowed by project-level
    /// blanket denies for `agent_id`.
    ///
    /// This is a diagnostic companion to [`Self::resolve_for`]. It does not
    /// participate in permission resolution and does not change deny-wins.
    #[must_use]
    pub fn shadow_for(&self, agent_id: AgentId) -> PermissionShadowReport {
        shadow_report(
            self.project_defaults.as_ref(),
            self.agent_permissions(agent_id),
        )
    }
}

/// Diagnostic report for agent overrides that cannot loosen the project policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionShadowReport {
    /// Agent blanket read allow is shadowed by a project blanket read deny.
    pub read: bool,
    /// Agent blanket write allow is shadowed by a project blanket write deny.
    pub write: bool,
    /// Agent blanket delete allow is shadowed by a project blanket delete deny.
    pub delete: bool,
    /// Agent blanket bash allow is shadowed by a project blanket bash deny.
    pub execute_bash: bool,
    /// Agent fallback choice is looser than the resolved project-tightened
    /// fallback.
    pub fallback: bool,
}

/// The normalised, flattened output of [`resolve`] — the **sole input** of the
/// future per-CLI projectors.
///
/// It holds the union of the project and agent rules (project first, then agent
/// superposed) plus the resolved fallback. Deny-wins is applied at decision time
/// by [`EffectivePermissions::decide_file`] / [`EffectivePermissions::decide_bash`]
/// so the projectors can either emit the raw allow/deny lists or ask for a
/// concrete verdict.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EffectivePermissions {
    rules: Vec<PermissionRule>,
    fallback: Posture,
}

impl EffectivePermissions {
    /// All flattened rules (project rules followed by agent rules).
    #[must_use]
    pub fn rules(&self) -> &[PermissionRule] {
        &self.rules
    }

    /// The resolved fallback posture.
    #[must_use]
    pub fn fallback(&self) -> Posture {
        self.fallback
    }

    /// Resolves the verdict for a **file** capability against `path`.
    ///
    /// Deny-wins: any matching `Deny` (project or agent) yields
    /// [`Posture::Deny`]; otherwise any matching `Allow` yields
    /// [`Posture::Allow`]; otherwise the [`EffectivePermissions::fallback`].
    ///
    /// A bash capability passed here always falls through to the fallback (file
    /// rules and bash rules never cross).
    #[must_use]
    pub fn decide_file(&self, capability: Capability, path: &str) -> Posture {
        let mut allowed = false;
        for rule in &self.rules {
            if rule.capability != capability || !rule.capability.is_file() {
                continue;
            }
            if rule.paths.matches(path) {
                match rule.effect {
                    Effect::Deny => return Posture::Deny,
                    Effect::Allow => allowed = true,
                }
            }
        }
        if allowed {
            Posture::Allow
        } else {
            self.fallback
        }
    }

    /// Resolves the verdict for running `command`.
    ///
    /// Each bash rule contributes a verdict: a rule with a non-empty `commands`
    /// list contributes the effect of every matching [`CommandRule`]; a rule
    /// with an empty list contributes its rule-level effect for every command.
    /// Deny-wins across every contribution; absent any contribution, the
    /// fallback applies.
    #[must_use]
    pub fn decide_bash(&self, command: &str) -> Posture {
        let mut allowed = false;
        for rule in &self.rules {
            if !rule.capability.is_bash() {
                continue;
            }
            if rule.commands.is_empty() {
                // Blanket bash rule: applies to every command.
                match rule.effect {
                    Effect::Deny => return Posture::Deny,
                    Effect::Allow => allowed = true,
                }
            } else {
                for cmd in &rule.commands {
                    if cmd.matcher.matches(command) {
                        match cmd.effect {
                            Effect::Deny => return Posture::Deny,
                            Effect::Allow => allowed = true,
                        }
                    }
                }
            }
        }
        if allowed {
            Posture::Allow
        } else {
            self.fallback
        }
    }
}

fn posture_to_opencode(posture: Posture) -> &'static str {
    match posture {
        Posture::Allow => "allow",
        Posture::Ask => "ask",
        Posture::Deny => "deny",
    }
}

/// Builds the OpenCode `permission` config block from `eff` (ticket #94).
///
/// OpenCode's schema only exposes `bash`/`edit` keys (no `read`/`delete`: those
/// two capabilities are not expressible in it and are already enforced by the
/// Landlock sandbox). Mapping: `bash` ← [`EffectivePermissions::decide_bash`] on
/// the blanket command `""`; `edit` ← [`EffectivePermissions::decide_file`] on
/// [`Capability::Write`] with the blanket glob `"**"`.
///
/// `eff == None` (nothing posed) ⇒ [`None`]: the caller must omit the
/// `permission` key entirely, preserving OpenCode's native prompting — the same
/// invariant already enforced for Claude/Codex projection.
#[must_use]
pub fn opencode_permission_block(eff: Option<&EffectivePermissions>) -> Option<serde_json::Value> {
    let eff = eff?;
    let bash = posture_to_opencode(eff.decide_bash(""));
    let edit = posture_to_opencode(eff.decide_file(Capability::Write, "**"));
    Some(serde_json::json!({
        "bash": bash,
        "edit": edit
    }))
}

/// Resolves a project-level and an agent-level [`PermissionSet`] into the
/// normalised [`EffectivePermissions`].
///
/// 1. **`project == None && agent == None ⇒ None`** — nothing posed, nothing
///    projected; the engine keeps its native prompting (the product invariant).
/// 2. Otherwise the project rules are taken as the inherited base and the agent
///    rules are superposed on top (`project` first, then `agent`).
/// 3. The fallback is the **more restrictive** of the two present fallbacks
///    ([`Posture::tighten`]): the agent may resserrer, never loosen.
///
/// Deny-wins itself is enforced by the decision methods of the returned
/// [`EffectivePermissions`], over the union of both sets' rules — a project deny
/// is therefore never overridable by an agent allow.
///
/// The function is **total** and **deterministic**.
#[must_use]
pub fn resolve(
    project: Option<&PermissionSet>,
    agent: Option<&PermissionSet>,
) -> Option<EffectivePermissions> {
    if project.is_none() && agent.is_none() {
        return None;
    }

    let mut rules = Vec::new();
    if let Some(p) = project {
        rules.extend(p.rules.iter().cloned());
    }
    if let Some(a) = agent {
        rules.extend(a.rules.iter().cloned());
    }

    let fallback = match (project, agent) {
        (Some(p), Some(a)) => p.fallback.tighten(a.fallback),
        (Some(p), None) => p.fallback,
        (None, Some(a)) => a.fallback,
        // Unreachable: the both-`None` case returned above.
        (None, None) => Posture::Ask,
    };

    Some(EffectivePermissions { rules, fallback })
}

/// Reports agent-level blanket allows shadowed by project-level blanket denies.
///
/// This is deliberately **not** a general glob-overlap solver. It is shaped to
/// the current UI contract: file capabilities are considered blanket only when
/// the rule has exactly one glob, `"**"`; bash is considered blanket only when
/// the rule has no command matchers. Scoped rules are ignored by this diagnostic
/// even though normal [`resolve`] and decision methods still honour them.
#[must_use]
pub fn shadow_report(
    project: Option<&PermissionSet>,
    agent: Option<&PermissionSet>,
) -> PermissionShadowReport {
    let Some(agent) = agent else {
        return PermissionShadowReport::default();
    };

    let shadowed = |capability| {
        blanket_effect(project, capability, BlanketLookupMode::DenyWins) == Some(Effect::Deny)
            && blanket_effect(Some(agent), capability, BlanketLookupMode::AllowWins)
                == Some(Effect::Allow)
    };
    let fallback = resolve(project, Some(agent))
        .is_some_and(|resolved| agent.fallback() != resolved.fallback());

    PermissionShadowReport {
        read: shadowed(Capability::Read),
        write: shadowed(Capability::Write),
        delete: shadowed(Capability::Delete),
        execute_bash: shadowed(Capability::ExecuteBash),
        fallback,
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BlanketLookupMode {
    DenyWins,
    AllowWins,
}

fn blanket_effect(
    set: Option<&PermissionSet>,
    capability: Capability,
    mode: BlanketLookupMode,
) -> Option<Effect> {
    let mut found = None;
    for rule in set?.rules() {
        if rule.capability() != capability || !is_blanket_rule(rule) {
            continue;
        }
        match (mode, rule.effect()) {
            (BlanketLookupMode::DenyWins, Effect::Deny) => return Some(Effect::Deny),
            (BlanketLookupMode::AllowWins, Effect::Allow) => return Some(Effect::Allow),
            _ => found = Some(rule.effect()),
        }
    }
    found
}

fn is_blanket_rule(rule: &PermissionRule) -> bool {
    if rule.capability().is_bash() {
        return rule.commands().is_empty();
    }
    let globs = rule.paths().globs();
    globs.len() == 1 && globs[0].pattern() == "**"
}

/// Renders a human-readable Markdown **summary** of the resolved policy, suitable
/// for injection into an agent's context (lot LP4-0).
///
/// `eff == None ⇒ None` (mirrors [`resolve`]'s product invariant: nothing posed ⇒
/// nothing to summarise). A `Some` result is a self-contained Markdown block.
///
/// The summary makes the **enforcement boundary** explicit: file rules are locked
/// by the OS sandbox **when supported** (Landlock), whereas command rules
/// ([`Capability::ExecuteBash`]) remain **advisory** — honoured only by the CLI's
/// own prompting, never by the OS. This honesty is load-bearing: an agent must not
/// believe a command deny is airtight.
#[must_use]
pub fn render_permission_summary(eff: Option<&EffectivePermissions>) -> Option<String> {
    let eff = eff?;

    let mut file_lines: Vec<String> = Vec::new();
    let mut bash_lines: Vec<String> = Vec::new();
    for rule in eff.rules() {
        if rule.capability().is_file() {
            let verb = match rule.effect() {
                Effect::Allow => "Allow",
                Effect::Deny => "Deny",
            };
            let cap = match rule.capability() {
                Capability::Read => "read",
                Capability::Write => "write",
                Capability::Delete => "delete",
                Capability::ExecuteBash => "run", // unreachable (is_file filtered)
            };
            let scope = if rule.paths().is_empty() {
                "(nothing)".to_string()
            } else {
                rule.paths()
                    .globs()
                    .iter()
                    .map(|g| format!("`{}`", g.pattern()))
                    .collect::<Vec<_>>()
                    .join(", ")
            };
            file_lines.push(format!("- {verb} {cap}: {scope}"));
        } else {
            // Bash rule: blanket (empty commands) or per-command.
            if rule.commands().is_empty() {
                let verb = match rule.effect() {
                    Effect::Allow => "Allow",
                    Effect::Deny => "Deny",
                };
                bash_lines.push(format!("- {verb}: every command"));
            } else {
                for cmd in rule.commands() {
                    let verb = match cmd.effect {
                        Effect::Allow => "Allow",
                        Effect::Deny => "Deny",
                    };
                    let shown = match &cmd.matcher {
                        CommandMatcher::Exact(s) => format!("`{s}` (exact)"),
                        CommandMatcher::Prefix(s) => format!("`{s}*` (prefix)"),
                        CommandMatcher::Glob(g) => format!("`{}` (glob)", g.pattern()),
                    };
                    bash_lines.push(format!("- {verb}: {shown}"));
                }
            }
        }
    }

    let mut out = String::new();
    out.push_str("## Permissions (IdeA)\n\n");
    out.push_str(&format!(
        "**Default posture:** {}\n\n",
        match eff.fallback() {
            Posture::Allow => "Allow",
            Posture::Ask => "Ask",
            Posture::Deny => "Deny",
        }
    ));

    out.push_str("### Files — OS-enforced when the sandbox is supported (Landlock)\n");
    if file_lines.is_empty() {
        out.push_str("- (no file rule; only the default posture applies)\n");
    } else {
        for line in &file_lines {
            out.push_str(line);
            out.push('\n');
        }
    }
    out.push('\n');

    out.push_str("### Commands — advisory, NOT OS-locked\n");
    out.push_str(
        "These rules are honoured only by the AI CLI's own prompting, not by the OS \
sandbox: Landlock locks file access only, so command execution (`ExecuteBash`) \
cannot be sandboxed and stays advisory.\n",
    );
    if bash_lines.is_empty() {
        out.push_str("- (no command rule; only the default posture applies)\n");
    } else {
        for line in &bash_lines {
            out.push_str(line);
            out.push('\n');
        }
    }

    Some(out)
}

// ---------------------------------------------------------------------------
// Per-CLI projection (lot LP3) — the PURE contract that translates the resolved
// `EffectivePermissions` into a concrete, file/args/env-level *plan* for one CLI.
// This module owns only the contract (a value + a trait); the concrete Claude /
// Codex projectors and the launch-time application live in `infrastructure`.
// ---------------------------------------------------------------------------

/// Stable key identifying **which** per-CLI projector a profile uses.
///
/// Closed set, mirroring [`crate::profile::StructuredAdapter`]: a projector is a
/// declarative choice (data, not code), and each variant has exactly one concrete
/// implementation in `infrastructure`. A closed enum (rather than a `String`
/// newtype) is the idiomatic shape here — it matches the other engine-family keys
/// of the domain, makes the match in the launch path exhaustive, and cannot carry
/// an unknown value at run time.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ProjectorKey {
    /// Projects to Claude Code's `settings.json` permission shape.
    Claude,
    /// Projects to Codex's sandbox / `config.toml` permission shape.
    Codex,
}

/// Immutable inputs a [`PermissionProjector`] may interpolate into the paths it
/// emits. Borrowed (no ownership): a projection is computed and consumed at the
/// launch site, never stored.
pub struct ProjectionContext<'a> {
    /// Absolute project root.
    pub project_root: &'a str,
    /// Absolute isolated run dir of the agent (`.ideai/run/<agent-id>/`).
    pub run_dir: &'a str,
    /// Optional model selected by the agent profile. Orthogonal to permissions:
    /// projectors may still materialise or forward it when appropriate.
    pub model: Option<&'a str>,
}

/// One file a projector wants materialised at launch, tagged by **ownership**.
///
/// Ownership decides the launch/swap lifecycle: a [`Self::Replace`] file is 100 %
/// IdeA's (clobbered at launch, removed when the agent swaps away from this
/// projector), whereas a [`Self::MergeToml`] file is co-owned with the CLI (only
/// the managed tables/keys are merged in, and it is **never** deleted on swap).
pub enum ProjectedFile {
    /// File fully owned by IdeA → clobber at launch, delete on swap-away.
    Replace {
        /// Run-dir-relative path of the file.
        rel_path: String,
        /// Full contents to write.
        contents: String,
    },
    /// File co-owned with the CLI (e.g. Codex `config.toml`) → merge only the
    /// managed tables/keys, never deleted on swap.
    MergeToml {
        /// Run-dir-relative path of the file.
        rel_path: String,
        /// TOML tables IdeA manages (everything else is preserved).
        managed_tables: Vec<String>,
        /// Top-level keys IdeA manages (everything else is preserved).
        managed_keys: Vec<String>,
        /// The managed fragment to merge in.
        contents: String,
    },
}

/// The concrete, CLI-specific **plan** a [`PermissionProjector`] produces from the
/// resolved [`EffectivePermissions`]: the files to materialise, plus any launch
/// args and environment variables. It is a *value* (a plan), not an action — the
/// infrastructure applies it.
pub struct PermissionProjection {
    /// Files to materialise (ownership-tagged, cf. [`ProjectedFile`]).
    pub files: Vec<ProjectedFile>,
    /// Extra launch arguments the CLI needs to honour the policy.
    pub args: Vec<String>,
    /// Extra environment variables (`name`, `value`).
    pub env: Vec<(String, String)>,
}

impl PermissionProjection {
    /// The empty projection: nothing materialised, no args, no env.
    ///
    /// This is the **invariant value** a projector returns when `eff == None`:
    /// nothing is posed ⇒ we project nothing and the CLI keeps its native
    /// prompting (mirrors [`resolve`]'s product invariant).
    #[must_use]
    pub fn empty() -> Self {
        Self {
            files: Vec::new(),
            args: Vec::new(),
            env: Vec::new(),
        }
    }
}

/// Translates the resolved [`EffectivePermissions`] into a CLI-specific
/// [`PermissionProjection`].
///
/// **Pure**: an implementation only *computes* a plan; it writes nothing and
/// touches no I/O. The launch path (infrastructure) is what materialises the
/// returned files / args / env. Concrete impls (Claude, Codex) live in
/// `infrastructure`, keyed by [`ProjectorKey`].
pub trait PermissionProjector: Send + Sync {
    /// The key this projector answers to.
    fn key(&self) -> ProjectorKey;

    /// Computes the projection.
    ///
    /// `eff == None` means no filesystem/bash policy is projected. Implementations
    /// may still project orthogonal system permissions, such as network env.
    fn project(
        &self,
        eff: Option<&EffectivePermissions>,
        network: Option<crate::system_permissions::NetworkPolicy>,
        ctx: &ProjectionContext,
    ) -> PermissionProjection;

    /// Run-dir-relative paths of the [`ProjectedFile::Replace`] files this
    /// projector owns, so the swap path can clean them up when an agent moves
    /// away from this projector.
    fn owned_replace_paths(&self) -> Vec<String>;
}

/// Errors raised by the validating constructors of this module.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PermissionError {
    /// A glob pattern was empty.
    #[error("glob pattern must not be empty")]
    EmptyGlob,
    /// A glob pattern was not compilable.
    #[error("glob `{pattern}` is not compilable: {reason}")]
    InvalidGlob {
        /// The offending pattern.
        pattern: String,
        /// Why it failed to compile.
        reason: String,
    },
    /// An `Exact`/`Prefix` command matcher was empty.
    #[error("command matcher must not be empty")]
    EmptyCommandMatcher,
    /// A path-scope glob was absolute or contained a `..` traversal component.
    #[error("path glob `{pattern}` must be relative and must not contain `..`")]
    PathNotRelativeSafe {
        /// The offending pattern.
        pattern: String,
    },
    /// A bash rule carried a non-empty path scope.
    #[error("an ExecuteBash rule must not carry a path scope")]
    BashRuleHasPaths,
    /// A file rule carried command rules.
    #[error("a Read/Write/Delete rule must not carry commands")]
    FileRuleHasCommands,
    /// [`PermissionRule::file`] was given [`Capability::ExecuteBash`].
    #[error("expected a file capability (Read/Write/Delete), got {capability:?}")]
    NotAFileCapability {
        /// The offending capability.
        capability: Capability,
    },
}

// ---------------------------------------------------------------------------
// Pure glob engine (no external crate; supports `*`, `**`, `?`, `[...]`).
// ---------------------------------------------------------------------------

/// Validates that every `[` in `pattern` opens a terminated character class.
fn validate_glob(pattern: &str) -> Result<(), String> {
    let chars: Vec<char> = pattern.chars().collect();
    let mut i = 0;
    while i < chars.len() {
        if chars[i] == '[' {
            i = class_end(&chars, i)
                .ok_or_else(|| "unterminated character class `[`".to_string())?;
        } else {
            i += 1;
        }
    }
    Ok(())
}

/// Given `chars[start] == '['`, returns the index **after** the closing `]`, or
/// `None` if the class is unterminated. A `]` immediately after the (optional)
/// negation marker is treated as a literal member (POSIX rule).
fn class_end(chars: &[char], start: usize) -> Option<usize> {
    let mut i = start + 1;
    if i < chars.len() && (chars[i] == '!' || chars[i] == '^') {
        i += 1;
    }
    // A leading ']' is a literal member, not the terminator.
    if i < chars.len() && chars[i] == ']' {
        i += 1;
    }
    while i < chars.len() {
        if chars[i] == ']' {
            return Some(i + 1);
        }
        i += 1;
    }
    None
}

/// Whether `pat` matches the whole of `text`.
fn glob_match(pat: &[char], text: &[char]) -> bool {
    if pat.is_empty() {
        return text.is_empty();
    }
    match pat[0] {
        '*' => {
            if pat.get(1) == Some(&'*') {
                // `**` — match any run of chars, including `/`.
                let rest = &pat[2..];
                (0..=text.len()).any(|i| glob_match(rest, &text[i..]))
            } else {
                // `*` — match any run of non-`/` chars.
                let rest = &pat[1..];
                let mut i = 0;
                loop {
                    if glob_match(rest, &text[i..]) {
                        return true;
                    }
                    if i >= text.len() || text[i] == '/' {
                        return false;
                    }
                    i += 1;
                }
            }
        }
        '?' => match text.first() {
            Some(&c) if c != '/' => glob_match(&pat[1..], &text[1..]),
            _ => false,
        },
        '[' => {
            if let Some(end) = class_end(pat, 0) {
                match text.first() {
                    Some(&c) if c != '/' && class_contains(&pat[..end], c) => {
                        glob_match(&pat[end..], &text[1..])
                    }
                    _ => false,
                }
            } else {
                // Not a valid class (should not happen post-validation): literal.
                matches_literal(pat, text, '[')
            }
        }
        c => matches_literal(pat, text, c),
    }
}

/// Matches a single literal char `c` at the head of `pat` against `text`.
fn matches_literal(pat: &[char], text: &[char], c: char) -> bool {
    match text.first() {
        Some(&t) if t == c => glob_match(&pat[1..], &text[1..]),
        _ => false,
    }
}

/// Whether character `c` belongs to the class `class` (`class[0] == '['`,
/// `class` ends just after the closing `]`).
fn class_contains(class: &[char], c: char) -> bool {
    let mut i = 1;
    let mut negated = false;
    if class.get(i) == Some(&'!') || class.get(i) == Some(&'^') {
        negated = true;
        i += 1;
    }
    let end = class.len() - 1; // index of the closing ']'
    let mut found = false;
    let mut first = true;
    while i < end {
        // A literal ']' is only possible as the first member.
        if class[i] == ']' && !first {
            break;
        }
        first = false;
        if i + 2 < end && class[i + 1] == '-' && class[i + 2] != ']' {
            let (lo, hi) = (class[i], class[i + 2]);
            if lo <= c && c <= hi {
                found = true;
            }
            i += 3;
        } else {
            if class[i] == c {
                found = true;
            }
            i += 1;
        }
    }
    found ^ negated
}

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

    fn glob(p: &str) -> Glob {
        Glob::new(p).unwrap()
    }

    fn path_scope(patterns: &[&str]) -> PathScope {
        PathScope::new(patterns.iter().map(|s| s.to_string())).unwrap()
    }

    fn blanket_file(capability: Capability, effect: Effect) -> PermissionRule {
        PermissionRule::file(capability, effect, path_scope(&["**"])).unwrap()
    }

    // ---- VO construction & invariants -----------------------------------

    #[test]
    fn glob_rejects_empty_and_unterminated_class() {
        assert_eq!(Glob::new(""), Err(PermissionError::EmptyGlob));
        assert!(matches!(
            Glob::new("src/[abc"),
            Err(PermissionError::InvalidGlob { .. })
        ));
        assert!(Glob::new("src/[abc]/*.rs").is_ok());
    }

    #[test]
    fn path_scope_rejects_absolute_and_traversal() {
        assert!(matches!(
            PathScope::new(["/etc/passwd".to_string()]),
            Err(PermissionError::PathNotRelativeSafe { .. })
        ));
        assert!(matches!(
            PathScope::new(["../secret".to_string()]),
            Err(PermissionError::PathNotRelativeSafe { .. })
        ));
        assert!(PathScope::new(["src/**/*.rs".to_string()]).is_ok());
    }

    #[test]
    fn file_rule_rejects_bash_capability() {
        assert!(matches!(
            PermissionRule::file(Capability::ExecuteBash, Effect::Allow, PathScope::empty()),
            Err(PermissionError::NotAFileCapability { .. })
        ));
        assert!(
            PermissionRule::file(Capability::Read, Effect::Allow, path_scope(&["src/**"])).is_ok()
        );
    }

    #[test]
    fn new_enforces_bash_paths_and_file_commands_invariants() {
        // Bash rule with paths => error.
        assert_eq!(
            PermissionRule::new(
                Capability::ExecuteBash,
                Effect::Allow,
                path_scope(&["src/**"]),
                vec![],
            ),
            Err(PermissionError::BashRuleHasPaths)
        );
        // File rule with commands => error.
        let cmd = CommandRule::new(CommandMatcher::exact("ls").unwrap(), Effect::Allow);
        assert_eq!(
            PermissionRule::new(
                Capability::Read,
                Effect::Allow,
                PathScope::empty(),
                vec![cmd],
            ),
            Err(PermissionError::FileRuleHasCommands)
        );
    }

    #[test]
    fn empty_command_matchers_are_rejected() {
        assert_eq!(
            CommandMatcher::exact(""),
            Err(PermissionError::EmptyCommandMatcher)
        );
        assert_eq!(
            CommandMatcher::prefix(""),
            Err(PermissionError::EmptyCommandMatcher)
        );
    }

    // ---- glob matching ---------------------------------------------------

    #[test]
    fn star_does_not_cross_slash_but_doublestar_does() {
        assert!(glob("src/*.rs").matches("src/main.rs"));
        assert!(!glob("src/*.rs").matches("src/a/main.rs"));
        assert!(glob("src/**/*.rs").matches("src/a/b/main.rs"));
        assert!(glob("**").matches("any/deep/path"));
        assert!(glob("*.rs").matches("main.rs"));
    }

    #[test]
    fn question_and_class_match_single_char() {
        assert!(glob("?.rs").matches("a.rs"));
        assert!(!glob("?.rs").matches("ab.rs"));
        assert!(glob("[abc].rs").matches("b.rs"));
        assert!(!glob("[abc].rs").matches("d.rs"));
        assert!(glob("[a-z].rs").matches("q.rs"));
        assert!(glob("[!a-z].rs").matches("Q.rs"));
        assert!(!glob("[!a-z].rs").matches("q.rs"));
    }

    // ---- command matchers ------------------------------------------------

    #[test]
    fn command_matchers_behave() {
        assert!(CommandMatcher::exact("git status")
            .unwrap()
            .matches("git status"));
        assert!(!CommandMatcher::exact("git").unwrap().matches("git status"));
        assert!(CommandMatcher::prefix("git ")
            .unwrap()
            .matches("git status"));
        assert!(CommandMatcher::glob("git *").unwrap().matches("git status"));
        assert!(!CommandMatcher::glob("git *").unwrap().matches("npm test"));
    }

    // ---- resolve: product invariant -------------------------------------

    #[test]
    fn resolve_none_when_nothing_posed() {
        assert!(resolve(None, None).is_none());
    }

    #[test]
    fn resolve_some_when_anything_posed() {
        let set = PermissionSet::new(vec![], Posture::Ask);
        assert!(resolve(Some(&set), None).is_some());
        assert!(resolve(None, Some(&set)).is_some());
    }

    // ---- resolve: merge + deny-wins -------------------------------------

    #[test]
    fn merge_superposes_project_then_agent() {
        let project = PermissionSet::new(
            vec![
                PermissionRule::file(Capability::Read, Effect::Allow, path_scope(&["src/**"]))
                    .unwrap(),
            ],
            Posture::Ask,
        );
        let agent = PermissionSet::new(
            vec![
                PermissionRule::file(Capability::Write, Effect::Allow, path_scope(&["out/**"]))
                    .unwrap(),
            ],
            Posture::Ask,
        );
        let eff = resolve(Some(&project), Some(&agent)).unwrap();
        assert_eq!(eff.rules().len(), 2);
        assert_eq!(
            eff.decide_file(Capability::Read, "src/main.rs"),
            Posture::Allow
        );
        assert_eq!(eff.decide_file(Capability::Write, "out/x"), Posture::Allow);
    }

    #[test]
    fn project_deny_beats_agent_allow() {
        let project = PermissionSet::new(
            vec![
                PermissionRule::file(Capability::Write, Effect::Deny, path_scope(&[".ideai/**"]))
                    .unwrap(),
            ],
            Posture::Allow,
        );
        let agent = PermissionSet::new(
            vec![
                PermissionRule::file(Capability::Write, Effect::Allow, path_scope(&["**"]))
                    .unwrap(),
            ],
            Posture::Allow,
        );
        let eff = resolve(Some(&project), Some(&agent)).unwrap();
        // The agent's broad allow does NOT override the project's specific deny.
        assert_eq!(
            eff.decide_file(Capability::Write, ".ideai/agents.json"),
            Posture::Deny
        );
        // A path outside the deny scope is allowed.
        assert_eq!(
            eff.decide_file(Capability::Write, "src/main.rs"),
            Posture::Allow
        );
    }

    #[test]
    fn decide_file_falls_back_when_no_rule_matches() {
        let set = PermissionSet::new(
            vec![
                PermissionRule::file(Capability::Read, Effect::Allow, path_scope(&["src/**"]))
                    .unwrap(),
            ],
            Posture::Ask,
        );
        let eff = resolve(Some(&set), None).unwrap();
        assert_eq!(
            eff.decide_file(Capability::Read, "doc/readme.md"),
            Posture::Ask
        );
        // A bash query never crosses into file rules.
        assert_eq!(eff.decide_bash("ls"), Posture::Ask);
    }

    // ---- resolve: bash semantics ----------------------------------------

    #[test]
    fn bash_blanket_allow_with_specific_deny() {
        let set = PermissionSet::new(
            vec![
                PermissionRule::bash(Effect::Allow, vec![]),
                PermissionRule::bash(
                    Effect::Deny,
                    vec![CommandRule::new(
                        CommandMatcher::prefix("rm ").unwrap(),
                        Effect::Deny,
                    )],
                ),
            ],
            Posture::Deny,
        );
        let eff = resolve(Some(&set), None).unwrap();
        assert_eq!(eff.decide_bash("ls -la"), Posture::Allow);
        assert_eq!(eff.decide_bash("rm -rf /"), Posture::Deny);
    }

    #[test]
    fn bash_specific_only_falls_back_for_unmatched() {
        let set = PermissionSet::new(
            vec![PermissionRule::bash(
                Effect::Deny, // ignored: commands non-empty
                vec![CommandRule::new(
                    CommandMatcher::glob("git *").unwrap(),
                    Effect::Allow,
                )],
            )],
            Posture::Ask,
        );
        let eff = resolve(Some(&set), None).unwrap();
        assert_eq!(eff.decide_bash("git status"), Posture::Allow);
        assert_eq!(eff.decide_bash("npm test"), Posture::Ask);
    }

    // ---- resolve: fallback tightening -----------------------------------

    #[test]
    fn fallback_takes_more_restrictive_of_both() {
        let project = PermissionSet::new(vec![], Posture::Allow);
        let agent = PermissionSet::new(vec![], Posture::Deny);
        let eff = resolve(Some(&project), Some(&agent)).unwrap();
        assert_eq!(eff.fallback(), Posture::Deny);

        let project2 = PermissionSet::new(vec![], Posture::Deny);
        let agent2 = PermissionSet::new(vec![], Posture::Allow);
        // Agent cannot loosen the project's stance.
        assert_eq!(
            resolve(Some(&project2), Some(&agent2)).unwrap().fallback(),
            Posture::Deny
        );
    }

    #[test]
    fn posture_tighten_orders_allow_ask_deny() {
        assert_eq!(Posture::Allow.tighten(Posture::Ask), Posture::Ask);
        assert_eq!(Posture::Ask.tighten(Posture::Allow), Posture::Ask);
        assert_eq!(Posture::Ask.tighten(Posture::Deny), Posture::Deny);
        assert_eq!(Posture::Deny.tighten(Posture::Allow), Posture::Deny);
    }

    #[test]
    fn project_permissions_resolves_sparse_agent_override() {
        let agent = AgentId::new_random();
        let project = PermissionSet::new(vec![], Posture::Ask);
        let custom = PermissionSet::new(vec![], Posture::Deny);
        let doc = ProjectPermissions::new(
            Some(project),
            vec![AgentPermissionOverride::new(agent, custom)],
        );

        assert_eq!(doc.resolve_for(agent).unwrap().fallback(), Posture::Deny);
        assert_eq!(
            doc.resolve_for(AgentId::new_random()).unwrap().fallback(),
            Posture::Ask
        );
    }

    // ---- shadow diagnostics (ticket #122) -------------------------------

    #[test]
    fn shadow_report_flags_read_write_delete_bash_when_project_blanket_deny_beats_agent_blanket_allow(
    ) {
        let project = PermissionSet::new(
            vec![
                blanket_file(Capability::Read, Effect::Deny),
                blanket_file(Capability::Write, Effect::Deny),
                blanket_file(Capability::Delete, Effect::Deny),
                PermissionRule::bash(Effect::Deny, vec![]),
            ],
            Posture::Ask,
        );
        let agent = PermissionSet::new(
            vec![
                blanket_file(Capability::Read, Effect::Allow),
                blanket_file(Capability::Write, Effect::Allow),
                blanket_file(Capability::Delete, Effect::Allow),
                PermissionRule::bash(Effect::Allow, vec![]),
            ],
            Posture::Ask,
        );

        assert_eq!(
            shadow_report(Some(&project), Some(&agent)),
            PermissionShadowReport {
                read: true,
                write: true,
                delete: true,
                execute_bash: true,
                fallback: false,
            }
        );
    }

    #[test]
    fn shadow_report_false_when_project_has_no_deny_rule_for_capability() {
        let project = PermissionSet::new(
            vec![blanket_file(Capability::Read, Effect::Allow)],
            Posture::Ask,
        );
        let agent = PermissionSet::new(
            vec![blanket_file(Capability::Read, Effect::Allow)],
            Posture::Ask,
        );

        assert_eq!(shadow_report(Some(&project), Some(&agent)).read, false);
    }

    #[test]
    fn shadow_report_false_when_agent_rule_is_scoped_not_blanket() {
        let project = PermissionSet::new(
            vec![blanket_file(Capability::Write, Effect::Deny)],
            Posture::Ask,
        );
        let agent = PermissionSet::new(
            vec![
                PermissionRule::file(Capability::Write, Effect::Allow, path_scope(&["src/**"]))
                    .unwrap(),
            ],
            Posture::Ask,
        );

        assert_eq!(shadow_report(Some(&project), Some(&agent)).write, false);
    }

    #[test]
    fn shadow_report_false_when_agent_also_denies() {
        let project = PermissionSet::new(
            vec![PermissionRule::bash(Effect::Deny, vec![])],
            Posture::Ask,
        );
        let agent = PermissionSet::new(
            vec![PermissionRule::bash(Effect::Deny, vec![])],
            Posture::Ask,
        );

        assert_eq!(
            shadow_report(Some(&project), Some(&agent)).execute_bash,
            false
        );
    }

    #[test]
    fn shadow_report_fallback_true_when_project_fallback_stricter_than_agent_chosen_fallback() {
        let project = PermissionSet::new(vec![], Posture::Deny);
        let agent = PermissionSet::new(vec![], Posture::Allow);

        assert!(shadow_report(Some(&project), Some(&agent)).fallback);
    }

    #[test]
    fn shadow_report_all_false_when_agent_is_none() {
        let project = PermissionSet::new(
            vec![PermissionRule::bash(Effect::Deny, vec![])],
            Posture::Deny,
        );

        assert_eq!(
            shadow_report(Some(&project), None),
            PermissionShadowReport::default()
        );
    }

    #[test]
    fn shadow_for_matches_free_function_via_project_permissions() {
        let agent = AgentId::new_random();
        let project = PermissionSet::new(
            vec![PermissionRule::bash(Effect::Deny, vec![])],
            Posture::Ask,
        );
        let custom = PermissionSet::new(
            vec![PermissionRule::bash(Effect::Allow, vec![])],
            Posture::Ask,
        );
        let doc = ProjectPermissions::new(
            Some(project.clone()),
            vec![AgentPermissionOverride::new(agent, custom.clone())],
        );

        assert_eq!(
            doc.shadow_for(agent),
            shadow_report(Some(&project), Some(&custom))
        );
    }

    #[test]
    fn permission_shadow_report_serialises_camel_case() {
        let report = PermissionShadowReport {
            read: false,
            write: false,
            delete: false,
            execute_bash: true,
            fallback: true,
        };

        assert_eq!(
            serde_json::to_value(report).unwrap(),
            serde_json::json!({
                "read": false,
                "write": false,
                "delete": false,
                "executeBash": true,
                "fallback": true
            })
        );
    }

    // ---- LP3: ProjectorKey serde + PermissionProjection invariant ------

    #[test]
    fn projector_key_serialises_to_stable_camel_case() {
        // The wire form is load-bearing (it keys the concrete projector in infra).
        assert_eq!(
            serde_json::to_string(&ProjectorKey::Claude).unwrap(),
            "\"claude\""
        );
        assert_eq!(
            serde_json::to_string(&ProjectorKey::Codex).unwrap(),
            "\"codex\""
        );
    }

    #[test]
    fn projector_key_round_trips() {
        for key in [ProjectorKey::Claude, ProjectorKey::Codex] {
            let json = serde_json::to_string(&key).unwrap();
            let back: ProjectorKey = serde_json::from_str(&json).unwrap();
            assert_eq!(key, back);
        }
        // And the literal wire form deserialises back to the expected variant.
        assert_eq!(
            serde_json::from_str::<ProjectorKey>("\"claude\"").unwrap(),
            ProjectorKey::Claude
        );
        assert_eq!(
            serde_json::from_str::<ProjectorKey>("\"codex\"").unwrap(),
            ProjectorKey::Codex
        );
    }

    #[test]
    fn permission_projection_empty_is_fully_empty() {
        // The invariant value returned when `eff == None`: project nothing.
        let proj = PermissionProjection::empty();
        assert!(proj.files.is_empty(), "no files materialised");
        assert!(proj.args.is_empty(), "no launch args");
        assert!(proj.env.is_empty(), "no env vars");
    }

    #[test]
    fn project_permissions_keeps_last_override_for_agent() {
        let agent = AgentId::new_random();
        let doc = ProjectPermissions::new(
            None,
            vec![
                AgentPermissionOverride::new(agent, PermissionSet::new(vec![], Posture::Ask)),
                AgentPermissionOverride::new(agent, PermissionSet::new(vec![], Posture::Deny)),
            ],
        );

        assert_eq!(doc.agents.len(), 1);
        assert_eq!(doc.resolve_for(agent).unwrap().fallback(), Posture::Deny);
    }

    // ---- LP4-0: render_permission_summary -------------------------------

    #[test]
    fn summary_is_none_when_nothing_posed() {
        // Mirrors resolve's product invariant: nothing posed ⇒ nothing to render.
        assert!(render_permission_summary(None).is_none());
    }

    #[test]
    fn summary_states_files_os_enforced_and_commands_advisory() {
        // The honesty invariant: files are OS-enforced (Landlock when supported)
        // while commands stay advisory / NOT OS-locked. Both must be explicit.
        let set = PermissionSet::new(
            vec![
                PermissionRule::file(Capability::Read, Effect::Allow, path_scope(&["src/**"]))
                    .unwrap(),
                PermissionRule::bash(
                    Effect::Deny,
                    vec![CommandRule::new(
                        CommandMatcher::prefix("rm ").unwrap(),
                        Effect::Deny,
                    )],
                ),
            ],
            Posture::Ask,
        );
        let eff = resolve(Some(&set), None).unwrap();
        let md = render_permission_summary(Some(&eff)).expect("a posed policy renders a summary");

        // Files: OS-enforced / Landlock when supported.
        assert!(
            md.contains("OS-enforced"),
            "files block must say OS-enforced"
        );
        assert!(md.contains("Landlock"), "files block must name Landlock");
        // Commands: advisory, NOT OS-locked, and the why (ExecuteBash).
        assert!(md.contains("advisory"), "commands must be called advisory");
        assert!(
            md.contains("NOT OS-locked"),
            "commands must be flagged NOT OS-locked"
        );
        assert!(
            md.contains("ExecuteBash"),
            "the rationale must name the ExecuteBash capability"
        );
        // The actual rules surface in their respective sections.
        assert!(md.contains("`src/**`"), "the file scope is shown");
        assert!(
            md.contains("`rm *` (prefix)"),
            "the command matcher is shown"
        );
        // Resolved posture is surfaced.
        assert!(md.contains("**Default posture:** Ask"));
    }

    #[test]
    fn summary_handles_empty_rule_lists_per_section() {
        // A policy with only a fallback still renders both sections honestly.
        let set = PermissionSet::new(vec![], Posture::Deny);
        let eff = resolve(Some(&set), None).unwrap();
        let md = render_permission_summary(Some(&eff)).unwrap();
        assert!(md.contains("no file rule"));
        assert!(md.contains("no command rule"));
        // The enforcement-boundary wording is present even with no rules.
        assert!(md.contains("OS-enforced") && md.contains("NOT OS-locked"));
        assert!(md.contains("**Default posture:** Deny"));
    }

    // ---- opencode_permission_block (ticket #94) --------------------------

    #[test]
    fn opencode_permission_block_none_when_nothing_posed() {
        assert!(opencode_permission_block(None).is_none());
    }

    #[test]
    fn opencode_permission_block_maps_bash_from_decide_bash_and_edit_from_write() {
        // Distinct rules per capability so bash/edit can't be conflated: bash is
        // allowed, Write is denied, fallback stays Ask (never consulted here).
        let set = PermissionSet::new(
            vec![
                PermissionRule::bash(Effect::Allow, vec![]),
                PermissionRule::file(Capability::Write, Effect::Deny, path_scope(&["**"])).unwrap(),
            ],
            Posture::Ask,
        );
        let eff = resolve(Some(&set), None).unwrap();
        let block = opencode_permission_block(Some(&eff)).unwrap();
        assert_eq!(block["bash"], "allow");
        assert_eq!(block["edit"], "deny");
    }

    #[test]
    fn opencode_permission_block_falls_back_when_no_rule_matches() {
        for (fallback, expected) in [
            (Posture::Allow, "allow"),
            (Posture::Ask, "ask"),
            (Posture::Deny, "deny"),
        ] {
            let set = PermissionSet::new(vec![], fallback);
            let eff = resolve(Some(&set), None).unwrap();
            let block = opencode_permission_block(Some(&eff)).unwrap();
            assert_eq!(block["bash"], expected);
            assert_eq!(block["edit"], expected);
        }
    }

    #[test]
    fn opencode_permission_block_omits_read_and_delete_keys() {
        let set = PermissionSet::new(vec![], Posture::Deny);
        let eff = resolve(Some(&set), None).unwrap();
        let block = opencode_permission_block(Some(&eff)).unwrap();
        let obj = block.as_object().unwrap();
        assert_eq!(obj.len(), 2, "only bash/edit are opencode-expressible");
        assert!(!obj.contains_key("read"));
        assert!(!obj.contains_key("delete"));
    }
}
