/* Any copyright is dedicated to the Public Domain.
 * http://creativecommons.org/publicdomain/zero/1.0/ */

"use strict";

const { FxAccounts } = ChromeUtils.importESModule(
  "resource://gre/modules/FxAccounts.sys.mjs"
);
const { FxAccountsClient } = ChromeUtils.importESModule(
  "resource://gre/modules/FxAccountsClient.sys.mjs"
);
var { AccountState } = ChromeUtils.importESModule(
  "resource://gre/modules/FxAccounts.sys.mjs"
);

function promiseNotification(topic) {
  return new Promise(resolve => {
    let observe = () => {
      Services.obs.removeObserver(observe, topic);
      resolve();
    };
    Services.obs.addObserver(observe, topic);
  });
}

// Just enough mocks so we can avoid hawk and storage.
function MockStorageManager() {}

MockStorageManager.prototype = {
  promiseInitialized: Promise.resolve(),

  initialize(accountData) {
    this.accountData = accountData;
  },

  finalize() {
    return Promise.resolve();
  },

  getAccountData() {
    return Promise.resolve(this.accountData);
  },

  updateAccountData(updatedFields) {
    for (let [name, value] of Object.entries(updatedFields)) {
      if (value == null) {
        delete this.accountData[name];
      } else {
        this.accountData[name] = value;
      }
    }
    return Promise.resolve();
  },

  deleteAccountData() {
    this.accountData = null;
    return Promise.resolve();
  },
};

function MockFxAccountsClient(activeTokens) {
  this._email = "nobody@example.com";
  this._verified = false;

  this.accountStatus = function (uid) {
    return Promise.resolve(!!uid && !this._deletedOnServer);
  };

  this.signOut = function () {
    return Promise.resolve();
  };
  this.registerDevice = function () {
    return Promise.resolve();
  };
  this.updateDevice = function () {
    return Promise.resolve();
  };
  this.signOutAndDestroyDevice = function () {
    return Promise.resolve();
  };
  this.getDeviceList = function () {
    return Promise.resolve();
  };
  this.accessTokenWithSessionToken = function (
    sessionTokenHex,
    clientId,
    scope,
    ttl
  ) {
    let token = `token${this.numTokenFetches}`;
    if (ttl) {
      token += `-ttl-${ttl}`;
    }
    this.numTokenFetches += 1;
    this.activeTokens.add(token);
    print("accessTokenWithSessionToken returning token", token);
    return Promise.resolve({ access_token: token, ttl });
  };
  this.oauthDestroy = sinon.stub().callsFake((_clientId, token) => {
    this.activeTokens.delete(token);
    return Promise.resolve();
  });

  // Test only stuff.
  this.activeTokens = activeTokens;
  this.numTokenFetches = 0;

  FxAccountsClient.apply(this);
}

MockFxAccountsClient.prototype = {};
Object.setPrototypeOf(
  MockFxAccountsClient.prototype,
  FxAccountsClient.prototype
);

function MockFxAccounts() {
  // The FxA "auth" and "oauth" servers both share the same db of tokens,
  // so we need to simulate the same here in the tests.
  const activeTokens = new Set();
  return new FxAccounts({
    fxAccountsClient: new MockFxAccountsClient(activeTokens),
    newAccountState(credentials) {
      // we use a real accountState but mocked storage.
      let storage = new MockStorageManager();
      storage.initialize(credentials);
      return new AccountState(storage);
    },
    _getDeviceName() {
      return "mock device name";
    },
    fxaPushService: {
      registerPushEndpoint() {
        return new Promise(resolve => {
          resolve({
            endpoint: "http://mochi.test:8888",
          });
        });
      },
    },
  });
}

async function createMockFxA() {
  let fxa = new MockFxAccounts();
  let credentials = {
    email: "foo@example.com",
    uid: "1234@lcip.org",
    sessionToken: "dead",
    scopedKeys: {
      [SCOPE_OLD_SYNC]: {
        kid: "key id for sync key",
        k: "key material for sync key",
        kty: "oct",
      },
    },
    verified: true,
  };

  await fxa._internal.setSignedInUser(credentials);
  return fxa;
}

// The tests.

add_task(async function testRevoke() {
  let tokenOptions = { scope: "test-scope" };
  let fxa = await createMockFxA();
  let client = fxa._internal.fxAccountsClient;

  // get our first token and check we hit the mock.
  let token1 = await fxa.getOAuthToken(tokenOptions);
  equal(client.numTokenFetches, 1);
  equal(client.activeTokens.size, 1);
  ok(token1, "got a token");
  equal(token1, "token0");

  // drop the new token from our cache.
  await fxa.removeCachedOAuthToken({ token: token1 });
  ok(client.oauthDestroy.calledOnce);

  // the revoke should have been successful.
  equal(client.activeTokens.size, 0);
  // fetching it again hits the server.
  let token2 = await fxa.getOAuthToken(tokenOptions);
  equal(client.numTokenFetches, 2);
  equal(client.activeTokens.size, 1);
  ok(token2, "got a token");
  notEqual(token1, token2, "got a different token");
});

add_task(async function testSignOutDestroysTokens() {
  let fxa = await createMockFxA();
  let client = fxa._internal.fxAccountsClient;

  // get our first token and check we hit the mock.
  let token1 = await fxa.getOAuthToken({ scope: "test-scope" });
  equal(client.numTokenFetches, 1);
  equal(client.activeTokens.size, 1);
  ok(token1, "got a token");

  // get another
  let token2 = await fxa.getOAuthToken({ scope: "test-scope-2" });
  equal(client.numTokenFetches, 2);
  equal(client.activeTokens.size, 2);
  ok(token2, "got a token");
  notEqual(token1, token2, "got a different token");

  // FxA fires an observer when the "background" signout is complete.
  let signoutComplete = promiseNotification("testhelper-fxa-signout-complete");
  // now sign out - they should be removed.
  await fxa.signOut();
  await signoutComplete;
  ok(client.oauthDestroy.calledTwice);
  // No active tokens left.
  equal(client.activeTokens.size, 0);
});

add_task(async function testTokenRaces() {
  // Here we do 2 concurrent fetches each for 2 different token scopes (ie,
  // 4 token fetches in total).
  // This should provoke a potential race in the token fetching but we use
  // a map of in-flight token fetches, so we should still only perform 2
  // fetches, but each of the 4 calls should resolve with the correct values.
  let fxa = await createMockFxA();
  let client = fxa._internal.fxAccountsClient;

  let results = await Promise.all([
    fxa.getOAuthToken({ scope: "test-scope" }),
    fxa.getOAuthToken({ scope: "test-scope" }),
    fxa.getOAuthToken({ scope: "test-scope-2" }),
    fxa.getOAuthToken({ scope: "test-scope-2" }),
  ]);

  equal(client.numTokenFetches, 2, "should have fetched 2 tokens.");

  // Should have 2 unique tokens
  results.sort();
  equal(results[0], results[1]);
  equal(results[2], results[3]);
  // should be 2 active.
  equal(client.activeTokens.size, 2);
  await fxa.removeCachedOAuthToken({ token: results[0] });
  equal(client.activeTokens.size, 1);
  await fxa.removeCachedOAuthToken({ token: results[2] });
  equal(client.activeTokens.size, 0);
  ok(client.oauthDestroy.calledTwice);
});

add_task(async function testTokenTTL() {
  // This tests the TTL option passed into the method
  let fxa = await createMockFxA();
  let token = await fxa.getOAuthToken({ scope: "test-ttl", ttl: 1000 });
  equal(token, "token0-ttl-1000");
});
