//! Declarative, project-authored deny rules for the [`PolicyEngine`]
//! (EPIC A / task A2).
//!
//! Historically `PolicyEngine` rules were only ever registered in code —
//! the hardcoded coder prohibitions, a handful of test closures — so an
//! operator had no way to forbid a tool or parameter for *their* project
//! without patching CAR. This module adds a TOML rule format, auto-loaded
//! from a project's `.car/policies/` directory (the same `.car/`
//! convention the engine already discovers by walking up from cwd), and
//! lowers each declarative rule into a [`PolicyCheck`] closure registered
//! on a `PolicyEngine`.
//!
//! ```toml
//! # .car/policies/security.toml
//! deny_tool    = ["deploy", "rm"]
//! deny_keyword = ["DROP TABLE", "rm -rf /"]
//!
//! [[deny_tool_param]]
//! tool     = "http_request"
//! param    = "url"
//! contains = "169.254.169.254"   # block cloud metadata exfiltration
//!
//! [[deny_tool_param]]
//! tool   = "shell"
//! param  = "command"
//! equals = "shutdown"
//! ```
//!
//! Rules are *deny*-only: matching an action produces a violation, which —
//! once A9 makes `PolicyEngine` violations blocking at admission — refuses
//! the proposal. Loading is strict: a malformed file is a loud error, not
//! a silently-skipped rule (a dropped security rule is worse than a failed
//! boot).

use crate::{PolicyCheck, PolicyEngine};
use car_ir::Action;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fmt;
use std::path::{Path, PathBuf};

/// A single `deny_tool_param` rule: forbid calling `tool` when its `param`
/// matches a condition. Exactly one of `equals` / `contains` should be set;
/// if both are set both must match, if neither is set the rule matches any
/// call to `tool` that carries `param` at all (presence-deny).
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct DenyToolParam {
    /// Tool name the rule applies to.
    pub tool: String,
    /// Parameter key inspected on the action.
    pub param: String,
    /// Forbid when the parameter equals this JSON value exactly.
    #[serde(default)]
    pub equals: Option<Value>,
    /// Forbid when the parameter's string form contains this substring
    /// (case-sensitive).
    #[serde(default)]
    pub contains: Option<String>,
}

impl DenyToolParam {
    /// Does this rule forbid the given action?
    fn matches(&self, action: &Action) -> bool {
        if action.tool.as_deref() != Some(self.tool.as_str()) {
            return false;
        }
        let Some(val) = action.parameters.get(&self.param) else {
            return false; // param absent → nothing to forbid
        };
        // Presence-deny when no condition is given.
        if self.equals.is_none() && self.contains.is_none() {
            return true;
        }
        let mut ok = true;
        if let Some(expected) = &self.equals {
            ok &= val == expected;
        }
        if let Some(needle) = &self.contains {
            let hay = match val {
                Value::String(s) => s.clone(),
                other => other.to_string(),
            };
            ok &= hay.contains(needle);
        }
        ok
    }
}

/// A project's declarative policy rule set — the deserialized union of
/// every `.car/policies/*.toml` file.
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
pub struct PolicyRules {
    /// Tool names that may never be invoked.
    #[serde(default)]
    pub deny_tool: Vec<String>,
    /// Substrings that, if present in any string-valued parameter of any
    /// action, forbid that action. The coarse "never let this text near a
    /// tool" guard (e.g. `rm -rf /`, SQL drops).
    #[serde(default)]
    pub deny_keyword: Vec<String>,
    /// Per-tool parameter conditions.
    #[serde(default)]
    pub deny_tool_param: Vec<DenyToolParam>,
}

impl PolicyRules {
    /// Fold another rule set into this one (used to merge multiple files).
    pub fn merge(&mut self, other: PolicyRules) {
        self.deny_tool.extend(other.deny_tool);
        self.deny_keyword.extend(other.deny_keyword);
        self.deny_tool_param.extend(other.deny_tool_param);
    }

    /// True when no rules are defined.
    pub fn is_empty(&self) -> bool {
        self.deny_tool.is_empty() && self.deny_keyword.is_empty() && self.deny_tool_param.is_empty()
    }

    /// Parse a single TOML document into a rule set.
    pub fn from_toml(src: &str) -> Result<PolicyRules, PolicyLoadError> {
        toml::from_str(src).map_err(|e| PolicyLoadError::Parse {
            path: None,
            message: e.to_string(),
        })
    }

    /// Register every rule in this set as a [`PolicyCheck`] on `engine`.
    ///
    /// Rule names are stable and descriptive so a violation report names
    /// the offending rule (`deny_tool:deploy`, `deny_keyword:rm -rf /`,
    /// `deny_tool_param:http_request.url`).
    pub fn apply(&self, engine: &mut PolicyEngine) {
        for tool in &self.deny_tool {
            let tool = tool.clone();
            let name = format!("deny_tool:{tool}");
            let desc = format!("project .car/policies deny_tool: {tool}");
            let check: PolicyCheck = Box::new(move |action: &Action, _state| {
                if action.tool.as_deref() == Some(tool.as_str()) {
                    Some(format!("tool '{tool}' is denied by project policy"))
                } else {
                    None
                }
            });
            engine.register(&name, check, &desc);
        }

        for kw in &self.deny_keyword {
            let kw = kw.clone();
            let name = format!("deny_keyword:{kw}");
            let desc = format!("project .car/policies deny_keyword: {kw}");
            let check: PolicyCheck = Box::new(move |action: &Action, _state| {
                for (k, v) in &action.parameters {
                    let hay = match v {
                        Value::String(s) => s.clone(),
                        other => other.to_string(),
                    };
                    if hay.contains(&kw) {
                        return Some(format!("parameter '{k}' contains denied keyword '{kw}'"));
                    }
                }
                None
            });
            engine.register(&name, check, &desc);
        }

        for rule in &self.deny_tool_param {
            let rule = rule.clone();
            let name = format!("deny_tool_param:{}.{}", rule.tool, rule.param);
            let desc = format!(
                "project .car/policies deny_tool_param on {}.{}",
                rule.tool, rule.param
            );
            let check: PolicyCheck = Box::new(move |action: &Action, _state| {
                if rule.matches(action) {
                    Some(format!(
                        "tool '{}' parameter '{}' is denied by project policy",
                        rule.tool, rule.param
                    ))
                } else {
                    None
                }
            });
            engine.register(&name, check, &desc);
        }
    }
}

/// Load and merge every `*.toml` file in a `.car/policies/` directory.
///
/// Files are read in sorted order for determinism. A missing directory is
/// not an error — it yields an empty rule set (most projects have none).
/// A present-but-malformed file *is* an error: a security rule that fails
/// to parse must surface, never be silently skipped.
pub fn load_policy_dir(dir: impl AsRef<Path>) -> Result<PolicyRules, PolicyLoadError> {
    let dir = dir.as_ref();
    if !dir.exists() {
        return Ok(PolicyRules::default());
    }
    let mut files: Vec<PathBuf> = std::fs::read_dir(dir)
        .map_err(|e| PolicyLoadError::Io {
            path: dir.to_path_buf(),
            message: e.to_string(),
        })?
        .filter_map(|e| e.ok().map(|e| e.path()))
        .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("toml"))
        .collect();
    files.sort();

    let mut merged = PolicyRules::default();
    for path in files {
        let src = std::fs::read_to_string(&path).map_err(|e| PolicyLoadError::Io {
            path: path.clone(),
            message: e.to_string(),
        })?;
        let rules = PolicyRules::from_toml(&src).map_err(|e| match e {
            PolicyLoadError::Parse { message, .. } => PolicyLoadError::Parse {
                path: Some(path.clone()),
                message,
            },
            other => other,
        })?;
        merged.merge(rules);
    }
    Ok(merged)
}

/// Errors raised while loading project policy rules.
#[derive(Debug, Clone)]
pub enum PolicyLoadError {
    /// A file or directory could not be read.
    Io { path: PathBuf, message: String },
    /// A TOML document failed to parse.
    Parse {
        path: Option<PathBuf>,
        message: String,
    },
}

impl fmt::Display for PolicyLoadError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            PolicyLoadError::Io { path, message } => {
                write!(f, "policy I/O error at {}: {message}", path.display())
            }
            PolicyLoadError::Parse { path, message } => match path {
                Some(p) => write!(f, "policy parse error in {}: {message}", p.display()),
                None => write!(f, "policy parse error: {message}"),
            },
        }
    }
}

impl std::error::Error for PolicyLoadError {}

#[cfg(test)]
mod tests {
    use super::*;
    use car_ir::{Action, ActionType, FailureBehavior};
    use car_state::StateStore;
    use std::collections::HashMap;

    fn tool_action(tool: &str, params: HashMap<String, Value>) -> Action {
        Action {
            id: "a1".to_string(),
            action_type: ActionType::ToolCall,
            tool: Some(tool.to_string()),
            parameters: params,
            preconditions: vec![],
            expected_effects: HashMap::new(),
            state_dependencies: vec![],
            read_set: vec![],
            write_set: vec![],
            assumptions: vec![],
            invocation_mode: Default::default(),
            idempotent: false,
            max_retries: 0,
            failure_behavior: FailureBehavior::Abort,
            timeout_ms: None,
            metadata: HashMap::new(),
        }
    }

    #[test]
    fn parses_full_document() {
        let src = r#"
            deny_tool = ["deploy", "rm"]
            deny_keyword = ["DROP TABLE"]

            [[deny_tool_param]]
            tool = "http_request"
            param = "url"
            contains = "169.254.169.254"
        "#;
        let rules = PolicyRules::from_toml(src).unwrap();
        assert_eq!(rules.deny_tool, vec!["deploy", "rm"]);
        assert_eq!(rules.deny_keyword, vec!["DROP TABLE"]);
        assert_eq!(rules.deny_tool_param.len(), 1);
        assert_eq!(rules.deny_tool_param[0].tool, "http_request");
    }

    #[test]
    fn deny_tool_blocks_named_tool() {
        let mut engine = PolicyEngine::new();
        PolicyRules {
            deny_tool: vec!["deploy".to_string()],
            ..Default::default()
        }
        .apply(&mut engine);
        let state = StateStore::new();
        // The denied tool produces a violation...
        let v = engine.check(&tool_action("deploy", HashMap::new()), &state);
        assert_eq!(v.len(), 1);
        assert!(v[0].reason.contains("denied by project policy"));
        // ...an unrelated tool does not.
        assert!(engine
            .check(&tool_action("echo", HashMap::new()), &state)
            .is_empty());
    }

    #[test]
    fn deny_keyword_scans_params() {
        let mut engine = PolicyEngine::new();
        PolicyRules {
            deny_keyword: vec!["rm -rf /".to_string()],
            ..Default::default()
        }
        .apply(&mut engine);
        let state = StateStore::new();
        let params = [("command".to_string(), Value::from("sudo rm -rf / now"))].into();
        let v = engine.check(&tool_action("shell", params), &state);
        assert_eq!(v.len(), 1);
        assert!(v[0].reason.contains("denied keyword"));
    }

    #[test]
    fn deny_tool_param_contains_and_equals() {
        let mut engine = PolicyEngine::new();
        PolicyRules {
            deny_tool_param: vec![
                DenyToolParam {
                    tool: "http_request".to_string(),
                    param: "url".to_string(),
                    equals: None,
                    contains: Some("metadata".to_string()),
                },
                DenyToolParam {
                    tool: "shell".to_string(),
                    param: "command".to_string(),
                    equals: Some(Value::from("shutdown")),
                    contains: None,
                },
            ],
            ..Default::default()
        }
        .apply(&mut engine);
        let state = StateStore::new();

        // contains match
        let p1 = [("url".to_string(), Value::from("http://metadata.local"))].into();
        assert_eq!(
            engine.check(&tool_action("http_request", p1), &state).len(),
            1
        );
        // contains miss
        let p2 = [("url".to_string(), Value::from("http://example.com"))].into();
        assert!(engine
            .check(&tool_action("http_request", p2), &state)
            .is_empty());
        // equals match
        let p3 = [("command".to_string(), Value::from("shutdown"))].into();
        assert_eq!(engine.check(&tool_action("shell", p3), &state).len(), 1);
        // equals miss
        let p4 = [("command".to_string(), Value::from("ls"))].into();
        assert!(engine.check(&tool_action("shell", p4), &state).is_empty());
        // right param, wrong tool
        let p5 = [("command".to_string(), Value::from("shutdown"))].into();
        assert!(engine.check(&tool_action("other", p5), &state).is_empty());
    }

    #[test]
    fn missing_dir_is_empty_not_error() {
        let rules = load_policy_dir("/nonexistent/.car/policies").unwrap();
        assert!(rules.is_empty());
    }

    #[test]
    fn malformed_file_is_loud_error() {
        let dir = std::env::temp_dir().join(format!("car_pol_test_{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(dir.join("bad.toml"), "deny_tool = [unclosed").unwrap();
        let err = load_policy_dir(&dir).unwrap_err();
        assert!(matches!(err, PolicyLoadError::Parse { .. }));
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn loads_and_merges_multiple_files() {
        let dir = std::env::temp_dir().join(format!("car_pol_merge_{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(dir.join("a.toml"), "deny_tool = [\"x\"]").unwrap();
        std::fs::write(dir.join("b.toml"), "deny_tool = [\"y\"]").unwrap();
        let rules = load_policy_dir(&dir).unwrap();
        assert!(rules.deny_tool.contains(&"x".to_string()));
        assert!(rules.deny_tool.contains(&"y".to_string()));
        std::fs::remove_dir_all(&dir).ok();
    }
}
