// Copyright 2009 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import type * as ProtocolProxyApi from '../../generated/protocol-proxy-api.js';
import * as Protocol from '../../generated/protocol.js';
import type {DOMPinnedWebIDLProp, DOMPinnedWebIDLType} from '../common/JavaScriptMetaData.js';

import type {DebuggerModel, FunctionDetails} from './DebuggerModel.js';
import type {RuntimeModel} from './RuntimeModel.js';

/** This cannot be an interface due to "instanceof RemoteObject" checks in the code. **/
export abstract class RemoteObject {
  static fromLocalObject(value: unknown): RemoteObject {
    return new LocalJSONObject(value);
  }

  static type(remoteObject: RemoteObject): string {
    if (remoteObject === null) {
      return 'null';
    }

    const type = typeof remoteObject;
    if (type !== 'object' && type !== 'function') {
      return type;
    }

    return remoteObject.type;
  }

  static isNullOrUndefined(remoteObject?: RemoteObject): boolean {
    if (remoteObject === undefined) {
      return true;
    }
    switch (remoteObject.type) {
      case Protocol.Runtime.RemoteObjectType.Object:
        return remoteObject.subtype === Protocol.Runtime.RemoteObjectSubtype.Null;
      case Protocol.Runtime.RemoteObjectType.Undefined:
        return true;
      default:
        return false;
    }
  }

  static arrayNameFromDescription(description: string): string {
    return description.replace(descriptionLengthParenRegex, '').replace(descriptionLengthSquareRegex, '');
  }

  static arrayLength(object: RemoteObject|Protocol.Runtime.RemoteObject|Protocol.Runtime.ObjectPreview): number {
    if (object.subtype !== 'array' && object.subtype !== 'typedarray') {
      return 0;
    }
    // Array lengths in V8-generated descriptions switched from square brackets to parentheses.
    // Both formats are checked in case the front end is dealing with an old version of V8.
    const parenMatches = object.description?.match(descriptionLengthParenRegex);
    const squareMatches = object.description?.match(descriptionLengthSquareRegex);
    return parenMatches ? parseInt(parenMatches[1], 10) : (squareMatches ? parseInt(squareMatches[1], 10) : 0);
  }

  static arrayBufferByteLength(object: RemoteObject|Protocol.Runtime.RemoteObject|Protocol.Runtime.ObjectPreview):
      number {
    if (object.subtype !== 'arraybuffer') {
      return 0;
    }
    const matches = object.description?.match(descriptionLengthParenRegex);
    return matches ? parseInt(matches[1], 10) : 0;
  }

  static isEmptyArray(object: RemoteObject|Protocol.Runtime.RemoteObject|Protocol.Runtime.ObjectPreview): boolean {
    const matches = object.description?.match(descriptionLengthParenRegex);
    return Boolean(matches?.[1] === '0');
  }

  static unserializableDescription(object: unknown): string|null {
    if (typeof object === 'number') {
      const description = String(object);
      if (object === 0 && 1 / object < 0) {
        return UnserializableNumber.NEGATIVE_ZERO;
      }
      if (description === UnserializableNumber.NAN || description === UnserializableNumber.INFINITY ||
          description === UnserializableNumber.NEGATIVE_INFINITY) {
        return description;
      }
    }
    if (typeof object === 'bigint') {
      return object + 'n';
    }
    return null;
  }

  static toCallArgument(object: string|number|bigint|boolean|RemoteObject|Protocol.Runtime.RemoteObject|null|undefined):
      Protocol.Runtime.CallArgument {
    const type = typeof object;
    if (type === 'undefined') {
      return {};
    }
    const unserializableDescription = RemoteObject.unserializableDescription(object);
    if (type === 'number') {
      if (unserializableDescription !== null) {
        return {unserializableValue: unserializableDescription};
      }
      return {value: object};
    }
    if (type === 'bigint') {
      return {unserializableValue: unserializableDescription ?? undefined};
    }
    if (type === 'string' || type === 'boolean') {
      return {value: object};
    }

    if (!object) {
      return {value: null};
    }

    // The unserializableValue is a function on RemoteObject's and a simple property on
    // Protocol.Runtime.RemoteObject's.
    const objectAsProtocolRemoteObject = (object as Protocol.Runtime.RemoteObject);
    if (object instanceof RemoteObject) {
      const unserializableValue = object.unserializableValue();
      if (unserializableValue !== undefined) {
        return {unserializableValue};
      }
    } else if (objectAsProtocolRemoteObject.unserializableValue !== undefined) {
      return {unserializableValue: objectAsProtocolRemoteObject.unserializableValue};
    }

    if (typeof objectAsProtocolRemoteObject.objectId !== 'undefined') {
      return {objectId: objectAsProtocolRemoteObject.objectId};
    }

    return {value: objectAsProtocolRemoteObject.value};
  }

  static async loadFromObjectPerProto(object: RemoteObject, generatePreview: boolean, nonIndexedPropertiesOnly = false):
      Promise<GetPropertiesResult> {
    const result = await Promise.all([
      object.getAllProperties(true /* accessorPropertiesOnly */, generatePreview, nonIndexedPropertiesOnly),
      object.getOwnProperties(generatePreview, nonIndexedPropertiesOnly),
    ]);
    const accessorProperties = result[0].properties;
    const ownProperties = result[1].properties;
    const internalProperties = result[1].internalProperties;
    if (!ownProperties || !accessorProperties) {
      return {properties: null, internalProperties: null};
    }
    const propertiesMap = new Map<string, RemoteObjectProperty>();
    const propertySymbols = [];
    for (let i = 0; i < accessorProperties.length; i++) {
      const property = accessorProperties[i];
      if (property.symbol) {
        propertySymbols.push(property);
      } else if (property.isOwn || property.name !== '__proto__') {
        // TODO(crbug/1076820): Eventually we should move away from
        // showing accessor #properties directly on the receiver.
        propertiesMap.set(property.name, property);
      }
    }
    for (let i = 0; i < ownProperties.length; i++) {
      const property = ownProperties[i];
      if (property.isAccessorProperty()) {
        continue;
      }
      if (property.private || property.symbol) {
        propertySymbols.push(property);
      } else {
        propertiesMap.set(property.name, property);
      }
    }
    return {
      properties: [...propertiesMap.values()].concat(propertySymbols),
      internalProperties: internalProperties ? internalProperties : null,
    };
  }

  customPreview(): Protocol.Runtime.CustomPreview|null {
    return null;
  }

  abstract get objectId(): Protocol.Runtime.RemoteObjectId|undefined;
  abstract get type(): string;

  abstract get subtype(): string|undefined;

  // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  abstract get value(): any;

  abstract get description(): string|undefined;
  abstract set description(description: string|undefined);

  abstract get hasChildren(): boolean;

  abstract arrayLength(): number;

  abstract getOwnProperties(generatePreview: boolean, nonIndexedPropertiesOnly?: boolean): Promise<GetPropertiesResult>;

  abstract getAllProperties(
      accessorPropertiesOnly: boolean, generatePreview: boolean,
      nonIndexedPropertiesOnly?: boolean): Promise<GetPropertiesResult>;

  unserializableValue(): string|undefined {
    throw new Error('Not implemented');
  }

  get preview(): Protocol.Runtime.ObjectPreview|undefined {
    return undefined;
  }

  get className(): string|null {
    return null;
  }

  callFunction<T, U>(_functionDeclaration: (this: U, ...args: any[]) => T, _args?: Protocol.Runtime.CallArgument[]):
      Promise<CallFunctionResult> {
    throw new Error('Not implemented');
  }

  callFunctionJSON<T, U>(
      _functionDeclaration: (this: U, ...args: any[]) => T,
      _args: Protocol.Runtime.CallArgument[]|undefined): Promise<T|null> {
    throw new Error('Not implemented');
  }

  arrayBufferByteLength(): number {
    throw new Error('Not implemented');
  }

  deleteProperty(_name: Protocol.Runtime.CallArgument): Promise<string|undefined> {
    throw new Error('Not implemented');
  }

  setPropertyValue(_name: string|Protocol.Runtime.CallArgument, _value: string): Promise<string|undefined> {
    throw new Error('Not implemented');
  }

  release(): void {
  }

  debuggerModel(): DebuggerModel {
    throw new Error('DebuggerModel-less object');
  }

  runtimeModel(): RuntimeModel {
    throw new Error('RuntimeModel-less object');
  }

  isNode(): boolean {
    return false;
  }

  /**
   * Checks whether this object can be inspected with the Linear memory inspector.
   * @returns `true` if this object can be inspected with the Linear memory inspector.
   */
  isLinearMemoryInspectable(): boolean {
    return false;
  }

  webIdl?: RemoteObjectWebIdlTypeMetadata;
}

export class RemoteObjectImpl extends RemoteObject {
  #runtimeModel: RuntimeModel;
  readonly #runtimeAgent: ProtocolProxyApi.RuntimeApi;
  readonly #type: string;
  readonly #subtype: string|undefined;
  #objectId: Protocol.Runtime.RemoteObjectId|undefined;
  #description: string|undefined;
  #hasChildren: boolean;
  readonly #preview: Protocol.Runtime.ObjectPreview|undefined;
  readonly #unserializableValue: string|undefined;
  readonly #value: typeof RemoteObject.prototype.value;
  readonly #customPreview: Protocol.Runtime.CustomPreview|null;
  readonly #className: string|null;

  constructor(
      runtimeModel: RuntimeModel,
      objectId: Protocol.Runtime.RemoteObjectId|undefined,
      type: string,
      subtype: string|undefined,
      value: typeof RemoteObject.prototype.value,
      unserializableValue?: string,
      description?: string,
      preview?: Protocol.Runtime.ObjectPreview,
      customPreview?: Protocol.Runtime.CustomPreview,
      className?: string,
  ) {
    super();

    this.#runtimeModel = runtimeModel;
    this.#runtimeAgent = runtimeModel.target().runtimeAgent();

    this.#type = type;
    this.#subtype = subtype;
    if (objectId) {
      // handle
      this.#objectId = objectId;
      this.#description = description;
      this.#hasChildren = (type !== 'symbol');
      this.#preview = preview;
    } else {
      this.#description = description;
      if (!this.description && unserializableValue) {
        this.#description = unserializableValue;
      }
      if (!this.#description && (typeof value !== 'object' || value === null)) {
        this.#description = String(value);
      }
      this.#hasChildren = false;
      if (typeof unserializableValue === 'string') {
        this.#unserializableValue = unserializableValue;
        if (unserializableValue === UnserializableNumber.INFINITY ||
            unserializableValue === UnserializableNumber.NEGATIVE_INFINITY ||
            unserializableValue === UnserializableNumber.NEGATIVE_ZERO ||
            unserializableValue === UnserializableNumber.NAN) {
          this.#value = Number(unserializableValue);
        } else if (type === 'bigint' && unserializableValue.endsWith('n')) {
          this.#value = BigInt(unserializableValue.substring(0, unserializableValue.length - 1));
        } else {
          this.#value = unserializableValue;
        }

      } else {
        this.#value = value;
      }
    }
    this.#customPreview = customPreview || null;
    this.#className = typeof className === 'string' ? className : null;
  }

  override customPreview(): Protocol.Runtime.CustomPreview|null {
    return this.#customPreview;
  }

  override get objectId(): Protocol.Runtime.RemoteObjectId|undefined {
    return this.#objectId;
  }

  override get type(): string {
    return this.#type;
  }

  override get subtype(): string|undefined {
    return this.#subtype;
  }

  override get value(): typeof RemoteObject.prototype.value {
    return this.#value;
  }

  override unserializableValue(): string|undefined {
    return this.#unserializableValue;
  }

  override get description(): string|undefined {
    return this.#description;
  }

  override set description(description: string|undefined) {
    this.#description = description;
  }

  override get hasChildren(): boolean {
    return this.#hasChildren;
  }

  override get preview(): Protocol.Runtime.ObjectPreview|undefined {
    return this.#preview;
  }

  override get className(): string|null {
    return this.#className;
  }

  override getOwnProperties(generatePreview: boolean, nonIndexedPropertiesOnly = false): Promise<GetPropertiesResult> {
    return this.doGetProperties(true, false, nonIndexedPropertiesOnly, generatePreview);
  }

  override getAllProperties(
      accessorPropertiesOnly: boolean, generatePreview: boolean,
      nonIndexedPropertiesOnly = false): Promise<GetPropertiesResult> {
    return this.doGetProperties(false, accessorPropertiesOnly, nonIndexedPropertiesOnly, generatePreview);
  }

  async createRemoteObject(object: Protocol.Runtime.RemoteObject): Promise<RemoteObject> {
    return this.#runtimeModel.createRemoteObject(object);
  }

  async doGetProperties(
      ownProperties: boolean, accessorPropertiesOnly: boolean, nonIndexedPropertiesOnly: boolean,
      generatePreview: boolean): Promise<GetPropertiesResult> {
    if (!this.#objectId) {
      return {properties: null, internalProperties: null};
    }

    const response = await this.#runtimeAgent.invoke_getProperties({
      objectId: this.#objectId,
      ownProperties,
      accessorPropertiesOnly,
      nonIndexedPropertiesOnly,
      generatePreview,
    });
    if (response.getError()) {
      return {properties: null, internalProperties: null};
    }
    if (response.exceptionDetails) {
      this.#runtimeModel.exceptionThrown(Date.now(), response.exceptionDetails);
      return {properties: null, internalProperties: null};
    }
    const {result: properties = [], internalProperties = [], privateProperties = []} = response;
    const result = [];
    for (const property of properties) {
      const propertyValue = property.value ? await this.createRemoteObject(property.value) : null;
      const propertySymbol = property.symbol ? this.#runtimeModel.createRemoteObject(property.symbol) : null;
      const remoteProperty = new RemoteObjectProperty(
          property.name, propertyValue, Boolean(property.enumerable), Boolean(property.writable),
          Boolean(property.isOwn), Boolean(property.wasThrown), propertySymbol);

      if (typeof property.value === 'undefined') {
        if (property.get && property.get.type !== 'undefined') {
          remoteProperty.getter = this.#runtimeModel.createRemoteObject(property.get);
        }
        if (property.set && property.set.type !== 'undefined') {
          remoteProperty.setter = this.#runtimeModel.createRemoteObject(property.set);
        }
      }
      result.push(remoteProperty);
    }
    for (const property of privateProperties) {
      const propertyValue = property.value ? this.#runtimeModel.createRemoteObject(property.value) : null;
      const remoteProperty = new RemoteObjectProperty(
          property.name, propertyValue, true, true, true, false, undefined, false, undefined, true);

      if (typeof property.value === 'undefined') {
        if (property.get && property.get.type !== 'undefined') {
          remoteProperty.getter = this.#runtimeModel.createRemoteObject(property.get);
        }
        if (property.set && property.set.type !== 'undefined') {
          remoteProperty.setter = this.#runtimeModel.createRemoteObject(property.set);
        }
      }
      result.push(remoteProperty);
    }

    const internalPropertiesResult = [];
    for (const property of internalProperties) {
      if (!property.value) {
        continue;
      }
      const propertyValue = this.#runtimeModel.createRemoteObject(property.value);
      internalPropertiesResult.push(
          new RemoteObjectProperty(property.name, propertyValue, true, false, undefined, undefined, undefined, true));
    }
    return {properties: result, internalProperties: internalPropertiesResult};
  }

  override async setPropertyValue(name: string|Protocol.Runtime.CallArgument, value: string):
      Promise<string|undefined> {
    if (!this.#objectId) {
      return 'Can’t set a property of non-object.';
    }

    const response = await this.#runtimeAgent.invoke_evaluate({expression: value, silent: true});
    if (response.getError() || response.exceptionDetails) {
      return response.getError() ||
          (response.result.type !== 'string' ? response.result.description : response.result.value as string);
    }

    if (typeof name === 'string') {
      name = RemoteObject.toCallArgument(name);
    }

    const resultPromise = this.doSetObjectPropertyValue(response.result, name);

    if (response.result.objectId) {
      void this.#runtimeAgent.invoke_releaseObject({objectId: response.result.objectId});
    }

    return await resultPromise;
  }

  async doSetObjectPropertyValue(result: Protocol.Runtime.RemoteObject, name: Protocol.Runtime.CallArgument):
      Promise<string|undefined> {
    // This assignment may be for a regular (data) property, and for an accessor property (with getter/setter).
    // Note the sensitive matter about accessor property: the property may be physically defined in some proto object,
    // but logically it is bound to the object in question. JavaScript passes this object to getters/setters, not the object
    // where property was defined; so do we.
    const setPropertyValueFunction = 'function(a, b) { this[a] = b; }';

    const argv = [name, RemoteObject.toCallArgument(result)];
    const response = await this.#runtimeAgent.invoke_callFunctionOn({
      objectId: this.#objectId,
      functionDeclaration: setPropertyValueFunction,
      arguments: argv,
      silent: true,
    });
    const error = response.getError();
    return error || response.exceptionDetails ? error || response.result.description : undefined;
  }

  override async deleteProperty(name: Protocol.Runtime.CallArgument): Promise<string|undefined> {
    if (!this.#objectId) {
      return 'Can’t delete a property of non-object.';
    }

    const deletePropertyFunction = 'function(a) { delete this[a]; return !(a in this); }';
    const response = await this.#runtimeAgent.invoke_callFunctionOn({
      objectId: this.#objectId,
      functionDeclaration: deletePropertyFunction,
      arguments: [name],
      silent: true,
    });

    if (response.getError() || response.exceptionDetails) {
      return response.getError() || response.result.description;
    }

    if (!response.result.value) {
      return 'Failed to delete property.';
    }

    return undefined;
  }

  override async callFunction<T, U>(
      functionDeclaration: (this: U, ...args: any[]) => T,
      args?: Protocol.Runtime.CallArgument[]): Promise<CallFunctionResult> {
    const response = await this.#runtimeAgent.invoke_callFunctionOn({
      objectId: this.#objectId,
      functionDeclaration: functionDeclaration.toString(),
      arguments: args,
      silent: true,
    });
    if (response.getError()) {
      return {object: null, wasThrown: false};
    }
    // TODO: release exceptionDetails object
    return {
      object: this.#runtimeModel.createRemoteObject(response.result),
      wasThrown: Boolean(response.exceptionDetails),
    };
  }

  override async callFunctionJSON<T, U>(
      functionDeclaration: (this: U, ...args: any[]) => T,
      args: Protocol.Runtime.CallArgument[]|undefined): Promise<T|null> {
    const response = await this.#runtimeAgent.invoke_callFunctionOn({
      objectId: this.#objectId,
      functionDeclaration: functionDeclaration.toString(),
      arguments: args,
      silent: true,
      returnByValue: true,
    });
    if (response.getError() || response.exceptionDetails) {
      return null;
    }

    return response.result.value;
  }

  override release(): void {
    if (!this.#objectId) {
      return;
    }
    void this.#runtimeAgent.invoke_releaseObject({objectId: this.#objectId});
  }

  override arrayLength(): number {
    return RemoteObject.arrayLength(this);
  }

  override arrayBufferByteLength(): number {
    return RemoteObject.arrayBufferByteLength(this);
  }

  override debuggerModel(): DebuggerModel {
    return this.#runtimeModel.debuggerModel();
  }

  override runtimeModel(): RuntimeModel {
    return this.#runtimeModel;
  }

  override isNode(): boolean {
    return Boolean(this.#objectId) && this.type === 'object' && this.subtype === 'node';
  }

  override isLinearMemoryInspectable(): boolean {
    return this.type === 'object' && this.subtype !== undefined &&
        ['webassemblymemory', 'typedarray', 'dataview', 'arraybuffer'].includes(this.subtype) &&
        !RemoteObject.isEmptyArray(this);
  }
}

export class ScopeRemoteObject extends RemoteObjectImpl {
  readonly #scopeRef: ScopeRef;
  #savedScopeProperties: RemoteObjectProperty[]|undefined;

  constructor(
      runtimeModel: RuntimeModel, objectId: Protocol.Runtime.RemoteObjectId|undefined, scopeRef: ScopeRef, type: string,
      subtype: string|undefined, value: typeof RemoteObjectImpl.prototype.value, unserializableValue?: string,
      description?: string, preview?: Protocol.Runtime.ObjectPreview) {
    super(runtimeModel, objectId, type, subtype, value, unserializableValue, description, preview);
    this.#scopeRef = scopeRef;
    this.#savedScopeProperties = undefined;
  }

  override async doGetProperties(ownProperties: boolean, accessorPropertiesOnly: boolean, _generatePreview: boolean):
      Promise<GetPropertiesResult> {
    if (accessorPropertiesOnly) {
      return {properties: [], internalProperties: []};
    }

    if (this.#savedScopeProperties) {
      // No need to reload scope variables, as the remote object never
      // changes its #properties. If variable is updated, the #properties
      // array is patched locally.
      return {properties: this.#savedScopeProperties.slice(), internalProperties: null};
    }

    const allProperties = await super.doGetProperties(
        ownProperties, accessorPropertiesOnly, false /* nonIndexedPropertiesOnly */, true /* generatePreview */);
    if (Array.isArray(allProperties.properties)) {
      this.#savedScopeProperties = allProperties.properties.slice();
    }
    return allProperties;
  }

  override async doSetObjectPropertyValue(
      result: Protocol.Runtime.RemoteObject, argumentName: Protocol.Runtime.CallArgument): Promise<string|undefined> {
    const name = (argumentName.value as string);
    const error = await this.debuggerModel().setVariableValue(
        this.#scopeRef.number, name, RemoteObject.toCallArgument(result), this.#scopeRef.callFrameId);
    if (error) {
      return error;
    }
    if (this.#savedScopeProperties) {
      for (const property of this.#savedScopeProperties) {
        if (property.name === name) {
          property.value = this.runtimeModel().createRemoteObject(result);
        }
      }
    }
    return;
  }
}

export class ScopeRef {
  readonly number: number;
  readonly callFrameId: Protocol.Debugger.CallFrameId;

  constructor(number: number, callFrameId: Protocol.Debugger.CallFrameId) {
    this.number = number;
    this.callFrameId = callFrameId;
  }
}

export class RemoteObjectProperty {
  name: string;
  value?: RemoteObject;
  enumerable: boolean;
  writable: boolean;
  isOwn: boolean;
  wasThrown: boolean;
  symbol: RemoteObject|undefined;
  synthetic: boolean;
  syntheticSetter: ((arg0: string) => Promise<RemoteObject|null>)|undefined;

  private: boolean;
  getter: RemoteObject|undefined;
  setter: RemoteObject|undefined;

  webIdl?: RemoteObjectWebIdlPropertyMetadata;

  constructor(
      name: string, value: RemoteObject|null, enumerable?: boolean, writable?: boolean, isOwn?: boolean,
      wasThrown?: boolean, symbol?: RemoteObject|null, synthetic?: boolean,
      syntheticSetter?: ((arg0: string) => Promise<RemoteObject|null>), isPrivate?: boolean) {
    this.name = name;
    this.value = value !== null ? value : undefined;
    this.enumerable = typeof enumerable !== 'undefined' ? enumerable : true;
    const isNonSyntheticOrSyntheticWritable = !synthetic || Boolean(syntheticSetter);
    this.writable = typeof writable !== 'undefined' ? writable : isNonSyntheticOrSyntheticWritable;
    this.isOwn = Boolean(isOwn);
    this.wasThrown = Boolean(wasThrown);
    if (symbol) {
      this.symbol = symbol;
    }
    this.synthetic = Boolean(synthetic);
    if (syntheticSetter) {
      this.syntheticSetter = syntheticSetter;
    }
    this.private = Boolean(isPrivate);
  }

  async setSyntheticValue(expression: string): Promise<boolean> {
    if (!this.syntheticSetter) {
      return false;
    }
    const result = await this.syntheticSetter(expression);
    if (result) {
      this.value = result;
    }
    return Boolean(result);
  }

  isAccessorProperty(): boolean {
    return Boolean(this.getter || this.setter);
  }

  match({includeNullOrUndefinedValues, regex}: {includeNullOrUndefinedValues: boolean, regex: RegExp|null}): boolean {
    if (regex !== null) {
      if (!regex.test(this.name) && !regex.test(this.value?.description ?? '')) {
        return false;
      }
    }
    if (!includeNullOrUndefinedValues) {
      if (!this.isAccessorProperty() && RemoteObject.isNullOrUndefined(this.value)) {
        return false;
      }
    }
    return true;
  }

  cloneWithNewName(newName: string): RemoteObjectProperty {
    const property = new RemoteObjectProperty(
        newName, this.value ?? null, this.enumerable, this.writable, this.isOwn, this.wasThrown, this.symbol,
        this.synthetic, this.syntheticSetter, this.private);
    property.getter = this.getter;
    property.setter = this.setter;
    return property;
  }
}

// Below is a wrapper around a local object that implements the RemoteObject interface,
// which can be used by the UI code (primarily ObjectPropertiesSection).
// Note that only JSON-compliant objects are currently supported, as there's no provision
// for traversing prototypes, extracting class names via constructor, handling #properties
// or functions.

export class LocalJSONObject extends RemoteObject {
  #value: typeof RemoteObject.prototype.value;
  #cachedDescription!: string;
  #cachedChildren!: RemoteObjectProperty[];

  constructor(value: typeof RemoteObject.prototype.value) {
    super();
    this.#value = value;
  }

  override get objectId(): Protocol.Runtime.RemoteObjectId|undefined {
    return undefined;
  }

  override get value(): typeof RemoteObject.prototype.value {
    return this.#value;
  }

  override unserializableValue(): string|undefined {
    const unserializableDescription = RemoteObject.unserializableDescription(this.#value);
    return unserializableDescription || undefined;
  }

  override get description(): string {
    if (this.#cachedDescription) {
      return this.#cachedDescription;
    }

    function formatArrayItem(this: LocalJSONObject, property: RemoteObjectProperty): string {
      return this.formatValue(property.value || null);
    }

    function formatObjectItem(this: LocalJSONObject, property: RemoteObjectProperty): string {
      let name: string = property.name;
      if (/^\s|\s$|^$|\n/.test(name)) {
        name = '"' + name.replace(/\n/g, '\u21B5') + '"';
      }
      return name + ': ' + this.formatValue(property.value || null);
    }

    if (this.type === 'object') {
      switch (this.subtype) {
        case 'array':
          this.#cachedDescription = this.concatenate('[', ']', formatArrayItem.bind(this));
          break;
        case 'date':
          this.#cachedDescription = String(this.#value);
          break;
        case 'null':
          this.#cachedDescription = 'null';
          break;
        default:
          this.#cachedDescription = this.concatenate('{', '}', formatObjectItem.bind(this));
      }
    } else {
      this.#cachedDescription = String(this.#value);
    }

    return this.#cachedDescription;
  }

  private formatValue(value: RemoteObject|null): string {
    if (!value) {
      return 'undefined';
    }
    const description = value.description || '';
    if (value.type === 'string') {
      return '"' + description.replace(/\n/g, '\u21B5') + '"';
    }
    return description;
  }

  private concatenate(prefix: string, suffix: string, formatProperty: (arg0: RemoteObjectProperty) => string): string {
    const previewChars = 100;

    let buffer = prefix;
    const children = this.children();
    for (let i = 0; i < children.length; ++i) {
      const itemDescription = formatProperty(children[i]);
      if (buffer.length + itemDescription.length > previewChars) {
        buffer += ',…';
        break;
      }
      if (i) {
        buffer += ', ';
      }
      buffer += itemDescription;
    }
    buffer += suffix;
    return buffer;
  }

  override get type(): string {
    return typeof this.#value;
  }

  override get subtype(): string|undefined {
    if (this.#value === null) {
      return 'null';
    }

    if (Array.isArray(this.#value)) {
      return 'array';
    }

    if (this.#value instanceof Date) {
      return 'date';
    }

    if (this.#value instanceof Error) {
      return 'error';
    }

    return undefined;
  }

  override get hasChildren(): boolean {
    if ((typeof this.#value !== 'object') || (this.#value === null)) {
      return false;
    }
    return Boolean(Object.keys(this.#value).length);
  }

  override async getOwnProperties(_generatePreview: boolean, nonIndexedPropertiesOnly = false):
      Promise<GetPropertiesResult> {
    function isArrayIndex(name: string): boolean {
      const index = Number(name) >>> 0;
      return String(index) === name;
    }

    let properties = this.children();
    if (nonIndexedPropertiesOnly) {
      properties = properties.filter(property => !isArrayIndex(property.name));
    }
    return {properties, internalProperties: null};
  }

  override async getAllProperties(
      accessorPropertiesOnly: boolean, generatePreview: boolean,
      nonIndexedPropertiesOnly = false): Promise<GetPropertiesResult> {
    if (accessorPropertiesOnly) {
      return {properties: [], internalProperties: null};
    }
    return await this.getOwnProperties(generatePreview, nonIndexedPropertiesOnly);
  }

  private children(): RemoteObjectProperty[] {
    if (!this.hasChildren) {
      return [];
    }
    if (!this.#cachedChildren) {
      this.#cachedChildren = Object.entries(this.#value).map(([name, value]) => {
        return new RemoteObjectProperty(
            name, value instanceof RemoteObject ? value : RemoteObject.fromLocalObject(value));
      });
    }
    return this.#cachedChildren;
  }

  override arrayLength(): number {
    return Array.isArray(this.#value) ? this.#value.length : 0;
  }

  override async callFunction<T, U>(
      functionDeclaration: (this: U, ...args: any[]) => T,
      args?: Protocol.Runtime.CallArgument[]): Promise<CallFunctionResult> {
    const target = this.#value as U;
    const rawArgs = args ? args.map(arg => arg.value) : [];

    let result;
    let wasThrown = false;
    try {
      result = functionDeclaration.apply(target, rawArgs);
    } catch {
      wasThrown = true;
    }

    const object = RemoteObject.fromLocalObject(result);

    return {object, wasThrown};
  }

  override async callFunctionJSON<T, U>(
      functionDeclaration: (this: U, ...args: any[]) => T,
      args: Protocol.Runtime.CallArgument[]|undefined): Promise<T|null> {
    const target = this.#value as U;
    const rawArgs = args ? args.map(arg => arg.value) : [];

    let result;
    try {
      result = functionDeclaration.apply(target, rawArgs);
    } catch {
      result = null;
    }

    return result;
  }
}

export class RemoteArrayBuffer {
  readonly #object: RemoteObject;
  constructor(object: RemoteObject) {
    if (object.type !== 'object' || object.subtype !== 'arraybuffer') {
      throw new Error('Object is not an arraybuffer');
    }
    this.#object = object;
  }

  byteLength(): number {
    return this.#object.arrayBufferByteLength();
  }

  async bytes(start = 0, end = this.byteLength()): Promise<number[]|null> {
    if (start < 0 || start >= this.byteLength()) {
      throw new RangeError('start is out of range');
    }
    if (end < start || end > this.byteLength()) {
      throw new RangeError('end is out of range');
    }
    return await this.#object.callFunctionJSON(bytes, [{value: start}, {value: end - start}]);

    function bytes(this: ArrayBuffer, offset: number, length: number): number[] {
      return [...new Uint8Array(this, offset, length)];
    }
  }

  object(): RemoteObject {
    return this.#object;
  }
}

export class RemoteArray {
  readonly #object: RemoteObject;
  constructor(object: RemoteObject) {
    this.#object = object;
  }

  static objectAsArray(object: RemoteObject|null): RemoteArray {
    if (object?.type !== 'object' || (object.subtype !== 'array' && object.subtype !== 'typedarray')) {
      throw new Error('Object is empty or not an array');
    }
    return new RemoteArray(object);
  }

  static async createFromRemoteObjects(objects: RemoteObject[]): Promise<RemoteArray> {
    if (!objects.length) {
      throw new Error('Input array is empty');
    }
    const result = await objects[0].callFunction(createArray, objects.map(RemoteObject.toCallArgument));
    if (result.wasThrown || !result.object) {
      throw new Error('Call function throws exceptions or returns empty value');
    }
    return RemoteArray.objectAsArray(result.object);

    function createArray<T>(...args: T[]): T[] {
      return args;
    }
  }

  async at(index: number): Promise<RemoteObject> {
    if (index < 0 || index > this.#object.arrayLength()) {
      throw new Error('Out of range');
    }
    const result = await this.#object.callFunction<unknown, unknown[]>(at, [RemoteObject.toCallArgument(index)]);
    if (result.wasThrown || !result.object) {
      throw new Error('Exception in callFunction or result value is empty');
    }
    return result.object;

    function at<T>(this: T[], index: number): T {
      return this[index];
    }
  }

  length(): number {
    return this.#object.arrayLength();
  }

  map<T>(func: (arg0: RemoteObject) => Promise<T>): Promise<T[]> {
    const promises = [];
    for (let i = 0; i < this.length(); ++i) {
      promises.push(this.at(i).then(func));
    }
    return Promise.all(promises);
  }

  object(): RemoteObject {
    return this.#object;
  }
}

export class RemoteFunction {
  readonly #object: RemoteObject;

  constructor(object: RemoteObject) {
    this.#object = object;
  }

  static objectAsFunction(object: RemoteObject): RemoteFunction {
    if (object.type !== 'function') {
      throw new Error('Object is empty or not a function');
    }
    return new RemoteFunction(object);
  }

  async targetFunction(): Promise<RemoteObject> {
    const ownProperties = await this.#object.getOwnProperties(false /* generatePreview */);
    const targetFunction = ownProperties.internalProperties?.find(({name}) => name === '[[TargetFunction]]');
    return targetFunction?.value ?? this.#object;
  }

  async targetFunctionDetails(): Promise<FunctionDetails|null> {
    const targetFunction = await this.targetFunction();
    const functionDetails = await targetFunction.debuggerModel().functionDetailsPromise(targetFunction);
    if (this.#object !== targetFunction) {
      targetFunction.release();
    }
    return functionDetails;
  }
}

export class RemoteError {
  readonly #object: RemoteObject;

  #exceptionDetails?: Promise<Protocol.Runtime.ExceptionDetails|undefined>;
  #cause?: Promise<RemoteObject|undefined>;

  private constructor(object: RemoteObject) {
    this.#object = object;
  }

  static objectAsError(object: RemoteObject): RemoteError {
    if (object.subtype !== 'error') {
      throw new Error(`Object of type ${object.subtype} is not an error`);
    }
    return new RemoteError(object);
  }

  get errorStack(): string {
    return this.#object.description ?? '';
  }

  exceptionDetails(): Promise<Protocol.Runtime.ExceptionDetails|undefined> {
    if (!this.#exceptionDetails) {
      this.#exceptionDetails = this.#lookupExceptionDetails();
    }
    return this.#exceptionDetails;
  }

  #lookupExceptionDetails(): Promise<Protocol.Runtime.ExceptionDetails|undefined> {
    if (this.#object.objectId) {
      return this.#object.runtimeModel().getExceptionDetails(this.#object.objectId);
    }
    return Promise.resolve(undefined);
  }

  cause(): Promise<RemoteObject|undefined> {
    if (!this.#cause) {
      this.#cause = this.#lookupCause();
    }
    return this.#cause;
  }

  async #lookupCause(): Promise<RemoteObject|undefined> {
    const allProperties =
        await this.#object.getAllProperties(false /* accessorPropertiesOnly */, false /* generatePreview */);
    const cause = allProperties.properties?.find(prop => prop.name === 'cause');

    return cause?.value;
  }
}

const descriptionLengthParenRegex = /\(([0-9]+)\)/;
const descriptionLengthSquareRegex = /\[([0-9]+)\]/;

const enum UnserializableNumber {
  NEGATIVE_ZERO = ('-0'),
  NAN = ('NaN'),
  INFINITY = ('Infinity'),
  NEGATIVE_INFINITY = ('-Infinity'),
}

export interface CallFunctionResult {
  object: RemoteObject|null;
  wasThrown?: boolean;
}

export interface GetPropertiesResult {
  properties: RemoteObjectProperty[]|null;
  internalProperties: RemoteObjectProperty[]|null;
}

export interface RemoteObjectWebIdlTypeMetadata {
  info: DOMPinnedWebIDLType;
  state: Map<string, string>;
}

export interface RemoteObjectWebIdlPropertyMetadata {
  info: DOMPinnedWebIDLProp;
  applicable?: boolean;
}

/**
 * Pair of a linear memory inspectable {@link RemoteObject} and an optional
 * expression, which identifies the variable holding the object in the
 * current scope or the name of the field holding the object.
 *
 * This data structure is used to reveal an object in the Linear Memory
 * Inspector panel.
 */
export class LinearMemoryInspectable {
  /** The linear memory inspectable {@link RemoteObject}. */
  readonly object: RemoteObject;
  /** The name of the variable or the field holding the `object`. */
  readonly expression: string|undefined;

  /**
   * Wrap `object` and `expression` into a reveable structure.
   *
   * @param object A linear memory inspectable {@link RemoteObject}.
   * @param expression An optional name of the field or variable holding the `object`.
   */
  constructor(object: RemoteObject, expression?: string) {
    if (!object.isLinearMemoryInspectable()) {
      throw new Error('object must be linear memory inspectable');
    }
    this.object = object;
    this.expression = expression;
  }
}
