//! MCP tool policy for constrained assistant sessions.
//!
//! This is a pure domain value. It models what a requester may do through the
//! MCP surface; it does not know how the MCP server enforces it.

use serde::{Deserialize, Serialize};

use crate::issue::IssueRef;

/// Tool policy attached to one agent/requester.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentToolPolicy {
    /// Explicitly allowed MCP tool names.
    pub allow: Vec<String>,
    /// Optional issue boundary for ticket mutations.
    pub bound_issue: Option<IssueRef>,
    /// Whether tools outside [`Self::allow`] are denied.
    pub deny_others: bool,
}

impl AgentToolPolicy {
    /// Builds a tool policy.
    #[must_use]
    pub fn new(allow: Vec<String>, bound_issue: Option<IssueRef>, deny_others: bool) -> Self {
        Self {
            allow,
            bound_issue,
            deny_others,
        }
    }

    /// Returns true when `tool` is allowed by the allowlist/default rule.
    #[must_use]
    pub fn permits(&self, tool: &str) -> bool {
        self.allow.iter().any(|allowed| allowed == tool) || !self.deny_others
    }

    /// Returns true when `tool` may mutate the supplied ticket reference.
    ///
    /// The method first applies the tool allowlist. Then, for known ticket mutation
    /// tools, an optional [`Self::bound_issue`] fences the mutation to that single
    /// issue reference.
    #[must_use]
    pub fn permits_ticket_mutation(&self, tool: &str, issue_ref: IssueRef) -> bool {
        if !self.permits(tool) {
            return false;
        }
        if !is_ticket_mutation_tool(tool) {
            return true;
        }
        self.bound_issue.map_or(true, |bound| bound == issue_ref)
    }
}

fn is_ticket_mutation_tool(tool: &str) -> bool {
    matches!(
        tool,
        "idea_ticket_update"
            | "idea_ticket_update_status"
            | "idea_ticket_update_priority"
            | "idea_ticket_update_carnet"
            | "idea_ticket_link"
            | "idea_ticket_unlink"
            | "idea_ticket_create"
    )
}

#[cfg(test)]
mod tests {
    use std::str::FromStr;

    use super::*;

    fn issue_ref(raw: &str) -> IssueRef {
        IssueRef::from_str(raw).unwrap()
    }

    #[test]
    fn permits_allowlisted_tool_when_deny_others_is_true() {
        let policy = AgentToolPolicy::new(vec!["idea_ticket_read".to_owned()], None, true);
        assert!(policy.permits("idea_ticket_read"));
        assert!(!policy.permits("idea_ask_agent"));
    }

    #[test]
    fn deny_others_false_allows_non_listed_tools() {
        let policy = AgentToolPolicy::new(vec!["idea_ticket_read".to_owned()], None, false);
        assert!(policy.permits("idea_ticket_read"));
        assert!(policy.permits("idea_ask_agent"));
    }

    #[test]
    fn ticket_mutation_is_bound_to_the_configured_issue() {
        let policy = AgentToolPolicy::new(
            vec!["idea_ticket_update".to_owned()],
            Some(issue_ref("#7")),
            true,
        );
        assert!(policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#7")));
        assert!(!policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#8")));
    }

    #[test]
    fn ticket_mutation_requires_the_tool_itself_to_be_allowed() {
        let policy = AgentToolPolicy::new(
            vec!["idea_ticket_read".to_owned()],
            Some(issue_ref("#7")),
            true,
        );
        assert!(!policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#7")));
    }
}
