import type {
  GatewayGraphQLRequest,
  GatewayGraphQLRequestContext,
  GatewayGraphQLResponse,
  GatewaySchemaHash,
} from '@apollo/server-gateway-interface';
import type { FetcherHeaders } from '@apollo/utils.fetcher';
import type { ApolloServer, ApolloServerInternals } from '../ApolloServer';
import type {
  BaseContext,
  GraphQLRequestContextExecutionDidStart,
} from '../externalTypes';
import type { HeaderMap } from './HeaderMap';

// Apollo Gateway's API included `GraphQLRequestContext` from AS2/AS3.
// Specifically, a request context is passed to the main executor method, which
// it then exposes to user-configurable `GraphQLDataSource`s.
// `GraphQLRequestContext` has changed in incompatible ways since AS4; for example,
// we represent HTTP messages using our own data structures rather than Fetches,
// and some fields have been removed because they relate to features that don't
// exist any more.
//
// In general, the future of Apollo's development is in Apollo Router, not
// Gateway. So rather than have a big transition where a new version of Gateway
// supports AS5's GraphQLRequestContext instead of AS3's, we simply teach AS5
// how to produce AS3-style GraphQLRequestContext objects specifically for use
// by Gateway. We have changed Gateway to get its TS type definitions from a new
// package rather than from AS3 itself, so that Gateway no longer needs to
// depend on Apollo Server.
//
// This function turns an AS5 GraphQLRequestContext into a
// GatewayGraphQLRequestContext (which is basically an AS3
// GraphQLRequestContext).
//
// You might think that *after* invoking the executor, we would then need to
// propagate any changes made by the gateway back onto the "real"
// GraphQLRequestContext. It turns out that for each bit of data on the request
// context, this is either unnecessary or impossible. (We don't need to support
// use cases where people break type safe, eg by changing the values of readonly
// fields.) Here's why:
//
// Many fields on GatewayGraphQLRequestContext are declared readonly and their
// values are taken directly from the real GraphQLRequestContext. This means
// that gateways should not change the field's value, and any mutations of the
// object stored in the field (say, calling
// `requestContext.overallCachePolicy.restrict`, as RemoteGraphQLDataSource
// does) already take effect.
//
//  The only two fields not declared as readonly are `logger` and `debug`.
//
// Technically, a gateway implementation could set `requestContext.logger` to a
// different Logger without breaking the TypeScript declarations. In AS5 we
// don't actually have a requestContext.logger; we have `readonly
// requestContext.server` and `readonly server.logger`. So there's not an easy
// way for us to carry out this change: AS5 just doesn't let gateway or plugins
// override the server's logger (and generally doesn't allow the logger to
// change after the server is created), which seems like a simpler model. If it
// turns out there is a real use case for the gateway to be able to change the
// overall logger for the request as seen by plugins, we can fix that later.
//
// Similarly, it's not clear what the intended use case of mutating `debug` in
// gateway would be. `debug` has now mostly changed into
// `includeStacktraceInErrorResponses`. So perhaps this could be used to let you
// decide whether or not to include the stacktrace on a per-operation basis...
// but you can also use `formatError` or `didEncounterErrors` for this perhaps?
// In any case, AS5 doesn't track `includeStacktraceInErrorResponses` on a
// per-operation basis; if we find a use case for this we can add it later.
//
// So we'll just ignore changes to `logger` and `debug`.
//
// Next, there's `request`. We don't know of a use case for mutating the
// *request* at execution time. If there was a real use case, we could add a
// function that copies pieces back from the gateway `request` to the AS5
// request, but we're not bothering to yet.
//
// Finally, there's `response`. Sure, the executor *could* mutate `response`.
// But the main thing the executor is doing is *returning* a response, which
// then semi-overwrites `requestContext.response` anyway. So it doesn't seem
// like we need to support `executor` *also* overwriting response. Yet again, we
// can fix this if it turns out it's necessary. (That said, the executor could
// in theory write HTTP response headers or status, so we make sure to hook them
// up directly to the appropriate data in the real GraphQLRequestContext.)
//
// So all in all, it looks like it's OK for this to be a "one-way" conversion.
export function makeGatewayGraphQLRequestContext<TContext extends BaseContext>(
  newRequestContext: GraphQLRequestContextExecutionDidStart<TContext>,
  server: ApolloServer<TContext>,
  internals: ApolloServerInternals<TContext>,
): GatewayGraphQLRequestContext {
  const request: GatewayGraphQLRequest = {};
  if ('query' in newRequestContext.request) {
    request.query = newRequestContext.request.query;
  }
  if ('operationName' in newRequestContext.request) {
    request.operationName = newRequestContext.request.operationName;
  }
  if ('variables' in newRequestContext.request) {
    request.variables = newRequestContext.request.variables;
  }
  if ('extensions' in newRequestContext.request) {
    request.extensions = newRequestContext.request.extensions;
  }
  if (newRequestContext.request.http) {
    const newHttp = newRequestContext.request.http;
    const needQuestion =
      newHttp.search !== '' && !newHttp.search.startsWith('?');
    request.http = {
      method: newHttp.method,
      // As of AS4, we no longer attempt to track complete URLs (just the search
      // parameters used in GET requests). So we have to fake them for Gateway.
      url: `https://unknown-url.invalid/${needQuestion ? '?' : ''}${
        newHttp.search
      }`,
      headers: new FetcherHeadersForHeaderMap(newHttp.headers),
    };
  }

  const response: GatewayGraphQLResponse = {
    http: {
      headers: new FetcherHeadersForHeaderMap(
        newRequestContext.response.http.headers,
      ),
      get status() {
        return newRequestContext.response.http.status;
      },
      set status(newStatus) {
        newRequestContext.response.http.status = newStatus;
      },
    },
    // We leave off `body` because it hasn't been set yet.
  };

  return {
    request,
    response,
    logger: server.logger,
    schema: newRequestContext.schema,
    // For the sake of typechecking, we still provide this field, but we don't
    // calculate it. If somebody really needs it in their gateway
    // implementation, they're welcome to copy
    // https://github.com/apollographql/apollo-server/blob/3f218e78/packages/apollo-server-core/src/utils/schemaHash.ts
    // into their code.
    schemaHash:
      'schemaHash no longer exists since Apollo Server 4' as GatewaySchemaHash,
    context: newRequestContext.contextValue,
    cache: server.cache,
    queryHash: newRequestContext.queryHash,
    document: newRequestContext.document,
    source: newRequestContext.source,
    operationName: newRequestContext.operationName,
    operation: newRequestContext.operation,
    errors: newRequestContext.errors,
    metrics: newRequestContext.metrics,
    debug: internals.includeStacktraceInErrorResponses,
    overallCachePolicy: newRequestContext.overallCachePolicy,
    requestIsBatched: newRequestContext.requestIsBatched,
  };
}

// An implementation of the W3C-style headers class used by Gateway (and AS3),
// backed by AS5's HeaderMap. Changes are written directly to the HeaderMap, so
// any concurrent writes to the underlying HeaderMap (eg from a plugin) can be
// seen immediately by the gateway and vice versa.
class FetcherHeadersForHeaderMap implements FetcherHeaders {
  constructor(private map: HeaderMap) {}
  append(name: string, value: string) {
    if (this.map.has(name)) {
      this.map.set(name, this.map.get(name) + ', ' + value);
    } else {
      this.map.set(name, value);
    }
  }
  delete(name: string) {
    this.map.delete(name);
  }
  get(name: string): string | null {
    return this.map.get(name) ?? null;
  }
  has(name: string): boolean {
    return this.map.has(name);
  }
  set(name: string, value: string) {
    this.map.set(name, value);
  }
  entries(): Iterator<[string, string]> {
    return this.map.entries();
  }
  keys(): Iterator<string> {
    return this.map.keys();
  }
  values(): Iterator<string> {
    return this.map.values();
  }
  [Symbol.iterator](): Iterator<[string, string]> {
    return this.map.entries();
  }
}
