"""Apply patches to a parsed json document.

Add { op: "add", path: "/a/b/c", value: v }
  Add value v to /a/b at index c. If /a/b is a list, c must be an integer, and
  v will be inserted at c. To append a value to list, use a trailing / in the
  path.

Remove { op: "remove", path: "/a/b/c" }
  Remove the key c from dictionary /a/b, or the item at index c from list /a/b.

Replace { op: "replace", path: "/a/b/c", value: v, src: k }
  Replace the item at c on /a/b with v. If check is specified, the patch will
  fail if the existing value at c is not k.

Merge { op: "merge", path: "/a/b/c", value: { k0: v0, k1: v1, ..., kn: vn } }
  This will create or replace all k0 - kn on c. It can be used to group
  multiple adds and replaces, or can act as an upsert.

Copy { op: "copy", path: "/b/c/d", src: "/a/b/c" }
  Copy the value at /a/b/c to d on /b/c.

Move { op: "move", path: "/b/c/d", src: "/a/b/c" }
  Equivalent to a copy from 'src' to 'path' followed by a remove at 'path'.

Test { op: "test", path: "/a/b/c" [, value: v] }
  Test that the index c on /a/b is specified. If value is given, check
  additionally that the value at c on /a/b equals v

Set Remove { op: "setremove", path: "/a/b", value: v }
  Remove v from the list at /a/b, if v is in /a/b.

Set Add { op: "setadd", path: "/a/b", value: v }
  Add v to the list at /a/b if it doesn't already exist in the list.

All paths must conform to RFC 6901.

Inspiration from http://jsonpatch.com with additional features to handle
certain types of concurrent editing.
"""

from collections import namedtuple
from . import try_parse_int


_NO_VAL = object()
_PATH_SEP = "/"
_Patch = namedtuple("Patch", ["op", "path", "value", "src"])


def Patch(op, path, value=_NO_VAL, src=_NO_VAL):
  return _Patch(op=op, path=path, value=value, src=src)


def add(parent, idx, value):
  if isinstance(parent, dict):
    if idx in parent:
      raise Exception("Item already exists")
    parent[idx] = value
  elif isinstance(parent, list):
    if idx == "" or idx == "~":
      parent.append(value)
    else:
      parent.insert(int(idx), value)
  else:
    raise Exception("Invalid path for operation")


def remove(parent, idx):
  if isinstance(parent, dict):
    del parent[idx]
  elif isinstance(parent, list):
    del parent[int(idx)]
  else:
    raise Exception("Invalid path for operation")


def replace(parent, idx, value, checkValue=_NO_VAL):
  if isinstance(parent, dict):
    if idx not in parent:
      raise Exception("Item does not exist")
  elif isinstance(parent, list):
    idx = int(idx)
    if idx < 0 or idx >= len(parent):
      raise Exception("List index out of range")
  if checkValue is not _NO_VAL:
    if parent[idx] != checkValue:
      raise Exception("Check value did not pass")
  parent[idx] = value


def merge(parent, idx, value):
  t = get_child(parent, idx)
  for k, v in value.items():
    t[k] = v


def copy(src_parent, src_idx, dest_parent, dest_idx):
  if isinstance(dest_parent, list):
    dest_idx = int(dest_idx)
  dest_parent[dest_idx] = get_child(src_parent, src_idx)


def move(src_parent, src_idx, dest_parent, dest_idx):
  copy(src_parent, src_idx, dest_parent, dest_idx)
  remove(src_parent, src_idx)


def test(parent, idx, value=_NO_VAL):
  try:
    v = get_child(parent, idx)
  except:
    return False

  if value is _NO_VAL:
    return True
  return v == value


def set_remove(parent, idx, value):
  l = get_child(parent, idx)
  if value in l:
    l.remove(value)


def set_add(parent, idx, value):
  l = get_child(parent, idx)
  if value not in l:
    l.append(value)


def get_children(parent, idx):
  if isinstance(parent, dict):
    if idx in parent:
      yield parent[idx]
    else:
      raise Exception("Invalid path at {0}".format(idx))

  elif isinstance(parent, list):
    if idx == "*":
      yield from parent
    else:
      is_int, i = try_parse_int(idx)

      if is_int and i >= 0 and i < len(parent):
        yield parent[i]
      else:
        raise Exception("Invalid list index: {0}".format(i))

  else:
    raise Exception("Type {0} does not have children".format(type(parent).__name__))


def get_child(parent, idx):
  return next(get_children(parent, idx))


def parse_path(path):
  if not path:
    raise ValueError("Invalid path")

  if isinstance(path, str):
    if path == "/":
      raise ValueError("Invalid path")
    if path[0] != "/":
      raise ValueError("Invalid path")
    return path.split(_PATH_SEP)[1:]
  elif isinstance(path, (tuple, list)):
    return path
  else:
    raise ValueError("A path must be a string, tuple or list")


def resolve_path(root, path):
  path = parse_path(path)

  parent = root

  for p in path[:-1]:
    parent = get_child(parent, rfc_6901_replace(p))

  return (parent, rfc_6901_replace(path[-1]))


def find(root, path):
  return get_child(*resolve_path(root, path))


def find_all(root, path):
  path = parse_path(path)

  if len(path) == 1:
    yield from get_children(root, path[0])
  else:
    for child in get_children(root, path[0]):
      yield from find_all(child, path[1:])


def rfc_6901_replace(p):
  return p.replace("~1", "/").replace("~0", "~")


def apply_patch(document, patch):
  op = patch.op
  parent, idx = resolve_path(document, patch.path)
  if op == "add":
    return add(parent, idx, patch.value)
  elif op == "remove":
    return remove(parent, idx)
  elif op == "replace":
    return replace(parent, idx, patch.value, patch.src)
  elif op == "merge":
    return merge(parent, idx, patch.value)
  elif op == "copy":
    sparent, sidx = resolve_path(document, patch.src)
    return copy(sparent, sidx, parent, idx)
  elif op == "move":
    sparent, sidx = resolve_path(document, patch.src)
    return move(sparent, sidx, parent, idx)
  elif op == "test":
    return test(parent, idx, patch.value)
  elif op == "setremove":
    return set_remove(parent, idx, patch.value)
  elif op == "setadd":
    return set_add(parent, idx, patch.value)
  else:
    raise Exception("Invalid operator")


def apply_patches(document, patches):
  for i, patch in enumerate(patches):
    try:
      result = apply_patch(document, patch)
      if patch.op == "test" and result is False:
        raise Exception("Test patch {0} failed. Cancelling entire set.".format(i + 1))
    except Exception as ex:
      raise Exception("An error occurred with patch {0}: {1}".format(i + 1, ex)) from ex
