'use strict';

/**
 * Unit tests for sw-update.js (Task #61).
 *
 * Bug: "Update ready / Reload now" reappeared immediately after the user
 * clicked "Reload now". Root cause — the old flow unregistered the service
 * worker and wiped every cache before navigating, forcing a brand-new
 * install on the next load; that fresh install's registration briefly has
 * `reg.waiting` truthy again (a normal but transient SW lifecycle state),
 * which was misread as a new pending update and re-showed the banner.
 *
 * These tests drive applyUpdate() directly against mocked registration /
 * container / reload objects — no real service worker or browser needed.
 */

const { test } = require('node:test');
const assert = require('node:assert');
const { applyUpdate } = require('./sw-update');

// A minimal fake ServiceWorkerContainer supporting addEventListener/once.
function fakeContainer() {
  const listeners = {};
  return {
    addEventListener(type, fn, opts) {
      (listeners[type] = listeners[type] || []).push({ fn, once: !!(opts && opts.once) });
    },
    fireControllerChange() {
      const fns = (listeners.controllerchange || []).slice();
      for (const { fn, once } of fns) {
        fn();
        if (once) {
          listeners.controllerchange = listeners.controllerchange.filter((l) => l.fn !== fn);
        }
      }
    },
  };
}

test('messages the waiting worker to skipWaiting and reloads only after controllerchange', async () => {
  const messages = [];
  const waiting = { postMessage: (m) => messages.push(m) };
  const container = fakeContainer();
  let reloadCount = 0;

  const done = applyUpdate({
    reg: { waiting },
    container,
    reload: () => { reloadCount++; },
    setTimeout: () => {}, // no-op — we drive controllerchange manually
  });

  // SKIP_WAITING should be sent immediately, before any reload.
  await Promise.resolve();
  assert.deepStrictEqual(messages, [{ type: 'SKIP_WAITING' }]);
  assert.strictEqual(reloadCount, 0, 'must not reload before the new SW has taken control');

  container.fireControllerChange();
  await done;
  assert.strictEqual(reloadCount, 1, 'reloads exactly once after controllerchange');
});

test('reloads only once even if controllerchange fires more than once (no reload loop)', async () => {
  const waiting = { postMessage: () => {} };
  const container = fakeContainer();
  let reloadCount = 0;

  const done = applyUpdate({
    reg: { waiting },
    container,
    reload: () => { reloadCount++; },
    setTimeout: () => {},
  });

  container.fireControllerChange();
  container.fireControllerChange(); // simulate a spurious second event
  await done;

  assert.strictEqual(reloadCount, 1, 'reload must be idempotent — no loop');
});

test('falls back to a plain reload when there is no waiting worker', async () => {
  const container = fakeContainer();
  let reloadCount = 0;

  await applyUpdate({
    reg: { waiting: null },
    container,
    reload: () => { reloadCount++; },
    setTimeout: () => { throw new Error('timeout should not be scheduled without a waiting worker'); },
  });

  assert.strictEqual(reloadCount, 1);
});

test('with no waiting worker, fetches the SW update and activates the newly installed worker (buildTag-poll path)', async () => {
  // Simulates: server redeployed (banner shown by the /api/version poll) but
  // the browser hasn't fetched the new sw.js yet — reg.waiting is null until
  // reg.update() is called and the new worker finishes installing.
  const messages = [];
  const container = fakeContainer();
  let reloadCount = 0;

  const stateListeners = [];
  const installing = {
    state: 'installing',
    addEventListener: (type, fn) => { if (type === 'statechange') stateListeners.push(fn); },
    postMessage: (m) => messages.push(m),
  };
  const reg = {
    waiting: null,
    installing: null,
    update() {
      // Browser found a byte-different sw.js → a new worker starts installing.
      this.installing = installing;
      return Promise.resolve();
    },
  };

  const done = applyUpdate({
    reg,
    container,
    reload: () => { reloadCount++; },
    setTimeout: () => {}, // no-op — we drive state transitions manually
  });

  // Let applyUpdate reach the waitForInstalled stage, then finish the install.
  await Promise.resolve(); await Promise.resolve();
  installing.state = 'installed';
  reg.waiting = installing;
  stateListeners.forEach((fn) => fn());
  await Promise.resolve(); await Promise.resolve();

  assert.deepStrictEqual(messages, [{ type: 'SKIP_WAITING' }], 'skip-waiting sent to the freshly installed worker');
  assert.strictEqual(reloadCount, 0, 'must not reload before the new SW takes control');

  container.fireControllerChange();
  await done;
  assert.strictEqual(reloadCount, 1);
});

test('with no waiting worker and no update found, reloads once after the bounded wait', async () => {
  const container = fakeContainer();
  let reloadCount = 0;
  const timeouts = [];

  const reg = {
    waiting: null,
    installing: null,
    update: () => Promise.resolve(), // update check ran; nothing new
  };

  const done = applyUpdate({
    reg,
    container,
    reload: () => { reloadCount++; },
    setTimeout: (fn) => { timeouts.push(fn); },
  });

  await Promise.resolve(); await Promise.resolve();
  // reg.installing is null → waitForInstalled resolves immediately with null.
  await done;
  assert.strictEqual(reloadCount, 1, 'plain reload when the update check finds nothing');
});

test('falls back to a plain reload when there is no registration at all', async () => {
  const container = fakeContainer();
  let reloadCount = 0;

  await applyUpdate({
    reg: null,
    container,
    reload: () => { reloadCount++; },
  });

  assert.strictEqual(reloadCount, 1);
});

test('the timeout safety net reloads once if controllerchange never fires', async () => {
  const waiting = { postMessage: () => {} };
  const container = fakeContainer();
  let reloadCount = 0;
  let scheduledFn = null;

  await applyUpdate({
    reg: { waiting },
    container,
    reload: () => { reloadCount++; },
    setTimeout: (fn) => { scheduledFn = fn; }, // capture instead of real timer
  });

  assert.strictEqual(reloadCount, 0, 'not reloaded yet — timeout not fired');
  scheduledFn(); // simulate the timeout elapsing
  assert.strictEqual(reloadCount, 1);

  // A late controllerchange after the timeout already reloaded must not
  // trigger a second reload.
  container.fireControllerChange();
  assert.strictEqual(reloadCount, 1, 'no double reload once the timeout fallback has fired');
});
