/*
 * Copyright 2000-2024 Vaadin Ltd.
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
 * use this file except in compliance with the License. You may obtain a copy of
 * the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License.
 */
/// <reference lib="es2018" />
import { Flow as _Flow } from "Frontend/generated/jar-resources/Flow.js";
import React, {
    useCallback,
    useEffect,
    useRef,
    useState
} from "react";
import {
    matchRoutes,
    useBlocker,
    useLocation,
    useNavigate,
    type NavigateOptions, useHref,
} from "react-router-dom";
import type { AgnosticRouteObject } from '@remix-run/router';

const flow = new _Flow({
    imports: () => import("Frontend/generated/flow/generated-flow-imports.js")
});

const router = {
    render() {
        return Promise.resolve();
    }
};

// ClickHandler for vaadin-router-go event is copied from vaadin/router click.js
// @ts-ignore
function getAnchorOrigin(anchor) {
    // IE11: on HTTP and HTTPS the default port is not included into
    // window.location.origin, so won't include it here either.
    const port = anchor.port;
    const protocol = anchor.protocol;
    const defaultHttp = protocol === 'http:' && port === '80';
    const defaultHttps = protocol === 'https:' && port === '443';
    const host = (defaultHttp || defaultHttps)
        ? anchor.hostname // does not include the port number (e.g. www.example.org)
        : anchor.host; // does include the port number (e.g. www.example.org:80)
    return `${protocol}//${host}`;
}

function normalizeURL(url: URL): void | string {
    // ignore click if baseURI does not match the document (external)
    if (!url.href.startsWith(document.baseURI)) {
        return;
    }

    // Normalize path against baseURI
    return '/' + url.href.slice(document.baseURI.length);
}

function extractPath(event: MouseEvent): void | string {
    // ignore the click if the default action is prevented
    if (event.defaultPrevented) {
        return;
    }

    // ignore the click if not with the primary mouse button
    if (event.button !== 0) {
        return;
    }

    // ignore the click if a modifier key is pressed
    if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) {
        return;
    }

    // find the <a> element that the click is at (or within)
    let maybeAnchor = event.target;
    const path = event.composedPath
        ? event.composedPath()
        // @ts-ignore
        : (event.path || []);

    // example to check: `for...of` loop here throws the "Not yet implemented" error
    for (let i = 0; i < path.length; i++) {
        const target = path[i];
        if (target.nodeName && target.nodeName.toLowerCase() === 'a') {
            maybeAnchor = target;
            break;
        }
    }

    // @ts-ignore
    while (maybeAnchor && maybeAnchor.nodeName.toLowerCase() !== 'a') {
        // @ts-ignore
        maybeAnchor = maybeAnchor.parentNode;
    }

    // ignore the click if not at an <a> element
    // @ts-ignore
    if (!maybeAnchor || maybeAnchor.nodeName.toLowerCase() !== 'a') {
        return;
    }

    const anchor = maybeAnchor as HTMLAnchorElement;

    // ignore the click if the <a> element has a non-default target
    if (anchor.target && anchor.target.toLowerCase() !== '_self') {
        return;
    }

    // ignore the click if the <a> element has the 'download' attribute
    if (anchor.hasAttribute('download')) {
        return;
    }

    // ignore the click if the <a> element has the 'router-ignore' attribute
    if (anchor.hasAttribute('router-ignore')) {
        return;
    }

    // ignore the click if the target URL is a fragment on the current page
    if (anchor.pathname === window.location.pathname && anchor.hash !== '') {
        // @ts-ignore
        window.location.hash = anchor.hash;
        return;
    }

    // ignore the click if the target is external to the app
    // In IE11 HTMLAnchorElement does not have the `origin` property
    // @ts-ignore
    const origin = anchor.origin || getAnchorOrigin(anchor);
    if (origin !== window.location.origin) {
        return;
    }

    return normalizeURL(new URL(anchor.href, anchor.baseURI));
}

/**
 * Fire 'vaadin-navigated' event to inform components of navigation.
 * @param pathname pathname of navigation
 * @param search search of navigation
 */
function fireNavigated(pathname:string, search: string) {
    setTimeout(() => {
            window.dispatchEvent(new CustomEvent('vaadin-navigated', {
                detail: {
                    pathname,
                    search
                }
            }));
            // @ts-ignore
            delete window.Vaadin.Flow.navigation;
        }
    )
}

function postpone() {
}

const prevent = () => postpone;

type RouterContainer = Awaited<ReturnType<typeof flow.serverSideRoutes[0]["action"]>>;


type NavigateOpts =  {
    to: string,
    callback: boolean,
    opts?: NavigateOptions
};

type NavigateFn = (to: string, callback: boolean, opts?: NavigateOptions) => void;

/**
 * A hook providing the `navigate(path: string, opts?: NavigateOptions)` function
 * with React Router API that has more consistent history updates. Uses internal
 * queue for processing navigate calls.
 */
function useQueuedNavigate(waitReference: React.MutableRefObject<Promise<void> | undefined>, navigated: React.MutableRefObject<boolean>): NavigateFn {
    const navigate = useNavigate();
    const navigateQueue = useRef<NavigateOpts[]>([]).current;
    const [navigateQueueLength, setNavigateQueueLength] = useState(0);

    const dequeueNavigation = useCallback(() => {
        const navigateArgs = navigateQueue.shift();
        if (navigateArgs === undefined) {
            // Empty queue, do nothing.
            return;
        }

        const blockingNavigate = async () => {
            if (waitReference.current) {
                await waitReference.current;
                waitReference.current = undefined;
            }
            navigated.current = !navigateArgs.callback;
            navigate(navigateArgs.to, navigateArgs.opts);
            setNavigateQueueLength(navigateQueue.length);
        }
        blockingNavigate();
    }, [navigate, setNavigateQueueLength]);

    const dequeueNavigationAfterCurrentTask = useCallback(() => {
        queueMicrotask(dequeueNavigation);
    }, [dequeueNavigation]);

    const enqueueNavigation = useCallback((to: string, callback: boolean, opts?: NavigateOptions) => {
        navigateQueue.push({to: to, callback: callback, opts: opts});
        setNavigateQueueLength(navigateQueue.length);
        if (navigateQueue.length === 1) {
            // The first navigation can be started right after any pending sync
            // jobs, which could add more navigations to the queue.
            dequeueNavigationAfterCurrentTask();
        }
    }, [setNavigateQueueLength, dequeueNavigationAfterCurrentTask]);

    useEffect(() => () => {
        // The Flow component has rendered, but history might not be
        // updated yet, as React Router does it asynchronously.
        // Use microtask callback for history consistency.
        dequeueNavigationAfterCurrentTask();
    }, [navigateQueueLength, dequeueNavigationAfterCurrentTask]);

    return enqueueNavigation;
}

function Flow() {
    const ref = useRef<HTMLOutputElement>(null);
    const navigate = useNavigate();
    const blocker = useBlocker(({ currentLocation, nextLocation }) => {
        navigated.current = navigated.current || (nextLocation.pathname === currentLocation.pathname && nextLocation.search === currentLocation.search && nextLocation.hash === currentLocation.hash);
        return true;
    });
    const location = useLocation();
    const navigated = useRef<boolean>(false);
    const blockerHandled = useRef<boolean>(false);
    const fromAnchor = useRef<boolean>(false);
    const containerRef = useRef<RouterContainer | undefined>(undefined);
    const roundTrip = useRef<Promise<void> | undefined>(undefined);
    const queuedNavigate = useQueuedNavigate(roundTrip, navigated);
    const basename = useHref('/');

    const navigateEventHandler = useCallback((event: MouseEvent) => {
        const path = extractPath(event);
        if (!path) {
            return;
        }

        if (event && event.preventDefault) {
            event.preventDefault();
        }

        navigated.current = false;
        // When navigation is triggered by click on a link, fromAnchor is set to true
        // in order to get a server round-trip even when navigating to the same URL again
        fromAnchor.current = true;
        navigate(path);
        // Dispatch close event for overlay drawer on click navigation.
        window.dispatchEvent(new CustomEvent('close-overlay-drawer'));
    }, [navigate]);

    const vaadinRouterGoEventHandler = useCallback((event: CustomEvent<URL>) => {
        const url = event.detail;
        const path = normalizeURL(url);
        if (!path) {
            return;
        }

        event.preventDefault();
        navigate(path);
    }, [navigate]);

    const vaadinNavigateEventHandler = useCallback((event: CustomEvent<{state: unknown, url: string, replace?: boolean, callback: boolean}>) => {
        // @ts-ignore
        window.Vaadin.Flow.navigation = true;
        const path = '/' + event.detail.url;
        fromAnchor.current = false;
        queuedNavigate(path, event.detail.callback, { state: event.detail.state, replace: event.detail.replace });
    }, [navigate]);

    const redirect = useCallback((path: string) => {
        return (() => {
            navigate(path, {replace: true});
        });
    }, [navigate]);

    useEffect(() => {
        // @ts-ignore
        window.addEventListener('vaadin-router-go', vaadinRouterGoEventHandler);
        // @ts-ignore
        window.addEventListener('vaadin-navigate', vaadinNavigateEventHandler);

        return () => {
            // @ts-ignore
            window.removeEventListener('vaadin-router-go', vaadinRouterGoEventHandler);
            // @ts-ignore
            window.removeEventListener('vaadin-navigate', vaadinNavigateEventHandler);
        };
    }, [vaadinRouterGoEventHandler, vaadinNavigateEventHandler]);

    useEffect(() => {
        return () => {
            containerRef.current?.parentNode?.removeChild(containerRef.current);
            containerRef.current = undefined;
        };
    }, []);

    useEffect(() => {
        if (blocker.state === 'blocked') {
            if(blockerHandled.current) {
                // Blocker is handled and the new navigation
                // gets queued to be executed after the current handling ends.
                const {pathname, state} = blocker.location;
                queuedNavigate(pathname.substring(basename.length), true, { state: state, replace: true });
                return;
            }
            blockerHandled.current = true;
            let blockingPromise: any;
            roundTrip.current = new Promise<void>((resolve,reject) => blockingPromise = {resolve:resolve,reject:reject});
            // Release blocker handling after promise is fulfilled
            roundTrip.current.then(() => blockerHandled.current = false, () => blockerHandled.current = false);

            // Proceed to the blocked location, unless the navigation originates from a click on a link.
            // In that case continue with function execution and perform a server round-trip
            if (navigated.current && !fromAnchor.current) {
                blocker.proceed();
                blockingPromise.resolve();
                return;
            }
            fromAnchor.current = false;
            const {pathname, search} = blocker.location;
            const routes = ((window as any)?.Vaadin?.routesConfig || []) as AgnosticRouteObject[];
            let matched = matchRoutes(Array.from(routes), pathname);

            // Navigation between server routes
            // @ts-ignore
            if (matched && matched.filter(path => path.route?.element?.type?.name === Flow.name).length != 0) {
                containerRef.current?.onBeforeEnter?.call(containerRef?.current,
                    {pathname, search}, {
                        prevent() {
                            blocker.reset();
                            blockingPromise.resolve();
                            navigated.current = false;
                        },
                        redirect,
                        continue() {
                            blocker.proceed();
                            blockingPromise.resolve();
                        }
                    }, router);
                navigated.current = true;
            } else {
                // For covering the 'server -> client' use case
                Promise.resolve(containerRef.current?.onBeforeLeave?.call(containerRef?.current, {
                    pathname,
                    search
                }, {prevent}, router))
                    .then((cmd: unknown) => {
                        if (cmd === postpone && containerRef.current) {
                            // postponed navigation: expose existing blocker to Flow
                            containerRef.current.serverConnected = (cancel) => {
                                if (cancel) {
                                    blocker.reset();
                                    blockingPromise.resolve();
                                } else {
                                    blocker.proceed();
                                    window.removeEventListener('click',  navigateEventHandler);
                                    blockingPromise.resolve();
                                }
                            }
                        } else {
                            // permitted navigation: proceed with the blocker
                            blocker.proceed();
                            window.removeEventListener('click',  navigateEventHandler);
                            blockingPromise.resolve();
                        }
                    });
            }
        }
    }, [blocker.state, blocker.location]);

    useEffect(() => {
        if (blocker.state === 'blocked') {
            return;
        }
        if (navigated.current) {
            navigated.current = false;
            fireNavigated(location.pathname,location.search);
            return;
        }
        flow.serverSideRoutes[0].action({pathname: location.pathname, search: location.search})
            .then((container) => {
                const outlet = ref.current?.parentNode;
                if (outlet && outlet !== container.parentNode) {
                    outlet.append(container);
                    window.addEventListener('click',  navigateEventHandler);
                    containerRef.current = container
                }
                return container.onBeforeEnter?.call(container, {pathname: location.pathname, search: location.search}, {prevent, redirect, continue() {
                        fireNavigated(location.pathname,location.search);}}, router);
            })
            .then((result: unknown) => {
                if (typeof result === "function") {
                    result();
                }
            });
    }, [location]);

    return <output ref={ref} />;
}
Flow.type = 'FlowContainer'; // This is for copilot to recognize this

export const serverSideRoutes = [
    { path: '/*', element: <Flow/> },
];

/**
 * Load the script for an exported WebComponent with the given tag
 *
 * @param tag name of the exported web-component to load
 *
 * @returns Promise(resolve, reject) that is fulfilled on script load
 */
export const loadComponentScript = (tag: String): Promise<void> => {
    return new Promise((resolve, reject) => {
        useEffect(() => {
            const script = document.createElement('script');
            script.src = `/web-component/${tag}.js`;
            script.onload = function() {
                resolve();
            };
            script.onerror = function(err) {
                reject(err);
            };
            document.head.appendChild(script);

            return () => {
                document.head.removeChild(script);
            }
        }, []);
    });
};

interface Properties {
    [key: string]: string;
}

/**
 * Load WebComponent script and create a React element for the WebComponent.
 *
 * @param tag custom web-component tag name.
 * @param props optional Properties object to create element attributes with
 * @param onload optional callback to be called for script onload
 * @param onerror optional callback for error loading the script
 */
export const reactElement = (tag: string, props?: Properties, onload?: () => void, onerror?: (err:any) => void) => {
    loadComponentScript(tag).then(() => onload?.(), (err) => {
        if(onerror) {
            onerror(err);
        } else {
            console.error(`Failed to load script for ${tag}.`, err);
        }
    });

    if(props) {
        return React.createElement(tag, props);
    }
    return React.createElement(tag);
};

export default Flow;

// @ts-ignore
if (import.meta.hot) {
  // @ts-ignore
  import.meta.hot.accept((newModule) => {
    // A hot module replace for Flow.tsx happens when any JS/TS imported through @JsModule
    // or similar is updated because this updates generated-flow-imports.js and that in turn
    // is imported by this file. We have no means of hot replacing those files, e.g. some
    // custom lit element so we need to reload the page. */
    if (newModule) {
      window.location.reload();
    }
  });
}
