// This class is a helper for ApolloServerPluginUsageReporting and
// ApolloServerPluginInlineTrace.
import {
  GraphQLError,
  type GraphQLResolveInfo,
  type ResponsePath,
} from 'graphql';
import { Trace, google } from '@apollo/usage-reporting-protobuf';
import type { SendErrorsOptions } from './usageReporting';
import { UnreachableCaseError } from '../utils/UnreachableCaseError.js';

function internalError(message: string) {
  return new Error(`[internal apollo-server error] ${message}`);
}

export class TraceTreeBuilder {
  private rootNode = new Trace.Node();
  public trace = new Trace({
    root: this.rootNode,
    // By default, each trace counts as one operation for the sake of field
    // execution counts. If we end up calling the fieldLevelInstrumentation
    // callback (once we've successfully resolved the operation) then we
    // may set this to a higher number; but we'll start it at 1 so that traces
    // that don't successfully resolve the operation (eg parse failures) or
    // where we don't call the callback because a plugin set captureTraces to
    // true have a reasonable default.
    fieldExecutionWeight: 1,
  });
  public startHrTime?: [number, number];
  private stopped = false;
  private nodes = new Map<string, Trace.Node>([
    [responsePathAsString(), this.rootNode],
  ]);
  private readonly transformError:
    | ((err: GraphQLError) => GraphQLError | null)
    | null;

  public constructor(options: {
    maskedBy: string;
    sendErrors?: SendErrorsOptions;
  }) {
    const { sendErrors, maskedBy } = options;
    if (!sendErrors || 'masked' in sendErrors) {
      this.transformError = () =>
        new GraphQLError('<masked>', {
          extensions: { maskedBy },
        });
    } else if ('transform' in sendErrors) {
      this.transformError = sendErrors.transform;
    } else if ('unmodified' in sendErrors) {
      this.transformError = null;
    } else {
      throw new UnreachableCaseError(sendErrors);
    }
  }

  public startTiming() {
    if (this.startHrTime) {
      throw internalError('startTiming called twice!');
    }
    if (this.stopped) {
      throw internalError('startTiming called after stopTiming!');
    }
    this.trace.startTime = dateToProtoTimestamp(new Date());
    this.startHrTime = process.hrtime();
  }

  public stopTiming() {
    if (!this.startHrTime) {
      throw internalError('stopTiming called before startTiming!');
    }
    if (this.stopped) {
      throw internalError('stopTiming called twice!');
    }

    this.trace.durationNs = durationHrTimeToNanos(
      process.hrtime(this.startHrTime),
    );
    this.trace.endTime = dateToProtoTimestamp(new Date());
    this.stopped = true;
  }

  public willResolveField(info: GraphQLResolveInfo): () => void {
    if (!this.startHrTime) {
      throw internalError('willResolveField called before startTiming!');
    }
    if (this.stopped) {
      // We've been stopped, which means execution is done... and yet we're
      // still resolving more fields? How can that be? Shouldn't we throw an
      // error or something?
      //
      // Well... we used to do exactly that. But this "shouldn't happen" error
      // showed up in practice! Turns out that graphql-js can actually continue
      // to execute more fields indefinitely long after `execute()` resolves!
      // That's because parallelism on a selection set is implemented using
      // `Promise.all`, and as soon as one of its arguments (ie, one field)
      // throws an error, the combined Promise resolves, but there's no
      // "cancellation" of the Promises that are the other arguments to
      // `Promise.all`. So the code contributing to those Promises keeps on
      // chugging away indefinitely.
      //
      // Concrete example: let’s say you have
      //
      //    { x y { ARBITRARY_SELECTION_SET } }
      //
      // where x has a non-null return type, and x and y both have resolvers
      // that return Promises. And let’s say that x returns a Promise that
      // rejects (or resolves to null). What this means is that we’re going to
      // eventually end up with `data: null` (nothing under y will actually
      // matter), but graphql-js execution will continue running whatever is
      // under ARBITRARY_SELECTION_SET without any sort of short circuiting. In
      // fact, the Promise returned from execute itself can happily resolve
      // while execution is still chugging away on an arbitrary amount of fields
      // under that ARBITRARY_SELECTION_SET. There’s no way to detect from the
      // outside "all the execution related to this operation is done", nor to
      // "short-circuit" execution so that it stops going.
      //
      // So, um. We will record any field whose execution we manage to observe
      // before we "stop" the TraceTreeBuilder (whether it is one that actually
      // ends up in the response or whether it gets thrown away due to null
      // bubbling), but if we get any more fields afterwards, we just ignore
      // them rather than throwing a confusing error.
      //
      // (That said, the error we used to throw here generally was hidden
      // anyway, for the same reason: it comes from a branch of execution that
      // ends up not being included in the response. But
      // https://github.com/graphql/graphql-js/pull/3529 means that this
      // sometimes crashed execution anyway. Our error never caught any actual
      // problematic cases, so keeping it around doesn't really help.)
      return () => {};
    }

    const path = info.path;
    const node = this.newNode(path);
    node.type = info.returnType.toString();
    node.parentType = info.parentType.toString();
    node.startTime = durationHrTimeToNanos(process.hrtime(this.startHrTime));
    if (typeof path.key === 'string' && path.key !== info.fieldName) {
      // This field was aliased; send the original field name too (for FieldStats).
      node.originalFieldName = info.fieldName;
    }

    return () => {
      node.endTime = durationHrTimeToNanos(process.hrtime(this.startHrTime));
    };
  }

  public didEncounterErrors(errors: readonly GraphQLError[]) {
    errors.forEach((err) => {
      // This is an error from a federated service. We will already be reporting
      // it in the nested Trace in the query plan.
      //
      // XXX This probably shouldn't skip query or validation errors, which are
      //      not in nested Traces because format() isn't called in this case! Or
      //      maybe format() should be called in that case?
      if (err.extensions?.serviceName) {
        return;
      }

      // In terms of reporting, errors can be re-written by the user by
      // utilizing the `transformError` parameter.  This allows changing
      // the message or stack to remove potentially sensitive information.
      // Returning `null` will result in the error not being reported at all.
      const errorForReporting = this.transformAndNormalizeError(err);

      if (errorForReporting === null) {
        return;
      }

      this.addProtobufError(
        errorForReporting.path,
        errorToProtobufError(errorForReporting),
      );
    });
  }

  private addProtobufError(
    path: ReadonlyArray<string | number> | undefined,
    error: Trace.Error,
  ) {
    if (!this.startHrTime) {
      throw internalError('addProtobufError called before startTiming!');
    }
    if (this.stopped) {
      throw internalError('addProtobufError called after stopTiming!');
    }

    // By default, put errors on the root node.
    let node = this.rootNode;
    // If a non-GraphQLError Error sneaks in here somehow with a non-array
    // path, don't crash.
    if (Array.isArray(path)) {
      const specificNode = this.nodes.get(path.join('.'));
      if (specificNode) {
        node = specificNode;
      } else {
        const responsePath = responsePathFromArray(path, this.rootNode);
        if (!responsePath) {
          throw internalError('addProtobufError called with invalid path!');
        }
        node = this.newNode(responsePath);
      }
    }

    node.error.push(error);
  }

  private newNode(path: ResponsePath): Trace.Node {
    const node = new Trace.Node();
    const id = path.key;
    if (typeof id === 'number') {
      node.index = id;
    } else {
      node.responseName = id;
    }
    this.nodes.set(responsePathAsString(path), node);
    const parentNode = this.ensureParentNode(path);
    parentNode.child.push(node);
    return node;
  }

  private ensureParentNode(path: ResponsePath): Trace.Node {
    const parentPath = responsePathAsString(path.prev);
    const parentNode = this.nodes.get(parentPath);
    if (parentNode) {
      return parentNode;
    }
    // Because we set up the root path when creating this.nodes, we now know
    // that path.prev isn't undefined.
    return this.newNode(path.prev!);
  }

  private transformAndNormalizeError(err: GraphQLError): GraphQLError | null {
    if (this.transformError) {
      // Before passing the error to the user-provided `transformError` function,
      // we'll make a shadow copy of the error so the user is free to change
      // the object as they see fit.

      // At this stage, this error is only for the purposes of reporting, but
      // this is even more important since this is still a reference to the
      // original error object and changing it would also change the error which
      // is returned in the response to the client.

      // For the clone, we'll create a new object which utilizes the exact same
      // prototype of the error being reported.
      const clonedError = Object.assign(
        Object.create(Object.getPrototypeOf(err)),
        err,
      );

      const rewrittenError = this.transformError(clonedError);

      // Returning an explicit `null` means the user is requesting that the error
      // not be reported to Apollo.
      if (rewrittenError === null) {
        return null;
      }

      // We don't want users to be inadvertently not reporting errors, so if
      // they haven't returned an explicit `GraphQLError` (or `null`, handled
      // above), then we'll report the error as usual.
      if (!(rewrittenError instanceof GraphQLError)) {
        return err;
      }

      // We only allow transformError to change the message and extensions of the
      // error; we keep everything else the same. That way people don't have to
      // do extra work to keep the error on the same trace node. We also keep
      // extensions the same if it isn't explicitly changed (to, eg, {}). (Note
      // that many of the fields of GraphQLError are not enumerable and won't
      // show up in the trace (even in the json field) anyway.)
      return new GraphQLError(rewrittenError.message, {
        nodes: err.nodes,
        source: err.source,
        positions: err.positions,
        path: err.path,
        originalError: err.originalError,
        extensions: rewrittenError.extensions || err.extensions,
      });
    }
    return err;
  }
}

// Converts an hrtime array (as returned from process.hrtime) to nanoseconds.
//
// ONLY CALL THIS ON VALUES REPRESENTING DELTAS, NOT ON THE RAW RETURN VALUE
// FROM process.hrtime() WITH NO ARGUMENTS.
//
// The entire point of the hrtime data structure is that the JavaScript Number
// type can't represent all int64 values without loss of precision:
// Number.MAX_SAFE_INTEGER nanoseconds is about 104 days. Calling this function
// on a duration that represents a value less than 104 days is fine. Calling
// this function on an absolute time (which is generally roughly time since
// system boot) is not a good idea.
//
// XXX We should probably use google.protobuf.Duration on the wire instead of
// ever trying to store durations in a single number.
function durationHrTimeToNanos(hrtime: [number, number]) {
  return hrtime[0] * 1e9 + hrtime[1];
}

// Convert from the linked-list ResponsePath format to a dot-joined
// string. Includes the full path (field names and array indices).
function responsePathAsString(p?: ResponsePath): string {
  if (p === undefined) {
    return '';
  }

  // A previous implementation used `responsePathAsArray` from `graphql-js/execution`,
  // however, that employed an approach that created new arrays unnecessarily.
  let res = String(p.key);

  while ((p = p.prev) !== undefined) {
    res = `${p.key}.${res}`;
  }

  return res;
}

function responsePathFromArray(
  path: ReadonlyArray<string | number>,
  node: Trace.Node,
): ResponsePath | undefined {
  let responsePath: ResponsePath | undefined;
  let nodePtr: Trace.INode | undefined = node;
  for (const key of path) {
    nodePtr = nodePtr?.child?.find((child) => child.responseName === key);
    responsePath = {
      key,
      prev: responsePath,
      typename: nodePtr?.type ?? undefined,
    };
  }
  return responsePath;
}

function errorToProtobufError(error: GraphQLError): Trace.Error {
  return new Trace.Error({
    message: error.message,
    location: (error.locations || []).map(
      ({ line, column }) => new Trace.Location({ line, column }),
    ),
    json: JSON.stringify(error),
  });
}

// Converts a JS Date into a Timestamp.
export function dateToProtoTimestamp(date: Date): google.protobuf.Timestamp {
  const totalMillis = +date;
  const millis = totalMillis % 1000;
  return new google.protobuf.Timestamp({
    seconds: (totalMillis - millis) / 1000,
    nanos: millis * 1e6,
  });
}
