/*
 * 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 { parse as parseCookies, serialize as serializeSetCookie } from "cookie";

// path prefix to always route traffic to Spec Proxy
const SPEC_PATH_PREFIX = "/spec_traffic";
// 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";
// cookie for the mirror mode key
const SPEC_HEADER_CUSTOMER_KEY = "x-spec-customer-authorization";
// 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_X_FORWARDED_FOR = "x-forwarded-for";
// header that indicates successful processing by the Spec platform
const HEADER_SPEC_ACTIVITY = "x-atvak-activity-count";

const SPEC_MIRROR_HEADER_REQ_CONTENT_LENGTH = "x-spec-req-prop-content-length";
const SPEC_MIRROR_HEADER_REQ_METHOD = "x-spec-req-prop-method";
const SPEC_MIRROR_HEADER_REQ_URI = "x-spec-req-prop-uri";
const SPEC_MIRROR_HEADER_RESP_STATUS = "x-spec-resp-prop-status";
const SPEC_MIRROR_HEADER_RESP_PREFIX = "x-spec-resp-head";

// Track the type of request that we're processing, allows libraries
// to handle requests differently in different contexts.
// Such as fastly cache overrides.
export enum RequestType {
  Inline,
  Origin,
  Mirrored,
}

// Note: type RequestInfo = string | Request;
export type PlatformRequest = new (
  url: RequestInfo,
  info?: RequestInit,
  original?: Request,
  config?: SpecConfiguration,
  requestType?: RequestType,
) => Request;

// 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 Spec platform should process.
   */
  percentageOfIPs?: number;
  /**
   * An key provided by spec, which validates traffic as originating from
   * the customer when in mirror mode.
   */
  customerKey?: string;
  /**
   * Disables routing traffic prefixed with /spec_traffic to Spec
   */
  disableSpecTraffic?: boolean;

  /**
   * A prefix to add to the `.spec-internal.com` suffix this library adds to
   * the incoming request's hostname.
   * ex: domainOverride: "prefix" -> "prefix.spec-internal.com"
   */
  domainOverride?: string;

  /**
   * When not in inline mode, HTTP exchanges (a request/response pair)
   * are is mirrored to Spec. By defualt, to save network bandwidth,
   * the exchange data is sent in a single, unified request. However,
   * certain use-cases may require request and response data to be
   * sent individually, at the time the request or response is
   * received.
   */
  splitExchangeMirroring?: boolean;

  /**
   * Enable debug logging.
   */
  debug?: boolean;

  localDev?: boolean;
}

/**
 * Common interface for extra-request context needed for request/response
 * processing. This interface is satisfied by the `event` object in
 * service worker modules, or can be constructed from the request and
 * the Context in ES Modules workers.
 *
 * Individual libraries (e.g. the Cloudflare worker library) provide
 * helper methods for construction. If constructing manually, be sure
 * to bind `.waitUntil` to the appropriate `this`, for example:
 *
 * ```ts
 * export function makeContext(
 *   request: Request,
 *   ctx: ExecutionContext,
 * ): spec.ExecutionContext {
 *   return {
 *     request,
 *     waitUntil: ctx.waitUntil.bind(ctx),
 *   }
 * }
 * ```
 */
export interface ExecutionContext {
  request: Request;
  waitUntil(f: Promise<any>): void;
}

/**
 * This is one of two lower-level entrypoints into the Spec Proxy framework.
 * This function takes the Service Worker `event` object that's provided as an
 * argument to the 'fetch' event and a configuration object that describes how
 * the library should behave.
 *
 * Clients of this library should use the returned request as if it were the request
 * originally provided by your Edge Worker.
 *
 * The returned request should always be processed with `processSpecProxyResponse`
 * before being returned.
 *
 * Use this lower-level API if you need to do additional work between the
 * request and response, such as integrate additional libraries in your worker
 * function.
 *
 * ```javascript
 * import {
 *   specProxyProcessRequest,
 *   specProxyProcessResponse,
 * } from '@specprotected/spec-proxy-service-worker';
 *
 * addEventListener('fetch', event => {
 *   // if we don't catch the exception, fail open to original traffic path
 *   event.passThroughOnException();
 *
 *   event.respondWith(handleEvent(event));
 * })
 *
 * const specConfig = { inlineMode: false };
 *
 * async function handleEvent(event) {
 *   let request = specProxyProcessRequest(event, specConfig);
 *
 *   let response = await fetch(request.request);
 *
 *   // do more work, if required
 *
 *   // call the Spec Proxy response method to enact potential response changes
 *   return specProxyProcessResponse(event, request, response, specConfig);
 * }
 * ```
 *
 * @param event - an `ExecutionContext`
 * @param config - configuration object to adjust Spec Proxy behavior
 * @returns the modified Request object
 */
export function specProxyProcessRequest(
  event: ExecutionContext,
  config: SpecConfiguration = {},
  requestConstructor: PlatformRequest = Request,
): RequestData {
  const originalUrl = new URL(event.request.url);

  // first check to see if we shouldn't process anything at all
  if (
    config.disableSpecProxy ||
    !shouldHandleRequest(
      new URL(event.request.url),
      event.request.headers,
      config,
    )
  ) {
    return {
      specUrl: originalUrl,
      originalUrl,
      request: event.request,
      requestBodyForResponseProcessing: null,
    };
  }

  if (config.debug) {
    console.log("specProxyProcessRequest: enabled");
  }

  const request = event.request;

  const specUrl = makeSpecUrl(originalUrl, config, MessageType.Request);

  if (config.debug) {
    console.log(`specProxyProcessRequest: specUrl: ${specUrl}`);
  }

  const specHeaders = new Headers(request.headers);
  setCommonSpecHeaders(specHeaders, config);

  // use this flag to signal that Spec Proxy is responsible for forwarding the
  // request on to the customer's servers.
  if (
    config.inlineMode ||
    // or if we're sending some traffic to /spec_traffic
    (!config.disableSpecTraffic && isSpecTraffic(originalUrl))
  ) {
    if (config.debug) {
      console.log(`specProxyProcessRequest: inline`);
    }

    setForwardingSpecHeaders(specHeaders, originalUrl, config);
    // Note: /spec_traffic ignores forwarding directives
    // specHeaders.set(SPEC_HEADER_FORWARD_ORIGIN, originalHost);
    return {
      specUrl,
      originalUrl,
      requestBodyForResponseProcessing: null,
      request: new requestConstructor(
        specUrl.toString(),
        {
          body: request.body,
          headers: specHeaders,
          method: request.method,
          redirect: request.redirect,
        },
        request,
        config,
        RequestType.Inline,
      ),
    };
  } else {
    // Otherwise, we'll mirror the request.
    if (config.debug) {
      console.log(`specProxyProcessRequest: mirror`);
    }

    // If we are mirroring AND sending split exchange data, go ahead and
    // send the mirror request.
    let requestBody = request.body;
    if (config.splitExchangeMirroring) {
      if (config.debug) {
        console.log(`specProxyProcessRequest: split mirroring`);
      }
      const [specRequest, teedBody] = makeMirrorRequestSplit(
        specUrl,
        specHeaders,
        request,
        requestConstructor,
        config,
      );

      if (teedBody) {
        requestBody = teedBody;
      }

      if (config.debug) {
        console.log(
          `specProxyProcessRequest: sending split mirror request: ${specRequest.url}`,
        );
      }

      // background the request to spec proxy, but ask service worker to wait
      // until it's finished even though we don't await it
      event.waitUntil(fetch(specRequest));
    }

    // If the requestBody is either:
    // - the original request's body stream (if the request has a body,
    //   we are mirroring, and exchangeMirroring is 'split')
    // - the teed body stream (if the request has a body, we are
    //   mirroring, and exchangeMirroring is 'unified')
    //
    // Tee it, using one for the requestBody and another for response
    // processing.
    //
    // For split processing, this will be a tee of a tee, meaning we
    // have three copies of the request stream:
    // - Original request (`requestBody`)
    // - Mirrored request (sent off with `specRequest`)
    // - Response processing (`requestBodyForResponseProcessing`)
    //
    // For unified processing, there will just be two copies:
    // - Original request (`requestBody`)
    // - Response processing (`requestBodyForResponseProcessing`)
    let requestBodyForResponseProcessing = null;
    if (requestBody) {
      [requestBody, requestBodyForResponseProcessing] = requestBody.tee();
    }

    // create the new request with the adjusted host if redirectHeader is set
    // and use the new teed ReadableStream body
    return {
      specUrl,
      originalUrl,
      requestBodyForResponseProcessing,
      request: new requestConstructor(
        request.url,
        {
          body: requestBody,
          headers: request.headers,
          method: request.method,
          redirect: request.redirect,
        },
        request,
        config,
        RequestType.Origin,
      ),
    };
  }
}

/**
 * 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.
 *
 * @param event - worker ExecutionContext
 * @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(
  event: ExecutionContext,
  req: RequestData,
  response: Response,
  config: SpecConfiguration = {},
  requestConstructor: PlatformRequest = Request,
): Response {
  const request = req.request;
  const url = new URL(request.url);

  if (
    config.disableSpecProxy ||
    // in inline mode, we expect the addition of cookies to be done at the proxy layer
    config.inlineMode === true ||
    isSpecTraffic(url) ||
    !shouldHandleRequest(url, request.headers, config)
  ) {
    return response;
  }

  if (config.debug) {
    console.log(`specProxyProcessResponse: enabled`);
  }

  let cookies: Record<string, string | undefined> = {};
  if (request.headers) {
    cookies = parseCookies(request.headers.get(HEADER_COOKIE) || "");
  }

  // Note: falsy check because we set our cookie on undefined or "" values
  if (!cookies[SPEC_COOKIE_ID]) {
    // the || will turn the (empty string | null) into undefined
    let domain = extractTopLevelDomain(request.headers) || undefined;

    // domain can't have a port in it, so if we're running locally,
    // where having a port in your host is useful, split it off.
    if (config.localDev && domain?.startsWith("localhost:")) {
      domain = domain.split(":")[0];
    }

    let specId = crypto.randomUUID();
    let setCookie = serializeSetCookie(SPEC_COOKIE_ID, specId, {
      // Note: 10 years long, essentially a "very long time"
      maxAge: 320000000,
      domain,
      path: "/",
      sameSite: "none",
      secure: true,
    });

    // reconstruct Response to avoid immutability
    response = new Response(response.body, response);
    response.headers.append(HEADER_SET_COOKIE, setCookie);
  }

  const [mirrorRequest, responseBody] = makeMirrorExchangeRequest(
    req,
    response,
    config,
    requestConstructor,
  );

  if (config.debug) {
    console.log(
      `specProxyProcessResponse: mirror exchange request: ` +
        `${mirrorRequest.url}: ${mirrorRequest.headers.get(SPEC_MIRROR_HEADER_REQ_URI)}` +
        `: ${mirrorRequest.headers.get("host")}`,
    );
  }

  // background the request to spec proxy, but ask service worker to wait
  // until it's finished even though we don't await it
  event.waitUntil(
    fetch(mirrorRequest).then((resp) => {
      if (config.debug) {
        console.log(
          `specProxyProcessResponse: response from mirror server: ${resp.status}: ${resp.statusText}`,
        );
      }
    }),
  );

  response = new Response(responseBody, response);

  return response;
}

/**
 * This is the simplest method in the Spec Proxy Service Worker library.
 * If the edge worker you're writing only requires integration Spec Proxy and
 * no other additional libraries, this is the easiest way to use Spec Proxy.
 *
 * It only requires one method, so we'll show an example:
 *
 * ```javascript
 * import { specProxyProcess } from '@spectrust/server-worker-proxy';
 *
 * addEventListener('fetch', event => {
 *   // if we don't catch the exception, fail open to original traffic path
 *   event.passThroughOnException();
 *
 *   event.respondWith(specProxyProcess(event, {
 *     // set to false to mirror traffic to Spec Proxy instead of redirecting it
 *     inlineMode: true
 *   }));
 * })
 * ```
 *
 * @param event - the worker's ExecutionContext
 * @param config - configuration object to control Spec Proxy behavior
 * @returns - a Promise to resolve to the modified response from the customer server
 */
export async function specProxyProcess(
  event: ExecutionContext,
  config: SpecConfiguration,
  requestConstructor: PlatformRequest,
): Promise<Response> {
  let request = specProxyProcessRequest(event, config, requestConstructor);
  if (config.debug) {
    console.log(
      `specProxyProcess: sending request to downstream: ${request.request.url}`,
    );
  }
  let response = await specMakeRequestWithFallback(
    event,
    request.request,
    config,
    requestConstructor,
  );
  return specProxyProcessResponse(event, request, response, config);
}

/**
 * Makes a request with intelligent fallback behavior based on the configuration mode.
 * This function is responsible for handling the actual network request to either the
 * Spec Proxy service or directly to the customer origin, depending on the response
 * from the initial request and the configured mode.
 *
 * In inline mode, this function first attempts to make a request through the Spec Proxy
 * service. If the response contains the "x-activity-count" header, it indicates
 * successful processing by the Spec platform, and the response is returned after
 * removing the internal header. If this header is not present, it falls back to
 * making a direct request to the customer origin using the original request from
 * the FetchEvent.
 *
 * In mirror mode, there is no fallback mechanism since the request body has been teed
 * (duplicated) and the original request cannot be safely reused. The function simply
 * makes the request as provided.
 *
 * @param event - The FetchEvent originating from the Fetch API, used for fallback in inline mode
 * @param request - The potentially modified Request object to be sent to Spec Proxy
 * @param config - Configuration object that controls Spec Proxy behavior
 * @returns - A Promise that resolves to the Response from either Spec Proxy or the customer origin
 */
export async function specMakeRequestWithFallback(
  event: ExecutionContext,
  request: Request,
  config: SpecConfiguration,
  requestConstructor: PlatformRequest,
): Promise<Response> {
  if (config.inlineMode) {
    let [initialBody, fallbackBody]: [
      ReadableStream | null,
      ReadableStream | null,
    ] = [null, null];
    if (event.request.body) {
      [initialBody, fallbackBody] = event.request.body.tee();
    }

    let response = await fetch(
      new requestConstructor(request, {
        body: initialBody,
      }),
    );

    // This is the correct case, we received a header that the Spec platform sets.
    if (response.headers.get(HEADER_SPEC_ACTIVITY)) {
      fallbackBody?.cancel();
      let newResponse = new Response(response.body, response);
      newResponse.headers.delete(HEADER_SPEC_ACTIVITY);
      return newResponse;
    }

    if (config.debug) {
      console.log("specMakeReqeustWithFallback: making fallback request");
    }

    // This is the fallback case, we did not receive a header that the Spec platform sets.
    // Note that this is safe because in inline mode, we don't tee the body, so we can
    // use the request on the event directly.
    return await fetch(
      new requestConstructor(event.request, { body: fallbackBody }),
    );
  } else {
    // In mirror mode, the request "is the original" except for the teed body,
    // so there's no way to fallback.
    return await fetch(request);
  }
}

/**
 * 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(
  url: URL,
  headers: Headers,
  config: SpecConfiguration,
): boolean {
  // If we're directing /spec_traffic to Spec, we should always do that!
  if (!config.disableSpecTraffic && url.pathname.startsWith(SPEC_PATH_PREFIX)) {
    return true;
  }

  // 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.get(HEADER_X_FORWARDED_FOR) || "99")
    .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: Headers): string | null {
  let host = headers.get(HEADER_HOST);
  if (host) {
    // 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 =
      /^(.*?\.)?(?<domain>[^.]+\.(com|co|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?.groups?.domain) {
      host = matches.groups.domain;
    }
  }

  return host;
}

enum MessageType {
  Request,
  Response,
}

function makeSpecUrl(
  originalUrl: URL,
  config: SpecConfiguration,
  messageType: MessageType,
): URL {
  // Note: this was just `.host` before, but for local development sometimes
  // ports will be included, using hostname avoids setting overwriting port values
  const originalHost = originalUrl.hostname;
  const specUrl = new URL(originalUrl);

  specUrl.protocol = config.localDev ? "http" : "https";

  // write to spec proxy
  specUrl.hostname = config.localDev
    ? "localhost"
    : specUrl.hostname.endsWith(".spec-internal.com")
      ? specUrl.hostname
      : config.domainOverride
        ? `${config.domainOverride}.spec-internal.com`
        : `${originalHost}.spec-internal.com`;

  if (config.localDev) {
    specUrl.port = "5000";
  }

  setSpecUrlPath(specUrl, config, messageType);

  return specUrl;
}

function setSpecUrlPath(
  specUrl: URL,
  config: SpecConfiguration,
  messageType: MessageType,
): void {
  // For inline mode or for spec_traffic endpoints, we forward
  // directly to the proxy.
  if (config.inlineMode || isSpecTraffic(specUrl)) {
    return;
  }

  // For mirror mode, we send traffic to a dedicated mirror endpoint.
  if (!config.splitExchangeMirroring) {
    specUrl.pathname = "/speclayer/api/mirror/exchanges";
  } else if (messageType === MessageType.Request) {
    specUrl.pathname = "/speclayer/api/mirror/requests";
  } else if (messageType === MessageType.Response) {
    specUrl.pathname = "/speclayer/api/mirror/responses";
  }

  // We retain the path and query string in headers, and do not need
  // to send them to the Spec endpoint.
  specUrl.search = "";
}

function isSpecTraffic(url: URL): boolean {
  return url.pathname.startsWith(SPEC_PATH_PREFIX);
}

function setCommonSpecHeaders(
  specHeaders: Headers,
  config: SpecConfiguration,
): void {
  // mark a timestamp on the header so we can tell when the request
  // was sent from the background in the CDN worker
  specHeaders.set("x-spec-origination-ts", new Date().toISOString());

  if (config.customerKey) {
    specHeaders.set(SPEC_HEADER_CUSTOMER_KEY, config.customerKey);
  }
}

function setForwardingSpecHeaders(
  specHeaders: Headers,
  originalUrl: URL,
  config: SpecConfiguration,
): void {
  if (config.localDev && originalUrl.hostname === "localhost") {
    specHeaders.set(SPEC_HEADER_FORWARD_ORIGIN, `http://${originalUrl.host}`);
  } else {
    specHeaders.set(SPEC_HEADER_FORWARD_ORIGIN, originalUrl.hostname);
  }
}

function setMirrorRequestHeaders(
  specHeaders: Headers,
  originalRequest: Request,
): void {
  const contentLength = originalRequest.headers.get("content-length");
  if (contentLength) {
    specHeaders.set(SPEC_MIRROR_HEADER_REQ_CONTENT_LENGTH, contentLength);
  }

  specHeaders.set(SPEC_MIRROR_HEADER_REQ_METHOD, originalRequest.method);
  specHeaders.set(SPEC_MIRROR_HEADER_REQ_URI, originalRequest.url);
}

function setMirrorResponseHeaders(
  specHeaders: Headers,
  originalResponse: Response,
): void {
  specHeaders.set(
    SPEC_MIRROR_HEADER_RESP_STATUS,
    originalResponse.status.toString(),
  );
  // Remove any existing headers that start with our special prefix,
  // then convert response headers to new headers on the outgoing mirror
  // request.
  for (const key of specHeaders.keys()) {
    if (key.startsWith(SPEC_MIRROR_HEADER_RESP_PREFIX)) {
      specHeaders.delete(key);
    }
  }
  originalResponse.headers.forEach((val, name, _headers) => {
    specHeaders.append(`${SPEC_MIRROR_HEADER_RESP_PREFIX}-${name}`, val);
  });
}

function makeMirrorRequestSplit(
  specUrl: URL,
  specHeaders: Headers,
  originalRequest: Request,
  requestConstructor: PlatformRequest,
  config: SpecConfiguration,
): [Request, ReadableStream | null] {
  setMirrorRequestHeaders(specHeaders, originalRequest);

  // We will register this request promise to be awaited in the
  // background by the Service Worker API and in order to do that
  // without reading the body, we need to duplicate the body stream.
  //
  // clone the ReadableStream, if it exists.
  let teedBody: [ReadableStream | null, ReadableStream | null] = [null, null];
  if (originalRequest.body !== null && originalRequest.body !== undefined) {
    teedBody = originalRequest.body.tee();
  }

  // build a request with the new url and the new ReadableStream
  const specRequest = new requestConstructor(
    specUrl.toString(),
    {
      body: teedBody[0],
      headers: specHeaders,
      method: "POST",
      redirect: originalRequest.redirect,
    },
    originalRequest,
    config,
    RequestType.Mirrored,
  );

  return [specRequest, teedBody[1]];
}

function makeMirrorExchangeRequest(
  req: RequestData,
  originalResponse: Response,
  config: SpecConfiguration,
  requestConstructor: PlatformRequest,
): [Request, ReadableStream | null] {
  const originalRequest = req.request;
  const specHeaders = new Headers(originalRequest.headers);

  setCommonSpecHeaders(specHeaders, config);
  setMirrorRequestHeaders(specHeaders, originalRequest);
  setMirrorResponseHeaders(specHeaders, originalResponse);

  const [originalResponseBody, requestBody] = makeMirrorExchangeBodyUnified(
    req,
    originalResponse,
  );

  const specUrl = makeSpecUrl(req.originalUrl, config, MessageType.Response);

  return [
    new requestConstructor(
      specUrl.toString(),
      {
        body: requestBody,
        headers: specHeaders,
        method: "POST",
      },
      originalRequest,
      config,
      RequestType.Mirrored,
    ),
    originalResponseBody,
  ];
}

const DIVIDER = new Blob([
  "-------SPEC-DIVIDER------- Wir betreten feuertrunken Himmlische, dein Heiligtum -------SPEC-DIVIDER-------\n",
]);

function makeMirrorExchangeBodyUnified(
  req: RequestData,
  originalResponse: Response,
): [ReadableStream | null, ReadableStream] {
  const [originalResponseBody, teedResponseBody] = originalResponse.body
    ? originalResponse.body.tee()
    : [originalResponse.body, null];

  const components: Array<ReadableStream> = [
    req.requestBodyForResponseProcessing || new Blob([]).stream(),
    DIVIDER.stream(),
    teedResponseBody || new Blob([]).stream(),
  ];

  const { readable: mirrorExchangeStream, writable } = new TransformStream();

  components.reduce(
    (acc, i, idx, arr) =>
      acc.then(() =>
        i.pipeTo(writable, { preventClose: idx + 1 !== arr.length }),
      ),
    Promise.resolve(),
  );

  return [originalResponseBody, mirrorExchangeStream];
}

export type RequestData = {
  specUrl: URL;
  originalUrl: URL;
  request: Request;
  requestBodyForResponseProcessing: ReadableStream | null;
};
