import {
  CookieNotFound,
  GraphqlQueryError,
  HttpResponseError,
  InvalidHmacError,
  InvalidOAuthError,
  Session,
  Shopify,
} from '@shopify/shopify-api';

import type {BasicParams} from '../../../types';
import {
  beginAuth,
  handleClientErrorFactory,
  redirectToAuthPage,
  redirectToShopifyOrAppRoot,
  redirectWithExitIframe,
  triggerAfterAuthHook,
  validateShopAndHostParams,
} from '../helpers';
import {AppConfig, AppConfigArg} from '../../../config-types';
import {getSessionTokenHeader} from '../../helpers';
import {HandleAdminClientError} from '../../../clients';
import type {
  ApiConfigWithFutureFlags,
  ApiFutureFlags,
} from '../../../future/flags';

import {AuthorizationStrategy, SessionContext, OnErrorOptions} from './types';

export class AuthCodeFlowStrategy<
  Config extends AppConfigArg,
> implements AuthorizationStrategy {
  protected api: Shopify<
    ApiConfigWithFutureFlags<Config['future']>,
    ApiFutureFlags<Config['future']>
  >;

  protected config: AppConfig;
  protected logger: Shopify['logger'];

  public constructor({api, config, logger}: BasicParams) {
    this.api = api;
    this.config = config;
    this.logger = logger;
  }

  public async respondToOAuthRequests(request: Request): Promise<void | never> {
    const {api, config} = this;

    const url = new URL(request.url);
    const isAuthRequest = url.pathname === config.auth.path;
    const isAuthCallbackRequest = url.pathname === config.auth.callbackPath;

    if (isAuthRequest || isAuthCallbackRequest) {
      const shop = api.utils.sanitizeShop(url.searchParams.get('shop')!);
      if (!shop) throw new Response('Shop param is invalid', {status: 400});

      if (isAuthRequest) {
        throw await this.handleAuthBeginRequest(request, shop);
      } else {
        throw await this.handleAuthCallbackRequest(request, shop);
      }
    }

    if (!getSessionTokenHeader(request)) {
      // This is a document request that doesn't contain a session token. We check if the app is installed.
      // If the app isn't installed, we initiate the OAuth auth code flow.
      // Requests with a header can only happen after the app is installed.
      await this.ensureInstalledOnShop(request);
    }
  }

  public async authenticate(
    request: Request,
    sessionContext: SessionContext,
  ): Promise<Session | never> {
    const {api, config, logger} = this;

    const {shop, session} = sessionContext;

    if (!session) {
      logger.debug('No session found, redirecting to OAuth', {shop});
      await redirectToAuthPage({config, logger, api}, request, shop);
    } else if (!session.isActive(config.scopes)) {
      logger.debug(
        'Found a session, but it has expired, redirecting to OAuth',
        {shop},
      );
      await redirectToAuthPage({config, logger, api}, request, shop);
    }

    logger.debug('Found a valid session', {shop});

    return session!;
  }

  public handleClientError(request: Request): HandleAdminClientError {
    const {api, config, logger} = this;
    return handleClientErrorFactory({
      request,
      onError: async ({session, error}: OnErrorOptions) => {
        if (error.response.code === 401) {
          throw await redirectToAuthPage(
            {api, config, logger},
            request,
            session.shop,
          );
        }
      },
    });
  }

  private async ensureInstalledOnShop(request: Request) {
    const {api, config, logger} = this;

    validateShopAndHostParams({api, config, logger}, request);

    const url = new URL(request.url);
    let shop = url.searchParams.get('shop');

    // Ensure app is installed
    logger.debug('Ensuring app is installed on shop', {shop});

    if (!(await this.hasValidOfflineId(request))) {
      logger.info("Could not find a shop, can't authenticate request");
      throw new Response(undefined, {
        status: 400,
        statusText: 'Bad Request',
      });
    }

    const offlineSession = await this.getOfflineSession(request);
    const isEmbedded = url.searchParams.get('embedded') === '1';

    if (!offlineSession) {
      logger.info("Shop hasn't installed app yet, redirecting to OAuth", {
        shop,
      });
      if (isEmbedded) {
        redirectWithExitIframe({api, config, logger}, request, shop!);
      } else {
        throw await beginAuth({api, config, logger}, request, false, shop!);
      }
    }

    shop = shop || offlineSession.shop;

    if (config.isEmbeddedApp && !isEmbedded) {
      try {
        logger.debug('Ensuring offline session is valid before embedding', {
          shop,
        });
        await this.testSession(offlineSession);

        logger.debug('Offline session is still valid, embedding app', {shop});
      } catch (error) {
        await this.handleInvalidOfflineSession(error, request, shop);
      }
    }
  }

  private async handleAuthBeginRequest(
    request: Request,
    shop: string,
  ): Promise<never> {
    const {api, config, logger} = this;

    logger.info('Handling OAuth begin request', {shop});

    // If we're loading from an iframe, we need to break out of it
    if (
      config.isEmbeddedApp &&
      request.headers.get('Sec-Fetch-Dest') === 'iframe'
    ) {
      logger.debug('Auth request in iframe detected, exiting iframe', {shop});
      throw redirectWithExitIframe({api, config, logger}, request, shop);
    } else {
      throw await beginAuth({api, config, logger}, request, false, shop);
    }
  }

  private async handleAuthCallbackRequest(
    request: Request,
    shop: string,
  ): Promise<never> {
    const {api, config, logger} = this;

    logger.info('Handling OAuth callback request', {shop});

    try {
      const {session, headers: responseHeaders} = await api.auth.callback({
        rawRequest: request,
        expiring: config.future.expiringOfflineAccessTokens,
      });

      await config.sessionStorage!.storeSession(session);

      if (config.useOnlineTokens && !session.isOnline) {
        logger.info('Requesting online access token for offline session', {
          shop,
        });
        await beginAuth({api, config, logger}, request, true, shop);
      }

      logger.debug('Request is valid, loaded session from OAuth callback', {
        shop: session.shop,
        isOnline: session.isOnline,
      });

      await triggerAfterAuthHook({api, config, logger}, session, request, this);

      throw await redirectToShopifyOrAppRoot(
        request,
        {api, config, logger},
        responseHeaders,
      );
    } catch (error) {
      if (error instanceof Response) throw error;

      throw await this.oauthCallbackError(error, request, shop);
    }
  }

  private async getOfflineSession(
    request: Request,
  ): Promise<Session | undefined> {
    const offlineId = await this.getOfflineSessionId(request);
    return this.config.sessionStorage!.loadSession(offlineId!);
  }

  private async hasValidOfflineId(request: Request) {
    return Boolean(await this.getOfflineSessionId(request));
  }

  private async getOfflineSessionId(
    request: Request,
  ): Promise<string | undefined> {
    const {api} = this;
    const url = new URL(request.url);
    const shop = url.searchParams.get('shop');

    return shop
      ? api.session.getOfflineId(shop)
      : api.session.getCurrentId({isOnline: false, rawRequest: request});
  }

  private async testSession(session: Session): Promise<void> {
    const {api} = this;

    const client = new api.clients.Graphql({
      session,
    });

    await client.request(`#graphql
      query shopifyAppShopName {
        shop {
          name
        }
      }
    `);
  }

  private async oauthCallbackError(
    error: Error,
    request: Request,
    shop: string,
  ) {
    const {logger} = this;
    logger.error('Error during OAuth callback', {shop, error: error.message});

    if (error instanceof CookieNotFound) {
      return this.handleAuthBeginRequest(request, shop);
    }

    if (
      error instanceof InvalidHmacError ||
      error instanceof InvalidOAuthError
    ) {
      return new Response(undefined, {
        status: 400,
        statusText: 'Invalid OAuth Request',
      });
    }

    return new Response(undefined, {
      status: 500,
      statusText: 'Internal Server Error',
    });
  }

  private async handleInvalidOfflineSession(
    error: Error,
    request: Request,
    shop: string,
  ) {
    const {api, logger, config} = this;
    if (error instanceof HttpResponseError) {
      if (error.response.code === 401) {
        logger.info('Shop session is no longer valid, redirecting to OAuth', {
          shop,
        });
        throw await beginAuth({api, config, logger}, request, false, shop);
      } else {
        const message = JSON.stringify(error.response.body, null, 2);
        logger.error(`Unexpected error during session validation: ${message}`, {
          shop,
        });

        throw new Response(undefined, {
          status: error.response.code,
          statusText: error.response.statusText,
        });
      }
    } else if (error instanceof GraphqlQueryError) {
      const context: Record<string, string> = {shop};
      if (error.response) {
        context.response = JSON.stringify(error.body);
      }

      logger.error(
        `Unexpected error during session validation: ${error.message}`,
        context,
      );

      throw new Response(undefined, {
        status: 500,
        statusText: 'Internal Server Error',
      });
    }
  }
}
