/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/* globals UniFFIScaffolding */

// This JS module contains shared functionality for the generated UniFFI JS
// code.
//
let lazy = {};

ChromeUtils.defineLazyGetter(lazy, "decoder", () => new TextDecoder());
ChromeUtils.defineLazyGetter(lazy, "encoder", () => new TextEncoder());

// TypeError for UniFFI calls
//
// This extends TypeError to add support for recording a nice description of
// the item that fails the type check. This is especially useful for invalid
// values nested in objects/arrays/maps, etc.
//
// To accomplish this, the FfiConverter.checkType methods of records, arrays,
// maps, etc. catch UniFFITypeError, call `addItemDescriptionPart()` with a
// string representing the child item, then re-raise the exception.  We then
// join all the parts together, in reverse order, to create item description
// strings like `foo.bar[123]["key"]`
export class UniFFITypeError extends TypeError {
  constructor(reason) {
    // our `message` getter isn't invoked in all cases, so we supply a default
    // to the `TypeError` constructor.
    super(reason);
    this.reason = reason;
    this.itemDescriptionParts = [];
  }

  addItemDescriptionPart(part) {
    this.itemDescriptionParts.push(part);
    this.updateMessage();
  }

  itemDescription() {
    const itemDescriptionParts = [...this.itemDescriptionParts];
    itemDescriptionParts.reverse();
    return itemDescriptionParts.join("");
  }

  updateMessage() {
    this.message = `${this.itemDescription()}: ${this.reason}`;
  }
}

// Write/Read data to/from an ArrayBuffer
export class ArrayBufferDataStream {
  constructor(arrayBuffer) {
    this.dataView = new DataView(arrayBuffer);
    this.pos = 0;
  }

  readUint8() {
    let rv = this.dataView.getUint8(this.pos);
    this.pos += 1;
    return rv;
  }

  writeUint8(value) {
    this.dataView.setUint8(this.pos, value);
    this.pos += 1;
  }

  readUint16() {
    let rv = this.dataView.getUint16(this.pos);
    this.pos += 2;
    return rv;
  }

  writeUint16(value) {
    this.dataView.setUint16(this.pos, value);
    this.pos += 2;
  }

  readUint32() {
    let rv = this.dataView.getUint32(this.pos);
    this.pos += 4;
    return rv;
  }

  writeUint32(value) {
    this.dataView.setUint32(this.pos, value);
    this.pos += 4;
  }

  readUint64() {
    let rv = this.dataView.getBigUint64(this.pos);
    this.pos += 8;
    return Number(rv);
  }

  writeUint64(value) {
    this.dataView.setBigUint64(this.pos, BigInt(value));
    this.pos += 8;
  }

  readInt8() {
    let rv = this.dataView.getInt8(this.pos);
    this.pos += 1;
    return rv;
  }

  writeInt8(value) {
    this.dataView.setInt8(this.pos, value);
    this.pos += 1;
  }

  readInt16() {
    let rv = this.dataView.getInt16(this.pos);
    this.pos += 2;
    return rv;
  }

  writeInt16(value) {
    this.dataView.setInt16(this.pos, value);
    this.pos += 2;
  }

  readInt32() {
    let rv = this.dataView.getInt32(this.pos);
    this.pos += 4;
    return rv;
  }

  writeInt32(value) {
    this.dataView.setInt32(this.pos, value);
    this.pos += 4;
  }

  readInt64() {
    let rv = this.dataView.getBigInt64(this.pos);
    this.pos += 8;
    return Number(rv);
  }

  writeInt64(value) {
    this.dataView.setBigInt64(this.pos, BigInt(value));
    this.pos += 8;
  }

  readFloat32() {
    let rv = this.dataView.getFloat32(this.pos);
    this.pos += 4;
    return rv;
  }

  writeFloat32(value) {
    this.dataView.setFloat32(this.pos, value);
    this.pos += 4;
  }

  readFloat64() {
    let rv = this.dataView.getFloat64(this.pos);
    this.pos += 8;
    return rv;
  }

  writeFloat64(value) {
    this.dataView.setFloat64(this.pos, value);
    this.pos += 8;
  }

  writeString(value) {
    // Note: in order to efficiently write this data, we first write the
    // string data, reserving 4 bytes for the size.
    const dest = new Uint8Array(this.dataView.buffer, this.pos + 4);
    const encodeResult = lazy.encoder.encodeInto(value, dest);
    if (encodeResult.read != value.length) {
      throw new UniFFIError(
        "writeString: out of space when writing to ArrayBuffer.  Did the computeSize() method returned the wrong result?"
      );
    }
    const size = encodeResult.written;
    // Next, go back and write the size before the string data
    this.dataView.setUint32(this.pos, size);
    // Finally, advance our position past both the size and string data
    this.pos += size + 4;
  }

  readString() {
    const size = this.readUint32();
    const source = new Uint8Array(this.dataView.buffer, this.pos, size);
    const value = lazy.decoder.decode(source);
    this.pos += size;
    return value;
  }

  readBytes() {
    const size = this.readInt32();
    const bytes = new Uint8Array(this.dataView.buffer, this.pos, size);
    this.pos += size;
    return bytes;
  }

  writeBytes(value) {
    this.writeUint32(value.length);
    value.forEach(elt => {
      this.writeUint8(elt);
    });
  }

  // Reads a pointer from the data stream
  // UniFFI Pointers are **always** 8 bytes long. That is enforced
  // by the C++ and Rust Scaffolding code.
  readPointer(pointerId) {
    const res = UniFFIScaffolding.readPointer(
      pointerId,
      this.dataView.buffer,
      this.pos
    );
    this.pos += 8;
    return res;
  }

  // Writes a pointer into the data stream
  // UniFFI Pointers are **always** 8 bytes long. That is enforced
  // by the C++ and Rust Scaffolding code.
  writePointer(pointerId, value) {
    UniFFIScaffolding.writePointer(
      pointerId,
      value,
      this.dataView.buffer,
      this.pos
    );
    this.pos += 8;
  }

  // Reads either a handle or pointer from the data stream
  //
  // This is used for trait interfaces, which can use either one.
  // In both cases, the data is **always** 8 bytes long. That is enforced
  // by the C++ and Rust Scaffolding code.
  readHandleOrPointer(pointerId) {
    // Try to read a handle from the data stream
    let handleOrPointer = Number(this.dataView.getBigInt64(this.pos));

    if ((handleOrPointer & 1) == 0) {
      // Actually the buffer is storing a pointer at this point, read that instead
      const ptr = UniFFIScaffolding.readPointer(
        pointerId,
        this.dataView.buffer,
        this.pos
      );
      this.pos += 8;
      return ptr;
    }
    this.pos += 8;
    return handleOrPointer;
  }
}

// Base class for FFI converters
export class FfiConverter {
  // throw `UniFFITypeError` if a value to be converted has an invalid type
  static checkType(value) {
    if (value === undefined) {
      throw new UniFFITypeError(`undefined`);
    }
    if (value === null) {
      throw new UniFFITypeError(`null`);
    }
  }
}

// Base class for FFI converters that lift/lower by reading/writing to an ArrayBuffer
export class FfiConverterArrayBuffer extends FfiConverter {
  static lift(buf) {
    return this.read(new ArrayBufferDataStream(buf));
  }

  static lower(value) {
    const buf = new ArrayBuffer(this.computeSize(value));
    const dataStream = new ArrayBufferDataStream(buf);
    this.write(dataStream, value);
    return buf;
  }

  /**
   * Computes the size of the value.
   *
   * @param {*} _value
   * @returns {number}
   */
  static computeSize(_value) {
    throw new UniFFIInternalError(
      "computeSize() should be declared in the derived class"
    );
  }

  /**
   * Reads the type from a data stream.
   *
   * @param {ArrayBufferDataStream} _dataStream
   * @returns {any}
   */
  static read(_dataStream) {
    throw new UniFFIInternalError(
      "read() should be declared in the derived class"
    );
  }

  /**
   * Writes the type to a data stream.
   *
   * @param {ArrayBufferDataStream} _dataStream
   * @param {any} _value
   */
  static write(_dataStream, _value) {
    throw new UniFFIInternalError(
      "write() should be declared in the derived class"
    );
  }
}

export class FfiConverterInt8 extends FfiConverter {
  static checkType(value) {
    super.checkType(value);
    if (!Number.isInteger(value)) {
      throw new UniFFITypeError(`${value} is not an integer`);
    }
    if (value < -128 || value > 127) {
      throw new UniFFITypeError(`${value} exceeds the I8 bounds`);
    }
  }
  static computeSize(_value) {
    return 1;
  }
  static lift(value) {
    return value;
  }
  static lower(value) {
    return value;
  }
  static write(dataStream, value) {
    dataStream.writeInt8(value);
  }
  static read(dataStream) {
    return dataStream.readInt8();
  }
}

export class FfiConverterUInt8 extends FfiConverter {
  static checkType(value) {
    super.checkType(value);
    if (!Number.isInteger(value)) {
      throw new UniFFITypeError(`${value} is not an integer`);
    }
    if (value < 0 || value > 256) {
      throw new UniFFITypeError(`${value} exceeds the U8 bounds`);
    }
  }
  static computeSize(_value) {
    return 1;
  }
  static lift(value) {
    return value;
  }
  static lower(value) {
    return value;
  }
  static write(dataStream, value) {
    dataStream.writeUint8(value);
  }
  static read(dataStream) {
    return dataStream.readUint8();
  }
}

export class FfiConverterInt16 extends FfiConverter {
  static checkType(value) {
    super.checkType(value);
    if (!Number.isInteger(value)) {
      throw new UniFFITypeError(`${value} is not an integer`);
    }
    if (value < -32768 || value > 32767) {
      throw new UniFFITypeError(`${value} exceeds the I16 bounds`);
    }
  }
  static computeSize(_value) {
    return 2;
  }
  static lift(value) {
    return value;
  }
  static lower(value) {
    return value;
  }
  static write(dataStream, value) {
    dataStream.writeInt16(value);
  }
  static read(dataStream) {
    return dataStream.readInt16();
  }
}

export class FfiConverterUInt16 extends FfiConverter {
  static checkType(value) {
    super.checkType(value);
    if (!Number.isInteger(value)) {
      throw new UniFFITypeError(`${value} is not an integer`);
    }
    if (value < 0 || value > 65535) {
      throw new UniFFITypeError(`${value} exceeds the U16 bounds`);
    }
  }
  static computeSize(_value) {
    return 2;
  }
  static lift(value) {
    return value;
  }
  static lower(value) {
    return value;
  }
  static write(dataStream, value) {
    dataStream.writeUint16(value);
  }
  static read(dataStream) {
    return dataStream.readUint16();
  }
}

export class FfiConverterInt32 extends FfiConverter {
  static checkType(value) {
    super.checkType(value);
    if (!Number.isInteger(value)) {
      throw new UniFFITypeError(`${value} is not an integer`);
    }
    if (value < -2147483648 || value > 2147483647) {
      throw new UniFFITypeError(`${value} exceeds the I32 bounds`);
    }
  }
  static computeSize(_value) {
    return 4;
  }
  static lift(value) {
    return value;
  }
  static lower(value) {
    return value;
  }
  static write(dataStream, value) {
    dataStream.writeInt32(value);
  }
  static read(dataStream) {
    return dataStream.readInt32();
  }
}

export class FfiConverterUInt32 extends FfiConverter {
  static checkType(value) {
    super.checkType(value);
    if (!Number.isInteger(value)) {
      throw new UniFFITypeError(`${value} is not an integer`);
    }
    if (value < 0 || value > 4294967295) {
      throw new UniFFITypeError(`${value} exceeds the U32 bounds`);
    }
  }
  static computeSize(_value) {
    return 4;
  }
  static lift(value) {
    return value;
  }
  static lower(value) {
    return value;
  }
  static write(dataStream, value) {
    dataStream.writeUint32(value);
  }
  static read(dataStream) {
    return dataStream.readUint32();
  }
}

export class FfiConverterInt64 extends FfiConverter {
  static checkType(value) {
    super.checkType(value);
    if (!Number.isSafeInteger(value)) {
      throw new UniFFITypeError(`${value} exceeds the safe integer bounds`);
    }
  }
  static computeSize(_value) {
    return 8;
  }
  static lift(value) {
    return value;
  }
  static lower(value) {
    return value;
  }
  static write(dataStream, value) {
    dataStream.writeInt64(value);
  }
  static read(dataStream) {
    return dataStream.readInt64();
  }
}

export class FfiConverterUInt64 extends FfiConverter {
  static checkType(value) {
    super.checkType(value);
    if (!Number.isSafeInteger(value)) {
      throw new UniFFITypeError(`${value} exceeds the safe integer bounds`);
    }
    if (value < 0) {
      throw new UniFFITypeError(`${value} exceeds the U64 bounds`);
    }
  }
  static computeSize(_value) {
    return 8;
  }
  static lift(value) {
    return value;
  }
  static lower(value) {
    return value;
  }
  static write(dataStream, value) {
    dataStream.writeUint64(value);
  }
  static read(dataStream) {
    return dataStream.readUint64();
  }
}

export class FfiConverterFloat32 extends FfiConverter {
  static computeSize(_value) {
    return 4;
  }
  static lift(value) {
    return value;
  }
  static lower(value) {
    return value;
  }
  static write(dataStream, value) {
    dataStream.writeFloat32(value);
  }
  static read(dataStream) {
    return dataStream.readFloat32();
  }
}
// Export the FFIConverter object to make external types work.
export class FfiConverterFloat64 extends FfiConverter {
  static computeSize(_value) {
    return 8;
  }
  static lift(value) {
    return value;
  }
  static lower(value) {
    return value;
  }
  static write(dataStream, value) {
    dataStream.writeFloat64(value);
  }
  static read(dataStream) {
    return dataStream.readFloat64();
  }
}

export class FfiConverterBoolean extends FfiConverter {
  static computeSize(_value) {
    return 1;
  }
  static lift(value) {
    return value == 1;
  }
  static lower(value) {
    if (value) {
      return 1;
    }
    return 0;
  }
  static write(dataStream, value) {
    dataStream.writeUint8(this.lower(value));
  }
  static read(dataStream) {
    return this.lift(dataStream.readUint8());
  }
}

// Export the FFIConverter object to make external types work.
export class FfiConverterString extends FfiConverter {
  static checkType(value) {
    super.checkType(value);
    if (typeof value !== "string") {
      throw new UniFFITypeError(`${value} is not a string`);
    }
  }

  static lift(buf) {
    const utf8Arr = new Uint8Array(buf);
    return lazy.decoder.decode(utf8Arr);
  }
  static lower(value) {
    return lazy.encoder.encode(value).buffer;
  }

  static write(dataStream, value) {
    dataStream.writeString(value);
  }

  static read(dataStream) {
    return dataStream.readString();
  }

  static computeSize(value) {
    return 4 + lazy.encoder.encode(value).length;
  }
}

export class FfiConverterBytes extends FfiConverterArrayBuffer {
  static read(dataStream) {
    return dataStream.readBytes();
  }

  static write(dataStream, value) {
    dataStream.writeBytes(value);
  }

  static computeSize(value) {
    // The size of the length + 1 byte / item
    return 4 + value.length;
  }

  static checkType(value) {
    if (!(value instanceof Uint8Array)) {
      throw new UniFFITypeError(`${value} is not an Uint8Array`);
    }
  }
}

export function handleRustResult(result, liftCallback, liftErrCallback) {
  switch (result.code) {
    case "success":
      return liftCallback(result.data);

    case "error":
      throw liftErrCallback(result.data);

    case "internal-error":
      if (result.data) {
        throw new UniFFIInternalError(FfiConverterString.lift(result.data));
      } else {
        throw new UniFFIInternalError("Unknown error");
      }

    default:
      throw new UniFFIError(`Unexpected status code: ${result.code}`);
  }
}

export class UniFFIError extends Error {
  constructor(message) {
    super(message);
  }
}

export class UniFFIInternalError extends UniFFIError {}

// Symbols that are used to ensure that Object constructors
// can only be used with a proper UniFFI pointer
export const uniffiObjectPtr = Symbol("uniffiObjectPtr");
export const constructUniffiObject = Symbol("constructUniffiObject");

/**
 * Handler for a single UniFFI CallbackInterface
 *
 * This class stores objects that implement a callback interface in a handle
 * map, allowing them to be referenced by the Rust code using an integer
 * handle.
 *
 * While the callback object is stored in the map, it allows the Rust code to
 * call methods on the object using the callback object handle, a method id,
 * and an ArrayBuffer packed with the method arguments.
 *
 * When the Rust code drops its reference, it sends a call with the methodId=0,
 * which causes callback object to be removed from the map.
 */
export class UniFFICallbackHandler {
  #name;
  #interfaceId;
  #handleMap;
  #methodHandlers;
  #allowNewCallbacks;

  /**
   * Create a UniFFICallbackHandler
   *
   * @param {string} name - Human-friendly name for this callback interface
   * @param {int} interfaceId - Interface ID for this CallbackInterface.
   * @param {UniFFICallbackMethodHandler[]} methodHandlers -- UniFFICallbackHandler for each method, in the same order as the UDL file
   */
  constructor(name, interfaceId, methodHandlers) {
    this.#name = name;
    this.#interfaceId = interfaceId;
    this.#handleMap = new Map();
    this.#methodHandlers = methodHandlers;
    this.#allowNewCallbacks = true;

    UniFFIScaffolding.registerCallbackHandler(this.#interfaceId, this);
    Services.obs.addObserver(this, "xpcom-shutdown");
  }

  /**
   * Store a callback object in the handle map and return the handle
   *
   * @param {obj} callbackObj - Object that implements the callback interface
   * @returns {int} - Handle for this callback object, this is what gets passed back to Rust.
   */
  storeCallbackObj(callbackObj) {
    if (!this.#allowNewCallbacks) {
      throw new UniFFIError(`No new callbacks allowed for ${this.#name}`);
    }
    const handle = UniFFIScaffolding.callbackHandleCreate();
    this.#handleMap.set(
      handle,
      new UniFFICallbackHandleMapEntry(
        callbackObj,
        Components.stack.caller.formattedStack.trim()
      )
    );
    return handle;
  }

  /**
   * Get a previously stored callback object
   *
   * This consumes the handle, decreasing it's refcount.
   *
   * @param {int} handle - Callback object handle, returned from `storeCallbackObj()`
   * @returns {obj} - Callback object
   */
  takeCallbackObj(handle) {
    const entry = this.#handleMap.get(handle);
    if (entry === undefined) {
      throw new UniFFIError(
        `${this.#name}: invalid callback handle id: ${handle}`
      );
    }
    if (UniFFIScaffolding.callbackHandleRelease(handle) == 0) {
      this.destroy(handle);
    }
    return entry.callbackObj;
  }

  /**
   * Borrow a previously stored callback object
   *
   * This does not consume the handle and keeps the refcount the same.
   * The only time we use this is when lifting the receiver for a callback interface method.
   *
   * @param {int} handle - Callback object handle, returned from `storeCallbackObj()`
   * @returns {obj} - Callback object
   */
  borrowCallbackObj(handle) {
    const entry = this.#handleMap.get(handle);
    if (entry === undefined) {
      throw new UniFFIError(
        `${this.#name}: invalid callback handle id: ${handle}`
      );
    }
    return entry.callbackObj;
  }

  /**
   * Get a UniFFICallbackMethodHandler
   *
   * @param {int} methodId - index of the method
   * @returns {UniFFICallbackMethodHandler}
   */
  getMethodHandler(methodId) {
    const methodHandler = this.#methodHandlers[methodId];
    if (methodHandler === undefined) {
      throw new UniFFIError(`${this.#name}: invalid method id: ${methodId}`);
    }
    return methodHandler;
  }

  /**
   * Set if new callbacks are allowed for this handler
   *
   * This is called with false during shutdown to ensure the callback maps don't
   * prevent JS objects from being GCed.
   */
  setAllowNewCallbacks(allow) {
    this.#allowNewCallbacks = allow;
  }

  /**
   * Check if there are any registered callbacks in the handle map
   *
   * This is used in the unit tests
   */
  hasRegisteredCallbacks() {
    return this.#handleMap.size > 0;
  }
  /**
   * Check that no callbacks are currently registered
   *
   * If there are callbacks registered a UniFFIError will be thrown.  This is
   * called during shutdown to generate an alert if there are leaked callback
   * interfaces.
   */
  assertNoRegisteredCallbacks() {
    if (this.#handleMap.size > 0) {
      const entry = this.#handleMap.values().next().value;
      throw new UniFFIError(
        `UniFFI interface ${this.#name} has ${this.#handleMap.size} registered callbacks at xpcom-shutdown. This likely indicates a UniFFI callback leak.\nStack trace for the first leaked callback:\n${entry.stackTrace}.`
      );
    }
  }

  /**
   * Invoke a method on a stored callback object
   *
   * @param {int} handle - Object handle
   * @param {int} methodId - Method index (0-based)
   * @param {UniFFIScaffoldingValue[]} args - Arguments to pass to the method
   */
  callSync(handle, methodId, ...args) {
    const callbackObj = this.borrowCallbackObj(handle);
    const methodHandler = this.getMethodHandler(methodId);
    try {
      const returnValue = methodHandler.call(callbackObj, args);
      return methodHandler.lowerReturn(returnValue);
    } catch (e) {
      return methodHandler.lowerError(e);
    }
  }

  /**
   * Invoke a method on a stored callback object
   *
   * @param {int} handle - Object handle
   * @param {int} methodId - Method index (0-based)
   * @param {UniFFIScaffoldingValue[]} args - Arguments to pass to the method
   */
  async callAsync(handle, methodId, ...args) {
    const callbackObj = this.borrowCallbackObj(handle);
    const methodHandler = this.getMethodHandler(methodId);
    try {
      const returnValue = await methodHandler.call(callbackObj, args);
      return methodHandler.lowerReturn(returnValue);
    } catch (e) {
      return methodHandler.lowerError(e);
    }
  }

  /**
   * Destroy a stored callback object
   *
   * @param {int} handle - Object handle
   */
  destroy(handle) {
    this.#handleMap.delete(handle);
    UniFFIScaffolding.callbackHandleFree(handle);
  }

  /**
   * xpcom-shutdown observer method
   *
   * This handles:
   *  - Deregistering ourselves as the UniFFI callback handler
   *  - Checks for any leftover stored callbacks which indicate memory leaks
   */
  observe(aSubject, aTopic, _aData) {
    if (aTopic == "xpcom-shutdown") {
      try {
        this.setAllowNewCallbacks(false);
        this.assertNoRegisteredCallbacks();
        UniFFIScaffolding.deregisterCallbackHandler(this.#interfaceId);
      } catch (ex) {
        console.error(
          `UniFFI Callback interface error during xpcom-shutdown: ${ex}`
        );
        // See bug 2034905 for filename / fileName shenanigans.
        Cc["@mozilla.org/xpcom/debug;1"]
          .getService(Ci.nsIDebug2)
          .abort(ex.filename || ex.fileName, ex.lineNumber);
      }
    }
  }
}

/**
 * Handles calling a single method for a callback interface
 */
export class UniFFICallbackMethodHandler {
  #name;
  #argsConverters;
  #returnConverter;
  #errorConverter;

  /**
     * Create a UniFFICallbackMethodHandler

     * @param {string} name -- Name of the method to call on the callback object
     * @param {FfiConverter[]} argsConverters - FfiConverter for each argument type
     */
  constructor(name, argsConverters, returnConverter, errorConverter) {
    this.#name = name;
    this.#argsConverters = argsConverters;
    this.#returnConverter = returnConverter;
    this.#errorConverter = errorConverter;
  }

  call(callbackObj, args) {
    const convertedArgs = this.#argsConverters.map((converter, i) =>
      converter.lift(args[i])
    );
    return callbackObj[this.#name](...convertedArgs);
  }

  lowerReturn(returnValue) {
    return {
      code: "success",
      data: this.#returnConverter(returnValue),
    };
  }

  lowerError(error) {
    return {
      code: "error",
      data: this.#errorConverter(error),
    };
  }

  toString() {
    return `CallbackMethodHandler(${this.#name})`;
  }
}

/**
 * UniFFICallbackHandler.handleMap entry
 *
 * @property callbackObj - Callback object, this must implement the callback interface.
 * @property {string} stackTrace - Stack trace from when the callback object was registered.  This is used to proved extra context when debugging leaked callback objects.
 */
class UniFFICallbackHandleMapEntry {
  constructor(callbackObj, stackTrace) {
    this.callbackObj = callbackObj;
    this.stackTrace = stackTrace;
  }
}
