/*
 Copyright 2019 Florian Dold

 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at

 http://www.apache.org/licenses/LICENSE-2.0

 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
 or implied. See the License for the specific language governing
 permissions and limitations under the License.
*/

/**
 * Encoding (new, compositional version):
 *
 * Encapsulate object that itself might contain a "$" field:
 * { $: "obj", val: ... }
 * (Outer level only:) Wrap other values into object
 * { $: "lit", val: ... }
 * Circular reference:
 * { $: "ref" l: uplevel, p: path }
 * Date:
 * { $: "date", val: datestr }
 * Bigint:
 * { $: "bigint", val: bigintstr }
 * Array with special (non-number) attributes:
 * { $: "array", val: arrayobj }
 * Undefined field
 * { $: "undef" }
 */

/**
 * Imports.
 */
import { DataCloneError } from "./errors.js";

const { toString: toStr } = {};
const hasOwn = {}.hasOwnProperty;
const getProto = Object.getPrototypeOf;
const fnToString = hasOwn.toString;

function toStringTag(val: any) {
  return toStr.call(val).slice(8, -1);
}

function hasConstructorOf(a: any, b: any) {
  if (!a || typeof a !== "object") {
    return false;
  }
  const proto = getProto(a);
  if (!proto) {
    return b === null;
  }
  const Ctor = hasOwn.call(proto, "constructor") && proto.constructor;
  if (typeof Ctor !== "function") {
    return b === null;
  }
  if (b === Ctor) {
    return true;
  }
  if (b !== null && fnToString.call(Ctor) === fnToString.call(b)) {
    return true;
  }
  return false;
}

function isPlainObject(val: any): boolean {
  if (!val || toStringTag(val) !== "Object") {
    return false;
  }

  const proto = getProto(val);
  if (!proto) {
    // `Object.create(null)`
    return true;
  }

  return hasConstructorOf(val, Object);
}

function isUserObject(val: any): boolean {
  if (!val || toStringTag(val) !== "Object") {
    return false;
  }

  const proto = getProto(val);
  if (!proto) {
    // `Object.create(null)`
    return true;
  }
  return hasConstructorOf(val, Object) || isUserObject(proto);
}

function copyBuffer(cur: any) {
  if (cur instanceof Buffer) {
    return Buffer.from(cur);
  }

  return new cur.constructor(cur.buffer.slice(), cur.byteOffset, cur.length);
}

function checkCloneableOrThrow(x: any) {
  if (x == null) return;
  if (typeof x !== "object" && typeof x !== "function") return;
  if (x instanceof Date) return;
  if (Array.isArray(x)) return;
  if (x instanceof Map) return;
  if (x instanceof Set) return;
  if (isUserObject(x)) return;
  if (isPlainObject(x)) return;
  throw new DataCloneError();
}

export function mkDeepClone() {
  const refs = [] as any;
  const refsNew = [] as any;

  return clone;

  function cloneArray(a: any) {
    var keys = Object.keys(a);
    var a2 = new Array(keys.length);
    refs.push(a);
    refsNew.push(a2);
    for (var i = 0; i < keys.length; i++) {
      var k = keys[i] as any;
      var cur = a[k];
      checkCloneableOrThrow(cur);
      if (typeof cur !== "object" || cur === null) {
        a2[k] = cur;
      } else if (cur instanceof Date) {
        a2[k] = new Date(cur);
      } else if (ArrayBuffer.isView(cur)) {
        a2[k] = copyBuffer(cur);
      } else {
        var index = refs.indexOf(cur);
        if (index !== -1) {
          a2[k] = refsNew[index];
        } else {
          a2[k] = clone(cur);
        }
      }
    }
    refs.pop();
    refsNew.pop();
    return a2;
  }

  function clone(o: any) {
    checkCloneableOrThrow(o);
    if (typeof o !== "object" || o === null) return o;
    if (o instanceof Date) return new Date(o);
    if (Array.isArray(o)) return cloneArray(o);
    if (o instanceof Map) return new Map(cloneArray(Array.from(o)));
    if (o instanceof Set) return new Set(cloneArray(Array.from(o)));
    var o2 = {} as any;
    refs.push(o);
    refsNew.push(o2);
    for (var k in o) {
      if (Object.hasOwnProperty.call(o, k) === false) continue;
      var cur = o[k] as any;
      checkCloneableOrThrow(cur);
      if (typeof cur !== "object" || cur === null) {
        o2[k] = cur;
      } else if (cur instanceof Date) {
        o2[k] = new Date(cur);
      } else if (cur instanceof Map) {
        o2[k] = new Map(cloneArray(Array.from(cur)));
      } else if (cur instanceof Set) {
        o2[k] = new Set(cloneArray(Array.from(cur)));
      } else if (ArrayBuffer.isView(cur)) {
        o2[k] = copyBuffer(cur);
      } else {
        var i = refs.indexOf(cur);
        if (i !== -1) {
          o2[k] = refsNew[i];
        } else {
          o2[k] = clone(cur);
        }
      }
    }
    refs.pop();
    refsNew.pop();
    return o2;
  }
}

/**
 * Check if an object is deeply cloneable.
 * Only called for the side-effect of throwing an exception.
 */
export function mkDeepCloneCheckOnly() {
  const refs = [] as any;

  return clone;

  function cloneArray(a: any) {
    var keys = Object.keys(a);
    refs.push(a);
    for (var i = 0; i < keys.length; i++) {
      var k = keys[i] as any;
      var cur = a[k];
      checkCloneableOrThrow(cur);
      if (typeof cur !== "object" || cur === null) {
        // do nothing
      } else if (cur instanceof Date) {
        // do nothing
      } else if (ArrayBuffer.isView(cur)) {
        // do nothing
      } else {
        var index = refs.indexOf(cur);
        if (index !== -1) {
          // do nothing
        } else {
          clone(cur);
        }
      }
    }
    refs.pop();
  }

  function clone(o: any) {
    checkCloneableOrThrow(o);
    if (typeof o !== "object" || o === null) return o;
    if (o instanceof Date) return;
    if (Array.isArray(o)) return cloneArray(o);
    if (o instanceof Map) return cloneArray(Array.from(o));
    if (o instanceof Set) return cloneArray(Array.from(o));
    refs.push(o);
    for (var k in o) {
      if (Object.hasOwnProperty.call(o, k) === false) continue;
      var cur = o[k] as any;
      checkCloneableOrThrow(cur);
      if (typeof cur !== "object" || cur === null) {
        // do nothing
      } else if (cur instanceof Date) {
        // do nothing
      } else if (cur instanceof Map) {
        cloneArray(Array.from(cur));
      } else if (cur instanceof Set) {
        cloneArray(Array.from(cur));
      } else if (ArrayBuffer.isView(cur)) {
        // do nothing
      } else {
        var i = refs.indexOf(cur);
        if (i !== -1) {
          // do nothing
        } else {
          clone(cur);
        }
      }
    }
    refs.pop();
  }
}

function internalEncapsulate(
  val: any,
  path: string[],
  memo: Map<any, string[]>,
): any {
  const memoPath = memo.get(val);
  if (memoPath) {
    return { $: "ref", d: path.length, p: memoPath };
  }
  if (val === null) {
    return null;
  }
  if (val === undefined) {
    return { $: "undef" };
  }
  if (Array.isArray(val)) {
    memo.set(val, path);
    const outArr: any[] = [];
    let special = false;
    for (const x in val) {
      const n = Number(x);
      if (n < 0 || n >= val.length || Number.isNaN(n)) {
        special = true;
        break;
      }
    }
    for (const x in val) {
      const p = [...path, x];
      outArr[x] = internalEncapsulate(val[x], p, memo);
    }
    if (special) {
      return { $: "array", val: outArr };
    } else {
      return outArr;
    }
  }
  if (val instanceof Date) {
    return { $: "date", val: val.getTime() };
  }
  if (isUserObject(val) || isPlainObject(val)) {
    memo.set(val, path);
    const outObj: any = {};
    for (const x in val) {
      const p = [...path, x];
      outObj[x] = internalEncapsulate(val[x], p, memo);
    }
    if ("$" in outObj) {
      return { $: "obj", val: outObj };
    }
    return outObj;
  }
  if (typeof val === "bigint") {
    return { $: "bigint", val: val.toString() };
  }
  if (typeof val === "boolean") {
    return val;
  }
  if (typeof val === "number") {
    return val;
  }
  if (typeof val === "string") {
    return val;
  }
  throw Error();
}

function derefPath(
  root: any,
  p1: Array<string | number>,
  n: number,
  p2: Array<string | number>,
): any {
  let v = root;
  for (let i = 0; i < n; i++) {
    v = v[p1[i]];
  }
  for (let i = 0; i < p2.length; i++) {
    v = v[p2[i]];
  }
  return v;
}

function internalReviveArray(sval: any, root: any, path: string[]): any {
  const newArr: any[] = [];
  if (root === undefined) {
    root = newArr;
  }
  for (let i = 0; i < sval.length; i++) {
    const p = [...path, String(i)];
    newArr.push(internalStructuredRevive(sval[i], root, p));
  }
  return newArr;
}

function internalReviveObject(sval: any, root: any, path: string[]): any {
  const newObj = {} as any;
  if (root === undefined) {
    root = newObj;
  }
  for (const key of Object.keys(sval)) {
    const p = [...path, key];
    newObj[key] = internalStructuredRevive(sval[key], root, p);
  }
  return newObj;
}

function internalStructuredRevive(sval: any, root: any, path: string[]): any {
  if (typeof sval === "string") {
    return sval;
  }
  if (typeof sval === "number") {
    return sval;
  }
  if (typeof sval === "boolean") {
    return sval;
  }
  if (sval === null) {
    return null;
  }
  if (Array.isArray(sval)) {
    return internalReviveArray(sval, root, path);
  }

  if (isUserObject(sval) || isPlainObject(sval)) {
    if ("$" in sval) {
      const dollar = sval.$;
      switch (dollar) {
        case "undef":
          return undefined;
        case "bigint":
          return BigInt((sval as any).val);
        case "date":
          return new Date((sval as any).val);
        case "obj": {
          return internalReviveObject((sval as any).val, root, path);
        }
        case "array":
          return internalReviveArray((sval as any).val, root, path);
        case "ref": {
          const level = (sval as any).l;
          const p2 = (sval as any).p;
          return derefPath(root, path, path.length - level, p2);
        }
        default:
          throw Error();
      }
    } else {
      return internalReviveObject(sval, root, path);
    }
  }

  throw Error();
}

/**
 * Encapsulate a cloneable value into a plain JSON value.
 */
export function structuredEncapsulate(val: any): any {
  return internalEncapsulate(val, [], new Map());
}

export function structuredRevive(sval: any): any {
  return internalStructuredRevive(sval, undefined, []);
}

/**
 * Structured clone for IndexedDB.
 */
export function structuredClone(val: any): any {
  // @ts-ignore
  if (globalThis._tart?.structuredClone) {
    // @ts-ignore
    return globalThis._tart?.structuredClone(val);
  }
  return mkDeepClone()(val);
}

/**
 * Structured clone for IndexedDB.
 */
export function checkStructuredCloneOrThrow(val: any): void {
  // @ts-ignore
  if (globalThis._tart?.structuredClone) {
    // @ts-ignore
    globalThis._tart?.structuredClone(val);
    return;
  }
  mkDeepCloneCheckOnly()(val);
}
