lib/dmp/patch.ex

defmodule Dmp.Patch do
  @moduledoc """
  Apply a list of patches onto plain text. Use best effort to apply
  patch even when the underlying text doesn't match.
  """

  import Dmp.StringUtils

  alias Dmp.{Cursor, Diff, Match, Options}

  alias __MODULE__

  defstruct diffs: [], start1: 0, start2: 0, length1: 0, length2: 0

  @type t() :: %Patch{
          diffs: Diff.difflist(),
          start1: non_neg_integer(),
          start2: non_neg_integer(),
          length1: non_neg_integer(),
          length2: non_neg_integer()
        }
  @type patchlist() :: list(t())
  @type options() :: Options.t()

  defimpl String.Chars, for: Patch do
    @doc """
    Emulate GNU diff's format:

    ```
    @@ -382,8 +481,9 @@
    +change
     same

    ```

    Indices are printed as 1-based, not 0-based.
    """
    def to_string(%Patch{
          diffs: diffs,
          start1: start1,
          start2: start2,
          length1: length1,
          length2: length2
        }) do
      coords1 = patch_location(start1, length1)
      coords2 = patch_location(start2, length2)
      header = "@@ -#{coords1} +#{coords2} @@\n"
      # Escape the body of the patch with %xx notation.
      diffs
      |> Enum.reduce(header, fn diff, acc -> append_diff_contents(diff, acc) end)
      |> unescape_for_encode_uri_compatability()
    end

    defp patch_location(start, length) do
      case length do
        0 ->
          "#{start},0"

        1 ->
          "#{start + 1}"

        _ ->
          "#{start + 1},#{length}"
      end
    end

    defp append_diff_contents({op, text}, acc) do
      op_text =
        case op do
          :insert ->
            "+"

          :delete ->
            "-"

          :equal ->
            " "

          _ ->
            raise RuntimeError, "Invalid operation #{inspect(op)}"
        end

      text = uri_encode(text)
      acc <> op_text <> text <> "\n"
    end
  end

  @doc """
  Increase the context until it is unique,
  but don't let the pattern expand beyond match_max_bits.

    * `patch` - The `Patch` to grow.
    * `text` - Source text.
    * `patch_margin` - Chunk size for context length.
    * `match_max_bits` - The number of bits in an integer (default is expected 32).

  Returns the updated `Patch`.
  """
  @spec add_context(t(), String.t(), non_neg_integer(), non_neg_integer()) :: t()
  def add_context(patch, text, patch_margin, match_max_bits \\ 32)
  def add_context(patch, "", _patch_margin, _match_max_bits), do: patch

  def add_context(
        %Patch{} = patch,
        text,
        patch_margin,
        match_max_bits
      ) do
    pattern = substring(text, patch.start2, patch.start2 + patch.length1)
    {padding, _pattern} = increase_padding(0, pattern, patch, text, patch_margin, match_max_bits)

    prefix = substring(text, max(0, patch.start2 - padding), patch.start2)
    prefix_length = String.length(prefix)

    patch =
      if prefix_length != 0 do
        # Add the prefix.
        %Patch{patch | diffs: [{:equal, prefix} | patch.diffs]}
      else
        patch
      end

    suffix =
      substring(
        text,
        patch.start2 + patch.length1,
        patch.start2 + patch.length1 + padding
      )

    suffix_length = String.length(suffix)

    patch =
      if suffix_length != 0 do
        # Add the suffix.
        %Patch{patch | diffs: patch.diffs ++ [{:equal, suffix}]}
      else
        patch
      end

    # Roll back the start points.
    # Extend lengths.
    %Patch{
      patch
      | start1: patch.start1 - prefix_length,
        start2: patch.start2 - prefix_length,
        length1: patch.length1 + prefix_length + suffix_length,
        length2: patch.length2 + prefix_length + suffix_length
    }
  end

  defp increase_padding(padding, pattern, patch, text, patch_margin, match_max_bits) do
    # Look for the first and last matches of pattern in text.  If two different
    # matches are found, increase the pattern length.
    if index_of(text, pattern) != last_index_of(text, pattern) &&
         (match_max_bits == 0 ||
            String.length(pattern) < match_max_bits - patch_margin * 2) do
      padding = padding + patch_margin

      pattern =
        substring(
          text,
          max(0, patch.start2 - padding),
          patch.start2 + patch.length1 + padding
        )

      increase_padding(padding, pattern, patch, text, patch_margin, match_max_bits)
    else
      # Add one chunk for good luck.
      {padding + patch_margin, pattern}
    end
  end

  @doc """
  Compute a list of patches to turn `text1` into `text2`. `text1` will be derived
  from the provided diffs.

    * `diffs` - A difflist from `text1` to `text2`.
    * `opts` - A options keyword list, `[]` to use the default options.

  Returns a patchlist.
  """
  @spec from_diffs(Diff.difflist(), options()) :: patchlist()
  def from_diffs(diffs, opts \\ []) do
    # No origin string provided, compute our own.
    text1 = Diff.text1(diffs)
    make(text1, diffs, opts)
  end

  @doc """
  Deprecated

  Compute a list of patches to turn `text1` into `text2`. `text2` is ignored.
  `diffs` are the delta between text1 and text2.

    * `text1` - Old text.
    * `text2` - Ignored.
    * `diffs` - A difflist from `text1` to `text2`.
    * `opts` - A options keyword list, `[]` to use the default options.

  Returns a patchlist.
  """
  @spec from_texts_and_diffs(String.t(), String.t(), Diff.difflist(), options()) ::
          patchlist()
  def from_texts_and_diffs(text1, _text2, diffs, opts \\ []) do
    make(text1, diffs, opts)
  end

  @doc """
  This function can be called two ways. In either case the first argument,
  `a` is the original text (`text1`).

  The second argument `b` has two cases:

    * If `b` is a String `text2`, a difflist that turns `text1` into `text2` will be computed.
    * If `b` is a difflist, it is the delta between `text1` and the target `text2`.
    * `opts` - A options keyword list, `[]` to use the default options.

  Returns a patchlist.
  """
  @spec make(String.t(), String.t() | Diff.difflist(), options()) :: patchlist()
  def make(a, b, opts \\ [])

  def make(text1, text2, opts) when is_binary(text2) do
    opts = Options.valid_options!(opts)
    diff_edit_cost = Keyword.fetch!(opts, :diff_edit_cost)

    # No diffs provided, compute our own.
    diffs = Diff.main(text1, text2, true)

    diffs =
      if Enum.count(diffs) > 2 do
        diffs
        |> Diff.cleanup_semantic()
        |> Diff.cleanup_efficiency(diff_edit_cost)
      else
        diffs
      end

    make_impl(text1, diffs, opts)
  end

  def make(text1, diffs, opts) when is_list(diffs) do
    opts = Options.valid_options!(opts)
    make_impl(text1, diffs, opts)
  end

  # Get rid of the null case.
  @spec make_impl(String.t(), Diff.difflist(), options()) :: patchlist()
  defp make_impl(_text1, [], _opts), do: []

  defp make_impl(text1, diffs, opts) do
    patch_margin = Keyword.fetch!(opts, :patch_margin)
    match_max_bits = Keyword.fetch!(opts, :match_max_bits)

    {patches, patch, prepatch_text} =
      diffs
      |> Cursor.from_list(position: 0)
      |> make_loop(
        {[], %Patch{}, text1, text1, 0, 0},
        patch_margin,
        match_max_bits
      )

    if patch.diffs != [] do
      # Pick up the leftover patch if not empty.
      patch =
        add_context(
          patch,
          prepatch_text,
          patch_margin,
          match_max_bits
        )

      patches = patches ++ [patch]

      patches
    else
      patches
    end
  end

  # Start with text1 (`prepatch_text`) and apply the diffs until we arrive at
  # text2 (`postpatch_text`).
  # We recreate the patches one by one to determine context info.
  # `char_count1` Number of characters into the text1 string.
  # `char_count2` Number of characters into the text2 string.

  defp make_loop(
         %Cursor{current: nil},
         {patches, patch, prepatch_text, _postpatch_text, _char_count1, _char_count2},
         _patch_margin,
         _match_max_bits
       ) do
    {patches, patch, prepatch_text}
  end

  defp make_loop(
         diffs,
         {patches, patch, prepatch_text, postpatch_text, char_count1, char_count2},
         patch_margin,
         match_max_bits
       ) do
    patch = maybe_new_patch(diffs, patch, char_count1, char_count2)

    acc = {patches, patch, prepatch_text, postpatch_text, char_count1, char_count2}

    # Add to patch.diffs, then update the current character count
    # (`char_count1` and `char_count2`).
    acc = add_diff_to_patch(diffs, acc, patch_margin, match_max_bits)

    diffs
    |> Cursor.move_forward()
    |> make_loop(
      acc,
      patch_margin,
      match_max_bits
    )
  end

  # For `:insert` and `:delete` diffs, a new patch starting at the current character count.
  defp maybe_new_patch(%Cursor{current: {:equal, _}}, patch, _start1, _start2), do: patch

  defp maybe_new_patch(_diffs, %Patch{diffs: []} = patch, start1, start2) do
    %Patch{patch | start1: start1, start2: start2}
  end

  defp maybe_new_patch(_diffs, patch, _start1, _start2), do: patch

  # Add an `:insert` diff and update the current character count.
  defp add_diff_to_patch(
         %Cursor{current: {:insert, text} = this_diff},
         {patches, patch, prepatch_text, postpatch_text, char_count1, char_count2},
         _patch_margin,
         _match_max_bits
       ) do
    text_length = String.length(text)

    {patches,
     %Patch{patch | diffs: patch.diffs ++ [this_diff], length2: patch.length2 + text_length},
     prepatch_text,
     substring(postpatch_text, 0, char_count2) <>
       text <> substring(postpatch_text, char_count2), char_count1, char_count2 + text_length}
  end

  # Add a `:delete` diff and update the current character count.
  defp add_diff_to_patch(
         %Cursor{current: {:delete, text} = this_diff},
         {patches, patch, prepatch_text, postpatch_text, char_count1, char_count2},
         _patch_margin,
         _match_max_bits
       ) do
    text_length = String.length(text)

    {patches,
     %Patch{patch | diffs: patch.diffs ++ [this_diff], length1: patch.length1 + text_length},
     prepatch_text,
     substring(postpatch_text, 0, char_count2) <>
       substring(postpatch_text, char_count2 + text_length), char_count1 + text_length,
     char_count2}
  end

  # Nothing to do for equality with empty diffs, just update the current character count.
  defp add_diff_to_patch(
         %Cursor{current: {:equal, text}},
         {patches, %Patch{diffs: []} = patch, prepatch_text, postpatch_text, char_count1,
          char_count2},
         _patch_margin,
         _match_max_bits
       ) do
    text_length = String.length(text)

    {patches, patch, prepatch_text, postpatch_text, char_count1 + text_length,
     char_count2 + text_length}
  end

  # Add an `:equal` diff and update the current character count.
  defp add_diff_to_patch(
         %Cursor{current: {:equal, text}} = diffs,
         {patches, patch, prepatch_text, postpatch_text, char_count1, char_count2},
         patch_margin,
         match_max_bits
       ) do
    text_length = String.length(text)

    if text_length <= 2 * patch_margin do
      patch = small_equality_patch(diffs, patch, text_length)

      {patches, patch, prepatch_text, postpatch_text, char_count1 + text_length,
       char_count2 + text_length}
    else
      {patches, patch, prepatch_text, postpatch_text, char_count1} =
        large_equality_patch(
          patches,
          patch,
          prepatch_text,
          postpatch_text,
          char_count1,
          char_count2,
          patch_margin,
          match_max_bits
        )

      {patches, patch, prepatch_text, postpatch_text, char_count1 + text_length,
       char_count2 + text_length}
    end
  end

  # Small equality inside a patch.
  # No op if this is the last diff.
  defp small_equality_patch(%Cursor{next: []}, patch, _), do: patch

  defp small_equality_patch(%Cursor{current: this_diff}, patch, text_length) do
    %Patch{
      patch
      | diffs: patch.diffs ++ [this_diff],
        length1: patch.length1 + text_length,
        length2: patch.length2 + text_length
    }
  end

  # Equality diff is too big.
  # Time for a new patch.
  defp large_equality_patch(
         patches,
         patch,
         prepatch_text,
         postpatch_text,
         _char_count1,
         char_count2,
         patch_margin,
         match_max_bits
       ) do
    patch = add_context(patch, prepatch_text, patch_margin, match_max_bits)
    patches = patches ++ [patch]

    {
      patches,
      %Patch{},
      # Unlike Unidiff, our patch lists have a rolling context.
      # https://github.com/google/diff-match-patch/wiki/Unidiff
      # Update prepatch text and pos to reflect the application of the
      # just completed patch.
      postpatch_text,
      postpatch_text,
      char_count2
    }
  end

  @doc """
  Merge a set of patches onto the text.  Return a patched text, as well
  as an array of true/false values indicating which patches were applied.

    * `patches` - A patchlist.
    * `text` - Text to apply patch to.
    * `opts` - A options keyword list, `[]` to use the default options.

  Returns a tuple with two elements: the patched text, and a list of
  boolean values. Each boolean corresponds to a patch in the patchlist,
  and is `true` if a match was found for the corresponding patch.
  """
  @spec apply(patchlist(), String.t(), options()) :: {String.t(), list(boolean())}
  def apply([], text), do: {text, []}

  def apply(patches, text, opts \\ []) do
    opts = Options.valid_options!(opts)
    patch_margin = Keyword.fetch!(opts, :patch_margin)
    match_max_bits = Keyword.fetch!(opts, :match_max_bits)
    {patches, null_padding} = add_padding(patches, patch_margin)
    text = null_padding <> text <> null_padding

    patches =
      split_max(
        patches,
        patch_margin,
        match_max_bits
      )

    {results, text, _delta, _opts} =
      Enum.reduce(
        patches,
        {[], text, 0, opts},
        fn patch, acc -> apply_loop(patch, acc) end
      )

    # Strip the padding off.
    null_padding_length = String.length(null_padding)
    text = substring(text, null_padding_length, String.length(text) - null_padding_length)

    {text, Enum.reverse(results)}
  end

  @type apply_loop_acc() ::
          {list(boolean()), String.t(), integer(), options()}

  defp apply_loop(
         patch,
         {results, text, delta, opts}
       ) do
    expected_loc = patch.start2 + delta
    text1 = Diff.text1(patch.diffs)

    {start_loc, end_loc} = find_expected_match(text, text1, expected_loc, opts)

    if start_loc == -1 do
      # No match found.  :(
      # Subtract the delta for this failed patch from subsequent patches.
      delta = delta - (patch.length2 - patch.length1)
      {[false | results], text, delta, opts}
    else
      # Found a match.  :)
      text2 =
        if end_loc == -1 do
          substring(text, start_loc, start_loc + String.length(text1))
        else
          match_max_bits = Keyword.fetch!(opts, :match_max_bits)
          substring(text, start_loc, end_loc + match_max_bits)
        end

      {found, text} = apply_at_match(patch, text, text1, text2, start_loc, opts)
      delta = start_loc - expected_loc
      {[found | results], text, delta, opts}
    end
  end

  defp find_expected_match(text, text1, expected_loc, opts) do
    text1_length = String.length(text1)
    match_max_bits = Keyword.fetch!(opts, :match_max_bits)

    if text1_length > match_max_bits do
      # split_max will only provide an oversized pattern in the case of
      # a monster delete.
      start_loc =
        Match.main(
          text,
          substring(text1, 0, match_max_bits),
          expected_loc,
          opts
        )

      if start_loc == -1 do
        {-1, -1}
      else
        end_loc =
          Match.main(
            text,
            substring(text1, text1_length - match_max_bits),
            expected_loc + text1_length - match_max_bits,
            opts
          )

        if end_loc == -1 || start_loc >= end_loc do
          # Can't find valid trailing context.  Drop this patch.
          {-1, end_loc}
        else
          {start_loc, end_loc}
        end
      end
    else
      {Match.main(text, text1, expected_loc, opts), -1}
    end
  end

  defp apply_at_match(patch, text, text1, text2, start_loc, _opts) when text1 == text2 do
    # Perfect match, just shove the replacement text in.
    {true,
     substring(text, 0, start_loc) <>
       Diff.text2(patch.diffs) <>
       substring(text, start_loc + String.length(text1))}
  end

  defp apply_at_match(patch, text, text1, text2, start_loc, opts) do
    # Imperfect match.  Run a diff to get a framework of equivalent indices.
    diffs = Diff.main_(text1, text2, false, opts)

    if bad_match?(diffs, text1, opts) do
      # The end points match, but the content is unacceptably bad.
      {false, text}
    else
      diffs = Diff.cleanup_semantic_lossless(diffs)

      {acc_text, _index1} =
        Enum.reduce(patch.diffs, {text, 0}, fn diff, {acc_text, index1} ->
          apply_match_diff(diff, acc_text, index1, diffs, start_loc)
        end)

      {true, acc_text}
    end
  end

  # Returns true if the end points match, but the content is unacceptably bad.
  def bad_match?(diffs, text1, opts) do
    text1_length = String.length(text1)

    if text1_length > Keyword.fetch!(opts, :match_max_bits) do
      normalized_lev = Diff.levenshtein(diffs) / text1_length
      normalized_lev > Keyword.fetch!(opts, :patch_delete_threshold)
    else
      false
    end
  end

  def apply_match_diff({op, first_text}, acc_text, index1, diffs, start_loc) do
    dtext_length = String.length(first_text)

    case op do
      :equal ->
        {acc_text, index1 + dtext_length}

      :insert ->
        # Insertion
        index2 = Diff.x_index(diffs, index1)

        acc_text =
          substring(acc_text, 0, start_loc + index2) <>
            first_text <>
            substring(acc_text, start_loc + index2)

        {acc_text, index1 + dtext_length}

      :delete ->
        # Deletion
        index2 = Diff.x_index(diffs, index1)
        index3 = Diff.x_index(diffs, index1 + dtext_length)

        acc_text =
          substring(acc_text, 0, start_loc + index2) <>
            substring(acc_text, start_loc + index3)

        {acc_text, index1}

      _ ->
        raise RuntimeError, "Invalid operation #{inspect(op)}"
    end
  end

  @doc """
  Add some padding on text start and end so that edges can match something.

  Intended to be called only from within `Patch.apply`.

    * `patches` - A patchlist..
    * `patch_margin` - Chunk size for context length.

  Returns a tuple of the padded patchlist and the padding string added to each side.
  """
  @spec add_padding(patchlist(), non_neg_integer()) :: {patchlist(), String.t()}
  def add_padding([], _patch_margin), do: {[], ""}

  def add_padding(patches, patch_margin) do
    padding_length = patch_margin
    null_padding = Enum.reduce(1..padding_length, "", fn x, s -> s <> to_string([x]) end)

    # Bump all the patches forward.
    patches =
      Enum.map(patches, fn patch ->
        %Patch{
          patch
          | start1: patch.start1 + padding_length,
            start2: patch.start2 + padding_length
        }
      end)

    # Separate first and last patch
    {first_patch, mid_patches, last_patch} = split_patches(patches)

    # Add padding before and after
    first_patch = pad_first_patch(first_patch, null_padding)
    # Sometimes the first patch and the last patch are the same
    if is_nil(last_patch) do
      first_patch = pad_last_patch(first_patch, null_padding)
      {[first_patch], null_padding}
    else
      # Recombine
      last_patch = pad_last_patch(last_patch, null_padding)
      {[first_patch | mid_patches] ++ [last_patch], null_padding}
    end
  end

  # Separate first and last patch
  defp split_patches([one_patch]), do: {one_patch, [], nil}
  defp split_patches([first_patch, last_patch]), do: {first_patch, [], last_patch}

  defp split_patches([first_patch | mid_patches]) do
    {last_patch, mid_patches} = List.pop_at(mid_patches, -1)
    {first_patch, mid_patches, last_patch}
  end

  # Add some padding on start of first diff.
  defp pad_first_patch(first_patch, null_padding) do
    padding_length = String.length(null_padding)
    first_diff = List.first(first_patch.diffs)

    {op, text} = Diff.undiff(first_diff)

    if op == :equal do
      text_length = String.length(text)

      if padding_length > text_length do
        # Grow first equality.
        extra_length = padding_length - text_length
        text = substring(null_padding, text_length) <> text
        first_diff = {op, text}

        %Patch{
          first_patch
          | diffs: [first_diff | Enum.drop(first_patch.diffs, 1)],
            start1: first_patch.start1 - extra_length,
            start2: first_patch.start2 - extra_length,
            length1: first_patch.length1 + extra_length,
            length2: first_patch.length2 + extra_length
        }
      else
        first_patch
      end
    else
      # :nil, :insert, or :delete
      # Add null_padding equality.
      # start1 and start2 should be 0.
      %Patch{
        first_patch
        | diffs: [{:equal, null_padding} | first_patch.diffs],
          start1: first_patch.start1 - padding_length,
          start2: first_patch.start2 - padding_length,
          length1: first_patch.length1 + padding_length,
          length2: first_patch.length2 + padding_length
      }
    end
  end

  # Add some padding on end of last diff.
  defp pad_last_patch(last_patch, null_padding) do
    padding_length = String.length(null_padding)
    last_diff = List.last(last_patch.diffs)

    {op, text} = Diff.undiff(last_diff)

    if op == :equal do
      text_length = String.length(text)

      if padding_length > text_length do
        # Grow last equality.
        extra_length = padding_length - text_length
        last_diff = {op, text <> substring(null_padding, 0, extra_length)}

        %Patch{
          last_patch
          | diffs: Enum.drop(last_patch.diffs, -1) ++ [last_diff],
            length1: last_patch.length1 + extra_length,
            length2: last_patch.length2 + extra_length
        }
      else
        last_patch
      end
    else
      # :nil, :insert, or :delete
      # Add null_padding equality.
      %Patch{
        last_patch
        | diffs: last_patch.diffs ++ [{:equal, null_padding}],
          length1: last_patch.length1 + padding_length,
          length2: last_patch.length2 + padding_length
      }
    end
  end

  @doc """
  Look through the patches and break up any which are longer than the
  maximum limit of the match algorithm.

  Intended to be called only from within `Patch.apply`.

    * `patches` - A patchlist.
    * `patch_margin` - Chunk size for context length.
    * `match_max_bits` - The number of bits in an int (default 32).

  Returns the updated patchlist.
  """
  @spec split_max(patchlist(), non_neg_integer(), non_neg_integer()) :: patchlist()
  def split_max(patches, patch_margin, match_max_bits \\ 32) do
    if match_max_bits <= patch_margin do
      patches
    else
      patches
      |> Cursor.from_list(position: 0)
      |> split_max_loop(patch_margin, match_max_bits)
    end
  end

  defp split_max_loop(%Cursor{current: nil} = patches, _patch_margin, _patch_size),
    do: Cursor.to_list(patches)

  defp split_max_loop(%Cursor{current: bigpatch} = patches, patch_margin, patch_size) do
    patches =
      if bigpatch.length1 > patch_size do
        # Remove the big old patch.
        patches
        |> Cursor.delete(1)
        |> Cursor.move_back()
        |> create_subpatches(
          bigpatch.diffs,
          {"", bigpatch.start1, bigpatch.start2, patch_margin, patch_size}
        )
      else
        Cursor.move_forward(patches)
      end

    split_max_loop(patches, patch_margin, patch_size)
  end

  defp create_subpatches(patches, [], _acc), do: patches

  defp create_subpatches(
         patches,
         bigpatch_diffs,
         {precontext, start1, start2, patch_margin, patch_size}
       ) do
    precontext_length = String.length(precontext)
    # Create one of several smaller patches.
    patch = %Patch{start1: start1 - precontext_length, start2: start2 - precontext_length}

    patch =
      if precontext_length != 0 do
        %Patch{
          patch
          | diffs: [{:equal, precontext}],
            length1: precontext_length,
            length2: precontext_length
        }
      else
        patch
      end

    {bigpatch_diffs, patch, {start1, start2, empty, _patch_margin, _patch_size}} =
      subpatch_loop(bigpatch_diffs, patch, {start1, start2, true, patch_margin, patch_size})

    # Compute the head context for the next patch.
    precontext = Diff.text2(patch.diffs)
    precontext = substring(precontext, max(0, String.length(precontext) - patch_margin))

    # Append the end context for this patch.
    text1 = Diff.text1(bigpatch_diffs)

    postcontext =
      if String.length(text1) > patch_margin do
        substring(text1, 0, patch_margin)
      else
        text1
      end

    patch =
      if postcontext != "" do
        postcontext_length = String.length(postcontext)
        last_diff = List.last(patch.diffs)
        {op, text} = Diff.undiff(last_diff)

        patch_diffs =
          if op == :equal do
            Enum.drop(patch.diffs, -1) ++ [{:equal, text <> postcontext}]
          else
            # :nil, :insert, or :delete
            patch.diffs ++ [{:equal, postcontext}]
          end

        %Patch{
          patch
          | diffs: patch_diffs,
            length1: patch.length1 + postcontext_length,
            length2: patch.length2 + postcontext_length
        }
      else
        patch
      end

    patches =
      if empty do
        patches
      else
        patches
        |> Cursor.move_forward()
        |> Cursor.insert_before([patch])
      end

    # Call again, without advancing
    create_subpatches(
      patches,
      bigpatch_diffs,
      {precontext, start1, start2, patch_margin, patch_size}
    )
  end

  def subpatch_loop([], patch, acc), do: {[], patch, acc}

  def subpatch_loop(
        bigpatch_diffs,
        %Patch{length1: length1} = patch,
        {_start1, _start2, _empty, patch_margin, patch_size} = acc
      )
      when length1 >= patch_size - patch_margin do
    {bigpatch_diffs, patch, acc}
  end

  def subpatch_loop([first_diff | rest], patch, acc) do
    {bigpatch_diffs, patch, acc} = add_diff_to_subpatch(first_diff, rest, patch, acc)
    subpatch_loop(bigpatch_diffs, patch, acc)
  end

  def add_diff_to_subpatch(
        {:insert, first_text} = first_diff,
        rest,
        patch,
        {start1, start2, _empty, patch_margin, patch_size}
      ) do
    # Insertions are harmless.
    text_length = String.length(first_text)

    patch = %Patch{
      patch
      | diffs: patch.diffs ++ [first_diff],
        length2: patch.length2 + text_length
    }

    {rest, patch, {start1, start2 + text_length, false, patch_margin, patch_size}}
  end

  # If the patch is just one equality
  # and we have a large deletion...
  def add_diff_to_subpatch(
        {:delete, first_text} = first_diff,
        rest,
        %Patch{diffs: [{:equal, _}]} = patch,
        {start1, start2, _empty, patch_margin, patch_size} = acc
      ) do
    text_length = String.length(first_text)

    if text_length > 2 * patch_size do
      # This is a large deletion.  Let it pass in one chunk.
      patch = %Patch{
        patch
        | diffs: patch.diffs ++ [first_diff],
          length1: patch.length1 + text_length
      }

      {rest, patch, {start1 + text_length, start2, false, patch_margin, patch_size}}
    else
      # Handle same as :equal
      add_other_diff_to_subpatch(first_diff, rest, patch, acc)
    end
  end

  # Equality, or deletion when patch is not a single equality
  def add_diff_to_subpatch(first_diff, rest, patch, acc) do
    add_other_diff_to_subpatch(first_diff, rest, patch, acc)
  end

  # Equality, or small deletion
  def add_other_diff_to_subpatch(
        {first_op, first_text},
        rest,
        patch,
        {start1, start2, empty, patch_margin, patch_size}
      ) do
    # Deletion or equality.  Only take as much as we can stomach.
    diff_text =
      substring(
        first_text,
        0,
        patch_size - patch.length1 - patch_margin
      )

    text_length = String.length(diff_text)
    patch = %Patch{patch | length1: patch.length1 + text_length}
    start1 = start1 + text_length

    {patch, start2, empty} =
      if first_op == :equal do
        {%Patch{patch | length2: patch.length2 + text_length}, start2 + text_length, empty}
      else
        {patch, start2, false}
      end

    patch = %Patch{patch | diffs: patch.diffs ++ [{first_op, diff_text}]}

    bigpatch_diffs =
      if diff_text == first_text do
        rest
      else
        first_text = substring(first_text, text_length)
        [{first_op, first_text} | rest]
      end

    {bigpatch_diffs, patch, {start1, start2, empty, patch_margin, patch_size}}
  end

  @doc """
  Return the textual representation of a patchlist.
  """
  def to_text(patches) do
    Enum.reduce(patches, "", fn patch, acc -> acc <> to_string(patch) end)
  end

  @header_regex ~r/^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@$/

  @doc """
  Parse a textual representation of patches and return a patchlist.

  Raises an `ArgumentError` if the text has invalid contents.
  """
  @spec from_text(String.t()) :: patchlist()
  def from_text(""), do: []

  def from_text(text) do
    text
    |> String.split("\n")
    |> Cursor.from_list(position: 0)
    |> parse_patch_header([])
    |> Enum.reverse()
  end

  @spec parse_patch_header(Cursor.t(), patchlist()) :: patchlist()
  defp parse_patch_header(%Cursor{current: line} = lines, patches) do
    case line do
      nil ->
        patches

      "" ->
        Cursor.move_forward(lines)
        |> parse_patch_header(patches)

      _ ->
        case Regex.run(@header_regex, line) do
          nil ->
            raise ArgumentError, "Invalid patch header: #{line}"

          groups ->
            {start1, length1} =
              parse_start_length(Enum.at(groups, 1), Enum.at(groups, 2), line, "1")

            {start2, length2} =
              parse_start_length(Enum.at(groups, 3), Enum.at(groups, 4), line, "2")

            {lines, diffs} =
              lines
              |> Cursor.move_forward()
              |> parse_patch_body([])

            parse_patch_header(
              lines,
              [
                %Patch{
                  diffs: Enum.reverse(diffs),
                  start1: start1,
                  start2: start2,
                  length1: length1,
                  length2: length2
                }
                | patches
              ]
            )
        end
    end
  end

  defp parse_start_length(s1, s2, line, suffix) do
    start =
      case Integer.parse(s1) do
        {start, ""} -> start
        _ -> raise ArgumentError, "Invalid patch header (start#{suffix}): #{line}"
      end

    case s2 do
      "" ->
        {start - 1, 1}

      "0" ->
        {start, 0}

      _ ->
        case Integer.parse(s2) do
          {length, ""} -> {start - 1, length}
          _ -> raise ArgumentError, "Invalid patch header (length#{suffix}): #{line}"
        end
    end
  end

  @spec parse_patch_body(Cursor.t(), Diff.difflist()) :: {Cursor.t(), Diff.difflist()}
  defp parse_patch_body(%Cursor{current: nil} = lines, diffs), do: {lines, diffs}

  defp parse_patch_body(%Cursor{current: ""} = lines, diffs) do
    Cursor.move_forward(lines)
    |> parse_patch_body(diffs)
  end

  defp parse_patch_body(%Cursor{current: line} = lines, diffs) do
    diff = parse_patch_diff(line)

    if is_nil(diff) do
      {lines, diffs}
    else
      Cursor.move_forward(lines)
      |> parse_patch_body([diff | diffs])
    end
  end

  defp parse_patch_diff(line) do
    {sign, text} = String.split_at(line, 1)
    # Decode would change all "+" to " "
    text = text |> String.replace("+", "%2B") |> URI.decode()

    case sign do
      "-" ->
        # Deletion.
        {:delete, text}

      "+" ->
        # Insertion.
        {:insert, text}

      " " ->
        # Minor equality.
        {:equal, text}

      "@" ->
        nil

      _ ->
        # WTF?
        raise ArgumentError, "Invalid patch mode '#{sign}' in: #{line}"
    end
  end
end