export const description = `
Validate that various GPU objects behave correctly in JavaScript.

Examples:
  - GPUTexture, GPUBuffer, GPUQuerySet, GPUDevice, GPUAdapter, GPUAdapter.limits, GPUDevice.limits
    - return nothing for Object.keys()
    - adds no properties for {...object}
    - iterate over keys for (key in object)
    - throws for (key of object)
    - return nothing from Object.getOwnPropertyDescriptors
 - GPUAdapter.limits, GPUDevice.limits
     - can not be passed to requestAdapter as requiredLimits
 - GPUAdapter.features, GPUDevice.features
    - do spread to array
    - can be copied to set
    - can be passed to requestAdapter as requiredFeatures
`;

import { makeTestGroup } from '../../common/framework/test_group.js';
import { keysOf } from '../../common/util/data_tables.js';
import { getGPU } from '../../common/util/navigator_gpu.js';
import {
  assert,
  objectEquals,
  raceWithRejectOnTimeout,
  unreachable,
} from '../../common/util/util.js';
import { getDefaultLimitsForDevice, kPossibleLimits } from '../capability_info.js';
import { AllFeaturesMaxLimitsGPUTest, GPUTest } from '../gpu_test.js';

// MAINTENANCE_TODO: Remove this filter when these limits are added to the spec.
const isUnspecifiedLimit = (limit: string) =>
  /maxStorage(Buffer|Texture)sIn(Vertex|Fragment)Stage/.test(limit);

const kSpecifiedLimits = kPossibleLimits.filter(s => !isUnspecifiedLimit(s));

type ResourceInfo = {
  create: (t: GPUTest) => Object;
  requiredKeys: readonly string[];
  getters: readonly string[];
  settable: readonly string[];
  sameObject: readonly string[];
  // Note: some of the tests need to be skipped in compatibility mode related to 'gpu'.
  // This is because getGPU() doesn't return the actual `navigator.gpu` in compatibility mode.
  // Instead it returns something that's wrapped to request compatibility mode.
  skipInCompatibility?: boolean;
};
const kResourceInfo: { [key: string]: ResourceInfo } = {
  gpu: {
    create(t: GPUTest) {
      return getGPU(null);
    },
    requiredKeys: ['wgslLanguageFeatures', 'requestAdapter'],
    getters: ['wgslLanguageFeatures'],
    settable: [],
    sameObject: ['wgslLanguageFeatures'],
    skipInCompatibility: true,
  },
  buffer: {
    create(t: GPUTest) {
      return t.createBufferTracked({ size: 16, usage: GPUBufferUsage.UNIFORM });
    },
    requiredKeys: [
      'destroy',
      'getMappedRange',
      'label',
      'mapAsync',
      'mapState',
      'size',
      'unmap',
      'usage',
    ],
    getters: ['label', 'mapState', 'size', 'usage'],
    settable: ['label'],
    sameObject: [],
  },
  texture: {
    create(t: GPUTest) {
      return t.createTextureTracked({
        size: [2, 3],
        format: 'r8unorm',
        usage: GPUTextureUsage.TEXTURE_BINDING,
      });
    },
    requiredKeys: [
      'createView',
      'depthOrArrayLayers',
      'destroy',
      'dimension',
      'format',
      'height',
      'label',
      'mipLevelCount',
      'sampleCount',
      'usage',
      'width',
    ],
    getters: [
      'depthOrArrayLayers',
      'dimension',
      'format',
      'height',
      'label',
      'mipLevelCount',
      'sampleCount',
      'usage',
      'width',
    ],
    settable: ['label'],
    sameObject: [],
  },
  querySet: {
    create(t: GPUTest) {
      return t.createQuerySetTracked({
        type: 'occlusion',
        count: 2,
      });
    },
    requiredKeys: ['count', 'destroy', 'label', 'type'],
    getters: ['count', 'label', 'type'],
    settable: ['label'],
    sameObject: [],
  },
  adapter: {
    create(t: GPUTest) {
      return t.adapter;
    },
    requiredKeys: ['features', 'info', 'limits', 'requestDevice'],
    getters: ['features', 'info', 'limits'],
    settable: [],
    sameObject: ['features', 'info', 'limits'],
  },
  device: {
    create(t: GPUTest) {
      return t.device;
    },
    requiredKeys: [
      'adapterInfo',
      'addEventListener',
      'createBindGroup',
      'createBindGroupLayout',
      'createBuffer',
      'createCommandEncoder',
      'createComputePipeline',
      'createComputePipelineAsync',
      'createPipelineLayout',
      'createQuerySet',
      'createRenderBundleEncoder',
      'createRenderPipeline',
      'createRenderPipelineAsync',
      'createSampler',
      'createShaderModule',
      'createTexture',
      'destroy',
      'dispatchEvent',
      'features',
      'importExternalTexture',
      'label',
      'limits',
      'lost',
      'onuncapturederror',
      'popErrorScope',
      'pushErrorScope',
      'queue',
      'removeEventListener',
    ],
    getters: ['adapterInfo', 'features', 'label', 'limits', 'lost', 'onuncapturederror', 'queue'],
    settable: ['label', 'onuncapturederror'],
    sameObject: ['adapterInfo', 'features', 'limits', 'queue'],
  },
  'adapter.limits': {
    create(t: GPUTest) {
      return t.adapter.limits;
    },
    requiredKeys: kSpecifiedLimits,
    getters: kSpecifiedLimits,
    settable: [],
    sameObject: [],
  },
  'device.limits': {
    create(t: GPUTest) {
      return t.device.limits;
    },
    requiredKeys: kSpecifiedLimits,
    getters: kSpecifiedLimits,
    settable: [],
    sameObject: [],
  },
} as const;
const kResources = keysOf(kResourceInfo);
type ResourceName = (typeof kResources)[number];

function createResource(t: GPUTest, type: ResourceName) {
  return kResourceInfo[type].create(t);
}

type Resource = ReturnType<typeof createResource>;

function forOfIterations(obj: Resource) {
  let count = 0;
  for (const _ of obj as unknown as []) {
    ++count;
  }
  return count;
}

function hasRequiredKeys(t: GPUTest, obj: Resource, requiredKeys: readonly string[]) {
  for (const requiredKey of requiredKeys) {
    t.expect(requiredKey in obj, `${requiredKey} in ${obj.constructor.name} exists`);
  }
}

function aHasBElements(
  t: GPUTest,
  a: GPUSupportedFeatures | string[],
  b: GPUSupportedFeatures | string[]
) {
  for (const elem of b) {
    if (Array.isArray(a)) {
      t.expect(a.includes(elem), `missing ${elem}`);
    } else if (a.has) {
      t.expect(a.has(elem), `missing ${elem}`);
    } else {
      unreachable();
    }
  }
}

export const g = makeTestGroup(AllFeaturesMaxLimitsGPUTest);
g.test('obj,Object_keys')
  .desc('tests returns nothing for Object.keys()')
  .params(u => u.combine('type', kResources))
  .fn(t => {
    const { type } = t.params;
    const { skipInCompatibility } = kResourceInfo[type];
    t.skipIf(t.isCompatibility && !!skipInCompatibility, 'skipped in compatibility mode');

    const obj = createResource(t, type);
    t.expect(objectEquals([...Object.keys(obj)], []), `[...Object.keys(${type})] === []`);
  });

g.test('obj,spread')
  .desc('does not spread')
  .params(u => u.combine('type', kResources))
  .fn(t => {
    const { type } = t.params;
    const { skipInCompatibility } = kResourceInfo[type];
    t.skipIf(t.isCompatibility && !!skipInCompatibility, 'skipped in compatibility mode');

    const obj = createResource(t, type);
    t.expect(objectEquals({ ...obj }, {}), `{ ...${type} ] === {}`);
  });

g.test('obj,for_in')
  .desc('iterates over keys - for (key in object)')
  .params(u => u.combine('type', kResources))
  .fn(t => {
    const { type } = t.params;
    const obj = createResource(t, type);
    hasRequiredKeys(t, obj, kResourceInfo[type].requiredKeys);
  });

g.test('obj,for_of')
  .desc('throws TypeError - for (key of object')
  .params(u => u.combine('type', kResources))
  .fn(t => {
    const { type } = t.params;
    const obj = createResource(t, type);
    t.shouldThrow('TypeError', () => forOfIterations(obj), {
      message: `for (const key of ${type} } throws TypeError`,
    });
  });

g.test('obj,getOwnPropertyDescriptors')
  .desc('Object.getOwnPropertyDescriptors returns {}')
  .params(u => u.combine('type', kResources))
  .fn(t => {
    const { type } = t.params;
    const { skipInCompatibility } = kResourceInfo[type];
    t.skipIf(t.isCompatibility && !!skipInCompatibility, 'skipped in compatibility mode');

    const obj = createResource(t, type);
    t.expect(
      objectEquals(Object.getOwnPropertyDescriptors(obj), {}),
      `Object.getOwnPropertyDescriptors(${type}} === {}`
    );
  });

const kSetLikeFeaturesInfo: { [key: string]: (t: GPUTest) => GPUSupportedFeatures } = {
  adapterFeatures(t: GPUTest) {
    return t.adapter.features;
  },
  deviceFeatures(t: GPUTest) {
    return t.device.features;
  },
};
const kSetLikeFeatures = keysOf(kSetLikeFeaturesInfo);

const kSetLikeInfo: { [key: string]: (t: GPUTest) => ReadonlySet<string> } = {
  ...kSetLikeFeaturesInfo,
  wgslLanguageFeatures() {
    return getGPU(null).wgslLanguageFeatures;
  },
};
const kSetLikes = keysOf(kSetLikeInfo);

g.test('setlike,spread')
  .desc('obj spreads')
  .params(u => u.combine('type', kSetLikes))
  .fn(t => {
    const { type } = t.params;
    const setLike = kSetLikeInfo[type](t);
    const copy = [...setLike];
    aHasBElements(t, copy, setLike);
    aHasBElements(t, setLike, copy);
  });

g.test('setlike,set')
  .desc('obj copies to set')
  .params(u => u.combine('type', kSetLikes))
  .fn(t => {
    const { type } = t.params;
    const setLike = kSetLikeInfo[type](t);
    const copy = new Set(setLike);
    aHasBElements(t, copy, setLike);
    aHasBElements(t, setLike, copy);
  });

g.test('setlike,requiredFeatures')
  .desc('can be passed as required features')
  .params(u => u.combine('type', kSetLikeFeatures))
  .fn(async t => {
    const { type } = t.params;
    const features = kSetLikeFeaturesInfo[type](t);

    const gpu = getGPU(null);
    const adapter = await gpu.requestAdapter();
    const device = await t.requestDeviceTracked(adapter!, {
      requiredFeatures: features as Iterable<GPUFeatureName>,
    });
    aHasBElements(t, device.features, features);
    aHasBElements(t, features, device.features);
  });

g.test('limits')
  .desc('adapter/device.limits can not be passed as requiredLimits')
  .params(u => u.combine('type', ['adapter', 'device'] as const))
  .fn(async t => {
    const { type } = t.params;
    const obj = type === 'adapter' ? t.adapter : t.device;

    const gpu = getGPU(null);
    const adapter = await gpu.requestAdapter();
    assert(!!adapter);
    const device = await t.requestDeviceTracked(adapter, {
      requiredLimits: obj.limits as unknown as Record<string, number>,
    });
    const defaultLimits = getDefaultLimitsForDevice(device);
    for (const [key, { default: defaultLimit }] of Object.entries(defaultLimits)) {
      if (isUnspecifiedLimit(key)) {
        continue;
      }
      const actual = (device.limits as unknown as Record<string, number>)[key];
      t.expect(
        actual === defaultLimit,
        `expected device.limits.${key}(${actual}) === ${defaultLimit}`
      );
    }
  });

g.test('readonly_properties')
  .desc(
    `
    Test that setting a property with no setter throws.
    `
  )
  .params(u => u.combine('type', kResources))
  .fn(t => {
    'use strict'; // This makes setting a readonly property produce a TypeError.
    const { type } = t.params;
    const { getters, settable } = kResourceInfo[type];
    const obj = createResource(t, type);
    for (const getter of getters) {
      const origValue = (obj as unknown as Record<string, unknown>)[getter];

      // try setting it.
      const isSettable = settable.includes(getter);
      t.shouldThrow(
        isSettable ? false : 'TypeError',
        () => {
          (obj as unknown as Record<string, string>)[getter] = 'test value';
          // If we were able to set it, restore it.
          (obj as unknown as Record<string, unknown>)[getter] = origValue;
        },
        {
          message: `instanceof ${type}.${getter} = value ${
            isSettable ? 'does not throw' : 'throws'
          }`,
        }
      );
    }
  });

g.test('getter_replacement')
  .desc(
    `
    Test that replacing getters on class prototypes works

    This is a common pattern for shims and debugging libraries so make sure this pattern works.
    `
  )
  .params(u => u.combine('type', kResources))
  .fn(t => {
    const { type } = t.params;
    const { getters } = kResourceInfo[type];

    const obj = createResource(t, type);
    for (const getter of getters) {
      // Check it's not 'ownProperty`
      const properties = Object.getOwnPropertyDescriptor(obj, getter);
      t.expect(
        properties === undefined,
        `Object.getOwnPropertyDescriptor(instance of ${type}, '${getter}') === undefined`
      );

      // Check it's actually a getter that returns a non-function value.
      const origValue = (obj as unknown as Record<string, () => unknown>)[getter];
      t.expect(typeof origValue !== 'function', `instance of ${type}.${getter} !== 'function'`);

      // check replacing the getter on constructor works.
      const ctorPrototype = obj.constructor.prototype;
      const origProperties = Object.getOwnPropertyDescriptor(ctorPrototype, getter);
      assert(
        !!origProperties,
        `Object.getOwnPropertyDescriptor(${type}, '${getter}') !== undefined`
      );
      try {
        Object.defineProperty(ctorPrototype, getter, {
          get() {
            return 'testGetterValue';
          },
        });
        const value = (obj as unknown as Record<string, string>)[getter];
        t.expect(
          value === 'testGetterValue',
          `replacing getter: '${getter}' on ${type} returns test value`
        );
      } finally {
        Object.defineProperty(ctorPrototype, getter, origProperties);
      }

      // Check it turns the same value after restoring as before restoring.
      const afterValue = (obj as unknown as Record<string, () => unknown>)[getter];
      assert(afterValue === origValue, `able to restore getter for instance of ${type}.${getter}`);

      // Check getOwnProperty also returns the value we got before.
      assert(
        Object.getOwnPropertyDescriptor(ctorPrototype, getter)!.get === origProperties.get,
        `getOwnPropertyDescriptor(${type}, '${getter}').get is original function`
      );
    }
  });

g.test('method_replacement')
  .desc(
    `
    Test that replacing methods on class prototypes works

    This is a common pattern for shims and debugging libraries so make sure this pattern works.
    `
  )
  .params(u => u.combine('type', kResources))
  .fn(t => {
    const { type } = t.params;
    const { requiredKeys, getters, skipInCompatibility } = kResourceInfo[type];
    t.skipIf(t.isCompatibility && !!skipInCompatibility, 'skipped in compatibility mode');

    const gettersSet = new Set<string>(getters);
    const methods = requiredKeys.filter(k => !gettersSet.has(k));

    const obj = createResource(t, type);
    for (const method of methods) {
      const ctorPrototype = obj.constructor.prototype;
      const origFunc = ctorPrototype[method];

      t.expect(typeof origFunc === 'function', `${type}.prototype.${method} is a function`);

      // Check the function the prototype and the one on the object are the same
      t.expect(
        (obj as unknown as Record<string, unknown>)[method] === origFunc,
        `instance of ${type}.${method} === ${type}.prototype.${method}`
      );

      // Check replacing the method on constructor works.
      try {
        (ctorPrototype as unknown as Record<string, unknown>)[method] = function () {
          return 'testMethodValue';
        };
        const value = (obj as unknown as Record<string, () => string>)[method]();
        t.expect(
          value === 'testMethodValue',
          `replacing method: '${method}' on ${type} returns test value`
        );
      } finally {
        (ctorPrototype as unknown as Record<string, unknown>)[method] = origFunc;
      }

      // Check the function the prototype and the one on the object are the same after restoring.
      assert(
        (obj as unknown as Record<string, unknown>)[method] === origFunc,
        `instance of ${type}.${method} === ${type}.prototype.${method}`
      );
    }
  });

g.test('sameObject')
  .desc(
    `
    Test that property that are supposed to return the sameObject do.
    `
  )
  .params(u => u.combine('type', kResources))
  .fn(t => {
    const { type } = t.params;
    const { sameObject } = kResourceInfo[type];
    const obj = createResource(t, type);
    for (const property of sameObject) {
      const origValue1 = (obj as unknown as Record<string, Record<string, string>>)[property];
      const origValue2 = (obj as unknown as Record<string, Record<string, string>>)[property];
      t.expect(typeof origValue1 === 'object');
      t.expect(typeof origValue2 === 'object');
      // Seems like this should be enough if they are objects.
      t.expect(origValue1 === origValue2);
      // add a property
      origValue1['foo'] = 'test';
      // see that it appears on the object.
      t.expect(origValue1.foo === 'test');
      t.expect(origValue2.foo === 'test');
      // Delete the property.
      delete origValue2.foo;
      // See it was removed.
      assert(origValue1.foo === undefined);
      assert(origValue2.foo === undefined);
    }
  });

const kClassInheritanceTests: { [key: string]: () => boolean } = {
  GPUDevice: () => GPUDevice.prototype instanceof EventTarget,
  GPUPipelineError: () => GPUPipelineError.prototype instanceof DOMException,
  GPUValidationError: () => GPUValidationError.prototype instanceof GPUError,
  GPUOutOfMemoryError: () => GPUOutOfMemoryError.prototype instanceof GPUError,
  GPUInternalError: () => GPUInternalError.prototype instanceof GPUError,
  GPUUncapturedErrorEvent: () => GPUUncapturedErrorEvent.prototype instanceof Event,
} as const;

g.test('inheritance')
  .desc(
    `
Test that objects inherit from the correct base class

This is important because apps might patch the base class
and expect instances of these classes to respond correctly.
`
  )
  .params(u => u.combine('type', keysOf(kClassInheritanceTests)))
  .fn(t => {
    const fn = kClassInheritanceTests[t.params.type];
    t.expect(fn(), fn.toString());
  });

const kDispatchTests = {
  async canPassEventThroughDevice(t: GPUTest) {
    const result = await raceWithRejectOnTimeout(
      new Promise(resolve => {
        t.device.addEventListener('foo', resolve, { once: true });
        t.device.dispatchEvent(new Event('foo'));
      }),
      500,
      'timeout'
    );
    const event = result as Event;
    t.expect(() => event instanceof Event, 'event');
    t.expect(() => event.type === 'foo');
  },
  async canPassCustomEventThroughDevice(t: GPUTest) {
    const result = await raceWithRejectOnTimeout(
      new Promise(resolve => {
        t.device.addEventListener('bar', resolve, { once: true });
        t.device.dispatchEvent(new CustomEvent('bar'));
      }),
      500,
      'timeout'
    );
    const event = result as CustomEvent;
    t.expect(() => event instanceof CustomEvent);
    t.expect(() => event instanceof Event);
    t.expect(() => event.type === 'bar');
  },
  async patchingEventTargetAffectsDevice(t: GPUTest) {
    let addEventListenerWasCalled = false;
    let dispatchEventWasCalled = false;
    let removeEventListenerWasCalled = false;

    // eslint-disable-next-line @typescript-eslint/unbound-method
    const origFnAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function (
      ...args: Parameters<typeof EventTarget.prototype.addEventListener>
    ) {
      addEventListenerWasCalled = true;
      return origFnAddEventListener.call(this, ...args);
    };

    // eslint-disable-next-line @typescript-eslint/unbound-method
    const origFnDispatchEvent = EventTarget.prototype.dispatchEvent;
    EventTarget.prototype.dispatchEvent = function (event: Event) {
      dispatchEventWasCalled = true;
      return origFnDispatchEvent.call(this, event);
    };

    // eslint-disable-next-line @typescript-eslint/unbound-method
    const origFnRemoveEventListener = EventTarget.prototype.removeEventListener;
    EventTarget.prototype.removeEventListener = function (
      ...args: Parameters<typeof EventTarget.prototype.removeEventListener>
    ) {
      removeEventListenerWasCalled = true;
      return origFnRemoveEventListener.call(this, ...args);
    };

    try {
      await raceWithRejectOnTimeout(
        new Promise(resolve => {
          t.device.addEventListener('foo', resolve);
          t.device.dispatchEvent(new Event('foo'));
          t.device.removeEventListener('foo', resolve);
        }),
        500,
        'timeout'
      );
    } finally {
      EventTarget.prototype.addEventListener = origFnAddEventListener;
      EventTarget.prototype.dispatchEvent = origFnDispatchEvent;
      EventTarget.prototype.removeEventListener = origFnRemoveEventListener;
    }
    t.expect(addEventListenerWasCalled, 'overriding EventTarget addEventListener worked');
    t.expect(dispatchEventWasCalled, 'overriding EventTarget dispatchEvent worked');
    t.expect(removeEventListenerWasCalled, 'overriding EventTarget removeEventListener worked');
  },
  async passingGPUUncapturedErrorEventWorksThoughEventTarget(t: GPUTest) {
    const target = new EventTarget();
    const result = await raceWithRejectOnTimeout(
      new Promise(resolve => {
        target.addEventListener('uncapturederror', resolve, { once: true });
        target.dispatchEvent(
          new GPUUncapturedErrorEvent('uncapturederror', {
            error: new GPUValidationError('test error'),
          })
        );
      }),
      500,
      'timeout'
    );
    const event = result as GPUUncapturedErrorEvent;
    t.expect(() => event instanceof GPUUncapturedErrorEvent);
    t.expect(() => event.error instanceof GPUValidationError);
    t.expect(() => event.error.message === 'test error');
  },
} as const;

g.test('device,EventTarget')
  .desc(
    `
Test some repercussions of the fact that GPUDevice extends EventTarget
`
  )
  .params(u => u.combine('test', keysOf(kDispatchTests)))
  .fn(async t => {
    await kDispatchTests[t.params.test](t);
  });

const kAddEventListenerTests = {
  EventHandler: async (t: GPUTest) => {
    const result = await raceWithRejectOnTimeout(
      new Promise(resolve => {
        t.device.addEventListener('foo', resolve, { once: true });
        t.device.dispatchEvent(new Event('foo'));
      }),
      500,
      'timeout'
    );
    const event = result as Event;
    t.expect(() => event instanceof Event, 'event');
    t.expect(() => event.type === 'foo');
  },
  EventListener: async (t: GPUTest) => {
    const result = await raceWithRejectOnTimeout(
      new Promise(resolve => {
        t.device.addEventListener(
          'foo',
          {
            handleEvent: resolve,
          },
          { once: true }
        );
        t.device.dispatchEvent(new Event('foo'));
      }),
      500,
      'timeout'
    );
    const event = result as Event;
    t.expect(() => event instanceof Event, 'event');
    t.expect(() => event.type === 'foo');
  },
} as const;

g.test('device,addEventListener')
  .desc(
    `
Test that addEventListener works with both an EventListener and an EventHandler

* https://dom.spec.whatwg.org/#interface-eventtarget
* https://html.spec.whatwg.org/multipage/webappapis.html#eventhandler

`
  )
  .params(u => u.combine('test', keysOf(kAddEventListenerTests)))
  .fn(async t => {
    await kAddEventListenerTests[t.params.test](t);
  });
