/**
 * Service worker responsible for providing offline support to Enplug Apps.
 * @author Michal Drewniak (michal@enplug.com)
 */

'use strict';

/**
 * Offline support configuration.
 * @type {Object}
 * @property {string} cacheVersion - Version of the cache. This should be updated any time we want
 *     the previous version of the cache removed.
 * @property {string} cacheName - Name of the cache. This is usually the same as the application
 *     name or ID.
 * @property {Array.<string>} = A list of static URLs which should be cached.
 * @property {Object.<string, number>} - A mapping of URLs (or their parts) to time in minutes.
 *     The URL denotes for which locations cached responses should be refreshed. Time denotes
 *     after which time (in minutes), given response should be refreshed. Time set to 0 means that
 *     a given response should never be cached.
 */
var config = <% config %>;
const TAG = '[<% app_name %>|offline]';

// End of config. There shouldn't be any need to edit code below.



const LAST_USED_TIME_CACHE = 'last-used-time-cache';
const REFRESH_TIME_CACHE = 'refresh-time';
const CACHE_PRUNE_CHECK_INTERVAL = 60;                      // in minutes
const META_DATA_STORE_INTERVAL = 20;                        // in minutes
const MAX_CACHE_TIME = config.cacheTime || 60 * 24;         // in minutes (default to 1 day)


/**
 * Time which ellapsed since last refresh. Used to determine whether a refresh should take place.
 * @type {Object}
 */
let refreshTime = {};
let lastUsedTime = {};

let cachePruneTime = Date.now();
let writeTime = Date.now();


/**
 * Installs service worker and adds static resources to cache.
 */
self.addEventListener('install', function (event) {
  event.waitUntil(caches.open(config.cacheVersion).then((cache) => {
    return cache.addAll(config.staticResources)
      .then((response) => {
        console.log(`${TAG} Offline support service worker v. ${config.cacheVersion} installed.`);
        self.skipWaiting();
      })
      .catch((error) => {
        console.warn(`${TAG} Error adding static resources to cache ${config.cacheVersion}.`, error);
      })
      // Read Refresh Time object from cache
      .then(() => cache.match(REFRESH_TIME_CACHE))
      .then((cachedData) => cachedData && cachedData.text())
      .then((text) => {
        try {
          refreshTime = text ? JSON.parse(text) : {};
        } catch (e) {
          refreshTime = {};
        }
      })
      // Read lastUsedTime object from cache
      .catch(() => {})
      .then(() => cache.match(LAST_USED_TIME_CACHE))
      .then((cachedData) => cachedData && cachedData.text())
      .then((text) => {
        try {
          lastUsedTime = text ? JSON.parse(text) : {};
          return removeLeastUsedCacheEntries();
        } catch (e) {
          lastUsedTime = {};
        }
      })
  }));
});


/**
 * Activate phase. Clears all non-whitelisted cache.
 */
self.addEventListener('activate', (event) => {
  var cacheWhitelist = [config.cacheVersion];

  // Check all available caches. If a cache for the specific app is found but its cache name differs (different app
  // version), remove that cache.
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map(function (cacheName) {
          if (cacheName.indexOf(config.appName) === 0 && cacheWhitelist.indexOf(cacheName) === -1) {
            console.log(`${TAG} Deleting cache ${cacheName}`);
            return caches.delete(cacheName);
          }
        })
      ).then(() => self.clients.claim());
    })
  );
});


/**
 * Captures requests and either exectures request to the server or returns a cached version.
 */
self.addEventListener('fetch', (event) => {
  // Ignore POST, mp4 and blob requests as well as URLs which are explicitly added to no-cache list.
  if (event.request.method === 'POST') {
    event.respondWith(fetch(event.request));
    return event = null;
  }
  if (event.request.url.startsWith('blob:') || event.request.url.endsWith('.mp4')) {
    event.respondWith(fetch(event.request));
    return event = null;
  }
  for (const noCacheUrl of config.noCacheUrls) {
    if (event.request.url.indexOf(noCacheUrl) >= 0) {
      event.respondWith(fetch(event.request));
      return event = null;
    }
  }

  // Get rid of the app token to generalize URL for caching. Having unique URLs (unique appToken) for each request
  // will only make the cache get bigger over time. Since We can't modify the URL of the original event.request.
  // Because of this, we create a new request which we'll later use for matching and caching.
  const requestUrl = event.request.url;
  const cacheUrl = requestUrl.replace(/\?apptoken.*/g, '');
  let cacheRequest = new Request(cacheUrl, {
    mode: 'cors'
  });

  // Remove least used cache entries and update used time for the current URL.
  if (shouldPruneCache()) {
    removeLeastUsedCacheEntries();
  }
  updateLastUsedTime(cacheUrl);

  event.respondWith(caches.match(cacheRequest).then((cachedResponse) => {
    // Cached data found and it shouldn't be refreshed. Returned cached response.
    if (cachedResponse && !canUrlBeRefreshed(cacheUrl)) {
      console.debug('returning cached response', cacheUrl);
      cacheRequest = null;
      return cachedResponse;
    }
    console.debug('fetching new file', cacheUrl);

    // Cached data not found or it should be refreshed.
    // Explicitly change the reqest to CORS request so that cross-domain resources get properly cached and fetch it.
    let fetchRequest = new Request(requestUrl, {
      mode: 'cors'
    });
    // Fetching CORS request of the origin event URL.
    return fetch(fetchRequest)
      // Initial error handling for requests which resolved but have incorrect status or null response. Returns
      // either cached response if available, or failed response if cached response is not available. Returning
       // invalid response will ensure we do not cache it.
      .then((response) => {
        fetchRequest = null;
        if (!response || response.status !== 200) {
          return false;
        }
        // Propagate correct response down the Promise chain.
        return response;
      })

      // CORS request has failed. We need attempt to refetch the request as no-cors (which is still preferable)
      // then a failed response. We want to propagate down the Promise chain the response to our no-CORS request.
      .catch(() => {
        console.log(`${TAG} CORS request failed. Attempting fetch with no-cors set.`);
        const noCorsFetchRequest = new Request(requestUrl, {
          mode: 'no-cors'
        });
        return fetch(noCorsFetchRequest);
      })

      // The response from either successful CORS or successful or failed no-CORS request. At this point, we want
      // to either return invalid response or cache the correct one.
      .then((response) => {
        if (!response) {
          cacheRequest = null;
          cachedResponse = null;
          return false;
        }

        return caches.open(config.cacheVersion).then(
          (cache) => {
            // A request is a stream and can only be consumed once. Since we are consuming it once by the cache and once
            // by the browser for fetch, we need to clone it.
            cachedResponse = null;
            return cache.put(cacheRequest, response.clone())
              .then(() => storeCachedTime(cacheUrl))
              .then(() => {
                cacheRequest = null;
                return response;
              });
          },
          (error) => { console.warn(`${TAG} Unable to cache ${cacheUrl}`, error) }
        );
      })

      // If something else has failed (for example URL refresh due to being offline) but we have a cached response,
      // return the cached response
      .catch(() => {
        cacheRequest = null;
        if (cachedResponse) {
          return cachedResponse;
        } else {
          return false;
        }
      });
  }));

  event = null;
})


/**
 * Checks whether given URL should be refeched and cached value associated with it replaced.
 * @param  {string} url
 * @return {boolean} - If true, the cached value associated with the URL should be refreshed.
 */
function canUrlBeRefreshed(url) {
  // Number of seconds that need to elapse since last refresh before the URL can be refreshed
  var refreshInterval = null;
  var isWhitelisted = false;
  var hasRequiredTimePassed = false;

  // Check whether the url is whitelisted
  if (config.refreshUrls) {
    for (var keyUrl in config.refreshUrls) {
      if (url.indexOf(keyUrl) >= 0) {
        isWhitelisted = true;
        refreshInterval = config.refreshUrls[keyUrl] * 60 * 1000; // convert to milliseconds
        break;
      }
    }
    // If refresh time is set to 0, requests should always return most recent version.
    if (refreshInterval === 0) {
      return true;
    }
  }

  // Check whether appropriate amount of time ellapsed since last refresh
  var currentTime = Date.now();
  var lastRefreshTime = refreshTime[url] || 0;
  hasRequiredTimePassed = currentTime - lastRefreshTime >= refreshInterval;

  return isWhitelisted && hasRequiredTimePassed;
}


/**
 * Determines whether enough time has passed since last cache prune check to allow for another round of pruning.
 * @returns {boolean} true if cache should be pruned again.
 */
function shouldPruneCache() {
  const timeDiff = (Date.now() - cachePruneTime) / 1000 / 60;   // in minutes
  if (timeDiff >= CACHE_PRUNE_CHECK_INTERVAL) {
    cachePruneTime = Date.now();
    return true;
  }
  return false;
}


/**
 * Sets the time a request with the given URL was last cached. Service workers have no access to localStorage.
 * However, we need some way of persisting some data across reloads of the worker. To do so, we utilize CacheStorage.
 * The function constructs and returns name of the cache used for storage purposes.
 * @param {string} url - URL for which the time is set.
 */
function storeCachedTime(url) {
  refreshTime[url] = Date.now();
  var response = new Response(JSON.stringify(refreshTime));
  return caches.open(config.cacheVersion).then((cache) => {
    return cache.put(REFRESH_TIME_CACHE, response).then(() => {
      response = null;
    });
  });
}


/**
 * Stores lastUsedTime object in cache.
 */
function storeLastUsedTimeInCache() {
  var response = new Response(JSON.stringify(lastUsedTime));
  return caches.open(config.cacheVersion).then((cache) => {
    writeTime = Date.now();
    return cache.put(LAST_USED_TIME_CACHE, response).then(() => {
      response = null;
    });
  });
}


/**
 * Based on the lastUsedTime object, it removes from Cache entries which haven't been accessed for longer than
 * MAX_CACHE_TIME.
 */
function removeLeastUsedCacheEntries() {
  return caches.open(config.cacheVersion).then((cache) => {
    for (const url in lastUsedTime) {
      if (lastUsedTime.hasOwnProperty(url)) {
        let timeDiff = (Date.now() - lastUsedTime[url]) / 1000 / 60;   // in minutes
        if (timeDiff >= MAX_CACHE_TIME) {
          console.log(`${TAG} Deleting cache entry for URL: ${url}`);
          delete lastUsedTime[url];
          cache.delete(url);
        }
      }
    }
    return storeLastUsedTimeInCache();
  });
}


/**
 * Stores the object containing times where each URL has last been used in Cache. We use this data to determine
 * whether the stored URL and associated response are still used or whether they can be removed from cache.
 * While the time stored in memory is constantly updated, the full object is written to Cache every
 * META_DATA_STORE_INTERVAL minutes. This is done to prevent extremely frequent writes to Cache.
 * @param {string} url - URL of a reasourse which is currently being accessed.
 */
function updateLastUsedTime(url) {
  const writeTimeDiff = (Date.now() - writeTime) / 1000 / 60;
  lastUsedTime[url] = Date.now();

  if (writeTimeDiff >= META_DATA_STORE_INTERVAL) {
    return caches.match(LAST_USED_TIME_CACHE)
      .then((cachedData) => cachedData && cachedData.text())
      .then((text) => {
        try {
          const storedLastUsedTime = text ? JSON.parse(text) : {};
          lastUsedTime = Object.assign(lastUsedTime, storedLastUsedTime);
        } catch (e) {
          // noop
        }
        return storeLastUsedTimeInCache();
      });
  }
  return Promise.resolve();
}
