/**
 * The routes API is used for various routing functionality, such as getting the current
 * route, registering custom routes, and generally easier navigation around the app.
 * @module Routes
 * @memberof API
 * @namespace API.Routes
 * @version 1.0.0
 */

import { assertString, toSnakeCase } from '@vizality/util/string';
import { Sidebar } from '@vizality/components/dashboard';
import { Events, Regexes } from '@vizality/constants';
import { assertObject } from '@vizality/util/object';
import { isComponent } from '@vizality/util/react';
import { getCaller } from '@vizality/util/file';
import { getModule } from '@vizality/webpack';
import { API } from '@vizality/entities';
import { isValidElement } from 'react';

import DashboardRoutes from '@vizality/builtins/dashboard/routes/Routes';


/**
 * Vizality route object.
 * @typedef VizalityRoute
 * @property {string} [id] Route ID
 * @property {string} path Route path
 * @property {ReactElement} render Route renderer
 * @property {ReactElement|undefined} sidebar Sidebar renderer
 */

/**
 * @extends API
 * @extends Events
 */
export default class Routes extends API {
  constructor () {
    super();
    /**
     * All currently registered routes.
     */
    this.routes = [];
  }

  getAllRoutes () {
    return this.routes;
  }

  /**
   * Shuts down the API, removing all listeners and stored objects.
   */
  stop () {
    this.unregisterAllRoutes();
    this.removeAllListeners();
    delete vizality.api.routes;
  }

  /**
   * Goes back to the last non-Vizality route.
   * @returns {Promise<void>}
   */
  async restorePreviousRoute () {
    try {
      if (this.getLocation()?.pathname?.startsWith('/vizality')) {
        let history = await vizality.native.app.getHistory();
        if (!history) {
          return;
        }
        history = history.reverse();
        history.shift();
        const match = history.find(location => !location.includes('/vizality'));
        const route = match.replace(new RegExp(Regexes.DISCORD), '');
        $discord.modules.router.replaceWith(route);
      }
    } catch (err) {
      return this.error(this._labels.concat('restorePreviousRoute'), err);
    }
  }

  /**
   * Registers a route.
   * @param {VizalityRoute} route Route to register
   * @emits Routes#routeAdd
   */
  /*
   * registerRoute (route) {
   *   try {
   *     if (!route?.id) {
   *       throw new Error('Route must contain a valid ID!');
   *     }
   *     if (route.id !== 'dashboard') {
   *       if (!route.path) {
   *         throw new Error(`Route ID "${route.id}" cannot be registered without a valid path.`);
   *       }
   *       if (this.routes[route.id]) {
   *         throw new Error(`Route ID "${route.id}" is already registered!`);
   *       }
   *       if (Object.values(this.routes).find(r => r.path === route.path)) {
   *         throw new Error(`Route ID "${route.id}" tried to register an already-registered path "${route.path}"!`);
   *       }
   *       route.caller = getCaller();
   *     }
   *     this.routes[route.id] = route;
   *     if (Object.keys(this.routes)[Object.keys(this.routes).length - 1] !== 'dashboard') {
   *       this._reregisterDashboardRoutes();
   *     }
   *     this.emit('routeAdd', route);
   *   } catch (err) {
   *     return this.error(err);
   *   }
   * }
   */

  /**
   * Registers a route.
   * @param {VizalityRoute} route Route to register
   * @emits Routes#Events.VIZALITY_ROUTE_ADD
   */
  registerRoute (route) {
    try {
      assertObject(route);
      route.caller = route.path === '' ? 'vizality' : getCaller();
      route.id = route.id || `${toSnakeCase(route.caller?.id).toUpperCase()}_ROUTE_${this.getRoutesByCaller(route.caller?.id)?.length + 1 || '1'}`;
      assertString(route.id);
      assertString(route.path);
      if (route.path !== '') {
        if (this.isRoute(route.id)) {
          throw new Error(`Route "${route.id}" is already registered!`);
        }
        if (this.isRoute(route.path)) {
          throw new Error(`Route "${route.id}" tried to register an already-registered path "${route.path}"!`);
        }
        if (!isComponent(route.render) && !isValidElement(route.render)) {
          throw new Error(`Route "${route.id}" did not provide a valid render property value! Must be a React element!`);
        }
      }
      /**
       * If no sidebar property is provided, use the Vizality dashboard sidebar as
       * the default.
       */
      if (!route.hasOwnProperty('sidebar')) {
        route.sidebar = Sidebar;
      }
      this.routes.push(route);
      if (this.routes[this.routes.length - 1]?.id !== 'dashboard') {
        this._reregisterDashboardRoutes();
      }
      this.emit(Events.VIZALITY_ROUTE_ADD, route.id, route.path);
    } catch (err) {
      return this.error(this._labels.concat('registerRoute'), err);
    }
  }

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

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

  /**
   * Unregisters a route.
   * @param {string} pathOrRouteId Route path or route ID
   * @emits Routes#routeRemove
   */
  unregisterRoute (pathOrRouteId) {
    try {
      if (!pathOrRouteId) {
        throw new Error(`Invalid route path or route ID provided!`);
      }
      if (pathOrRouteId.startsWith('/')) {
        if (!this.routes.find(r => r.path === pathOrRouteId)) {
          throw new Error(`Route path "${pathOrRouteId}" is not registered, so it cannot be unregistered!`);
        }
      } else if (!this.routes.find(r => r.id === pathOrRouteId)) {
        throw new Error(`Route ID "${pathOrRouteId}" is not registered, so it cannot be unregistered!`);
      }
      this.routes = this.getRoutes(route => route.id !== pathOrRouteId && route.path !== pathOrRouteId);
      this.emit('routeRemove', pathOrRouteId);
    } catch (err) {
      return this.error(err);
    }
  }

  /**
   * Checks if a route is registered.
   * @param {string} routeIdOrPath Route ID or route path
   * @returns {boolean} Whether a route with a given ID or path is registered
   */
  isRoute (routeIdOrPath) {
    try {
      assertString(routeIdOrPath);
      return this.routes.some(route => route.id === routeIdOrPath || route.path === routeIdOrPath);
    } catch (err) {
      return this.error(this._labels.concat('isRoute'), err);
    }
  }

  /**
   * Navigates to a route.
   * @param {string} pathOrRouteId Route path or route ID
   * @emits Routes#routeNavigate
   */
  navigateTo (pathOrRouteId) {
    try {
      if (!pathOrRouteId) {
        throw new Error('Invalid route ID or path provided!');
      }
      let path;
      if (!pathOrRouteId.startsWith('/')) {
        switch (pathOrRouteId) {
          case 'private': path = '/channels/@me/'; break;
          case 'discover': path = $discord.constants.Routes.GUILD_DISCOVERY; break;
          case 'friends': path = $discord.constants.Routes.FRIENDS; break;
          case 'library': path = $discord.constants.Routes.APPLICATION_LIBRARY; break;
          case 'nitro': path = $discord.constants.Routes.APPLICATION_STORE; break;
          default: path = `/vizality/${pathOrRouteId}`;
        }
      } else {
        path = pathOrRouteId;
      }

      /**
       * Check if it's a Vizality route and has a # in the path.
       */
      if (path.startsWith('/vizality/') && path.includes('#')) {
        $discord.modules.router.transitionTo(path);
        const hash = path.split('#')[1];
        /**
         * This is bad, but currently it's the only way I really know how to do it.
         * We're adding a timeout here so that we can give the new route page elements
         * (topOfElement and scroller) time to load into the DOM.
         */
        return setTimeout(() => {
          const topOfElement = document.querySelector(`#${hash}`)?.offsetTop;
          const scroller = document.querySelector('.vz-dashboard-scroller');
          /**
           * If the elements don't exist at the check, just load the page normally as if
           * there was no hash.
           */
          if (!topOfElement || !scroller) {
            return;
          }
          /**
           * Try to set the scroller position to the hash element.
           */
          return scroller.scroll({ top: topOfElement - 80, behavior: 'smooth' });
        }, 250);
      }
      /**
       * Go to the route.
       */
      return $discord.modules.router.transitionTo(path);
    } catch (err) {
      return this.error(err);
    }
  }

  /**
   * Unregisters all routes.
   * @emits Routes#routeRemoveAll
   */
  unregisterAllRoutes () {
    try {
      this.routes = {};
      this.emit('routeRemoveAll');
    } catch (err) {
      return this.error(err);
    }
  }

  /**
   * Unregisters all routes registered by a given addon.
   * @param {string} addonId Addon ID
   * @emits Routes#routeRemoveAllByAddon
   */
  unregisterRoutesByAddon (addonId) {
    try {
      this.emit('routeRemoveAllByAddon', addonId);
    } catch (err) {
      return this.error(err);
    }
  }

  /**
   * Goes back to the previous route.
   * @returns {void}
   */
  goBack () {
    try {
      const { back } = getModule('transitionTo', 'replaceWith', 'getHistory');
      return back();
    } catch (err) {
      return this.error(err);
    }
  }

  /**
   * Goes forward to the next route. Only works when a forward route exists.
   * @returns {void}
   */
  goForward () {
    try {
      const { forward } = getModule('transitionTo', 'replaceWith', 'getHistory');
      return forward();
    } catch (err) {
      return this.error(err);
    }
  }

  /**
   * Gets some information for the current route.
   * @returns {object|void}
   */
  getLocation () {
    try {
      const location = {};
      const routes = {
        private: '/channels/@me/',
        discover: $discord.constants.Routes.GUILD_DISCOVERY,
        friends: $discord.constants.Routes.FRIENDS,
        library: $discord.constants.Routes.APPLICATION_LIBRARY,
        nitro: $discord.constants.Routes.APPLICATION_STORE,
        guild: '/channels/',
        settings: '/vizality/settings',
        plugins: '/vizality/plugins',
        themes: '/vizality/themes',
        snippets: '/vizality/snippets',
        palettes: '/vizality/palettes',
        'quick-code': '/vizality/quick-code',
        development: '/vizality/development',
        docs: '/vizality/docs',
        updater: '/vizality/updater',
        changelog: '/vizality/changelog',
        dashboard: '/vizality'
      };

      for (const route in routes) {
        if (window.location.pathname.includes(routes[route])) {
          location.pathname = window.location.pathname;
          location.href = window.location.href;
          location.name = route || 'unknown';
          return location;
        }
      }
    } catch (err) {
      return this.error(err);
    }
  }

  /**
   * @note This is a hacky method used to unregister and reregister the main dashboard route
   * so that it doesn't override the plugin and theme routes... Not really sure how to do
   * this in a better way at the moment, but definitely should be addressed in the future.
   * @private
   */
  _reregisterDashboardRoutes () {
    try {
      if (!this.routes.find(route => route.id === 'dashboard')) {
        return;
      }
      this.unregisterRoute('dashboard');
      this.registerRoute({
        id: 'dashboard',
        path: '',
        render: DashboardRoutes,
        sidebar: Sidebar
      });
    } catch (err) {
      return this.error(err);
    }
  }
}
