import { getBasePath, getUser } from 'talon/configProvider';
import { autoBind, isObject, transferProperty } from 'talon/utils';
import { createRouter } from 'universal_container/routingService';
import {
    navigate as lwrNavigate,
    generateUrl as lwrGenerateUrl,
    subscribe as lwrSubscribe,
} from 'universal_container/navigation';
import { compile } from 'path-to-regexp';

const WHITELISTED_PARAMETERS = ['mode', 'talon.lwc.fallback'];

/**
 * Routing service class.
 *
 * A single instance of it will be used throughout the app
 * and selected methods will be exported.
 *
 * We still export the class itself for testing purpose so that we can
 * create as many instances as needed.
 */
export class RoutingService {
    router;
    defaultRouteDef = null;
    currentQueryParams = {};

    /**
     * Register a set of routes and start the router.
     *
     * See JSON schema at https://salesforce.quip.com/EDbbAKBI92ZM#LeeACA0AB7l.
     *
     * See validate-routes.js#validateRoutes() for the validation rules.
     *
     * @param {Object[]} routes - The routes to register
     */
    registerRoutes(routes) {
        // find the default route definition to use as a fallback
        this.defaultRouteDef = routes.find(route => route.isDefault);

        // create and start the router
        this.router = createRouter({
            basePath: getBasePath(),
            routes: routes,
            handleNavigation: this.handleNavigation.bind(this),
            getRouteFromUrl: this.getRouteFromUrl.bind(this),
            getUrlFromRoute: this.getUrlFromRoute.bind(this),
        });

        // block unauthorized viewing of routes
        this.router.addPreNavigate([
            this.isAuthorized.bind(this),
            this.addStickyQueryParams.bind(this),
        ]);

        // keep track of current query params with this listener
        this.router.addPostNavigate(this.trackQueryParams.bind(this));

        // handle navigation errors (e.g. URL or route match not found)
        this.router.addErrorNavigate(this.surfaceErrors.bind(this)).connect();
    }

    /**
     * When navigating to a standard__webPage typed route, open absolute URLs directly.
     * Then block propagation of the navigation event up the router tree.
     *
     * @param {object | string} route - The input to navigate(), could be a string URL or a route object
     */
    handleNavigation(route) {
        if (isObject(route)) {
            const { type, attributes: { url } = { url: null } } = route;
            if (type === 'standard__webPage' && this.isAbsoluteURL(url)) {
                window.open(url);
                return false;
            }
        }
        return true;
    }

    /**
     * Override the default URL => route translation
     *
     * @param {string} url - A URL to transform into a route
     * @param {function} defaultImpl - The default implementation of the getRouteFromUrl function
     *
     * @returns {object} - { route: route object, data: route definition object }
     */
    getRouteFromUrl(url, defaultImpl) {
        let { route, data } = defaultImpl(url);

        // Return the default route if no match is found for the URL
        if (route.type === 'standard__simpleRoute') {
            data = this.defaultRouteDef;
        }

        // Move filterName attribute to state for on-core standard__objectPages
        if (route.type === 'standard__objectPage' && data.path.includes('filterName')) {
            route = transferProperty(route, 'state', 'attributes', 'filterName', 'Default');
        }

        // Move attribute.term to state.term for on-core standard__search
        if (route.type === 'standard__search' && data.path.includes('term')) {
            route = transferProperty(route, 'state', 'attributes', 'term', ' ');
        }

        return { route, data };
    }

    /**
     * Override the default route => URL translation
     *
     * @param {object} route - A route to transform into a URL, including the queryParams as 'state'
     * @param {function} defaultImpl - The default implementation of the getUrlFromRoute function
     *
     * @returns {object} - { url: string URL, data: route definition object }
     */
    getUrlFromRoute(route = {}, defaultImpl) {
        const { type, attributes: { url } = {} } = route;

        // Return URL directly from standard__webPage types
        if (type === 'standard__webPage') {
            return { url: url || null, data: null };
        }

        // Move filterName state to attributes for on-core standard__objectPages
        if (type === 'standard__objectPage' && !defaultImpl(route).url) {
            route = transferProperty(route, 'attributes', 'state', 'filterName', 'Default');
        }

        // Move state.term to attributes.term for on-core standard__search
        if (type === 'standard__search' && !defaultImpl(route).url) {
            route = transferProperty(route, 'attributes', 'state', 'term', ' ');
        }

        return defaultImpl(route);
    }

    /**
     * Is the current user allowed to open this route (page)
     * Public routes -> all users are allowed, Private routes -> only authenticated users
     *
     * If isPublic is not defined on routes -> isAuthorized will always return true
     * @param {object} obj - A transaction object
     * @param {object} obj.next - The information that is next in line for navigation
     * @param {object} obj.next.route - The next route
     * @param {object} obj.next.data - The next route definition
     *
     * @returns {boolean}
     */
    isAuthorized({ next: { route, data } = {} } = {}) {
        const { isGuest } = getUser();

        // if guest user trying to access private page -> not authorized
        // check user is allowed to visit page -> if not reload and let the server redirect to the correct login page
        if (isGuest && 'isPublic' in data && !data.isPublic) {
            const url = compile(data.path)(route.attributes);
            if (url) {
                window.location.href = `${getBasePath()}${url}`;
            }
            return false;
        }
        return true;
    }

    addStickyQueryParams({ next: { route } = {} } = {}) {
        const updatedRoute = this.getRouteWithCombinedQueryParams(route);
        if (updatedRoute) {
            this.navigate(updatedRoute);
            return false;
        }
        return true;
    }

    /**
     * Keep track of the current query parameters
     * Use this information to add sticky query params in getUrlFromRoute
     *
     * @param {object} obj - A transaction object
     * @param {object} obj.current - The current route
     * @param {object} obj.current.route - The current route
     * @param {object} obj.current.route.state - The current route state/query object
     */
    trackQueryParams({ current: { route: { state = {} } = {} } } = {}) {
        this.currentQueryParams = state;
    }

    /**
     * surface any navigation error
     *
     * @param {object} obj - An error object including a code and message
     * @param {int} obj.code - An error code
     * @param {string} obj.message - An error message
     */
    surfaceErrors({ code, message }) {
        throw new Error(`Routing error: (${code}) ${message}`);
    }

    /**
     * Add the current sticky query params to the give route
     *
     * @param {object} route - A route object where sticky query params are added to the state
     *
     * @returns {object | null} - The route object with sticky query params combined with the given state,
     *                            null is returned if the state did not need to be updated
     */
    getRouteWithCombinedQueryParams(route = {}) {
        // Find the whitelisted parameters in the current query object,
        //      that are NOT in the given route's state.
        // The requested query params override any current whitelisted params.
        const newStickyQueryParams = WHITELISTED_PARAMETERS.reduce((acc, param) => {
            if (this.currentQueryParams[param] && !Object.hasOwnProperty.call(route.state, param)) {
                acc[param] = this.currentQueryParams[param];
            }
            return acc;
        }, {});

        if (Object.keys(newStickyQueryParams).length > 0) {
            return { ...route, state: { ...newStickyQueryParams, ...route.state } };
        }

        return null;
    }

    /**
     * Check whether url is absolute url
     *
     * @param {string} url - the url
     */
    isAbsoluteURL(url) {
        return new RegExp('^([a-z]+://|//)', 'i').test(url);
    }

    /* Compose the routing APIs with this router's navigation context ID
       This way, Talon API users do not need to know about navigation context
       APIs: navigate, generateUrl, subscribe            */

    /**
     * Navigate programmatically.
     *
     * @param {object | string} loc - A route or URL for navigating
     * @param {*} options - Usually a boolean; when true the previous browser history
     *              entry should be replaced by this one
     */
    navigate(loc, options) {
        lwrNavigate(this.router.id, loc, options);
    }

    /**
     * Generate a URL for the given route.
     *
     * @param {object} route - A route
     *
     * @returns {Promise<string>}
     */
    generateUrl(route) {
        return lwrGenerateUrl(this.router.id, route);
    }

    /**
     * Subscribe to navigation state changes.
     *
     * @param {function} callback - A callback function invoked when the navigation state changes
     *                     callback(route, routeDef)
     * @param {boolean} replay - Flag to determine if callback should be called with current route and data immediately
     */
    subscribe(callback, replay = true) {
        lwrSubscribe(this.router.id, callback, replay);
    }
}

// create an instance with bound methods so that they can be exported
const instance = autoBind(new RoutingService());

// export select functions from this instance
export const { registerRoutes, navigate, generateUrl, subscribe } = instance;

export default {
    registerRoutes,
    navigate,
    generateUrl,
    subscribe,
};
