"use strict";

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

let { BaseContext, LocalAPIImplementation } = ExtensionCommon;

let schemaJson = [
  {
    namespace: "testnamespace",
    types: [
      {
        id: "Widget",
        type: "object",
        properties: {
          size: { type: "integer" },
          colour: { type: "string", optional: true },
        },
      },
    ],
    functions: [
      {
        name: "one_required",
        type: "function",
        parameters: [
          {
            name: "first",
            type: "function",
            parameters: [],
          },
        ],
      },
      {
        name: "one_optional",
        type: "function",
        parameters: [
          {
            name: "first",
            type: "function",
            parameters: [],
            optional: true,
          },
        ],
      },
      {
        name: "async_required",
        type: "function",
        async: "first",
        parameters: [
          {
            name: "first",
            type: "function",
            parameters: [],
          },
        ],
      },
      {
        name: "async_optional",
        type: "function",
        async: "first",
        parameters: [
          {
            name: "first",
            type: "function",
            parameters: [],
            optional: true,
          },
        ],
      },
      {
        name: "async_result",
        type: "function",
        async: "callback",
        parameters: [
          {
            name: "callback",
            type: "function",
            parameters: [
              {
                name: "widget",
                $ref: "Widget",
              },
            ],
          },
        ],
      },
    ],
  },
];

const global = this;
class StubContext extends BaseContext {
  constructor() {
    let fakeExtension = { id: "test@web.extension" };
    super("testEnv", fakeExtension);
    this.sandbox = Cu.Sandbox(global);
  }

  get cloneScope() {
    return this.sandbox;
  }

  get principal() {
    return Cu.getObjectPrincipal(this.sandbox);
  }
}

let context;

function generateAPIs(extraWrapper, apiObj) {
  context = new StubContext();
  let localWrapper = {
    manifestVersion: 2,
    cloneScope: global,
    shouldInject() {
      return true;
    },
    getImplementation(namespace, name) {
      return new LocalAPIImplementation(apiObj, name, context);
    },
  };
  Object.assign(localWrapper, extraWrapper);

  let root = {};
  Schemas.inject(root, localWrapper);
  return root.testnamespace;
}

add_task(async function testParameterValidation() {
  await Schemas.load("data:," + JSON.stringify(schemaJson));

  let testnamespace;
  function assertThrows(name, ...args) {
    Assert.throws(
      () => testnamespace[name](...args),
      /Incorrect argument types/,
      `Expected testnamespace.${name}(${args.map(String).join(", ")}) to throw.`
    );
  }
  function assertNoThrows(name, ...args) {
    try {
      testnamespace[name](...args);
    } catch (e) {
      info(
        `testnamespace.${name}(${args
          .map(String)
          .join(", ")}) unexpectedly threw.`
      );
      throw new Error(e);
    }
  }
  let cb = () => {};

  for (let isChromeCompat of [true, false]) {
    info(`Testing API validation with isChromeCompat=${isChromeCompat}`);
    testnamespace = generateAPIs(
      {
        isChromeCompat,
      },
      {
        one_required() {},
        one_optional() {},
        async_required() {},
        async_optional() {},
      }
    );

    assertThrows("one_required");
    assertThrows("one_required", null);
    assertNoThrows("one_required", cb);
    assertThrows("one_required", cb, null);
    assertThrows("one_required", cb, cb);

    assertNoThrows("one_optional");
    assertNoThrows("one_optional", null);
    assertNoThrows("one_optional", cb);
    assertThrows("one_optional", cb, null);
    assertThrows("one_optional", cb, cb);

    // Schema-based validation happens before an async method is called, so
    // errors should be thrown synchronously.

    // The parameter was declared as required, but there was also an "async"
    // attribute with the same value as the parameter name, so the callback
    // parameter is actually optional.
    assertNoThrows("async_required");
    assertNoThrows("async_required", null);
    assertNoThrows("async_required", cb);
    assertThrows("async_required", cb, null);
    assertThrows("async_required", cb, cb);

    assertNoThrows("async_optional");
    assertNoThrows("async_optional", null);
    assertNoThrows("async_optional", cb);
    assertThrows("async_optional", cb, null);
    assertThrows("async_optional", cb, cb);
  }
});

add_task(async function testCheckAsyncResults() {
  await Schemas.load("data:," + JSON.stringify(schemaJson));

  const complete = generateAPIs(
    {},
    {
      async_result: async () => ({ size: 5, colour: "green" }),
    }
  );

  const optional = generateAPIs(
    {},
    {
      async_result: async () => ({ size: 6 }),
    }
  );

  const invalid = generateAPIs(
    {},
    {
      async_result: async () => ({}),
    }
  );

  deepEqual(await complete.async_result(), { size: 5, colour: "green" });

  deepEqual(
    await optional.async_result(),
    { size: 6 },
    "Missing optional properties is allowed"
  );

  if (AppConstants.DEBUG) {
    await Assert.rejects(
      invalid.async_result(),
      /Type error for widget value \(Property "size" is required\)/,
      "Should throw for invalid callback argument in DEBUG builds"
    );
  } else {
    deepEqual(
      await invalid.async_result(),
      {},
      "Invalid callback argument doesn't throw in release builds"
    );
  }
});

add_task(async function testAsyncResults() {
  await Schemas.load("data:," + JSON.stringify(schemaJson));
  function runWithCallback(func) {
    info(`Calling testnamespace.${func.name}, expecting callback with result`);
    return new Promise(resolve => {
      let result = "uninitialized value";
      let returnValue = func(reply => {
        result = reply;
        resolve(result);
      });
      // When a callback is given, the return value must be missing.
      Assert.equal(returnValue, undefined);
      // Callback must be called asynchronously.
      Assert.equal(result, "uninitialized value");
    });
  }

  function runFailCallback(func) {
    info(`Calling testnamespace.${func.name}, expecting callback with error`);
    return new Promise(resolve => {
      func(reply => {
        Assert.equal(reply, undefined);
        resolve(context.lastError.message); // eslint-disable-line no-undef
      });
    });
  }

  for (let isChromeCompat of [true, false]) {
    info(`Testing API invocation with isChromeCompat=${isChromeCompat}`);
    let testnamespace = generateAPIs(
      {
        isChromeCompat,
      },
      {
        async_required(cb) {
          Assert.equal(cb, undefined);
          return Promise.resolve(1);
        },
        async_optional(cb) {
          Assert.equal(cb, undefined);
          return Promise.resolve(2);
        },
      }
    );
    if (!isChromeCompat) {
      // No promises for chrome.
      info("testnamespace.async_required should be a Promise");
      let promise = testnamespace.async_required();
      Assert.ok(promise instanceof context.cloneScope.Promise);
      Assert.equal(await promise, 1);

      info("testnamespace.async_optional should be a Promise");
      promise = testnamespace.async_optional();
      Assert.ok(promise instanceof context.cloneScope.Promise);
      Assert.equal(await promise, 2);
    }

    Assert.equal(await runWithCallback(testnamespace.async_required), 1);
    Assert.equal(await runWithCallback(testnamespace.async_optional), 2);

    let otherSandbox = Cu.Sandbox(null, {});
    let errorFactories = [
      msg => {
        throw new context.cloneScope.Error(msg);
      },
      msg => context.cloneScope.Promise.reject({ message: msg }),
      msg => Cu.evalInSandbox(`throw new Error("${msg}")`, otherSandbox),
      msg =>
        Cu.evalInSandbox(`Promise.reject({message: "${msg}"})`, otherSandbox),
    ];
    for (let makeError of errorFactories) {
      info(`Testing callback/promise with error caused by: ${makeError}`);
      testnamespace = generateAPIs(
        {
          isChromeCompat,
        },
        {
          async_required() {
            return makeError("ONE");
          },
          async_optional() {
            return makeError("TWO");
          },
        }
      );

      if (!isChromeCompat) {
        // No promises for chrome.
        await Assert.rejects(
          testnamespace.async_required(),
          /ONE/,
          "should reject testnamespace.async_required()"
        );
        await Assert.rejects(
          testnamespace.async_optional(),
          /TWO/,
          "should reject testnamespace.async_optional()"
        );
      }

      Assert.equal(await runFailCallback(testnamespace.async_required), "ONE");
      Assert.equal(await runFailCallback(testnamespace.async_optional), "TWO");
    }
  }
});
