from __future__ import annotations

from pathlib import Path

from nanobot.agent.tools.apply_patch import ApplyPatchTool
from nanobot.agent.tools.filesystem import EditFileTool, WriteFileTool
from nanobot.utils.file_edit_events import (
    build_file_edit_end_event,
    build_file_edit_start_event,
    build_unified_diff_payload,
    line_diff_stats,
    prepare_file_edit_trackers,
    read_file_snapshot,
)


def _write_tool(workspace: Path) -> WriteFileTool:
    return WriteFileTool(workspace=workspace)


def _edit_tool(workspace: Path) -> EditFileTool:
    return EditFileTool(workspace=workspace)


def _patch_tool(workspace: Path) -> ApplyPatchTool:
    return ApplyPatchTool(workspace=workspace)


def test_line_diff_stats_counts_replacements_insertions_and_deletions() -> None:
    added, deleted = line_diff_stats("a\nb\nc\n", "a\nB\nc\nd\n")
    assert (added, deleted) == (2, 1)


def test_line_diff_stats_normalizes_crlf() -> None:
    assert line_diff_stats("a\r\nb\r\n", "a\nb\nc\n") == (1, 0)


def test_line_diff_stats_counts_new_file_crlf_lines_once() -> None:
    assert line_diff_stats("", "a\r\nb\r\n") == (2, 0)


def test_write_file_start_tracks_snapshot_and_end_emits_exact_diff(tmp_path: Path) -> None:
    target = tmp_path / "notes.txt"
    target.write_text("old\nkeep\n", encoding="utf-8")
    params = {"path": "notes.txt", "content": "new\nkeep\nextra\n"}
    trackers = prepare_file_edit_trackers(
        call_id="call-write",
        tool_name="write_file",
        tool=_write_tool(tmp_path),
        workspace=tmp_path,
        params=params,
    )
    [tracker] = trackers
    start = build_file_edit_start_event(tracker)
    assert start == {
        "version": 1,
        "call_id": "call-write",
        "tool": "write_file",
        "path": "notes.txt",
        "absolute_path": (tmp_path / "notes.txt").resolve().as_posix(),
        "phase": "start",
        "added": 0,
        "deleted": 0,
        "approximate": True,
        "status": "editing",
    }

    target.write_text("new\nkeep\nextra\n", encoding="utf-8")
    end = build_file_edit_end_event(tracker)
    assert end["phase"] == "end"
    assert end["status"] == "done"
    assert end["approximate"] is False
    assert (end["added"], end["deleted"]) == (2, 1)
    assert end["diff"]["format"] == "unified"
    assert "hunks" not in end["diff"]
    diff_text = end["diff"]["text"]
    assert "--- notes.txt" in diff_text
    assert "+++ notes.txt" in diff_text
    assert "@@ " in diff_text
    assert "-old" in diff_text
    assert "+new" in diff_text
    assert "+extra" in diff_text


def test_unified_diff_payload_truncates_large_diffs() -> None:
    before = "\n".join(f"old {i}" for i in range(12))
    after = "\n".join(f"new {i}" for i in range(12))

    diff = build_unified_diff_payload(before, after, context_lines=0, max_lines=5)

    assert diff is not None
    assert diff["truncated"] is True
    assert "hunks" not in diff
    body_lines = [
        line for line in diff["text"].splitlines()
        if line.startswith((" ", "+", "-")) and not line.startswith(("+++", "---"))
    ]
    assert len(body_lines) == 5


def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
    target = tmp_path / "data.bin"
    target.write_bytes(b"\x00\x01before")
    trackers = prepare_file_edit_trackers(
        call_id="call-bin",
        tool_name="edit_file",
        tool=_edit_tool(tmp_path),
        workspace=tmp_path,
        params={"path": "data.bin", "old_text": "before", "new_text": "after"},
    )
    [tracker] = trackers
    assert not read_file_snapshot(target).countable
    target.write_bytes(b"\x00\x01after")
    event = build_file_edit_end_event(tracker)
    assert event["binary"] is True
    assert (event["added"], event["deleted"]) == (0, 0)
    assert "diff" not in event


def test_binary_before_file_is_reported_but_not_counted(tmp_path: Path) -> None:
    target = tmp_path / "data.bin"
    target.write_bytes(b"\x00\x01before")
    trackers = prepare_file_edit_trackers(
        call_id="call-bin",
        tool_name="write_file",
        tool=_write_tool(tmp_path),
        workspace=tmp_path,
        params={"path": "data.bin", "content": "after\n"},
    )
    [tracker] = trackers
    target.write_text("after\n", encoding="utf-8")
    event = build_file_edit_end_event(tracker)
    assert event["binary"] is True
    assert (event["added"], event["deleted"]) == (0, 0)
    assert "diff" not in event


def test_apply_patch_prepares_trackers_for_each_touched_file(tmp_path: Path) -> None:
    (tmp_path / "src").mkdir()
    existing = tmp_path / "src" / "existing.py"
    existing.write_text("old\nkeep\n", encoding="utf-8")

    edits = [
        {"path": "src/new.py", "action": "add", "new_text": "fresh"},
        {"path": "src/existing.py", "action": "replace", "old_text": "old", "new_text": "new"},
    ]

    trackers = prepare_file_edit_trackers(
        call_id="call-patch",
        tool_name="apply_patch",
        tool=_patch_tool(tmp_path),
        workspace=tmp_path,
        params={"edits": edits},
    )

    assert [tracker.display_path for tracker in trackers] == [
        "src/new.py",
        "src/existing.py",
    ]

    (tmp_path / "src" / "new.py").write_text("fresh\n", encoding="utf-8")
    existing.write_text("new\nkeep\n", encoding="utf-8")

    events = [build_file_edit_end_event(tracker) for tracker in trackers]
    by_path = {event["path"]: event for event in events}
    assert (by_path["src/new.py"]["added"], by_path["src/new.py"]["deleted"]) == (1, 0)
    assert (by_path["src/existing.py"]["added"], by_path["src/existing.py"]["deleted"]) == (1, 1)
    assert by_path["src/new.py"]["diff"]["format"] == "unified"
    assert by_path["src/existing.py"]["diff"]["format"] == "unified"


def test_apply_patch_trackers_use_normalized_patch_paths(tmp_path: Path) -> None:
    (tmp_path / "file.txt").write_text("old\n", encoding="utf-8")

    trackers = prepare_file_edit_trackers(
        call_id="call-patch",
        tool_name="apply_patch",
        tool=_patch_tool(tmp_path),
        workspace=tmp_path,
        params={
            "edits": [
                {"path": " file.txt ", "action": "replace", "old_text": "old", "new_text": "new"},
                {"path": "bad\0.txt", "action": "add", "new_text": "ignored"},
            ],
        },
    )

    assert [tracker.display_path for tracker in trackers] == ["file.txt"]
    assert trackers[0].path == (tmp_path / "file.txt").resolve()


def test_apply_patch_dry_run_does_not_prepare_file_edit_trackers(tmp_path: Path) -> None:
    (tmp_path / "file.txt").write_text("old\n", encoding="utf-8")

    trackers = prepare_file_edit_trackers(
        call_id="call-patch",
        tool_name="apply_patch",
        tool=_patch_tool(tmp_path),
        workspace=tmp_path,
        params={
            "dry_run": True,
            "edits": [
                {"path": "file.txt", "action": "replace", "old_text": "old", "new_text": "new"}
            ],
        },
    )

    assert trackers == []


def test_oversized_file_is_reported_but_not_counted(tmp_path: Path) -> None:
    target = tmp_path / "large.txt"
    params = {"path": "large.txt", "content": "x"}
    trackers = prepare_file_edit_trackers(
        call_id="call-large",
        tool_name="write_file",
        tool=_write_tool(tmp_path),
        workspace=tmp_path,
        params=params,
    )
    [tracker] = trackers
    target.write_text("x" * (2 * 1024 * 1024 + 1), encoding="utf-8")
    event = build_file_edit_end_event(tracker)
    assert event["binary"] is True
    assert event["added"] == 0
    assert event["deleted"] == 0
    assert "diff" not in event


def test_untracked_tools_do_not_prepare_file_edit_trackers(tmp_path: Path) -> None:
    assert prepare_file_edit_trackers(
        call_id="call-exec",
        tool_name="exec",
        tool=None,
        workspace=tmp_path,
        params={"path": "created-by-shell.txt"},
    ) == []
