import * as React from 'react';
import { renderToString } from 'react-dom/server';
import Helmet from 'react-helmet';
import { Provider, updateIntl } from 'react-intl-redux';
import { StaticRouter } from 'react-router-dom';
import { Store } from 'redux';
import serialize from 'serialize-javascript';

import { logger } from 'tachyon-logger';
import { chatInitializeEmbed } from 'mweb/chat/chatAction';
import { errorReactToUnhandledError } from 'mweb/common/actions/error';
import { channelDirectoryPageTransition } from 'mweb/common/actions/pages/channelDirectory';
import {
  channelProfilePageRedirectAsOffline,
  channelProfilePageTransition,
} from 'mweb/common/actions/pages/channelProfile';
import { channelViewerPageTransition } from 'mweb/common/actions/pages/channelViewer';
import { chatEmbedPageTransition } from 'mweb/common/actions/pages/chatEmbed';
import { eventDetailsPageTransition } from 'mweb/common/actions/pages/eventDetails';
import { gameDirectoryPageTransition } from 'mweb/common/actions/pages/gameDirectory';
import { upsellPageTransition } from 'mweb/common/actions/pages/upsell';
import { vodViewerPageTransition } from 'mweb/common/actions/pages/vodViewer';
import { platfromSwitchToServer } from 'mweb/common/actions/platform';
import BASE_POLYFILL_FEATURES from 'mweb/common/config/polyfill';
import { getProfileContentTargetVideoCounts } from 'mweb/common/selectors/pages/channelProfile';
import { ChatEmbed } from 'mweb/common/containers/chatEmbed';
import { Location } from 'mweb/common/reducers/app';
import {
  ALL_CHANNELS,
  ChannelOnlineStatus,
} from 'mweb/common/reducers/data/channels';
import {
  getCurrentChannelForChannelViewer,
  getVODDetails,
  RootState,
} from 'mweb/common/reducers/root';
import Router from 'mweb/common/router';
import configureStore from 'mweb/common/stores/configureStore';
import {
  AppPath,
  buildChannelDirectoryPathFromDecodedGame,
  buildChannelPath,
  buildChannelProfilePath,
  buildLandingPagePath,
  buildVODPath,
  buildEventPath,
} from 'mweb/common/utils/pathBuilders';
import { logPromiseReject } from 'mweb/common/utils/promiseHelpers';
import { timePromiseResolution } from 'mweb/common/utils/timers';
import { PageLocalizationManager } from 'mweb/server/pageLocalizationManager';
import {
  experimentsConfigureExperiment,
  ExperimentOverrideMapping,
} from 'mweb/common/actions/experiments';
import { fetchExperimentMetadata } from 'mweb/common/fetch/experiments';

require('isomorphic-fetch');
require('object.entries').shim();
require('object.values').shim();

const RENDER_PAGE_TIMER_NAME = 'RENDER_PAGE';
const UNEXPECTED_LOCATION = 'unexpected location';
export const OFFLINE_CHANNEL_REDIRECT_VALUE = 'offline_channel';

export interface Render {
  html: string;
  state: RootState;
}

export interface RenderChannel {
  location: Location.Channel;
  channel: string;
}

export interface RenderProfile {
  location: Location.ChannelProfile;
  channel: string;
  redirectedFrom: string;
}

export interface RenderChatEmbed {
  location: Location.ChatEmbed;
  channel: string;
  theme: string;
  fontSize: string;
}

export interface RenderGameDirectory {
  location: Location.DirectoryMainGame;
}

export interface RenderChannelDirectory {
  location: Location.DirectoryGame;
  gameAlias?: string;
}
export interface RenderEventPage {
  location: Location.EventDetails;
  eventID: string;
}

export interface RenderUpsell {
  location: Location.Upsell;
}

export interface RenderVod {
  location: Location.VOD;
  vodID: string;
  channel: string;
}

export interface RenderUnknown {
  location: Location.Unknown;
}

export interface RendererOpts {
  readonly appTemplate: string;
  readonly chatEmbedTemplate: string;
  readonly acceptLanguageHeader: string | undefined;
  readonly recordedPath: string;
  readonly bucketId: string;
  readonly experimentOverrides?: ExperimentOverrideMapping;
}

export type RenderLocation =
  | RenderChannel
  | RenderProfile
  | RenderChatEmbed
  | RenderGameDirectory
  | RenderChannelDirectory
  | RenderEventPage
  | RenderUpsell
  | RenderVod
  | RenderUnknown;

export class Renderer {
  private readonly acceptLanguageHeader: string | undefined;
  private readonly localizationManager: PageLocalizationManager;
  private readonly appTemplate: string;
  private readonly chatEmbedTemplate: string;
  private readonly recordedPath: string;
  private readonly bucketId: string;
  private readonly experimentOverrides?: ExperimentOverrideMapping;

  constructor(opts: RendererOpts) {
    Object.assign(this, opts);
    this.localizationManager = new PageLocalizationManager(
      this.acceptLanguageHeader,
    );
  }

  render(renderLocation: RenderLocation): Promise<Render> {
    switch (renderLocation.location) {
      case Location.Channel:
        return this.renderChannelPage(renderLocation.channel);
      case Location.ChannelProfile:
        return this.renderChannelProfile(
          renderLocation.channel,
          renderLocation.redirectedFrom === OFFLINE_CHANNEL_REDIRECT_VALUE,
        );
      case Location.ChatEmbed:
        return this.renderChatEmbedPage(
          renderLocation.channel,
          renderLocation.theme,
          renderLocation.fontSize,
        );
      case Location.DirectoryGame:
        return this.renderChannelDirectory(
          renderLocation.gameAlias || ALL_CHANNELS,
        );
      case Location.DirectoryMainGame:
        return this.renderGameDirectory();
      case Location.EventDetails:
        return this.renderEventPage(renderLocation.eventID);
      case Location.Upsell:
        return this.renderUpsellPage();
      case Location.VOD:
        return this.renderVODPage(renderLocation.vodID, renderLocation.channel);
      case Location.Unknown:
        logger.error(`GOT UNKNOWN LOCATION, WAT I DO?`);
        throw UNEXPECTED_LOCATION;
      default:
        return this.handleUndefinedLocation(renderLocation);
    }
  }

  async renderGameDirectory(): Promise<Render> {
    const store = await this.configureStoreWithExperimentation();
    return this.timeAndLog(async () => {
      await store.dispatch(gameDirectoryPageTransition());
      const path = buildLandingPagePath();
      return this.renderPage(
        this.appTemplate,
        store,
        this.routerWrappedComponent(path),
      );
    });
  }

  async renderChannelDirectory(gameAlias: string): Promise<Render> {
    const store = await this.configureStoreWithExperimentation();
    return this.timeAndLog(async () => {
      await store.dispatch(channelDirectoryPageTransition(gameAlias));
      const path = buildChannelDirectoryPathFromDecodedGame(gameAlias);
      return this.renderPage(
        this.appTemplate,
        store,
        this.routerWrappedComponent(path),
      );
    });
  }

  async renderChannelPage(channelName: string): Promise<Render> {
    const store = await this.configureStoreWithExperimentation();
    return this.timeAndLog(async () => {
      await store.dispatch(channelViewerPageTransition(channelName));
      const state = store.getState();
      const channel = getCurrentChannelForChannelViewer(state);
      if (channel && channel.onlineStatus === ChannelOnlineStatus.Offline) {
        throw new ChannelOfflineError();
      }
      const path = buildChannelPath(channelName);
      return this.renderPage(
        this.appTemplate,
        store,
        this.routerWrappedComponent(path),
      );
    });
  }

  async renderChannelProfile(
    channel: string,
    redirected: boolean,
  ): Promise<Render> {
    const store = await this.configureStoreWithExperimentation();
    const action = redirected
      ? channelProfilePageRedirectAsOffline
      : channelProfilePageTransition;
    return this.timeAndLog(async () => {
      await store.dispatch(
        action(channel, getProfileContentTargetVideoCounts()),
      );
      const path = buildChannelProfilePath(channel);
      return this.renderPage(
        this.appTemplate,
        store,
        this.routerWrappedComponent(path),
      );
    });
  }

  async renderVODPage(vodID: string, channel?: string): Promise<Render> {
    const store = await this.configureStoreWithExperimentation();
    return this.timeAndLog(async () => {
      await store.dispatch(vodViewerPageTransition(vodID));
      const path = buildVODPath(vodID);
      const state = store.getState();
      const vodDetails = getVODDetails(
        state,
        state.pages.vodViewer.currentVODid,
      );
      if (channel && vodDetails && channel !== vodDetails.channel) {
        throw { status: 404 };
      }
      return this.renderPage(
        this.appTemplate,
        store,
        this.routerWrappedComponent(path),
      );
    });
  }

  async renderUpsellPage(): Promise<Render> {
    const store = await this.configureStoreWithExperimentation();
    await store.dispatch(upsellPageTransition());
    return this.renderPage(
      this.appTemplate,
      store,
      this.routerWrappedComponent(this.recordedPath),
    );
  }

  async renderEventPage(eventID: string): Promise<Render> {
    const store = await this.configureStoreWithExperimentation();
    return this.timeAndLog(async () => {
      await store.dispatch(eventDetailsPageTransition(eventID));
      const path = buildEventPath(eventID);
      return this.renderPage(
        this.appTemplate,
        store,
        this.routerWrappedComponent(path),
      );
    });
  }

  async renderChatEmbedPage(
    channel: string,
    theme: string,
    fontSize: string,
  ): Promise<Render> {
    const store = await this.configureStoreWithExperimentation();
    store.dispatch(chatInitializeEmbed(theme, fontSize));
    await store.dispatch(chatEmbedPageTransition(channel));
    return this.renderPage(this.chatEmbedTemplate, store, ChatEmbed);
  }

  async renderNotFound(): Promise<Render> {
    const store = await this.configureStoreWithExperimentation();
    return this.renderPage(
      this.appTemplate,
      store,
      this.routerWrappedComponent(this.recordedPath),
    );
  }

  renderError = (error: Error, store: Store<RootState>): Promise<Render> => {
    logger.error('Error caught in application rendering:');
    logger.error({
      path: this.recordedPath,
      locale: this.localizationManager.languageTag,
      error,
    });
    store.dispatch(errorReactToUnhandledError());
    return this.renderPage(
      this.appTemplate,
      store,
      this.routerWrappedComponent(this.recordedPath),
    );
  };

  private timeAndLog(renderer: () => Promise<Render>): Promise<Render> {
    return logPromiseReject(
      timePromiseResolution(RENDER_PAGE_TIMER_NAME, renderer),
    );
  }

  private routerWrappedComponent(location: AppPath): () => JSX.Element {
    return () => (
      <StaticRouter location={location} context={{}}>
        <Router />
      </StaticRouter>
    );
  }

  private async renderPage(
    template: string,
    store: Store<RootState>,
    View: React.ReactType,
  ): Promise<Render> {
    const reactIntlLocaleCode = this.localizationManager.reactIntlCode();
    const allPolyfills = [
      ...BASE_POLYFILL_FEATURES,
      this.localizationManager.localePolyfillString(),
    ];
    store.dispatch(
      updateIntl({
        locale: this.localizationManager.languageTag,
        messages: this.localizationManager.messages(),
      }),
    );

    try {
      await store.dispatch(
        platfromSwitchToServer({ gitHash: process.env.GIT_HASH }),
      );
      const finalState = store.getState();
      logger.info({ message: 'Final state', state: finalState });

      const html = renderToString(
        <Provider store={store}>
          <View />
        </Provider>,
      );
      const head = Helmet.renderStatic();

      const finalHTML = template
        .replace(/{{title}}/, String(head.title))
        .replace(/{{meta}}/, String(head.meta))
        .replace(/{{link}}/, String(head.link))
        .replace(/{{finalState}}/, serialize(finalState, { isJSON: true }))
        .replace(
          /{{polyfillFeatures}}/g,
          encodeURIComponent(allPolyfills.join(',')),
        )
        .replace(/{{reactIntlLocale}}/, reactIntlLocaleCode)
        .replace(/{{app}}/, html);

      return { html: finalHTML, state: finalState };
    } catch (error) {
      return this.renderError(error, store);
    }
  }

  private handleUndefinedLocation(_: never): never;
  private handleUndefinedLocation(
    renderLocation: RenderLocation,
  ): Promise<Render> {
    logger.error(`GOT UNEXPECTED LOCATION ${renderLocation.location}`);
    throw UNEXPECTED_LOCATION;
  }

  /**
   * Retrieve the metadata for the active mobile-web experiments and trigger
   * actions to group the bucket for each experiment.
   *
   * @param initialState
   */
  private async configureStoreWithExperimentation(
    initialState?: RootState,
  ): Promise<Store<RootState>> {
    const store = configureStore(initialState);
    const experiments = await fetchExperimentMetadata();
    logger.debug({
      message: 'Experiment overrides',
      experimentOverrides: this.experimentOverrides,
    });
    experiments.forEach(experiment =>
      store.dispatch(
        experimentsConfigureExperiment(
          experiment,
          this.bucketId,
          this.experimentOverrides || {},
        ),
      ),
    );
    return store;
  }
}

export const CHANNEL_OFFLINE_ERROR_NAME = 'Channel offline';
export class ChannelOfflineError extends Error {
  public readonly name: string = CHANNEL_OFFLINE_ERROR_NAME;
}
