/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

const { setTimeout } = ChromeUtils.importESModule(
  "resource://gre/modules/Timer.sys.mjs"
);

const {
  AnimationFramePromise,
  Deferred,
  EventPromise,
  PollPromise,
  TimedPromise,
} = ChromeUtils.importESModule("chrome://remote/content/shared/Sync.sys.mjs");

const { Log } = ChromeUtils.importESModule(
  "resource://gre/modules/Log.sys.mjs"
);

/**
 * Mimic a DOM node for listening for events.
 */
class MockElement {
  constructor() {
    this.capture = false;
    this.eventName = null;
    this.func = null;
    this.mozSystemGroup = false;
    this.wantUntrusted = false;
    this.untrusted = false;
  }

  addEventListener(name, func, options = {}) {
    const { capture, mozSystemGroup, wantUntrusted } = options;

    this.eventName = name;
    this.func = func;
    this.capture = capture ?? false;
    this.mozSystemGroup = mozSystemGroup ?? false;
    this.wantUntrusted = wantUntrusted ?? false;
  }

  click() {
    if (this.func) {
      const event = {
        capture: this.capture,
        mozSystemGroup: this.mozSystemGroup,
        target: this,
        type: this.eventName,
        untrusted: this.untrusted,
        wantUntrusted: this.wantUntrusted,
      };
      this.func(event);
    }
  }

  dispatchEvent() {
    if (this.wantUntrusted) {
      this.untrusted = true;
    }
    this.click();
  }

  removeEventListener() {
    this.capture = false;
    this.eventName = null;
    this.func = null;
    this.mozSystemGroup = false;
    this.untrusted = false;
    this.wantUntrusted = false;
  }
}

class MockAppender extends Log.Appender {
  constructor(formatter) {
    super(formatter);
    this.messages = [];
  }

  append(message) {
    this.doAppend(message);
  }

  doAppend(message) {
    this.messages.push(message);
  }
}

add_task(async function test_AnimationFramePromise() {
  for (const options of [
    undefined, // default options
    {}, //empty options
    { timeout: null }, // disabled timeout
    { timeout: 1234 }, // custom timeout
  ]) {
    let called = false;
    let win = {
      addEventListener(_event, _listener) {},
      requestAnimationFrame(callback) {
        called = true;
        callback();
      },
    };
    await AnimationFramePromise(win, options);
    ok(called);
  }
});

const mockNoopWindow = {
  addEventListener(_event, _listener) {},
  requestAnimationFrame(_callback) {},
};

add_task(async function test_AnimationFramePromiseDefaultTimeout() {
  await AnimationFramePromise(mockNoopWindow);
});

add_task(async function test_AnimationFramePromiseCustomTimeout() {
  await AnimationFramePromise(mockNoopWindow, { timeout: 10 });
});

add_task(async function test_AnimationFramePromiseAbortOnPageHide() {
  let resolvePageHideEvent;

  const mockWindow = {
    addEventListener(event, listener) {
      if (event === "pagehide") {
        resolvePageHideEvent = listener;
      }
    },
    removeEventListener() {},
    requestAnimationFrame(callback) {
      // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
      setTimeout(() => callback(), 10000);
    },
  };

  const trackedPromise = trackPromise(AnimationFramePromise(mockWindow));

  ok(trackedPromise.isPending(), "AnimationFramePromise is pending");

  // Simulate "pagehide" event.
  resolvePageHideEvent({});

  await trackedPromise;
});

add_task(async function test_AnimationFramePromiseTimeoutErrors() {
  // not an number or null
  for (const val of ["foo", true, [], {}]) {
    Assert.throws(
      () => AnimationFramePromise(mockNoopWindow, { timeout: val }),
      /TypeError: timeout must be a number or null/
    );
  }

  // not a nonnegative integer
  for (const val of [-1, -100000, -1.2, 1.2]) {
    Assert.throws(
      () => AnimationFramePromise(mockNoopWindow, { timeout: val }),
      /RangeError: timeout must be a non-negative integer/
    );
  }
});

add_task(async function test_DeferredPending() {
  const deferred = Deferred();
  ok(deferred.pending);

  deferred.resolve();
  await deferred.promise;
  ok(!deferred.pending);
});

add_task(async function test_DeferredRejected() {
  const deferred = Deferred();

  // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
  setTimeout(() => deferred.reject(new Error("foo")), 100);

  try {
    await deferred.promise;
    ok(false);
  } catch (e) {
    ok(!deferred.pending);

    ok(!deferred.fulfilled);
    ok(deferred.rejected);
    equal(e.message, "foo");
  }
});

add_task(async function test_DeferredResolved() {
  const deferred = Deferred();
  ok(deferred.pending);

  // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
  setTimeout(() => deferred.resolve("foo"), 100);

  const result = await deferred.promise;
  ok(!deferred.pending);

  ok(deferred.fulfilled);
  ok(!deferred.rejected);
  equal(result, "foo");
});

add_task(async function test_EventPromise_subjectTypes() {
  for (const subject of ["foo", 42, null, undefined, true, [], {}]) {
    Assert.throws(() => new EventPromise(subject, "click"), /TypeError/);
  }
});

add_task(async function test_EventPromise_eventNameTypes() {
  const element = new MockElement();

  for (const eventName of [42, null, undefined, true, [], {}]) {
    Assert.throws(() => new EventPromise(element, eventName), /TypeError/);
  }
});

add_task(async function test_EventPromise_subjectAndEventNameEvent() {
  const element = new MockElement();

  const clicked = new EventPromise(element, "click");
  element.click();
  const event = await clicked;

  equal(element, event.target);
});

add_task(async function test_EventPromise_captureTypes() {
  const element = new MockElement();

  for (const capture of [null, "foo", 42, [], {}]) {
    Assert.throws(
      () => new EventPromise(element, "click", { capture }),
      /TypeError/
    );
  }
});

add_task(async function test_EventPromise_captureEvent() {
  const element = new MockElement();

  for (const capture of [undefined, false, true]) {
    const expectedCapture = capture ?? false;

    const clicked = new EventPromise(element, "click", { capture });
    element.click();
    const event = await clicked;

    equal(element, event.target);
    equal(expectedCapture, event.capture);
  }
});

add_task(async function test_EventPromise_checkFnTypes() {
  const element = new MockElement();

  for (const checkFn of ["foo", 42, true, [], {}]) {
    Assert.throws(
      () => new EventPromise(element, "click", { checkFn }),
      /TypeError/
    );
  }
});

add_task(async function test_EventPromise_checkFnCallback() {
  const element = new MockElement();

  let count;
  const data = [
    { checkFn: null, expected_count: 0 },
    { checkFn: undefined, expected_count: 0 },
    {
      checkFn: () => {
        throw new Error("foo");
      },
      expected_count: 0,
    },
    { checkFn: () => count++ > 0, expected_count: 2 },
  ];

  for (const { checkFn, expected_count } of data) {
    count = 0;

    const clicked = new EventPromise(element, "click", { checkFn });
    element.click();
    element.click();
    const event = await clicked;

    equal(element, event.target);
    equal(expected_count, count);
  }
});

add_task(async function test_EventPromise_mozSystemGroupTypes() {
  const element = new MockElement();

  for (const mozSystemGroup of [null, "foo", 42, [], {}]) {
    Assert.throws(
      () => new EventPromise(element, "click", { mozSystemGroup }),
      /TypeError/
    );
  }
});

add_task(async function test_EventPromise_mozSystemGroupEvent() {
  const element = new MockElement();

  for (const mozSystemGroup of [undefined, false, true]) {
    const expectedMozSystemGroup = mozSystemGroup ?? false;

    const clicked = new EventPromise(element, "click", { mozSystemGroup });
    element.click();
    const event = await clicked;

    equal(element, event.target);
    equal(expectedMozSystemGroup, event.mozSystemGroup);
  }
});

add_task(async function test_EventPromise_wantUntrustedTypes() {
  const element = new MockElement();

  for (let wantUntrusted of [null, "foo", 42, [], {}]) {
    Assert.throws(
      () => new EventPromise(element, "click", { wantUntrusted }),
      /TypeError/
    );
  }
});

add_task(async function test_EventPromise_wantUntrustedEvent() {
  for (const wantUntrusted of [undefined, false, true]) {
    let expected_untrusted = wantUntrusted ?? false;

    const element = new MockElement();

    const clicked = new EventPromise(element, "click", { wantUntrusted });
    element.dispatchEvent(new CustomEvent("click", {}));
    const event = await clicked;

    equal(element, event.target);
    equal(expected_untrusted, event.untrusted);
  }
});

add_task(function test_executeSoon_callback() {
  // executeSoon() is already defined for xpcshell in head.js. As such import
  // our implementation into a custom namespace.
  let sync = ChromeUtils.importESModule(
    "chrome://remote/content/shared/Sync.sys.mjs"
  );

  for (let func of ["foo", null, true, [], {}]) {
    Assert.throws(() => sync.executeSoon(func), /TypeError/);
  }

  let a;
  sync.executeSoon(() => {
    a = 1;
  });
  executeSoon(() => equal(1, a));
});

add_task(function test_PollPromise_funcTypes() {
  for (let type of ["foo", 42, null, undefined, true, [], {}]) {
    Assert.throws(() => new PollPromise(type), /TypeError/);
  }
  new PollPromise(() => {});
  new PollPromise(function () {});
});

add_task(function test_PollPromise_timeoutTypes() {
  for (let timeout of ["foo", true, [], {}]) {
    Assert.throws(() => new PollPromise(() => {}, { timeout }), /TypeError/);
  }
  for (let timeout of [1.2, -1]) {
    Assert.throws(() => new PollPromise(() => {}, { timeout }), /RangeError/);
  }
  for (let timeout of [null, undefined, 42]) {
    new PollPromise(resolve => resolve(1), { timeout });
  }
});

add_task(function test_PollPromise_intervalTypes() {
  for (let interval of ["foo", null, true, [], {}]) {
    Assert.throws(() => new PollPromise(() => {}, { interval }), /TypeError/);
  }
  for (let interval of [1.2, -1]) {
    Assert.throws(() => new PollPromise(() => {}, { interval }), /RangeError/);
  }
  new PollPromise(() => {}, { interval: 42 });
});

add_task(async function test_PollPromise_retvalTypes() {
  for (let typ of [true, false, "foo", 42, [], {}]) {
    strictEqual(typ, await new PollPromise(resolve => resolve(typ)));
  }
});

add_task(async function test_PollPromise_rethrowError() {
  let nevals = 0;
  let err;
  try {
    await PollPromise(() => {
      ++nevals;
      throw new Error();
    });
  } catch (e) {
    err = e;
  }
  equal(1, nevals);
  ok(err instanceof Error);
});

add_task(async function test_PollPromise_noTimeout() {
  let nevals = 0;
  await new PollPromise((resolve, reject) => {
    ++nevals;
    nevals < 100 ? reject() : resolve();
  });
  equal(100, nevals);
});

add_task(async function test_PollPromise_zeroTimeout() {
  // run at least once when timeout is 0
  let nevals = 0;
  let start = new Date().getTime();
  await new PollPromise(
    (resolve, reject) => {
      ++nevals;
      reject();
    },
    { timeout: 0 }
  );
  let end = new Date().getTime();
  equal(1, nevals);
  less(end - start, 500);
});

add_task(async function test_PollPromise_timeoutElapse() {
  let nevals = 0;
  let start = new Date().getTime();
  await new PollPromise(
    (resolve, reject) => {
      ++nevals;
      reject();
    },
    { timeout: 100 }
  );
  let end = new Date().getTime();
  lessOrEqual(nevals, 11);
  greaterOrEqual(end - start, 100);
});

add_task(async function test_PollPromise_interval() {
  let nevals = 0;
  await new PollPromise(
    (resolve, reject) => {
      ++nevals;
      reject();
    },
    { timeout: 100, interval: 100 }
  );
  equal(2, nevals);
});

add_task(async function test_PollPromise_resolve() {
  const log = Log.repository.getLogger("RemoteAgent");
  const appender = new MockAppender(new Log.BasicFormatter());
  appender.level = Log.Level.Info;
  log.addAppender(appender);

  const errorMessage = "PollingFailed";
  const timeout = 100;

  await new PollPromise(
    resolve => {
      resolve();
    },
    { timeout, errorMessage }
  );
  Assert.equal(appender.messages.length, 0);

  await new PollPromise(
    (resolve, reject) => {
      reject();
    },
    { timeout, errorMessage: "PollingFailed" }
  );
  Assert.equal(appender.messages.length, 1);
  Assert.equal(appender.messages[0].level, Log.Level.Warn);
  Assert.equal(appender.messages[0].message, "PollingFailed after 100 ms");
});

add_task(function test_TimedPromise_funcTypes() {
  for (let type of ["foo", 42, null, undefined, true, [], {}]) {
    Assert.throws(() => new TimedPromise(type), /TypeError/);
  }
  new TimedPromise(resolve => resolve());
  new TimedPromise(function (resolve) {
    resolve();
  });
});

add_task(function test_TimedPromise_timeoutTypes() {
  for (let timeout of ["foo", null, true, [], {}]) {
    Assert.throws(
      () => new TimedPromise(resolve => resolve(), { timeout }),
      /TypeError/
    );
  }
  for (let timeout of [1.2, -1]) {
    Assert.throws(
      () => new TimedPromise(resolve => resolve(), { timeout }),
      /RangeError/
    );
  }
  new TimedPromise(resolve => resolve(), { timeout: 42 });
});

add_task(async function test_TimedPromise_errorMessage() {
  try {
    await new TimedPromise(() => {}, { timeout: 0 });
    ok(false, "Expected Timeout error not raised");
  } catch (e) {
    ok(
      e.message.includes("TimedPromise timed out after"),
      "Expected default error message found"
    );
  }

  try {
    await new TimedPromise(() => {}, {
      errorMessage: "Not found",
      timeout: 0,
    });
    ok(false, "Expected Timeout error not raised");
  } catch (e) {
    ok(
      e.message.includes("Not found after"),
      "Expected custom error message found"
    );
  }
});
