/**
 * The notifications API is meant for sending various types of notifications,
 * including toasts and notices.
 * @module Notifications
 * @memberof API
 * @namespace API.Notifications
 * @version 1.0.0
 */

/**
 * Vizality notice object.
 * @typedef VizalityNotice
 * @property {string} [id] Notice ID
 * @property {string} message Notice message
 * @property {string} [color='blurple'] Notice background color
 * @property {Function} [callback] Callback function triggered when the notice closes
 * @property {Array<NoticeButton>} [buttons] Notice buttons
 * @property {string} caller Addon ID of notice sender. This property is set automatically.
 */

/**
 * Vizality notice button.
 * @typedef NoticeButton
 * @property {Function} [onClick=()=> vizality.api.notifications.closeNotice(noticeId)] Button click executor. Closes the notice by default.
 * @property {string} text Button text
 */

/**
 * Vizality toast object.
 * @typedef VizalityToast
 * @property {string} [id] Toast ID
 * @property {boolean} [showCloseButton=true] Toast close button
 * @property {boolean|string|ToastIcon} [icon='InfoFilled'] Toast icon / image
 * @property {string|ReactElement} header Toast header
 * @property {string|ReactElement} [content] Toast content
 * @property {Array<ToastButton>} [buttons] Toast buttons
 * @property {boolean|number} [timeout] Time in milliseconds until the toast auto-closes, or a boolean of whether it should auto-close. This overrides the default user setting on a per-toast basis.
 * @property {number} [delay] Delay toast appearing (in milliseconds)
 * @property {Function} [callback] Callback function triggered when the toast closes
 * @property {string} caller Addon ID of toast sender. This property is set automatically.
 */

/**
 * Vizality toast icon.
 * @typedef ToastIcon
 * @property {string} [name='InfoFilled'] Icon name
 * @property {string} [size='40'] Icon size in pixels
 * @property {URL} [url] Icon external URL
 * @property {string} [color] Icon color
 */

/**
 * Vizality toast button.
 * @typedef ToastButton
 * @property {string} [size='small'] Button size
 * @property {string} [look='filled'] Button appearance
 * @property {string} [color='white'] Button color
 * @property {Function} [onClick=()=> vizality.api.notifications.closeToast(toastId)] Button click executor. Closes the toast by default.
 * @property {string} text Button text
 */

import { assertString, isString, isUrl, toSnakeCase } from '@vizality/util/string';
import { assertObject, isObject } from '@vizality/util/object';
import { assertNumber, isNumber } from '@vizality/util/number';
import { assertArray } from '@vizality/util/array';
import { isComponent } from '@vizality/util/react';
import { toast as _toast } from 'react-toastify';
import { getCaller } from '@vizality/util/file';
import { getModule } from '@vizality/webpack';
import React, { isValidElement } from 'react';
import { Events } from '@vizality/constants';
import { Icon } from '@vizality/components';
import { API } from '@vizality/entities';

/**
 * All currently active/pending notifications.
 */
const notifications = {
  notices: [],
  toasts: []
};

/**
 * @extends API
 * @extends Events
 */
export default class Notifications extends API {
  /**
   * Shuts down the API, removing all listeners and stored objects.
   */
  stop () {
    this.closeAllNotices();
    this.closeAllToasts();
    this.removeAllListeners();
    delete vizality.api.notifications;
  }

  /**
   * Sends a notice (in the form of a banner at the top of the client).
   * @param {VizalityNotice} notice Notice to send
   * @emits Notifications#Events.VIZALITY_NOTICE_SEND
   */
  sendNotice (notice) {
    try {
      assertObject(notice);
      assertString(notice.message);
      notice.caller = getCaller();
      if (notice.id) {
        assertString(notice.id);
        if (this.isNotice(notice.id)) {
          throw new Error(`Notice "${notice.id}" is already active!`);
        }
      } else {
        notice.id = `${toSnakeCase(notice.caller?.id).toUpperCase()}_NOTICE_${this.getNoticesByCaller(notice.caller?.id)?.length + 1 || '1'}`;
      }
      if (notice.callback && typeof notice.callback !== 'function') {
        throw new TypeError('Notice "callback" property value must be a function!');
      }
      if (notice.color) {
        assertString(notice.color);
      }
      if (notice.buttons) {
        assertArray(notice.buttons);
        notice.buttons.forEach(button => {
          assertString(button.text);
          if (button.onClick) {
            if (typeof button.onClick !== 'function') {
              throw new TypeError('Notice button "onClick" property value must be a function!');
            }
          } else {
            button.onClick = () => this.closeNotice(notice.id);
          }
        });
      }
      notifications.notices.push(notice);
      this.emit(Events.VIZALITY_NOTICE_SEND, notice.id);
    } catch (err) {
      return this.error(this._labels.concat('sendNotice'), err);
    }
  }

  /**
   * Sends a toast notification.
   * @param {VizalityToast|React.Component|function(): React.Element} toast Toast to send
   * @emits Notifications#Events.VIZALITY_TOAST_SEND
   */
  sendToast (toast) {
    try {
      if (!isComponent(toast) && !isObject(toast)) {
        throw new Error('Toast must be either a React component or an object!');
      }
      if (isComponent(toast) || isValidElement(toast)) {
        const component = toast;
        toast = {};
        toast.custom = component;
      }
      assertObject(toast);
      toast.caller = getCaller();
      if (toast.id) {
        assertString(toast.id);
        if (this.isToast(toast.id)) {
          throw new Error(`Toast "${toast.id}" already exists!`);
        }
      } else {
        toast.id = `${toSnakeCase(toast.caller?.id).toUpperCase()}_TOAST_${this.getToastsByCaller(toast.caller?.id)?.length + 1 || '1'}`;
      }
      if (toast.callback && typeof toast.callback !== 'function') {
        throw new TypeError('Toast "callback" property value must be a function!');
      }
      if (toast.hasOwnProperty('showCloseButton')) {
        if (typeof toast.showCloseButton !== 'boolean') {
          throw new TypeError('Toast "showCloseButton" property value must be a boolean!');
        }
      } else {
        toast.showCloseButton = true;
      }
      if (toast.delay) {
        assertNumber(toast.delay);
      }
      if (toast.icon) {
        if (isString(toast.icon)) {
          if (isUrl(toast.icon)) {
            const url = toast.icon;
            toast.icon = {};
            toast.icon.url = url;
          } else {
            if (Icon.Names.includes(toast.icon)) {
              const name = toast.icon;
              toast.icon = {};
              toast.icon.name = name;
            } else {
              throw new Error(`Icon "${toast.icon}" asset not found!`);
            }
          }
        } else if (isObject(toast.icon)) {
          /**
           * Check if an icon name property is provided.
           */
          if (toast.icon.name) {
            /**
             * Make sure the icon name is a string.
             */
            if (!isString(toast.icon.name)) {
              throw new Error(`Toast icon "name" property value must be a string!`);
            }
            /**
             * Delete the icon URL if one is provided, because the name overrides it.
             */
            delete toast.icon.url;
          } else {
            /**
             * Check if an icon URL is provided.
             */
            if (toast.icon.url) {
              /**
               * Make sure the icon URL is a valid URL.
               */
              if (!isUrl(toast.icon.url)) {
                throw new Error(`Toast icon "url" property value must be a URL!`);
              }
              /**
               * Use the icon name default.
               */
            } else {
              toast.icon.name = 'InfoFilled';
            }
          }
          if (toast.icon.size) {
            assertString(toast.icon.size);
          }
          if (toast.icon.color && toast.icon.color !== 'currentColor') {
            assertString(toast.icon.color);
          }
        } else {
          throw new TypeError(`Toast "icon" property value must be a boolean, string, or object!`);
        }
      }
      if (toast.buttons) {
        assertArray(toast.buttons);
        toast.buttons.forEach(button => {
          if (button.onClick) {
            if (typeof button.onClick !== 'function') {
              throw new TypeError('Toast button "onClick" property value must be a function!');
            }
          } else {
            button.onClick = () => this.closeToast(toast.id);
          }
          if (!button.text || !isString(button.text)) {
            throw new TypeError('Toast button "text" property value must be a string!');
          }
          /**
           * Checks types and set defaults for button properties.
           */
          if (button.size) {
            assertString(button.size);
          } else {
            button.size = 'small';
          }
          if (button.look) {
            assertString(button.look);
          } else {
            button.look = 'filled';
          }
          if (button.color) {
            assertString(button.color);
          } else {
            button.color = 'white';
          };
        });
      }
      if (toast.hasOwnProperty('timeout') && (!isNumber(toast.timeout) && typeof toast.timeout !== 'boolean')) {
        throw new TypeError('Toast "timeout" property value must be a number or boolean!');
      }
      assertString(toast.id);
      notifications.toasts.push(toast);
      this.emit(Events.VIZALITY_TOAST_SEND, toast.id);
    } catch (err) {
      return this.error(this._labels.concat('sendToast'), err);
    }
  }

  /**
   * Checks if a notice is active.
   * @param {string} noticeId Notice ID
   * @returns {boolean} Whether a notice with a given ID is active
   */
  isNotice (noticeId) {
    try {
      assertString(noticeId);
      return notifications.notices.some(notice => notice.id === noticeId);
    } catch (err) {
      return this.error(this._labels.concat('isNotice'), err);
    }
  }

  /**
   * Checks if a toast is currently active.
   * @param {string} toastId Toast ID
   * @returns {boolean} Whether a toast with a given ID is active
   */
  isToastActive (toastId) {
    try {
      assertString(toastId);
      return Boolean(_toast.isActive(toastId));
    } catch (err) {
      return this.error(this._labels.concat('isToast'), err);
    }
  }

  /**
   * Checks if a toast is in queue to be sent.
   * @param {string} toastId Toast ID
   * @returns {boolean} Whether a toast with a given ID is queued
   */
  isToastQueued (toastId) {
    try {
      assertString(toastId);
      return Boolean(this.isToast(toastId) && !this.isToastActive(toastId));
    } catch (err) {
      return this.error(this._labels.concat('isToast'), err);
    }
  }

  /**
   * Checks if a toast is currently active or is in queue to be sent.
   * @param {string} toastId Toast ID
   * @returns {boolean} Whether a toast with a given ID is active or queued
   */
  isToast (toastId) {
    try {
      assertString(toastId);
      return notifications.toasts.some(toast => toast.id === toastId);
    } catch (err) {
      return this.error(this._labels.concat('isToast'), err);
    }
  }

  /**
   * Gets the first notice found matching a given filter.
   * @param {Function} filter Function to use to filter notices by
   * @returns {object|null} Notice matching a given filter
   */
  getNotice (filter) {
    try {
      if (!filter?.length) return null;
      return notifications.notices.find(filter);
    } catch (err) {
      return this.error(this._labels.concat('getNotice'), err);
    }
  }

  /**
   * Gets the first toast found matching a given filter.
   * @param {Function} filter Function to use to filter toasts by
   * @returns {object|null} Toast matching a given filter
   */
  getToast (filter) {
    try {
      if (!filter?.length) return null;
      return notifications.toasts.find(filter);
    } catch (err) {
      return this.error(this._labels.concat('getToast'), err);
    }
  }

  /**
   * Gets a notice matching a given ID.
   * @param {string} noticeId Notice ID
   * @returns {object|null} Notice matching a given ID
   */
  getNoticeById (noticeId) {
    try {
      assertString(noticeId);
      return notifications.notices.find(notice => notice.id === noticeId);
    } catch (err) {
      return this.error(this._labels.concat('getNoticeById'), err);
    }
  }

  /**
   * Gets a toast matching a given ID.
   * @param {string} toastId Toast ID
   * @returns {object|null} Toast matching a given ID
   */
  getToastById (toastId) {
    try {
      assertString(toastId);
      return notifications.toasts.find(toast => toast.id === toastId);
    } catch (err) {
      return this.error(this._labels.concat('getToastById'), err);
    }
  }

  /**
   * Gets all notices found matching a given filter.
   * @param {Function} filter Function to use to filter notices by
   * @returns {Array<object|null>} Notices matching a given filter
   */
  getNotices (filter) {
    try {
      if (!filter?.length) return null;
      return notifications.notices.filter(filter);
    } catch (err) {
      return this.error(this._labels.concat('getNotices'), err);
    }
  }

  /**
   * Gets all toasts found matching a given filter.
   * @param {Function} filter Function to use to filter toasts by
   * @returns {Array<object|null>} Toasts matching a given filter
   */
  getToasts (filter) {
    try {
      if (!filter?.length) return null;
      return notifications.toasts.filter(filter);
    } catch (err) {
      return this.error(this._labels.concat('getToasts'), err);
    }
  }

  /**
   * Gets all notices matching a given caller.
   * @param {string} addonId Addon ID
   * @returns {Array<object|null>} Notices matching a given caller
   */
  getNoticesByCaller (addonId) {
    try {
      assertString(addonId);
      return notifications.notices.filter(notice => notice.caller?.id === addonId);
    } catch (err) {
      return this.error(this._labels.concat('getNoticesByCaller'), err);
    }
  }

  /**
   * Gets all toasts matching a given caller.
   * @param {string} addonId Addon ID
   * @returns {Array<object|null>} Toasts matching a given caller
   */
  getToastsByCaller (addonId) {
    try {
      assertString(addonId);
      return notifications.toasts.filter(toast => toast.caller?.id === addonId);
    } catch (err) {
      return this.error(this._labels.concat('getToastsByCaller'), err);
    }
  }

  /**
   * Gets all notices.
   * @returns {Array<object|null>} All notices
   */
  getAllNotices () {
    try {
      return notifications.notices;
    } catch (err) {
      return this.error(this._labels.concat('getAllNotices'), err);
    }
  }

  /**
   * Gets all toasts.
   * @returns {Array<object|null>} All toasts
   */
  getAllToasts () {
    try {
      return notifications.toasts;
    } catch (err) {
      return this.error(this._labels.concat('getAllToasts'), err);
    }
  }

  /**
   * Gets all active toasts.
   * @returns {Array<object|null>} All active toasts
   */
  getAllActiveToasts () {
    try {
      const activeToasts = [];
      notifications.toasts.forEach(toast => {
        if (this.isToastActive(toast.id)) {
          activeToasts.push(toast);
        }
      });
      return activeToasts;
    } catch (err) {
      return this.error(this._labels.concat('getAllActiveToasts'), err);
    }
  }

  /**
   * Gets all queued toasts.
   * @returns {Array<object|null>} All queued toasts
   */
  getAllQueuedToasts () {
    try {
      const queuedToasts = [];
      notifications.toasts.forEach(toast => {
        if (this.isToastQueued(toast.id)) {
          queuedToasts.push(toast);
        }
      });
      return queuedToasts;
    } catch (err) {
      return this.error(this._labels.concat('getAllQueuedToasts'), err);
    }
  }

  /**
   * Closes a notice.
   * @param {string} noticeId Notice ID
   * @emits Notifications#Events.VIZALITY_NOTICE_CLOSE
   */
  closeNotice (noticeId) {
    try {
      assertString(noticeId);
      const notice = this.getNoticeById(noticeId);
      notifications.notices = this.getNotices(notice => notice.id !== noticeId);
      if (typeof notice?.callback === 'function') {
        try {
          notice.callback();
        } catch (err) {
          return this.error(this._labels.concat('closeNotice'), `There was a problem invoking notice "${noticeId}" callback!`, err);
        }
      }
      this.emit(Events.VIZALITY_NOTICE_CLOSE, noticeId);
    } catch (err) {
      return this.error(this._labels.concat('closeNotice'), err);
    }
  }

  /**
   * Closes a toast.
   * @param {string} toastId Toast ID
   * @emits Notifications#Events.VIZALITY_TOAST_CLOSE
   */
  closeToast (toastId) {
    try {
      assertString(toastId);
      const toast = this.getToastById(toastId);
      if (!toast) return;
      notifications.toasts = this.getToasts(toast => toast.id !== toastId);
      _toast.dismiss(toastId);
      if (typeof toast?.callback === 'function') {
        try {
          toast.callback();
        } catch (err) {
          return this.error(this._labels.concat('closeToast'), `There was a problem invoking toast "${toastId}" callback!`, err);
        }
      }
      this.emit(Events.VIZALITY_TOAST_CLOSE, toastId);
    } catch (err) {
      return this.error(this._labels.concat('closeToast'), err);
    }
  }

  /**
   * Closes all notices sent by a given caller.
   * @param {string} addonId Addon ID
   * @emits Notifications#Events.VIZALITY_NOTICE_CLOSE_ALL_BY_CALLER
   */
  closeNoticesByCaller (addonId) {
    try {
      assertString(addonId);
      notifications.notices
        .filter(notice => notice.caller?.id === addonId)
        .forEach(notice => this.closeNotice(notice.id));
      this.emit(Events.VIZALITY_NOTICE_REMOVE_ALL_BY_CALLER, addonId);
    } catch (err) {
      return this.error(this._labels.concat('closeNoticesByCaller'), err);
    }
  }

  /**
   * Closes all toasts sent by a given caller.
   * @param {string} addonId Addon ID
   * @emits Notifications#Events.VIZALITY_TOAST_CLOSE_ALL_BY_CALLER
   */
  closeToastsByCaller (addonId) {
    try {
      assertString(addonId);
      notifications.toasts
        .filter(toast => toast.caller?.id === addonId)
        .forEach(toast => this.closeToast(toast.id));
      this.emit(Events.VIZALITY_TOAST_REMOVE_ALL_BY_CALLER, addonId);
    } catch (err) {
      return this.error(this._labels.concat('closeToastsByCaller'), err);
    }
  }

  /**
   * Closes all active notices.
   * @emits Notifications#Events.VIZALITY_NOTICE_CLOSE_ALL
   */
  closeAllNotices () {
    try {
      notifications.notices.forEach(notice => this.closeNotice(notice.id));
      // Dismiss all notices sent by Discord as well
      getModule(m => m.default?.dismiss)?.default?.dismiss();
      this.emit(Events.VIZALITY_NOTICE_CLOSE_ALL);
    } catch (err) {
      return this.error(this._labels.concat('closeAllNotices'), err);
    }
  }

  /**
   * Closes all active (currently visible) toasts. If no toast limit is set, this will be functionally
   * equivalent to the `closeAllToasts` method.
   * @emits Notifications#Events.VIZALITY_TOAST_CLOSE_ALL_ACTIVE
   */
  closeAllActiveToasts () {
    try {
      notifications.toasts.forEach(toast => {
        if (this.isToastActive(toast.id)) {
          notifications.toasts = this.getToasts(t => t.id !== toast.id);
        }
      });
      _toast.dismiss();
      this.emit(Events.VIZALITY_TOAST_CLOSE_ALL_ACTIVE);
    } catch (err) {
      return this.error(this._labels.concat('closeAllActiveToasts'), err);
    }
  }

  /**
   * Closes all queued toasts. If no toast limit is set, this method will simply emit
   * an event.
   * @emits Notifications#Events.VIZALITY_TOAST_CLOSE_ALL_QUEUED
   */
  closeAllQueuedToasts () {
    try {
      notifications.toasts.forEach(toast => {
        if (this.isToastQueued(toast.id)) {
          notifications.toasts = this.getToasts(t => t.id !== toast.id);
        }
      });
      _toast.clearWaitingQueue();
      this.emit(Events.VIZALITY_TOAST_CLOSE_ALL_QUEUED);
    } catch (err) {
      return this.error(this._labels.concat('closeAllQueuedToasts'), err);
    }
  }

  /**
   * Closes all active toasts.
   * @emits Notifications#Events.VIZALITY_TOAST_CLOSE_ALL
   */
  closeAllToasts () {
    try {
      _toast.clearWaitingQueue();
      _toast.dismiss();
      notifications.toasts = [];
      this.emit(Events.VIZALITY_TOAST_CLOSE_ALL);
    } catch (err) {
      return this.error(this._labels.concat('closeAllToasts'), err);
    }
  }
}
