#
# A service worker that bootstraps the board agenda application
#
#   *) Replace calls to fetch any agenda page with calls to fetch a bootstrap
#      page.   This page will reconstruct the page requested using cached
#      data and then request fresh data.  If the server doesn't respond
#      with 0.5 seconds or fails, return a cached version of the bootstrap
#      page.
#
#   *) When a bootstrap.html page is loaded, a scan is made for references
#      to javascripts and stylesheets, and if such a page is not present in
#      the cache, it is fetched and the results are cached.  This is because
#      browsers will sometimes try to request these pages -- even when marked
#      as immutable -- when offline.
#
#   *) Requests for javascript and stylesheets are cached and used to
#      respond to fetches that fail.  Once a new response is received,
#      old responses (with different query strings) are deleted.
#
#   *) Inform clients of the need to reload if a slow server caused
#      pages to be loaded with stale scripts and/or stylesheets
#
#   *) when requested to do so by a client, preload additional pages.  This
#      is for the initial installation, as the pages will have already been
#      loaded by the browser.
#

timeout = 500

# install immediately
self.addEventListener :install do
  return self.skipWaiting()
end

# take over responsibility for existing clients
# https://developer.mozilla.org/en-US/docs/Web/API/Clients/claim
self.addEventListener :activate do |event|
  event.waitUntil self.clients.claim();
end

# insert or replace a response into the cache.  Delete other responses
# with the same path (ignoring the query string).
def cache_replace(cache, request, response)
  path = request.url.split('?')[0]
  cache.keys!().then do |keys|
    keys.each do |key|
      if key.url.split('?')[0] == path and key.url != path
        cache.delete(key).then {}
      end
    end
  end

  cache.put(request, response)
end

# broadcast a message to all clients
def broadcast(message)
  clients.matchAll().then do |clients|
    clients.each do |client|
      client.postMessage(message)
    end
  end
end

# look for css and js files and in HTML response ensure that each are cached
def preload(cache, base, text, toolate)
  pattern = Regexp.new('"[-.\w+/]+\.(css|js)\?\d+"', 'g')

  count = 0
  changed = false
  while (match = pattern.exec(text))
    count += 1
    path = match[0].split('"')[1]
    request = Request.new(URL.new(path, base))
    cache.match(request).then do |response|
      if response
        count -= 1
      else
        changed = true
        fetch(request).then do |response|
          cache_replace(cache, request, response) if response.ok
          count -= 1
          broadcast(type: 'reload') if toolate and changed and count == 0
        end
      end
    end
  end
end

# fetch from cache with a network fallback
def fetch_from_cache(event)
  return caches.open('board/agenda').then do |cache|
    return cache.match(event.request).then do |response|
      return response || fetch(event.request).then do |response|
        cache_replace(cache, event.request, response.clone()) if response.ok
        return response
      end
    end
  end
end

# Return latest bootstrap page from the cache; then update the bootstrap
# from the server.  If the body has changed, broadcast that information to
# all the browser window clients.
def latest(event)
  return Promise.new do |fulfill, reject|
    caches.open('board/agenda').then do |cache|
      cache.matchAll().then do |responses|
        match = nil
        responses.each do |response|
          if response.url.end_with? '/bootstrap.html'
            match = response if not match or match.url < response.url
          end
        end

        if match
          match.clone().text().then do
            fulfill(match)

            request = Request.new(match.url, cache: 'no-store')
            fetch(request).then do |response|
              if response.ok
                response.clone().text().then do |after|
                  cache.put request, response
                  broadcast(type: 'latest', body: after) # if after != before
                end
              end
            end
          end
        else
          fetch(event.request).then(fulfill, reject)
        end
      end
    end
  end
end

# Return a bootstrap.html page within 0.5 seconds.  If the network responds
# in time, go with that response, otherwise respond with a cached version.
def bootstrap(event, request)
  return Promise.new do |fulfill, reject|
    timeoutId = nil

    caches.open('board/agenda').then do |cache|
      # common logic to reply from cache
      replyFromCache = lambda do |refetch|
        cache.match(request).then do |response|
          clearTimeout timeoutId

          if response
            fulfill response
            timeoutId = nil
          elsif refetch
            fetch(event.request).then(fulfill, reject)
          end
        end
      end

      # respond from cache if the server isn't fast enough
      timeoutId = setTimeout timeout do
        replyFromCache(false)
      end

      # attempt to fetch bootstrap.html from the network
      fetch(request).then {|response|
        # cache the response if OK, fulfill the response if not timed out
        if response.ok
          cache.put(request, response.clone())

          # preload stylesheets and javascripts
          if request.url =~ /bootstrap\.html$/
            response.clone().text().then do |text|
              toolate = !timeoutId
              setTimeout(toolate ? 0 : 3_000) do
                preload(cache, request.url, text, toolate)
              end
            end
          end

          if timeoutId
            clearTimeout timeoutId
            fulfill response
          end
        else
          # bad response: use cache instead
          replyFromCache(true)
        end
      }.catch { |_failure|
        # no response: use cache instead
        replyFromCache(true)
      }
    end
  end
end

# intercept selected pages
self.addEventListener :fetch do |event|
  scope = self.registration.scope
  url = event.request.url
  url = url.slice(scope.length) if url.start_with? scope

  if event.request.method == 'GET'
    # determine what url to fetch (if any)
    if url.end_with? '/bootstrap.html'
      return

    elsif url =~ %r{^\d\d\d\d-\d\d-\d\d/(\w+/)?[-\w]*$}
      # substitute bootstrap.html for html pages
      date =  url.split('/')[0]
      bootstrap_url = "#{scope}#{date}/bootstrap.html"
      request = Request.new(bootstrap_url, cache: 'no-store')

      # produce response
      event.respondWith(bootstrap(event, request))

    elsif url =~ %r{\.(js|css)\?\d+$}
      # cache and respond to js and css requests
      event.respondWith(fetch_from_cache(event))

    elsif url == ''
      # event.respondWith(Response.redirect('latest/'))

    elsif url == 'latest/'
      event.respondWith(latest(event))
    end
  end
end

# watch for preload requests
self.addEventListener :message do |event|
  if event.data.type == :preload
    caches.open('board/agenda').then do |cache|
      request = Request.new(event.data.url, cache: 'no-store')
      cache.match(request).then do |response|
        unless response
          fetch(request).then do |response|
            if response.ok
              response.text().then do |text|
                preload(cache, request.url, text, false)
              end
            end
          end
        end
      end
    end
  end
end
