//! Durable MCP tool permissions for IdeA agents.
//!
//! This model is deliberately separate from [`crate::permission`]: file/process
//! permissions and Landlock sandboxing are OS-level capabilities, while MCP tools
//! are application capabilities enforced before dispatch.

use std::collections::HashSet;

use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::ids::AgentId;

/// Current schema version for `.ideai/mcp-tool-permissions.json`.
pub const MCP_TOOL_PERMISSIONS_VERSION: u32 = 1;

/// Validation errors for durable MCP tool permission documents.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum McpToolPermissionError {
    /// The stored document uses an unsupported schema version.
    #[error("unsupported MCP tool permissions version: {0}")]
    UnsupportedVersion(u32),
    /// A tool name is empty.
    #[error("MCP tool name cannot be empty")]
    EmptyToolName,
    /// A tool name is not in the known MCP catalogue.
    #[error("unknown MCP tool: {0}")]
    UnknownTool(String),
    /// A tool appears more than once in one allowlist.
    #[error("duplicate MCP tool: {0}")]
    DuplicateTool(String),
    /// An agent has more than one override entry.
    #[error("duplicate MCP tool policy override for agent {0}")]
    DuplicateAgent(AgentId),
}

/// Allowlist of MCP tools.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpToolPolicy {
    /// Exact MCP tool names allowed by this policy.
    pub allowed_tools: Vec<String>,
}

impl McpToolPolicy {
    /// Builds and validates a policy against the known catalogue.
    ///
    /// # Errors
    /// [`McpToolPermissionError`] when a name is empty, duplicate, or unknown.
    pub fn new(
        allowed_tools: Vec<String>,
        known_tools: &[&str],
    ) -> Result<Self, McpToolPermissionError> {
        let policy = Self { allowed_tools };
        policy.validate(known_tools)?;
        Ok(policy)
    }

    /// Builds the canonical read-only fallback policy.
    ///
    /// # Errors
    /// [`McpToolPermissionError`] if the provided read-only set is not a valid
    /// subset of the known catalogue.
    pub fn read_only(
        read_only_tools: &[&str],
        known_tools: &[&str],
    ) -> Result<Self, McpToolPermissionError> {
        Self::new(
            read_only_tools
                .iter()
                .map(|tool| (*tool).to_owned())
                .collect(),
            known_tools,
        )
    }

    /// Returns whether `tool` is allowed by this allowlist.
    #[must_use]
    pub fn permits(&self, tool: &str) -> bool {
        self.allowed_tools.iter().any(|allowed| allowed == tool)
    }

    /// Validates the policy against a known catalogue.
    ///
    /// # Errors
    /// [`McpToolPermissionError`] when a name is empty, duplicate, or unknown.
    pub fn validate(&self, known_tools: &[&str]) -> Result<(), McpToolPermissionError> {
        let known = known_tools.iter().copied().collect::<HashSet<_>>();
        let mut seen = HashSet::new();
        for tool in &self.allowed_tools {
            if tool.is_empty() {
                return Err(McpToolPermissionError::EmptyToolName);
            }
            if !known.contains(tool.as_str()) {
                return Err(McpToolPermissionError::UnknownTool(tool.clone()));
            }
            if !seen.insert(tool.as_str()) {
                return Err(McpToolPermissionError::DuplicateTool(tool.clone()));
            }
        }
        Ok(())
    }
}

/// Agent-specific policy override.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentMcpToolPolicyOverride {
    /// Agent whose effective policy is overridden.
    pub agent_id: AgentId,
    /// Replacement policy for the agent.
    pub policy: McpToolPolicy,
}

impl AgentMcpToolPolicyOverride {
    /// Builds an agent override.
    #[must_use]
    pub fn new(agent_id: AgentId, policy: McpToolPolicy) -> Self {
        Self { agent_id, policy }
    }
}

/// Project document persisted as `.ideai/mcp-tool-permissions.json`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectMcpToolPermissions {
    /// Schema version.
    pub version: u32,
    /// Optional project-wide default policy.
    pub project_default: Option<McpToolPolicy>,
    /// Sparse agent overrides.
    pub agents: Vec<AgentMcpToolPolicyOverride>,
}

impl Default for ProjectMcpToolPermissions {
    fn default() -> Self {
        Self {
            version: MCP_TOOL_PERMISSIONS_VERSION,
            project_default: None,
            agents: Vec::new(),
        }
    }
}

impl ProjectMcpToolPermissions {
    /// Builds and validates a permissions document.
    ///
    /// # Errors
    /// [`McpToolPermissionError`] when the document contains invalid policies or
    /// duplicate agent overrides.
    pub fn new(
        project_default: Option<McpToolPolicy>,
        agents: Vec<AgentMcpToolPolicyOverride>,
        known_tools: &[&str],
    ) -> Result<Self, McpToolPermissionError> {
        let doc = Self {
            version: MCP_TOOL_PERMISSIONS_VERSION,
            project_default,
            agents,
        };
        doc.validate(known_tools)?;
        Ok(doc)
    }

    /// Validates version, policy contents, and override uniqueness.
    ///
    /// # Errors
    /// [`McpToolPermissionError`] when an invariant is violated.
    pub fn validate(&self, known_tools: &[&str]) -> Result<(), McpToolPermissionError> {
        if self.version != MCP_TOOL_PERMISSIONS_VERSION {
            return Err(McpToolPermissionError::UnsupportedVersion(self.version));
        }
        if let Some(policy) = &self.project_default {
            policy.validate(known_tools)?;
        }
        let mut seen_agents = HashSet::new();
        for override_ in &self.agents {
            if !seen_agents.insert(override_.agent_id) {
                return Err(McpToolPermissionError::DuplicateAgent(override_.agent_id));
            }
            override_.policy.validate(known_tools)?;
        }
        Ok(())
    }

    /// Resolves the effective policy for `agent_id`.
    ///
    /// Resolution order: agent override > project default > canonical read-only
    /// fallback.
    ///
    /// # Errors
    /// [`McpToolPermissionError`] if the document or fallback set is invalid.
    pub fn effective_policy(
        &self,
        agent_id: AgentId,
        read_only_tools: &[&str],
        known_tools: &[&str],
    ) -> Result<McpToolPolicy, McpToolPermissionError> {
        self.validate(known_tools)?;
        if let Some(override_) = self
            .agents
            .iter()
            .find(|override_| override_.agent_id == agent_id)
        {
            return Ok(override_.policy.clone());
        }
        if let Some(policy) = &self.project_default {
            return Ok(policy.clone());
        }
        McpToolPolicy::read_only(read_only_tools, known_tools)
    }
}

#[cfg(test)]
mod tests {
    use uuid::Uuid;

    use super::*;

    const KNOWN: &[&str] = &["idea_memory_read", "idea_memory_write", "idea_ticket_list"];
    const READ_ONLY: &[&str] = &["idea_memory_read", "idea_ticket_list"];

    fn agent(n: u128) -> AgentId {
        AgentId::from_uuid(Uuid::from_u128(n))
    }

    #[test]
    fn effective_policy_falls_back_to_read_only_when_unconfigured() {
        let doc = ProjectMcpToolPermissions::default();

        let effective = doc.effective_policy(agent(1), READ_ONLY, KNOWN).unwrap();

        assert!(effective.permits("idea_memory_read"));
        assert!(effective.permits("idea_ticket_list"));
        assert!(!effective.permits("idea_memory_write"));
    }

    #[test]
    fn agent_override_replaces_project_default() {
        let project_default =
            McpToolPolicy::new(vec!["idea_memory_read".to_owned()], KNOWN).unwrap();
        let override_policy =
            McpToolPolicy::new(vec!["idea_memory_write".to_owned()], KNOWN).unwrap();
        let doc = ProjectMcpToolPermissions::new(
            Some(project_default),
            vec![AgentMcpToolPolicyOverride::new(agent(7), override_policy)],
            KNOWN,
        )
        .unwrap();

        let effective = doc.effective_policy(agent(7), READ_ONLY, KNOWN).unwrap();

        assert!(effective.permits("idea_memory_write"));
        assert!(!effective.permits("idea_memory_read"));
    }

    #[test]
    fn project_default_applies_when_no_agent_override_exists() {
        let project_default =
            McpToolPolicy::new(vec!["idea_memory_write".to_owned()], KNOWN).unwrap();
        let doc = ProjectMcpToolPermissions::new(Some(project_default), Vec::new(), KNOWN).unwrap();

        let effective = doc.effective_policy(agent(8), READ_ONLY, KNOWN).unwrap();

        assert!(effective.permits("idea_memory_write"));
        assert!(!effective.permits("idea_memory_read"));
    }

    #[test]
    fn unknown_tool_is_rejected_in_allowlist() {
        let err = McpToolPolicy::new(vec!["idea_unknown".to_owned()], KNOWN).unwrap_err();

        assert_eq!(
            err,
            McpToolPermissionError::UnknownTool("idea_unknown".to_owned())
        );
    }

    #[test]
    fn duplicate_tool_is_rejected_in_allowlist() {
        let err = McpToolPolicy::new(
            vec!["idea_memory_read".to_owned(), "idea_memory_read".to_owned()],
            KNOWN,
        )
        .unwrap_err();

        assert_eq!(
            err,
            McpToolPermissionError::DuplicateTool("idea_memory_read".to_owned())
        );
    }

    #[test]
    fn empty_tool_name_is_rejected_in_allowlist() {
        let err = McpToolPolicy::new(vec![String::new()], KNOWN).unwrap_err();

        assert_eq!(err, McpToolPermissionError::EmptyToolName);
    }

    #[test]
    fn duplicate_agent_override_is_rejected() {
        let err = ProjectMcpToolPermissions::new(
            None,
            vec![
                AgentMcpToolPolicyOverride::new(agent(3), McpToolPolicy::default()),
                AgentMcpToolPolicyOverride::new(agent(3), McpToolPolicy::default()),
            ],
            KNOWN,
        )
        .unwrap_err();

        assert_eq!(err, McpToolPermissionError::DuplicateAgent(agent(3)));
    }
}
