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

"use strict";

const { FxAccountsPairingFlow } = ChromeUtils.importESModule(
  "resource://gre/modules/FxAccountsPairing.sys.mjs"
);
const { EventEmitter } = ChromeUtils.importESModule(
  "resource://gre/modules/EventEmitter.sys.mjs"
);
ChromeUtils.defineESModuleGetters(this, {
  jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs",
});
XPCOMUtils.defineLazyGlobalGetters(this, ["crypto"]);

const CHANNEL_ID = "sW-UA97Q6Dljqen7XRlYPw";
const CHANNEL_KEY = crypto.getRandomValues(new Uint8Array(32));

const SENDER_SUPP = {
  ua: "Firefox Supp",
  city: "Nice",
  region: "PACA",
  country: "France",
  remote: "127.0.0.1",
};
const UID = "abcd";
const EMAIL = "foo@bar.com";
const AVATAR = "https://foo.bar/avatar";
const DISPLAY_NAME = "Foo bar";
const DEVICE_NAME = "Foo's computer";

const PAIR_URI = "https://foo.bar/pair";
const OAUTH_URI = "https://foo.bar/oauth";
const KSYNC = "myksync";
const SESSION = "mysession";
const fxaConfig = {
  promisePairingURI() {
    return PAIR_URI;
  },
  promiseOAuthURI() {
    return OAUTH_URI;
  },
};
const fxAccounts = {
  getSignedInUser() {
    return {
      uid: UID,
      email: EMAIL,
      avatar: AVATAR,
      displayName: DISPLAY_NAME,
    };
  },
  async _withVerifiedAccountState(cb) {
    return cb({
      async getUserAccountData() {
        return {
          sessionToken: SESSION,
        };
      },
    });
  },
  _internal: {
    keys: {
      getKeyForScope(scope) {
        return {
          kid: "123456",
          k: KSYNC,
          kty: "oct",
        };
      },
    },
    fxAccountsClient: {
      async getScopedKeyData() {
        return {
          "https://identity.mozilla.com/apps/oldsync": {
            identifier: "https://identity.mozilla.com/apps/oldsync",
            keyRotationTimestamp: 12345678,
          },
        };
      },
      async oauthAuthorize() {
        return { code: "mycode", state: "mystate" };
      },
    },
  },
};
const weave = {
  Service: { clientsEngine: { localName: DEVICE_NAME } },
};

class MockPairingChannel extends EventTarget {
  get channelId() {
    return CHANNEL_ID;
  }

  get channelKey() {
    return CHANNEL_KEY;
  }

  send(data) {
    this.dispatchEvent(
      new CustomEvent("send", {
        detail: { data },
      })
    );
  }

  simulateIncoming(data) {
    this.dispatchEvent(
      new CustomEvent("message", {
        detail: { data, sender: SENDER_SUPP },
      })
    );
  }

  close() {
    this.closed = true;
  }
}

add_task(async function testFullFlow() {
  const emitter = new EventEmitter();
  const pairingChannel = new MockPairingChannel();
  const pairingUri = await FxAccountsPairingFlow.start({
    emitter,
    pairingChannel,
    fxAccounts,
    fxaConfig,
    weave,
  });
  Assert.equal(
    pairingUri,
    `${PAIR_URI}#channel_id=${CHANNEL_ID}&channel_key=${ChromeUtils.base64URLEncode(
      CHANNEL_KEY,
      { pad: false }
    )}`
  );

  const flow = FxAccountsPairingFlow.get(CHANNEL_ID);

  const promiseSwitchToWebContent = emitter.once("view:SwitchToWebContent");
  const promiseMetadataSent = promiseOutgoingMessage(pairingChannel);
  const epk = await generateEphemeralKeypair();

  pairingChannel.simulateIncoming({
    message: "pair:supp:request",
    data: {
      client_id: "client_id_1",
      state: "mystate",
      keys_jwk: ChromeUtils.base64URLEncode(
        new TextEncoder().encode(JSON.stringify(epk.publicJWK)),
        { pad: false }
      ),
      scope: "profile https://identity.mozilla.com/apps/oldsync",
      code_challenge: "chal",
      code_challenge_method: "S256",
    },
  });
  const sentAuthMetadata = await promiseMetadataSent;
  Assert.deepEqual(sentAuthMetadata, {
    message: "pair:auth:metadata",
    data: {
      email: EMAIL,
      avatar: AVATAR,
      displayName: DISPLAY_NAME,
      deviceName: DEVICE_NAME,
    },
  });
  const oauthUrl = await promiseSwitchToWebContent;
  Assert.equal(
    oauthUrl,
    `${OAUTH_URI}?client_id=client_id_1&scope=profile+https%3A%2F%2Fidentity.mozilla.com%2Fapps%2Foldsync&email=foo%40bar.com&uid=abcd&channel_id=${CHANNEL_ID}&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob%3Apair-auth-webchannel`
  );

  let pairSuppMetadata = await simulateIncomingWebChannel(
    flow,
    "fxaccounts:pair_supplicant_metadata"
  );
  Assert.deepEqual(
    {
      ua: "Firefox Supp",
      city: "Nice",
      region: "PACA",
      country: "France",
      ipAddress: "127.0.0.1",
    },
    pairSuppMetadata
  );

  const generateJWE = sinon.spy(jwcrypto, "generateJWE");
  const oauthAuthorize = sinon.spy(
    fxAccounts._internal.fxAccountsClient,
    "oauthAuthorize"
  );
  const promiseOAuthParamsMsg = promiseOutgoingMessage(pairingChannel);
  await simulateIncomingWebChannel(flow, "fxaccounts:pair_authorize");
  // We should have generated the expected JWE.
  Assert.ok(generateJWE.calledOnce);
  const generateArgs = generateJWE.firstCall.args;
  Assert.deepEqual(generateArgs[0], epk.publicJWK);
  Assert.deepEqual(JSON.parse(new TextDecoder().decode(generateArgs[1])), {
    "https://identity.mozilla.com/apps/oldsync": {
      kid: "123456",
      k: KSYNC,
      kty: "oct",
    },
  });
  // We should have authorized an oauth code with expected parameters.
  Assert.ok(oauthAuthorize.calledOnce);
  const oauthCodeArgs = oauthAuthorize.firstCall.args[1];
  console.log(oauthCodeArgs);
  Assert.ok(!oauthCodeArgs.keys_jwk);
  Assert.deepEqual(
    oauthCodeArgs.keys_jwe,
    await generateJWE.firstCall.returnValue
  );
  Assert.equal(oauthCodeArgs.client_id, "client_id_1");
  Assert.equal(oauthCodeArgs.access_type, "offline");
  Assert.equal(oauthCodeArgs.state, "mystate");
  Assert.equal(
    oauthCodeArgs.scope,
    "profile https://identity.mozilla.com/apps/oldsync"
  );
  Assert.equal(oauthCodeArgs.code_challenge, "chal");
  Assert.equal(oauthCodeArgs.code_challenge_method, "S256");

  const oAuthParams = await promiseOAuthParamsMsg;
  Assert.deepEqual(oAuthParams, {
    message: "pair:auth:authorize",
    data: { code: "mycode", state: "mystate" },
  });

  let heartbeat = await simulateIncomingWebChannel(
    flow,
    "fxaccounts:pair_heartbeat"
  );
  Assert.ok(!heartbeat.suppAuthorized);

  await pairingChannel.simulateIncoming({
    message: "pair:supp:authorize",
  });

  heartbeat = await simulateIncomingWebChannel(
    flow,
    "fxaccounts:pair_heartbeat"
  );
  Assert.ok(heartbeat.suppAuthorized);

  await simulateIncomingWebChannel(flow, "fxaccounts:pair_complete");
  // The flow should have been destroyed!
  Assert.ok(!FxAccountsPairingFlow.get(CHANNEL_ID));
  Assert.ok(pairingChannel.closed);
  generateJWE.restore();
  oauthAuthorize.restore();
});

add_task(async function testUnknownPairingMessage() {
  const emitter = new EventEmitter();
  const pairingChannel = new MockPairingChannel();
  await FxAccountsPairingFlow.start({
    emitter,
    pairingChannel,
    fxAccounts,
    fxaConfig,
    weave,
  });
  const flow = FxAccountsPairingFlow.get(CHANNEL_ID);
  const viewErrorObserved = emitter.once("view:Error");
  pairingChannel.simulateIncoming({
    message: "pair:boom",
  });
  await viewErrorObserved;
  let heartbeat = await simulateIncomingWebChannel(
    flow,
    "fxaccounts:pair_heartbeat"
  );
  Assert.ok(heartbeat.err);
});

add_task(async function testUnknownWebChannelCommand() {
  const emitter = new EventEmitter();
  const pairingChannel = new MockPairingChannel();
  await FxAccountsPairingFlow.start({
    emitter,
    pairingChannel,
    fxAccounts,
    fxaConfig,
    weave,
  });
  const flow = FxAccountsPairingFlow.get(CHANNEL_ID);
  const viewErrorObserved = emitter.once("view:Error");
  await simulateIncomingWebChannel(flow, "fxaccounts:boom");
  await viewErrorObserved;
  let heartbeat = await simulateIncomingWebChannel(
    flow,
    "fxaccounts:pair_heartbeat"
  );
  Assert.ok(heartbeat.err);
});

add_task(async function testPairingChannelFailure() {
  const emitter = new EventEmitter();
  const pairingChannel = new MockPairingChannel();
  await FxAccountsPairingFlow.start({
    emitter,
    pairingChannel,
    fxAccounts,
    fxaConfig,
    weave,
  });
  const flow = FxAccountsPairingFlow.get(CHANNEL_ID);
  const viewErrorObserved = emitter.once("view:Error");
  sinon.stub(pairingChannel, "send").callsFake(() => {
    throw new Error("Boom!");
  });
  pairingChannel.simulateIncoming({
    message: "pair:supp:request",
    data: {
      client_id: "client_id_1",
      state: "mystate",
      scope: "profile https://identity.mozilla.com/apps/oldsync",
      code_challenge: "chal",
      code_challenge_method: "S256",
    },
  });
  await viewErrorObserved;

  let heartbeat = await simulateIncomingWebChannel(
    flow,
    "fxaccounts:pair_heartbeat"
  );
  Assert.ok(heartbeat.err);
});

add_task(async function testFlowTimeout() {
  const emitter = new EventEmitter();
  const pairingChannel = new MockPairingChannel();
  const viewErrorObserved = emitter.once("view:Error");
  await FxAccountsPairingFlow.start({
    emitter,
    pairingChannel,
    fxAccounts,
    fxaConfig,
    weave,
    flowTimeout: 1,
  });
  const flow = FxAccountsPairingFlow.get(CHANNEL_ID);
  await viewErrorObserved;

  let heartbeat = await simulateIncomingWebChannel(
    flow,
    "fxaccounts:pair_heartbeat"
  );
  Assert.ok(heartbeat.err.match(/Timeout/));
});

async function simulateIncomingWebChannel(flow, command) {
  return flow.onWebChannelMessage(command);
}

async function promiseOutgoingMessage(pairingChannel) {
  return new Promise(res => {
    const onMessage = event => {
      pairingChannel.removeEventListener("send", onMessage);
      res(event.detail.data);
    };
    pairingChannel.addEventListener("send", onMessage);
  });
}

async function generateEphemeralKeypair() {
  const keypair = await crypto.subtle.generateKey(
    { name: "ECDH", namedCurve: "P-256" },
    true,
    ["deriveKey"]
  );
  const publicJWK = await crypto.subtle.exportKey("jwk", keypair.publicKey);
  const privateJWK = await crypto.subtle.exportKey("jwk", keypair.privateKey);
  delete publicJWK.key_ops;
  return {
    publicJWK,
    privateJWK,
  };
}
