"use strict";

const url = SimpleTest.getTestFileURL("mockpushserviceparent.js");
const chromeScript = SpecialPowers.loadChromeScript(url);

/**
 * Replaces `PushService.sys.mjs` with a mock implementation that handles requests
 * from the DOM API. This allows tests to simulate local errors and error
 * reporting, bypassing the `PushService.sys.mjs` machinery.
 */
async function replacePushService(mockService) {
  chromeScript.addMessageListener("service-delivery-error", function (msg) {
    mockService.reportDeliveryError(msg.messageId, msg.reason);
  });
  chromeScript.addMessageListener("service-request", function (msg) {
    let promise;
    try {
      let handler = mockService[msg.name];
      promise = Promise.resolve(handler(msg.params));
    } catch (error) {
      promise = Promise.reject(error);
    }
    promise.then(
      result => {
        chromeScript.sendAsyncMessage("service-response", {
          id: msg.id,
          result,
        });
      },
      error => {
        chromeScript.sendAsyncMessage("service-response", {
          id: msg.id,
          error,
        });
      }
    );
  });
  await new Promise(resolve => {
    chromeScript.addMessageListener("service-replaced", function onReplaced() {
      chromeScript.removeMessageListener("service-replaced", onReplaced);
      resolve();
    });
    chromeScript.sendAsyncMessage("service-replace");
  });
}

async function restorePushService() {
  await new Promise(resolve => {
    chromeScript.addMessageListener("service-restored", function onRestored() {
      chromeScript.removeMessageListener("service-restored", onRestored);
      resolve();
    });
    chromeScript.sendAsyncMessage("service-restore");
  });
}

let currentMockSocket = null;

/**
 * Sets up a mock connection for the WebSocket backend. This only replaces
 * the transport layer; `PushService.sys.mjs` still handles DOM API requests,
 * observes permission changes, writes to IndexedDB, and notifies service
 * workers of incoming push messages.
 */
function setupMockPushSocket(mockWebSocket) {
  currentMockSocket = mockWebSocket;
  currentMockSocket._isActive = true;
  chromeScript.sendAsyncMessage("socket-setup");
  chromeScript.addMessageListener("socket-client-msg", function (msg) {
    mockWebSocket.handleMessage(msg);
  });
}

function teardownMockPushSocket() {
  if (currentMockSocket) {
    return new Promise(resolve => {
      currentMockSocket._isActive = false;
      chromeScript.addMessageListener("socket-server-teardown", resolve);
      chromeScript.sendAsyncMessage("socket-teardown");
    });
  }
  return Promise.resolve();
}

/**
 * Minimal implementation of web sockets for use in testing. Forwards
 * messages to a mock web socket in the parent process that is used
 * by the push service.
 */
class MockWebSocket {
  // Default implementation to make the push server work minimally.
  // Override methods to implement custom functionality.
  constructor() {
    this.userAgentID = "8e1c93a9-139b-419c-b200-e715bb1e8ce8";
    this.registerCount = 0;
    // We only allow one active mock web socket to talk to the parent.
    // This flag is used to keep track of which mock web socket is active.
    this._isActive = false;
  }

  onHello() {
    this.serverSendMsg(
      JSON.stringify({
        messageType: "hello",
        uaid: this.userAgentID,
        status: 200,
        use_webpush: true,
      })
    );
  }

  onRegister(request) {
    this.serverSendMsg(
      JSON.stringify({
        messageType: "register",
        uaid: this.userAgentID,
        channelID: request.channelID,
        status: 200,
        pushEndpoint: "https://example.com/endpoint/" + this.registerCount++,
      })
    );
  }

  onUnregister(request) {
    this.serverSendMsg(
      JSON.stringify({
        messageType: "unregister",
        channelID: request.channelID,
        status: 200,
      })
    );
  }

  onAck() {
    // Do nothing.
  }

  handleMessage(msg) {
    let request = JSON.parse(msg);
    let messageType = request.messageType;
    switch (messageType) {
      case "hello":
        this.onHello(request);
        break;
      case "register":
        this.onRegister(request);
        break;
      case "unregister":
        this.onUnregister(request);
        break;
      case "ack":
        this.onAck(request);
        break;
      default:
        throw new Error("Unexpected message: " + messageType);
    }
  }

  serverSendMsg(msg) {
    if (this._isActive) {
      chromeScript.sendAsyncMessage("socket-server-msg", msg);
    }
  }
}

// Remove permissions and prefs when the test finishes.
SimpleTest.registerCleanupFunction(async function () {
  await new Promise(resolve => SpecialPowers.flushPermissions(resolve));
  await SpecialPowers.flushPrefEnv();
  await restorePushService();
  await teardownMockPushSocket();
});

function setPushPermission(allow) {
  let permissions = [
    { type: "desktop-notification", allow, context: document },
  ];

  if (isXOrigin) {
    // We need to add permission for the xorigin tests. In xorigin tests, the
    // test page will be run under third-party context, so we need to use
    // partitioned principal to add the permission.
    let partitionedPrincipal =
      SpecialPowers.wrap(document).partitionedPrincipal;

    permissions.push({
      type: "desktop-notification",
      allow,
      context: {
        url: partitionedPrincipal.originNoSuffix,
        originAttributes: {
          partitionKey: partitionedPrincipal.originAttributes.partitionKey,
        },
      },
    });
  }

  return SpecialPowers.pushPermissions(permissions);
}

function setupPrefs() {
  return SpecialPowers.pushPrefEnv({
    set: [
      ["dom.push.enabled", true],
      ["dom.push.connection.enabled", true],
      ["dom.push.maxRecentMessageIDsPerSubscription", 0],
      ["dom.serviceWorkers.exemptFromPerDomainMax", true],
      ["dom.serviceWorkers.enabled", true],
      ["dom.serviceWorkers.testing.enabled", true],
    ],
  });
}

async function setupPrefsAndReplaceService(mockService) {
  await replacePushService(mockService);
  await setupPrefs();
}

function setupPrefsAndMockSocket(mockSocket) {
  setupMockPushSocket(mockSocket);
  return setupPrefs();
}

function injectControlledFrame(target = document.body) {
  return new Promise(function (res) {
    var iframe = document.createElement("iframe");
    iframe.src = "/tests/dom/push/test/frame.html";

    var controlledFrame = {
      remove() {
        target.removeChild(iframe);
        iframe = null;
      },
      waitOnWorkerMessage(type) {
        return iframe
          ? iframe.contentWindow.waitOnWorkerMessage(type)
          : Promise.reject(new Error("Frame removed from document"));
      },
      innerWindowId() {
        return SpecialPowers.wrap(iframe).browsingContext.currentWindowContext
          .innerWindowId;
      },
    };

    iframe.onload = () => res(controlledFrame);
    target.appendChild(iframe);
  });
}

function sendRequestToWorker(request) {
  return navigator.serviceWorker.ready.then(registration => {
    return new Promise((resolve, reject) => {
      var channel = new MessageChannel();
      channel.port1.onmessage = e => {
        (e.data.error ? reject : resolve)(e.data);
      };
      registration.active.postMessage(request, [channel.port2]);
    });
  });
}

function waitForActive(swr) {
  let sw = swr.installing || swr.waiting || swr.active;
  return new Promise(resolve => {
    if (sw.state === "activated") {
      resolve(swr);
      return;
    }
    sw.addEventListener("statechange", function onStateChange() {
      if (sw.state === "activated") {
        sw.removeEventListener("statechange", onStateChange);
        resolve(swr);
      }
    });
  });
}

function base64UrlDecode(s) {
  s = s.replace(/-/g, "+").replace(/_/g, "/");

  // Replace padding if it was stripped by the sender.
  // See http://tools.ietf.org/html/rfc4648#section-4
  switch (s.length % 4) {
    case 0:
      break; // No pad chars in this case
    case 2:
      s += "==";
      break; // Two pad chars
    case 3:
      s += "=";
      break; // One pad char
    default:
      throw new Error("Illegal base64url string!");
  }

  // With correct padding restored, apply the standard base64 decoder
  var decoded = atob(s);

  var array = new Uint8Array(new ArrayBuffer(decoded.length));
  for (var i = 0; i < decoded.length; i++) {
    array[i] = decoded.charCodeAt(i);
  }
  return array;
}
