/*
 * (c) Copyright IBM Corp. 2021
 * (c) Copyright Instana Inc. and contributors 2019
 */

'use strict';

const shimmer = require('../../shimmer');
const hook = require('../../../util/hook');
const tracingUtil = require('../../tracingUtil');
const constants = require('../../constants');
const cls = require('../../cls');

let logger;
let isActive = false;

const queryOperationType = 'query';
const mutationOperationType = 'mutation';
const subscriptionOperationType = 'subscription';
const operationTypes = [queryOperationType, mutationOperationType, subscriptionOperationType];

const subscriptionUpdate = 'subscription-update';

exports.init = function init(config) {
  logger = config.logger;

  hook.onFileLoad(/\/graphql\/execution\/execute.js/, instrumentExecute);
  hook.onFileLoad(/\/@apollo\/gateway\/dist\/executeQueryPlan.js/, instrumentApolloGatewayExecuteQueryPlan);
  hook.onModuleLoad('@apollo/federation', logDeprecatedWarning);
};

function instrumentExecute(executeModule) {
  shimmer.wrap(executeModule, 'execute', shimExecuteFunction.bind(null));
}

function shimExecuteFunction(originalFunction) {
  return function instrumentedExecute() {
    if (!isActive || cls.tracingSuppressed()) {
      // If this GraphQL query has been triggered by an HTTP request and it had X-INSTANA-L: 0, we have already set the
      // tracing level in the current cls context.
      return originalFunction.apply(this, arguments);
    }

    const originalThis = this;
    const originalArgs = arguments;
    let doc;
    let operationName;

    if (originalArgs.length === 1 && typeof originalArgs[0] === 'object') {
      doc = originalArgs[0].document;
      operationName = originalArgs[0].operationName;
    } else {
      doc = originalArgs[1];
      operationName = originalArgs[5];
    }

    const operationDefinition = findOperationDefinition(doc, operationName);
    if (!operationDefinition) {
      logger.debug('No operation definition, GraphQL call will not be traced.');
      return originalFunction.apply(this, arguments);
    }
    if (!operationDefinition.operation) {
      logger.debug(
        `Operation definition has no operation, GraphQL call will not be traced. ${JSON.stringify(operationDefinition)}`
      );
      return originalFunction.apply(this, arguments);
    }

    if (operationDefinition.operation === subscriptionOperationType) {
      return traceSubscriptionUpdate(
        originalFunction,
        originalThis,
        originalArgs,
        instrumentedExecute,
        operationDefinition,
        operationName
      );
    } else {
      return traceQueryOrMutation(
        originalFunction,
        originalThis,
        originalArgs,
        instrumentedExecute,
        operationDefinition,
        operationName
      );
    }
  };
}

function traceQueryOrMutation(
  originalFunction,
  originalThis,
  originalArgs,
  stackTraceRef,
  operationDefinition,
  operationName
) {
  const activeEntrySpan = cls.getCurrentSpan(true);
  let span;
  if (activeEntrySpan && activeEntrySpan.k === constants.ENTRY && activeEntrySpan.n !== 'graphql.server') {
    // For now, we assume that the GraphQL operation is the only relevant operation that is happening while processing
    // the incoming request.
    //
    // With these assumptions in mind, we overwrite the entry span that has already been started (if any) and turn it
    // into a GraphQL entry span.
    //
    // HTTP is by far the most common transport for GraphQL, but others are possible and are actually used (AMQP for
    // example) (GraphQL is transport-layer agnostic).
    //
    // Possible consequences:
    // 1) If a customer does GraphQL over any transport that we trace as an entry, we will repurpose this
    // transport/protocol level entry span to a GraphQL entry by overwriting span.n. The attributes captured by the
    // protocol level tracing (the initial timestamp and for example HTTP attributes like url and method) will be kept.
    // But if the customer is using a transport that we do _not_ trace, we will start a new root entry span here. We
    // will still trace the GraphQL entry but might lose trace continuity, as X-Instana-T andX-Instana-S are not
    // transported at the GraphQL layer but rather in the underlying transport layer (HTTP, AMQP, ...)
    //
    // 2) If a customer does multiple things in an HTTP, AMQP, GRPC, ... call, only one of which is running a GraphQL
    // query, it is possible that they would rather see this call as the HTTP/AMQP/GRPC/... call instead of a GraphQL
    // call. But since we give GraphQL preference over protocol level entry spans, it will show up as a GraphQL call.

    // Replace generic node.http.server/rabbitmq/... span by a more specific graphql.server span. We change the values
    // in the active span in-situ. This way, other intermediate and exit spans that have already been created as
    // children of the current entry span still have the the correct parent span ID.
    span = activeEntrySpan;
    span.n = 'graphql.server';

    // Mark this span so that the GraphQL instrumentation will not transmit it, instead we wait for the protocol level
    // instrumentation to finish, which will then transmit it. This will ensure that we also capture attributes that are
    // only written by the protocol level instrumentation at the end (like the HTTP status code).
    // (This property is defined as non-enumerable in the InstanaSpan class and will not be serialized to JSON.)
    span.postponeTransmit = true;
  }

  return cls.ns.runAndReturn(() => {
    if (!span) {
      // If there hasn't been an entry span that we repurposed into a graphql.server entry span, we need to start a new
      // root entry span here.
      span = cls.startSpan({
        spanName: 'graphql.server',
        kind: constants.ENTRY
      });
    }
    span.stack = tracingUtil.getStackTrace(stackTraceRef);
    span.data.graphql = {
      operationType: operationDefinition.operation,
      operationName: operationDefinition.name ? operationDefinition.name.value : operationName,
      fields: {},
      args: {}
    };
    addFieldsAndArguments(span, operationDefinition);

    return runOriginalAndFinish(originalFunction, originalThis, originalArgs, span);
  });
}

function traceSubscriptionUpdate(
  originalFunction,
  originalThis,
  originalArgs,
  stackTraceRef,
  operationDefinition,
  operationName
) {
  if (!isActive) {
    return originalFunction.apply(originalThis, originalArgs);
  }

  // WE DO NOT MAKE USE OF `cls.skipExitTracing` in the graphql instrumentation,
  // because we have a special usage of `cls.getReducedSpan(true)`. Feel free to refactor this.

  // CASE 1: We pass `fallbackToSharedContext: true` to getCurrentSpan to access the GraphQL query context, which then
  // triggered this subscription query. We need to connect them.
  // CASE 2: If there is no active entry span, we fall back to the reduced span of the most recent entry span. See
  // comment in packages/core/src/tracing/clsHooked/unset.js#storeReducedSpan.
  // CASE 3: We can ignore `allowRootExitSpan` is enabled,  because subscriptions only work with the
  //         apollo-server-express and it doesn't make sense to enable `allowRootExitSpan` for this case.
  const parentSpan = cls.getCurrentSpan(true) || cls.getReducedSpan(true);

  if (parentSpan && !constants.isExitSpan(parentSpan) && parentSpan.t && parentSpan.s) {
    return cls.ns.runAndReturn(() => {
      const span = cls.startSpan({
        spanName: 'graphql.client',
        kind: constants.EXIT,
        traceId: parentSpan.t,
        parentSpanId: parentSpan.s
      });
      span.ts = Date.now();
      span.stack = tracingUtil.getStackTrace(stackTraceRef);
      span.data.graphql = {
        operationType: subscriptionUpdate,
        operationName: operationDefinition.name ? operationDefinition.name.value : operationName,
        fields: {},
        args: {}
      };
      addFieldsAndArguments(span, operationDefinition);
      return runOriginalAndFinish(originalFunction, originalThis, originalArgs, span);
    });
  } else {
    return originalFunction.apply(originalThis, originalArgs);
  }
}

function findOperationDefinition(doc, operationNameFromArgs) {
  if (doc && Array.isArray(doc.definitions)) {
    if (operationNameFromArgs) {
      return doc.definitions
        .filter(definition => operationTypes.indexOf(definition.operation) !== -1)
        .find(definition => {
          const name = definition.name ? definition.name.value : null;
          return name && operationNameFromArgs === name;
        });
    } else {
      return doc.definitions.find(definition => operationTypes.indexOf(definition.operation) !== -1);
    }
  }
  return null;
}

function addFieldsAndArguments(span, definition) {
  traverseSelections(definition, entities => {
    entities.forEach(function (entity) {
      const entityName = entity.name.value;
      traverseSelections(entity, fields => {
        span.data.graphql.fields[entityName] = fields.map(field => field.name.value);
      });

      if (Array.isArray(entity.arguments) && entity.arguments.length > 0) {
        span.data.graphql.args[entityName] = entity.arguments.map(arg =>
          arg.name && typeof arg.name.value === 'string' ? arg.name.value : '?'
        );
      }
    });
  });
}

function traverseSelections(definition, selectionPostProcessor) {
  if (
    !definition.selectionSet ||
    typeof definition.selectionSet !== 'object' ||
    !Array.isArray(definition.selectionSet.selections)
  ) {
    return null;
  }
  const candidates = definition.selectionSet.selections.filter(
    selection => selection && selection.kind === 'Field' && selection.name && typeof selection.name.value === 'string'
  );

  return selectionPostProcessor(candidates);
}

function runOriginalAndFinish(originalFunction, originalThis, originalArgs, span) {
  let result;
  try {
    result = originalFunction.apply(originalThis, originalArgs);
  } catch (e) {
    // A synchronous exception happened when resolving the GraphQL query, finish immediately.
    finishWithException(span, e);
    return result;
  }

  // A graphql resolver can yield a value, a promise or an array of promises. Fortunately, the "array of promises"
  // case is handled internally (the array is merged into one promise) so we only need to differntiate between
  // the promise and value cases.
  if (result && typeof result.then === 'function') {
    return result.then(
      promiseResult => {
        finishSpan(span, promiseResult);
        return promiseResult;
      },
      err => {
        finishWithException(span, err);
        throw err;
      }
    );
  } else {
    // The GraphQL operation returned a value instead of a promise - that means, the resolver finished synchronously. We
    // can finish the span immediately.
    finishSpan(span, result);
    return result;
  }
}

function finishSpan(span, result) {
  span.ec = result.errors && result.errors.length >= 1 ? 1 : 0;
  span.d = Date.now() - span.ts;
  if (Array.isArray(result.errors)) {
    span.data.graphql.errors = result.errors
      .map(singleError => (typeof singleError.message === 'string' ? singleError.message : null))
      .filter(msg => !!msg)
      .join(', ');
  }
  if (!span.postponeTransmit && !span.postponeTransmitApolloGateway) {
    span.transmit();
  }
}

function finishWithException(span, err) {
  span.ec = 1;
  span.d = Date.now() - span.ts;
  span.data.graphql.errors = err.message;
  if (!span.postponeTransmit) {
    span.transmit();
  }
}

function instrumentApolloGatewayExecuteQueryPlan(apolloGatewayExecuteQueryPlanModule) {
  shimmer.wrap(
    apolloGatewayExecuteQueryPlanModule,
    'executeQueryPlan',
    shimApolloGatewayExecuteQueryPlanFunction.bind(null)
  );
}

function shimApolloGatewayExecuteQueryPlanFunction(originalFunction) {
  return function instrumentedExecuteQueryPlan() {
    if (!isActive || cls.tracingSuppressed()) {
      return originalFunction.apply(this, arguments);
    }

    const activeEntrySpan = cls.getCurrentSpan();

    if (activeEntrySpan && activeEntrySpan.k === constants.ENTRY) {
      // Most of the heavy lifting to trace Apollo Federation gateways (implemented by @apollo/gateway) is done by our
      // standard GraphQL tracing, because those gateway queries are all also run through normal resolvers, which we
      // instrument. There is one case that requires extra instrumentation, though. @apollo/gateway does something funky
      // with errors that come back from individual services: We would normally finish and transmit the span in
      // shimExecuteFunction/traceQueryOrMutation/runOriginalAndFinish, but when Apollo Gateway is involved the errors
      // (if any) are not part of the response. Therefore we mark the span so that transmitting it will be postponed,
      // that is, it won't happen in runOriginalAndFinish. Once the call returns from @apollo/gateway#executeQueryPlan
      // the errors have been merged back into the response and only then will we record the errors and finally transmit
      // the span. This is implemented via the marker property postponeTransmitApolloGateway.
      // (This property is defined as non-enumerable in the InstanaSpan class and will not be serialized to JSON.)
      activeEntrySpan.postponeTransmitApolloGateway = true;
    }

    const resultPromise = originalFunction.apply(this, arguments);
    if (resultPromise && typeof resultPromise.then === 'function') {
      return resultPromise.then(
        promiseResult => {
          delete activeEntrySpan.postponeTransmitApolloGateway;
          finishSpan(activeEntrySpan, promiseResult);
          return promiseResult;
        },
        err => {
          delete activeEntrySpan.postponeTransmitApolloGateway;
          finishWithException(activeEntrySpan, err);
          throw err;
        }
      );
    }
    return resultPromise;
  };
}
function logDeprecatedWarning() {
  logger.warn(
    // eslint-disable-next-line max-len
    '[Deprecation Warning] The support for @apollo-federation is deprecated and will be removed in the next major release. Please consider migrating to an appropriate package.'
  );
}

exports.activate = function activate() {
  isActive = true;
};

exports.deactivate = function () {
  isActive = false;
};
