//! Allowlist matching logic.

use crate::parser::{Arg, SimpleCommand, Statement};

use super::config::{AllowlistEntry, RulesConfig};
use super::matching::normalize_command_name;
use std::borrow::Cow;

/// Check if a leaf node is allowlisted (or is a bare version check).
pub fn is_allowlisted(config: &RulesConfig, leaf: &Statement) -> bool {
    match leaf {
        Statement::SimpleCommand(cmd) => {
            // Bare assignments (no command name, just VAR=val) are safe
            // when all their embedded substitutions are also safe.
            if cmd.name.is_none() && !cmd.assignments.is_empty() && cmd.argv.is_empty() {
                return cmd.embedded_substitutions.iter().all(|sub| {
                    crate::parser::flatten(sub)
                        .iter()
                        .all(|inner| is_allowlisted(config, inner))
                });
            }
            find_allowlist_match(config, cmd).is_some() || is_version_check(cmd)
        }
        Statement::Empty => true, // Empty statements (e.g., comments) are always safe
        _ => false,
    }
}

/// Check if a command is a bare version check (e.g., `foo --version` or `foo -V`).
pub(super) fn is_version_check(cmd: &SimpleCommand) -> bool {
    cmd.argv.len() == 1 && (cmd.argv[0].text == "--version" || cmd.argv[0].text == "-V")
}

/// Strip `--flag VALUE` pairs where `--flag` is a known wrapper value-flag.
///
/// Used so an allowlist entry like `uv run ruff` still matches invocations that
/// pass space-separated wrapper value-flags between the subcommand and the inner
/// command (e.g. `uv run --project /tmp ruff`). The equals form `--project=/tmp`
/// is a single token and is already handled by `args_match_prefix`'s flag-skip
/// logic; we don't need to strip those.
///
/// Short combined forms like `-pVALUE` are also a single token and pass through
/// the existing flag-skip unchanged.
fn strip_wrapper_value_flag_pairs(argv: &[Arg], value_flags: &[&str]) -> Vec<Arg> {
    if value_flags.is_empty() {
        return argv.to_vec();
    }
    let mut out = Vec::with_capacity(argv.len());
    let mut i = 0;
    while i < argv.len() {
        let token = argv[i].text.as_str();
        if value_flags.contains(&token) && i + 1 < argv.len() {
            i += 2;
            continue;
        }
        out.push(argv[i].clone());
        i += 1;
    }
    out
}

/// Git supports global options like `-C <path>` that appear before the subcommand.
/// Strip those so allowlist entries like `git status` still match `git -C /tmp status`.
fn strip_git_global_c_flag(argv: &[Arg]) -> Vec<Arg> {
    let mut out = Vec::with_capacity(argv.len());
    let mut i = 0;
    let mut in_global_opts = true;

    while i < argv.len() {
        let arg = &argv[i];

        if in_global_opts {
            if arg.text == "--" {
                // End of options marker; everything after is command args.
                in_global_opts = false;
                out.push(arg.clone());
                i += 1;
                continue;
            }

            if arg.text == "-C" {
                // Skip `-C` and its path argument (if present).
                i += 1;
                if i < argv.len() {
                    i += 1;
                }
                continue;
            }

            // First non-flag token is the git subcommand; stop treating subsequent args as global opts.
            if !arg.text.starts_with('-') {
                in_global_opts = false;
            }
        }

        out.push(arg.clone());
        i += 1;
    }

    out
}

/// Normalize a path-like argument for matching.
/// Returns the basename if:
/// - The argument contains a `/` (is path-like)
/// - The path is relative (no leading `/`)
/// - The path has no traversal (`..` components)
///
/// Otherwise returns the original argument unchanged.
fn normalize_arg(arg: &str) -> &str {
    // Only normalize if it contains a path separator
    if !arg.contains('/') {
        return arg;
    }

    // Don't normalize absolute paths
    if arg.starts_with('/') {
        return arg;
    }

    // Don't normalize paths with traversal
    if arg.split('/').any(|component| component == "..") {
        return arg;
    }

    // Extract basename - the part after the last /
    arg.rsplit('/').next().unwrap_or(arg)
}

/// Check if required args match as an ordered prefix of argv.
/// Uses path normalization for safe relative paths.
/// When a required arg is not a flag (doesn't start with `-`), flag-like
/// argv elements are skipped so that `logcli --addr=... labels` matches
/// an allowlist entry for `logcli labels`.
fn args_match_prefix(required_args: &[&str], argv: &[Arg]) -> bool {
    let mut argv_idx = 0;
    for req in required_args {
        // If the required arg is a subcommand (not a flag), skip any
        // flag-like argv elements to find the matching subcommand.
        if !req.starts_with('-') {
            while argv_idx < argv.len() && argv[argv_idx].text.starts_with('-') {
                argv_idx += 1;
            }
        }
        if argv_idx >= argv.len() {
            return false;
        }
        let normalized = normalize_arg(&argv[argv_idx].text);
        if *req != normalized {
            return false;
        }
        argv_idx += 1;
    }
    true
}

/// Find the first allowlist entry matching the command.
/// If `check_trust` is true, skip entries requiring a higher trust level than configured.
fn find_matching_entry<'a>(
    config: &'a RulesConfig,
    cmd: &SimpleCommand,
    check_trust: bool,
) -> Option<&'a AllowlistEntry> {
    let cmd_name = match &cmd.name {
        Some(n) => n.as_str(),
        None => return None,
    };

    let argv: Cow<[Arg]> = if cmd_name == "git" && cmd.argv.iter().any(|a| a.text == "-C") {
        Cow::Owned(strip_git_global_c_flag(&cmd.argv))
    } else {
        let first_arg = cmd.argv.first().map(|a| a.text.as_str());
        let value_flags = crate::parser::wrappers::value_flags_for(cmd_name, first_arg);
        if !value_flags.is_empty()
            && cmd
                .argv
                .iter()
                .any(|a| value_flags.contains(&a.text.as_str()))
        {
            Cow::Owned(strip_wrapper_value_flag_pairs(&cmd.argv, value_flags))
        } else {
            Cow::Borrowed(&cmd.argv)
        }
    };

    for entry in &config.allowlists.commands {
        if check_trust && entry.trust > config.trust_level {
            continue;
        }

        let parts: Vec<&str> = entry.command.split_whitespace().collect();
        if parts.is_empty() {
            continue;
        }
        if parts[0] != normalize_command_name(cmd_name) {
            continue;
        }
        if parts.len() == 1 {
            return Some(entry);
        }
        let required_args = &parts[1..];
        if args_match_prefix(required_args, argv.as_ref()) {
            return Some(entry);
        }
    }
    None
}

/// Find the matching allowlist entry for a SimpleCommand.
/// Entries like "git status" match command name + required args.
/// Bare entries like "ls" match any invocation of that command.
/// Entries with a trust level above the config's trust_level are skipped.
/// Returns the matching command string, or None if no match.
pub fn find_allowlist_match<'a>(config: &'a RulesConfig, cmd: &SimpleCommand) -> Option<&'a str> {
    find_matching_entry(config, cmd, true).map(|entry| entry.command.as_str())
}

/// Search all allowlist entries (ignoring trust level) for a matching command.
/// Returns the entry's reason if the command matches an entry that has a reason.
/// Used as a fallback when a command was trust-filtered out of the allowlist.
pub fn find_allowlist_reason(config: &RulesConfig, cmd: &SimpleCommand) -> Option<String> {
    find_matching_entry(config, cmd, false).and_then(|entry| entry.reason.clone())
}

/// Check if an extra_leaf (unwrapped inner command) is covered by a compound
/// allowlist entry on one of the original leaves.
///
/// For example, if the original leaf is `uv run prefect config view` and
/// the allowlist entry is `"uv run prefect config view"`, then the extra_leaf
/// `prefect` is covered because "prefect" appears in the entry's arguments.
///
/// This handles both simple cases (`"uv run yamllint"` covers inner `yamllint`)
/// and multi-word subcommand cases (`"uv run prefect config view"` covers
/// inner `prefect`).
///
/// Bare entries like `"timeout"` (no prefix args) never cover extra_leaves.
pub fn is_covered_by_wrapper_entry(
    config: &RulesConfig,
    original_leaves: &[&Statement],
    extra_leaf: &Statement,
) -> bool {
    let extra_cmd_name = match extra_leaf {
        Statement::SimpleCommand(cmd) => cmd.name.as_deref(),
        _ => return false,
    };
    let extra_cmd_name = match extra_cmd_name {
        Some(name) => name,
        None => return false,
    };

    for leaf in original_leaves {
        if let Statement::SimpleCommand(cmd) = leaf {
            if let Some(entry_str) = find_allowlist_match(config, cmd) {
                let parts: Vec<&str> = entry_str.split_whitespace().collect();
                if parts.len() > 1 && parts[1..].contains(&extra_cmd_name) {
                    return true;
                }
            }
        }
    }
    false
}

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

    fn make_argv(args: &[&str]) -> Vec<Arg> {
        args.iter().map(|s| Arg::plain(*s)).collect()
    }

    fn test_git_cmd(argv: Vec<&str>) -> SimpleCommand {
        SimpleCommand {
            name: Some("git".to_string()),
            argv: argv.into_iter().map(Arg::plain).collect(),
            redirects: vec![],
            assignments: vec![],
            embedded_substitutions: vec![],
        }
    }

    // ================================================================
    // normalize_arg tests
    // ================================================================

    #[test]
    fn test_normalize_arg_no_slash_unchanged() {
        // Args without / should not be normalized
        assert_eq!(normalize_arg("manage.py"), "manage.py");
        assert_eq!(normalize_arg("check"), "check");
        assert_eq!(normalize_arg("myapp.tests"), "myapp.tests");
    }

    #[test]
    fn test_normalize_arg_relative_path_returns_basename() {
        // Safe relative paths should normalize to basename
        assert_eq!(normalize_arg("./manage.py"), "manage.py");
        assert_eq!(normalize_arg("server/manage.py"), "manage.py");
        assert_eq!(normalize_arg("apps/core/manage.py"), "manage.py");
    }

    #[test]
    fn test_normalize_arg_absolute_path_unchanged() {
        // Absolute paths should NOT be normalized
        assert_eq!(normalize_arg("/tmp/manage.py"), "/tmp/manage.py");
        assert_eq!(
            normalize_arg("/home/user/project/manage.py"),
            "/home/user/project/manage.py"
        );
    }

    #[test]
    fn test_normalize_arg_traversal_unchanged() {
        // Paths with .. should NOT be normalized
        assert_eq!(normalize_arg("../manage.py"), "../manage.py");
        assert_eq!(normalize_arg("../../manage.py"), "../../manage.py");
        assert_eq!(normalize_arg("foo/../manage.py"), "foo/../manage.py");
    }

    #[test]
    fn test_normalize_arg_dot_only_returns_empty() {
        // Edge case: ./ alone - basename is empty string
        // This should return empty or the original - either way it won't match "manage.py"
        let result = normalize_arg("./");
        assert!(result.is_empty() || result == "./");
    }

    // ================================================================
    // args_match_prefix tests
    // ================================================================

    #[test]
    fn test_args_match_prefix_exact_match() {
        let required = &["manage.py", "check"];
        let argv = make_argv(&["manage.py", "check"]);
        assert!(args_match_prefix(required, &argv));
    }

    #[test]
    fn test_args_match_prefix_with_extra_trailing_args() {
        // Extra args at end should be OK
        let required = &["manage.py", "check"];
        let argv = make_argv(&["manage.py", "check", "--deploy"]);
        assert!(args_match_prefix(required, &argv));
    }

    #[test]
    fn test_args_match_prefix_wrong_first_arg_fails() {
        // evil.py in first position should NOT match
        let required = &["manage.py", "check"];
        let argv = make_argv(&["evil.py", "manage.py", "check"]);
        assert!(!args_match_prefix(required, &argv));
    }

    #[test]
    fn test_args_match_prefix_out_of_order_fails() {
        // Args in wrong order should NOT match
        let required = &["manage.py", "check"];
        let argv = make_argv(&["check", "manage.py"]);
        assert!(!args_match_prefix(required, &argv));
    }

    #[test]
    fn test_args_match_prefix_with_path_normalization() {
        // ./manage.py should match manage.py after normalization
        let required = &["manage.py", "check"];
        let argv = make_argv(&["./manage.py", "check"]);
        assert!(args_match_prefix(required, &argv));
    }

    #[test]
    fn test_args_match_prefix_subdir_path_normalization() {
        // server/manage.py should match manage.py after normalization
        let required = &["manage.py", "check"];
        let argv = make_argv(&["server/manage.py", "check"]);
        assert!(args_match_prefix(required, &argv));
    }

    #[test]
    fn test_args_match_prefix_absolute_path_no_normalization() {
        // /tmp/manage.py should NOT be normalized, so won't match
        let required = &["manage.py", "check"];
        let argv = make_argv(&["/tmp/manage.py", "check"]);
        assert!(!args_match_prefix(required, &argv));
    }

    #[test]
    fn test_args_match_prefix_traversal_no_normalization() {
        // ../manage.py should NOT be normalized, so won't match
        let required = &["manage.py", "check"];
        let argv = make_argv(&["../manage.py", "check"]);
        assert!(!args_match_prefix(required, &argv));
    }

    #[test]
    fn test_args_match_prefix_not_enough_args() {
        // Fewer args than required should fail
        let required = &["manage.py", "check"];
        let argv = make_argv(&["manage.py"]);
        assert!(!args_match_prefix(required, &argv));
    }

    #[test]
    fn test_args_match_prefix_empty_required() {
        // Empty required args should match anything
        let required: &[&str] = &[];
        let argv = make_argv(&["anything"]);
        assert!(args_match_prefix(required, &argv));
    }

    // ================================================================
    // strip_git_global_c_flag tests
    // ================================================================

    #[test]
    fn test_strip_git_global_c_flag_basic() {
        let argv = make_argv(&["-C", "/tmp", "status"]);
        assert_eq!(strip_git_global_c_flag(&argv), make_argv(&["status"]));
    }

    #[test]
    fn test_strip_git_global_c_flag_multiple() {
        let argv = make_argv(&["-C", "/tmp", "-C", "/other", "status"]);
        assert_eq!(strip_git_global_c_flag(&argv), make_argv(&["status"]));
    }

    #[test]
    fn test_find_allowlist_match_git_c_status_matches_git_status() {
        let config = RulesConfig {
            version: 1,
            default_decision: crate::types::Decision::Ask,
            safety_level: crate::policy::SafetyLevel::High,
            trust_level: crate::policy::TrustLevel::default(),
            allowlists: crate::policy::Allowlists {
                commands: vec![crate::policy::AllowlistEntry {
                    command: "git status".to_string(),
                    trust: crate::policy::TrustLevel::Standard,
                    reason: None,
                    source: crate::policy::RuleSource::default(),
                }],
                paths: vec![],
            },
            rules: vec![],
        };

        let cmd = test_git_cmd(vec!["-C", "/tmp", "status"]);
        assert_eq!(find_allowlist_match(&config, &cmd), Some("git status"));
    }

    #[test]
    fn test_find_allowlist_match_respects_trust_level() {
        let config_minimal = RulesConfig {
            version: 1,
            default_decision: crate::types::Decision::Ask,
            safety_level: crate::policy::SafetyLevel::High,
            trust_level: crate::policy::TrustLevel::Minimal,
            allowlists: crate::policy::Allowlists {
                commands: vec![
                    crate::policy::AllowlistEntry {
                        command: "ls".to_string(),
                        trust: crate::policy::TrustLevel::Minimal,
                        reason: None,
                        source: crate::policy::RuleSource::default(),
                    },
                    crate::policy::AllowlistEntry {
                        command: "go build".to_string(),
                        trust: crate::policy::TrustLevel::Standard,
                        reason: None,
                        source: crate::policy::RuleSource::default(),
                    },
                    crate::policy::AllowlistEntry {
                        command: "docker run".to_string(),
                        trust: crate::policy::TrustLevel::Full,
                        reason: None,
                        source: crate::policy::RuleSource::default(),
                    },
                ],
                paths: vec![],
            },
            rules: vec![],
        };

        // Minimal trust: only minimal entries match
        let ls_cmd = SimpleCommand {
            name: Some("ls".to_string()),
            argv: vec![],
            redirects: vec![],
            assignments: vec![],
            embedded_substitutions: vec![],
        };
        assert_eq!(
            find_allowlist_match(&config_minimal, &ls_cmd),
            Some("ls"),
            "Minimal-trust entry should match at Minimal trust level"
        );

        let go_cmd = SimpleCommand {
            name: Some("go".to_string()),
            argv: make_argv(&["build"]),
            redirects: vec![],
            assignments: vec![],
            embedded_substitutions: vec![],
        };
        assert_eq!(
            find_allowlist_match(&config_minimal, &go_cmd),
            None,
            "Standard-trust entry should be skipped at Minimal trust level"
        );

        let docker_cmd = SimpleCommand {
            name: Some("docker".to_string()),
            argv: make_argv(&["run"]),
            redirects: vec![],
            assignments: vec![],
            embedded_substitutions: vec![],
        };
        assert_eq!(
            find_allowlist_match(&config_minimal, &docker_cmd),
            None,
            "Full-trust entry should be skipped at Minimal trust level"
        );

        // Standard trust: minimal and standard match, full is skipped
        let config_standard = RulesConfig {
            trust_level: crate::policy::TrustLevel::Standard,
            ..config_minimal
        };
        assert_eq!(
            find_allowlist_match(&config_standard, &go_cmd),
            Some("go build"),
            "Standard-trust entry should match at Standard trust level"
        );
        assert_eq!(
            find_allowlist_match(&config_standard, &docker_cmd),
            None,
            "Full-trust entry should be skipped at Standard trust level"
        );
    }

    #[test]
    fn test_find_allowlist_reason_returns_reason_for_trust_filtered_entry() {
        let config = RulesConfig {
            version: 1,
            default_decision: crate::types::Decision::Ask,
            safety_level: crate::policy::SafetyLevel::High,
            trust_level: crate::policy::TrustLevel::Standard,
            allowlists: crate::policy::Allowlists {
                commands: vec![crate::policy::AllowlistEntry {
                    command: "git push".to_string(),
                    trust: crate::policy::TrustLevel::Full,
                    reason: Some("Pushes local commits to a remote repository".to_string()),
                    source: crate::policy::RuleSource::default(),
                }],
                paths: vec![],
            },
            rules: vec![],
        };

        let cmd = test_git_cmd(vec!["push", "origin", "main"]);

        // find_allowlist_match should return None (trust-filtered)
        assert_eq!(find_allowlist_match(&config, &cmd), None);

        // find_allowlist_reason should return the reason
        assert_eq!(
            find_allowlist_reason(&config, &cmd),
            Some("Pushes local commits to a remote repository".to_string()),
        );
    }

    #[test]
    fn test_find_allowlist_reason_returns_none_for_unrecognized_command() {
        let config = RulesConfig {
            version: 1,
            default_decision: crate::types::Decision::Ask,
            safety_level: crate::policy::SafetyLevel::High,
            trust_level: crate::policy::TrustLevel::Standard,
            allowlists: crate::policy::Allowlists {
                commands: vec![crate::policy::AllowlistEntry {
                    command: "git push".to_string(),
                    trust: crate::policy::TrustLevel::Full,
                    reason: Some("Pushes local commits to a remote repository".to_string()),
                    source: crate::policy::RuleSource::default(),
                }],
                paths: vec![],
            },
            rules: vec![],
        };

        let cmd = SimpleCommand {
            name: Some("unknown-tool".to_string()),
            argv: vec![],
            redirects: vec![],
            assignments: vec![],
            embedded_substitutions: vec![],
        };

        assert_eq!(find_allowlist_reason(&config, &cmd), None);
    }

    #[test]
    fn test_find_allowlist_reason_returns_none_when_no_reason_field() {
        let config = RulesConfig {
            version: 1,
            default_decision: crate::types::Decision::Ask,
            safety_level: crate::policy::SafetyLevel::High,
            trust_level: crate::policy::TrustLevel::Standard,
            allowlists: crate::policy::Allowlists {
                commands: vec![crate::policy::AllowlistEntry {
                    command: "git push".to_string(),
                    trust: crate::policy::TrustLevel::Full,
                    reason: None,
                    source: crate::policy::RuleSource::default(),
                }],
                paths: vec![],
            },
            rules: vec![],
        };

        let cmd = test_git_cmd(vec!["push"]);
        assert_eq!(find_allowlist_reason(&config, &cmd), None);
    }

    #[test]
    fn test_find_allowlist_match_git_c_clean_does_not_match_git_clean_allowlist() {
        let config = RulesConfig {
            version: 1,
            default_decision: crate::types::Decision::Ask,
            safety_level: crate::policy::SafetyLevel::High,
            trust_level: crate::policy::TrustLevel::default(),
            allowlists: crate::policy::Allowlists {
                commands: vec![crate::policy::AllowlistEntry {
                    command: "git status".to_string(),
                    trust: crate::policy::TrustLevel::Standard,
                    reason: None,
                    source: crate::policy::RuleSource::default(),
                }],
                paths: vec![],
            },
            rules: vec![],
        };

        let cmd = test_git_cmd(vec!["-C", "/tmp", "clean", "-f"]);
        assert_eq!(find_allowlist_match(&config, &cmd), None);
    }

    // ================================================================
    // args_match_prefix: flag-skipping for subcommand matching
    // ================================================================

    #[test]
    fn test_args_match_prefix_skips_flags_before_subcommand() {
        // logcli --addr=http://foo:3100 labels host
        // should match required_args ["labels"] by skipping --addr=...
        let required = &["labels"];
        let argv = make_argv(&["--addr=http://foo:3100", "labels", "host"]);
        assert!(
            args_match_prefix(required, &argv),
            "Should skip --addr= flag to match 'labels' subcommand"
        );
    }

    #[test]
    fn test_args_match_prefix_skips_multiple_flags() {
        // logcli --addr=http://foo:3100 -q labels host
        let required = &["labels"];
        let argv = make_argv(&["--addr=http://foo:3100", "-q", "labels", "host"]);
        assert!(
            args_match_prefix(required, &argv),
            "Should skip multiple flags to match subcommand"
        );
    }

    #[test]
    fn test_args_match_prefix_no_flags_still_works() {
        // logcli labels host (no flags) should still match
        let required = &["labels"];
        let argv = make_argv(&["labels", "host"]);
        assert!(args_match_prefix(required, &argv));
    }

    #[test]
    fn test_args_match_prefix_multi_subcommand_with_flags() {
        // uv --quiet run prefect config view
        let required = &["run", "prefect", "config", "view"];
        let argv = make_argv(&["--quiet", "run", "prefect", "config", "view"]);
        assert!(
            args_match_prefix(required, &argv),
            "Should skip --quiet to match multi-word subcommand chain"
        );
    }

    #[test]
    fn test_args_match_prefix_flag_in_required_still_matches() {
        // If allowlist entry requires a flag, it should match literally
        let required = &["push", "--force"];
        let argv = make_argv(&["push", "--force"]);
        assert!(
            args_match_prefix(required, &argv),
            "Required flags should match literally"
        );
    }

    #[test]
    fn test_args_match_prefix_wrong_subcommand_after_flags() {
        // logcli --addr=http://foo:3100 query (should NOT match "labels")
        let required = &["labels"];
        let argv = make_argv(&["--addr=http://foo:3100", "query"]);
        assert!(
            !args_match_prefix(required, &argv),
            "Wrong subcommand should not match even after skipping flags"
        );
    }

    #[test]
    fn test_args_match_prefix_only_flags_no_subcommand() {
        // logcli --addr=http://foo:3100 -q (no subcommand at all)
        let required = &["labels"];
        let argv = make_argv(&["--addr=http://foo:3100", "-q"]);
        assert!(
            !args_match_prefix(required, &argv),
            "Should not match if no subcommand present after flags"
        );
    }

    #[test]
    fn test_is_covered_by_wrapper_entry_compound_entry() {
        use crate::parser::Statement;

        let config = RulesConfig {
            version: 1,
            default_decision: crate::types::Decision::Ask,
            safety_level: crate::policy::SafetyLevel::High,
            trust_level: crate::policy::TrustLevel::Standard,
            allowlists: crate::policy::Allowlists {
                commands: vec![crate::policy::AllowlistEntry {
                    command: "uv run yamllint".to_string(),
                    trust: crate::policy::TrustLevel::Standard,
                    reason: None,
                    source: crate::policy::RuleSource::default(),
                }],
                paths: vec![],
            },
            rules: vec![],
        };

        let original_leaf = Statement::SimpleCommand(SimpleCommand {
            name: Some("uv".to_string()),
            argv: make_argv(&["run", "yamllint", ".gitlab-ci.yml"]),
            redirects: vec![],
            assignments: vec![],
            embedded_substitutions: vec![],
        });

        let extra_leaf_covered = Statement::SimpleCommand(SimpleCommand {
            name: Some("yamllint".to_string()),
            argv: make_argv(&[".gitlab-ci.yml"]),
            redirects: vec![],
            assignments: vec![],
            embedded_substitutions: vec![],
        });

        let extra_leaf_not_covered = Statement::SimpleCommand(SimpleCommand {
            name: Some("dangeroustool".to_string()),
            argv: vec![],
            redirects: vec![],
            assignments: vec![],
            embedded_substitutions: vec![],
        });

        let leaves = vec![&original_leaf];

        assert!(
            is_covered_by_wrapper_entry(&config, &leaves, &extra_leaf_covered),
            "yamllint should be covered by 'uv run yamllint' entry"
        );
        assert!(
            !is_covered_by_wrapper_entry(&config, &leaves, &extra_leaf_not_covered),
            "dangeroustool should NOT be covered"
        );
    }

    #[test]
    fn test_is_covered_by_wrapper_entry_multi_word_subcommand() {
        use crate::parser::Statement;

        let config = RulesConfig {
            version: 1,
            default_decision: crate::types::Decision::Ask,
            safety_level: crate::policy::SafetyLevel::High,
            trust_level: crate::policy::TrustLevel::Standard,
            allowlists: crate::policy::Allowlists {
                commands: vec![crate::policy::AllowlistEntry {
                    command: "uv run prefect config view".to_string(),
                    trust: crate::policy::TrustLevel::Standard,
                    reason: None,
                    source: crate::policy::RuleSource::default(),
                }],
                paths: vec![],
            },
            rules: vec![],
        };

        // Original leaf: uv run prefect config view
        let original_leaf = Statement::SimpleCommand(SimpleCommand {
            name: Some("uv".to_string()),
            argv: make_argv(&["run", "prefect", "config", "view"]),
            redirects: vec![],
            assignments: vec![],
            embedded_substitutions: vec![],
        });

        // Extra leaf: prefect (the unwrapped inner command)
        let extra_leaf_covered = Statement::SimpleCommand(SimpleCommand {
            name: Some("prefect".to_string()),
            argv: make_argv(&["config", "view"]),
            redirects: vec![],
            assignments: vec![],
            embedded_substitutions: vec![],
        });

        let extra_leaf_not_covered = Statement::SimpleCommand(SimpleCommand {
            name: Some("dangeroustool".to_string()),
            argv: vec![],
            redirects: vec![],
            assignments: vec![],
            embedded_substitutions: vec![],
        });

        let leaves = vec![&original_leaf];

        assert!(
            is_covered_by_wrapper_entry(&config, &leaves, &extra_leaf_covered),
            "prefect should be covered by 'uv run prefect config view' entry"
        );
        assert!(
            !is_covered_by_wrapper_entry(&config, &leaves, &extra_leaf_not_covered),
            "dangeroustool should NOT be covered"
        );
    }

    #[test]
    fn test_is_covered_by_wrapper_entry_bare_entry_no_coverage() {
        use crate::parser::Statement;

        let config = RulesConfig {
            version: 1,
            default_decision: crate::types::Decision::Ask,
            safety_level: crate::policy::SafetyLevel::High,
            trust_level: crate::policy::TrustLevel::Standard,
            allowlists: crate::policy::Allowlists {
                commands: vec![crate::policy::AllowlistEntry {
                    command: "timeout".to_string(),
                    trust: crate::policy::TrustLevel::Standard,
                    reason: None,
                    source: crate::policy::RuleSource::default(),
                }],
                paths: vec![],
            },
            rules: vec![],
        };

        let original_leaf = Statement::SimpleCommand(SimpleCommand {
            name: Some("timeout".to_string()),
            argv: make_argv(&["10", "unknown_command"]),
            redirects: vec![],
            assignments: vec![],
            embedded_substitutions: vec![],
        });

        let extra_leaf = Statement::SimpleCommand(SimpleCommand {
            name: Some("unknown_command".to_string()),
            argv: vec![],
            redirects: vec![],
            assignments: vec![],
            embedded_substitutions: vec![],
        });

        let leaves = vec![&original_leaf];

        assert!(
            !is_covered_by_wrapper_entry(&config, &leaves, &extra_leaf),
            "Bare 'timeout' entry should NOT cover unknown_command"
        );
    }

    // ================================================================
    // find_allowlist_match: wrapper value-flag awareness (uv run --project …)
    // ================================================================
    //
    // `uv run` takes value-flags like `--project PATH`, `-p PATH`, `--directory PATH`,
    // and `--python VERSION`. When these appear in space-separated form between
    // `uv run` and the inner command, the allowlist matcher's generic flag-skip
    // logic (skip tokens starting with `-`) stops at the VALUE (which doesn't start
    // with `-`) and fails to reach the inner command. The equals form works because
    // the whole token starts with `-`.

    fn uv_cmd(argv: &[&str]) -> SimpleCommand {
        SimpleCommand {
            name: Some("uv".to_string()),
            argv: argv.iter().map(|s| Arg::plain(*s)).collect(),
            redirects: vec![],
            assignments: vec![],
            embedded_substitutions: vec![],
        }
    }

    fn uv_run_ruff_config() -> RulesConfig {
        RulesConfig {
            version: 1,
            default_decision: crate::types::Decision::Ask,
            safety_level: crate::policy::SafetyLevel::High,
            trust_level: crate::policy::TrustLevel::Standard,
            allowlists: crate::policy::Allowlists {
                commands: vec![crate::policy::AllowlistEntry {
                    command: "uv run ruff".to_string(),
                    trust: crate::policy::TrustLevel::Standard,
                    reason: None,
                    source: crate::policy::RuleSource::default(),
                }],
                paths: vec![],
            },
            rules: vec![],
        }
    }

    #[test]
    fn test_find_match_uv_run_ruff_with_project_space_value() {
        // `uv run --project /tmp ruff --version` must match `uv run ruff` entry.
        // This is the bug: space-separated value-flag broke the matcher.
        let config = uv_run_ruff_config();
        let cmd = uv_cmd(&["run", "--project", "/tmp", "ruff", "--version"]);
        assert_eq!(find_allowlist_match(&config, &cmd), Some("uv run ruff"));
    }

    #[test]
    fn test_find_match_uv_run_ruff_with_short_flag_p_space_value() {
        // `uv run -p /tmp ruff` must match `uv run ruff` entry.
        let config = uv_run_ruff_config();
        let cmd = uv_cmd(&["run", "-p", "/tmp", "ruff"]);
        assert_eq!(find_allowlist_match(&config, &cmd), Some("uv run ruff"));
    }

    #[test]
    fn test_find_match_uv_run_ruff_with_directory_space_value() {
        let config = uv_run_ruff_config();
        let cmd = uv_cmd(&["run", "--directory", "/tmp", "ruff"]);
        assert_eq!(find_allowlist_match(&config, &cmd), Some("uv run ruff"));
    }

    #[test]
    fn test_find_match_uv_run_ruff_with_python_version_space_value() {
        let config = uv_run_ruff_config();
        let cmd = uv_cmd(&["run", "--python", "3.12", "ruff"]);
        assert_eq!(find_allowlist_match(&config, &cmd), Some("uv run ruff"));
    }

    #[test]
    fn test_find_match_uv_run_ruff_equals_form_still_matches() {
        // Regression: --project=VALUE form already worked, must keep working.
        let config = uv_run_ruff_config();
        let cmd = uv_cmd(&["run", "--project=/tmp", "ruff"]);
        assert_eq!(find_allowlist_match(&config, &cmd), Some("uv run ruff"));
    }

    #[test]
    fn test_find_match_uv_run_ruff_bool_flag_still_matches() {
        // Regression: bool flags like --isolated must keep matching.
        let config = uv_run_ruff_config();
        let cmd = uv_cmd(&["run", "--isolated", "ruff"]);
        assert_eq!(find_allowlist_match(&config, &cmd), Some("uv run ruff"));
    }

    #[test]
    fn test_find_match_uv_run_ruff_no_flags_still_matches() {
        // Regression: the simple form must keep matching.
        let config = uv_run_ruff_config();
        let cmd = uv_cmd(&["run", "ruff"]);
        assert_eq!(find_allowlist_match(&config, &cmd), Some("uv run ruff"));
    }

    #[test]
    fn test_find_match_uv_run_project_does_not_match_other_subcommand() {
        // Security: --project /tmp must not let an unrelated tool sneak past.
        let config = uv_run_ruff_config();
        let cmd = uv_cmd(&["run", "--project", "/tmp", "dangeroustool"]);
        assert_eq!(find_allowlist_match(&config, &cmd), None);
    }

    #[test]
    fn test_find_match_uv_run_project_value_is_not_the_subcommand() {
        // Edge: if the value IS "ruff" (e.g. project literally named ruff),
        // we should still require the real ruff token to follow.
        let config = uv_run_ruff_config();
        let cmd = uv_cmd(&["run", "--project", "ruff"]);
        // "ruff" is consumed as --project's value, no subcommand follows.
        assert_eq!(find_allowlist_match(&config, &cmd), None);
    }
}
