import { nanoid } from "nanoid";
import { AsyncMemory } from "./async-memory";

const SERIALIZATION = {
  UNDEFINED: 0,
  NULL: 1,
  FALSE: 2,
  TRUE: 3,
  NUMBER: 4,
  DATE: 5,
  KNOWN_SYMBOL: 6,
  STRING: 10,
  BIGINT: 11,
  OBJECT: 255,
} as const;

// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol
const KNOWN_SYMBOLS = [
  Symbol.asyncIterator,
  Symbol.hasInstance,
  Symbol.isConcatSpreadable,
  Symbol.iterator,
  Symbol.match,
  Symbol.matchAll,
  Symbol.replace,
  Symbol.search,
  Symbol.species,
  Symbol.split,
  Symbol.toPrimitive,
  Symbol.toStringTag,
  Symbol.unscopables,
];

const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder("utf-8");

const encodeFloat = useFloatEncoder();
const decodeFloat = useFloatDecoder();

function useFloatEncoder() {
  // https://stackoverflow.com/a/14379836/3492994
  const temp = new ArrayBuffer(8);
  const tempFloat64 = new Float64Array(temp);
  const tempUint8 = new Uint8Array(temp);

  return function (value: number): Uint8Array {
    tempFloat64[0] = value;
    return tempUint8;
  };
}

function useFloatDecoder() {
  const temp = new ArrayBuffer(8);
  const tempFloat64 = new Float64Array(temp);
  const tempUint8 = new Uint8Array(temp);

  return function (value: Uint8Array): number {
    tempUint8.set(value);
    return tempFloat64[0];
  };
}

/**
 * Lets one other thread access the objects on this thread.
 * Usually runs on the main thread.
 */
export class ObjectProxyHost {
  readonly rootReferences = new Map<string, any>();
  readonly temporaryReferences = new Map<string, any>();
  readonly memory: AsyncMemory;

  private writeMemoryContinuation?: () => void;

  constructor(memory: AsyncMemory) {
    this.memory = memory;
  }

  /** Creates a valid, random id for a given object */
  private getId(value: any) {
    return nanoid() + "-" + (typeof value === "function" ? "f" : "o");
  }

  registerRootObject(value: any) {
    const id = this.getId(value);
    this.rootReferences.set(id, value);
    return id;
  }

  registerTempObject(value: any) {
    const id = this.getId(value);
    this.temporaryReferences.set(id, value);
    return id;
  }

  clearTemporary() {
    this.temporaryReferences.clear();
  }

  getObject(id: string) {
    return this.rootReferences.get(id) ?? this.temporaryReferences.get(id);
  }

  // A serializePostMessage isn't needed here, because all we're ever going to pass to the worker are ids

  serializeMemory(value: any, memory: AsyncMemory) {
    // Format:
    // [1 byte][n bytes ]
    // [type  ][data    ]

    memory.writeSize(8); // Anything that fits into 8 bytes is fine

    // Simple primitives. Guaranteed to fit into the shared memory.
    if (value === undefined) {
      memory.memory[0] = SERIALIZATION.UNDEFINED;
      memory.unlockSize();
    } else if (value === null) {
      memory.memory[0] = SERIALIZATION.NULL;
      memory.unlockSize();
    } else if (value === false) {
      memory.memory[0] = SERIALIZATION.FALSE;
      memory.unlockSize();
    } else if (value === true) {
      memory.memory[0] = SERIALIZATION.TRUE;
      memory.unlockSize();
    } else if (typeof value === "number") {
      memory.memory[0] = SERIALIZATION.NUMBER;
      memory.memory.set(encodeFloat(value), 1);
      memory.unlockSize();
    } else if (value instanceof Date) {
      memory.memory[0] = SERIALIZATION.DATE;
      const time = value.getTime();
      memory.memory.set(encodeFloat(time), 1);
      memory.unlockSize();
    } else if (typeof value === "symbol" && KNOWN_SYMBOLS.includes(value)) {
      memory.memory[0] = SERIALIZATION.KNOWN_SYMBOL;
      memory.memory[1] = KNOWN_SYMBOLS.indexOf(value);
      memory.unlockSize();
    }
    // Variable length primitives. Not guaranteed to fit into the shared memory, but we know their size.
    else if (typeof value === "string") {
      memory.memory[0] = SERIALIZATION.STRING;
      // A string encoded in utf-8 uses at most 4 bytes per character
      if (value.length * 4 <= memory.memory.byteLength) {
        const data = textEncoder.encode(value);
        memory.memory.set(data, 1);
        memory.writeSize(data.byteLength);
        memory.unlockSize();
      } else {
        // Longer strings need to be sent piece by piece
        const bytes = textEncoder.encode(value);
        const memorySize = memory.memory.byteLength;
        let offset = 0;
        let remainingBytes = bytes.byteLength;
        memory.memory.set(bytes.subarray(offset, memorySize - 1), 1);
        offset += memorySize - 1;
        remainingBytes -= memorySize - 1;
        this.writeMemoryContinuation = () => {
          if (remainingBytes > 0) {
            memory.memory.set(bytes.subarray(offset, memorySize), 0);
            offset += memorySize;
            remainingBytes -= memorySize;
          } else {
            this.writeMemoryContinuation = undefined;
          }
          memory.unlockSize();
        };
        memory.writeSize(bytes.byteLength);
        memory.unlockSize();
      }
    } else if (typeof value === "bigint") {
      memory.memory[0] = SERIALIZATION.BIGINT;
      const digits = value.toString();
      // TODO: Implement this (just like the text ^)
      console.warn("Bigint support is not implemented");
      memory.unlockSize();
    }
    // Object. Serialized as ID, guaranteed to fit into shared memory
    else {
      memory.memory[0] = SERIALIZATION.OBJECT;
      const id = this.registerTempObject(value);
      const data = textEncoder.encode(id);
      memory.memory.set(data, 1);
      memory.writeSize(data.byteLength);
      memory.unlockSize();
    }
  }

  /**
   * Deserializes an object that was sent through postMessage
   */
  deserializePostMessage(value: any): any {
    if (typeof value === "object" && value !== null) {
      // Special cases
      if (value.id) return this.getObject(value.id);
      if (value.value) return value.value;
      if (value.symbol) return KNOWN_SYMBOLS[value.symbol];
    }
    // It's a primitive
    return value;
  }

  handleProxyMessage(message: ProxyMessage, memory: AsyncMemory) {
    if (message.type === "proxy_reflect") {
      try {
        if (message.method === "apply") {
          const method = Reflect[message.method];
          const args = (message.args ?? []).map((v) => this.deserializePostMessage(v));
          const result = (method as any)(this.getObject(message.target), this.getObject(message.thisArg), args);
          // Write result to shared memory
          this.serializeMemory(result, memory);
        } else {
          const method = Reflect[message.method];
          const args = (message.args ?? []).map((v) => this.deserializePostMessage(v));
          const result = (method as any)(this.getObject(message.target), ...args);
          // Write result to shared memory
          this.serializeMemory(result, memory);
        }
      } catch (e) {
        console.error(message);
        throw e;
      }
    } else if (message.type === "proxy_shared_memory") {
      // Write remaining data to shared memory
      if (this.writeMemoryContinuation === undefined) {
        console.warn("No more data to write to shared memory");
      } else {
        this.writeMemoryContinuation();
      }
    } else if (message.type === "proxy_print_object") {
      console.log("Object with id", message.target, "is", this.getObject(message.target));
    } else if (message.type === "proxy_promise") {
      const promiseObject: Promise<any> = this.getObject(message.target);
      if (message.method === "then") {
        promiseObject[message.method](
          (value) => {
            const result = { value: value };
            this.serializeMemory(result, memory);
          },
          (err) => {
            const result = { error: err };
            this.serializeMemory(result, memory);
          }
        );
      } else {
        console.error("Unknown proxy promise method", message);
      }
    } else {
      console.warn("Unknown proxy message", message);
    }
  }
}

export const ObjectId = Symbol.for("id");
/**
 * Allows this thread to access objects from another thread.
 * Must run on a worker thread.
 */
export class ObjectProxyClient {
  readonly memory: AsyncMemory;
  readonly postMessage: (message: ProxyMessage) => void;
  constructor(memory: AsyncMemory, postMessage: (message: ProxyMessage) => void) {
    this.memory = memory;
    this.postMessage = postMessage;
  }

  /**
   * Serializes an object so that it can be sent using postMessage
   */
  serializePostMessage(value: any): any {
    if (isSimplePrimitive(value)) {
      return value;
    } else if (isSymbolPrimitive(value)) {
      return { symbol: KNOWN_SYMBOLS.indexOf(value) };
    } else if (isVariableLengthPrimitive(value)) {
      return value;
    } else if (value[ObjectId] !== undefined) {
      return { id: value[ObjectId] };
    } else {
      // Maybe serialize simple functions https://stackoverflow.com/questions/1833588/javascript-clone-a-function
      return { value: value }; // Might fail to get serialized
    }
  }

  /**
   * Deserializes an object from a shared array buffer. Can return a proxy.
   */
  deserializeMemory(memory: AsyncMemory) {
    const numberOfBytes = memory.readSize();

    // Uint8Arrays have the convenient property of having 1 byte per element.
    let resultBytes: Uint8Array;
    if (numberOfBytes <= memory.sharedMemory.byteLength) {
      resultBytes = memory.memory;
    } else {
      const memorySize = memory.sharedMemory.byteLength;
      let offset = 0;
      let remainingBytes = numberOfBytes;
      resultBytes = new Uint8Array(numberOfBytes);
      while (remainingBytes >= memorySize) {
        resultBytes.set(memory.memory, offset);
        offset += memorySize;
        remainingBytes -= memorySize;
        memory.lockSize();
        this.postMessage({ type: "proxy_shared_memory" });
        memory.waitForSize();
      }
      if (remainingBytes > 0) {
        resultBytes.set(memory.memory.subarray(0, remainingBytes), offset);
      }
    }

    // Simple primitives. Guaranteed to fit into the shared memory.
    if (resultBytes[0] === SERIALIZATION.UNDEFINED) {
      return undefined;
    } else if (resultBytes[0] === SERIALIZATION.NULL) {
      return null;
    } else if (resultBytes[0] === SERIALIZATION.FALSE) {
      return false;
    } else if (resultBytes[0] === SERIALIZATION.TRUE) {
      return true;
    } else if (resultBytes[0] === SERIALIZATION.NUMBER) {
      return decodeFloat(resultBytes.subarray(1, 9));
    } else if (resultBytes[0] === SERIALIZATION.DATE) {
      const date = new Date();
      date.setTime(decodeFloat(resultBytes.subarray(1, 9)));
      return date;
    } else if (resultBytes[0] === SERIALIZATION.KNOWN_SYMBOL) {
      const symbol = KNOWN_SYMBOLS[resultBytes[1]];
      return symbol;
    }
    // Variable length primitives. We already read all of their data
    else if (resultBytes[0] === SERIALIZATION.STRING) {
      // Note: This *copies* the entire thing, because you aren't allowed to call decode on a SharedArrayBuffer
      return textDecoder.decode(resultBytes.slice(1, numberOfBytes + 1));
    } else if (resultBytes[0] === SERIALIZATION.BIGINT) {
      return BigInt(textDecoder.decode(resultBytes.slice(1, numberOfBytes + 1)));
    }
    // Object. Serialized as ID, guaranteed to fit into shared memory
    else if (resultBytes[0] === SERIALIZATION.OBJECT) {
      const id = textDecoder.decode(resultBytes.slice(1, numberOfBytes + 1));
      return this.getObjectProxy(id);
    } else {
      console.warn("Unknown type", resultBytes[0]);
      return null;
    }
  }

  /**
   * Calls a Reflect function on an object from the other thread
   * @returns The result of the operation, can be a primitive or a proxy
   */
  private proxyReflect(method: keyof typeof Reflect, targetId: string, args: any[]) {
    let value: any = undefined;
    try {
      this.memory.lockWorker();
      this.memory.lockSize();
      this.memory.writeSize(0);
      if (method === "apply") {
        // Special case for "apply"
        this.postMessage({
          type: "proxy_reflect",
          method: method,
          target: targetId,
          thisArg: args[0],
          args: args[1].map((v: any[]) => this.serializePostMessage(v)),
        });
      } else {
        this.postMessage({
          type: "proxy_reflect",
          method: method,
          target: targetId,
          args: args.map((v) => this.serializePostMessage(v)),
        });
      }

      this.memory.waitForSize();
      value = this.deserializeMemory(this.memory);
    } catch (e) {
      console.error({ method, targetId, args });
      console.error(e);
      this.postMessage({
        type: "proxy_print_object",
        target: targetId,
      });
    } finally {
      // Regardless of what happened, unlock the size and the worker
      this.memory.forceUnlockSize();
      this.memory.unlockWorker();
    }
    return value;
  }

  private proxyPromise(method: "then", targetId: string): { value?: any; error?: any } {
    let value: any = undefined;
    try {
      this.memory.lockWorker();
      this.memory.lockSize();
      this.memory.writeSize(0);

      this.postMessage({
        type: "proxy_promise",
        method: method,
        target: targetId,
      });

      this.memory.waitForSize();
      value = this.deserializeMemory(this.memory);
    } catch (e) {
      console.error({ method, targetId });
      console.error(e);
      this.postMessage({
        type: "proxy_print_object",
        target: targetId,
      });
    } finally {
      // Regardless of what happened, unlock the size and the worker
      this.memory.forceUnlockSize();
      this.memory.unlockWorker();
    }
    return value;
  }

  /** Checks if an id encodes a function. Mostly a silly hack to ensure that proxies can work as expected */
  private isFunction(id: string) {
    return id.endsWith("-f");
  }

  /**
   * Gets a proxy object for a given id
   */
  getObjectProxy<T = any>(id: string): T {
    const client = this;

    return new Proxy(this.isFunction(id) ? function () {} : {}, {
      get(target, prop, receiver) {
        if (prop === ObjectId) {
          return id;
        }

        // const value = Reflect.get(target, prop, receiver);
        const value = client.proxyReflect("get", id, [prop, receiver]);

        if (typeof value !== "function") return value;
        // TODO: Special handling for .bind, .apply and more
        /* Functions need special handling
         * https://stackoverflow.com/questions/27983023/proxy-on-dom-element-gives-error-when-returning-functions-that-implement-interfa
         * https://stackoverflow.com/questions/37092179/javascript-proxy-objects-dont-work
         */
        return new Proxy(value, {
          apply(_, thisArg, argumentsList) {
            // thisArg: the object the function was called with. Can be the proxy or something else
            // receiver: the object the propery was gotten from. Is always the proxy or something inheriting from the proxy
            // target: the original object

            const calledWithProxy = thisArg === receiver;

            // return Reflect.apply(value, calledWithProxy ? target : thisArg, args);
            const functionReturnValue = client.proxyReflect("apply", value[ObjectId], [
              calledWithProxy ? id : thisArg[ObjectId],
              argumentsList,
            ]);
            return functionReturnValue;
          },
        });
      },
      set(target, prop, value, receiver) {
        // return Reflect.set(target, prop, value, receiver);
        return client.proxyReflect("set", id, [prop, value, receiver]);
      },
      ownKeys(target) {
        // return Reflect.ownKeys(target);
        return client.proxyReflect("ownKeys", id, []);
      },
      has(target, prop) {
        // return Reflect.has(target, prop);
        return client.proxyReflect("has", id, [prop]);
      },
      defineProperty(target, prop, attributes) {
        // return Reflect.defineProperty(target, prop, attributes);
        return client.proxyReflect("defineProperty", id, [prop, attributes]);
      },
      deleteProperty(target, prop) {
        // return Reflect.deleteProperty(target, prop);
        return client.proxyReflect("deleteProperty", id, [prop]);
      },
      // TODO: Those functions might be interesting as well
      /*
      getOwnPropertyDescriptor(target, prop) {
        // return Reflect.getOwnPropertyDescriptor(target, prop);
        return client.proxyReflect("getOwnPropertyDescriptor", id, [prop]);
      },
      isExtensible(target) {
        // return Reflect.isExtensible(target);
        return client.proxyReflect("isExtensible", id, []);
      },
      preventExtensions(target) {
        // return Reflect.preventExtensions(target);
        return client.proxyReflect("preventExtensions", id, []);
      },
      getPrototypeOf(target) {
        // return Reflect.getPrototypeOf(target);
        return client.proxyReflect("getPrototypeOf", id, []);
      },
      setPrototypeOf(target, proto) {
        // return Reflect.setPrototypeOf(target, proto);
        return client.proxyReflect("setPrototypeOf", id, [proto]);
      },*/

      // For function objects
      apply(target, thisArg, argumentsList) {
        // Note: It can happen that a function gets called with a thisArg that cannot be serialized (due to it being an object on the worker thread)
        // One solution is to provide a `[ObjectId]: ""` property
        // TODO: Can it also happen that a function gets called with an easily serializeable thisArg that doesn't have an ObjectId?
        // return Reflect.apply(target, thisArg, argumentsList);
        return client.proxyReflect("apply", id, [thisArg[ObjectId], argumentsList]);
      },
      construct(target, argumentsList, newTarget) {
        // return Reflect.construct(target, argumentsList, newTarget)
        return client.proxyReflect("construct", id, [argumentsList, newTarget]);
      },
    }) as T;
  }

  /**
   * Wraps an object in a proxy that does not proxy certain properties
   */
  wrapExcluderProxy<T extends object>(obj: T, underlyingObject: T, exclude: Set<string | symbol>): T {
    return new Proxy<T>(obj, {
      get(target, prop, receiver) {
        if (exclude.has(prop)) {
          target = underlyingObject;
        }

        const value = Reflect.get(target, prop, receiver);
        if (typeof value !== "function") return value;
        return new Proxy(value, {
          apply(_, thisArg, args) {
            const calledWithProxy = thisArg === receiver;
            return Reflect.apply(value, calledWithProxy ? target : thisArg, args);
          },
        });
      },
      has(target, prop) {
        if (exclude.has(prop)) {
          target = underlyingObject;
        }
        return Reflect.has(target, prop);
      },
    });
  }

  /**
   * Blocks until an proxy object promise has returned a result or an error
   */
  thenSync<T>(obj: Promise<T>): T {
    const objectId = (obj as any)[ObjectId];
    if (!objectId) {
      throw new Error("Not a proxy object");
    }
    const result = this.proxyPromise("then", objectId);
    if (result.error) {
      throw result.error;
    }
    return result.value;
  }
}

function isSimplePrimitive(value: any) {
  if (value === undefined) {
    return true;
  } else if (value === null) {
    return true;
  } else if (value === false) {
    return true;
  } else if (value === true) {
    return true;
  } else if (typeof value === "number") {
    return true;
  } else if (value instanceof Date) {
    return true;
  } else {
    return false;
  }
}

function isSymbolPrimitive(value: any) {
  if (typeof value === "symbol" && KNOWN_SYMBOLS.includes(value)) {
    return true;
  }
  return false;
}

function isVariableLengthPrimitive(value: any) {
  if (typeof value === "string") {
    return true;
  } else if (typeof value === "bigint") {
    return true;
  }
}

export type ProxyMessage =
  | {
      type: "proxy_reflect";
      method: Exclude<keyof typeof Reflect, "apply">;
      /**
       * An object id
       */
      target: string;
      /**
       * Further parameters. Have to be serialized
       */
      args?: any[];
    }
  | {
      type: "proxy_reflect";
      method: "apply";
      /**
       * An object id
       */
      target: string;
      /**
       * An object id
       */
      thisArg: string;
      /**
       * Further parameters. Have to be serialized
       */
      args?: any[];
    }
  | {
      /** For requesting more bytes from the shared memory*/
      type: "proxy_shared_memory";
    }
  | {
      type: "proxy_print_object";
      target: string;
    }
  | {
      type: "proxy_promise";
      method: "then";
      target: string;
    };
