import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import Head from 'next/head';
import nextRouter from 'next/router';
import {
  getI18nMessages,
  I18nProvider,
} from '@theorchard/suite-i18n-localization';
import classnames from 'classnames';
import Debug from 'debug';

import type { WithI18n } from '@theorchard/suite-i18n-localization';
import type { Features, SessionState } from '~/src/store/session/types';
import type { AppReduxStore } from '~/src/store/types';
import type { ComponentProps, FC } from 'react';

import { getUserClientCookieData } from '~/lib/auth/browser';
import { onFetchJsonError } from '~/lib/fetchJson';
import { getClientCookie } from '~/lib/utils/cookie';
import { SwrProvider } from '~/src/hooks/useApi';
import useFetchSessionUser from '~/src/hooks/useFetchSessionUser';
import { AppRouterProvider } from '~/src/lib/router2';
import { reportErrorClient } from '~/src/lib/sentry/client';
import themes from '~/src/lib/theme';
import { THEME_TYPE_DARK } from '~/src/lib/theme/constants';
import ThemeContext from '~/src/lib/theme/ThemeContext';
import { TrackerProvider } from '~/src/lib/tracker/useTracker';
import { on } from '~/src/lib/utils/events';
import { DatadogRUM } from '../DatadogRUM';
import { resolveToErrorTextParams, toDefaultErrorText } from '../ErrorText';
import AppShellContext from './lib/useAppShell/AppShellContext';
// ensure i18n is always initialized even if the child page isn't using it
// otherwise the call to `getI18nMessages` will error
import '~/src/lib/i18n';

import type { SessionInitClientParams } from '~/src/store/session/actions';
import type { WithReduxStaticProps } from './lib/withRedux';
import type { AppPage, AppPageContext, NextApp } from './types';

import {
  BYPASS_COUNTRY_BLOCKING,
  CUSTOM_DOMAIN_QUERY_PARAM,
  GEOLOCATION_COOKIE_NAME,
  SONGWHIP_NO_CACHE_QUERY_PARAM,
} from '~/lib/constants';
import { fromQueryString } from '~/lib/utils/url';
import { sessionInitClient } from '~/src/store/session/actions';
import { HydrationGuard } from '../HydrationGuard';
import { NotificationType } from '../Notification';
import { CoreUiProvider, useAppLoading, useAppToast } from './lib/CoreUi';
import { withAppRedirects } from './lib/redirects';
import useIntercom from './lib/useIntercom';
import useServiceWorker from './lib/useServiceWorker';
import withReduxStore from './lib/withRedux';
import { appStyle, globalStyle } from './styles';

// This splits the SideNav off into it's own chunk but it's still loaded
// upfront on critical path. This means we split the main _app chunk into
// smaller parallel-able chunks. Ideally we'd be able to SSR it but delay the
// loading of the chunk until it's hydrated to defer script download/parse.
// Not sure if this is possible.
const SideNavDynamic = dynamic(() => import('../SideNav'));

const debug = Debug('songwhip/components/NextApp');

export const getAppHasHydrated = () => getAppHasHydrated.v;
getAppHasHydrated.v = false;

const MyAppContent: FC<
  Pick<ComponentProps<typeof MyApp>, 'Component' | 'pageProps' | 'err'>
> = ({ Component, pageProps, err }) => {
  const theme = themes[THEME_TYPE_DARK];

  const [drawerIsOpen, setDrawerIsOpen] = useState(false);
  const drawerNodeRef = useRef<HTMLDivElement>(null);
  const backdropNodeRef = useRef<HTMLDivElement>(null);
  const setAppLoading = useAppLoading();
  const showToast = useAppToast();

  // ensure we always kick-off user fetch asap
  const { isLoggedIn, user } = useFetchSessionUser();

  useServiceWorker();
  useIntercom();

  const openDrawer = useCallback(() => {
    const drawerEl = drawerNodeRef.current;
    const backdropEl = backdropNodeRef.current;

    if (!drawerEl || !backdropEl) return;

    drawerEl.style.visibility = 'visible';
    drawerEl.style.transform = 'translateX(0%)';

    backdropEl.style.visibility = 'visible';
    backdropEl.style.opacity = '1';

    setTimeout(() => {
      setDrawerIsOpen(true);
    }, 600);
  }, []);

  const closeDrawer = useCallback(
    () =>
      new Promise<void>((resolve) => {
        const drawerEl = drawerNodeRef.current;
        const backdropEl = backdropNodeRef.current;

        if (!drawerEl || !backdropEl) return;

        if (!drawerEl) {
          resolve();
          return;
        }

        drawerEl.style.transform = 'translateX(-100%)';
        backdropEl.style.opacity = '0';

        setTimeout(() => {
          drawerEl.style.visibility = 'hidden';
          backdropEl.style.visibility = 'hidden';

          setDrawerIsOpen(false);
          resolve();
        }, 200);
      }),
    []
  );

  const onRouteChangeStart = useCallback(() => {
    debug('on route change start');
    setAppLoading(true);
  }, []);

  const onRouteChangeComplete = useCallback((path) => {
    debug('on route change complete', path);
    setAppLoading(false);
    closeDrawer();
  }, []);

  const appShellContextValue = useMemo(
    () => ({
      openAppDrawer: openDrawer,
    }),
    []
  );

  useEffect(() => {
    debug('component did mount');
    nextRouter.events.on('routeChangeComplete', onRouteChangeComplete);
    nextRouter.events.on('routeChangeStart', onRouteChangeStart);

    // flag the app has hydrated (used in LazyHydrate logic)
    getAppHasHydrated.v = true;

    // add a data attribute to the body to signal hydration complete
    // using mostly for cypress e2e tests
    document.body.dataset['hydrated'] = '';

    // show a toast when api errors
    const offApiError = onFetchJsonError((error) => {
      if (error.showToast) {
        const errorTextParams = resolveToErrorTextParams(error);

        showToast({
          text: toDefaultErrorText(errorTextParams),
          type: NotificationType.ERROR,
          testId: 'apiErrorText',
        });
      }
    });

    // Report any uncaught errors that bubble to `window`. This includes
    // exceptions thrown from within event listeners (eg 'click'). Other
    // errors in render phase or getInitialProps() are handled by next.js
    // and renders pages/_error where error ux and sentry reporting is handled.
    on(window, 'error', (event: ErrorEvent) => {
      const { error } = event ?? {};

      if (error) {
        reportErrorClient({
          error,
          extras: {
            message: event.message,
            filename: event.filename,
            lineno: event.lineno,
            colno: event.colno,
          },
        });
      }
    });

    return () => {
      debug('component will unmount');

      nextRouter.events.off('routeChangeComplete', onRouteChangeComplete);
      nextRouter.events.off('routeChangeStart', onRouteChangeComplete);

      offApiError();
    };
  }, []);

  return (
    <>
      <DatadogRUM isLoggedIn={isLoggedIn} user={user} />
      <AppShellContext.Provider value={appShellContextValue}>
        <div
          className={`${appStyle.className} app`}
          style={{
            color: theme.textColor90,
            background: theme.background,
          }}
        >
          <div className={classnames(appStyle.className, 'content')}>
            {/* workaround for https://github.com/zeit/next.js/issues/8592 */}
            <Component {...pageProps} err={err} />
          </div>
          <div
            ref={backdropNodeRef}
            className={classnames(appStyle.className, 'backdrop', {
              drawerIsOpen,
            })}
            onClick={closeDrawer}
          />
          <div
            ref={drawerNodeRef}
            className={classnames(appStyle.className, 'drawer', {
              drawerIsOpen,
            })}
          >
            {/* The HydrationGuard prevents rendering the sidenav on the server */}
            <HydrationGuard>
              <SideNavDynamic
                onCloseClick={closeDrawer}
                onItemClick={closeDrawer}
                isVisible={drawerIsOpen}
              />
            </HydrationGuard>
          </div>
          {appStyle.styles}
        </div>
        <style jsx global>
          {globalStyle}
        </style>
      </AppShellContext.Provider>
    </>
  );
};

const MyApp: NextApp<{}, WithReduxStaticProps> = (props) => {
  const { Component, pageProps, err, language, i18nMessages } = props;

  return (
    <>
      <Head>
        <meta name="viewport" content="width=device-width" />
      </Head>
      <SwrProvider>
        <AppRouterProvider>
          {/* Although <TrackerProvider> is defined inside <Page> we also define
      it here to be sure that `useTracker()` can be used anywhere */}
          <TrackerProvider>
            <I18nProvider messages={i18nMessages} locale={language}>
              <ThemeContext.Provider value={THEME_TYPE_DARK}>
                <CoreUiProvider>
                  <MyAppContent
                    Component={Component}
                    pageProps={pageProps}
                    err={err}
                  />
                </CoreUiProvider>
              </ThemeContext.Provider>
            </I18nProvider>
          </TrackerProvider>
        </AppRouterProvider>
      </SwrProvider>
    </>
  );
};

/**
 * Runs once on the serverside and then once on each clientside navigation
 * to determine which top-level props are passed to the Page's render function.
 *
 * This function is heavily cached and won't run for all users on SSR,
 * DO NOT READ ANY USER STATE IN THIS FUNCTION! Otherwise bad stuff
 * will happen.
 */
MyApp.getInitialProps = async ({
  Component: Page,
  ctx,
}: {
  ctx: AppPageContext;
  Component: AppPage & WithI18n;
}) => {
  const { req, res, asPath, pathname, query } = ctx;

  const {
    session: { language },
  } = ctx.reduxStore.getState();

  debug('get initial props', {
    language,
  });

  // Set a default cache-control header allowing page.getInitialProps()
  // to override should they need to. This value is consumed by edgeWorker
  // to decide how long to keep pages in the html cache. When no `s-maxage`
  // is defined the edgeWorker will use the default value. We don't define
  // that value here as if/when the edge-worker isn't active, we don't want
  // Vercel to cache the html at it's edge as we don't have the granularity
  // of country+device-type cache-keys that we apply at the edgeWorker layer.
  // Only `cache-control` headers with `private` or `no-cache` will be bypass
  // the edgeWorker cache.
  // NOTE: setting 'cache-control' doesn't work in dev as nextjs overrides.
  if (typeof window === 'undefined' && res) {
    const cacheControl: string[] = [];

    // Return a `Cache-Control: no-store` header to bypass the
    // Vercel edge cache if the `no_cache=1` query param has been passed
    if (query[SONGWHIP_NO_CACHE_QUERY_PARAM] === '1') {
      cacheControl.push('no-store');
    }

    // Avoid storing edge cache if we skip country blocking with
    // the special `bypass_country_blocking` query param
    if (query[BYPASS_COUNTRY_BLOCKING] !== undefined) {
      cacheControl.push('no-cache');
    }

    res.setHeader(
      'cache-control',
      cacheControl.length ? cacheControl : ['public', 'max-age=0']
    );
  }

  const pageProps = Page.getInitialProps ? await Page.getInitialProps(ctx) : {};
  const [appMessages, pageMessages] = await getI18nMessages(language, Page);

  if (typeof window === 'undefined') {
    const { resolveUserCookieValues, serverRedirectIfNeeded } = await import(
      './lib/serverImports'
    );

    if (res) {
      serverRedirectIfNeeded({
        res,
        currentAsPath: asPath!,
        currentRoutePathname: pathname,
        state: ctx.reduxStore.getState(),
      });

      // stop here if the previous function handled the request
      if (res.headersSent) {
        return {};
      }

      // On each ssr entrypoint we read the HttpOnly/server-only songwhip.token cookie
      // and set a clientside cookie (songwhip.userId) that contains just the `userId`.
      // The clientside uses this cookie to infer login state whilst keeping the sensitive
      // jwt token safely away from third-party/malicious scripts.
      res.setHeader('set-cookie', resolveUserCookieValues(req!.headers));
    }
  }

  return {
    language,
    i18nMessages: appMessages,

    pageProps: {
      ...pageProps,
      i18nMessages: pageMessages,
    },
  };
};

/**
 * Only runs serverside
 *
 * WARN: because pages are heavily cached at the edge-worker
 * most requests don't hit a server, so user specific redux
 * state must be inflated client-side.
 *
 * 🚨 WARN: Ensure that any dynamic params/cookies/headers consumed here
 * are included in the edgeWorker's page cache-key. If not, cached
 * html/redux-state will get served to the wrong users and
 * stuff will break!
 */
if (typeof window === 'undefined') {
  MyApp.getInitialReduxStateServer = async ({ req, query }) => {
    debug('get initial redux state');

    const {
      resolveCountry,
      getFirstValue,
      resolveDeviceType,
      resolveLanguage,
      resolveUserFromHeaders,
      EDGE_FORWARDED_HOST_HEADER,
    } = await import('./lib/serverImports');

    const { headers } = req;
    const reqIsFromEdgeWorker = !!headers[EDGE_FORWARDED_HOST_HEADER];
    const country = resolveCountry(req, query);
    const language = resolveLanguage({ headers, query });
    const userData = resolveUserFromHeaders(headers);

    const session: Partial<SessionState> = {
      // defines a restricted 'scope' of pages that can be viewed
      // this is used for custom subdomains (eg. cdbaby.sng.to)
      scope: getFirstValue(query.scope),

      // defines if the request is coming from a custom domain for V2
      customDomain: getFirstValue(query[CUSTOM_DOMAIN_QUERY_PARAM]),

      // the `country` is part of the edge-worker page cache-key
      // so this will correspond to the cache page response
      country,

      // the `lang` is part of the edge-worker page cache-key
      // so this will correspond to the cache page response
      language,

      // the `deviceType` is part of the edge-worker page cache-key
      // so this will correspond to the cache page response
      deviceType: resolveDeviceType({
        userAgent: headers['user-agent'],
        query,
      }),

      // COMPLEX: these values are only defined when the app is not being
      // requested via edgeWorker (ie. in development or if the CF Worker is disabled).
      // This ensures that user data is never cached by mistake in redux state (inlined in html).
      userId: !reqIsFromEdgeWorker ? userData?.userId : undefined,
      userBrand: !reqIsFromEdgeWorker ? userData?.userBrand : undefined,

      utmSource: getFirstValue(query.utm_source),
      utmMedium: getFirstValue(query.utm_medium),
    };

    return {
      // WARN: this state is not merged with reducers' initialState by default
      session,
    };
  };
}

/**
 * Called once when the client store is first created/hydrated.
 * Gives session store a chance to add some data persisted
 * in clientside storage (eg. cookie or localStorage).
 */
MyApp.onReduxInitClient = ({ reduxStore }: { reduxStore: AppReduxStore }) => {
  const userCookieData = getUserClientCookieData();
  const query = fromQueryString(location.search);

  // resolve a userId to use throughout this session for admin/dev
  // purposes we allow an emulateUserId query param override
  const userId = Number(query.userId) || userCookieData?.userId;

  const geolocationCookie = getClientCookie(GEOLOCATION_COOKIE_NAME);

  const sessionState: SessionInitClientParams = {
    userId,

    userBrand: userCookieData?.userBrand,
    userAgent: navigator.userAgent,

    // feature flags are pulled in from Split.io and contained in the session user object
    // but they can also be enabled using the `enable` query param on entry page
    // (eg. `/?enable=songwhip_feature_next,foo,bar`) or via an `enable` cookie.
    // This is handy for sharing unreleased feature links and enabling
    // unreleased features to test in cypress.
    localFeatureFlags: (query.enable || getClientCookie('enable'))?.split(
      ','
    ) as Features[] | undefined,

    // When the app is served via the edgeWorker we set a cookie in the response
    // containing the user's lat/long as a JSON string.
    // Here we read this cookie to store the lat/long inside the Redux state.
    geolocation: geolocationCookie ? JSON.parse(geolocationCookie) : undefined,
  };

  reduxStore.dispatch(sessionInitClient(sessionState));
};

export default withReduxStore(withAppRedirects(MyApp));
