/**
 * @copyright 2010-2019 Jeremy Ashkenas and DocumentCloud
 * @copyright 2023 JC Brand
 */
import isEmpty from 'lodash-es/isEmpty';
import keys from 'lodash-es/keys';
import uniqueId from 'lodash-es/uniqueId';
import Listening from './listening';
import { eventsApi, onApi, offApi, onceMap, tryCatchOn, triggerApi } from './utils/events';
import {
  ClassConstructor,
  EventCallback,
  EventCallbackMap,
  EventContext,
  EventHandlersMap,
  EventListenerMap,
  ObjectListenedTo,
} from './types';

// A private global variable to share between listeners and listenees.
let _listening: Listening | undefined;

/**
 * @public
 */
export function EventEmitter<T extends ClassConstructor>(Base: T) {
  return class EventEmitter extends Base implements EventEmitter {
    _events?: EventHandlersMap;
    _listeners?: EventListenerMap;
    _listeningTo?: EventListenerMap;
    _listenId?: string;

    /**
     * Bind an event to a `callback` function. Passing `"all"` will bind
     * the callback to all events fired.
     */
    on(name: string | EventCallbackMap, callback?: EventCallback | EventContext, context?: EventContext): this {
      this._events = eventsApi(onApi, this._events || {}, name, callback, {
        context: context,
        ctx: this,
        listening: _listening,
      }) as EventHandlersMap;

      if (_listening) {
        const listeners = this._listeners || (this._listeners = {});
        listeners[_listening.id] = _listening;
        // Allow the listening to use a counter, instead of tracking
        // callbacks for library interop
        _listening.interop = false;
      }

      return this;
    }

    /**
     * Inversion-of-control versions of `on`. Tell *this* object to listen to
     * an event in another object... keeping track of what it's listening to
     * for easier unbinding later.
     */
    listenTo(obj: ObjectListenedTo, name: string | EventCallbackMap, callback?: EventCallback): this {
      if (!obj) return this;

      const id = obj._listenId || (obj._listenId = uniqueId('l'));
      const listeningTo = this._listeningTo || (this._listeningTo = {});
      let listening = (_listening = listeningTo[id]);

      // This object is not listening to any other events on `obj` yet.
      // Setup the necessary references to track the listening callbacks.
      if (!listening) {
        this._listenId || (this._listenId = uniqueId('l'));
        listening = _listening = listeningTo[id] = new Listening(this, obj);
      }

      // Bind callbacks on obj.
      const error = tryCatchOn(obj, name, callback, this);
      _listening = undefined;

      if (error) throw error;
      // If the target obj is not Backbone.Events, track events manually.
      if (listening.interop) listening.start(name, callback, this, _listening);

      return this;
    }

    /**
     * Remove one or many callbacks. If `context` is null, removes all
     * callbacks with that function. If `callback` is null, removes all
     * callbacks for the event. If `name` is null, removes all bound
     * callbacks for all events.
     */
    off(
      name?: string | EventCallbackMap | null,
      callback?: EventCallback | EventContext | null,
      context?: EventContext
    ): this {
      if (!this._events) return this;
      this._events = eventsApi(offApi, this._events, name, callback, {
        context: context,
        listeners: this._listeners,
      }) as EventHandlersMap;

      return this;
    }

    /**
     * Tell this object to stop listening to either specific events ... or
     * to every object it's currently listening to.
     */
    stopListening(obj?: any, name?: string | EventCallbackMap, callback?: EventCallback): this {
      const listeningTo = this._listeningTo;
      if (!listeningTo) return this;

      const ids = obj ? [obj._listenId] : keys(listeningTo);
      for (let i = 0; i < ids.length; i++) {
        const listening = listeningTo[ids[i]];

        // If listening doesn't exist, this object is not currently
        // listening to obj. Break out early.
        if (!listening) break;

        listening.obj.off(name, callback, this);
        if (listening.interop) listening.stop(name, callback);
      }
      if (isEmpty(listeningTo)) this._listeningTo = undefined;

      return this;
    }

    /**
     * Bind an event to only be triggered a single time. After the first time
     * the callback is invoked, its listener will be removed. If multiple events
     * are passed in using the space-separated syntax, the handler will fire
     * once for each event, not once for a combination of all events.
     */
    once(name: string | EventCallbackMap, callback?: EventCallback | EventContext, context?: EventContext): this {
      // Map the event into a `{event: once}` object.
      const events = eventsApi(onceMap, {}, name, callback, this.off.bind(this));
      if (typeof name === 'string' && (context === null || context === undefined)) callback = undefined;
      return this.on(events as EventCallbackMap, callback, context);
    }

    /**
     * Inversion-of-control versions of `once`.
     */
    listenToOnce(obj: any, name: string | EventCallbackMap, callback?: EventCallback): this {
      // Map the event into a `{event: once}` object.
      const events = eventsApi(onceMap, {}, name, callback, this.stopListening.bind(this, obj));
      return this.listenTo(obj, events as string | EventCallbackMap);
    }

    /**
     * Trigger one or many events, firing all bound callbacks. Callbacks are
     * passed the same arguments as `trigger` is, apart from the event name
     * (unless you're listening on `"all"`, which will cause your callback to
     * receive the true name of the event as the first argument).
     */
    trigger(name: string, ...args: any[]): this {
      if (!this._events) return this;

      eventsApi(triggerApi, this._events, name, undefined, args);
      return this;
    }
  };
}

/**
 * @public
 */
const EventEmitterObject = EventEmitter(Object);
export { EventEmitterObject };

export default EventEmitter;
