"""Tests for the approvals module."""

from __future__ import annotations

import json
import os
import tempfile
from unittest import mock

import pytest

from elizaos_plugin_shell.approvals.types import (
    DEFAULT_SAFE_BINS,
    EXEC_APPROVAL_DEFAULTS,
    EXEC_APPROVAL_TIMEOUT_MS,
    ApprovalResult,
    CommandClassification,
    CommandResolution,
    ExecAllowlistEntry,
    ExecApprovalDecision,
    ExecApprovalRequest,
    ExecApprovalsAgent,
    ExecApprovalsDefaults,
    ExecApprovalsFile,
    ExecAsk,
    ExecCommandAnalysis,
    ExecCommandSegment,
    ExecSecurity,
    RiskLevel,
)
from elizaos_plugin_shell.approvals.allowlist import (
    add_allowlist_entry,
    get_approval_file_path,
    load_approvals,
    match_allowlist,
    max_ask,
    min_security,
    normalize_approvals,
    record_allowlist_use,
    resolve_approvals_from_file,
    save_approvals,
)
from elizaos_plugin_shell.approvals.analysis import (
    _is_path_like_token,
    analyze_shell_command,
    classify_command_type,
    detect_patterns,
    evaluate_shell_allowlist,
    get_risk_level,
    is_safe_bin_usage,
    normalize_safe_bins,
    requires_exec_approval,
    resolve_command_from_argv,
    resolve_command_resolution,
    resolve_safe_bins,
)
from elizaos_plugin_shell.approvals.service import (
    ApprovalService,
    CommandCheckResult,
)


# ============================================================================
# Types tests
# ============================================================================


class TestTypes:
    def test_exec_security_values(self):
        assert ExecSecurity.DENY == "deny"
        assert ExecSecurity.ALLOWLIST == "allowlist"
        assert ExecSecurity.FULL == "full"

    def test_exec_ask_values(self):
        assert ExecAsk.OFF == "off"
        assert ExecAsk.ON_MISS == "on-miss"
        assert ExecAsk.ALWAYS == "always"

    def test_risk_level_values(self):
        assert RiskLevel.LOW == "low"
        assert RiskLevel.MEDIUM == "medium"
        assert RiskLevel.HIGH == "high"
        assert RiskLevel.CRITICAL == "critical"

    def test_command_classification_values(self):
        assert CommandClassification.READ_ONLY == "read_only"
        assert CommandClassification.DESTRUCTIVE == "destructive"

    def test_allowlist_entry_creation(self):
        entry = ExecAllowlistEntry(pattern="/usr/bin/ls")
        assert entry.pattern == "/usr/bin/ls"
        assert entry.id is None

    def test_approval_defaults(self):
        assert EXEC_APPROVAL_DEFAULTS.security == ExecSecurity.DENY
        assert EXEC_APPROVAL_DEFAULTS.ask == ExecAsk.ON_MISS
        assert EXEC_APPROVAL_TIMEOUT_MS == 120_000

    def test_default_safe_bins(self):
        assert "jq" in DEFAULT_SAFE_BINS
        assert "grep" in DEFAULT_SAFE_BINS
        assert "wc" in DEFAULT_SAFE_BINS

    def test_approval_request_model(self):
        req = ExecApprovalRequest(
            id="test-id",
            command="ls -la",
            room_id="room-1",
        )
        assert req.command == "ls -la"
        assert req.timeout_ms is None

    def test_approval_result_model(self):
        result = ApprovalResult(
            decision=ExecApprovalDecision.ALLOW_ONCE,
            timed_out=False,
        )
        assert result.decision == ExecApprovalDecision.ALLOW_ONCE


# ============================================================================
# Allowlist tests
# ============================================================================


class TestAllowlist:
    def test_normalize_approvals_empty(self):
        file = ExecApprovalsFile(version=1, agents={})
        normalized = normalize_approvals(file)
        assert normalized.version == 1

    def test_normalize_approvals_with_defaults(self):
        file = ExecApprovalsFile(
            version=1,
            defaults=ExecApprovalsDefaults(security=ExecSecurity.FULL),
            agents={},
        )
        normalized = normalize_approvals(file)
        assert normalized.defaults is not None
        assert normalized.defaults.security == ExecSecurity.FULL

    def test_match_allowlist_empty(self):
        result = match_allowlist([], None)
        assert result is None

    def test_match_allowlist_no_resolution(self):
        entries = [ExecAllowlistEntry(pattern="/usr/bin/ls")]
        result = match_allowlist(entries, None)
        assert result is None

    def test_match_allowlist_match(self):
        entries = [ExecAllowlistEntry(pattern="/usr/bin/ls")]
        resolution = CommandResolution(
            raw_executable="ls",
            resolved_path="/usr/bin/ls",
            executable_name="ls",
        )
        result = match_allowlist(entries, resolution)
        assert result is not None
        assert result.pattern == "/usr/bin/ls"

    def test_match_allowlist_no_match(self):
        entries = [ExecAllowlistEntry(pattern="/usr/bin/cat")]
        resolution = CommandResolution(
            raw_executable="ls",
            resolved_path="/usr/bin/ls",
            executable_name="ls",
        )
        result = match_allowlist(entries, resolution)
        assert result is None

    def test_match_allowlist_glob_pattern(self):
        entries = [ExecAllowlistEntry(pattern="/usr/bin/*")]
        resolution = CommandResolution(
            raw_executable="ls",
            resolved_path="/usr/bin/ls",
            executable_name="ls",
        )
        result = match_allowlist(entries, resolution)
        assert result is not None

    def test_min_security(self):
        assert min_security(ExecSecurity.DENY, ExecSecurity.FULL) == ExecSecurity.DENY
        assert min_security(ExecSecurity.FULL, ExecSecurity.ALLOWLIST) == ExecSecurity.ALLOWLIST
        assert min_security(ExecSecurity.ALLOWLIST, ExecSecurity.ALLOWLIST) == ExecSecurity.ALLOWLIST

    def test_max_ask(self):
        assert max_ask(ExecAsk.OFF, ExecAsk.ALWAYS) == ExecAsk.ALWAYS
        assert max_ask(ExecAsk.ON_MISS, ExecAsk.OFF) == ExecAsk.ON_MISS
        assert max_ask(ExecAsk.ALWAYS, ExecAsk.ALWAYS) == ExecAsk.ALWAYS

    def test_save_and_load_approvals(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            file_path = os.path.join(tmpdir, "test-approvals.json")
            with mock.patch(
                "elizaos_plugin_shell.approvals.allowlist.get_approval_file_path",
                return_value=file_path,
            ):
                config = ExecApprovalsFile(
                    version=1,
                    defaults=ExecApprovalsDefaults(security=ExecSecurity.ALLOWLIST),
                    agents={},
                )
                save_approvals(config)
                loaded = load_approvals()
                assert loaded.version == 1
                assert loaded.defaults is not None
                assert loaded.defaults.security == ExecSecurity.ALLOWLIST

    def test_add_allowlist_entry(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            file_path = os.path.join(tmpdir, "test-approvals.json")
            with mock.patch(
                "elizaos_plugin_shell.approvals.allowlist.get_approval_file_path",
                return_value=file_path,
            ):
                config = ExecApprovalsFile(version=1, agents={})
                save_approvals(config)
                result = add_allowlist_entry(config, "agent1", "/usr/bin/ls")
                assert result is True

                # Duplicate should return False
                loaded = load_approvals()
                result2 = add_allowlist_entry(loaded, "agent1", "/usr/bin/ls")
                assert result2 is False

    def test_add_empty_pattern(self):
        config = ExecApprovalsFile(version=1, agents={})
        result = add_allowlist_entry(config, "agent1", "")
        assert result is False

    def test_resolve_approvals_from_file(self):
        file = ExecApprovalsFile(
            version=1,
            defaults=ExecApprovalsDefaults(security=ExecSecurity.ALLOWLIST),
            agents={
                "agent1": ExecApprovalsAgent(
                    ask=ExecAsk.ALWAYS,
                    allowlist=[ExecAllowlistEntry(pattern="/usr/bin/ls")],
                ),
            },
        )
        resolved = resolve_approvals_from_file(file, agent_id="agent1")
        assert resolved.agent.ask == ExecAsk.ALWAYS
        assert len(resolved.allowlist) == 1

    def test_resolve_approvals_wildcard(self):
        file = ExecApprovalsFile(
            version=1,
            agents={
                "*": ExecApprovalsAgent(
                    allowlist=[ExecAllowlistEntry(pattern="/usr/bin/*")],
                ),
            },
        )
        resolved = resolve_approvals_from_file(file, agent_id="any-agent")
        assert len(resolved.allowlist) == 1


# ============================================================================
# Analysis tests
# ============================================================================


class TestAnalysis:
    def test_analyze_simple_command(self):
        result = analyze_shell_command("echo hello")
        assert result.ok is True
        assert len(result.segments) == 1
        assert result.segments[0].argv[0] == "echo"

    def test_analyze_pipeline(self):
        result = analyze_shell_command("echo hello | grep hello")
        assert result.ok is True
        assert len(result.segments) == 2

    def test_analyze_chain(self):
        result = analyze_shell_command("echo a && echo b")
        assert result.ok is True
        assert result.chains is not None
        assert len(result.chains) == 2

    def test_analyze_empty_command(self):
        result = analyze_shell_command("")
        assert result.ok is False

    def test_analyze_unsupported_token(self):
        result = analyze_shell_command("echo hello; echo world")
        # Semicolons cause chain split which is valid
        assert result.ok is True

    def test_resolve_safe_bins_default(self):
        bins = resolve_safe_bins()
        assert "jq" in bins
        assert "grep" in bins

    def test_resolve_safe_bins_custom(self):
        bins = resolve_safe_bins(["custom", "TOOL"])
        assert "custom" in bins
        assert "tool" in bins
        assert "jq" not in bins

    def test_normalize_safe_bins_empty(self):
        bins = normalize_safe_bins(None)
        assert len(bins) == 0

    def test_requires_approval_always(self):
        assert requires_exec_approval(
            ask=ExecAsk.ALWAYS,
            security=ExecSecurity.ALLOWLIST,
            analysis_ok=True,
            allowlist_satisfied=True,
        ) is True

    def test_requires_approval_off(self):
        assert requires_exec_approval(
            ask=ExecAsk.OFF,
            security=ExecSecurity.ALLOWLIST,
            analysis_ok=True,
            allowlist_satisfied=False,
        ) is False

    def test_requires_approval_on_miss_satisfied(self):
        assert requires_exec_approval(
            ask=ExecAsk.ON_MISS,
            security=ExecSecurity.ALLOWLIST,
            analysis_ok=True,
            allowlist_satisfied=True,
        ) is False

    def test_requires_approval_on_miss_not_satisfied(self):
        assert requires_exec_approval(
            ask=ExecAsk.ON_MISS,
            security=ExecSecurity.ALLOWLIST,
            analysis_ok=True,
            allowlist_satisfied=False,
        ) is True

    def test_get_risk_level_read_only(self):
        assert get_risk_level("ls -la") == RiskLevel.LOW

    def test_get_risk_level_network(self):
        assert get_risk_level("curl https://example.com") == RiskLevel.MEDIUM

    def test_get_risk_level_destructive(self):
        assert get_risk_level("rm file.txt") == RiskLevel.HIGH

    def test_get_risk_level_critical(self):
        assert get_risk_level("rm -rf /") == RiskLevel.CRITICAL

    def test_get_risk_level_sudo(self):
        assert get_risk_level("sudo anything") == RiskLevel.HIGH

    def test_classify_command_read_only(self):
        assert classify_command_type("cat file.txt") == CommandClassification.READ_ONLY

    def test_classify_command_destructive(self):
        assert classify_command_type("rm -rf dir") == CommandClassification.DESTRUCTIVE

    def test_classify_command_network(self):
        assert classify_command_type("curl url") == CommandClassification.NETWORK

    def test_classify_command_system(self):
        assert classify_command_type("systemctl status") == CommandClassification.SYSTEM

    def test_classify_command_write(self):
        assert classify_command_type("echo hi > file") == CommandClassification.WRITE

    def test_detect_patterns_pipeline(self):
        patterns = detect_patterns("echo a | grep b")
        assert "pipeline" in patterns

    def test_detect_patterns_chain(self):
        patterns = detect_patterns("cmd1 && cmd2")
        assert "chain" in patterns

    def test_detect_patterns_redirect(self):
        patterns = detect_patterns("echo hi > file")
        assert "output_redirect" in patterns

    def test_detect_patterns_sudo(self):
        patterns = detect_patterns("sudo apt update")
        assert "sudo" in patterns

    def test_detect_patterns_background(self):
        patterns = detect_patterns("sleep 100 &")
        assert "background" in patterns

    def test_resolve_command_resolution_echo(self):
        resolution = resolve_command_resolution("echo hello")
        assert resolution is not None
        assert resolution.raw_executable == "echo"

    def test_resolve_command_from_argv(self):
        resolution = resolve_command_from_argv(["echo", "hello"])
        assert resolution is not None
        assert resolution.raw_executable == "echo"

    def test_resolve_command_from_argv_empty(self):
        resolution = resolve_command_from_argv([])
        assert resolution is None


# ============================================================================
# Service tests
# ============================================================================


class TestApprovalService:
    def test_service_creation(self):
        service = ApprovalService(agent_id="test-agent")
        assert service.service_type == "exec_approval"

    def test_service_set_safe_bins(self):
        service = ApprovalService()
        service.set_safe_bins(["custom_tool"])
        # Safe bins should be set (internal, tested via check_command behavior)

    def test_service_set_skill_bins(self):
        service = ApprovalService()
        service.set_skill_bins(["MySkillBin"])
        # Skill bins should be lowercased internally

    def test_check_command_analysis_failure(self):
        service = ApprovalService()
        with tempfile.TemporaryDirectory() as tmpdir:
            file_path = os.path.join(tmpdir, "test-approvals.json")
            with mock.patch(
                "elizaos_plugin_shell.approvals.allowlist.get_approval_file_path",
                return_value=file_path,
            ):
                service.start()
                # Command with unsupported tokens
                result = service.check_command(command="", room_id="room-1")
                assert result.allowed is False
                assert result.requires_approval is False

    def test_check_command_deny_mode(self):
        service = ApprovalService()
        with tempfile.TemporaryDirectory() as tmpdir:
            file_path = os.path.join(tmpdir, "test-approvals.json")
            # Pre-write config with deny mode
            config = ExecApprovalsFile(
                version=1,
                defaults=ExecApprovalsDefaults(security=ExecSecurity.DENY),
                agents={},
            )
            os.makedirs(os.path.dirname(file_path), exist_ok=True)
            with open(file_path, "w") as f:
                json.dump(config.model_dump(mode="json"), f)

            with mock.patch(
                "elizaos_plugin_shell.approvals.allowlist.get_approval_file_path",
                return_value=file_path,
            ):
                service.start()
                result = service.check_command(command="echo hello", room_id="room-1")
                assert result.allowed is False
                assert result.reason == "Command execution is disabled"

    @pytest.mark.asyncio
    async def test_request_approval_default_deny(self):
        service = ApprovalService()
        request = ExecApprovalRequest(
            id="test",
            command="echo hello",
            room_id="room-1",
        )
        result = await service.request_approval(request)
        assert result.decision == ExecApprovalDecision.DENY

    @pytest.mark.asyncio
    async def test_handle_approval_allow_always(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            file_path = os.path.join(tmpdir, "test-approvals.json")
            config = ExecApprovalsFile(version=1, agents={})
            os.makedirs(os.path.dirname(file_path), exist_ok=True)
            with open(file_path, "w") as f:
                json.dump(config.model_dump(mode="json"), f)

            with mock.patch(
                "elizaos_plugin_shell.approvals.allowlist.get_approval_file_path",
                return_value=file_path,
            ):
                service = ApprovalService()
                service.start()

                request = ExecApprovalRequest(
                    id="test",
                    command="echo hello",
                    resolved_path="/usr/bin/echo",
                    room_id="room-1",
                )
                result = await service.handle_approval_response(
                    request, ExecApprovalDecision.ALLOW_ALWAYS
                )
                assert result.decision == ExecApprovalDecision.ALLOW_ALWAYS


# ============================================================================
# Command Injection (Security-Critical) tests
# ============================================================================


class TestCommandInjection:
    """Security-critical tests for command injection vectors."""

    def test_quoted_semicolon_safe(self):
        """Semicolons inside double quotes must not split the command."""
        result = analyze_shell_command('echo "hello; rm -rf /"')
        assert result.ok is True
        assert len(result.segments) == 1
        assert result.segments[0].argv[0] == "echo"
        assert result.segments[0].argv[1] == "hello; rm -rf /"
        # Must NOT produce chains - the semicolon is quoted
        assert result.chains is None

    def test_backtick_substitution_blocked(self):
        """Backtick command substitution must be rejected."""
        result = analyze_shell_command("echo `rm -rf /`")
        assert result.ok is False
        assert result.reason is not None
        assert "`" in result.reason
        assert len(result.segments) == 0

    def test_dollar_paren_substitution_blocked(self):
        """$() command substitution must be rejected."""
        result = analyze_shell_command("echo $(whoami)")
        assert result.ok is False
        assert result.reason is not None
        assert "$()" in result.reason
        assert len(result.segments) == 0

    def test_nested_subshell_blocked(self):
        """Nested $($()) subshells must be rejected at the outermost level."""
        result = analyze_shell_command("$($(cmd))")
        assert result.ok is False
        assert result.reason is not None
        assert "$()" in result.reason
        assert len(result.segments) == 0

    def test_pipe_in_single_quotes_safe(self):
        """Pipe character inside single quotes must not split the pipeline."""
        result = analyze_shell_command("echo 'a|b'")
        assert result.ok is True
        assert len(result.segments) == 1
        assert result.segments[0].argv[0] == "echo"
        assert result.segments[0].argv[1] == "a|b"
        # Must remain a single segment, not two
        assert result.chains is None

    def test_heredoc_redirect(self):
        """Here-doc/input redirect operator < must be rejected in pipeline."""
        result = analyze_shell_command("<< EOF")
        assert result.ok is False
        assert result.reason is not None
        assert "<" in result.reason
        assert len(result.segments) == 0

    def test_env_var_in_command(self):
        """ENV=value prefix is parsed as the first argv token, not separated.

        This is a known limitation: bash interprets 'HOME=/tmp ls' as setting
        HOME for ls, but the parser treats HOME=/tmp as the executable.
        """
        result = analyze_shell_command("HOME=/tmp ls")
        assert result.ok is True
        assert len(result.segments) == 1
        assert result.segments[0].argv[0] == "HOME=/tmp"
        assert result.segments[0].argv[1] == "ls"
        # Resolution uses HOME=/tmp as the raw executable
        assert result.segments[0].resolution is not None
        assert result.segments[0].resolution.raw_executable == "HOME=/tmp"

    def test_escaped_semicolons(self):
        """Backslash-escaped semicolons must not split the command."""
        result = analyze_shell_command("echo hello\\; world")
        assert result.ok is True
        assert len(result.segments) == 1
        # Tokenizer consumes the backslash, semicolon becomes literal
        assert result.segments[0].argv[0] == "echo"
        assert result.segments[0].argv[1] == "hello;"
        assert result.segments[0].argv[2] == "world"
        # Must NOT produce chains
        assert result.chains is None


# ============================================================================
# Pipeline and Chain Analysis tests
# ============================================================================


class TestPipelineAndChainAnalysis:
    """Tests for multi-command pipeline and chain operator handling."""

    def test_pipe_chain_three_commands(self):
        """Three-stage pipeline produces exactly three segments."""
        result = analyze_shell_command("cmd1 | cmd2 | cmd3")
        assert result.ok is True
        assert len(result.segments) == 3
        assert result.segments[0].argv[0] == "cmd1"
        assert result.segments[1].argv[0] == "cmd2"
        assert result.segments[2].argv[0] == "cmd3"
        # Simple pipeline, no chains
        assert result.chains is None

    def test_or_chain_rejected(self):
        """'cmd1 || cmd2' is split into separate chains for per-chain evaluation."""
        result = analyze_shell_command("cmd1 || cmd2")
        assert result.ok is True
        assert result.chains is not None
        assert len(result.chains) == 2
        assert result.chains[0][0].argv[0] == "cmd1"
        assert result.chains[1][0].argv[0] == "cmd2"
        # Both commands are captured in segments
        assert len(result.segments) == 2
        assert result.segments[0].argv[0] == "cmd1"
        assert result.segments[1].argv[0] == "cmd2"

    def test_and_chain_rejected(self):
        """'cmd1 && cmd2' is split into separate chains for per-chain evaluation."""
        result = analyze_shell_command("cmd1 && cmd2")
        assert result.ok is True
        assert result.chains is not None
        assert len(result.chains) == 2
        assert result.chains[0][0].argv[0] == "cmd1"
        assert result.chains[1][0].argv[0] == "cmd2"
        assert len(result.segments) == 2

    def test_background_job_rejected(self):
        """Background operator & is rejected by the pipeline parser."""
        result = analyze_shell_command("cmd &")
        assert result.ok is False
        assert result.reason is not None
        assert "&" in result.reason
        assert len(result.segments) == 0

    def test_empty_pipeline_segment(self):
        """'cmd1 | | cmd2' has whitespace-only segment between pipes.

        The whitespace-only middle segment is silently dropped by push_part(),
        producing a two-segment pipeline equivalent to 'cmd1 | cmd2'.
        A trailing pipe ('cmd1 |') is correctly detected as an error.
        """
        result = analyze_shell_command("cmd1 | | cmd2")
        # Whitespace-only segment is trimmed away
        assert result.ok is True
        assert len(result.segments) == 2
        assert result.segments[0].argv[0] == "cmd1"
        assert result.segments[1].argv[0] == "cmd2"

        # Trailing pipe IS properly detected
        trailing = analyze_shell_command("cmd1 |")
        assert trailing.ok is False
        assert trailing.reason is not None
        assert "empty" in trailing.reason.lower()


# ============================================================================
# Risk Assessment tests
# ============================================================================


class TestRiskAssessment:
    """Tests for risk level classification and pattern detection."""

    def test_fork_bomb_detected(self):
        """Fork bomb :(){:|:&};: is detected as critical risk and high_risk pattern."""
        cmd = ":(){:|:&};:"
        # Analysis rejects due to parentheses
        result = analyze_shell_command(cmd)
        assert result.ok is False
        # Risk level is CRITICAL
        assert get_risk_level(cmd) == RiskLevel.CRITICAL
        # Pattern detection catches it
        patterns = detect_patterns(cmd)
        assert "high_risk" in patterns
        assert "background" in patterns
        assert "pipeline" in patterns

    def test_input_redirect_detected(self):
        """Input redirect '<' is detected in pattern analysis."""
        patterns = detect_patterns("cmd < file")
        assert "input_redirect" in patterns
        # Also rejected by the pipeline parser
        result = analyze_shell_command("cmd < file")
        assert result.ok is False
        assert "<" in result.reason

    def test_stderr_redirect_detected(self):
        """stderr redirects 2>&1 and 2> are both detected."""
        patterns_merged = detect_patterns("cmd 2>&1")
        assert "stderr_redirect" in patterns_merged

        patterns_file = detect_patterns("cmd 2> error.log")
        assert "stderr_redirect" in patterns_file

        # Both should also have output_redirect (contains ">")
        assert "output_redirect" in patterns_merged
        assert "output_redirect" in patterns_file

    def test_write_commands_classified(self):
        """File-modifying commands (cp, mv, mkdir, touch, chmod, chown) are WRITE."""
        write_cmds = [
            ("cp src dst", "cp"),
            ("mv old new", "mv"),
            ("mkdir newdir", "mkdir"),
            ("touch newfile", "touch"),
            ("chmod 755 script.sh", "chmod"),
            ("chown user:group file", "chown"),
        ]
        for cmd, name in write_cmds:
            classification = classify_command_type(cmd)
            assert classification == CommandClassification.WRITE, (
                f"{name} should be classified as WRITE, got {classification}"
            )

    def test_get_risk_level_safe_commands(self):
        """Read-only commands have LOW risk level."""
        safe_commands = [
            ("echo hello", "echo"),
            ("ls -la", "ls"),
            ("cat README.md", "cat"),
            ("grep pattern file", "grep"),
            ("whoami", "whoami"),
            ("pwd", "pwd"),
            ("date", "date"),
            ("wc -l file.txt", "wc"),
        ]
        for cmd, name in safe_commands:
            risk = get_risk_level(cmd)
            assert risk == RiskLevel.LOW, (
                f"{name} should be LOW risk, got {risk}"
            )


# ============================================================================
# Quote-Aware Parsing tests
# ============================================================================


class TestQuoteAwareParsing:
    """Tests for quote-aware iteration and rejection of dangerous patterns."""

    def test_iterate_double_quoted_dollar_rejected(self):
        """$() inside double quotes is rejected."""
        result = analyze_shell_command('"$(cmd)"')
        assert result.ok is False
        assert result.reason is not None
        assert "$()" in result.reason

    def test_iterate_double_quoted_backtick_rejected(self):
        """Backtick inside double quotes is rejected."""
        result = analyze_shell_command('"`cmd`"')
        assert result.ok is False
        assert result.reason is not None
        assert "`" in result.reason

    def test_iterate_newline_rejected(self):
        """Embedded newline characters are rejected in commands."""
        # Newline outside quotes - rejected by pipeline parser
        result = analyze_shell_command("echo hello\nworld")
        assert result.ok is False
        assert result.reason is not None

        # Newline inside double quotes - also rejected
        result_quoted = analyze_shell_command('"hello\nworld"')
        assert result_quoted.ok is False
        assert result_quoted.reason is not None
        assert "newline" in result_quoted.reason

    def test_iterate_unterminated_escape(self):
        """Trailing backslash (unterminated escape) is rejected."""
        result = analyze_shell_command("echo hello\\")
        assert result.ok is False
        assert result.reason is not None
        assert "unterminated" in result.reason.lower()

        # Unterminated single quote
        result_sq = analyze_shell_command("echo 'hello")
        assert result_sq.ok is False
        assert "unterminated" in result_sq.reason.lower()

        # Unterminated double quote
        result_dq = analyze_shell_command('echo "hello')
        assert result_dq.ok is False
        assert "unterminated" in result_dq.reason.lower()


# ============================================================================
# Path and Bin Analysis tests
# ============================================================================


class TestPathAndBinAnalysis:
    """Tests for path-like token detection and safe bin usage evaluation."""

    def test_is_path_like_token_relative(self):
        """Relative paths are detected as path-like tokens."""
        assert _is_path_like_token("./script.sh") is True
        assert _is_path_like_token("../parent/script.sh") is True
        assert _is_path_like_token("~/bin/tool") is True
        # Plain filename without path separator is NOT path-like
        assert _is_path_like_token("script.sh") is False

    def test_is_path_like_token_absolute(self):
        """Absolute paths are detected as path-like tokens."""
        assert _is_path_like_token("/usr/bin/env") is True
        assert _is_path_like_token("/") is True
        # Empty string and bare dash are not path-like
        assert _is_path_like_token("") is False
        assert _is_path_like_token("-") is False
        # Whitespace-only is not path-like
        assert _is_path_like_token("   ") is False

    def test_is_safe_bin_usage_with_file_args(self):
        """A safe bin invoked with file path arguments is NOT safe usage."""
        resolution = CommandResolution(
            raw_executable="cat",
            resolved_path="/usr/bin/cat",
            executable_name="cat",
        )
        safe_bins = {"cat"}

        # File path arg makes it unsafe
        result = is_safe_bin_usage(
            argv=["cat", "/etc/passwd"],
            resolution=resolution,
            safe_bins=safe_bins,
        )
        assert result is False

        # Relative path also makes it unsafe
        result_rel = is_safe_bin_usage(
            argv=["cat", "./secrets.txt"],
            resolution=resolution,
            safe_bins=safe_bins,
        )
        assert result_rel is False

        # Flag-only usage (no file args) IS safe
        result_safe = is_safe_bin_usage(
            argv=["cat", "--help"],
            resolution=resolution,
            safe_bins=safe_bins,
            file_exists=lambda p: False,
        )
        assert result_safe is True

    def test_empty_string_executable(self):
        """Empty string as executable resolves to None."""
        result_argv = resolve_command_from_argv([""])
        assert result_argv is None

        result_cmd = resolve_command_resolution("")
        assert result_cmd is None

        # Whitespace-only also resolves to None
        result_ws = resolve_command_resolution("   ")
        assert result_ws is None
