/// <reference types="akamai-edgeworkers"/>
/*
 * This is the common Service Worker API library to support the Spec Proxy product.
 * If you are a user, you likely are looking for a platform-specific library like
 * spec-proxy-cloudflare-worker or spec-proxy-fastly-worker.
 */
import { httpRequest, HttpResponse, RequestBody } from "http-request";
import * as Cookies from "cookies";

// header that controls request forwarding for Spec Proxy
const SPEC_HEADER_FORWARD_ORIGIN = "x-spec-forward-origin";
// cookie key for the Spec ID
const SPEC_COOKIE_ID = "x-spec-id";
// the Set-Cookie header
const HEADER_SET_COOKIE = "set-cookie";
// the Cookie header
const HEADER_COOKIE = "cookie";
// standard X-Forwarded-For header
const HEADER_HOST = "host";
// standard X-Forwarded-For header
const HEADER_TRUE_CLIENT_IP = "true-client-ip";

/*
 * The `AkamaiRequest` type is a wrapper type that allows
 * the Spec Proxy library to manipulate the incoming request.
 * Callers of functions in this library will receive an AkamaiRequest
 * as a return parameter which they should use as an argument to
 * Akamai's `httpRequest` method.
 */
export type AkamaiRequest = {
  host: string;
  url: string; // Note: relative url to the host!
  method: string;
  headers: EW.Headers;
  body: RequestBody | undefined;
  [others: string]: any;
};

// Spec Proxy configuration object
export interface SpecConfiguration {
  /**
   * When true, disable Spec Proxy, this library and all functionality is disabled
   */
  disableSpecProxy?: boolean;
  /**
   * When true, the request returned by this function is modified
   * to make a request to Spec Proxy, which will result in Spec Proxy making
   * the request to the customer origin itself
   */
  inlineMode?: boolean;
  /**
   * A number between 0 and 100 that identifies the percentage of IP traffic
   * the SpecTrust platform should process.
   */
  percentageOfIPs?: number;
}

/**
 * This function is responsible for processing the `ResponseProviderRequest`
 * that the Akamai API provides clients during the
 * [Response Provider Event](https://techdocs.akamai.com/edgeworkers/docs/response-orchestration).
 *
 * Clients of this library need only create a `SpecConfiguration` object and pass
 * the arguments of the `responseProvider` function into this method along with
 * your configuration object.
 *
 * ```javascript
 * import {
 *   specProxyProcess,
 * } from '@specprotected/spec-proxy-akamai-worker';
 *
 * const specConfig = { inlineMode: false };
 *
 * export async function responseProvider(request: EW.ResponseProviderRequest) {
 *   return await specProxyProcess(request, specConfig);
 * }
 * ```
 *
 * @param request - the incoming ResponseProviderRequest object
 * @param config - configuration object to adjust Spec Proxy behavior
 * @returns - response created from calling createResponse, akamai doesn't provide a type for this value
 */
export async function specProxyProcess(
  request: EW.ResponseProviderRequest,
  config: SpecConfiguration
): Promise<HttpResponse> {
  let specRequest = await specProxyProcessRequest(request, config);
  return await httpRequest(specRequest.url, specRequest);
}

/**
 * This function is responsible for processing the `ResponseProviderRequest`
 * that the Akamai API provides clients during the
 * [Response Provider Event](https://techdocs.akamai.com/edgeworkers/docs/response-orchestration).
 *
 * Clients of this library need only create a `SpecConfiguration` object and pass
 * the arguments of the `responseProvider` function into this method along with
 * your configuration object.
 *
 * Due to the nature of how some of our features work, you should use our
 * returned `AkamaiRequest` type instead of the `ResponseProviderRequest`
 * you pass in as an argument. For bodies that are streamed, we attach a
 * consumer to the stream to split it so we don't incur any performance
 * penalties when redirecting requests. This is a custom type from this library,
 * but should be able to pass any Akamai properties through to `httpRequest`
 * calls.
 *
 *
 * ```javascript
 * import {
 *   specProxyProcessRequest,
 * } from '@specprotected/spec-proxy-akamai-worker';
 *
 * const specConfig = { inlineMode: false };
 *
 * export async function responseProvider(request: EW.ResponseProviderRequest) {
 *   let request: AkamaiRequest = await spec.specProxyProcessRequest(request, specConfig);
 *   let response = await httpRequest(request);
 *   return createResponse(response.body, response);
 * }
 * ```
 *
 * @param ogRequest - the incoming ResponseProviderRequest object
 * @param config - configuration object to adjust Spec Proxy behavior
 * @returns - the modified Request object, in this library's AkamaiRequest type
 */
export async function specProxyProcessRequest(
  ogRequest: EW.ResponseProviderRequest,
  config: SpecConfiguration = {}
): Promise<AkamaiRequest> {
  let request: AkamaiRequest = {
    headers: ogRequest.getHeaders(),
    ...ogRequest,
  };

  // first check to see if we shouldn't process anything at all
  if (config.disableSpecProxy) {
    return request;
  }

  // check if we should handle this http request
  if (!shouldHandleRequest(request.headers, config)) {
    return request;
  }

  // write to spec proxy using its hostname
  let specUrl = `https://${request.host}.spec-internal.com${request.url}`;

  // use this flag to signal that Spec Proxy is responsible for forwarding the
  // request on to the customer's servers.
  if (config.inlineMode) {
    request.headers[SPEC_HEADER_FORWARD_ORIGIN] = [request.host];
  } else {
    // clone the ReadableStream, if it exists.
    let body: RequestBody | undefined = undefined;
    if (isStream(request.body)) {
      [request.body, body] = request.body.tee();
    } else {
      body = request.body;
    }

    // build a request with the new url and the new ReadableStream
    const specRequest: AkamaiRequest = {
      ...request,
      // note: not the relative url that Akamai provides originally
      // as we're sending it to our proxy
      url: specUrl,
      body,
    };

    // Akamai doesn't provide a way for us to specify that we would like to
    // await this future in the background...it will abort it if we don't await
    await httpRequest(specRequest.url, specRequest);
  }

  // and give a request, either original or modified, back to the caller
  // so they can send it on
  return request;
}

/**
 * Function to isolate the ReadableStream type in the possible request
 * arguments. This type of body requires special handling.
 *
 * @param body the RequestBody from Akamai
 * @returns boolean and cast to EW.ReadableStreamEW type
 */
function isStream(body: RequestBody | undefined): body is EW.ReadableStreamEW {
  return body !== undefined && (body as EW.ReadableStreamEW).tee !== undefined;
}

/**
 * Process the Response as it returns to the originator of the Request. This
 * function generally adds any details the SpecTrust platform requires to identify
 * site visitors, such as the Spec Cookie.
 *
 * Note: this function does not process the Body of a Response, so won't require
 * awaiting while reading the body stream, which enables efficient processing.
 *
 * ```javascript
 * import {
 *   specProxyProcessRequest,
 *   specProxyProcessResponse,
 * } from '@specprotected/spec-proxy-akamai-worker';
 *
 * const specConfig = { inlineMode: false };
 *
 * export async function responseProvider(request: EW.ResponseProviderRequest) {
 *   let request: AkamaiRequest = await spec.specProxyProcessRequest(request, specConfig);
 *   let response = spec.specProxyProcessResponse(request, await httpRequest(request));
 *   return createResponse(response.body, response);
 * }
 * ```
 *
 * @param request - the request object that was sent to customer servers
 * @param response - the response object that was returned from customer servers
 * @param config - the configuration object that defines how this library should behave
 * @returns - the modified response object
 */
export function specProxyProcessResponse(
  request: EW.ResponseProviderRequest,
  response: HttpResponse,
  config: SpecConfiguration = {}
): HttpResponse {
  if (
    config.disableSpecProxy ||
    config.inlineMode === true ||
    !shouldHandleRequest(request.getHeaders(), config)
  ) {
    return response;
  }

  let cookies = new Cookies.Cookies(request.getHeader(HEADER_COOKIE) || "");

  // Note: falsy check because we set our cookie on undefined or "" values
  if (!cookies?.get(SPEC_COOKIE_ID)) {
    let specId = randomString(64);
    let setCookie = new Cookies.SetCookie({
      name: SPEC_COOKIE_ID,
      value: specId,
      // Note: 10 years long, essentially a "very long time"
      maxAge: 320000000,
      // the || will turn the (empty string | null) into undefined
      domain: extractTopLevelDomain(request.getHeaders()) || undefined,
      // valid for all paths
      path: "/",
    });

    let headers = response.getHeaders();
    let setCookies = headers[HEADER_SET_COOKIE] ?? [];
    setCookies.push(setCookie.toHeader());
    headers[HEADER_SET_COOKIE] = setCookies;
  }

  return response;
}

/**
 * Function that determines if Spec Proxy should handle the incoming request.
 * This involves observing the configuration object and resolving whether or not
 * we should process this Request under the given configuration values.
 *
 * @param headers - Header map from the originating request
 * @param config - configuration object to control Spec Proxy behavior
 * @returns - true if we should process the request
 */
function shouldHandleRequest(
  headers: EW.Headers,
  config: SpecConfiguration
): boolean {
  // if we're not filtering out a percentage of IPs, or the filter is 100%
  // we should always handle traffic.
  if (config.percentageOfIPs === undefined || config.percentageOfIPs >= 100) {
    return true;
  }
  // early abort if it's impossible to match
  else if (config.percentageOfIPs <= 0) {
    return false;
  }

  // split up the ip address into octets, convert them to integers, and then sum them.
  // default the string to 99 so if, for some reason, there's a problem the traffic
  // doesn't go through unless it's at 100%. Note: 99 because there's 100 numbers in
  // [0, 99]!
  let ip_octet_sum = (headers[HEADER_TRUE_CLIENT_IP] ?? ["99"])[0]
    .split(".")
    .map((octet) => parseInt(octet))
    .reduce((acc, n) => acc + n);

  // if we don't know what number this is...don't assume anything
  if (isNaN(ip_octet_sum)) {
    ip_octet_sum = 99;
  }

  // not `<=` because it's a percentage, e.g. "allow 1%" would allow
  // IP octect sums that result in `0`, which is 1 slice in the range [0, 99]
  return ip_octet_sum % 100 < config.percentageOfIPs;
}

/**
 * Extract the top-level (apex) domain from the Host header.
 * This will exclude the `.spec-internal.com` domain if it is present.
 * In the event that we do not match on the Host header for any reason,
 * the value of the header itself is returned.
 *
 * @param headers - Header map from the originating request
 * @returns - top-level domain if the Host header was present, otherwise null
 */
function extractTopLevelDomain(headers: EW.Headers): string | null {
  if (HEADER_HOST in headers && headers[HEADER_HOST]) {
    // truthy check is non-empty array
    let host = headers[HEADER_HOST][0];

    // regex attempts to match as much as it can, lazily, then a sequence of non-"." characters,
    // a ".", then more non-"." to comprise the apex domain. if .spec-internal.com is present,
    // the final group will attempt to match it, removing it from the apex domain.
    const domain_extract =
      /^(.*?\.)?([^.]+\.(com|org|edu|net|int|gov|mil|uk|co\.uk|ac\.uk|gov\.uk|ltd\.uk|me\.uk|net\.uk|nhs\.uk|org\.uk|plc\.uk|police\.uk))(\.spec-internal\.com)?$/;
    let matches = domain_extract.exec(host);
    if (matches) {
      host = matches[2];
    }

    return host;
  }

  return null;
}

/**
 * Akamai EdgeWorkers are on es6 meaning they don't have the nice
 * crypto.randomUUID(). Instead we'll generate a random string instead of
 * including the library `uuid` so we can keep file size down, since there
 * are limits on that for EdgeWorkers.
 *
 * @param n - length of string to generate
 * @returns - a string of length n
 */
function randomString(n: number): string {
  let s = "";
  while (n > 0) {
    let gen = Math.random()
      .toString(36)
      .slice(2, 2 + n);
    s += gen;
    n -= gen.length;
  }
  return s;
}
