/**
 * @license
 * Copyright 2017 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import '../types/globals';
import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
import {_testOnly_resetEndpoints} from '../elements/shared/gr-js-api-interface/gr-plugin-endpoints';
import {getAppContext} from '../services/app-context';
import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
import {SinonSpy, SinonStub} from 'sinon';
import {StorageService} from '../services/storage/gr-storage';
import {AuthService} from '../services/gr-auth/gr-auth';
import {ReportingService} from '../services/gr-reporting/gr-reporting';
import {UserModel} from '../models/user/user-model';
import {queryAndAssert, query} from '../utils/common-util';
import {FlagsService} from '../services/flags/flags';
import {Key, Modifier} from '../utils/dom-util';
import {Observable} from 'rxjs';
import {filter, take, timeout} from 'rxjs/operators';
import {HighlightService} from '../services/highlight/highlight-service';
import {assert} from '@open-wc/testing';
export {query, queryAll, queryAndAssert} from '../utils/common-util';

export interface MockPromise<T> extends Promise<T> {
  resolve: (value?: T) => void;
  reject: (reason?: any) => void;
}

export function mockPromise<T = unknown>(): MockPromise<T> {
  let res: (value?: T) => void;
  let rej: (reason?: any) => void;
  const promise: MockPromise<T> = new Promise<T | undefined>(
    (resolve, reject) => {
      res = resolve;
      rej = reject;
    }
  ) as MockPromise<T>;
  promise.resolve = res!;
  promise.reject = rej!;
  return promise;
}

export function isHidden(el: Element | undefined | null) {
  if (!el) return true;
  return getComputedStyle(el).display === 'none';
}

export function isVisible(el: Element) {
  assert.ok(el);
  return getComputedStyle(el).getPropertyValue('display') !== 'none';
}

// Provide reset plugins function to clear installed plugins between tests.
// No gr-app found (running tests)
export const resetPlugins = () => {
  _testOnly_resetEndpoints();
  const pl = _testOnly_resetPluginLoader();
  pl.loadPlugins([]);
};

export type CleanupCallback = () => void;

const cleanups: CleanupCallback[] = [];

export function getCleanupsCount() {
  return cleanups.length;
}

export function registerTestCleanup(cleanupCallback: CleanupCallback) {
  cleanups.push(cleanupCallback);
}

export function addListenerForTest(
  el: EventTarget,
  type: string,
  listener: EventListenerOrEventListenerObject
) {
  el.addEventListener(type, listener);
  registerListenerCleanup(el, type, listener);
}

export function registerListenerCleanup(
  el: EventTarget,
  type: string,
  listener: EventListenerOrEventListenerObject
) {
  registerTestCleanup(() => {
    el.removeEventListener(type, listener);
  });
}

export function cleanupTestUtils() {
  cleanups.forEach(cleanup => cleanup());
  cleanups.splice(0);
}

export function stubBaseUrl(newUrl: string) {
  const originalCanonicalPath = window.CANONICAL_PATH;
  window.CANONICAL_PATH = newUrl;
  registerTestCleanup(() => (window.CANONICAL_PATH = originalCanonicalPath));
}

export function stubRestApi<K extends keyof RestApiService>(method: K) {
  return sinon.stub(getAppContext().restApiService, method);
}

export function spyRestApi<K extends keyof RestApiService>(method: K) {
  return sinon.spy(getAppContext().restApiService, method);
}

export function stubUsers<K extends keyof UserModel>(method: K) {
  return sinon.stub(getAppContext().userModel, method);
}

export function stubHighlightService<K extends keyof HighlightService>(
  method: K
) {
  return sinon.stub(getAppContext().highlightService, method);
}

export function stubStorage<K extends keyof StorageService>(method: K) {
  return sinon.stub(getAppContext().storageService, method);
}

export function spyStorage<K extends keyof StorageService>(method: K) {
  return sinon.spy(getAppContext().storageService, method);
}

export function stubAuth<K extends keyof AuthService>(method: K) {
  return sinon.stub(getAppContext().authService, method);
}

export function stubReporting<K extends keyof ReportingService>(method: K) {
  return sinon.stub(getAppContext().reportingService, method);
}

export function stubFlags<K extends keyof FlagsService>(method: K) {
  return sinon.stub(getAppContext().flagsService, method);
}

export function stubElement<
  T extends keyof HTMLElementTagNameMap,
  K extends keyof HTMLElementTagNameMap[T]
>(tagName: T, method: K) {
  // This method is inspired by web-component-tester method
  const proto = document.createElement(tagName).constructor
    .prototype as HTMLElementTagNameMap[T];
  const stub = sinon.stub(proto, method);
  registerTestCleanup(() => {
    stub.restore();
  });
  return stub;
}

export type SinonSpyMember<F extends (...args: any) => any> = SinonSpy<
  Parameters<F>,
  ReturnType<F>
>;

/**
 * Forcing an opacity of 0 onto the ironOverlayBackdrop is required, because
 * otherwise the backdrop stays around in the DOM for too long waiting for
 * an animation to finish.
 */
export function addIronOverlayBackdropStyleEl() {
  const el = document.createElement('style');
  el.setAttribute('id', 'backdrop-style');
  document.head.appendChild(el);
  el.sheet!.insertRule('body { --iron-overlay-backdrop-opacity: 0; }');
}

export function removeIronOverlayBackdropStyleEl() {
  const el = document.getElementById('backdrop-style');
  if (!el?.parentNode) throw new Error('Backdrop style element not found.');
  el.parentNode?.removeChild(el);
}

export function removeThemeStyles() {
  // Do not remove the light theme, because it is only added once statically,
  // not once per gr-app instantiation.
  // document.head.querySelector('#light-theme')?.remove();
  document.head.querySelector('#dark-theme')?.remove();
}

export async function waitQueryAndAssert<E extends Element = Element>(
  el: Element | null | undefined,
  selector: string
): Promise<E> {
  await waitUntil(
    () => !!query<E>(el, selector),
    `The element '${selector}' did not appear in the DOM within 1000 ms.`
  );
  return queryAndAssert<E>(el, selector);
}

export async function waitUntil(
  predicate: (() => boolean) | (() => Promise<boolean>),
  message = 'The waitUntil() predicate is still false after 1000 ms.',
  timeout_ms = 1000
): Promise<void> {
  const start = Date.now();
  let sleep = 0;
  if (await predicate()) return Promise.resolve();
  const error = new Error(message);
  return new Promise((resolve, reject) => {
    const waiter = async () => {
      if (await predicate()) {
        resolve();
        return;
      }
      if (Date.now() - start >= timeout_ms) {
        reject(error);
        return;
      }
      setTimeout(waiter, sleep);
      sleep = sleep === 0 ? 1 : sleep * 4;
    };
    waiter();
  });
}

export function waitUntilCalled(stub: SinonStub | SinonSpy, name: string) {
  return waitUntil(() => stub.called, `${name} was not called`);
}

/**
 * Subscribes to the observable and resolves once it emits a matching value.
 * Usage:
 *   await waitUntilObserved(
 *     myTestModel.state$,
 *     state => state.prop === expectedValue
 *   );
 */
export async function waitUntilObserved<T>(
  observable$: Observable<T>,
  predicate: (t: T) => boolean,
  message = 'The waitUntilObserved() predicate did not match after 1000 ms.'
): Promise<T> {
  return new Promise((resolve, reject) => {
    observable$.pipe(filter(predicate), take(1), timeout(1000)).subscribe({
      next: t => resolve(t),
      error: () => reject(new Error(message)),
    });
  });
}

/**
 * sinon.useFakeTimers() overwrites window.setTimeout with a controlled,
 * synchronous version for tests to use. Keep the original one for use in
 * waitEventLoop
 */
const nativeSetTimeout = window.setTimeout;
/**
 * Wait for the current event loop's tasks to complete by scheduling a promise
 * to resolve during the next loop. Prefer other wait methods over this one to
 * wait for specific work to be done or for specific states to exist.
 */
export function waitEventLoop(): Promise<void> {
  return new Promise(resolve => nativeSetTimeout(resolve, 0));
}
/**
 * Promisify an event callback to simplify async...await tests.
 *
 * Use like this:
 *   await listenOnce(el, 'render');
 *   ...
 */
export function listenOnce<T extends Event>(
  el: EventTarget,
  eventType: string
) {
  return new Promise<T>(resolve => {
    const listener = (e: Event) => {
      removeEventListener();
      resolve(e as T);
    };
    let removeEventListener = () => {
      el.removeEventListener(eventType, listener);
      removeEventListener = () => {};
    };
    el.addEventListener(eventType, listener);
    registerTestCleanup(removeEventListener);
  });
}

export function dispatch<T>(element: HTMLElement, type: string, detail: T) {
  const eventOptions = {
    detail,
    bubbles: true,
    composed: true,
  };
  element.dispatchEvent(new CustomEvent<T>(type, eventOptions));
}

export function pressKey(
  element: HTMLElement,
  key: string | Key,
  ...modifiers: Modifier[]
) {
  const eventOptions = {
    key,
    bubbles: true,
    cancelable: true,
    composed: true,
    altKey: modifiers.includes(Modifier.ALT_KEY),
    ctrlKey: modifiers.includes(Modifier.CTRL_KEY),
    metaKey: modifiers.includes(Modifier.META_KEY),
    shiftKey: modifiers.includes(Modifier.SHIFT_KEY),
  };
  element.dispatchEvent(new KeyboardEvent('keydown', eventOptions));
}

export function mouseDown(element: HTMLElement) {
  const rect = element.getBoundingClientRect();
  const eventOptions = {
    bubbles: true,
    composed: true,
    clientX: (rect.left + rect.right) / 2,
    clientY: (rect.top + rect.bottom) / 2,
    screenX: (rect.left + rect.right) / 2,
    screenY: (rect.top + rect.bottom) / 2,
  };
  element.dispatchEvent(new MouseEvent('mousedown', eventOptions));
}

export function assertFails(promise: Promise<unknown>, error?: unknown) {
  return promise
    .then((_v: unknown) => {
      assert.fail('Promise resolved but should have failed');
    })
    .catch((e: unknown) => {
      if (error) {
        assert.equal(e, error);
      }
      return e;
    });
}

export function logProxy<T extends object>(obj: T, name?: string): T {
  const handler = {
    get(target: object, prop: PropertyKey, receiver: any) {
      const result = Reflect.get(target, prop, receiver);
      if (result instanceof Function) {
        return (...rest: unknown[]) => {
          console.error(`${name}.${String(prop)}(${rest})`);
          return result.apply(target, rest);
        };
      }
      return result;
    },
  };
  return new Proxy(obj, handler) as unknown as T;
}
