import { contentModeCookieName, isSiteInShellCookieName } from "@insite/client-framework/Common/ContentMode";
import { encodeCookie } from "@insite/client-framework/Common/Cookies";
import { SafeDictionary } from "@insite/client-framework/Common/Types";
import { getOdpTrackingScript } from "@insite/client-framework/Common/Utilities/odpTracking";
import prepareOrganizationStructuredPageData from "@insite/client-framework/Common/Utilities/prepareOrganizationStructuredPageData";
import prepareSitelinksSearchBoxStructuredPageData from "@insite/client-framework/Common/Utilities/prepareSitelinksSearchBoxStructuredPageData";
import { getHeadTrackingScript, getNoscriptTrackingScript } from "@insite/client-framework/Common/Utilities/tracking";
import { getIsWebCrawler } from "@insite/client-framework/Common/WebCrawler";
import { ShellContext } from "@insite/client-framework/Components/IsInShell";
import Maintenance from "@insite/client-framework/Components/Maintenance";
import PreviewLogin from "@insite/client-framework/Components/PreviewLogin";
import SessionLoader from "@insite/client-framework/Components/SessionLoader";
import SpireRouter, { convertToLocation } from "@insite/client-framework/Components/SpireRouter";
import { makeHandlerChainAwaitable } from "@insite/client-framework/HandlerCreator";
import logger from "@insite/client-framework/Logger";
import {
    getInitialPage,
    getOdpEvents,
    getPageMetadata,
    getRedirectTo,
    getStatusCode,
    getTrackedPromises,
    setEnableSsr,
    setInitialPage,
    setPromiseAddedCallback,
    setServerSiteMessages,
    setServerTranslations,
    setSessionCookies,
} from "@insite/client-framework/ServerSideRendering";
import { rawRequest } from "@insite/client-framework/Services/ApiService";
import { getContentByVersionPath, RetrievePageResult } from "@insite/client-framework/Services/ContentService";
import { getSession } from "@insite/client-framework/Services/SessionService";
import { getSettings } from "@insite/client-framework/Services/SettingsService";
import ApplicationState from "@insite/client-framework/Store/ApplicationState";
import { configureStore as publicConfigureStore } from "@insite/client-framework/Store/ConfigureStore";
import loadCurrentWebsite from "@insite/client-framework/Store/Context/Handlers/LoadCurrentWebsite";
import { theme as defaultTheme } from "@insite/client-framework/Theme";
import { postStyleGuideTheme, preStyleGuideTheme } from "@insite/client-framework/ThemeConfiguration";
import translate from "@insite/client-framework/Translate";
import { LayoutSectionRenderingContext } from "@insite/client-framework/Types/LayoutSectionInjector";
import BodyEnd from "@insite/content-library/LayoutSections/BodyEnd";
import BodyStart from "@insite/content-library/LayoutSections/BodyStart";
import HeadEnd from "@insite/content-library/LayoutSections/HeadEnd";
import HeadStart from "@insite/content-library/LayoutSections/HeadStart";
import ThemeProvider from "@insite/mobius/ThemeProvider";
import { shareEntityGenerateFromWebpage } from "@insite/server-framework/InternalService";
import { shareEntityRoute } from "@insite/server-framework/Server";
import {
    getCachedAsyncWidgetFieldDefinitions,
    getCachedSiteMessages,
    getCachedTheme,
    getCachedTranslationDictionary,
} from "@insite/server-framework/ServerSideCache";
import { generateSiteIfNeeded, restorePagesFromCreators } from "@insite/server-framework/SiteGeneration";
import { loadAndParseFontCss } from "@insite/server-framework/Utilities/fontProcessing";
import { Request, Response as ExpressResponse } from "express";
import parse from "html-react-parser";
import merge from "lodash/merge";
import qs from "qs";
import * as React from "react";
import { renderToStaticMarkup, renderToString } from "react-dom/server";
import { Provider } from "react-redux";
import setCookie from "set-cookie-parser";
import { ServerStyleSheet } from "styled-components";
import * as htmlparser2 from "htmlparser2";
import { textContent } from "domutils";

let checkedForSiteGeneration = false;

const stripHtml = (html: string): string => {
    if (!html) {
        return "";
    }

    return textContent(htmlparser2.parseDocument(html));
};

const stripHtmlAndLinks = (html: string): string => {
    const text = stripHtml(html);
    if (!text) {
        return "";
    }

    // Remove URLs (http, https, ftp, etc.)
    const urlPattern = /(?:https?|ftp):\/\/[^\s]+/gi;
    // Remove www. domains
    const wwwPattern = /www\.[^\s]+/gi;
    // Remove email addresses
    const emailPattern = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/gi;
    // Remove bare domains (e.g., example.com, sub.example.co.uk)
    const domainPattern =
        /\b[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*\.(?:com|org|net|edu|gov|mil|biz|info|io|co|uk|ca|de|fr|au|us|ru|ch|it|nl|se|no|es|jp|cn|in|br|mx|kr|tv|me|ly|to|cc|ws|fm|am|xyz|online|site|tech|store|shop|app|dev|cloud|ai|gg)\b[^\s]*/gi;

    return text
        .replace(urlPattern, "")
        .replace(wwwPattern, "")
        .replace(emailPattern, "")
        .replace(domainPattern, "")
        .replace(/\s+/g, " ")
        .trim();
};

const safeRequest = <T,>(func: () => Promise<T>, defaultValue: T) => {
    return (async () => {
        try {
            return await func();
        } catch (e) {
            logger.error(e);
        }
        return defaultValue;
    })();
};

export async function pageRenderer(request: Request, response: ExpressResponse) {
    const { websiteIsClassic } = await generateDataIfNeeded(request);
    if (websiteIsClassic) {
        response.send(
            "The current website is configured to be a Classic website and cannot return Spire CMS pages. This data is cached.",
        );
        return;
    }

    const isShareEntityRequest = request.originalUrl.startsWith(shareEntityRoute);

    const isSiteInShell =
        (request.headers.referer && request.headers.referer.toLowerCase().indexOf("/contentadmin") > 0) ||
        (request.cookies && request.cookies[isSiteInShellCookieName] === "true" && request.query?.editor !== "off");

    let isEditing = false;
    if (isSiteInShell) {
        if (!request.cookies[contentModeCookieName]) {
            response.cookie(contentModeCookieName, "Viewing");
        } else {
            isEditing = request.cookies[contentModeCookieName] === "Editing";
        }

        response.cookie(isSiteInShellCookieName, "true");
    } else {
        response.clearCookie(isSiteInShellCookieName);
    }

    let sheet: ServerStyleSheet | undefined;

    let responseCookies = await loadPageAndSetInitialCookies(request, response);
    let initialPage = getInitialPage();
    let isWebCrawler = getIsWebCrawler();

    let statusCode = initialPage?.result?.statusCode;
    if ((statusCode === 404 || statusCode === 500) && isWebCrawler) {
        response.status(statusCode);
        response.send();
        return;
    }

    // in the case of a first page load with no request cookies, we get the default language code from the first response
    let languageCode =
        request.cookies.SetContextLanguageCode ??
        (responseCookies && responseCookies.find(o => o.name === "SetContextLanguageCode")?.value);

    const isGetContentRequest = request.path.startsWith(getContentByVersionPath);
    if (isGetContentRequest) {
        languageCode = qs.parse(request.query as unknown as string).languageId;
    }

    const getSiteMessagesPromise = getCachedSiteMessages(languageCode);
    const getTranslationDictionariesPromise = getCachedTranslationDictionary(languageCode);

    // Do some work while the API calls above are running.
    let rawHtml = "";
    const store = publicConfigureStore();

    const settings = await safeRequest(() => getSettings({}), undefined);
    if (!settings) {
        // we return an empty object if something went wrong
        logger.error(`Could not load website settings. Aborting page render.`);
        response.status(500);
        response.send("Internal Error");
        return;
    }
    const enableSSR = !!Object.keys(request.query).find(o => o.toLowerCase() === "enablessr");
    setEnableSsr(enableSSR);
    const hasAccessToken = !!Object.keys(request.query).find(o => o.toLowerCase() === "access_token");

    const renderStorefrontServerSide =
        !isGetContentRequest &&
        ((!hasAccessToken &&
            (isSiteInShell ||
                isWebCrawler ||
                enableSSR ||
                (initialPage && initialPage?.result.statusCode >= 300) ||
                isShareEntityRequest)) ||
            settings.settingsCollection.websiteSettings.enableServerSideRendering);

    const getThemePromiseOrDefault = safeRequest(
        async () =>
            isShareEntityRequest
                ? defaultTheme
                : merge(
                      {},
                      defaultTheme,
                      preStyleGuideTheme,
                      await safeRequest(() => getCachedTheme(), undefined),
                      postStyleGuideTheme,
                  ),
        defaultTheme,
    );

    const getAsyncWidgetFieldDefinitionsPromise = getCachedAsyncWidgetFieldDefinitions();

    // Out of other work, wait for API results.
    const [theme, siteMessages, translationDictionaries, asyncWidgetFieldDefinitions] = await Promise.all([
        getThemePromiseOrDefault,
        getSiteMessagesPromise,
        getTranslationDictionariesPromise,
        getAsyncWidgetFieldDefinitionsPromise,
    ]);

    let fontFamilyImportUrl = theme.typography.fontFamilyImportUrl;
    if (fontFamilyImportUrl.startsWith("http")) {
        fontFamilyImportUrl = await safeRequest(
            () => loadAndParseFontCss(theme.typography.fontFamilyImportUrl, request.headers["user-agent"]),
            theme.typography.fontFamilyImportUrl,
        );
    }
    if (siteMessages) {
        setServerSiteMessages(siteMessages);
    }

    if (translationDictionaries) {
        setServerTranslations(translationDictionaries);
    }

    if (renderStorefrontServerSide) {
        const renderRawAndStyles = () => {
            sheet = new ServerStyleSheet();

            const value = { isEditing, isCurrentPage: true, isInShell: isSiteInShell };

            // Changes here must be mirrored to the storefront ClientApp.tsx so the render output matches.
            const rawStorefront = (
                <Provider store={store}>
                    <ShellContext.Provider value={value}>
                        <ThemeProvider
                            theme={theme}
                            createGlobalStyle={true}
                            createChildGlobals={false}
                            translate={translate}
                        >
                            <SessionLoader location={convertToLocation(request.url)}>
                                <Maintenance>
                                    <PreviewLogin>
                                        <SpireRouter />
                                    </PreviewLogin>
                                </Maintenance>
                            </SessionLoader>
                        </ThemeProvider>
                    </ShellContext.Provider>
                </Provider>
            );

            rawHtml = renderToString(sheet.collectStyles(rawStorefront));
        };

        const redirect = await renderUntilPromisesResolved(request, renderRawAndStyles, store.dispatch);

        const statusCode = getStatusCode();
        if (statusCode) {
            response.status(statusCode);
        }

        if (redirect) {
            const redirectStatusCode = statusCode && statusCode >= 300 && statusCode < 400 ? statusCode : 302;
            response.redirect(redirectStatusCode, redirect);
            return;
        }
    }

    const awaitableLoadWebsite = makeHandlerChainAwaitable(loadCurrentWebsite);
    await awaitableLoadWebsite({})(store.dispatch as any, store.getState as any);

    const metadata = getPageMetadata();
    const state = store.getState() as ApplicationState;

    const session = await getSession({});
    const { googleTrackingTypeComputed, googleTrackingAccountId } = settings.settingsCollection.websiteSettings;
    const noscriptTrackingScript = getNoscriptTrackingScript(settings);
    const headTrackingScript = getHeadTrackingScript(settings, session);
    const preconnectToGoogleTagManager =
        googleTrackingTypeComputed === "GoogleAnalytics4" || googleTrackingTypeComputed === "GoogleTagManager";

    const faviconUrl = state.context?.website?.websiteFavicon || "#";

    const layoutSectionRenderingContext: LayoutSectionRenderingContext = {
        isInShell: isSiteInShell,
        settings,
    };

    const robotsValues: string[] = [];
    metadata?.hideFromSearchEngines && robotsValues.push("noindex");
    robotsValues.push(metadata?.doNotFollowLinks ? "nofollow" : "follow");

    const app = (rawHtml: string) => (
        <html lang={languageCode}>
            <head>
                {HeadStart(layoutSectionRenderingContext)}
                {!isShareEntityRequest && preconnectToGoogleTagManager && (
                    <link rel="preconnect" href="https://www.googletagmanager.com"></link>
                )}
                {!isShareEntityRequest &&
                    googleTrackingTypeComputed === "GoogleAnalytics4" &&
                    googleTrackingAccountId && (
                        <script
                            async
                            src={`https://www.googletagmanager.com/gtag/js?id=${googleTrackingAccountId}`}
                        ></script>
                    )}
                {/* eslint-disable react/no-danger */}
                {!isShareEntityRequest && headTrackingScript && (
                    <script id="headTrackingScript" dangerouslySetInnerHTML={{ __html: headTrackingScript }}></script>
                )}
                <meta charSet="utf-8" />
                <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"></meta>
                <title>{metadata?.title}</title>
                <link rel="icon" href={faviconUrl} type="image/x-icon" />
                <link rel="apple-touch-icon image_src" href={faviconUrl} />
                <meta name="robots" content={robotsValues.join(", ")} />
                <meta property="og:type" content="website" />
                {metadata?.openGraphTitle && (
                    <meta id="ogTitle" property="og:title" content={metadata?.openGraphTitle} />
                )}
                {metadata?.openGraphImage && (
                    <meta id="ogImage" property="og:image" content={metadata?.openGraphImage} />
                )}
                {metadata?.openGraphUrl && <meta id="ogUrl" property="og:url" content={metadata?.openGraphUrl} />}
                {metadata?.metaKeywords && <meta name="keywords" content={metadata?.metaKeywords} />}
                {metadata?.metaDescription && <meta name="description" content={metadata?.metaDescription} />}
                {metadata?.canonicalUrl && <link rel="canonical" href={metadata?.canonicalUrl} />}
                {metadata?.alternateLanguageUrls &&
                    Object.entries(metadata.alternateLanguageUrls).map(([key, value]) => (
                        <link key={key} rel="alternate" hrefLang={key} href={value} />
                    ))}
                <base href="/" />
                {fontFamilyImportUrl ? (
                    <>
                        <link href={fontFamilyImportUrl} rel="preload" as="style" type="text/css" />
                        <link href={fontFamilyImportUrl} rel="stylesheet" type="text/css" />
                    </>
                ) : null}
                {sheet?.getStyleElement()}
                <script>{`if (window.parent !== window) {
    try {
        window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = window.parent.__REACT_DEVTOOLS_GLOBAL_HOOK__;
    }
    catch (ex) { }
}`}</script>
                {HeadEnd(layoutSectionRenderingContext)}
                {isGetContentRequest && (
                    <style>
                        #react-app {"{"} pointer-events: none; {"}"}
                    </style>
                )}
                {state.context?.settings?.settingsCollection?.websiteSettings?.enableSitelinksSearchBoxStructuredData &&
                    initialPage?.result.page?.type === "HomePage" && (
                        <script
                            type="application/ld+json"
                            id="sitelinks-search-box-structured-data"
                            dangerouslySetInnerHTML={{
                                __html: prepareSitelinksSearchBoxStructuredPageData(),
                            }}
                        />
                    )}
                {state.context?.settings?.settingsCollection?.websiteSettings?.enableOrganizationStructuredData &&
                    state.context?.website &&
                    initialPage?.result.page?.type === "HomePage" && (
                        <script
                            type="application/ld+json"
                            id="organization-structured-data"
                            dangerouslySetInnerHTML={{
                                __html: prepareOrganizationStructuredPageData(state.context?.website),
                            }}
                        />
                    )}
                {metadata?.structuredPageData && (
                    <script
                        type="application/ld+json"
                        id={metadata?.jsonLdScriptId || (null as any)}
                        dangerouslySetInnerHTML={{
                            __html: metadata?.structuredPageData,
                        }}
                    />
                )}
                {state.context?.settings?.settingsCollection?.websiteSettings?.enableOdpStorefrontEvents && (
                    <script
                        id="odpTrackingScript"
                        dangerouslySetInnerHTML={{
                            __html: getOdpTrackingScript(
                                state.context.settings.settingsCollection.websiteSettings.odpJsTagUrl,
                            ),
                        }}
                    ></script>
                )}
                {theme.codeInjection?.head && parse(theme.codeInjection.head)}
            </head>
            <body>
                {BodyStart(layoutSectionRenderingContext)}
                {noscriptTrackingScript && (
                    <noscript
                        id="noscriptTrackingScript"
                        dangerouslySetInnerHTML={{ __html: noscriptTrackingScript }}
                    ></noscript>
                )}
                <div id="react-app" dangerouslySetInnerHTML={{ __html: rawHtml }}></div>
                <script
                    dangerouslySetInnerHTML={{
                        __html: `try{eval("(async()=>{})()");}catch(e){alert((typeof siteMessages !== 'undefined' && siteMessages.Browser_UnsupportedWarning) || 'This site does not support your current browser. Please update your browser to the most recent version of Chrome, Edge, Safari or Firefox.');}`,
                    }}
                ></script>
                <script
                    dangerouslySetInnerHTML={{
                        __html: `
                var translationDictionaries = ${JSON.stringify(translationDictionaries)};
                var siteMessages = ${JSON.stringify(siteMessages)};
                var odpEvents = ${JSON.stringify(getOdpEvents())};
                var asyncWidgetFieldDefinitions = ${JSON.stringify(asyncWidgetFieldDefinitions)
                    .replace(new RegExp("</", "g"), "<\\/")
                    .replace(new RegExp("<", "g"), "\\u003C")
                    .replace(new RegExp(">", "g"), "\\u003E")};`,
                    }}
                ></script>
                {renderStorefrontServerSide && (
                    <script
                        dangerouslySetInnerHTML={{
                            __html: `var initialReduxState = ${JSON.stringify(state)
                                .replace(new RegExp("</", "g"), "<\\/")
                                .replace(new RegExp("<", "g"), "\\u003C")
                                .replace(new RegExp(">", "g"), "\\u003E")}`,
                        }}
                    ></script>
                )}
                {/* eslint-enable react/no-danger */}
                <script defer src={`/dist/public.js?v=${BUILD_DATE}`} />
                {BodyEnd(layoutSectionRenderingContext)}
            </body>
        </html>
    );

    const renderedApp = `<!DOCTYPE html>${renderToStaticMarkup(app(rawHtml))}`;

    if (isShareEntityRequest) {
        const { shareEntityModel } = request.body;
        const res = await shareEntityGenerateFromWebpage({
            emailTo: shareEntityModel.emailTo,
            emailFrom: shareEntityModel.emailFrom,
            subject: stripHtmlAndLinks(shareEntityModel.subject),
            message: stripHtmlAndLinks(shareEntityModel.message),
            entityId: shareEntityModel.entityId,
            entityName: shareEntityModel.entityName,
            html: renderedApp,
            username: state.context.session.userName,
        });
        response.status(res.status);
        response.send();
    } else {
        response.send(renderedApp);
    }
}

async function renderUntilPromisesResolved(
    request: Request,
    renderRawAndStyles: () => void,
    dispatch: React.Dispatch<any>,
) {
    let redirectToUrl: string | undefined;

    const renderSite = () => {
        renderRawAndStyles();
        redirectToUrl = getRedirectTo();
    };

    const trackedPromises = getTrackedPromises() ?? [];
    let renderLoop = 0;

    renderSite();

    const maxLoops = 10;
    while (trackedPromises.length !== 0 && renderLoop < maxLoops && !redirectToUrl) {
        renderLoop += 1;
        if (renderLoop === 6) {
            setPromiseAddedCallback(stack => {
                const message = `During SSR of ${request.url} there was a new promise added on loop ${renderLoop}.
This usually indicates a problem with react state management that will affect performance.
The new promise was added at the following location:
${stack}`;
                if (
                    !process.env.BYPASS_FAIL_ON_PROMISE_LOOPS &&
                    renderLoop === maxLoops &&
                    (!IS_PRODUCTION || process.env.FAIL_ON_PROMISE_LOOPS)
                ) {
                    throw message;
                }

                if (!IS_PRODUCTION) {
                    dispatch({ type: "Context/SetServerSideRenderingCount", serverSideRenderingCount: renderLoop });
                }

                return logger.warn(message);
            });
        }

        const awaitedPromises: typeof trackedPromises = [];
        while (trackedPromises.length > 0) {
            awaitedPromises.push(trackedPromises.shift()!);
        }

        await Promise.all(awaitedPromises);

        renderSite();
    }
    return redirectToUrl;
}

export interface GenerateDataResponse {
    /** When true, the website is operating in "Classic" mode and can't be used with the Spire CMS. */
    websiteIsClassic?: true;
}

export async function generateDataIfNeeded(request: Request): Promise<GenerateDataResponse> {
    if (request.url.toLowerCase().indexOf("restoreInitialPages=true".toLowerCase()) >= 0) {
        try {
            const generateDataResponse = await restorePagesFromCreators();
            if (generateDataResponse?.websiteIsClassic) {
                return generateDataResponse;
            }
        } catch (error) {
            if (IS_PRODUCTION) {
                logger.error(`Site generation failed:`, error);
            } else {
                throw error;
            }
        }

        return {};
    }

    if (
        !checkedForSiteGeneration ||
        !IS_PRODUCTION ||
        request.url.toLowerCase().indexOf("generateIfNeeded=true".toLowerCase()) >= 0
    ) {
        try {
            const generateDataResponse = await generateSiteIfNeeded();
            if (generateDataResponse?.websiteIsClassic) {
                return generateDataResponse;
            }
        } catch (error) {
            if (IS_PRODUCTION) {
                logger.error(`Site generation failed:`, error);
            } else {
                throw error;
            }
        }
        checkedForSiteGeneration = true;
    }

    return {};
}

let pageByUrlResult: RetrievePageResult;

async function loadPageAndSetInitialCookies(request: Request, response: ExpressResponse) {
    let pageByUrlResponse: Response | undefined;
    try {
        const bypassFilters = request.url.startsWith("/Content/Page/") ? "&bypassfilters=true" : "";
        // if our url has a query string like SetContextLanguageCode, we need it to be in the query string for the request below
        // not just encoded into the url query
        let queryString = qs.stringify(request.query);

        const endpoint = `/api/v2/content/pageByUrl?url=${encodeURIComponent(
            request.url,
        )}${bypassFilters}&${queryString}`;
        pageByUrlResponse = await rawRequest(endpoint, "GET", {}, undefined);
    } catch (ex) {
        // if this fails just log and continue, the regular way to retrieve the page will deal with sending the user to the unhandled error page.
        logger.error(ex);
    }

    if (pageByUrlResponse) {
        pageByUrlResult = await pageByUrlResponse.json();
        setInitialPage(pageByUrlResult, request.url);

        const existingCookies = request.cookies as SafeDictionary<string>;
        Object.keys(existingCookies).forEach((cookieName: string | number) => {
            existingCookies[cookieName] = encodeCookie(existingCookies[cookieName]!);
        });

        let setHeaderIfExists = (key: string) => {
            const headerValue = pageByUrlResponse?.headers?.get(key);
            if (headerValue) {
                response.header(key, headerValue);
            }
        };

        setHeaderIfExists("X-Frame-Options");
        setHeaderIfExists("X-Content-Type-Options");
        setHeaderIfExists("Content-Security-Policy");
        setHeaderIfExists("Strict-Transport-Security");
        setHeaderIfExists("Referrer-Policy");

        // see https://github.com/node-fetch/node-fetch/issues/251 for why we have to use .raw() instead of .get("set-cookie")?.split(",")
        // set-cookie can include , in the expires
        const responseCookies = setCookie.parse((pageByUrlResponse.headers as any).raw()["set-cookie"] ?? "");
        for (const cookie of responseCookies) {
            const options = {
                path: cookie.path,
                expires: cookie.expires,
                secure: cookie.secure,
                encode: encodeCookie,
            };

            response.cookie(cookie.name, cookie.value, options);
            existingCookies[cookie.name] = cookie.value;
        }

        setSessionCookies(existingCookies);

        return responseCookies;
    }
}
