lib/jsonpatch.ex

defmodule Jsonpatch do
  @moduledoc """
  A implementation of [RFC 6902](https://tools.ietf.org/html/rfc6902) in pure Elixir.

  The patch can be a single change or a list of things that shall be changed. Therefore
  a list or a single JSON patch can be provided. Every patch belongs to a certain operation
  which influences the usage.

  According to [RFC 6901](https://tools.ietf.org/html/rfc6901) escaping of `/` and `~` is done
  by using `~1` for `/` and `~0` for `~`.
  """

  alias Jsonpatch.Types
  alias Jsonpatch.Operation.{Add, Copy, Move, Remove, Replace, Test}

  @typedoc """
  A valid Jsonpatch operation by RFC 6902
  """
  @type t :: map() | Add.t() | Remove.t() | Replace.t() | Copy.t() | Move.t() | Test.t()

  @doc """
  Apply a Jsonpatch or a list of Jsonpatches to a map or struct. The whole patch will not be applied
  when any path is invalid or any other error occured. When a list is provided, the operations are
  applied in the order as they appear in the list.

  Atoms are never garbage collected. Therefore, `Jsonpatch` works by default only with maps
  which used binary strings as key. This behaviour can be controlled via the `:keys` option.

  ## Examples
      iex> patch = [
      ...> %{op: "add", path: "/age", value: 33},
      ...> %{op: "replace", path: "/hobbies/0", value: "Elixir!"},
      ...> %{op: "replace", path: "/married", value: true},
      ...> %{op: "remove", path: "/hobbies/2"},
      ...> %{op: "remove", path: "/hobbies/1"},
      ...> %{op: "copy", from: "/name", path: "/surname"},
      ...> %{op: "move", from: "/home", path: "/work"},
      ...> %{op: "test", path: "/name", value: "Bob"}
      ...> ]
      iex> target = %{"name" => "Bob", "married" => false, "hobbies" => ["Sport", "Elixir", "Football"], "home" => "Berlin"}
      iex> Jsonpatch.apply_patch(patch, target)
      {:ok, %{"name" => "Bob", "married" => true, "hobbies" => ["Elixir!"], "age" => 33, "surname" => "Bob", "work" => "Berlin"}}

      iex> # Patch will not be applied if test fails. The target will not be changed.
      iex> patch = [
      ...> %{op: "add", path: "/age", value: 33},
      ...> %{op: "test", path: "/name", value: "Alice"}
      ...> ]
      iex> target = %{"name" => "Bob", "married" => false, "hobbies" => ["Sport", "Elixir", "Football"], "home" => "Berlin"}
      iex> Jsonpatch.apply_patch(patch, target)
      {:error, %Jsonpatch.Error{patch: %{"op" => "test", "path" => "/name", "value" => "Alice"}, patch_index: 1, reason: {:test_failed, "Expected value '\\"Alice\\"' at '/name'"}}}

      iex> # Patch will succeed, not applying invalid path operations.
      iex> patch = [
      ...> %{op: "replace", path: "/name", value: "Alice"},
      ...> %{op: "replace", path: "/age", value: 42}
      ...> ]
      iex> target = %{"name" => "Bob"} # No age in target
      iex> Jsonpatch.apply_patch(patch, target, ignore_invalid_paths: true)
      {:ok, %{"name" => "Alice"}}
  """
  @spec apply_patch(t() | [t()], target :: Types.json_container(), Types.opts()) ::
          {:ok, Types.json_container()} | {:error, Jsonpatch.Error.t()}
  def apply_patch(json_patch, target, opts \\ []) do
    # https://datatracker.ietf.org/doc/html/rfc6902#section-3
    # > Operations are applied sequentially in the order they appear in the array.
    {ignore_invalid_paths?, opts} = Keyword.pop(opts, :ignore_invalid_paths, false)

    json_patch
    |> List.wrap()
    |> Enum.with_index()
    |> Enum.reduce_while({:ok, target}, fn {patch, patch_index}, {:ok, acc} ->
      patch = cast_to_op_map(patch)

      do_apply_patch(patch, acc, opts)
      |> handle_patch_result(acc, patch, patch_index, ignore_invalid_paths?)
    end)
  end

  defp handle_patch_result(result, acc, patch, patch_index, ignore_invalid_paths?) do
    case result do
      {:error, {error, _} = reason} ->
        if ignore_invalid_paths? && error == :invalid_path do
          {:cont, {:ok, acc}}
        else
          error = %Jsonpatch.Error{patch: patch, patch_index: patch_index, reason: reason}
          {:halt, {:error, error}}
        end

      {:ok, res} ->
        {:cont, {:ok, res}}
    end
  end

  defp cast_to_op_map(%struct_mod{} = json_patch) do
    json_patch =
      json_patch
      |> Map.from_struct()

    op =
      case struct_mod do
        Add -> "add"
        Remove -> "remove"
        Replace -> "replace"
        Copy -> "copy"
        Move -> "move"
        Test -> "test"
      end

    json_patch = Map.put(json_patch, "op", op)

    cast_to_op_map(json_patch)
  end

  defp cast_to_op_map(json_patch) do
    Map.new(json_patch, fn {k, v} -> {to_string(k), v} end)
  end

  defp do_apply_patch(%{"op" => "add", "path" => path, "value" => value}, target, opts) do
    Add.apply(%Add{path: path, value: value}, target, opts)
  end

  defp do_apply_patch(%{"op" => "remove", "path" => path}, target, opts) do
    Remove.apply(%Remove{path: path}, target, opts)
  end

  defp do_apply_patch(%{"op" => "replace", "path" => path, "value" => value}, target, opts) do
    Replace.apply(%Replace{path: path, value: value}, target, opts)
  end

  defp do_apply_patch(%{"op" => "copy", "from" => from, "path" => path}, target, opts) do
    Copy.apply(%Copy{from: from, path: path}, target, opts)
  end

  defp do_apply_patch(%{"op" => "move", "from" => from, "path" => path}, target, opts) do
    Move.apply(%Move{from: from, path: path}, target, opts)
  end

  defp do_apply_patch(%{"op" => "test", "path" => path, "value" => value}, target, opts) do
    Test.apply(%Test{path: path, value: value}, target, opts)
  end

  defp do_apply_patch(json_patch, _target, _opts) do
    {:error, {:invalid_spec, json_patch}}
  end

  @doc """
  Apply a Jsonpatch or a list of Jsonpatches to a map or struct. In case of an error
  it will raise an exception. When a list is provided, the operations are applied in
  the order as they appear in the list.

  (See Jsonpatch.apply_patch/2 for more details)
  """
  @spec apply_patch!(t() | list(t()), target :: Types.json_container(), Types.opts()) ::
          Types.json_container()
  def apply_patch!(json_patch, target, opts \\ []) do
    case apply_patch(json_patch, target, opts) do
      {:ok, patched} -> patched
      {:error, _} = error -> raise JsonpatchException, error
    end
  end

  @doc """
  Creates a patch from the difference of a source map to a destination map or list.

  ## Options

    * `:ancestor_path` - Sets the initial ancestor path for the diff operation.
      Defaults to `""` (root). Useful when you need to diff starting from a nested path.
    * `:prepare_map` - A function that lets to customize maps and structs before diffing.
      Defaults to `fn map -> map end` (no-op). Useful when you need to customize
      how maps and structs are handled during the diff process. Example:

      ```elixir
      fn
        %Struct{field1: value1, field2: value2} -> %{field1: "\#{value1} - \#{value2}"}
        %OtherStruct{} = struct -> Map.take(struct, [:field1])
        map -> map
      end
      ```

  ## Examples

      iex> source = %{"name" => "Bob", "married" => false, "hobbies" => ["Elixir", "Sport", "Football"]}
      iex> destination = %{"name" => "Bob", "married" => true, "hobbies" => ["Elixir!"], "age" => 33}
      iex> Jsonpatch.diff(source, destination)
      [
        %{path: "/married", value: true, op: "replace"},
        %{path: "/hobbies/2", op: "remove"},
        %{path: "/hobbies/1", op: "remove"},
        %{path: "/hobbies/0", value: "Elixir!", op: "replace"},
        %{path: "/age", value: 33, op: "add"}
      ]

      iex> source = %{"a" => 1, "b" => 2}
      iex> destination = %{"a" => 3, "c" => 4}
      iex> Jsonpatch.diff(source, destination, ancestor_path: "/nested")
      [
        %{path: "/nested/b", op: "remove"},
        %{path: "/nested/c", value: 4, op: "add"},
        %{path: "/nested/a", value: 3, op: "replace"}
      ]
  """
  @spec diff(Types.json_container(), Types.json_container(), Types.opts_diff()) :: [Jsonpatch.t()]
  def diff(source, destination, opts \\ []) do
    opts =
      opts
      |> Keyword.update(:object_hash, nil, &make_safe_hash_fn/1)
      |> Keyword.validate!(
        ancestor_path: "",
        prepare_map: &Function.identity/1,
        object_hash: nil
      )

    do_diff(destination, source, opts[:ancestor_path], nil, [], opts)
  end

  defguardp are_unequal_maps(val1, val2) when val1 != val2 and is_map(val2) and is_map(val1)
  defguardp are_unequal_lists(val1, val2) when val1 != val2 and is_list(val2) and is_list(val1)

  defp do_diff(dest, source, path, key, patches, opts) when are_unequal_lists(dest, source) do
    # uneqal lists, let's use a specialized function for that
    do_list_diff(dest, source, join_key(path, key), patches, opts)
  end

  defp do_diff(dest, source, path, key, patches, opts) when are_unequal_maps(dest, source) do
    # Convert structs to maps if prepare_map function is provided
    dest = maybe_prepare_map(dest, opts)
    source = maybe_prepare_map(source, opts)

    if not is_map(dest) or not is_map(source) do
      # type changed, let's process it again
      do_diff(dest, source, path, key, patches, opts)
    else
      # uneqal maps, let's use a specialized function for that
      do_map_diff(dest, source, join_key(path, key), patches, opts)
    end
  end

  defp do_diff(dest, source, path, key, patches, opts) when dest != source do
    # scalar values or change of type (map -> list etc), let's just make a replace patch
    [%{op: "replace", path: join_key(path, key), value: maybe_prepare_map(dest, opts)} | patches]
  end

  defp do_diff(_dest, _source, _path, _key, patches, _opts) do
    # no changes, return patches as is
    patches
  end

  defp do_map_diff(%{} = destination, %{} = source, ancestor_path, patches, opts) do
    # entrypoint for map diff, let's convert the map to a list of {k, v} tuples
    destination
    |> Map.to_list()
    |> do_map_diff(source, ancestor_path, patches, [], opts)
  end

  defp do_map_diff([], source, ancestor_path, patches, checked_keys, _opts) do
    # The complete desination was check. Every key that is not in the list of
    # checked keys, must be removed.
    Enum.reduce(source, patches, fn {k, _}, patches ->
      if k in checked_keys do
        patches
      else
        [%{op: "remove", path: join_key(ancestor_path, k)} | patches]
      end
    end)
  end

  defp do_map_diff([{key, val} | rest], source, ancestor_path, patches, checked_keys, opts) do
    # normal iteration through list of map {k, v} tuples. We track seen keys to later remove not seen keys.
    patches =
      case Map.fetch(source, key) do
        {:ok, source_val} ->
          do_diff(val, source_val, ancestor_path, key, patches, opts)

        :error ->
          value = maybe_prepare_map(val, opts)
          [%{op: "add", path: join_key(ancestor_path, key), value: value} | patches]
      end

    # Diff next value of same level
    do_map_diff(rest, source, ancestor_path, patches, [key | checked_keys], opts)
  end

  defp do_list_diff(destination, source, ancestor_path, patches, opts) do
    if opts[:object_hash] do
      do_hash_list_diff(destination, source, ancestor_path, patches, opts)
    else
      do_pairwise_list_diff(destination, source, ancestor_path, patches, 0, opts)
    end
  catch
    # happens if we've got a nil hash or we tried to hash a non-map
    :hash_not_implemented ->
      do_pairwise_list_diff(destination, source, ancestor_path, patches, 0, opts)
  end

  defp do_pairwise_list_diff(destination, source, ancestor_path, patches, idx, opts)

  defp do_pairwise_list_diff([], [], _path, patches, _idx, _opts), do: patches

  defp do_pairwise_list_diff([], [_item | source_rest], ancestor_path, patches, idx, opts) do
    # if we find any leftover items in source, we have to remove them
    patches = [%{op: "remove", path: join_key(ancestor_path, idx)} | patches]
    do_pairwise_list_diff([], source_rest, ancestor_path, patches, idx + 1, opts)
  end

  defp do_pairwise_list_diff(items, [], ancestor_path, patches, idx, opts) do
    # we have to do it without recursion, because we have to keep the order of the items
    items
    |> Enum.map_reduce(idx, fn val, idx ->
      {%{op: "add", path: join_key(ancestor_path, idx), value: maybe_prepare_map(val, opts)},
       idx + 1}
    end)
    |> elem(0)
    |> Kernel.++(patches)
  end

  defp do_pairwise_list_diff(
         [val | rest],
         [source_val | source_rest],
         ancestor_path,
         patches,
         idx,
         opts
       ) do
    # case when there's an item in both desitation and source. Let's just compare them
    patches = do_diff(val, source_val, ancestor_path, idx, patches, opts)
    do_pairwise_list_diff(rest, source_rest, ancestor_path, patches, idx + 1, opts)
  end

  defp do_hash_list_diff(destination, source, ancestor_path, patches, opts) do
    hash_fn = Keyword.fetch!(opts, :object_hash)

    {additions, modifications, removals} =
      greedy_find_additions_modifications_removals(
        List.to_tuple(destination),
        List.to_tuple(source),
        index_by(destination, hash_fn),
        index_by(source, hash_fn),
        hash_fn,
        ancestor_path,
        opts
      )

    List.flatten([removals, additions, modifications, patches])
  end

  # credo:disable-for-next-line
  defp greedy_find_additions_modifications_removals(
         dest,
         source,
         dest_map,
         source_map,
         hash_fn,
         path,
         opts,
         dest_idx \\ 0,
         source_idx \\ 0,
         additions \\ [],
         modifications \\ [],
         removals \\ []
       ) do
    cond do
      tuple_size(dest) == dest_idx ->
        # we're at the end of the destination tuple, let's remove all remaining source items
        removals = add_removals(source_idx, tuple_size(source) - 1, path, removals)
        {Enum.reverse(additions), modifications, removals}

      tuple_size(source) == source_idx ->
        # we're at the end of the source tuple, let's add all remaining destination items
        additions = add_additions(dest_idx, tuple_size(dest) - 1, path, dest, additions, opts)
        {Enum.reverse(additions), modifications, removals}

      true ->
        # we're in the middle of the tuples, let's find the next matching items
        dest_item = elem(dest, dest_idx)
        source_item = elem(source, source_idx)

        source_hash = hash_fn.(source_item)
        dest_hash = hash_fn.(dest_item)

        if source_hash == dest_hash do
          # same items, let's diff recursively and bump both indexes
          modifications = do_diff(dest_item, source_item, path, dest_idx, modifications, opts)

          greedy_find_additions_modifications_removals(
            dest,
            source,
            dest_map,
            source_map,
            hash_fn,
            path,
            opts,
            dest_idx + 1,
            source_idx + 1,
            additions,
            modifications,
            removals
          )
        else
          # different items, let's find index of destination item in source and vice versa
          {next_dest_idx, next_source_idx} =
            determine_next_idx(
              dest_idx,
              source_idx,
              Map.get(dest_map, source_hash),
              Map.get(source_map, dest_hash)
            )

          removals = add_removals(source_idx, next_source_idx - 1, path, removals)
          additions = add_additions(dest_idx, next_dest_idx - 1, path, dest, additions, opts)

          greedy_find_additions_modifications_removals(
            dest,
            source,
            dest_map,
            source_map,
            hash_fn,
            path,
            opts,
            next_dest_idx,
            next_source_idx,
            additions,
            modifications,
            removals
          )
        end
    end
  end

  # credo:disable-for-next-line
  defp determine_next_idx(d_idx, s_idx, next_d_idx, next_s_idx) do
    dest_found = next_d_idx != nil and next_d_idx > d_idx
    source_found = next_s_idx != nil and next_s_idx > s_idx
    source_closer = dest_found and source_found and next_s_idx - s_idx < next_d_idx - d_idx

    cond do
      # in case when we can jump to either of them, we want to jump to the closer one
      source_closer -> {d_idx, next_s_idx}
      # only source is found ahead, we have to do source jump
      next_d_idx == nil and source_found -> {d_idx, next_s_idx}
      # only dest is found ahead, we have to do dest jump
      next_s_idx == nil and dest_found -> {next_d_idx, s_idx}
      # neither is found ahead, we have to advance both indexes
      true -> {d_idx + 1, s_idx + 1}
    end
  end

  @compile {:inline, index_by: 2}
  defp index_by(list, hash_fn) do
    list
    |> Enum.reduce({%{}, 0}, fn item, {map, idx} ->
      # if we have a hash collision, we throw an error and handle as if the hash is not implemented
      {Map.update(map, hash_fn.(item), idx, fn _ -> throw(:hash_not_implemented) end), idx + 1}
    end)
    |> elem(0)
  end

  @compile {:inline, add_removals: 4}
  defp add_removals(from_idx, to_idx, path, removals) do
    Enum.reduce(from_idx..to_idx//1, removals, fn idx, removals ->
      [%{op: "remove", path: join_key(path, idx)} | removals]
    end)
  end

  @compile {:inline, add_additions: 6}
  defp add_additions(from_idx, to_idx, path, dest_tuple, additions, opts) do
    Enum.reduce(from_idx..to_idx//1, additions, fn idx, additions ->
      value = dest_tuple |> elem(idx) |> maybe_prepare_map(opts)
      [%{op: "add", path: join_key(path, idx), value: value} | additions]
    end)
  end

  @compile {:inline, maybe_prepare_map: 2}
  defp maybe_prepare_map(value, opts) when is_map(value) do
    prepare_fn = Keyword.fetch!(opts, :prepare_map)
    prepare_fn.(value)
  end

  defp maybe_prepare_map(value, _opts), do: value

  @compile {:inline, escape: 1}
  defp escape(fragment) when is_binary(fragment) do
    fragment =
      if :binary.match(fragment, "~") != :nomatch,
        do: String.replace(fragment, "~", "~0"),
        else: fragment

    if :binary.match(fragment, "/") != :nomatch,
      do: String.replace(fragment, "/", "~1"),
      else: fragment
  end

  defp escape(fragment), do: fragment

  @compile {:inline, join_key: 2}
  defp join_key(path, nil), do: path
  defp join_key(path, key), do: "#{path}/#{escape(key)}"

  defp make_safe_hash_fn(hash_fn) do
    # we want to compare only maps, and returning nil should mean
    # we should compare lists pairwise instead
    fn
      %{} = item ->
        case hash_fn.(item) do
          nil -> throw(:hash_not_implemented)
          hash -> hash
        end

      _item ->
        throw(:hash_not_implemented)
    end
  end
end