#!/usr/bin/env python3

"""Tests for change_provider pure-Python helper functions.

Tests detect_edits and _on_bytes_to_edits as pure functions.  The `vim`
module is mocked by pythonx/conftest.py so these run without Vim.
"""

import unittest

from UltiSnips.change_provider import (
    _is_pathological_diff_input,
    _listener_to_edits,
    _on_bytes_to_edits,
    detect_edits,
    diff,
)


def _apply(old_lines, cmds, start_line=0):
    """Apply edit commands to old_lines, return resulting lines list."""
    buf = old_lines[:]
    for cmd in cmds:
        ctype, line, col, char = cmd
        line -= start_line
        if ctype == "D":
            if char != "\n":
                buf[line] = buf[line][:col] + buf[line][col + len(char) :]
            else:
                if line + 1 < len(buf):
                    buf[line] = buf[line] + buf[line + 1]
                    del buf[line + 1]
                else:
                    del buf[line]
        elif ctype == "I":
            buf[line] = buf[line][:col] + char + buf[line][col:]
        buf = "\n".join(buf).split("\n")
    return buf


class TestDetectEdits(unittest.TestCase):
    """Test detect_edits for common editing patterns."""

    def _check(self, old, new, start, cursor_line, cursor_col):
        """Run detect_edits and verify the result transforms old into new."""
        cmds = detect_edits(old, new, start, cursor_line, cursor_col)
        self.assertIsNotNone(cmds, "detect_edits returned None (gave up)")
        result = _apply(old, cmds, start)
        self.assertEqual(
            result, new, f"Commands {cmds} did not transform {old} to {new}"
        )
        return cmds

    def test_no_change(self):
        cmds = self._check(["hello"], ["hello"], 0, 0, 5)
        self.assertEqual(cmds, [])

    def test_single_char_insert(self):
        cmds = self._check(["hello"], ["helxlo"], 0, 0, 4)
        self.assertEqual(len(cmds), 1)
        self.assertEqual(cmds[0][0], "I")

    def test_single_char_insert_at_end(self):
        cmds = self._check(["hello"], ["hellox"], 0, 0, 6)
        self.assertEqual(cmds, [("I", 0, 5, "x")])

    def test_single_char_delete_backspace(self):
        cmds = self._check(["hello"], ["helo"], 0, 0, 3)
        self.assertEqual(len(cmds), 1)
        self.assertEqual(cmds[0][0], "D")

    def test_single_char_delete_x(self):
        # 'x' key at col 1: cursor stays at col 1, 'e' deleted
        cmds = self._check(["hello"], ["hllo"], 0, 0, 1)
        self.assertEqual(cmds, [("D", 0, 1, "e")])

    def test_multi_char_insert_completion(self):
        cmds = self._check(["hel"], ["hello"], 0, 0, 5)
        self.assertEqual(cmds, [("I", 0, 3, "lo")])

    def test_multi_char_delete(self):
        cmds = self._check(["hello"], ["ho"], 0, 0, 1)
        self.assertEqual(cmds, [("D", 0, 1, "ell")])

    def test_replace_on_same_line(self):
        cmds = self._check(["hello"], ["hXYZo"], 0, 0, 4)
        self.assertEqual(len(cmds), 2)  # D + I

    def test_ambiguous_insert_cursor_disambiguates(self):
        # "ab" -> "abb" — could be insert 'b' at pos 1 or pos 2
        # Cursor at col 2 means insert was at col 1 (before existing 'b')
        cmds = self._check(["ab"], ["abb"], 0, 0, 2)
        result = _apply(["ab"], cmds, 0)
        self.assertEqual(result, ["abb"])

    def test_with_nonzero_start_line(self):
        cmds = self._check(["hello"], ["helxlo"], 5, 5, 4)
        self.assertEqual(cmds[0][1], 5)  # line number should be absolute

    def test_insert_with_repeated_pattern_after(self):
        # Regression: inserting a space at col 5 in "hallo hallo hallo hallo"
        # The greedy prefix matches "hallo " (6 chars), then cursor disambiguation
        # must reduce prefix to 5 AND let suffix grow so the result is a clean
        # 1-char insertion, not a delete-and-insert.
        cmds = self._check(
            ["hallo hallo hallo hallo"], ["hallo  hallo hallo hallo"], 0, 0, 6
        )
        self.assertEqual(cmds, [("I", 0, 5, " ")])

    def test_insert_in_repeated_substring(self):
        # Insert "x" between two identical "abc" substrings: "abcabc" → "abcxabc"
        # Greedy prefix could match all 3 of "abc" before differing.
        cmds = self._check(["abcabc"], ["abcxabc"], 0, 0, 4)
        self.assertEqual(cmds, [("I", 0, 3, "x")])

    def test_line_deletion_dd(self):
        cmds = self._check(["a", "b", "c"], ["a", "c"], 0, 1, 0)
        self.assertIsNotNone(cmds)
        result = _apply(["a", "b", "c"], cmds, 0)
        self.assertEqual(result, ["a", "c"])

    def test_multi_line_deletion(self):
        cmds = self._check(["a", "b", "c", "d"], ["a", "d"], 0, 1, 0)
        self.assertIsNotNone(cmds)
        result = _apply(["a", "b", "c", "d"], cmds, 0)
        self.assertEqual(result, ["a", "d"])

    def test_enter_simple_split(self):
        cmds = self._check(["hello"], ["hel", "lo"], 0, 1, 0)
        self.assertIsNotNone(cmds)
        result = _apply(["hello"], cmds, 0)
        self.assertEqual(result, ["hel", "lo"])

    def test_enter_with_indent(self):
        cmds = self._check(["hello"], ["hel", "  lo"], 0, 1, 2)
        self.assertIsNotNone(cmds)
        result = _apply(["hello"], cmds, 0)
        self.assertEqual(result, ["hel", "  lo"])

    def test_multiple_lines_changed(self):
        # Two lines changed simultaneously (e.g., multi-cursor or mirror)
        cmds = self._check(["aaa", "bbb"], ["axa", "bxb"], 0, 0, 2)
        self.assertIsNotNone(cmds)
        result = _apply(["aaa", "bbb"], cmds, 0)
        self.assertEqual(result, ["axa", "bxb"])

    def test_delete_only_remaining_line_content(self):
        # All lines present but content deleted
        cmds = self._check(["hello", "world"], ["", "world"], 0, 0, 0)
        self.assertIsNotNone(cmds)
        result = _apply(["hello", "world"], cmds, 0)
        self.assertEqual(result, ["", "world"])

    def test_join_lines(self):
        # J command: two lines joined into one
        cmds = self._check(["hello", "world"], ["helloworld"], 0, 0, 5)
        self.assertIsNotNone(cmds)
        result = _apply(["hello", "world"], cmds, 0)
        self.assertEqual(result, ["helloworld"])

    def test_1_to_n_multi_line_paste(self):
        # Paste of 3 additional lines into a single line — mimics e.g. a
        # multi-line clipboard paste inside a tabstop. Previously this
        # fell through detect_edits to the O(n·m) diff() fallback; it now
        # resolves deterministically via the general 1→N handler.
        old = ["hello world"]
        new = ["hello X", "middle", "another", "Y world"]
        cmds = self._check(old, new, 0, 0, 0)
        self.assertIsNotNone(cmds)

    def test_1_to_n_many_lines_large_paste(self):
        # Regression for the #1074-style large-paste-into-tabstop case:
        # detect_edits must resolve this without falling back to diff.
        old = ["AB"]
        new = ["A" + "x" + str(i) for i in range(100)] + ["end B"]
        cmds = self._check(old, new, 0, 0, 0)
        self.assertIsNotNone(cmds)

    def test_1_to_n_empty_old_returns_none(self):
        # Regression for #1513: when rem_old is a single empty line, the
        # snippet's remembered state has diverged from the buffer (e.g.
        # undo past the expansion). detect_edits deliberately gives up
        # here so that consume_edits' pathological check can drop the
        # snippet instead of replaying edits into a stale tree.
        old = [""]
        new = ["AAAAA"] * 50
        cmds = detect_edits(old, new, 0, 0, 0)
        self.assertIsNone(cmds)


class TestIsPathologicalDiffInput(unittest.TestCase):
    """Regression for #1513/#155/#1074 — diff() is O(n·m) and freezes for
    large inputs. consume_edits uses _is_pathological_diff_input as a
    guard: when the old→new character delta is too large, we signal the
    caller to drop the snippet rather than attempting a (slow, and at
    this point semantically pointless) reconciliation."""

    def test_pure_insert_many_lines_is_pathological(self):
        # The #1513 shape: [''] → thousands of content lines (undo of big
        # deletion while snippet still tracked).
        self.assertTrue(_is_pathological_diff_input([""], ["AAAAA"] * 3000))

    def test_pure_delete_many_lines_is_pathological(self):
        # The #155 shape: thousands of lines visually selected and fed
        # into a snippet ${VISUAL} ends up producing a huge delta in the
        # other direction on subsequent edits.
        self.assertTrue(_is_pathological_diff_input(["AAAAA"] * 3000, [""]))

    def test_small_edit_is_not_pathological(self):
        # Normal typing/backspace: diff() would be trivial anyway.
        self.assertFalse(_is_pathological_diff_input(["hello"], ["hellox"]))
        self.assertFalse(_is_pathological_diff_input(["hello"], ["helo"]))

    def test_moderate_multi_line_edit_is_not_pathological(self):
        # ~100-char paste: diff would run in a few ms. Not worth dropping
        # the snippet over.
        old = ["hello world"]
        new = ["hello"] + ["x" * 10] * 5 + ["world"]
        self.assertFalse(_is_pathological_diff_input(old, new))

    def test_at_threshold_boundary(self):
        # Delta exactly at the threshold is NOT pathological;
        # above is. Documents the cutoff.
        from UltiSnips.change_provider import _PATHOLOGICAL_CHAR_DELTA

        under = ["a" * _PATHOLOGICAL_CHAR_DELTA]
        self.assertFalse(_is_pathological_diff_input([""], under))
        over = ["a" * (_PATHOLOGICAL_CHAR_DELTA + 1)]
        self.assertTrue(_is_pathological_diff_input([""], over))


class TestOnBytesToEdits(unittest.TestCase):
    """Test _on_bytes_to_edits for Neovim on_bytes event translation."""

    def test_single_char_insert(self):
        # Type "x" at (5,3)
        event = (5, 3, 0, 0, 0, 1)
        old_lines = ["hello"]
        new_buf = [""] * 5 + ["helxlo"]
        cmds = _on_bytes_to_edits(event, old_lines, new_buf, 5)
        self.assertEqual(cmds, [("I", 5, 3, "x")])

    def test_enter(self):
        # Enter at (5,3): splits line
        event = (5, 3, 0, 0, 1, 0)
        old_lines = ["hello"]
        new_buf = [""] * 5 + ["hel", "lo"]
        cmds = _on_bytes_to_edits(event, old_lines, new_buf, 5)
        self.assertEqual(cmds, [("I", 5, 3, "\n")])

    def test_backspace(self):
        # Backspace at (5,3): deletes char at (5,2)
        event = (5, 2, 0, 1, 0, 0)
        old_lines = ["hello"]
        new_buf = [""] * 5 + ["helo"]
        cmds = _on_bytes_to_edits(event, old_lines, new_buf, 5)
        self.assertEqual(cmds, [("D", 5, 2, "l")])

    def test_delete_line_dd(self):
        # dd on line "hello" at row 5
        event = (5, 0, 1, 0, 0, 0)
        old_lines = ["hello", "world"]
        new_buf = [""] * 5 + ["world"]
        cmds = _on_bytes_to_edits(event, old_lines, new_buf, 5)
        self.assertEqual(cmds, [("D", 5, 0, "hello"), ("D", 5, 0, "\n")])

    def test_multi_line_paste(self):
        # Paste "abc\ndef" at (5,3)
        event = (5, 3, 0, 0, 1, 3)
        old_lines = ["hello"]
        new_buf = [""] * 5 + ["helabc", "deflo"]
        cmds = _on_bytes_to_edits(event, old_lines, new_buf, 5)
        self.assertEqual(
            cmds,
            [
                ("I", 5, 3, "abc"),
                ("I", 5, 6, "\n"),
                ("I", 6, 0, "def"),
            ],
        )

    def test_visual_replace(self):
        # Visual replace "ello" with "i" at (5,1)
        event = (5, 1, 0, 4, 0, 1)
        old_lines = ["hello"]
        new_buf = [""] * 5 + ["hio"]
        cmds = _on_bytes_to_edits(event, old_lines, new_buf, 5)
        self.assertEqual(
            cmds,
            [
                ("D", 5, 1, "ello"),
                ("I", 5, 1, "i"),
            ],
        )

    def test_multi_line_deletion(self):
        # Delete lines 5-6 ("hello\nworld" -> cursor at line 5, col 0, deletes 2 lines worth)
        event = (5, 0, 2, 0, 0, 0)
        old_lines = ["hello", "world", "end"]
        new_buf = [""] * 5 + ["end"]
        cmds = _on_bytes_to_edits(event, old_lines, new_buf, 5)
        self.assertEqual(
            cmds,
            [
                ("D", 5, 0, "hello"),
                ("D", 5, 0, "\n"),
                ("D", 5, 0, "world"),
                ("D", 5, 0, "\n"),
            ],
        )

    def test_no_change(self):
        event = (5, 0, 0, 0, 0, 0)
        old_lines = ["hello"]
        new_buf = [""] * 5 + ["hello"]
        cmds = _on_bytes_to_edits(event, old_lines, new_buf, 5)
        self.assertEqual(cmds, [])

    def test_multi_line_visual_replace(self):
        # Replace "llo\nwor" with "X" — (5,2) old=(1,3) new=(0,1)
        event = (5, 2, 1, 3, 0, 1)
        old_lines = ["hello", "world"]
        new_buf = [""] * 5 + ["heXld"]
        cmds = _on_bytes_to_edits(event, old_lines, new_buf, 5)
        self.assertEqual(
            cmds,
            [
                ("D", 5, 2, "llo"),
                ("D", 5, 2, "\n"),
                ("D", 5, 2, "wor"),
                ("I", 5, 2, "X"),
            ],
        )

    def test_utf8_insert_after_umlauts(self):
        # "te üü " is 8 bytes (t=1, e=1, space=1, ü=2, ü=2, space=1) and 6 chars.
        # Inserting "h" at byte position 8 = char position 6.
        event = (5, 8, 0, 0, 0, 1)
        old_lines = ["te üü world"]
        new_buf = [""] * 5 + ["te üü hworld"]
        cmds = _on_bytes_to_edits(event, old_lines, new_buf, 5)
        self.assertEqual(cmds, [("I", 5, 6, "h")])

    def test_utf8_replace_in_umlaut_string(self):
        # Replace "world" with "h" at byte 8 = char 6
        # Old: "te üü world" (13 bytes, 11 chars)
        # New: "te üü h" (9 bytes, 7 chars)
        event = (5, 8, 0, 5, 0, 1)
        old_lines = ["te üü world"]
        new_buf = [""] * 5 + ["te üü h"]
        cmds = _on_bytes_to_edits(event, old_lines, new_buf, 5)
        self.assertEqual(cmds, [("D", 5, 6, "world"), ("I", 5, 6, "h")])

    def test_utf8_delete_umlaut(self):
        # Delete one "ü" (2 bytes) at byte position 3 (char position 3)
        # Old: "te üü world", new: "te ü world"
        event = (5, 3, 0, 2, 0, 0)
        old_lines = ["te üü world"]
        new_buf = [""] * 5 + ["te ü world"]
        cmds = _on_bytes_to_edits(event, old_lines, new_buf, 5)
        self.assertEqual(cmds, [("D", 5, 3, "ü")])

    def test_bounds_violation_returns_none(self):
        # Multi-line deletion extending past snippet boundary
        # Snippet has 3 lines, but old_end_row=2 + start_col=0 means we'd
        # need a 4th line for the trailing slice
        event = (1, 0, 2, 0, 0, 0)
        old_lines = ["hello", "nice", "world"]  # 3 lines
        new_buf = ["", ""]  # not relevant
        # rel_row=1, old_end_row=2, rel_row + old_end_row = 3 = len(old_lines)
        cmds = _on_bytes_to_edits(event, old_lines, new_buf, 0)
        self.assertIsNone(cmds)

    def test_bounds_change_above_snippet_returns_none(self):
        # Change at row 3, but snippet starts at row 5
        event = (3, 0, 0, 1, 0, 0)
        old_lines = ["hello"]
        new_buf = [""] * 3 + [""]
        cmds = _on_bytes_to_edits(event, old_lines, new_buf, 5)
        self.assertIsNone(cmds)


class TestListenerToEdits(unittest.TestCase):
    """Test _listener_to_edits for Vim listener_add event translation."""

    def _check(self, event, old_lines, new_buf, snippet_start, cursor_line, cursor_col):
        cmds = _listener_to_edits(
            event, old_lines, new_buf, snippet_start, cursor_line, cursor_col
        )
        self.assertIsNotNone(cmds, "_listener_to_edits returned None (gave up)")
        result = _apply(old_lines, cmds, snippet_start)
        # Build expected new_lines from the snippet region in new_buf
        added = int(event["added"])
        new_end = snippet_start + len(old_lines) + added
        expected = list(new_buf[snippet_start:new_end])
        self.assertEqual(
            result, expected, f"Commands {cmds} did not produce expected result"
        )
        return cmds

    def test_single_line_char_insert(self):
        # Type "x" on line 5 (1-indexed lnum=6, end=7, added=0)
        event = {"lnum": "6", "end": "7", "added": "0"}
        old_lines = ["hello"]
        new_buf = [""] * 5 + ["helxlo"]
        cmds = self._check(event, old_lines, new_buf, 5, 5, 4)
        self.assertEqual(len(cmds), 1)
        self.assertEqual(cmds[0][0], "I")

    def test_single_line_backspace(self):
        # Backspace on line 5 (lnum=6, end=7, added=0)
        event = {"lnum": "6", "end": "7", "added": "0"}
        old_lines = ["hello"]
        new_buf = [""] * 5 + ["helo"]
        cmds = self._check(event, old_lines, new_buf, 5, 5, 3)
        self.assertEqual(cmds, [("D", 5, 3, "l")])

    def test_line_deletion_dd(self):
        # dd on "bbb" at buffer line 7 (1-indexed), snippet starts at line 5 (0-indexed)
        event = {"lnum": "7", "end": "8", "added": "-1"}
        old_lines = ["aaa", "bbb", "ccc"]
        new_buf = [""] * 5 + ["aaa", "ccc"]
        cmds = self._check(event, old_lines, new_buf, 5, 6, 0)
        result = _apply(["aaa", "bbb", "ccc"], cmds, 5)
        self.assertEqual(result, ["aaa", "ccc"])

    def test_enter_split(self):
        # Enter on line 6 (1-indexed): lnum=6, end=7, added=1
        event = {"lnum": "6", "end": "7", "added": "1"}
        old_lines = ["hello"]
        new_buf = [""] * 5 + ["hel", "lo"]
        cmds = self._check(event, old_lines, new_buf, 5, 6, 0)
        result = _apply(["hello"], cmds, 5)
        self.assertEqual(result, ["hel", "lo"])

    def test_multi_line_snippet_scoped_to_changed_line(self):
        # Snippet has 3 lines, only middle line changed
        event = {"lnum": "7", "end": "8", "added": "0"}
        old_lines = ["aaa", "bbb", "ccc"]
        new_buf = [""] * 5 + ["aaa", "bxb", "ccc"]
        cmds = self._check(event, old_lines, new_buf, 5, 6, 2)
        # Should produce edit only for line 6 (0-indexed)
        self.assertEqual(len(cmds), 2)  # D + I for the replacement

    def test_disambiguates_identical_lines(self):
        # Snippet: ["aaa", "bbb", "aaa"], delete middle line
        # Without scoping, trimming might be confused by identical "aaa" lines
        event = {"lnum": "7", "end": "8", "added": "-1"}
        old_lines = ["aaa", "bbb", "aaa"]
        new_buf = [""] * 5 + ["aaa", "aaa"]
        cmds = self._check(event, old_lines, new_buf, 5, 6, 0)
        result = _apply(["aaa", "bbb", "aaa"], cmds, 5)
        self.assertEqual(result, ["aaa", "aaa"])

    def test_change_outside_snippet_returns_none(self):
        # Change on line 3 but snippet starts at line 5
        event = {"lnum": "4", "end": "5", "added": "0"}
        old_lines = ["hello"]
        new_buf = [""] * 5 + ["hello"]
        cmds = _listener_to_edits(event, old_lines, new_buf, 5, 3, 0)
        self.assertIsNone(cmds)

    def test_multi_line_deletion(self):
        # Delete "bbb" and "ccc": buffer lines 7-8 (1-indexed), snippet starts at 5 (0-indexed)
        event = {"lnum": "7", "end": "9", "added": "-2"}
        old_lines = ["aaa", "bbb", "ccc", "ddd"]
        new_buf = [""] * 5 + ["aaa", "ddd"]
        cmds = self._check(event, old_lines, new_buf, 5, 6, 0)
        result = _apply(["aaa", "bbb", "ccc", "ddd"], cmds, 5)
        self.assertEqual(result, ["aaa", "ddd"])

    # Deterministic fast path: single-line change with col >= 2.
    # cursor_line/cursor_col are passed but unused by the fast path —
    # the col anchor makes the prefix unambiguous.

    def test_col_anchored_insert(self):
        # Insert "x" at byte col 4 (= char col 3) in "hello" → "helxlo"
        event = {"lnum": "6", "end": "7", "added": "0", "col": "4"}
        old_lines = ["hello"]
        new_buf = [""] * 5 + ["helxlo"]
        cmds = _listener_to_edits(event, old_lines, new_buf, 5, 5, 4)
        self.assertEqual(cmds, [("I", 5, 3, "x")])

    def test_col_anchored_avoids_ambiguity(self):
        # Insert space at col 6 in "hallo hallo hallo hallo".
        # Without col, greedy prefix matches "hallo " (6 chars).
        # With col=6, prefix is fixed at 5 (col-1).
        event = {"lnum": "6", "end": "7", "added": "0", "col": "6"}
        old_lines = ["hallo hallo hallo hallo"]
        new_buf = [""] * 5 + ["hallo  hallo hallo hallo"]
        cmds = _listener_to_edits(event, old_lines, new_buf, 5, 5, 6)
        self.assertEqual(cmds, [("I", 5, 5, " ")])

    def test_col_anchored_utf8_byte_to_char(self):
        # Insert "h" at byte col 9 (= char col 6) in "te üü world"
        # ("te üü " is 8 bytes, 6 chars, so byte col 9 is right after).
        event = {"lnum": "6", "end": "7", "added": "0", "col": "9"}
        old_lines = ["te üü world"]
        new_buf = [""] * 5 + ["te üü hworld"]
        cmds = _listener_to_edits(event, old_lines, new_buf, 5, 5, 7)
        self.assertEqual(cmds, [("I", 5, 6, "h")])

    def test_col_anchored_delete(self):
        # Delete "ell" at byte col 2 (= char col 1) in "hello" → "ho"
        event = {"lnum": "6", "end": "7", "added": "0", "col": "2"}
        old_lines = ["hello"]
        new_buf = [""] * 5 + ["ho"]
        cmds = _listener_to_edits(event, old_lines, new_buf, 5, 5, 1)
        self.assertEqual(cmds, [("D", 5, 1, "ell")])

    def test_col_one_falls_back_to_scoped(self):
        # col == 1 means "unknown / whole line". Fast path is skipped;
        # we fall through to scoped detect_edits which can still find
        # the edit (with cursor disambiguation).
        event = {"lnum": "6", "end": "7", "added": "0", "col": "1"}
        old_lines = ["hello"]
        new_buf = [""] * 5 + ["xhello"]
        cmds = self._check(event, old_lines, new_buf, 5, 5, 1)
        result = _apply(["hello"], cmds, 5)
        self.assertEqual(result, ["xhello"])


class TestDiff(unittest.TestCase):
    """Test diff() — the shortest-edit-path fallback."""

    @staticmethod
    def _transform(a, cmds):
        buf = a.split("\n")
        for cmd in cmds:
            ctype, line, col, char = cmd
            if ctype == "D":
                if char != "\n":
                    buf[line] = buf[line][:col] + buf[line][col + len(char) :]
                else:
                    buf[line] = buf[line] + buf[line + 1]
                    del buf[line + 1]
            elif ctype == "I":
                buf[line] = buf[line][:col] + char + buf[line][col:]
            buf = "\n".join(buf).split("\n")
        return "\n".join(buf)

    def _check(self, a, b, wanted):
        es = diff(a, b)
        self.assertEqual(b, self._transform(a, es))
        self.assertEqual(wanted, es)

    def test_empty_string(self):
        self._check("", "", ())

    def test_all_match(self):
        self._check("abcdef", "abcdef", ())

    def test_lotsa_newlines(self):
        self._check(
            "Hello",
            "Hello\nWorld\nWorld\nWorld",
            (
                ("I", 0, 5, "\n"),
                ("I", 1, 0, "World"),
                ("I", 1, 5, "\n"),
                ("I", 2, 0, "World"),
                ("I", 2, 5, "\n"),
                ("I", 3, 0, "World"),
            ),
        )

    def test_crash(self):
        self._check(
            "hallo Blah mitte=sdfdsfsd\nhallo kjsdhfjksdhfkjhsdfkh mittekjshdkfhkhsdfdsf",
            "hallo Blah mitte=sdfdsfsd\nhallo b mittekjshdkfhkhsdfdsf",
            (("D", 1, 6, "kjsdhfjksdhfkjhsdfkh"), ("I", 1, 6, "b")),
        )

    def test_real_life(self):
        self._check(
            "hallo End Beginning",
            "hallo End t",
            (("D", 0, 10, "Beginning"), ("I", 0, 10, "t")),
        )

    def test_real_life_1(self):
        self._check(
            "Vorne hallo Hinten",
            "Vorne hallo  Hinten",
            (("I", 0, 11, " "),),
        )

    def test_with_newline(self):
        self._check(
            "First Line\nSecond Line",
            "n",
            (
                ("D", 0, 0, "First Line"),
                ("D", 0, 0, "\n"),
                ("D", 0, 0, "Second Line"),
                ("I", 0, 0, "n"),
            ),
        )

    def test_cheap_delete(self):
        self._check(
            "Vorne hallo Hinten",
            "Vorne Hinten",
            (("D", 0, 5, " hallo"),),
        )

    def test_no_substring(self):
        self._check("abc", "def", (("D", 0, 0, "abc"), ("I", 0, 0, "def")))

    def test_common_characters(self):
        self._check(
            "hasomelongertextbl",
            "hol",
            (("D", 0, 1, "asomelongertextb"), ("I", 0, 1, "o")),
        )

    def test_ultisnips_problem(self):
        self._check(
            "this is it this is it this is it",
            "this is it a this is it",
            (("D", 0, 11, "this is it"), ("I", 0, 11, "a")),
        )

    def test_match_is_too_cheap(self):
        self._check("stdin.h", "s", (("D", 0, 1, "tdin.h"),))

    def test_multi_line(self):
        self._check(
            "hi first line\nsecond line first line\nsecond line world",
            "hi first line\nsecond line k world",
            (
                ("D", 1, 12, "first line"),
                ("D", 1, 12, "\n"),
                ("D", 1, 12, "second line"),
                ("I", 1, 12, "k"),
            ),
        )


if __name__ == "__main__":
    unittest.main()
