import {
  execute,
  ExecutionResult,
  GraphQLSchema,
  parse,
  subscribe,
} from "graphql";
import http, { IncomingMessage } from "http";
import {
  ConnectionContext,
  ExecutionParams,
  OperationMessage,
  OperationMessagePayload,
  SubscriptionServer,
} from "subscriptions-transport-ws";

import { ACCESS_TOKEN_PARAM, CLIENT_ID_PARAM } from "coral-common/constants";
import { Omit, RequireProperty } from "coral-common/types";
import { AppOptions } from "coral-server/app";
import { getHostname } from "coral-server/app/helpers/hostname";
import {
  createVerifiers,
  verifyAndRetrieveUser,
} from "coral-server/app/middleware/passport/strategies/jwt";
import {
  CoralError,
  InternalError,
  LiveUpdatesDisabled,
  RawQueryNotAuthorized,
  TenantNotFoundError,
} from "coral-server/errors";
import { enrichError, logError, logQuery } from "coral-server/graph/extensions";
import { getOperationMetadata } from "coral-server/graph/extensions/helpers";
import { getPersistedQuery } from "coral-server/graph/persisted";
import logger from "coral-server/logger";
import { PersistedQuery } from "coral-server/models/queries";
import { hasStaffRole } from "coral-server/models/user/helpers";
import { extractTokenFromRequest } from "coral-server/services/jwt";

import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";

import GraphContext, { GraphContextOptions } from "../context";

type OnConnectFn = (
  params: OperationMessagePayload,
  socket: any,
  context: ConnectionContext
) => Promise<GraphContext>;

export function extractTokenFromWSRequest(
  connectionParams: OperationMessagePayload,
  req: IncomingMessage
): string | null {
  // Try to grab the token from the connection params if available.
  if (
    typeof connectionParams[ACCESS_TOKEN_PARAM] === "string" &&
    connectionParams[ACCESS_TOKEN_PARAM].length > 0
  ) {
    return connectionParams[ACCESS_TOKEN_PARAM];
  }

  // Try to get the access token from the request.
  return extractTokenFromRequest(req);
}

export function extractClientID(connectionParams: OperationMessagePayload) {
  if (
    typeof connectionParams[CLIENT_ID_PARAM] === "string" &&
    connectionParams[CLIENT_ID_PARAM].length > 0
  ) {
    return connectionParams[CLIENT_ID_PARAM];
  }

  return null;
}

export type OnConnectOptions = RequireProperty<
  Omit<GraphContextOptions, "tenant" | "disableCaching">,
  "signingConfig"
>;

export function onConnect(options: OnConnectOptions): OnConnectFn {
  // Create the JWT verifiers that will be used to verify all the requests
  // coming in.
  const verifiers = createVerifiers(options);

  // Return the per-connection operation.
  return async (connectionParams, socket) => {
    try {
      // Pull the upgrade request off of the connection.
      const req: IncomingMessage = socket.upgradeReq;

      // Get the hostname of the request.
      const hostname = getHostname(req);
      if (!hostname) {
        throw new Error("could not detect hostname");
      }

      // Get the Tenant for this hostname.
      const tenant = await options.tenantCache.retrieveByDomain(hostname);
      if (!tenant) {
        throw new TenantNotFoundError(hostname);
      }

      // Create some new options to store the tenant context details inside.
      const opts: GraphContextOptions = {
        ...options,
        // Disable caching with this Context to ensure that every call (besides)
        // to the tenant, is not cached, and is instead fresh.
        disableCaching: true,
        tenant,
      };

      // If the token is available, try to get the user.
      const tokenString = extractTokenFromWSRequest(connectionParams, req);
      if (tokenString) {
        const user = await verifyAndRetrieveUser(
          verifiers,
          tenant,
          tokenString
        );
        if (user) {
          opts.user = user;
        }
      }

      // Check to see if live updates are disabled on the server, if they are,
      // we can block the websocket request here for non-staff users.
      if (options.config.get("disable_live_updates")) {
        // TODO: (wyattjoh) if the story settings can only disable, and not
        // enable live updates (as it takes precedence over global settings)
        // then we can add a check for `!tenant.live.enabled` here too.
        if (!opts.user || !hasStaffRole(opts.user)) {
          throw new LiveUpdatesDisabled();
        }
      }

      // Extract the users clientID from the request.
      const clientID = extractClientID(connectionParams);
      if (clientID) {
        opts.clientID = clientID;
      }

      return new GraphContext(opts);
    } catch (err) {
      if (err instanceof LiveUpdatesDisabled) {
        logger.info({ err }, "websocket connection rejected");
      } else {
        logger.error({ err }, "could not setup websocket connection");
      }

      if (!(err instanceof CoralError)) {
        // eslint-disable-next-line no-ex-assign
        err = new InternalError(err, "could not setup websocket connection");
      }
      const { message } = err.serializeExtensions(
        options.i18n.getDefaultBundle()
      );

      throw new Error(message);
    }
  };
}

export type FormatResponseOptions = Pick<AppOptions, "metrics">;

export function formatResponse(
  { metrics }: FormatResponseOptions,
  persisted?: PersistedQuery
) {
  return (
    value: ExecutionResult,
    { context, query }: ExecutionParams<GraphContext>
  ) => {
    // Parse the query in order to extract operation metadata.
    if (typeof query === "string") {
      query = parse(query);
    }

    // Log out the query.
    logQuery(context, query, persisted);

    // Increment the metrics if enabled.
    if (metrics) {
      // Get the request metadata.
      const { operation, operationName } = getOperationMetadata(query);
      if (operation && operationName) {
        // Increment the graph query value, tagging with the name of the query.
        metrics.executedGraphQueriesTotalCounter
          .labels(operation, operationName)
          .inc();
      }
    }

    if (value.errors && value.errors.length > 0) {
      return {
        ...value,
        errors: value.errors.map(err => {
          const enriched = enrichError(context, err);

          // Log the error out.
          logError(context, enriched);

          return enriched;
        }),
      };
    }

    return value;
  };
}

export type OnOperationOptions = FormatResponseOptions &
  Pick<AppOptions, "persistedQueryCache" | "persistedQueriesRequired">;

export function onOperation(options: OnOperationOptions) {
  return async (
    message: OperationMessage,
    params: ExecutionParams<GraphContext>
  ) => {
    // Handle the payload if it is a persisted query.
    const persisted = await getPersistedQuery(
      options.persistedQueryCache,
      message.payload
    );
    if (!persisted) {
      // Check to see if this is from an ADMIN token which is allowed to run
      // un-persisted queries.
      if (
        options.persistedQueriesRequired &&
        (!params.context.user ||
          params.context.user.role !== GQLUSER_ROLE.ADMIN)
      ) {
        throw new RawQueryNotAuthorized(
          params.context.tenant.id,
          message.payload && message.payload.query
            ? message.payload.query
            : null,
          params.context.user ? params.context.user.id : null
        );
      }
    } else {
      // The query was found for this operation, replace the query with the one
      // provided.
      params.query = persisted.query;
    }

    // Attach the response formatter.
    params.formatResponse = formatResponse(options, persisted);

    return params;
  };
}

export type Options = OnConnectOptions & OnOperationOptions;

export function createSubscriptionServer(
  server: http.Server,
  schema: GraphQLSchema,
  options: Options
) {
  // This typecast is needed because the custom `ms` format does not return the
  // desired `number` type even though that's the only type it can output.
  const keepAlive = (options.config.get(
    "websocket_keep_alive_timeout"
  ) as unknown) as number;

  return SubscriptionServer.create(
    {
      schema,
      execute,
      subscribe,
      onConnect: onConnect(options),
      onOperation: onOperation(options),
      keepAlive,
    },
    {
      server,
      path: "/api/graphql/live",
    }
  );
}
