"""Tests for core tools"""

import re

import pytest
from pathlib import Path
from cody.core.tools import (
    read_file, write_file, edit_file, list_directory,
    grep, glob, patch, search_files,
)
from cody.core.tools._file_filter import _is_binary, _parse_gitignore, _is_gitignored, _iter_files
from cody.core.tools.command import _BLOCKED_COMMAND_PATTERNS, exec_command
from cody.core.config import Config
from cody.core.skill_manager import SkillManager
from cody.core.deps import CodyDeps
from cody.core.errors import ToolPathDenied, ToolInvalidParams


class MockContext:
    """Mock RunContext for testing"""
    def __init__(self, workdir, allowed_roots=None, strict_read_boundary=False):
        workdir = Path(workdir)
        config = Config()
        self.deps = CodyDeps(
            config=config,
            workdir=workdir,
            skill_manager=SkillManager(config, workdir=workdir),
            allowed_roots=allowed_roots or [],
            strict_read_boundary=strict_read_boundary,
        )


# ── File operation tests ─────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_write_and_read_file(tmp_path):
    ctx = MockContext(tmp_path)

    result = await write_file(ctx, "test.txt", "Hello, World!")
    assert "Written" in result

    content = await read_file(ctx, "test.txt")
    assert content == "Hello, World!"


@pytest.mark.asyncio
async def test_edit_file(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "code.py").write_text("def foo():\n    return 1\n")

    result = await edit_file(ctx, "code.py", "return 1", "return 42")
    assert "Edited" in result

    content = (tmp_path / "code.py").read_text()
    assert "return 42" in content
    assert "return 1" not in content


@pytest.mark.asyncio
async def test_edit_file_text_not_found(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "code.py").write_text("def foo():\n    pass\n")

    with pytest.raises(ToolInvalidParams, match="Text not found"):
        await edit_file(ctx, "code.py", "nonexistent text", "new text")


@pytest.mark.asyncio
async def test_list_directory(tmp_path):
    ctx = MockContext(tmp_path)

    (tmp_path / "file1.txt").write_text("test")
    (tmp_path / "file2.py").write_text("test")
    (tmp_path / "subdir").mkdir()

    result = await list_directory(ctx, ".")
    assert "file1.txt" in result
    assert "file2.py" in result
    assert "subdir" in result


# ── Security tests ───────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_read_file_outside_workdir_allowed(tmp_path):
    """read_file allows reading outside workdir (read-only is safe)"""
    ctx = MockContext(tmp_path)

    # Reading outside workdir is allowed but file may not exist
    with pytest.raises(ToolInvalidParams):
        await read_file(ctx, "../../../nonexistent_file.txt")


@pytest.mark.asyncio
async def test_read_file_directory_raises(tmp_path):
    """read_file raises ToolInvalidParams when path is a directory"""
    ctx = MockContext(tmp_path)
    subdir = tmp_path / "some_dir"
    subdir.mkdir()

    with pytest.raises(ToolInvalidParams, match="directory"):
        await read_file(ctx, "some_dir")


@pytest.mark.asyncio
async def test_strict_read_boundary_blocks_read_outside(tmp_path):
    """When strict_read_boundary=True, read_file blocks access outside workdir"""
    ctx = MockContext(tmp_path, strict_read_boundary=True)

    with pytest.raises(ToolPathDenied, match="outside.*permitted directories"):
        await read_file(ctx, "../../../etc/passwd")


@pytest.mark.asyncio
async def test_strict_read_boundary_allows_read_inside(tmp_path):
    """When strict_read_boundary=True, read_file still works inside workdir"""
    ctx = MockContext(tmp_path, strict_read_boundary=True)
    (tmp_path / "hello.txt").write_text("hi")

    result = await read_file(ctx, "hello.txt")
    assert result == "hi"


@pytest.mark.asyncio
async def test_strict_read_boundary_allows_allowed_roots(tmp_path):
    """When strict_read_boundary=True, read_file works in allowed_roots"""
    workdir = tmp_path / "work"
    workdir.mkdir()
    extra = tmp_path / "extra"
    extra.mkdir()
    (extra / "data.txt").write_text("extra data")

    ctx = MockContext(workdir, allowed_roots=[extra], strict_read_boundary=True)
    result = await read_file(ctx, str(extra / "data.txt"))
    assert result == "extra data"


@pytest.mark.asyncio
async def test_strict_read_boundary_grep_blocks_outside(tmp_path):
    """When strict_read_boundary=True, grep blocks access outside workdir"""
    ctx = MockContext(tmp_path, strict_read_boundary=True)

    with pytest.raises(ToolPathDenied, match="outside.*permitted directories"):
        await grep(ctx, "pattern", path="../../../etc")


@pytest.mark.asyncio
async def test_strict_read_boundary_exec_command_blocks_outside(tmp_path):
    """When strict_read_boundary=True, exec_command blocks commands with outside paths"""
    ctx = MockContext(tmp_path, strict_read_boundary=True)

    with pytest.raises(ToolPathDenied, match="outside.*permitted directories"):
        await exec_command(ctx, "ls -la /etc")


@pytest.mark.asyncio
async def test_strict_read_boundary_exec_command_allows_inside(tmp_path):
    """When strict_read_boundary=True, exec_command allows paths inside workdir"""
    ctx = MockContext(tmp_path, strict_read_boundary=True)

    result = await exec_command(ctx, f"ls {tmp_path}")
    assert "[ERROR]" not in result


@pytest.mark.asyncio
async def test_strict_read_boundary_exec_command_no_abs_path_ok(tmp_path):
    """When strict_read_boundary=True, commands without absolute paths are fine"""
    ctx = MockContext(tmp_path, strict_read_boundary=True)

    result = await exec_command(ctx, "echo hello")
    assert "hello" in result


@pytest.mark.asyncio
async def test_security_check_write_outside(tmp_path):
    ctx = MockContext(tmp_path)

    with pytest.raises(ToolPathDenied, match="outside.*permitted directories"):
        await write_file(ctx, "../../evil.txt", "bad content")


@pytest.mark.asyncio
async def test_security_check_symlink_write_escape(tmp_path):
    """Write via symlinks that escape workdir should be caught"""
    ctx = MockContext(tmp_path)

    # Create a symlink pointing outside
    link = tmp_path / "escape"
    link.symlink_to("/tmp")

    with pytest.raises(ToolPathDenied, match="outside.*permitted directories"):
        await write_file(ctx, "escape/evil.txt", "bad content")


# ── Grep tests ───────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_grep_basic(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "hello.py").write_text("def hello():\n    print('world')\n")
    (tmp_path / "other.txt").write_text("nothing here\n")

    result = await grep(ctx, "def hello")
    assert "hello.py:1:" in result
    assert "def hello" in result


@pytest.mark.asyncio
async def test_grep_regex(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "code.py").write_text("x = 123\ny = 456\nz = abc\n")

    result = await grep(ctx, r"\d{3}")
    assert "code.py:1:" in result
    assert "code.py:2:" in result
    assert "z = abc" not in result


@pytest.mark.asyncio
async def test_grep_include_filter(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "code.py").write_text("def foo(): pass\n")
    (tmp_path / "readme.md").write_text("def foo in docs\n")

    result = await grep(ctx, "def foo", include="*.py")
    assert "code.py" in result
    assert "readme.md" not in result


@pytest.mark.asyncio
async def test_grep_no_matches(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "file.txt").write_text("hello world\n")

    result = await grep(ctx, "nonexistent_pattern")
    assert "No matches found" in result


@pytest.mark.asyncio
async def test_grep_invalid_regex(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "file.txt").write_text("test\n")

    with pytest.raises(ToolInvalidParams, match="Invalid regex"):
        await grep(ctx, "[invalid")


@pytest.mark.asyncio
async def test_grep_single_file(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "target.py").write_text("line1\nmatch_me\nline3\n")

    result = await grep(ctx, "match_me", path="target.py")
    assert "target.py:2:" in result


# ── Glob tests ───────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_glob_star_py(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "main.py").write_text("")
    (tmp_path / "utils.py").write_text("")
    (tmp_path / "readme.md").write_text("")

    result = await glob(ctx, "*.py")
    assert "main.py" in result
    assert "utils.py" in result
    assert "readme.md" not in result


@pytest.mark.asyncio
async def test_glob_recursive(tmp_path):
    ctx = MockContext(tmp_path)
    sub = tmp_path / "src" / "core"
    sub.mkdir(parents=True)
    (sub / "engine.py").write_text("")
    (tmp_path / "main.py").write_text("")

    result = await glob(ctx, "**/*.py")
    assert "main.py" in result
    assert "engine.py" in result


@pytest.mark.asyncio
async def test_glob_no_matches(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "file.txt").write_text("")

    result = await glob(ctx, "*.rs")
    assert "No files matched" in result


# ── Patch tests ──────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_patch_add_line(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "file.py").write_text("line1\nline2\nline3\n")

    diff = (
        "@@ -2,2 +2,3 @@\n"
        " line2\n"
        "+new_line\n"
        " line3\n"
    )
    result = await patch(ctx, "file.py", diff)
    assert "Patched" in result

    content = (tmp_path / "file.py").read_text()
    assert "new_line" in content
    assert content.index("line2") < content.index("new_line") < content.index("line3")


@pytest.mark.asyncio
async def test_patch_remove_line(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "file.py").write_text("line1\ndelete_me\nline3\n")

    diff = (
        "@@ -1,3 +1,2 @@\n"
        " line1\n"
        "-delete_me\n"
        " line3\n"
    )
    result = await patch(ctx, "file.py", diff)
    assert "Patched" in result

    content = (tmp_path / "file.py").read_text()
    assert "delete_me" not in content
    assert "line1\nline3\n" == content


@pytest.mark.asyncio
async def test_patch_replace_line(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "file.py").write_text("old_value = 1\n")

    diff = (
        "@@ -1,1 +1,1 @@\n"
        "-old_value = 1\n"
        "+new_value = 42\n"
    )
    result = await patch(ctx, "file.py", diff)
    assert "Patched" in result

    content = (tmp_path / "file.py").read_text()
    assert "new_value = 42" in content
    assert "old_value" not in content


@pytest.mark.asyncio
async def test_patch_file_not_found(tmp_path):
    ctx = MockContext(tmp_path)

    with pytest.raises(ToolInvalidParams):
        await patch(ctx, "nonexistent.py", "@@ -1,1 +1,1 @@\n-a\n+b\n")


@pytest.mark.asyncio
async def test_patch_with_headers(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "file.py").write_text("aaa\nbbb\nccc\n")

    diff = (
        "--- a/file.py\n"
        "+++ b/file.py\n"
        "@@ -1,3 +1,3 @@\n"
        " aaa\n"
        "-bbb\n"
        "+BBB\n"
        " ccc\n"
    )
    result = await patch(ctx, "file.py", diff)
    assert "Patched" in result
    assert (tmp_path / "file.py").read_text() == "aaa\nBBB\nccc\n"


# ── Search files tests ───────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_search_files_exact_match(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "config.py").write_text("")
    (tmp_path / "config_test.py").write_text("")
    (tmp_path / "readme.md").write_text("")

    result = await search_files(ctx, "config.py")
    lines = result.strip().split("\n")
    # Exact match should come first
    assert lines[0] == "config.py"


@pytest.mark.asyncio
async def test_search_files_contains(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "my_config.py").write_text("")
    (tmp_path / "other.txt").write_text("")

    result = await search_files(ctx, "config")
    assert "my_config.py" in result
    assert "other.txt" not in result


@pytest.mark.asyncio
async def test_search_files_in_subdirectory(tmp_path):
    ctx = MockContext(tmp_path)
    sub = tmp_path / "src"
    sub.mkdir()
    (sub / "utils.py").write_text("")
    (tmp_path / "main.py").write_text("")

    result = await search_files(ctx, "utils")
    assert "utils.py" in result


@pytest.mark.asyncio
async def test_search_files_case_insensitive(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "README.md").write_text("")

    result = await search_files(ctx, "readme")
    assert "README.md" in result


@pytest.mark.asyncio
async def test_search_files_no_match(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "file.txt").write_text("")

    result = await search_files(ctx, "nonexistent")
    assert "No files found" in result


@pytest.mark.asyncio
async def test_search_files_path_contains(tmp_path):
    ctx = MockContext(tmp_path)
    sub = tmp_path / "api" / "v2"
    sub.mkdir(parents=True)
    (sub / "handler.py").write_text("")

    result = await search_files(ctx, "api")
    assert "handler.py" in result


# ── Binary file detection tests ─────────────────────────────────────────────


def test_is_binary_with_text_file(tmp_path):
    f = tmp_path / "hello.txt"
    f.write_text("Hello, World!\n")
    assert _is_binary(f) is False


def test_is_binary_with_binary_file(tmp_path):
    f = tmp_path / "data.bin"
    f.write_bytes(b"some data\x00more data")
    assert _is_binary(f) is True


def test_is_binary_empty_file(tmp_path):
    f = tmp_path / "empty"
    f.write_bytes(b"")
    assert _is_binary(f) is False


def test_is_binary_utf8(tmp_path):
    f = tmp_path / "utf8.txt"
    f.write_text("你好世界\nこんにちは\n")
    assert _is_binary(f) is False


# ── Gitignore parsing tests ─────────────────────────────────────────────────


def test_parse_gitignore(tmp_path):
    (tmp_path / ".gitignore").write_text("*.pyc\n__pycache__/\n# comment\n\n*.log\n")
    patterns = _parse_gitignore(tmp_path)
    assert patterns == ["*.pyc", "__pycache__/", "*.log"]


def test_parse_gitignore_missing(tmp_path):
    patterns = _parse_gitignore(tmp_path)
    assert patterns == []


def test_is_gitignored_simple():
    patterns = ["*.pyc", "*.log"]
    assert _is_gitignored("foo.pyc", patterns) is True
    assert _is_gitignored("foo.py", patterns) is False
    assert _is_gitignored("app.log", patterns) is True


def test_is_gitignored_dir_pattern():
    patterns = ["build/"]
    assert _is_gitignored("build", patterns, is_dir=True) is True
    # File inside build dir: the dir pattern matches parent component
    assert _is_gitignored("build/output.js", patterns) is True
    # A file named "build" is NOT matched by a dir-only pattern
    assert _is_gitignored("build", patterns, is_dir=False) is False


def test_is_gitignored_negation():
    patterns = ["*.log", "!important.log"]
    assert _is_gitignored("debug.log", patterns) is True
    assert _is_gitignored("important.log", patterns) is False


def test_is_gitignored_anchored():
    patterns = ["/dist"]
    assert _is_gitignored("dist", patterns) is True
    assert _is_gitignored("src/dist", patterns) is False


def test_is_gitignored_nested_path():
    patterns = ["docs/generated"]
    assert _is_gitignored("docs/generated", patterns, is_dir=True) is True
    assert _is_gitignored("other/generated", patterns, is_dir=True) is False


# ── Default ignore tests (grep) ─────────────────────────────────────────────


@pytest.mark.asyncio
async def test_grep_skips_node_modules(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "app.js").write_text("const x = 1\n")
    nm = tmp_path / "node_modules" / "pkg"
    nm.mkdir(parents=True)
    (nm / "index.js").write_text("const x = 2\n")

    result = await grep(ctx, "const x")
    assert "app.js" in result
    assert "node_modules" not in result


@pytest.mark.asyncio
async def test_grep_skips_pycache(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "main.py").write_text("hello = True\n")
    pc = tmp_path / "__pycache__"
    pc.mkdir()
    (pc / "main.cpython-311.pyc").write_bytes(b"\x00\x00compiled")

    result = await grep(ctx, "hello")
    assert "main.py:1:" in result
    assert "__pycache__" not in result


@pytest.mark.asyncio
async def test_grep_skips_git_dir(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "code.py").write_text("real code\n")
    git = tmp_path / ".git" / "objects"
    git.mkdir(parents=True)
    (git / "abc123").write_text("git internal\n")

    result = await grep(ctx, "code")
    assert "code.py" in result
    assert ".git" not in result


@pytest.mark.asyncio
async def test_grep_skips_binary_files(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "readme.txt").write_text("hello world\n")
    (tmp_path / "image.png").write_bytes(b"\x89PNG\r\n\x1a\n\x00\x00more binary data")

    result = await grep(ctx, "hello")
    assert "readme.txt" in result
    # Binary file should be skipped, not error
    assert "image.png" not in result


@pytest.mark.asyncio
async def test_grep_respects_gitignore(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / ".gitignore").write_text("*.log\ntemp/\n")
    (tmp_path / "app.py").write_text("logging\n")
    (tmp_path / "debug.log").write_text("logging\n")
    temp = tmp_path / "temp"
    temp.mkdir()
    (temp / "scratch.py").write_text("logging\n")

    result = await grep(ctx, "logging")
    assert "app.py" in result
    assert "debug.log" not in result
    assert "temp" not in result


@pytest.mark.asyncio
async def test_grep_gitignore_negation(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / ".gitignore").write_text("*.log\n!important.log\n")
    (tmp_path / "debug.log").write_text("error line\n")
    (tmp_path / "important.log").write_text("error line\n")

    result = await grep(ctx, "error line")
    assert "debug.log" not in result
    assert "important.log" in result


@pytest.mark.asyncio
async def test_grep_reports_skipped_large_files(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "small.py").write_text("hello world\n")
    # Create a file just over 1 MB
    large = tmp_path / "big.sql"
    large.write_text("hello world\n" * 100_000)  # ~1.2 MB

    result = await grep(ctx, "hello")
    assert "small.py" in result
    assert "big.sql" not in result.split("[NOTE]")[0]  # not in matches
    assert "[NOTE]" in result
    assert "big.sql" in result  # mentioned in the skip note
    assert "large file" in result


@pytest.mark.asyncio
async def test_grep_no_note_when_no_large_files(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "a.py").write_text("hello\n")

    result = await grep(ctx, "hello")
    assert "a.py" in result
    assert "[NOTE]" not in result


# ── Default ignore tests (glob) ──────────────────────────────────────────────


@pytest.mark.asyncio
async def test_glob_skips_node_modules(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "app.js").write_text("")
    nm = tmp_path / "node_modules" / "pkg"
    nm.mkdir(parents=True)
    (nm / "index.js").write_text("")

    result = await glob(ctx, "**/*.js")
    assert "app.js" in result
    assert "node_modules" not in result


@pytest.mark.asyncio
async def test_glob_skips_hidden_dirs(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "main.py").write_text("")
    hidden = tmp_path / ".hidden"
    hidden.mkdir()
    (hidden / "secret.py").write_text("")

    result = await glob(ctx, "**/*.py")
    assert "main.py" in result
    assert ".hidden" not in result


@pytest.mark.asyncio
async def test_glob_respects_gitignore(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / ".gitignore").write_text("*.generated.py\n")
    (tmp_path / "app.py").write_text("")
    (tmp_path / "models.generated.py").write_text("")

    result = await glob(ctx, "**/*.py")
    assert "app.py" in result
    assert "generated" not in result


# ── Default ignore tests (search_files) ─────────────────────────────────────


@pytest.mark.asyncio
async def test_search_files_skips_node_modules(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "utils.js").write_text("")
    nm = tmp_path / "node_modules" / "lodash"
    nm.mkdir(parents=True)
    (nm / "utils.js").write_text("")

    result = await search_files(ctx, "utils")
    lines = result.strip().split("\n")
    assert len(lines) == 1
    assert lines[0] == "utils.js"


@pytest.mark.asyncio
async def test_search_files_skips_hidden(tmp_path):
    ctx = MockContext(tmp_path)
    (tmp_path / "config.py").write_text("")
    vscode = tmp_path / ".vscode"
    vscode.mkdir()
    (vscode / "config.json").write_text("")

    result = await search_files(ctx, "config")
    assert "config.py" in result
    assert ".vscode" not in result


# ── iter_files tests ─────────────────────────────────────────────────────────


def test_iter_files_prunes_ignored_dirs(tmp_path):
    (tmp_path / "real.py").write_text("code")
    nm = tmp_path / "node_modules" / "pkg"
    nm.mkdir(parents=True)
    (nm / "lib.js").write_text("code")
    pc = tmp_path / "__pycache__"
    pc.mkdir()
    (pc / "mod.pyc").write_bytes(b"\x00compiled")

    files = _iter_files(tmp_path, tmp_path, [])
    names = [f.name for f in files]
    assert "real.py" in names
    assert "lib.js" not in names
    assert "mod.pyc" not in names


def test_iter_files_respects_gitignore(tmp_path):
    (tmp_path / "app.py").write_text("code")
    (tmp_path / "debug.log").write_text("log")
    logs = tmp_path / "logs"
    logs.mkdir()
    (logs / "app.log").write_text("log")

    files = _iter_files(tmp_path, tmp_path, ["*.log", "logs/"])
    names = [f.name for f in files]
    assert "app.py" in names
    assert "debug.log" not in names
    assert "app.log" not in names


# ── allowed_roots tests ───────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_write_file_to_allowed_root(tmp_path):
    """write_file succeeds when target is in allowed_roots."""
    workdir = tmp_path / "workdir"
    workdir.mkdir()
    shared = tmp_path / "shared"
    shared.mkdir()

    ctx = MockContext(workdir, allowed_roots=[shared])
    result = await write_file(ctx, str(shared / "output.txt"), "content")
    assert "Written" in result
    assert (shared / "output.txt").read_text() == "content"


@pytest.mark.asyncio
async def test_write_file_outside_all_roots_denied(tmp_path):
    """write_file blocked when target is outside workdir AND allowed_roots."""
    workdir = tmp_path / "workdir"
    workdir.mkdir()
    shared = tmp_path / "shared"
    shared.mkdir()
    other = tmp_path / "other"
    other.mkdir()

    ctx = MockContext(workdir, allowed_roots=[shared])
    with pytest.raises(ToolPathDenied, match="outside.*permitted directories"):
        await write_file(ctx, str(other / "bad.txt"), "evil")


@pytest.mark.asyncio
async def test_read_file_from_allowed_root(tmp_path):
    """read_file can read from allowed_roots."""
    workdir = tmp_path / "workdir"
    workdir.mkdir()
    shared = tmp_path / "shared"
    shared.mkdir()
    (shared / "data.txt").write_text("shared data")

    ctx = MockContext(workdir, allowed_roots=[shared])
    content = await read_file(ctx, str(shared / "data.txt"))
    assert content == "shared data"


@pytest.mark.asyncio
async def test_empty_allowed_roots_same_as_before(tmp_path):
    """Empty allowed_roots = old behavior (workdir only)."""
    ctx = MockContext(tmp_path, allowed_roots=[])
    with pytest.raises(ToolPathDenied, match="outside.*permitted directories"):
        await write_file(ctx, "../../outside.txt", "bad")


# ── Blocked command pattern tests ───────────────────────────────────────────


def _matches_any_blocked(command: str) -> bool:
    """Helper: check if a command matches any blocked pattern after normalization."""
    normalized = re.sub(r'\s+', ' ', command.strip())
    return any(p.search(normalized) for p in _BLOCKED_COMMAND_PATTERNS)


class TestBlockedCommandPatterns:
    """Tests for regex-based command blocklist (S3 fix)."""

    def test_basic_rm_rf_root(self):
        assert _matches_any_blocked("rm -rf /")

    def test_rm_rf_extra_spaces(self):
        """Multiple spaces between args should still be blocked."""
        assert _matches_any_blocked("rm  -rf  /")

    def test_rm_rf_tabs(self):
        """Tabs between args should still be blocked."""
        assert _matches_any_blocked("rm\t-rf\t/")

    def test_rm_separate_flags(self):
        """rm -r -f / (separate flags) should be blocked."""
        assert _matches_any_blocked("rm -r -f /")

    def test_rm_fr_root(self):
        """rm -fr / (reversed flag order) should be blocked."""
        assert _matches_any_blocked("rm -fr /")

    def test_rm_rf_home(self):
        assert _matches_any_blocked("rm -rf ~")

    def test_rm_rf_home_slash(self):
        assert _matches_any_blocked("rm -rf ~/")

    def test_rm_rf_wildcard(self):
        assert _matches_any_blocked("rm -rf /*")

    def test_dd_if(self):
        assert _matches_any_blocked("dd if=/dev/zero of=/dev/sda")

    def test_fork_bomb(self):
        assert _matches_any_blocked(":() { :|:& };:")

    def test_mkfs(self):
        assert _matches_any_blocked("mkfs.ext4 /dev/sda1")

    def test_overwrite_disk(self):
        assert _matches_any_blocked("echo bad > /dev/sda")

    def test_safe_command_not_blocked(self):
        """Normal commands should not be blocked."""
        assert not _matches_any_blocked("ls -la")
        assert not _matches_any_blocked("python script.py")
        assert not _matches_any_blocked("rm temp_file.txt")
        assert not _matches_any_blocked("cat /etc/hosts")
