/***
 * Copyright (c) 2016 - 2022 Alex Grant (@localnerve), LocalNerve LLC
 * Copyrights licensed under the BSD License. See the accompanying LICENSE file for terms.
 *
 * A custom network strategy supportive methods.
 */
/* global Promise, fetch, caches, Response, clients */
import toolbox from 'sw-toolbox';
import Dmp from 'diff-match-patch';
import debugLib from './debug';

const debug = debugLib('customHelpers');

/**
 * Fetch and Cache using different versions of the same request.
 * Only caches GET requests, other methods just return response promise.
 *
 * This is a common service worker pattern that works around lack of ignoreSearch
 * implementation, but could also be useful for ignoring arbitrary components of
 * the request url in the cache, network cookie preservation, changing VARY, etc.
 * @see https://code.google.com/p/chromium/issues/detail?id=426309
 *
 * @param {Request} reqNet - A Request used to fetch from the network.
 * @param {Request|String} [reqCache] - A RequestOrUrl used to update the cache.
 * Required only for GET requests.
 * @param {Object} [options] - Options to modify behavior.
 * @param {RegExp} [options.successResponses] - Defines a successful response test.
 * @param {Function} [options.successHandler] - Receives netRequest, Response,
 * cacheRequest; Returns Promise that resolves to Response.
 * Supply to optionally post process a successful network response.
 * @param {Function} [options.cacheHandler] - Receives cache, Request,
 * PreviousResponse, NewResponse; Returns Promise to Response.
 * Supply to optionally post process after cache update.
 * @return {Response} A Response Promise.
 * @throws {Response} A Response Promise if response is not successful.
 */
export function fetchAndCache (reqNet, reqCache, options = {}) {
  const successResponses = options.successResponses ||
    toolbox.options.successResponses;

  return fetch(reqNet).then((response) => {
    if (successResponses.test(response.status)) {
      const promResponse =
        typeof options.successHandler === 'function' ?
          options.successHandler(reqNet, response, reqCache)
          : Promise.resolve(response);

      return promResponse.then((newResponse) => {
        // Only update cache for GET requests
        if (reqNet.method === 'GET') {
          return caches.open(toolbox.options.cache.name).then((cache) => {
            debug(
              'caching successful network request:', reqCache
            );

            return cache.match(reqCache).then((previousResponse) => {
              return cache.put(reqCache, newResponse.clone()).then(() => {
                const promCache =
                  typeof options.cacheHandler === 'function' ?
                    options.cacheHandler(
                      cache, reqCache, previousResponse, newResponse
                    )
                    : Promise.resolve(newResponse);

                return promCache.then((res) => {
                  debug('cached response', res);
                  return res;
                });
              });
            });
          });
        }

        return newResponse;
      });
    }

    // Raise an error and trigger the catch
    throw response;
  });
}

/**
 * Get content fastest way possible.
 * Start network and get from cache, whichever finishes first wins.
 * Network response eventually updates the cache.
 * If the network response is different than a previously cached response,
 * AND the original request was satisfied from cache, calls updateHandler.
 *
 * @param {Request} reqNet - A Request used to fetch from the network.
 * @param {Request|String} reqCache - A RequestOrUrl used to update the cache.
 * @param {Function} [updateHandler] - Receives request, response, called if stale content was updated.
 * @param {Object} [options] - Passed through to fetchAndCache, cacheHandler is overwritten.
 */
export function contentRace (reqNet, reqCache, updateHandler, options = {}) {
  updateHandler = updateHandler || defaultContentUpdate;

  return new Promise((resolve, reject) => {
    let maybeStale = false, rejected = false;
    const errors = [];

    /**
     * Resolve this promise if the result is a Response.
     * Otherwise, start a rejection.
     */
    const conditionalResolve = (result) => {
      if (result instanceof Response) {
        resolve(result);
      } else {
        conditionalReject(result);
      }
    };

    /**
     * Reject this promise after two rejections.
     */
    const conditionalReject = (error) => {
      errors.push(error);
      if (rejected) {
        debug('Both cache and network failed');
        reject(new Error(`Cache and network failed "${errors.join('", "')}"`));
      } else {
        rejected = true;
      }
    };

    // Overwrite/assign the defaultCacheHandler to call updateHandler when
    // the new response is different than previous.
    options.cacheHandler =
      /**
       * A cacheHandler that calls the given updateHandler when the new response
       * is different than previous.
       *
       * @param {Cache} cache - A reference to the service worker cache.
       * @param {Request} req - The request used to cache the newResponse.
       * @param {Response} prevResponse - The previously cached response.
       * Already evicted, undefined if no previous entry.
       * @param {Response} res - The new currently cached response.
       */
      function cacheHandler (cache, req, prevResponse, res) {
        if (prevResponse) {
          const newResponse = res.clone();

          debug(
            'cacheHandler comparing responses',
            prevResponse,
            newResponse
          );

          // Get the UVString representation of the responses
          return Promise.all([
            prevResponse.text(),
            newResponse.text()
          ]).then((contents) => {
            // If the contents are 1% different and a stale response was served...
            if (nDiff(contents[0], contents[1], 1.0) && maybeStale) {
              debug(
                'cacheHandler found objects different AND stale prior response'
              );

              // Notify that there is a content update
              updateHandler(req, res);
            }
            return res;
          });
        }

        return Promise.resolve(res);
      };

    // Start network
    fetchAndCache(reqNet, reqCache, options)
      .then(conditionalResolve)
      .catch(conditionalReject);

    // Start cache
    caches.open(toolbox.options.cache.name)
      .then((cache) => {
        cache.match(reqCache)
          .then((response) => {
            maybeStale = !!response;
            conditionalResolve(response);
          })
          .catch(conditionalReject);
      })
      .catch(conditionalReject);
  });
}

/**
 * Determine if the percentage of change from old to new exceeds a threshold.
 *
 * Compute the edit distance of transforming the old to the new string.
 *   NOTE: levenshtein edit distance, line method used.
 * Convert the levenshtein edit distance to a percentage of change.
 * Compare against threshold of allowed change.
 *
 * @private
 *
 * @param {String} oldstr - The first string to compare.
 * @param {String} newstr - The second string to compare.
 * @param {Number} threshold - The percentage threshold above which defines a
 * difference, and then the diff is true.
 * @returns true if changes exceed the threshold %, false otherwise.
 */
function nDiff (oldstr, newstr, threshold) {
  const dmp = new Dmp();
  const levenshteinMax = Math.max(oldstr.length, newstr.length);
  const diffs = dmp.diff_main(oldstr, newstr);
  const levenshtein = dmp.diff_levenshtein(diffs);

  return ((levenshtein / levenshteinMax) * 100.0) > threshold;
}

/**
 * Called to notify the client application that new content is available.
 * This is the default contentUpdate handler for contentRace.
 *
 * @private
 *
 * @param {Request} request - The request associated with the content update.
 * @returns {Promise} A promise that resolves when the update handled (no value).
 */
function defaultContentUpdate (request) {
  const requestUrl = typeof request === 'string' ? request : request.url;

  return clients.matchAll({
    type: 'window'
  }).then((clientWindows) => {
    clientWindows.forEach((clientWindow) => {
      if (clientWindow.url.indexOf(requestUrl) !== -1) {
        clientWindow.postMessage({
          command: 'notify',
          show: true,
          time: 3000,
          message: 'New content is available. Please refresh.'
        });
      }
    });
  });
}
