/* PaintOps service worker (tier 1: offline read + app shell).
 * Generated by includes/pwa.php. VERSION is the plugin version, so
 * publishing a plugin update rolls the shell cache automatically. */
'use strict';

var VERSION = '__VERSION__';
var APP_PATH = '__APP_PATH__';
var START_URL = '__START_URL__';
var ICONS = __ICONS__;

var SHELL_CACHE = 'paintops-shell-' + VERSION;
// Versioned so a plugin update clears stale cached pages. Without this, a
// job page cached before a feature shipped (e.g. offline check-in) would
// be served offline forever, missing the new code. Re-populated fresh by
// the tier-2 pre-cache and by warmPageCache() on the next online visit.
var PAGE_CACHE = 'paintops-pages-' + VERSION;
var ASSET_CACHE = 'paintops-assets-' + VERSION;

/* On install: cache the icons (must succeed) and opportunistically the
 * app's start page so the shell is available even on a cold offline
 * open. A failed start-page fetch must not fail the whole install. */
self.addEventListener('install', function (event) {
  event.waitUntil((async function () {
    // Warm the caches opportunistically, but NEVER block activation on a
    // precache fetch. A slow or hung icon/page request must not leave the
    // worker stuck installing with the previous version still in control
    // (that is exactly what pins a stale icon in place). Fire-and-forget.
    caches.open(SHELL_CACHE)
      .then(function (c) { return c.addAll(ICONS); })
      .catch(function () {});
    caches.open(PAGE_CACHE)
      .then(function (c) {
        return fetch(START_URL, { credentials: 'same-origin' }).then(function (res) {
          if (res && res.ok) { return c.put(START_URL, res.clone()); }
        });
      })
      .catch(function () {});
    await self.skipWaiting();
  })());
});

/* On activate: drop shell and asset caches from previous plugin
 * versions so updated icons/CSS/JS are not served stale. The page cache
 * is kept (it is content, refreshed network-first, not versioned). */
self.addEventListener('activate', function (event) {
  event.waitUntil((async function () {
    var keys = await caches.keys();
    await Promise.all(keys.map(function (k) {
      if ((k.indexOf('paintops-shell-') === 0 && k !== SHELL_CACHE) ||
          (k.indexOf('paintops-assets-') === 0 && k !== ASSET_CACHE) ||
          (k.indexOf('paintops-pages-') === 0 && k !== PAGE_CACHE)) {
        return caches.delete(k);
      }
      return null;
    }));
    await self.clients.claim();
  })());
});

self.addEventListener('message', function (event) {
  var data = event.data || {};

  /* Logout housekeeping: the page posts this when it renders the
   * "please log in" view, so a shared device does not keep one user's
   * cached job pages after they sign out. */
  if (data.type === 'paintops-clear-pages') {
    event.waitUntil(caches.delete(PAGE_CACHE));
    return;
  }

  /* Tier 2 pre-cache: the page hands us today's job URLs (fetched while
   * online) so every job on today's schedule is saved for offline use
   * even if the painter never opens it. Each is fetched with the user's
   * cookies and stored in the same page cache the navigation handler
   * reads from, so a pre-cached job opens offline exactly like a
   * visited one. */
  if (data.type === 'paintops-precache' && Array.isArray(data.urls)) {
    event.waitUntil((async function () {
      var cache = await caches.open(PAGE_CACHE);
      await Promise.all(data.urls.map(async function (u) {
        try {
          var r = await fetch(u, { credentials: 'same-origin' });
          if (r && r.ok && r.type === 'basic') { await cache.put(u, r.clone()); }
        } catch (e) { /* offline or a bad URL, skip it */ }
      }));
    })());
  }
});

function isStaticAsset(url) {
  return /\.(?:css|js|mjs|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|otf)(?:\?|$)/i.test(url.pathname);
}

/* A minimal, self-contained offline page for a route that was never
 * cached. Styled inline so it needs nothing from the network. */
function offlinePage() {
  var html = '<!doctype html><html lang="en"><head><meta charset="utf-8">' +
    '<meta name="viewport" content="width=device-width, initial-scale=1">' +
    '<title>Reconnecting - PaintOps</title><style>' +
    'body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;' +
    'background:#2f5f7a;color:#fff;display:flex;min-height:100vh;align-items:center;justify-content:center;text-align:center;padding:24px;box-sizing:border-box}' +
    '.box{max-width:360px}h1{font-size:22px;margin:0 0 10px}p{font-size:15px;line-height:1.5;opacity:.92;margin:0 0 20px}' +
    'a{display:inline-block;background:#fca311;color:#1b1b1b;text-decoration:none;font-weight:600;padding:11px 20px;border-radius:8px}' +
    'a.alt{background:none;color:#fff;text-decoration:underline;font-weight:400;padding:10px;font-size:14px}' +
    '</style></head><body><div class="box">' +
    '<h1>Can\'t reach PaintOps</h1>' +
    '<p>Your connection dropped for a moment, or this screen has not been saved for offline use yet. Job pages you opened while you had signal are still available. This will retry on its own once you\'re back online.</p>' +
    '<a href="#" id="po-retry">Try again</a>' +
    '<div><a class="alt" href="' + START_URL + '">Back to PaintOps</a></div>' +
    '<script>(function(){function go(){location.reload();}var b=document.getElementById("po-retry");if(b){b.addEventListener("click",function(e){e.preventDefault();go();});}window.addEventListener("online",go);try{var k="po_off_"+location.pathname+location.search;if(navigator.onLine&&!sessionStorage.getItem(k)){sessionStorage.setItem(k,"1");setTimeout(function(){if(navigator.onLine){go();}},3000);}}catch(e){}})();</script>' +
    '</div></body></html>';
  return new Response(html, {
    status: 200,
    headers: { 'Content-Type': 'text/html; charset=utf-8' }
  });
}

/* Offline app-page navigation: serve the saved copy of that exact page
 * (stamped with a visible "offline copy" banner, see withOfflineBanner
 * below), or the friendly fallback if it was never saved. No network
 * attempt, this path only runs when the device reports itself offline. */
async function offlineNavigation(request) {
  var cache = await caches.open(PAGE_CACHE);
  var cached = await cache.match(request);
  if (cached) { return withOfflineBanner(cached); }
  return offlinePage();
}

/* Stamp a saved page with a banner so an offline snapshot can never be
 * mistaken for live data (2026-07-26: a phone's saved calendar copy was
 * missing a job scheduled AFTER the copy was cached; with no marker it
 * read as the calendar losing the job, same complaint class as the
 * 1.0.239 "painter didn't see his job" report). The banner says when the
 * copy was saved (the cached response's own Date header) and the page
 * reloads itself as soon as the connection returns. Injection is plain
 * string surgery right after the <body ...> tag; if the markup or the
 * read fails in any way the original cached response is returned
 * untouched, because showing the page beats showing the banner. */
async function withOfflineBanner(cached) {
  try {
    var text = await cached.clone().text();
    var bodyOpen = text.search(/<body[^>]*>/i);
    if (bodyOpen === -1) { return cached; }
    var insertAt = text.indexOf('>', bodyOpen) + 1;
    var savedAt = '';
    var dateHeader = cached.headers.get('date');
    if (dateHeader) {
      var d = new Date(dateHeader);
      if (!isNaN(d.getTime())) {
        savedAt = ' from ' + d.toLocaleDateString([], { month: 'short', day: 'numeric' }) +
          ', ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
      }
    }
    var banner = '<div id="po-offline-banner" style="position:sticky;top:0;z-index:100000;' +
      'background:#b45309;color:#fff;padding:8px 12px;text-align:center;' +
      "font:600 13px/1.4 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;" +
      '">You\'re offline. This is a saved copy' + savedAt +
      '; anything added or changed since then is not shown. ' +
      '<a href="#" onclick="location.reload();return false;" ' +
      'style="color:#fff;text-decoration:underline;">Refresh</a></div>' +
      '<script>window.addEventListener("online",function(){location.reload();});</scr' + 'ipt>';
    var headers = new Headers(cached.headers);
    headers.delete('Content-Length');
    return new Response(text.slice(0, insertAt) + banner + text.slice(insertAt), {
      status: cached.status,
      statusText: cached.statusText,
      headers: headers
    });
  } catch (e) {
    return cached;
  }
}

/* Warm the page cache in the background after an online visit, so the
 * page can be read later with no signal. ALWAYS re-fetches and overwrites
 * any existing entry (network-first) rather than skipping when one is
 * already cached: this page can reflect per-request state (logged in vs.
 * the "please log in" gate, a job's current status) that changes between
 * visits, so a stale snapshot from a previous state, e.g. cached the one
 * time the login cookie had expired, would otherwise be served offline
 * forever afterward even after the user is logged back in. Best-effort,
 * never user-facing. */
async function warmPageCache(request) {
  try {
    var res = await fetch(request);
    if (res && res.ok && res.type === 'basic') {
      var cache = await caches.open(PAGE_CACHE);
      await cache.put(request, res.clone());
    }
  } catch (e) { /* best effort */ }
}

/* Static assets: stale-while-revalidate. Serve the cached copy instantly
 * (fast, and works offline), but always kick off a background fetch to
 * refresh it, so an updated asset heals itself on the next load without
 * waiting for a version bump. With no cached copy yet, wait on network. */
async function handleAssetRequest(request, event) {
  var cache = await caches.open(ASSET_CACHE);
  var cached = await cache.match(request);
  var networkPromise = fetch(request).then(function (res) {
    if (res && res.ok) { cache.put(request, res.clone()); }
    return res;
  }).catch(function () { return null; });
  if (cached) {
    if (event && event.waitUntil) { event.waitUntil(networkPromise); }
    return cached;
  }
  var res = await networkPromise;
  return res || Response.error();
}

self.addEventListener('fetch', function (event) {
  var request = event.request;
  if (request.method !== 'GET') { return; }

  var url;
  try { url = new URL(request.url); } catch (e) { return; }
  if (url.origin !== self.location.origin) { return; }

  // Never get between the user and wp-admin / login / ajax.
  if (url.pathname.indexOf('/wp-admin') === 0 ||
      url.pathname.indexOf('/wp-login') === 0 ||
      url.pathname.indexOf('/wp-json') === 0 ||
      url.pathname.indexOf('admin-ajax.php') !== -1) {
    return;
  }

  if (request.mode === 'navigate') {
    // Only concern ourselves with navigations inside the app.
    if (url.pathname.indexOf(APP_PATH) === 0) {
      if (self.navigator && self.navigator.onLine === false) {
        // Genuinely offline: serve a saved page or the fallback screen.
        event.respondWith(offlineNavigation(request));
      } else {
        // Online: DO NOT intercept. Let the browser do a normal native
        // navigation, which is always reliable and can never be blocked
        // or fail on the worker's own fetch (the cause of a page wrongly
        // showing "offline" while connected). Warm the cache in the
        // background so this page is still readable later with no signal.
        event.waitUntil(warmPageCache(request));
      }
    }
    return;
  }

  if (isStaticAsset(url)) {
    event.respondWith(handleAssetRequest(request, event));
  }
});
