import { Disposable } from './disposable';
import { LinkedList } from './linkedList';
import type { MaybePromise } from './types';
import { noop } from './utils';

/**
 * Represents a typed event.
 */
export type Event<T> = {
  /**
   *
   * @param listener The listener function will be call when the event happens.
   * @param context The 'this' which will be used when calling the event listener.
   * @return a disposable to remove the listener again.
   */
  (listener: (e: T) => any, context?: any): Disposable;
};

export namespace Event {
  export const None: Event<void> = () => Disposable.NONE;

  /**
   * Given an event and a `map` function, returns another event which maps each element
   * through the mapping function.
   */
  export function map<I, O>(event: Event<I>, mapFunc: (i: I) => O): Event<O> {
    return (listener: (e: O) => any, context?: any) =>
      event((i) => listener.call(context, mapFunc(i)), undefined);
  }
}

type Callback = (...args: any[]) => any;
export class CallbackList implements Iterable<Callback> {
  protected _callbacks: [Callback, any][] | undefined;

  get length(): number {
    return (this._callbacks && this._callbacks.length) || 0;
  }

  public add(callback: Callback, context: any = undefined): void {
    if (!this._callbacks) {
      this._callbacks = [];
    }
    this._callbacks.push([callback, context]);
  }

  public remove(callback: Callback, context: any = undefined): void {
    if (!this._callbacks) {
      return;
    }

    let foundCallbackWithDifferentContext = false;
    for (let i = 0; i < this._callbacks.length; i += 1) {
      if (this._callbacks[i][0] === callback) {
        if (this._callbacks[i][1] === context) {
          // remove when callback & context match
          this._callbacks.splice(i, 1);
          return;
        }
        foundCallbackWithDifferentContext = true;
      }
    }

    if (foundCallbackWithDifferentContext) {
      throw new Error('You should remove it with the same context you add it');
    }
  }

  // tslint:disable-next-line:typedef
  public [Symbol.iterator]() {
    if (!this._callbacks) {
      return [][Symbol.iterator]();
    }
    const callbacks = this._callbacks.slice(0);

    return callbacks
      .map(
        (callback) =>
          (...args: any[]) =>
            callback[0].apply(callback[1], args),
      )
      [Symbol.iterator]();
  }

  public invoke(...args: any[]): any[] {
    const ret: any[] = [];
    for (const callback of this) {
      try {
        ret.push(callback(...args));
      } catch (e) {
        console.error(e);
      }
    }
    return ret;
  }

  public isEmpty(): boolean {
    return !this._callbacks || this._callbacks.length === 0;
  }

  public dispose(): void {
    this._callbacks = undefined;
  }
}

export type EmitterOptions = {
  onFirstListenerAdd?: (ctx: any) => void;
  onLastListenerRemove?: (ctx: any) => void;
};

export class Emitter<T = any> {
  protected _event?: Event<T>;
  protected _callbacks: CallbackList | undefined;
  protected _disposed = false;
  protected _options?: EmitterOptions | undefined;

  constructor(_options?: EmitterOptions) {
    this._options = _options;
  }

  /**
   * For the public to allow to subscribe
   * to events from this Emitter
   */
  get event(): Event<T> {
    if (!this._event) {
      this._event = (
        listener: (e: T) => any,
        thisArgs?: any,
        disposables?: Disposable[],
      ) => {
        if (!this._callbacks) {
          this._callbacks = new CallbackList();
        }
        if (
          this._options &&
          this._options.onFirstListenerAdd &&
          this._callbacks.isEmpty()
        ) {
          this._options.onFirstListenerAdd(this);
        }
        this._callbacks.add(listener, thisArgs);

        const result: Disposable = {
          dispose: () => {
            result.dispose = noop;
            if (!this._disposed) {
              this._callbacks!.remove(listener, thisArgs);
              result.dispose = noop;
              if (
                this._options &&
                this._options.onLastListenerRemove &&
                this._callbacks!.isEmpty()
              ) {
                this._options.onLastListenerRemove(this);
              }
            }
          },
        };
        if (Array.isArray(disposables)) {
          disposables.push(result);
        }

        return result;
      };
    }
    return this._event;
  }

  /**
   * To be kept protected to fire an event to
   * subscribers
   */
  fire(event: T): any {
    if (this._callbacks) {
      this._callbacks.invoke(event);
    }
  }

  /**
   * Process each listener one by one.
   * Return `false` to stop iterating over the listeners, `true` to continue.
   */
  async sequence(
    processor: (listener: (e: T) => any) => MaybePromise<boolean>,
  ): Promise<void> {
    if (this._callbacks) {
      for (const listener of this._callbacks) {
        // eslint-disable-next-line no-await-in-loop
        const result = await processor(listener);
        if (!result) {
          break;
        }
      }
    }
  }

  dispose(): void {
    if (this._callbacks) {
      this._callbacks.dispose();
      this._callbacks = undefined;
    }
    this._disposed = true;
  }
}

export class PauseableEmitter<T> extends Emitter<T> {
  protected _isPaused = 0;
  protected _eventQueue = new LinkedList<T>();
  protected _mergeFn: undefined | ((input: T[]) => T);
  protected _listeners: any;

  constructor(options?: EmitterOptions & { merge?: (input: T[]) => T }) {
    super(options);
    this._mergeFn = options?.merge;
  }

  pause(): void {
    this._isPaused++;
  }

  resume(): void {
    if (this._isPaused !== 0 && --this._isPaused === 0) {
      if (this._mergeFn) {
        // use the merge function to create a single composite
        // event. make a copy in case firing pauses this emitter
        const events = Array.from(this._eventQueue);
        this._eventQueue.clear();
        super.fire(this._mergeFn(events));
      } else {
        // no merging, fire each event individually and test
        // that this emitter isn't paused halfway through
        while (!this._isPaused && this._eventQueue.size !== 0) {
          super.fire(this._eventQueue.shift()!);
        }
      }
    }
  }

  override fire(event: T): void {
    if (this._listeners) {
      if (this._isPaused !== 0) {
        this._eventQueue.push(event);
      } else {
        super.fire(event);
      }
    }
  }
}
