/* eslint-disable @typescript-eslint/naming-convention */
import { GLOBAL_ROUTER_EVENTS_TARGET, ROUTER_SLOT_TAG_NAME } from './config.js';
import { addListener, constructAbsolutePath, dispatchGlobalRouterEvent, dispatchRouteChangeEvent, ensureAnchorHistory, ensureHistoryEvents, handleRedirect, isRedirectRoute, isResolverRoute, matchRoutes, pathWithoutBasePath, queryParentRouterSlot, removeListeners, resolvePageComponent, shouldNavigate, } from './util.js';
const template = document.createElement('template');
template.innerHTML = `<slot></slot>`;
// Patches the history object and ensures the correct events.
ensureHistoryEvents();
// Ensure the anchor tags uses the history API
ensureAnchorHistory();
/**
 * Slot for a node in the router tree.
 * @slot - Default content.
 * @event changestate - Dispatched when the router slot state changes.
 */
// eslint-disable-next-line local-rules/enforce-element-suffix-on-element-class-name
export class RouterSlot extends HTMLElement {
    get routes() {
        return this._routes;
    }
    set routes(routes) {
        this.clear();
        this.add(routes);
    }
    get parent() {
        return this._parent;
    }
    set parent(router) {
        this._lockParent = true;
        this._setParent(router);
    }
    _setParent(router) {
        if (this._parent === router)
            return;
        this.detachListeners();
        this._parent = router;
        this.attachListeners();
    }
    /**
     * Whether the router is a root router.
     */
    get isRoot() {
        return this.parent == null;
    }
    get match() {
        return this._routeMatch;
    }
    /**
     * The current route of the match.
     */
    get route() {
        return this.match != null ? this.match.route : null;
    }
    /**
     * The current path fragment of the match
     */
    get fragments() {
        return this.match != null ? this.match.fragments : null;
    }
    /**
     * The current params of the match.
     */
    get params() {
        return this.match != null ? this.match.params : null;
    }
    /**
     * Hooks up the element.
     */
    constructor() {
        super();
        /**
         * Listeners on the router.
         */
        this.listeners = [];
        /**
         * The available routes.
         */
        this._routes = [];
        this._lockParent = false;
        /**
         * The current route match.
         */
        this._routeMatch = null;
        this.addEventListener('router-slot:capture-parent', (e) => {
            e.stopPropagation();
            e.detail.parent = this;
        });
        this.render = this.render.bind(this);
        // Attach the template
        const shadow = this.attachShadow({ mode: 'open' });
        shadow.appendChild(template.content.cloneNode(true));
    }
    /**
     * Query the parent router slot when the router slot is connected.
     */
    connectedCallback() {
        // Do not query a parent if the parent has been set from the outside.
        if (!this._lockParent) {
            const captureParentEvent = new CustomEvent('router-slot:capture-parent', {
                composed: true,
                bubbles: true,
                detail: { parent: null },
            });
            if (this.parentNode) {
                this.parentNode.dispatchEvent(captureParentEvent);
                this._setParent(captureParentEvent.detail.parent ?? null);
            }
            else {
                this._setParent(null);
            }
        }
        if (this.parent) {
            requestAnimationFrame(() => {
                if (this.parent && this.parent.match !== null && this.match === null) {
                    this.render();
                }
            });
        }
    }
    /**
     * Tears down the element.
     */
    disconnectedCallback() {
        this._setParent(null);
        this._cancelNavigation?.();
        this.detachListeners();
        this.clearChildren();
        this._routeMatch = null;
    }
    /**
     * Queries the parent router.
     */
    queryParentRouterSlot() {
        return queryParentRouterSlot(this);
    }
    /**
     * Returns an absolute path relative to the router slot.
     * @param path
     */
    constructAbsolutePath(path) {
        return constructAbsolutePath(this, path);
    }
    /**
     * Adds routes to the router.
     * Navigates automatically if the router slot is the root and is connected.
     * @param routes
     * @param navigate
     */
    add(routes, navigate) {
        // Add the routes to the array
        this._routes.push(...routes);
        if (navigate === undefined) {
            if (this.isConnected) {
                // If navigate is not determined, then we will check if we have a route match, and if the new match is different from current. [NL]
                const newMatch = this.getRouteMatch();
                if (newMatch) {
                    // Check if this match matches the current match (aka. If the path has changed), if so we should navigate. [NL]
                    navigate = shouldNavigate(this.match, newMatch);
                }
            }
        }
        // Navigate fallback:
        navigate ??= this.isRoot && this.isConnected;
        // Register that the path has changed so the correct route can be loaded.
        if (navigate) {
            this.render();
        }
    }
    /**
     * Removes all routes.
     */
    clear() {
        this._routes.length = 0;
    }
    /**
     * Each time the path changes, load the new path.
     */
    async render() {
        // When using ShadyDOM the disconnectedCallback in the child router slot is called async
        // in a microtask. This means that when using the ShadyDOM polyfill, sometimes child router slots
        // would not clear event listeners from the parent router slots and therefore route even though
        // it was no longer in the DOM. The solution is to check whether the isConnected flag is false
        // before rendering the path.
        if (!this.isConnected) {
            return;
        }
        // Either choose the parent fragment or the current path if no parent exists.
        // The root router slot will always use the entire path.
        const pathFragment = this.getPathFragment();
        // Route to the path
        await this.renderPath(pathFragment);
    }
    getPathFragment() {
        return this.parent != null && this.parent.fragments != null ? this.parent.fragments.rest : pathWithoutBasePath();
    }
    getRouteMatch() {
        // Find the corresponding route.
        return matchRoutes(this._routes, this.getPathFragment());
    }
    /**
     * Attaches listeners, either globally or on the parent router.
     */
    attachListeners() {
        // Add listeners that updates the route
        this.listeners.push(this.parent != null
            ? // Attach child router listeners
                addListener(this.parent, 'changestate', this.render)
            : // Add global listeners.
                addListener(GLOBAL_ROUTER_EVENTS_TARGET, 'changestate', this.render));
    }
    /**
     * Clears the children in the DOM.
     */
    clearChildren() {
        while (this.firstChild != null) {
            // If our route-component has a destroy method, then call it.
            this.firstChild.destroy?.();
            this.firstChild.parentNode.removeChild(this.firstChild);
        }
    }
    /**
     * Detaches the listeners.
     */
    detachListeners() {
        removeListeners(this.listeners);
    }
    /**
     * Notify the listeners.
     * @param info
     */
    notifyChildRouters(info) {
        // This method should be called before routeMatch is being set!
        // This only work cause we are using a requestAnimationFrame to dispatch the event,
        // in other words, the routeMatch will be set when the child router receives the event.
        // Scenario:
        // When this router came from a route(routeMatch !== null), then:
        // Dispatch the route change event to notify the children that something happened.
        // This is because the child routes might have to change routes further down the tree.
        // The event is dispatched in an animation frame to allow route children to make the initial render first
        // and hook up the new router slot.
        if (this._routeMatch !== null) {
            requestAnimationFrame(() => {
                dispatchRouteChangeEvent(this, info);
            });
        }
    }
    getRedirectDelay() {
        if ('connection' in navigator) {
            const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
            switch (connection.effectiveType) {
                case 'slow-2g':
                case '2g':
                    return 1200;
                case '3g':
                    return 800;
                case '4g':
                    return 200;
            }
        }
        return 400;
    }
    /**
     * Loads a new path based on the routes.
     * Returns true if a navigation was made to a new page.
     * @param path
     */
    async renderPath(path) {
        // Notice: Since this is never called from any other place than one higher in this file(when writing this...), we could just retrieve the path and find a match by using this.getRouteMatch() [NL]
        // Find the corresponding route.
        const match = matchRoutes(this._routes, path);
        // Ensure that a route was found, otherwise we just clear the current state of the route.
        if (match == null) {
            this._routeMatch = null;
            return false;
        }
        const { route } = match;
        const info = { match, slot: this };
        try {
            // Only change route if its a new route.
            const navigate = shouldNavigate(this.match, match);
            if (navigate) {
                // If another navigation is still begin resolved in this very moment, then we need to cancel that so it does not end up overriding this new navigation.[NL]
                this._cancelNavigation?.();
                // Listen for another push state event. If another push state event happens
                // while we are about to navigate we have to cancel.
                let navigationInvalidated = false;
                const cancelNavigation = () => {
                    navigationInvalidated = true;
                    this._cancelNavigation = undefined;
                };
                this._cancelNavigation = cancelNavigation;
                const removeChangeListener = addListener(GLOBAL_ROUTER_EVENTS_TARGET, 'changestate', cancelNavigation, {
                    once: true,
                });
                // Cleans up the routing by removing listeners and restoring the match from before
                const cleanup = () => {
                    removeChangeListener();
                };
                // Cleans up and dispatches a global event that a navigation was cancelled.
                const cancel = () => {
                    cleanup();
                    dispatchGlobalRouterEvent('navigationcancel', info);
                    dispatchGlobalRouterEvent('navigationend', info);
                    return false;
                };
                // Dispatch globally that a navigation has started
                dispatchGlobalRouterEvent('navigationstart', info);
                // Check whether the guards allow us to go to the new route.
                if (route.guards != null) {
                    for (const guard of route.guards) {
                        if (!(await guard(info))) {
                            return cancel();
                        }
                    }
                }
                // We are going to navigate, so we want to notify the child routers:
                this.notifyChildRouters(info);
                // Redirect if necessary
                if (isRedirectRoute(route)) {
                    cleanup();
                    if (route.awaitStability === true) {
                        // await until browser is done loading, based on a guess:
                        const delay = this.getRedirectDelay();
                        await new Promise((resolve) => setTimeout(resolve, delay));
                        if (navigationInvalidated) {
                            return cancel();
                        }
                    }
                    // Check if the current route is still matching the browser URL.:
                    if (!window.location.href.includes(this.constructAbsolutePath(''))) {
                        // If the parent is active, we should not redirect.
                        return cancel();
                    }
                    handleRedirect(this, route);
                    return false;
                }
                // Handle custom resolving if necessary
                else if (isResolverRoute(route)) {
                    // The resolve will handle the rest of the navigation. This includes whether or not the navigation
                    // should be cancelled. If the resolve function returns false we cancel the navigation.
                    if ((await route.resolve(info)) === false) {
                        return cancel();
                    }
                }
                // If the component provided is a function (and not a class) call the function to get the promise.
                else {
                    const page = await resolvePageComponent(route, info);
                    // Cancel the navigation if another navigation event was sent while this one was loading
                    if (navigationInvalidated) {
                        return cancel();
                    }
                    // We have some routes that share the same component instance, those should not be removed and re-appended [NL]
                    const isTheSameComponent = this.firstChild === page;
                    if (!isTheSameComponent) {
                        // Remove the old page by clearing the slot
                        this.clearChildren();
                    }
                    // Store the new route match before we append the new page to the DOM.
                    // We do this to ensure that we can find the match in the connectedCallback of the page.
                    this._routeMatch = match;
                    if (!isTheSameComponent) {
                        if (page) {
                            // Append the new page
                            this.appendChild(page);
                        }
                    }
                }
                // Remember to cleanup after the navigation
                cleanup();
            }
            else {
                // We did not make a new navigation this time, but we want to notify children here:
                this.notifyChildRouters(info);
            }
            // Store the new route match
            this._routeMatch = match;
            // Dispatch globally that a navigation has ended.
            if (navigate) {
                dispatchGlobalRouterEvent('navigationsuccess', info);
                dispatchGlobalRouterEvent('navigationend', info);
            }
            return navigate;
        }
        catch (e) {
            dispatchGlobalRouterEvent('navigationerror', info);
            dispatchGlobalRouterEvent('navigationend', info);
            throw e;
        }
    }
}
window.customElements.define(ROUTER_SLOT_TAG_NAME, RouterSlot);
