from __future__ import annotations

import asyncio

from nanobot.agent.tools.apply_patch import ApplyPatchTool


def test_apply_patch_edits_replace(tmp_path):
    target = tmp_path / "calc.py"
    target.write_text("def add(a, b):\n    return a + b\n")
    tool = ApplyPatchTool(workspace=tmp_path)

    result = asyncio.run(
        tool.execute(
            edits=[
                {
                    "path": "calc.py",
                    "action": "replace",
                    "old_text": "    return a + b",
                    "new_text": "    return a - b",
                }
            ]
        )
    )

    assert "update calc.py" in result
    assert target.read_text() == "def add(a, b):\n    return a - b\n"


def test_apply_patch_edits_add_new_file(tmp_path):
    tool = ApplyPatchTool(workspace=tmp_path)

    result = asyncio.run(
        tool.execute(
            edits=[
                {
                    "path": "config.py",
                    "action": "add",
                    "new_text": "DEBUG = True",
                }
            ]
        )
    )

    assert "add config.py" in result
    assert (tmp_path / "config.py").read_text() == "DEBUG = True\n"


def test_apply_patch_edits_preserves_new_file_trailing_blank_lines(tmp_path):
    tool = ApplyPatchTool(workspace=tmp_path)

    result = asyncio.run(
        tool.execute(
            edits=[
                {
                    "path": "notes.txt",
                    "action": "add",
                    "new_text": "one\n\n",
                }
            ]
        )
    )

    assert "add notes.txt" in result
    assert (tmp_path / "notes.txt").read_text() == "one\n\n"


def test_apply_patch_edits_add_to_existing_file(tmp_path):
    target = tmp_path / "log.py"
    target.write_text("import logging\n\nlogger = logging.getLogger(__name__)\n")
    tool = ApplyPatchTool(workspace=tmp_path)

    result = asyncio.run(
        tool.execute(
            edits=[
                {
                    "path": "log.py",
                    "action": "add",
                    "new_text": "def debug(msg):\n    logger.debug(msg)",
                }
            ]
        )
    )

    assert "update log.py" in result
    assert (
        target.read_text()
        == "import logging\n\nlogger = logging.getLogger(__name__)\ndef debug(msg):\n    logger.debug(msg)\n"
    )


def test_apply_patch_edits_add_to_existing_file_without_final_newline(tmp_path):
    target = tmp_path / "notes.txt"
    target.write_text("alpha", encoding="utf-8")
    tool = ApplyPatchTool(workspace=tmp_path)

    result = asyncio.run(
        tool.execute(
            edits=[
                {
                    "path": "notes.txt",
                    "action": "add",
                    "new_text": "beta",
                }
            ]
        )
    )

    assert "update notes.txt" in result
    assert target.read_text(encoding="utf-8") == "alpha\nbeta\n"


def test_apply_patch_edits_add_to_existing_crlf_file_without_final_newline(tmp_path):
    target = tmp_path / "notes.txt"
    target.write_bytes(b"alpha\r\nbravo")
    tool = ApplyPatchTool(workspace=tmp_path)

    result = asyncio.run(
        tool.execute(
            edits=[
                {
                    "path": "notes.txt",
                    "action": "add",
                    "new_text": "charlie",
                }
            ]
        )
    )

    assert "update notes.txt" in result
    assert target.read_bytes() == b"alpha\r\nbravo\r\ncharlie\r\n"


def test_apply_patch_edits_add_to_existing_file_respects_leading_newline(tmp_path):
    target = tmp_path / "notes.txt"
    target.write_text("alpha", encoding="utf-8")
    tool = ApplyPatchTool(workspace=tmp_path)

    result = asyncio.run(
        tool.execute(
            edits=[
                {
                    "path": "notes.txt",
                    "action": "add",
                    "new_text": "\nbeta",
                }
            ]
        )
    )

    assert "update notes.txt" in result
    assert target.read_text(encoding="utf-8") == "alpha\nbeta\n"


def test_apply_patch_rejects_delete_action(tmp_path):
    target = tmp_path / "utils.py"
    target.write_text("def unused():\n    pass\ndef used():\n    return 1\n")
    tool = ApplyPatchTool(workspace=tmp_path)

    result = asyncio.run(
        tool.execute(
            edits=[
                {
                    "path": "utils.py",
                    "action": "delete",
                    "old_text": "def unused():\n    pass\n",
                }
            ]
        )
    )

    assert "unknown action: delete" in result
    assert target.read_text() == "def unused():\n    pass\ndef used():\n    return 1\n"


def test_apply_patch_edits_batch_multiple_files(tmp_path):
    a = tmp_path / "a.py"
    a.write_text("X = 1\n")
    b = tmp_path / "b.py"
    b.write_text("from a import X\nprint(X)\n")
    tool = ApplyPatchTool(workspace=tmp_path)

    result = asyncio.run(
        tool.execute(
            edits=[
                {
                    "path": "a.py",
                    "action": "replace",
                    "old_text": "X = 1",
                    "new_text": "Y = 1",
                },
                {
                    "path": "b.py",
                    "action": "replace",
                    "old_text": "from a import X",
                    "new_text": "from a import Y",
                },
            ]
        )
    )

    assert "update a.py" in result
    assert "update b.py" in result
    assert a.read_text() == "Y = 1\n"
    assert b.read_text() == "from a import Y\nprint(X)\n"


def test_apply_patch_edits_rejects_ambiguous_old_text(tmp_path):
    target = tmp_path / "repeated.txt"
    target.write_text("target\nmiddle\ntarget\n")
    tool = ApplyPatchTool(workspace=tmp_path)

    result = asyncio.run(
        tool.execute(
            edits=[
                {
                    "path": "repeated.txt",
                    "action": "replace",
                    "old_text": "target",
                    "new_text": "changed",
                }
            ]
        )
    )

    assert "old_text appears multiple times" in result
    assert target.read_text() == "target\nmiddle\ntarget\n"


def test_apply_patch_edits_dry_run_validates_without_writing(tmp_path):
    target = tmp_path / "dry.txt"
    target.write_text("before\n")
    tool = ApplyPatchTool(workspace=tmp_path)

    result = asyncio.run(
        tool.execute(
            edits=[
                {
                    "path": "dry.txt",
                    "action": "replace",
                    "old_text": "before",
                    "new_text": "after",
                },
                {
                    "path": "added.txt",
                    "action": "add",
                    "new_text": "new",
                },
            ],
            dry_run=True,
        )
    )

    assert "Patch dry-run succeeded" in result
    assert target.read_text() == "before\n"
    assert not (tmp_path / "added.txt").exists()


def test_apply_patch_edits_allows_absolute_and_parent_paths_when_unrestricted(tmp_path):
    workspace = tmp_path / "workspace"
    outside = tmp_path / "outside"
    workspace.mkdir()
    outside.mkdir()
    absolute_target = outside / "absolute.txt"
    parent_target = outside / "parent.txt"
    tool = ApplyPatchTool(workspace=workspace, restrict_to_workspace=False)

    absolute = asyncio.run(
        tool.execute(
            edits=[
                {
                    "path": str(absolute_target),
                    "action": "add",
                    "new_text": "absolute",
                }
            ]
        )
    )
    parent = asyncio.run(
        tool.execute(
            edits=[
                {
                    "path": "../outside/parent.txt",
                    "action": "add",
                    "new_text": "parent",
                }
            ]
        )
    )

    assert "Patch applied" in absolute
    assert "Patch applied" in parent
    assert absolute_target.read_text() == "absolute\n"
    assert parent_target.read_text() == "parent\n"


def test_apply_patch_edits_rejects_outside_paths_when_restricted(tmp_path):
    workspace = tmp_path / "workspace"
    outside = tmp_path / "outside"
    workspace.mkdir()
    outside.mkdir()
    tool = ApplyPatchTool(workspace=workspace, allowed_dir=workspace)

    absolute = asyncio.run(
        tool.execute(
            edits=[
                {
                    "path": str(outside / "absolute.txt"),
                    "action": "add",
                    "new_text": "nope",
                }
            ]
        )
    )
    parent = asyncio.run(
        tool.execute(
            edits=[
                {
                    "path": "../outside/parent.txt",
                    "action": "add",
                    "new_text": "nope",
                }
            ]
        )
    )

    assert "outside allowed directory" in absolute
    assert "outside allowed directory" in parent
    assert not (outside / "absolute.txt").exists()
    assert not (outside / "parent.txt").exists()


def test_apply_patch_legacy_extra_allowed_dirs_are_read_only(tmp_path):
    workspace = tmp_path / "workspace"
    skills = tmp_path / "skills"
    workspace.mkdir()
    skills.mkdir()
    target = skills / "demo.md"
    target.write_text("before\n", encoding="utf-8")
    tool = ApplyPatchTool(
        workspace=workspace,
        allowed_dir=workspace,
        extra_allowed_dirs=[skills],
    )

    result = asyncio.run(
        tool.execute(
            edits=[
                {
                    "path": str(target),
                    "action": "replace",
                    "old_text": "before",
                    "new_text": "after",
                }
            ]
        )
    )

    assert "outside allowed directory" in result
    assert target.read_text(encoding="utf-8") == "before\n"


def test_apply_patch_media_dir_is_read_only_by_default(tmp_path, monkeypatch):
    workspace = tmp_path / "workspace"
    media = tmp_path / "media"
    workspace.mkdir()
    media.mkdir()
    target = media / "demo.md"
    target.write_text("before\n", encoding="utf-8")
    monkeypatch.setattr("nanobot.agent.tools.path_utils.get_media_dir", lambda: media)
    tool = ApplyPatchTool(workspace=workspace, allowed_dir=workspace)

    result = asyncio.run(
        tool.execute(
            edits=[
                {
                    "path": str(target),
                    "action": "replace",
                    "old_text": "before",
                    "new_text": "after",
                }
            ]
        )
    )

    assert "outside allowed directory" in result
    assert target.read_text(encoding="utf-8") == "before\n"


def test_apply_patch_allows_explicit_extra_write_allowed_dirs_when_restricted(tmp_path):
    workspace = tmp_path / "workspace"
    writable = tmp_path / "writable"
    workspace.mkdir()
    writable.mkdir()
    target = writable / "allowed.txt"
    tool = ApplyPatchTool(
        workspace=workspace,
        allowed_dir=workspace,
        extra_write_allowed_dirs=[writable],
    )

    result = asyncio.run(
        tool.execute(
            edits=[
                {
                    "path": str(target),
                    "action": "add",
                    "new_text": "allowed",
                }
            ]
        )
    )

    assert "Patch applied" in result
    assert target.read_text(encoding="utf-8") == "allowed\n"


def test_apply_patch_edits_reports_invalid_edit_shapes(tmp_path):
    tool = ApplyPatchTool(workspace=tmp_path)

    missing_path = asyncio.run(tool.execute(edits=[{"action": "add", "new_text": "x"}]))
    missing_action = asyncio.run(tool.execute(edits=[{"path": "x.txt", "new_text": "x"}]))
    non_object = asyncio.run(tool.execute(edits=["not an object"]))  # type: ignore[list-item]

    assert "path required for edit" in missing_path
    assert "action required for edit: x.txt" in missing_action
    assert "each edit must be an object" in non_object


def test_apply_patch_edits_rolls_back_when_late_operation_fails(tmp_path):
    first = tmp_path / "first.txt"
    first.write_text("before\n")
    tool = ApplyPatchTool(workspace=tmp_path)

    result = asyncio.run(
        tool.execute(
            edits=[
                {
                    "path": "first.txt",
                    "action": "replace",
                    "old_text": "before",
                    "new_text": "after",
                },
                {
                    "path": "missing.txt",
                    "action": "replace",
                    "old_text": "remove me",
                    "new_text": "removed",
                },
            ]
        )
    )

    assert "file to update does not exist: missing.txt" in result
    assert first.read_text() == "before\n"
