/* eslint-disable no-unreachable-loop */
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable @typescript-eslint/typedef */
// #region Imports
import { getPattern, getTailGroup } from './../Utils';
import { emit, on } from '@breadstone/mosaik-elements';
/**
 * A reactive controller that performs location-based routing using a
 * configuration of URL patterns and associated render callbacks.
 *
 * @fires routesConnected - Fired when a child routes controller is connected.
 *
 * @public
 */
export class RouterController {
    // #region Fields
    static ORIGIN = location.origin || `${location.protocol}//${location.host}`; // We cache the origin since it can't change
    _childRoutes = []; // The current set of child Routes controllers. These are connected via the routes-connected event.
    _host;
    /**
         * State related to the current matching route.
         *
         * We keep this so that consuming code can access current parameters, and so
         * that we can propagate tail matches to child routes if they are added after
         * navigation / matching.
         */
    _currentPathname;
    _currentRoute;
    _currentParams = {};
    _parentRoutes;
    /**
         * Callback to call when this controller is disconnected.
         *
         * It's critical to call this immediately in hostDisconnected so that this
         * controller instance doesn't receive a tail match meant for another route.
         */
    // TODO (justinfagnani): Do we need this now that we have a direct reference
    // to the parent? We can call `this._parentRoutes.disconnect(this)`.
    _onDisconnect;
    _windowClickSubscription;
    _windowPopStateSubscription;
    _hostRoutesConnectedSubscription;
    // #endregion
    // #region Ctor
    /**
     * Constructs a new instance of the `MutationController` class.
     *
     * @public
     */
    constructor(host, routes, config) {
        this._host = host;
        host.addController(this);
        this.routes = [...routes];
        this.fallback = config?.fallback;
        this.callback = config?.callback;
        this.useHash = config?.useHash ?? false;
        this.scrollRestoration = config?.scrollRestoration ?? 'manual';
        this._windowClickSubscription = null;
        this._windowPopStateSubscription = null;
        this._hostRoutesConnectedSubscription = null;
        if ('scrollRestoration' in history) {
            window.history.scrollRestoration = config?.scrollRestoration ?? 'manual';
        }
    }
    // #endregion
    // #region Properties
    /**
     * The currently installed set of routes in precedence order.
     *
     * This array is mutable. To dynamically add a new route you can write:
     *
     * ```ts
     * this._routes.routes.push({
     *   path: '/foo',
     *   render: () => html`<p>Foo</p>`,
     * });
     * ```
     *
     * Mutating this property does not trigger any route transitions. If the
     * changes may result is a different route matching for the current path, you
     * must instigate a route update with `navigate()`.
     */
    routes = [];
    /**
         * A default fallback route which will always be matched if none of the
         * {@link routes} match. Implicitly matches to the path "/*".
         */
    fallback;
    callback;
    useHash;
    /**
     * Gets or sets the `scrollRestoration` property.
     *
     * @public
     */
    get scrollRestoration() {
        return window.history.scrollRestoration;
    }
    set scrollRestoration(value) {
        window.history.scrollRestoration = value;
    }
    /**
     * The current parsed route parameters.
     */
    get params() {
        return this._currentParams;
    }
    // #endregion
    // #region Methods
    hostConnected() {
        this._hostRoutesConnectedSubscription = on(this._host, 'routesConnected', (e) => this.onRoutesConnected(e));
        const details = { routes: this };
        emit(this._host, 'routesConnected', details);
        this._onDisconnect = details.disconnect;
        this._windowClickSubscription = on(window, 'click', (e) => this.onWindowClick(e));
        this._windowPopStateSubscription = on(window, 'popstate', (e) => this.onWindowPopState(e));
        // initial kick off routed rendering by going to the current URL
        let initialPath = this.useHash
            ? window.location.hash.substring(1)
            : window.location.pathname;
        // remove query params if exist
        initialPath = initialPath.split('?')[0];
        initialPath = initialPath === '' && this.useHash
            ? '/'
            : initialPath;
        void this.navigate(initialPath);
    }
    hostDisconnected() {
        // When this child routes controller is disconnected because a parent
        // outlet rendered a different template, disconnecting will ensure that
        // this controller doesn't receive a tail match meant for another route.
        this._onDisconnect?.();
        this._parentRoutes = undefined;
        this._hostRoutesConnectedSubscription?.dispose();
        this._windowClickSubscription?.dispose();
        this._windowPopStateSubscription?.dispose();
    }
    /**
     * Navigates this routes controller to `pathname`.
     *
     * This does not navigate parent routes, so it isn't (yet) a general page
     * navigation API. It does navigate child routes if pathname matches a
     * pattern with a tail wildcard pattern (`/*`).
     */
    async navigate(pathname) {
        // first, fix the case-insensitives
        pathname = pathname.toLowerCase();
        // if (this.useHash) {
        //     pathname = `#${pathname}`;
        // }
        // TODO (justinfagnani): handle absolute vs relative paths separately.
        // TODO (justinfagnani): do we need to detect when navigate() is called while
        // a previous navigate() call is still pending?
        // TODO (justinfagnani): generalize this to handle query params and
        // fragments. It currently only handles path names because it's easier to
        // completely disregard the origin for now. The click handler only does
        // an in-page navigation if the origin matches anyway.
        let tailGroup = undefined;
        if (this.routes.length === 0 && this.fallback === undefined) {
            // If a routes controller has none of its own routes it acts like it has
            // one route of `/*` so that it passes the whole pathname as a tail
            // match.
            tailGroup = pathname;
            this._currentPathname = '';
            // Simulate a tail group with the whole pathname
            this._currentParams = { 0: tailGroup };
        }
        else {
            const route = this.getRoute(pathname);
            if (route === undefined) {
                throw new Error(`No route found for ${pathname}`);
            }
            const pattern = getPattern(route);
            const result = pattern.reg.exec(pathname);
            const params = result?.groups ?? {};
            tailGroup = getTailGroup(params);
            if (typeof route.enter === 'function') {
                const isSuccess = await route.enter(params);
                // If enter() returns false, cancel this navigation
                if (!isSuccess) {
                    return;
                }
            }
            // Only update route state if the enter handler completes successfully
            this._currentRoute = route;
            this._currentParams = params;
            this._currentPathname = tailGroup === undefined
                ? pathname
                : pathname.substring(0, pathname.length - tailGroup.length);
            if (this.callback) {
                this.callback(this._currentRoute);
            }
        }
        // Propagate the tail match to children
        if (tailGroup !== undefined) {
            for (const childRoutes of this._childRoutes) {
                return childRoutes.navigate(tailGroup);
            }
        }
        this._host.requestUpdate();
        return Promise.resolve();
    }
    /**
     * Returns a URL string of the current route, including parent routes,
     * optionally replacing the local path with `pathname`.
     */
    link(pathname) {
        if (pathname?.startsWith('/')) {
            return pathname;
        }
        if (pathname?.startsWith('.')) {
            throw new Error('Not implemented');
        }
        pathname ??= this._currentPathname;
        let linkPath = (this._parentRoutes?.link() ?? '') + pathname;
        if (this.useHash) {
            linkPath = `#${linkPath}`;
        }
        return linkPath;
    }
    /**
     * The result of calling the current route's render() callback.
     */
    outlet() {
        const render = this._currentRoute?.render;
        const view = this._currentRoute?.view;
        if (render) {
            return render(this._currentParams);
        }
        if (view) {
            return view;
        }
        return undefined;
    }
    /**
     * Matches `url` against the installed routes and returns the first match.
     */
    getRoute(pathname) {
        // first, fix the case-insensitives
        pathname = pathname.toLowerCase();
        // if (this.useHash) {
        //     pathname = pathname.substring(1);
        // }
        const matchedRoute = this.routes.find((r) => getPattern(r).reg.test(pathname));
        if (matchedRoute ?? this.fallback === undefined) {
            return matchedRoute;
        }
        if (this.fallback) {
            // The fallback route behaves like it has a "/*" path. This is hidden from
            // the public API but is added here to return a valid RouteConfig.
            return {
                ...this.fallback,
                path: '/*'
            };
        }
        return undefined;
    }
    onRoutesConnected(e) {
        // Don't handle the event fired by this routes controller, which we get
        // because we do this.dispatchEvent(...)
        if (e.detail.routes === this) {
            return;
        }
        const childRoutes = e.detail.routes;
        this._childRoutes.push(childRoutes);
        childRoutes._parentRoutes = this;
        e.stopImmediatePropagation();
        e.detail.disconnect = () => {
            // Remove route from this._childRoutes:
            // `>>> 0` converts -1 to 2**32-1
            this._childRoutes.splice(this._childRoutes.indexOf(childRoutes) >>> 0, 1);
        };
        const tailGroup = getTailGroup(this._currentParams);
        if (tailGroup !== undefined) {
            void childRoutes.navigate(tailGroup);
        }
    }
    onWindowClick(event) {
        const isNonNavigationClick = event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey;
        if (event.defaultPrevented || isNonNavigationClick) {
            return;
        }
        // find a possible anchor element
        const anchor = event.composedPath()
            .filter((x) => x instanceof Element)
            .find((n) => {
            const tagName = n.tagName.toLowerCase();
            const isAnchor = tagName === 'a';
            if (isAnchor) {
                return n;
            }
            const hasHref = n.hasAttribute('href');
            if (hasHref) {
                return {
                    ...n,
                    pathname: n.getAttribute('href') ?? ''
                };
            }
            return undefined;
        });
        // ingonre invalid anchor
        if (anchor === undefined || anchor.target !== '' || anchor.hasAttribute('download') || anchor.getAttribute('rel') === 'external') {
            return;
        }
        // ignore invalid href
        if (anchor.href !== undefined && (anchor.href === '' || anchor.href.startsWith('mailto:') || anchor.href.startsWith('tel:'))) {
            return;
        }
        // ignore invalid origin
        if (anchor.origin !== RouterController.ORIGIN) {
            return;
        }
        event.preventDefault();
        if (anchor.href !== window.location.href && anchor.href !== undefined) {
            if (this.useHash) {
                const search = new URLSearchParams(anchor.search);
                const path = search.size ? `${anchor.pathname}?${search.toString()}` : anchor.pathname;
                window.location.hash = `#${path}`;
            }
            else {
                // track history step
                window.history.pushState({}, '', anchor.href);
            }
            void this.navigate(anchor.pathname);
        }
    }
    onWindowPopState(_event) {
        let pathname = '';
        if (this.useHash) {
            // if the substring is empty, we are on the root page
            const hasHash = window.location.hash.length > 0;
            pathname = hasHash
                ? window.location.hash.substring(1)
                : '/';
        }
        else {
            pathname = window.location.pathname;
        }
        // remove query params if exist
        pathname = pathname.split('?')[0];
        void this.navigate(pathname);
    }
}
//# sourceMappingURL=RouterController.js.map