from __future__ import annotations

import pytest

from codeflash_api.diff._base import DiffError, DiffMethod
from codeflash_api.diff._search_replace import (
    SearchAndReplaceDiff,
    SearchReplaceBlock,
    apply_patches,
    extract_patches,
    find_with_whitespace_flexibility,
    parse_diff,
)
from codeflash_api.diff._v4a import (
    V4ADiff,
    _norm,
    apply_update,
    extract_update_sections,
    find_context,
    find_context_core,
)


class TestDiffMethod:
    """Tests for DiffMethod enum."""

    def test_values(self) -> None:
        """
        All three diff methods exist.
        """
        assert "no_diff" == DiffMethod.NO_DIFF.value
        assert "v4a" == DiffMethod.V4A.value
        assert "search_and_replace" == DiffMethod.SEARCH_AND_REPLACE.value


class TestSearchReplaceParseDiff:
    """Tests for parse_diff."""

    def test_single_block(self) -> None:
        """
        Single SEARCH/REPLACE block is parsed correctly.
        """
        diff = "<<<<<<< SEARCH\nold code\n=======\nnew code\n>>>>>>> REPLACE\n"
        blocks = parse_diff(diff)

        assert 1 == len(blocks)
        assert "old code" == blocks[0].search
        assert "new code" == blocks[0].replace

    def test_multiple_blocks(self) -> None:
        """
        Multiple SEARCH/REPLACE blocks are all parsed.
        """
        diff = (
            "<<<<<<< SEARCH\nfoo\n=======\nbar\n>>>>>>> REPLACE\n"
            "<<<<<<< SEARCH\nbaz\n=======\nqux\n>>>>>>> REPLACE\n"
        )
        blocks = parse_diff(diff)

        assert 2 == len(blocks)

    def test_empty_search(self) -> None:
        """
        Empty search content (append mode) is valid.
        """
        diff = "<<<<<<< SEARCH\n=======\nnew stuff\n>>>>>>> REPLACE\n"
        blocks = parse_diff(diff)

        assert "" == blocks[0].search
        assert "new stuff" == blocks[0].replace

    def test_empty_input_raises(self) -> None:
        """
        Empty string raises ValueError.
        """
        with pytest.raises(ValueError, match="Empty"):
            parse_diff("")

    def test_missing_delimiter_raises(self) -> None:
        """
        Missing ======= marker raises ValueError.
        """
        with pytest.raises(ValueError, match="======="):
            parse_diff("<<<<<<< SEARCH\nfoo\n>>>>>>> REPLACE\n")

    def test_missing_replace_marker_raises(self) -> None:
        """
        Missing >>>>>>> REPLACE marker raises ValueError.
        """
        with pytest.raises(ValueError, match="REPLACE"):
            parse_diff("<<<<<<< SEARCH\nfoo\n=======\nbar\n")


class TestFindWithWhitespaceFlexibility:
    """Tests for find_with_whitespace_flexibility."""

    def test_exact_match(self) -> None:
        """
        Exact content is found.
        """
        result = find_with_whitespace_flexibility(
            "hello world", "prefix hello world suffix"
        )

        assert result is not None
        assert 7 == result[0]

    def test_flexible_whitespace(self) -> None:
        """
        Tabs match spaces.
        """
        result = find_with_whitespace_flexibility(
            "hello world", "hello\tworld"
        )

        assert result is not None

    def test_no_match(self) -> None:
        """
        Non-existent content returns None.
        """
        assert find_with_whitespace_flexibility("xyz", "abc") is None


class TestApplyPatches:
    """Tests for apply_patches."""

    def test_simple_replace(self) -> None:
        """
        Exact match is replaced.
        """
        diff = "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE\n"
        result = apply_patches(diff, "before old after")

        assert "before new after" == result

    def test_append_mode(self) -> None:
        """
        Empty search appends to content.
        """
        diff = "<<<<<<< SEARCH\n=======\n# appended\n>>>>>>> REPLACE\n"
        result = apply_patches(diff, "existing")

        assert "existing# appended" == result

    def test_parse_error_returns_original(self) -> None:
        """
        Invalid diff returns content unchanged.
        """
        result = apply_patches("not a diff", "original")

        assert "original" == result


class TestSearchAndReplaceDiff:
    """Tests for SearchAndReplaceDiff.run."""

    def test_single_file_patch(self) -> None:
        """
        Patch applied to matching file.
        """
        content = (
            "<replace_in_file>"
            "<path>foo.py</path>"
            "<diff>\n<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE\n</diff>"
            "</replace_in_file>"
        )
        diff = SearchAndReplaceDiff(
            content=content,
            source_code={"foo.py": "old code"},
        )
        result = diff.run()

        assert "new code" == result["foo.py"]


class TestNorm:
    """Tests for _norm."""

    def test_strips_cr(self) -> None:
        """
        Carriage returns are removed.
        """
        assert "hello" == _norm("hello\r")

    def test_no_cr(self) -> None:
        """
        Lines without CR are unchanged.
        """
        assert "hello" == _norm("hello")


class TestFindContextCore:
    """Tests for find_context_core."""

    def test_exact_match(self) -> None:
        """
        Exact match returns fuzz=0.
        """
        lines = ["a", "b", "c", "d"]
        idx, fuzz = find_context_core(lines, ["b", "c"], 0)

        assert 1 == idx
        assert 0 == fuzz

    def test_rstrip_match(self) -> None:
        """
        Trailing whitespace match returns fuzz=1.
        """
        lines = ["a  ", "b  "]
        idx, fuzz = find_context_core(lines, ["a", "b"], 0)

        assert 0 == idx
        assert 1 == fuzz

    def test_strip_match(self) -> None:
        """
        Leading+trailing whitespace match returns fuzz=100.
        """
        lines = ["  a  ", "  b  "]
        idx, fuzz = find_context_core(lines, ["a", "b"], 0)

        assert 0 == idx
        assert 100 == fuzz

    def test_not_found(self) -> None:
        """
        Missing context returns -1.
        """
        idx, fuzz = find_context_core(["a", "b"], ["x", "y"], 0)

        assert -1 == idx


class TestFindContext:
    """Tests for find_context with EOF handling."""

    def test_eof_penalty(self) -> None:
        """
        EOF match not at end gets +10000 fuzz penalty.
        """
        lines = ["a", "b", "c", "d"]
        idx, fuzz = find_context(lines, ["a", "b"], 0, eof=True)

        assert 0 == idx
        assert fuzz >= 10_000


class TestExtractUpdateSections:
    """Tests for extract_update_sections."""

    def test_single_file(self) -> None:
        """
        Single UPDATE section is extracted.
        """
        content = (
            "*** Begin Patch\n"
            "*** Update File: foo.py\n"
            " context\n"
            "-old\n"
            "+new\n"
            "*** End Patch\n"
        )
        sections = extract_update_sections(content)

        assert "foo.py" in sections

    def test_no_patch_markers(self) -> None:
        """
        Missing markers return empty dict.
        """
        assert {} == extract_update_sections("no patch here")


class TestApplyUpdate:
    """Tests for apply_update."""

    def test_simple_replacement(self) -> None:
        """
        Single chunk replaces lines correctly.
        """
        from codeflash_api.diff._v4a import Chunk, PatchAction

        action = PatchAction(
            path="test.py",
            chunks=[
                Chunk(
                    orig_index=1,
                    del_lines=["old_line"],
                    ins_lines=["new_line"],
                )
            ],
        )
        result = apply_update("first\nold_line\nlast", action, "test.py")

        assert "first\nnew_line\nlast\n" == result

    def test_mismatch_raises(self) -> None:
        """
        Deleted line mismatch raises DiffError.
        """
        from codeflash_api.diff._v4a import Chunk, PatchAction

        action = PatchAction(
            path="test.py",
            chunks=[
                Chunk(
                    orig_index=0,
                    del_lines=["expected"],
                    ins_lines=["new"],
                )
            ],
        )
        with pytest.raises(DiffError, match="mismatch"):
            apply_update("actual", action, "test.py")


class TestV4ADiff:
    """Tests for V4ADiff.run."""

    def test_no_changes(self) -> None:
        """
        No matching sections returns original code.
        """
        diff = V4ADiff(
            content="*** Begin Patch\n*** End Patch\n",
            source_code={"foo.py": "original"},
        )
        result = diff.run()

        assert "original" == result["foo.py"]


class TestFindContextCoreNoMatch:
    """Tests for find_context_core when no match exists."""

    def test_empty_lines(self) -> None:
        """
        Empty lines list returns -1.
        """
        idx, fuzz = find_context_core([], ["a"], 0)

        assert -1 == idx
        assert 0 == fuzz

    def test_context_longer_than_lines(self) -> None:
        """
        Context longer than the lines list returns -1.
        """
        idx, fuzz = find_context_core(["a"], ["a", "b"], 0)

        assert -1 == idx

    def test_start_past_match_range(self) -> None:
        """
        Start index beyond possible match range returns -1.
        """
        lines = ["a", "b", "c"]
        idx, fuzz = find_context_core(lines, ["a", "b"], 2)

        assert -1 == idx

    def test_exact_match_at_offset(self) -> None:
        """
        Exact match found when start skips earlier occurrences.
        """
        lines = ["a", "b", "a", "b"]
        idx, fuzz = find_context_core(lines, ["a", "b"], 2)

        assert 2 == idx
        assert 0 == fuzz


class TestFindContextEOFAndNonEOF:
    """Tests for find_context EOF and non-EOF paths."""

    def test_eof_match_at_end_no_penalty(self) -> None:
        """
        EOF match at the actual end of file returns fuzz without penalty.
        """
        lines = ["x", "y", "a", "b"]
        idx, fuzz = find_context(lines, ["a", "b"], 0, eof=True)

        assert 2 == idx
        assert 0 == fuzz

    def test_eof_no_match_returns_negative(self) -> None:
        """
        EOF search with no match anywhere returns -1.
        """
        lines = ["x", "y", "z"]
        idx, fuzz = find_context(lines, ["a", "b"], 0, eof=True)

        assert -1 == idx
        assert 0 == fuzz

    def test_non_eof_delegates_to_core(self) -> None:
        """
        Non-EOF call delegates directly to find_context_core.
        """
        lines = ["a", "b", "c"]
        idx, fuzz = find_context(lines, ["a", "b"], 0, eof=False)

        assert 0 == idx
        assert 0 == fuzz

    def test_non_eof_no_match(self) -> None:
        """
        Non-EOF with no match returns -1.
        """
        lines = ["x", "y"]
        idx, fuzz = find_context(lines, ["a"], 0, eof=False)

        assert -1 == idx

    def test_eof_multiple_candidates_prefers_end(self) -> None:
        """
        EOF search finds the match closest to the end of file.
        """
        lines = ["a", "b", "c", "a", "b"]
        idx, fuzz = find_context(lines, ["a", "b"], 0, eof=True)

        assert 3 == idx
        assert 0 == fuzz


class TestPeekNextSection:
    """Tests for peek_next_section state machine."""

    def test_keep_lines(self) -> None:
        """
        Lines starting with space are context (keep) lines.
        """
        from codeflash_api.diff._v4a import peek_next_section

        lines = [" hello", " world"]
        ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)

        assert ["hello", "world"] == ctx
        assert [] == chunks
        assert 2 == next_i
        assert is_eof is False

    def test_delete_then_add(self) -> None:
        """
        A delete line followed by an add line produces one chunk.
        """
        from codeflash_api.diff._v4a import peek_next_section

        lines = [" ctx", "-old", "+new", " after"]
        ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)

        assert 4 == next_i
        assert 1 == len(chunks)
        assert ["old"] == chunks[0].del_lines
        assert ["new"] == chunks[0].ins_lines

    def test_add_only(self) -> None:
        """
        Add lines without preceding deletes produce an insertion chunk.
        """
        from codeflash_api.diff._v4a import peek_next_section

        lines = [" before", "+inserted", " after"]
        ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)

        assert 1 == len(chunks)
        assert [] == chunks[0].del_lines
        assert ["inserted"] == chunks[0].ins_lines

    def test_delete_only(self) -> None:
        """
        Delete lines without adds produce a deletion chunk.
        """
        from codeflash_api.diff._v4a import peek_next_section

        lines = [" before", "-removed", " after"]
        ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)

        assert 1 == len(chunks)
        assert ["removed"] == chunks[0].del_lines
        assert [] == chunks[0].ins_lines

    def test_stops_at_scope_marker(self) -> None:
        """
        Parsing stops when an @@ scope marker is encountered.
        """
        from codeflash_api.diff._v4a import peek_next_section

        lines = [" context", "@@ next scope @@"]
        ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)

        assert 1 == next_i
        assert ["context"] == ctx

    def test_stops_at_update_marker(self) -> None:
        """
        Parsing stops when a *** marker (non-End of File) is encountered.
        """
        from codeflash_api.diff._v4a import peek_next_section

        lines = [" context", "*** Update File: bar.py"]
        ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)

        assert 1 == next_i
        assert is_eof is False

    def test_end_of_file_marker(self) -> None:
        """
        '*** End of File' sets the is_eof flag and advances past it.
        """
        from codeflash_api.diff._v4a import peek_next_section

        lines = [" tail", "*** End of File"]
        ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)

        assert is_eof is True
        assert 2 == next_i

    def test_invalid_line_raises(self) -> None:
        """
        A line that doesn't start with space, +, -, @@, or *** raises DiffError.
        """
        from codeflash_api.diff._v4a import peek_next_section

        lines = ["bad line with no prefix"]
        with pytest.raises(DiffError, match="Invalid patch line"):
            peek_next_section(lines, 0)

    def test_empty_line_treated_as_context(self) -> None:
        """
        An empty line (no prefix) is treated as an empty context line.
        """
        from codeflash_api.diff._v4a import peek_next_section

        lines = ["", " after"]
        ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)

        assert ["", "after"] == ctx
        assert 2 == next_i

    def test_add_followed_by_delete_flushes_chunk(self) -> None:
        """
        A delete line after add lines flushes the current chunk and starts a new one.
        """
        from codeflash_api.diff._v4a import peek_next_section

        lines = [" ctx", "+added", "-removed", " end"]
        ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)

        assert 2 == len(chunks)
        assert ["added"] == chunks[0].ins_lines
        assert [] == chunks[0].del_lines
        assert ["removed"] == chunks[1].del_lines

    def test_trailing_add_flushed_at_end(self) -> None:
        """
        Pending add/delete lines at end of input are flushed into a chunk.
        """
        from codeflash_api.diff._v4a import peek_next_section

        lines = [" ctx", "+trailing"]
        ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)

        assert 1 == len(chunks)
        assert ["trailing"] == chunks[0].ins_lines

    def test_multiple_deletes_then_adds(self) -> None:
        """
        Multiple delete lines followed by multiple add lines form one chunk.
        """
        from codeflash_api.diff._v4a import peek_next_section

        lines = [" ctx", "-del1", "-del2", "+add1", "+add2", " end"]
        ctx, chunks, next_i, is_eof = peek_next_section(lines, 0)

        assert 1 == len(chunks)
        assert ["del1", "del2"] == chunks[0].del_lines
        assert ["add1", "add2"] == chunks[0].ins_lines


class TestParseUpdateFileSections:
    """Tests for _parse_update_file_sections."""

    def test_simple_section(self) -> None:
        """
        A single context+change section is parsed into a PatchAction.
        """
        from codeflash_api.diff._v4a import _parse_update_file_sections

        patch_lines = [
            " first",
            "-old_line",
            "+new_line",
            " last",
        ]
        file_content = "first\nold_line\nlast"

        action, next_i, fuzz = _parse_update_file_sections(
            patch_lines, 0, file_content
        )

        assert 1 == len(action.chunks)
        assert 0 == fuzz

    def test_scope_marker_navigation(self) -> None:
        """
        @@ scope @@ markers navigate to the matching line in the file.
        """
        from codeflash_api.diff._v4a import _parse_update_file_sections

        patch_lines = [
            "@@ def foo(): @@",
            " def foo():",
            "-    old",
            "+    new",
        ]
        file_content = "import os\n\ndef foo():\n    old\n    rest"

        action, next_i, fuzz = _parse_update_file_sections(
            patch_lines, 0, file_content
        )

        assert 1 == len(action.chunks)

    def test_scope_not_found_raises(self) -> None:
        """
        A scope that doesn't exist in the file raises DiffError.
        """
        from codeflash_api.diff._v4a import _parse_update_file_sections

        patch_lines = [
            "@@ def nonexistent(): @@",
            " some context",
        ]
        file_content = "def foo():\n    pass"

        with pytest.raises(DiffError, match="Could not find scope"):
            _parse_update_file_sections(patch_lines, 0, file_content)

    def test_context_not_found_raises(self) -> None:
        """
        Context lines that don't match the file raise DiffError.
        """
        from codeflash_api.diff._v4a import _parse_update_file_sections

        patch_lines = [
            " nonexistent context",
            "-old",
            "+new",
        ]
        file_content = "completely different\ncontent here"

        with pytest.raises(DiffError, match="Could not find context"):
            _parse_update_file_sections(patch_lines, 0, file_content)

    def test_skips_blank_lines_between_sections(self) -> None:
        """
        Blank lines between sections are skipped.
        """
        from codeflash_api.diff._v4a import _parse_update_file_sections

        patch_lines = [
            "",
            " first",
            "-old",
            "+new",
        ]
        file_content = "first\nold\nlast"

        action, next_i, fuzz = _parse_update_file_sections(
            patch_lines, 0, file_content
        )

        assert 1 == len(action.chunks)

    def test_stops_at_next_file_marker(self) -> None:
        """
        Parsing stops at *** Update File marker for the next file.
        """
        from codeflash_api.diff._v4a import _parse_update_file_sections

        patch_lines = [
            " first",
            "-old",
            "+new",
            "*** Update File: other.py",
        ]
        file_content = "first\nold\nlast"

        action, next_i, fuzz = _parse_update_file_sections(
            patch_lines, 0, file_content
        )

        assert 3 == next_i

    def test_chunks_without_context(self) -> None:
        """
        Changes at the start of a section with no preceding context are offset
        by current_file_idx.
        """
        from codeflash_api.diff._v4a import _parse_update_file_sections

        patch_lines = [
            "+inserted_line",
        ]
        file_content = "existing"

        action, next_i, fuzz = _parse_update_file_sections(
            patch_lines, 0, file_content
        )

        assert 1 == len(action.chunks)
        assert 0 == action.chunks[0].orig_index


class TestParsePatchText:
    """Tests for parse_patch_text."""

    def test_single_file_update(self) -> None:
        """
        A patch updating one file is parsed correctly.
        """
        from codeflash_api.diff._v4a import parse_patch_text

        lines = [
            "*** Update File: foo.py",
            " hello",
            "-old",
            "+new",
            " world",
        ]
        files = {"foo.py": "hello\nold\nworld"}

        patch = parse_patch_text(lines, files)

        assert "foo.py" in patch.actions
        assert 1 == len(patch.actions["foo.py"].chunks)

    def test_multiple_files(self) -> None:
        """
        A patch updating multiple files parses all of them.
        """
        from codeflash_api.diff._v4a import parse_patch_text

        lines = [
            "*** Update File: a.py",
            " line_a",
            "-old_a",
            "+new_a",
            "*** Update File: b.py",
            " line_b",
            "-old_b",
            "+new_b",
        ]
        files = {"a.py": "line_a\nold_a", "b.py": "line_b\nold_b"}

        patch = parse_patch_text(lines, files)

        assert "a.py" in patch.actions
        assert "b.py" in patch.actions

    def test_file_not_found_raises(self) -> None:
        """
        Referencing a file not in current_files raises DiffError.
        """
        from codeflash_api.diff._v4a import parse_patch_text

        lines = ["*** Update File: missing.py", " ctx"]
        files = {"other.py": "content"}

        with pytest.raises(DiffError, match="File not found"):
            parse_patch_text(lines, files)

    def test_unknown_line_raises(self) -> None:
        """
        A line that isn't an Update File marker raises DiffError.
        """
        from codeflash_api.diff._v4a import parse_patch_text

        lines = ["garbage line"]
        files = {"foo.py": "content"}

        with pytest.raises(DiffError, match="Unknown or misplaced"):
            parse_patch_text(lines, files)

    def test_blank_lines_skipped(self) -> None:
        """
        Leading blank lines before a file section are skipped.
        """
        from codeflash_api.diff._v4a import parse_patch_text

        lines = [
            "",
            "*** Update File: foo.py",
            " hello",
            "-old",
            "+new",
        ]
        files = {"foo.py": "hello\nold\nrest"}

        patch = parse_patch_text(lines, files)

        assert "foo.py" in patch.actions

    def test_duplicate_file_merges_chunks(self) -> None:
        """
        Multiple UPDATE sections for the same file merge their chunks.
        """
        from codeflash_api.diff._v4a import parse_patch_text

        lines = [
            "*** Update File: foo.py",
            " aaa",
            "-bbb",
            "+ccc",
            "*** Update File: foo.py",
            " ddd",
            "-eee",
            "+fff",
        ]
        files = {"foo.py": "aaa\nbbb\nddd\neee"}

        patch = parse_patch_text(lines, files)

        assert 2 == len(patch.actions["foo.py"].chunks)

    def test_empty_lines_only(self) -> None:
        """
        Input with only blank lines produces a patch with no actions.
        """
        from codeflash_api.diff._v4a import parse_patch_text

        lines = ["", "", ""]
        files = {"foo.py": "content"}

        patch = parse_patch_text(lines, files)

        assert {} == patch.actions


class TestExtractUpdateSectionsEdgeCases:
    """Tests for extract_update_sections edge cases."""

    def test_multiple_files(self) -> None:
        """
        Multiple UPDATE sections are all extracted.
        """
        content = (
            "*** Begin Patch\n"
            "*** Update File: a.py\n"
            " ctx_a\n"
            "-old_a\n"
            "+new_a\n"
            "*** Update File: b.py\n"
            " ctx_b\n"
            "-old_b\n"
            "+new_b\n"
            "*** End Patch\n"
        )
        sections = extract_update_sections(content)

        assert "a.py" in sections
        assert "b.py" in sections

    def test_empty_section_skipped(self) -> None:
        """
        Empty section parts between split markers are skipped.
        """
        content = (
            "*** Begin Patch\n"
            "*** Update File: \n"
            "*** Update File: real.py\n"
            " ctx\n"
            "*** End Patch\n"
        )
        sections = extract_update_sections(content)

        assert "real.py" in sections

    def test_file_path_without_newline_skipped(self) -> None:
        """
        A section with just a filename and no newline is skipped.
        """
        content = (
            "*** Begin Patch\n*** Update File: only_name\n*** End Patch\n"
        )
        sections = extract_update_sections(content)

        assert {} == sections

    def test_section_content_preserved(self) -> None:
        """
        The diff content within a section is reconstructed with the
        UPDATE prefix.
        """
        content = (
            "*** Begin Patch\n"
            "*** Update File: test.py\n"
            " context\n"
            "-old\n"
            "+new\n"
            "*** End Patch\n"
        )
        sections = extract_update_sections(content)

        assert sections["test.py"].startswith("*** Update File: test.py")
        assert "-old" in sections["test.py"]
        assert "+new" in sections["test.py"]


class TestApplyUpdateEdgeCases:
    """Tests for apply_update edge cases."""

    def test_overlapping_chunks_raises(self) -> None:
        """
        Chunks with overlapping line ranges raise DiffError.
        """
        from codeflash_api.diff._v4a import Chunk, PatchAction

        action = PatchAction(
            path="test.py",
            chunks=[
                Chunk(
                    orig_index=0,
                    del_lines=["line1", "line2"],
                    ins_lines=["new1"],
                ),
                Chunk(
                    orig_index=1,
                    del_lines=["line2"],
                    ins_lines=["new2"],
                ),
            ],
        )

        with pytest.raises(DiffError, match="Overlapping"):
            apply_update("line1\nline2\nline3", action, "test.py")

    def test_insertion_only_chunk(self) -> None:
        """
        A chunk with no deletions only inserts lines.
        """
        from codeflash_api.diff._v4a import Chunk, PatchAction

        action = PatchAction(
            path="test.py",
            chunks=[
                Chunk(
                    orig_index=1,
                    del_lines=[],
                    ins_lines=["inserted"],
                )
            ],
        )
        result = apply_update("first\nsecond\nthird", action, "test.py")

        assert "first\ninserted\nsecond\nthird\n" == result

    def test_deletion_only_chunk(self) -> None:
        """
        A chunk with no insertions removes lines.
        """
        from codeflash_api.diff._v4a import Chunk, PatchAction

        action = PatchAction(
            path="test.py",
            chunks=[
                Chunk(
                    orig_index=1,
                    del_lines=["second"],
                    ins_lines=[],
                )
            ],
        )
        result = apply_update("first\nsecond\nthird", action, "test.py")

        assert "first\nthird\n" == result

    def test_multiple_non_overlapping_chunks(self) -> None:
        """
        Multiple sorted, non-overlapping chunks are all applied.
        """
        from codeflash_api.diff._v4a import Chunk, PatchAction

        action = PatchAction(
            path="test.py",
            chunks=[
                Chunk(
                    orig_index=1,
                    del_lines=["b"],
                    ins_lines=["B"],
                ),
                Chunk(
                    orig_index=3,
                    del_lines=["d"],
                    ins_lines=["D"],
                ),
            ],
        )
        result = apply_update("a\nb\nc\nd\ne", action, "test.py")

        assert "a\nB\nc\nD\ne\n" == result

    def test_empty_file_with_insertion(self) -> None:
        """
        Inserting into an empty file works.
        """
        from codeflash_api.diff._v4a import Chunk, PatchAction

        action = PatchAction(
            path="test.py",
            chunks=[
                Chunk(
                    orig_index=0,
                    del_lines=[],
                    ins_lines=["new_content"],
                )
            ],
        )
        result = apply_update("", action, "test.py")

        assert "new_content\n" == result

    def test_whitespace_tolerant_delete_match(self) -> None:
        """
        Deleted-line matching is whitespace-tolerant (strips both sides).
        """
        from codeflash_api.diff._v4a import Chunk, PatchAction

        action = PatchAction(
            path="test.py",
            chunks=[
                Chunk(
                    orig_index=0,
                    del_lines=["  hello  "],
                    ins_lines=["goodbye"],
                )
            ],
        )
        result = apply_update("  hello", action, "test.py")

        assert "goodbye\n" == result

    def test_no_chunks_returns_original(self) -> None:
        """
        A PatchAction with no chunks returns the original text with trailing newline.
        """
        from codeflash_api.diff._v4a import PatchAction

        action = PatchAction(path="test.py", chunks=[])
        result = apply_update("hello\nworld", action, "test.py")

        assert "hello\nworld\n" == result


class TestV4ADiffApply:
    """Tests for V4ADiff.run with actual patching."""

    def test_single_file_patch(self) -> None:
        """
        A complete V4A patch modifies matching file content.
        """
        content = (
            "*** Begin Patch\n"
            "*** Update File: main.py\n"
            " def foo():\n"
            "-    return 1\n"
            "+    return 2\n"
            "*** End Patch\n"
        )
        diff = V4ADiff(
            content=content,
            source_code={"main.py": "def foo():\n    return 1\n"},
        )
        result = diff.run()

        assert "def foo():\n    return 2\n" == result["main.py"]

    def test_unmatched_file_unchanged(self) -> None:
        """
        Files not mentioned in the patch are returned unchanged.
        """
        content = (
            "*** Begin Patch\n"
            "*** Update File: a.py\n"
            " line\n"
            "-old\n"
            "+new\n"
            "*** End Patch\n"
        )
        diff = V4ADiff(
            content=content,
            source_code={
                "a.py": "line\nold\nrest",
                "b.py": "untouched",
            },
        )
        result = diff.run()

        assert "untouched" == result["b.py"]

    def test_multi_file_patch(self) -> None:
        """
        A patch updating multiple files modifies all of them.
        """
        content = (
            "*** Begin Patch\n"
            "*** Update File: x.py\n"
            " alpha\n"
            "-beta\n"
            "+BETA\n"
            "*** Update File: y.py\n"
            " gamma\n"
            "-delta\n"
            "+DELTA\n"
            "*** End Patch\n"
        )
        diff = V4ADiff(
            content=content,
            source_code={
                "x.py": "alpha\nbeta\nepsilon",
                "y.py": "gamma\ndelta\nzeta",
            },
        )
        result = diff.run()

        assert "BETA" in result["x.py"]
        assert "DELTA" in result["y.py"]

    def test_patch_no_actions_for_file(self) -> None:
        """
        When a section is extracted but parses into no actions, the original
        code is returned.
        """
        content = (
            "*** Begin Patch\n*** Update File: empty.py\n\n*** End Patch\n"
        )
        diff = V4ADiff(
            content=content,
            source_code={"empty.py": "original code"},
        )
        result = diff.run()

        assert "original code" == result["empty.py"]

    def test_multi_hunk_single_file(self) -> None:
        """
        Multiple changes in different parts of the same file are all applied.
        """
        content = (
            "*** Begin Patch\n"
            "*** Update File: code.py\n"
            " aaa\n"
            "-bbb\n"
            "+BBB\n"
            " ccc\n"
            " ddd\n"
            "-eee\n"
            "+EEE\n"
            "*** End Patch\n"
        )
        diff = V4ADiff(
            content=content,
            source_code={"code.py": "aaa\nbbb\nccc\nddd\neee\nfff"},
        )
        result = diff.run()

        assert "aaa\nBBB\nccc\nddd\nEEE\nfff\n" == result["code.py"]
