import { parallel } from "@serwist/utils";
import { defaultMethod, type HTTPMethod } from "./constants.js";
import { disableDevLogs as disableDevLogsImpl } from "./disableDevLogs.js";
import { type GoogleAnalyticsInitializeOptions, initializeGoogleAnalytics } from "./lib/googleAnalytics/initializeGoogleAnalytics.js";
import { type PrecacheFallbackEntry, PrecacheFallbackPlugin } from "./lib/precaching/PrecacheFallbackPlugin.js";
import { PrecacheStrategy } from "./lib/strategies/PrecacheStrategy.js";
import { Strategy } from "./lib/strategies/Strategy.js";
import { NavigationRoute } from "./NavigationRoute.js";
import { enableNavigationPreload } from "./navigationPreload.js";
import { PrecacheRoute } from "./PrecacheRoute.js";
import type { Route } from "./Route.js";
import { setCacheNameDetails } from "./setCacheNameDetails.js";
import type {
  CleanupResult,
  InstallEvent,
  InstallResult,
  PrecacheEntry,
  PrecacheOptions,
  RequestRule,
  RouteHandler,
  RouteHandlerCallback,
  RouteHandlerCallbackOptions,
  RouteHandlerObject,
  RouteMatchCallback,
  RouteMatchCallbackOptions,
  RuntimeCaching,
} from "./types.js";
import { assert } from "./utils/assert.js";
import { cleanupOutdatedCaches as cleanupOutdatedCachesImpl } from "./utils/cleanupOutdatedCaches.js";
import { clientsClaim as clientsClaimImpl } from "./utils/clientsClaim.js";
import { createCacheKey } from "./utils/createCacheKey.js";
import { getFriendlyURL } from "./utils/getFriendlyURL.js";
import { logger } from "./utils/logger.js";
import { normalizeHandler } from "./utils/normalizeHandler.js";
import { PrecacheInstallReportPlugin } from "./utils/PrecacheInstallReportPlugin.js";
import { parsePrecacheOptions } from "./utils/parsePrecacheOptions.js";
import { parseRoute } from "./utils/parseRoute.js";
import { printCleanupDetails } from "./utils/printCleanupDetails.js";
import { printInstallDetails } from "./utils/printInstallDetails.js";
import { SerwistError } from "./utils/SerwistError.js";
import { waitUntil } from "./utils/waitUntil.js";

declare const self: ServiceWorkerGlobalScope;

export interface FallbackEntry extends PrecacheFallbackEntry {}

export interface FallbacksOptions {
  /**
   * A list of fallback entries.
   */
  entries: FallbackEntry[];
}

export interface SerwistOptions {
  /**
   * A list of URLs that should be cached.
   */
  precacheEntries?: (PrecacheEntry | string)[];
  /**
   * Options to customize how Serwist precaches the URLs in the precache list.
   */
  precacheOptions?: PrecacheOptions;
  /**
   * Forces the waiting service worker to become the active one.
   *
   * @see https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/skipWaiting
   */
  skipWaiting?: boolean;
  /**
   * Imports external scripts. They are executed in the order they
   * are passed.
   *
   * @see https://developer.mozilla.org/en-US/docs/Web/API/WorkerGlobalScope/importScripts
   */
  importScripts?: string[];
  /**
   * Enables navigation preloading if it is supported.
   *
   * @see https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/navigationPreload
   */
  navigationPreload?: boolean;
  /**
   * Modifies the prefix of the default cache names used by Serwist packages.
   */
  cacheId?: string | undefined;
  /**
   * Claims any currently available clients once the service worker
   * becomes active. This is normally used in conjunction with `skipWaiting()`.
   *
   * @default false
   */
  clientsClaim?: boolean;
  /**
   * A list of caching strategies.
   */
  runtimeCaching?: RuntimeCaching[];
  /**
   * Request rules that define how certain resources should be fetched
   * before the service worker starts up.
   *
   * @see https://developer.mozilla.org/en-US/docs/Web/API/InstallEvent/addRoutes
   * @experimental
   */
  requestRules?: RequestRule | RequestRule[];
  /**
   * Your configuration for {@linkcode initializeGoogleAnalytics}. This plugin is
   * only initialized when this option is not `undefined` or `false`.
   */
  offlineAnalyticsConfig?: Omit<GoogleAnalyticsInitializeOptions, "serwist"> | boolean;
  /**
   * Disables Serwist's logging in development mode.
   *
   * @default false
   */
  disableDevLogs?: boolean;
  /**
   * Precaches routes so that they can be used as a fallback when
   * a {@linkcode Strategy} fails to generate a response.
   *
   * Note: This option mutates `runtimeCaching`. It also expects the URLs
   * defined in `entries` to have been precached beforehand.
   */
  fallbacks?: FallbacksOptions;
}

type RequestArgs = string | [string, RequestInit?];

interface CacheURLsMessageData {
  type: string;
  payload: {
    urlsToCache: RequestArgs[];
  };
}

/**
 * A class that helps bootstrap the service worker.
 *
 * @see https://serwist.pages.dev/docs/serwist/core/serwist
 */
export class Serwist {
  private readonly _urlsToCacheKeys: Map<string, string> = new Map();
  private readonly _urlsToCacheModes: Map<string, "reload" | "default" | "no-store" | "no-cache" | "force-cache" | "only-if-cached"> = new Map();
  private readonly _cacheKeysToIntegrities: Map<string, string> = new Map();
  private _concurrentPrecaching: number;
  private readonly _precacheStrategy: Strategy;
  private readonly _routes: Map<HTTPMethod, Route[]>;
  private readonly _defaultHandlerMap: Map<HTTPMethod, RouteHandlerObject>;
  private _catchHandler?: RouteHandlerObject;
  private _requestRules?: RequestRule | RequestRule[];

  constructor({
    precacheEntries,
    precacheOptions,
    skipWaiting = false,
    importScripts,
    navigationPreload = false,
    cacheId,
    clientsClaim = false,
    runtimeCaching,
    offlineAnalyticsConfig,
    disableDevLogs = false,
    fallbacks,
    requestRules,
  }: SerwistOptions = {}) {
    const { precacheStrategyOptions, precacheRouteOptions, precacheMiscOptions } = parsePrecacheOptions(this, precacheOptions);

    this._concurrentPrecaching = precacheMiscOptions.concurrency;
    this._precacheStrategy = new PrecacheStrategy(precacheStrategyOptions);
    this._routes = new Map();
    this._defaultHandlerMap = new Map();
    this._requestRules = requestRules;

    this.handleInstall = this.handleInstall.bind(this);
    this.handleActivate = this.handleActivate.bind(this);
    this.handleFetch = this.handleFetch.bind(this);
    this.handleCache = this.handleCache.bind(this);

    if (!!importScripts && importScripts.length > 0) self.importScripts(...importScripts);

    if (navigationPreload) enableNavigationPreload();

    if (cacheId !== undefined) {
      setCacheNameDetails({
        prefix: cacheId,
      });
    }

    if (skipWaiting) {
      self.skipWaiting();
    } else {
      self.addEventListener("message", (event) => {
        if (event.data && event.data.type === "SKIP_WAITING") {
          self.skipWaiting();
        }
      });
    }

    if (clientsClaim) clientsClaimImpl();

    if (!!precacheEntries && precacheEntries.length > 0) {
      this.addToPrecacheList(precacheEntries);
    }

    if (precacheMiscOptions.cleanupOutdatedCaches) {
      cleanupOutdatedCachesImpl(precacheStrategyOptions.cacheName);
    }

    this.registerRoute(new PrecacheRoute(this, precacheRouteOptions));

    if (precacheMiscOptions.navigateFallback) {
      this.registerRoute(
        new NavigationRoute(this.createHandlerBoundToUrl(precacheMiscOptions.navigateFallback), {
          allowlist: precacheMiscOptions.navigateFallbackAllowlist,
          denylist: precacheMiscOptions.navigateFallbackDenylist,
        }),
      );
    }

    if (offlineAnalyticsConfig !== undefined) {
      if (typeof offlineAnalyticsConfig === "boolean") {
        offlineAnalyticsConfig && initializeGoogleAnalytics({ serwist: this });
      } else {
        initializeGoogleAnalytics({
          ...offlineAnalyticsConfig,
          serwist: this,
        });
      }
    }

    if (runtimeCaching !== undefined) {
      if (fallbacks !== undefined) {
        const fallbackPlugin = new PrecacheFallbackPlugin({
          fallbackUrls: fallbacks.entries,
          serwist: this,
        });

        runtimeCaching.forEach((cacheEntry) => {
          if (
            cacheEntry.handler instanceof Strategy &&
            // This also filters entries with `PrecacheFallbackPlugin` as it also has `handlerDidError`.
            !cacheEntry.handler.plugins.some((plugin) => "handlerDidError" in plugin)
          ) {
            cacheEntry.handler.plugins.push(fallbackPlugin);
          }
        });
      }
      for (const entry of runtimeCaching) {
        this.registerCapture(entry.matcher, entry.handler, entry.method);
      }
    }

    if (disableDevLogs) disableDevLogsImpl();
  }

  /**
   * The strategy used to precache assets and respond to `fetch` events.
   */
  get precacheStrategy(): Strategy {
    return this._precacheStrategy;
  }
  /**
   * A `Map` of HTTP method name (`'GET'`, etc.) to an array of all corresponding registered {@linkcode Route}
   * instances.
   */
  get routes(): Map<HTTPMethod, Route[]> {
    return this._routes;
  }

  /**
   * Adds Serwist's event listeners for you. Before calling it, add your own listeners should you need to.
   */
  addEventListeners() {
    self.addEventListener("install", this.handleInstall);
    self.addEventListener("activate", this.handleActivate);
    self.addEventListener("fetch", this.handleFetch);
    self.addEventListener("message", this.handleCache);
  }

  /**
   * Adds items to the precache list, removing duplicates and ensuring the information is valid.
   *
   * @param entries Array of entries to precache.
   */
  addToPrecacheList(entries: (PrecacheEntry | string)[]): void {
    if (process.env.NODE_ENV !== "production") {
      assert!.isArray(entries, {
        moduleName: "serwist",
        className: "Serwist",
        funcName: "addToCacheList",
        paramName: "entries",
      });
    }

    const urlsToWarnAbout: string[] = [];
    for (const entry of entries) {
      // See https://github.com/GoogleChrome/workbox/issues/2259
      if (typeof entry === "string") {
        urlsToWarnAbout.push(entry);
      } else if (entry && !entry.integrity && entry.revision === undefined) {
        urlsToWarnAbout.push(entry.url);
      }

      const { cacheKey, url } = createCacheKey(entry);
      const cacheMode = typeof entry !== "string" && entry.revision ? "reload" : "default";

      if (this._urlsToCacheKeys.has(url) && this._urlsToCacheKeys.get(url) !== cacheKey) {
        throw new SerwistError("add-to-cache-list-conflicting-entries", {
          firstEntry: this._urlsToCacheKeys.get(url),
          secondEntry: cacheKey,
        });
      }

      if (typeof entry !== "string" && entry.integrity) {
        if (this._cacheKeysToIntegrities.has(cacheKey) && this._cacheKeysToIntegrities.get(cacheKey) !== entry.integrity) {
          throw new SerwistError("add-to-cache-list-conflicting-integrities", {
            url,
          });
        }
        this._cacheKeysToIntegrities.set(cacheKey, entry.integrity);
      }

      this._urlsToCacheKeys.set(url, cacheKey);
      this._urlsToCacheModes.set(url, cacheMode);
    }
    if (urlsToWarnAbout.length > 0) {
      const warningMessage = `Serwist is precaching URLs without revision info: ${urlsToWarnAbout.join(
        ", ",
      )}\nThis is generally NOT safe. Learn more at https://bit.ly/wb-precache`;
      if (process.env.NODE_ENV === "production") {
        // Use console directly to display this warning without bloating
        // bundle sizes by pulling in all of the logger codebase in prod.
        console.warn(warningMessage);
      } else {
        logger.warn(warningMessage);
      }
    }
  }

  /**
   * Precaches new and updated assets. Call this method from the service worker's
   * `install` event.
   *
   * Note: this method calls `event.waitUntil()` for you, so you do not need
   * to call it yourself in your event handlers.
   *
   * @param event
   * @returns
   */
  handleInstall(event: InstallEvent): Promise<InstallResult> {
    void this.registerRequestRules(event);

    return waitUntil<InstallResult>(event, async () => {
      const installReportPlugin = new PrecacheInstallReportPlugin();
      this.precacheStrategy.plugins.push(installReportPlugin);

      await parallel(this._concurrentPrecaching, Array.from(this._urlsToCacheKeys.entries()), async ([url, cacheKey]): Promise<void> => {
        const integrity = this._cacheKeysToIntegrities.get(cacheKey);
        const cacheMode = this._urlsToCacheModes.get(url);

        const request = new Request(url, {
          integrity,
          cache: cacheMode,
          credentials: "same-origin",
        });

        await Promise.all(
          this.precacheStrategy.handleAll({
            event,
            request,
            url: new URL(request.url),
            params: { cacheKey },
          }),
        );
      });

      const { updatedURLs, notUpdatedURLs } = installReportPlugin;

      if (process.env.NODE_ENV !== "production") {
        printInstallDetails(updatedURLs, notUpdatedURLs);
      }

      return { updatedURLs, notUpdatedURLs };
    });
  }

  /**
   * Registers request rules using the experimental `InstallEvent.addRoutes()` API.
   * These rules allow bypassing the service worker for specific requests to improve performance.
   *
   * @param event The event object of an `install` event handler.
   * @throws {Error} When the route rules are invalid
   */
  async registerRequestRules(event: InstallEvent): Promise<void> {
    if (!this._requestRules) {
      return;
    }

    // Check if both the API and route rules are available
    if (!event?.addRoutes) {
      if (process.env.NODE_ENV !== "production") {
        logger.warn(
          "Request rules ignored as the Static Routing API is not supported in this browser. " +
            "See https://caniuse.com/mdn-api_installevent_addroutes for more information.",
        );
      }
      return;
    }

    try {
      if (process.env.NODE_ENV !== "production") {
        logger.warn(
          "Request rules may not be supported in all browsers as the Static Routing API is experimental. " +
            "This feature allows bypassing the service worker for specific requests to improve performance. " +
            "See https://developer.mozilla.org/en-US/docs/Web/API/InstallEvent/addRoutes for more information.",
        );
      }

      await event.addRoutes(this._requestRules);

      // Free up the rules object.
      this._requestRules = undefined;
    } catch (error) {
      if (process.env.NODE_ENV !== "production") {
        logger.error(
          `Failed to register request rules: ${error instanceof Error ? error.message : String(error)}. ` +
            "This may occur if the browser doesn't support the Static Routing API or if the request rules are invalid.",
        );
      }

      throw error;
    }
  }

  /**
   * Deletes assets that are no longer present in the current precache manifest.
   * Call this method from the service worker's `activate` event.
   *
   * Note: this method calls `event.waitUntil()` for you, so you do not need
   * to call it yourself in your event handlers.
   *
   * @param event
   * @returns
   */
  handleActivate(event: ExtendableEvent): Promise<CleanupResult> {
    return waitUntil<CleanupResult>(event, async () => {
      const cache = await self.caches.open(this.precacheStrategy.cacheName);
      const currentlyCachedRequests = await cache.keys();
      const expectedCacheKeys = new Set(this._urlsToCacheKeys.values());

      const deletedCacheRequests: string[] = [];

      for (const request of currentlyCachedRequests) {
        if (!expectedCacheKeys.has(request.url)) {
          await cache.delete(request);
          deletedCacheRequests.push(request.url);
        }
      }

      if (process.env.NODE_ENV !== "production") {
        printCleanupDetails(deletedCacheRequests);
      }

      return { deletedCacheRequests };
    });
  }

  /**
   * Gets a `Response` from an appropriate `Route`'s handler. Call this method
   * from the service worker's `fetch` event.
   * @param event
   */
  handleFetch(event: FetchEvent) {
    const { request } = event;
    const responsePromise = this.handleRequest({ request, event });
    if (responsePromise) {
      event.respondWith(responsePromise);
    }
  }

  /**
   * Caches new URLs on demand. Call this method from the service worker's
   * `message` event. To trigger the handler, send a message of type `"CACHE_URLS"`
   * alongside a list of URLs that should be cached as `urlsToCache`.
   * @param event
   */
  handleCache(event: ExtendableMessageEvent) {
    if (event.data && event.data.type === "CACHE_URLS") {
      const { payload }: CacheURLsMessageData = event.data;

      if (process.env.NODE_ENV !== "production") {
        logger.debug("Caching URLs from the window", payload.urlsToCache);
      }

      const requestPromises = Promise.all(
        payload.urlsToCache.map((entry: string | [string, RequestInit?]) => {
          let request: Request;
          if (typeof entry === "string") {
            request = new Request(entry);
          } else {
            request = new Request(...entry);
          }
          return this.handleRequest({ request, event });
        }),
      );

      event.waitUntil(requestPromises);

      // If a MessageChannel was used, reply to the message on success.
      if (event.ports?.[0]) {
        void requestPromises.then(() => event.ports[0].postMessage(true));
      }
    }
  }

  /**
   * Define a default handler that's called when no routes explicitly
   * match the incoming request.
   *
   * Each HTTP method (`'GET'`, `'POST'`, etc.) gets its own default handler.
   *
   * Without a default handler, unmatched requests will go against the
   * network as if there were no service worker present.
   *
   * @param handler A callback function that returns a `Promise` resulting in a `Response`.
   * @param method The HTTP method to associate with this default handler. Each method
   * has its own default. Defaults to `'GET'`.
   */
  setDefaultHandler(handler: RouteHandler, method: HTTPMethod = defaultMethod): void {
    this._defaultHandlerMap.set(method, normalizeHandler(handler));
  }

  /**
   * If a {@linkcode Route} throws an error while handling a request, this handler
   * will be called and given a chance to provide a response.
   *
   * @param handler A callback function that returns a `Promise` resulting
   * in a `Response`.
   */
  setCatchHandler(handler: RouteHandler): void {
    this._catchHandler = normalizeHandler(handler);
  }

  /**
   * Registers a `RegExp`, string, or function with a caching
   * strategy to the router.
   *
   * @param capture If the capture param is a {@linkcode Route} object, all other arguments will be ignored.
   * @param handler A callback function that returns a `Promise` resulting in a `Response`.
   * This parameter is required if `capture` is not a {@linkcode Route} object.
   * @param method The HTTP method to match the route against. Defaults to `'GET'`.
   * @returns The generated {@linkcode Route} object.
   */
  registerCapture<T extends RegExp | string | RouteMatchCallback | Route>(
    capture: T,
    handler?: T extends Route ? never : RouteHandler,
    method?: T extends Route ? never : HTTPMethod,
  ): Route {
    const route = parseRoute(capture, handler, method);
    this.registerRoute(route);
    return route;
  }

  /**
   * Registers a {@linkcode Route} with the router.
   *
   * @param route The {@linkcode Route} to register.
   */
  registerRoute(route: Route): void {
    if (process.env.NODE_ENV !== "production") {
      assert!.isType(route, "object", {
        moduleName: "serwist",
        className: "Serwist",
        funcName: "registerRoute",
        paramName: "route",
      });

      assert!.hasMethod(route, "match", {
        moduleName: "serwist",
        className: "Serwist",
        funcName: "registerRoute",
        paramName: "route",
      });

      assert!.isType(route.handler, "object", {
        moduleName: "serwist",
        className: "Serwist",
        funcName: "registerRoute",
        paramName: "route",
      });

      assert!.hasMethod(route.handler, "handle", {
        moduleName: "serwist",
        className: "Serwist",
        funcName: "registerRoute",
        paramName: "route.handler",
      });

      assert!.isType(route.method, "string", {
        moduleName: "serwist",
        className: "Serwist",
        funcName: "registerRoute",
        paramName: "route.method",
      });
    }

    if (!this._routes.has(route.method)) {
      this._routes.set(route.method, []);
    }

    // Give precedence to all of the earlier routes by adding this additional
    // route to the end of the array.
    this._routes.get(route.method)!.push(route);
  }

  /**
   * Unregisters a route from the router.
   *
   * @param route The {@linkcode Route} object to unregister.
   */
  unregisterRoute(route: Route): void {
    if (!this._routes.has(route.method)) {
      throw new SerwistError("unregister-route-but-not-found-with-method", {
        method: route.method,
      });
    }

    const routeIndex = this._routes.get(route.method)!.indexOf(route);
    if (routeIndex > -1) {
      this._routes.get(route.method)!.splice(routeIndex, 1);
    } else {
      throw new SerwistError("unregister-route-route-not-registered");
    }
  }

  /**
   * Returns a mapping of a precached URL to the corresponding cache key, taking
   * into account the revision information for the URL.
   *
   * @returns A URL to cache key mapping.
   */
  getUrlsToPrecacheKeys(): Map<string, string> {
    return this._urlsToCacheKeys;
  }

  /**
   * Returns a list of all the URLs that have been precached by the current
   * service worker.
   *
   * @returns The precached URLs.
   */
  getPrecachedUrls(): string[] {
    return [...this._urlsToCacheKeys.keys()];
  }

  /**
   * Returns the cache key used for storing a given URL. If that URL is
   * unversioned, like "/index.html", then the cache key will be the original
   * URL with a search parameter appended to it.
   *
   * @param url A URL whose cache key you want to look up.
   * @returns The versioned URL that corresponds to a cache key
   * for the original URL, or undefined if that URL isn't precached.
   */
  getPrecacheKeyForUrl(url: string): string | undefined {
    const urlObject = new URL(url, location.href);
    return this._urlsToCacheKeys.get(urlObject.href);
  }

  /**
   * @param url A cache key whose SRI you want to look up.
   * @returns The subresource integrity associated with the cache key,
   * or undefined if it's not set.
   */
  getIntegrityForPrecacheKey(cacheKey: string): string | undefined {
    return this._cacheKeysToIntegrities.get(cacheKey);
  }

  /**
   * This acts as a drop-in replacement for
   * [`cache.match()`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/match)
   * with the following differences:
   *
   * - It knows what the name of the precache is, and only checks in that cache.
   * - It allows you to pass in an "original" URL without versioning parameters,
   * and it will automatically look up the correct cache key for the currently
   * active revision of that URL.
   *
   * E.g., `matchPrecache('index.html')` will find the correct precached
   * response for the currently active service worker, even if the actual cache
   * key is `'/index.html?__WB_REVISION__=1234abcd'`.
   *
   * @param request The key (without revisioning parameters)
   * to look up in the precache.
   * @returns
   */
  async matchPrecache(request: string | Request): Promise<Response | undefined> {
    const url = request instanceof Request ? request.url : request;
    const cacheKey = this.getPrecacheKeyForUrl(url);
    if (cacheKey) {
      const cache = await self.caches.open(this.precacheStrategy.cacheName);
      return cache.match(cacheKey);
    }
    return undefined;
  }

  /**
   * Returns a function that looks up `url` in the precache (taking into
   * account revision information), and returns the corresponding `Response`.
   *
   * @param url The precached URL which will be used to lookup the response.
   * @return
   */
  createHandlerBoundToUrl(url: string): RouteHandlerCallback {
    const cacheKey = this.getPrecacheKeyForUrl(url);
    if (!cacheKey) {
      throw new SerwistError("non-precached-url", { url });
    }
    return (options) => {
      options.request = new Request(url);
      options.params = { cacheKey, ...options.params };

      return this.precacheStrategy.handle(options);
    };
  }

  /**
   * Applies the routing rules to a `FetchEvent` object to get a response from an
   * appropriate route.
   *
   * @param options
   * @returns A promise is returned if a registered route can handle the request.
   * If there is no matching route and there's no default handler, `undefined`
   * is returned.
   */
  handleRequest({
    request,
    event,
  }: {
    /**
     * The request to handle.
     */
    request: Request;
    /**
     * The event that triggered the request.
     */
    event: ExtendableEvent;
  }): Promise<Response> | undefined {
    if (process.env.NODE_ENV !== "production") {
      assert!.isInstance(request, Request, {
        moduleName: "serwist",
        className: "Serwist",
        funcName: "handleRequest",
        paramName: "options.request",
      });
    }

    const url = new URL(request.url, location.href);
    if (!url.protocol.startsWith("http")) {
      if (process.env.NODE_ENV !== "production") {
        logger.debug("Router only supports URLs that start with 'http'.");
      }
      return;
    }

    const sameOrigin = url.origin === location.origin;
    const { params, route } = this.findMatchingRoute({
      event,
      request,
      sameOrigin,
      url,
    });
    let handler = route?.handler;

    const debugMessages = [];
    if (process.env.NODE_ENV !== "production") {
      if (handler) {
        debugMessages.push(["Found a route to handle this request:", route]);

        if (params) {
          debugMessages.push([`Passing the following params to the route's handler:`, params]);
        }
      }
    }

    // If we don't have a handler because there was no matching route, then
    // fall back to defaultHandler if that's defined.
    const method = request.method as HTTPMethod;
    if (!handler && this._defaultHandlerMap.has(method)) {
      if (process.env.NODE_ENV !== "production") {
        debugMessages.push(`Failed to find a matching route. Falling back to the default handler for ${method}.`);
      }
      handler = this._defaultHandlerMap.get(method);
    }

    if (!handler) {
      if (process.env.NODE_ENV !== "production") {
        // No handler so Serwist will do nothing. If logs is set of debug
        // i.e. verbose, we should print out this information.
        logger.debug(`No route found for: ${getFriendlyURL(url)}`);
      }
      return;
    }

    if (process.env.NODE_ENV !== "production") {
      // We have a handler, meaning Serwist is going to handle the route.
      // print the routing details to the console.
      logger.groupCollapsed(`Router is responding to: ${getFriendlyURL(url)}`);

      for (const msg of debugMessages) {
        if (Array.isArray(msg)) {
          logger.log(...msg);
        } else {
          logger.log(msg);
        }
      }

      logger.groupEnd();
    }

    // Wrap in try and catch in case the handle method throws a synchronous
    // error. It should still callback to the catch handler.
    let responsePromise: Promise<Response>;
    try {
      responsePromise = handler.handle({ url, request, event, params });
    } catch (err) {
      responsePromise = Promise.reject(err);
    }

    // Get route's catch handler, if it exists
    const catchHandler = route?.catchHandler;

    if (responsePromise instanceof Promise && (this._catchHandler || catchHandler)) {
      responsePromise = responsePromise.catch(async (err) => {
        // If there's a route catch handler, process that first
        if (catchHandler) {
          if (process.env.NODE_ENV !== "production") {
            // Still include URL here as it will be async from the console group
            // and may not make sense without the URL
            logger.groupCollapsed(`Error thrown when responding to:  ${getFriendlyURL(url)}. Falling back to route's Catch Handler.`);
            logger.error("Error thrown by:", route);
            logger.error(err);
            logger.groupEnd();
          }

          try {
            return await catchHandler.handle({ url, request, event, params });
          } catch (catchErr) {
            if (catchErr instanceof Error) {
              err = catchErr;
            }
          }
        }

        if (this._catchHandler) {
          if (process.env.NODE_ENV !== "production") {
            // Still include URL here as it will be async from the console group
            // and may not make sense without the URL
            logger.groupCollapsed(`Error thrown when responding to:  ${getFriendlyURL(url)}. Falling back to global Catch Handler.`);
            logger.error("Error thrown by:", route);
            logger.error(err);
            logger.groupEnd();
          }
          return this._catchHandler.handle({ url, request, event });
        }

        throw err;
      });
    }

    return responsePromise;
  }

  /**
   * Checks a request and URL (and optionally an event) against the list of
   * registered routes, and if there's a match, returns the corresponding
   * route along with any params generated by the match.
   *
   * @param options
   * @returns An object with `route` and `params` properties. They are populated
   * if a matching route was found or `undefined` otherwise.
   */
  findMatchingRoute({ url, sameOrigin, request, event }: RouteMatchCallbackOptions): {
    route?: Route;
    params?: RouteHandlerCallbackOptions["params"];
  } {
    const routes = this._routes.get(request.method as HTTPMethod) || [];
    for (const route of routes) {
      let params: Promise<any> | undefined;
      // route.match returns type any, not possible to change right now.
      const matchResult = route.match({ url, sameOrigin, request, event });
      if (matchResult) {
        if (process.env.NODE_ENV !== "production") {
          // Warn developers that using an async matchCallback is almost always
          // not the right thing to do.
          if (matchResult instanceof Promise) {
            logger.warn(
              `While routing ${getFriendlyURL(
                url,
              )}, an async matchCallback function was used. Please convert the following route to use a synchronous matchCallback function:`,
              route,
            );
          }
        }

        // See https://github.com/GoogleChrome/workbox/issues/2079
        params = matchResult;
        if (Array.isArray(params) && params.length === 0) {
          // Instead of passing an empty array in as params, use undefined.
          params = undefined;
        } else if (matchResult.constructor === Object && Object.keys(matchResult).length === 0) {
          // Instead of passing an empty object in as params, use undefined.
          params = undefined;
        } else if (typeof matchResult === "boolean") {
          // For the boolean value true (rather than just something truth-y),
          // don't set params.
          // See https://github.com/GoogleChrome/workbox/pull/2134#issuecomment-513924353
          params = undefined;
        }

        // Return early if have a match.
        return { route, params };
      }
    }
    // If no match was found above, return and empty object.
    return {};
  }
}
