export type JsonPatchOperation =
  | { op: "add" | "replace"; path: string; value: unknown }
  | { op: "remove"; path: string };

export function applyPatch<T>(document: T, patch: JsonPatchOperation[]): T {
  let result: unknown = cloneJson(document);
  for (const operation of patch) {
    const tokens = parsePointer(operation.path);
    if (tokens.length === 0) {
      if (operation.op === "remove") {
        result = undefined;
      } else {
        result = cloneJson(operation.value);
      }
      continue;
    }

    const parent = resolveParent(result, tokens);
    const key = tokens[tokens.length - 1]!;
    if (operation.op === "remove") {
      removeValue(parent, key);
    } else {
      setValue(parent, key, cloneJson(operation.value), operation.op);
    }
  }
  return result as T;
}

function parsePointer(path: string): string[] {
  if (path === "") return [];
  if (!path.startsWith("/")) {
    throw new Error(`Invalid JSON pointer ${path}`);
  }
  return path.slice(1).split("/").map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"));
}

function resolveParent(root: unknown, tokens: string[]): unknown {
  let current = root;
  for (const token of tokens.slice(0, -1)) {
    if (Array.isArray(current)) {
      const index = numericIndex(token, current.length);
      current = current[index];
    } else if (isRecord(current)) {
      current = current[token];
    } else {
      throw new Error(`Cannot resolve JSON pointer through ${token}`);
    }
  }
  return current;
}

function setValue(parent: unknown, key: string, value: unknown, op: "add" | "replace"): void {
  if (Array.isArray(parent)) {
    if (key === "-" && op === "add") {
      parent.push(value);
      return;
    }
    const index = numericIndex(key, op === "add" ? parent.length + 1 : parent.length);
    if (op === "add") {
      parent.splice(index, 0, value);
    } else {
      parent[index] = value;
    }
    return;
  }
  if (isRecord(parent)) {
    if (op === "replace" && !(key in parent)) {
      throw new Error(`Cannot replace missing key ${key}`);
    }
    parent[key] = value;
    return;
  }
  throw new Error(`Cannot ${op} JSON pointer target ${key}`);
}

function removeValue(parent: unknown, key: string): void {
  if (Array.isArray(parent)) {
    const index = numericIndex(key, parent.length);
    parent.splice(index, 1);
    return;
  }
  if (isRecord(parent)) {
    if (!(key in parent)) {
      throw new Error(`Cannot remove missing key ${key}`);
    }
    delete parent[key];
    return;
  }
  throw new Error(`Cannot remove JSON pointer target ${key}`);
}

function numericIndex(token: string, maxExclusive: number): number {
  if (!/^(0|[1-9][0-9]*)$/.test(token)) {
    throw new Error(`Invalid array index ${token}`);
  }
  const index = Number(token);
  if (index < 0 || index >= maxExclusive) {
    throw new Error(`Array index ${index} is out of bounds`);
  }
  return index;
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function cloneJson<T>(value: T): T {
  return value === undefined ? value : JSON.parse(JSON.stringify(value)) as T;
}
