//! MCP tool permission use cases.
//!
//! These use cases expose the durable per-project MCP tool policy document while
//! keeping the application layer independent from the concrete MCP catalogue
//! adapter. The catalogue classification is injected by the composition root.

use std::collections::HashSet;
use std::sync::Arc;

use domain::ports::McpToolPermissionStore;
use domain::{
    AgentId, AgentMcpToolPolicyOverride, McpToolPermissionError, McpToolPolicy, Project,
    ProjectMcpToolPermissions,
};

use crate::error::AppError;

/// Read/write MCP tool catalogue classification used to validate policies.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpToolPermissionCatalogue {
    /// Tools allowed by the default fallback policy.
    pub read_only_tools: Vec<String>,
    /// Tools denied by default because they write, act, or execute.
    pub write_action_tools: Vec<String>,
}

impl McpToolPermissionCatalogue {
    /// Builds a catalogue from read-only and write/action tool names.
    ///
    /// # Errors
    /// [`AppError::Invalid`] if a tool name is empty or appears twice.
    pub fn new(
        read_only_tools: Vec<String>,
        write_action_tools: Vec<String>,
    ) -> Result<Self, AppError> {
        let catalogue = Self {
            read_only_tools,
            write_action_tools,
        };
        catalogue.validate()?;
        Ok(catalogue)
    }

    /// Returns all known tool names as borrowed strings.
    #[must_use]
    pub fn known_tool_refs(&self) -> Vec<&str> {
        self.read_only_tools
            .iter()
            .chain(self.write_action_tools.iter())
            .map(String::as_str)
            .collect()
    }

    fn validate(&self) -> Result<(), AppError> {
        let mut seen = HashSet::new();
        for tool in self
            .read_only_tools
            .iter()
            .chain(self.write_action_tools.iter())
        {
            if tool.is_empty() {
                return Err(AppError::Invalid(
                    "MCP tool name cannot be empty".to_owned(),
                ));
            }
            if !seen.insert(tool.as_str()) {
                return Err(AppError::Invalid(format!("duplicate MCP tool: {tool}")));
            }
        }
        Ok(())
    }
}

/// Reads durable MCP tool permissions for a project.
pub struct ReadMcpToolPermissions {
    store: Arc<dyn McpToolPermissionStore>,
    catalogue: McpToolPermissionCatalogue,
}

impl ReadMcpToolPermissions {
    /// Builds the use case.
    #[must_use]
    pub fn new(
        store: Arc<dyn McpToolPermissionStore>,
        catalogue: McpToolPermissionCatalogue,
    ) -> Self {
        Self { store, catalogue }
    }

    /// Executes the read.
    ///
    /// # Errors
    /// [`AppError`] on store or validation failure.
    pub async fn execute(
        &self,
        input: ReadMcpToolPermissionsInput,
    ) -> Result<ReadMcpToolPermissionsOutput, AppError> {
        let permissions = self.store.load_mcp_tool_permissions(&input.project).await?;
        validate_doc(&permissions, &self.catalogue)?;
        Ok(ReadMcpToolPermissionsOutput {
            catalogue: self.catalogue.clone(),
            permissions,
        })
    }
}

/// Input for [`ReadMcpToolPermissions`].
pub struct ReadMcpToolPermissionsInput {
    /// Target project.
    pub project: Project,
}

/// Output for MCP tool permission reads and writes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadMcpToolPermissionsOutput {
    /// Canonical catalogue classification.
    pub catalogue: McpToolPermissionCatalogue,
    /// Persisted permission document.
    pub permissions: ProjectMcpToolPermissions,
}

/// Replaces the project-wide default MCP tool policy.
pub struct UpdateProjectMcpToolPermissions {
    store: Arc<dyn McpToolPermissionStore>,
    catalogue: McpToolPermissionCatalogue,
}

impl UpdateProjectMcpToolPermissions {
    /// Builds the use case.
    #[must_use]
    pub fn new(
        store: Arc<dyn McpToolPermissionStore>,
        catalogue: McpToolPermissionCatalogue,
    ) -> Self {
        Self { store, catalogue }
    }

    /// Executes the mutation.
    ///
    /// # Errors
    /// [`AppError`] on store or validation failure.
    pub async fn execute(
        &self,
        input: UpdateProjectMcpToolPermissionsInput,
    ) -> Result<ReadMcpToolPermissionsOutput, AppError> {
        validate_policy(input.policy.as_ref(), &self.catalogue)?;
        let mut doc = self.store.load_mcp_tool_permissions(&input.project).await?;
        doc.project_default = input.policy;
        validate_doc(&doc, &self.catalogue)?;
        self.store
            .save_mcp_tool_permissions(&input.project, &doc)
            .await?;
        Ok(ReadMcpToolPermissionsOutput {
            catalogue: self.catalogue.clone(),
            permissions: doc,
        })
    }
}

/// Input for [`UpdateProjectMcpToolPermissions`].
pub struct UpdateProjectMcpToolPermissionsInput {
    /// Target project.
    pub project: Project,
    /// New project default policy. `None` removes the project default.
    pub policy: Option<McpToolPolicy>,
}

/// Replaces or removes one agent MCP tool policy override.
pub struct UpdateAgentMcpToolPermissions {
    store: Arc<dyn McpToolPermissionStore>,
    catalogue: McpToolPermissionCatalogue,
}

impl UpdateAgentMcpToolPermissions {
    /// Builds the use case.
    #[must_use]
    pub fn new(
        store: Arc<dyn McpToolPermissionStore>,
        catalogue: McpToolPermissionCatalogue,
    ) -> Self {
        Self { store, catalogue }
    }

    /// Executes the mutation.
    ///
    /// # Errors
    /// [`AppError`] on store or validation failure.
    pub async fn execute(
        &self,
        input: UpdateAgentMcpToolPermissionsInput,
    ) -> Result<ReadMcpToolPermissionsOutput, AppError> {
        validate_policy(input.policy.as_ref(), &self.catalogue)?;
        let mut doc = self.store.load_mcp_tool_permissions(&input.project).await?;
        doc.agents
            .retain(|override_| override_.agent_id != input.agent_id);
        if let Some(policy) = input.policy {
            doc.agents
                .push(AgentMcpToolPolicyOverride::new(input.agent_id, policy));
        }
        validate_doc(&doc, &self.catalogue)?;
        self.store
            .save_mcp_tool_permissions(&input.project, &doc)
            .await?;
        Ok(ReadMcpToolPermissionsOutput {
            catalogue: self.catalogue.clone(),
            permissions: doc,
        })
    }
}

/// Input for [`UpdateAgentMcpToolPermissions`].
pub struct UpdateAgentMcpToolPermissionsInput {
    /// Target project.
    pub project: Project,
    /// Target agent.
    pub agent_id: AgentId,
    /// New override policy. `None` removes the override.
    pub policy: Option<McpToolPolicy>,
}

fn validate_policy(
    policy: Option<&McpToolPolicy>,
    catalogue: &McpToolPermissionCatalogue,
) -> Result<(), AppError> {
    if let Some(policy) = policy {
        policy
            .validate(&catalogue.known_tool_refs())
            .map_err(permission_error)?;
    }
    Ok(())
}

fn validate_doc(
    doc: &ProjectMcpToolPermissions,
    catalogue: &McpToolPermissionCatalogue,
) -> Result<(), AppError> {
    doc.validate(&catalogue.known_tool_refs())
        .map_err(permission_error)
}

fn permission_error(error: McpToolPermissionError) -> AppError {
    AppError::Invalid(error.to_string())
}

#[cfg(test)]
mod tests {
    use std::sync::Mutex;

    use async_trait::async_trait;
    use domain::ids::ProjectId;
    use domain::ports::StoreError;
    use domain::project::ProjectPath;
    use domain::remote::RemoteRef;
    use uuid::Uuid;

    use super::*;

    struct FakeStore {
        doc: Mutex<ProjectMcpToolPermissions>,
    }

    impl FakeStore {
        fn new(doc: ProjectMcpToolPermissions) -> Self {
            Self {
                doc: Mutex::new(doc),
            }
        }
    }

    #[async_trait]
    impl McpToolPermissionStore for FakeStore {
        async fn load_mcp_tool_permissions(
            &self,
            _project: &Project,
        ) -> Result<ProjectMcpToolPermissions, StoreError> {
            Ok(self.doc.lock().unwrap().clone())
        }

        async fn save_mcp_tool_permissions(
            &self,
            _project: &Project,
            permissions: &ProjectMcpToolPermissions,
        ) -> Result<(), StoreError> {
            *self.doc.lock().unwrap() = permissions.clone();
            Ok(())
        }
    }

    fn catalogue() -> McpToolPermissionCatalogue {
        McpToolPermissionCatalogue::new(
            vec!["idea_memory_read".to_owned(), "idea_ticket_list".to_owned()],
            vec!["idea_memory_write".to_owned(), "idea_ask_agent".to_owned()],
        )
        .unwrap()
    }

    fn project() -> Project {
        Project::new(
            ProjectId::from_uuid(Uuid::from_u128(1)),
            "demo",
            ProjectPath::new("/tmp/project").unwrap(),
            RemoteRef::local(),
            1_000,
        )
        .unwrap()
    }

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

    fn store(doc: ProjectMcpToolPermissions) -> Arc<dyn McpToolPermissionStore> {
        Arc::new(FakeStore::new(doc))
    }

    #[tokio::test]
    async fn read_returns_classified_catalogue_and_current_document() {
        let policy = McpToolPolicy::new(
            vec!["idea_memory_write".to_owned()],
            &catalogue().known_tool_refs(),
        )
        .unwrap();
        let doc = ProjectMcpToolPermissions::new(
            Some(policy),
            Vec::new(),
            &catalogue().known_tool_refs(),
        )
        .unwrap();
        let use_case = ReadMcpToolPermissions::new(store(doc.clone()), catalogue());

        let output = use_case
            .execute(ReadMcpToolPermissionsInput { project: project() })
            .await
            .unwrap();

        assert_eq!(output.permissions, doc);
        assert_eq!(
            output.catalogue.read_only_tools,
            vec!["idea_memory_read", "idea_ticket_list"]
        );
        assert_eq!(
            output.catalogue.write_action_tools,
            vec!["idea_memory_write", "idea_ask_agent"]
        );
    }

    #[tokio::test]
    async fn update_rejects_unknown_tool() {
        let use_case = UpdateAgentMcpToolPermissions::new(
            store(ProjectMcpToolPermissions::default()),
            catalogue(),
        );

        let err = use_case
            .execute(UpdateAgentMcpToolPermissionsInput {
                project: project(),
                agent_id: agent(7),
                policy: Some(McpToolPolicy {
                    allowed_tools: vec!["idea_unknown".to_owned()],
                }),
            })
            .await
            .unwrap_err();

        assert!(matches!(err, AppError::Invalid(message) if message.contains("idea_unknown")));
    }

    #[tokio::test]
    async fn update_agent_override_roundtrips_through_read() {
        let shared = store(ProjectMcpToolPermissions::default());
        let update = UpdateAgentMcpToolPermissions::new(Arc::clone(&shared), catalogue());
        let read = ReadMcpToolPermissions::new(Arc::clone(&shared), catalogue());
        let agent_id = agent(9);

        update
            .execute(UpdateAgentMcpToolPermissionsInput {
                project: project(),
                agent_id,
                policy: Some(
                    McpToolPolicy::new(
                        vec!["idea_memory_write".to_owned()],
                        &catalogue().known_tool_refs(),
                    )
                    .unwrap(),
                ),
            })
            .await
            .unwrap();

        let output = read
            .execute(ReadMcpToolPermissionsInput { project: project() })
            .await
            .unwrap();

        assert_eq!(output.permissions.agents.len(), 1);
        assert_eq!(output.permissions.agents[0].agent_id, agent_id);
        assert_eq!(
            output.permissions.agents[0].policy.allowed_tools,
            vec!["idea_memory_write"]
        );
    }
}
