// 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/.

import {
  log,
  PREF_REMOTE_PAIRING_URI,
  COMMAND_PAIR_SUPP_METADATA,
  COMMAND_PAIR_AUTHORIZE,
  COMMAND_PAIR_DECLINE,
  COMMAND_PAIR_HEARTBEAT,
  COMMAND_PAIR_COMPLETE,
} from "resource://gre/modules/FxAccountsCommon.sys.mjs";

import {
  getFxAccountsSingleton,
  FxAccounts,
} from "resource://gre/modules/FxAccounts.sys.mjs";

const fxAccounts = getFxAccountsSingleton();
import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs";

ChromeUtils.importESModule("resource://services-common/utils.sys.mjs");
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
  FxAccountsPairingChannel:
    "resource://gre/modules/FxAccountsPairingChannel.sys.mjs",

  Weave: "resource://services-sync/main.sys.mjs",
  jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs",
});

const PAIRING_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob:pair-auth-webchannel";
// A pairing flow is not tied to a specific browser window, can also finish in
// various ways and subsequently might leak a Web Socket, so just in case we
// time out and free-up the resources after a specified amount of time.
const FLOW_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes.

class PairingStateMachine {
  constructor(emitter) {
    this._emitter = emitter;
    this._transition(SuppConnectionPending);
  }

  get currentState() {
    return this._currentState;
  }

  _transition(StateCtor, ...args) {
    const state = new StateCtor(this, ...args);
    this._currentState = state;
  }

  assertState(RequiredStates, messagePrefix = null) {
    if (!(RequiredStates instanceof Array)) {
      RequiredStates = [RequiredStates];
    }
    if (
      !RequiredStates.some(
        RequiredState => this._currentState instanceof RequiredState
      )
    ) {
      const msg = `${
        messagePrefix ? `${messagePrefix}. ` : ""
      }Valid expected states: ${RequiredStates.map(({ name }) => name).join(
        ", "
      )}. Current state: ${this._currentState.label}.`;
      throw new Error(msg);
    }
  }
}

/**
 * The pairing flow can be modeled by a finite state machine:
 * We start by connecting to a WebSocket channel (SuppConnectionPending).
 * Then the other party connects and requests some metadata from us (PendingConfirmations).
 * A confirmation happens locally first (PendingRemoteConfirmation)
 * or the oppposite (PendingLocalConfirmation).
 * Any side can decline this confirmation (Aborted).
 * Once both sides have confirmed, the pairing flow is finished (Completed).
 * During this flow errors can happen and should be handled (Errored).
 */
class State {
  constructor(stateMachine, ...args) {
    this._transition = (...args) => stateMachine._transition(...args);
    this._notify = (...args) => stateMachine._emitter.emit(...args);
    this.init(...args);
  }

  init() {
    /* Does nothing by default but can be re-implemented. */
  }

  get label() {
    return this.constructor.name;
  }

  hasErrored(error) {
    this._notify("view:Error", error);
    this._transition(Errored, error);
  }

  hasAborted() {
    this._transition(Aborted);
  }
}
class SuppConnectionPending extends State {
  suppConnected(sender, oauthOptions) {
    this._transition(PendingConfirmations, sender, oauthOptions);
  }
}
class PendingConfirmationsState extends State {
  localConfirmed() {
    throw new Error("Subclasses must implement this method.");
  }
  remoteConfirmed() {
    throw new Error("Subclasses must implement this method.");
  }
}
class PendingConfirmations extends PendingConfirmationsState {
  init(sender, oauthOptions) {
    this.sender = sender;
    this.oauthOptions = oauthOptions;
  }

  localConfirmed() {
    this._transition(PendingRemoteConfirmation);
  }

  remoteConfirmed() {
    this._transition(PendingLocalConfirmation, this.sender, this.oauthOptions);
  }
}
class PendingLocalConfirmation extends PendingConfirmationsState {
  init(sender, oauthOptions) {
    this.sender = sender;
    this.oauthOptions = oauthOptions;
  }

  localConfirmed() {
    this._transition(Completed);
  }

  remoteConfirmed() {
    throw new Error(
      "Insane state! Remote has already been confirmed at this point."
    );
  }
}
class PendingRemoteConfirmation extends PendingConfirmationsState {
  localConfirmed() {
    throw new Error(
      "Insane state! Local has already been confirmed at this point."
    );
  }

  remoteConfirmed() {
    this._transition(Completed);
  }
}
class Completed extends State {}
class Aborted extends State {}
class Errored extends State {
  init(error) {
    this.error = error;
  }
}

const flows = new Map();

export class FxAccountsPairingFlow {
  static get(channelId) {
    return flows.get(channelId);
  }

  static finalizeAll() {
    for (const flow of flows) {
      flow.finalize();
    }
  }

  static async start(options) {
    const { emitter } = options;
    const fxaConfig = options.fxaConfig || FxAccounts.config;
    const fxa = options.fxAccounts || fxAccounts;
    const weave = options.weave || lazy.Weave;
    const flowTimeout = options.flowTimeout || FLOW_TIMEOUT_MS;

    const contentPairingURI = await fxaConfig.promisePairingURI();
    const wsUri = Services.urlFormatter.formatURLPref(PREF_REMOTE_PAIRING_URI);
    const pairingChannel =
      options.pairingChannel ||
      (await lazy.FxAccountsPairingChannel.create(wsUri));
    const { channelId, channelKey } = pairingChannel;
    const channelKeyB64 = ChromeUtils.base64URLEncode(channelKey, {
      pad: false,
    });
    const pairingFlow = new FxAccountsPairingFlow({
      channelId,
      pairingChannel,
      emitter,
      fxa,
      fxaConfig,
      flowTimeout,
      weave,
    });
    flows.set(channelId, pairingFlow);

    return `${contentPairingURI}#channel_id=${channelId}&channel_key=${channelKeyB64}`;
  }

  constructor(options) {
    this._channelId = options.channelId;
    this._pairingChannel = options.pairingChannel;
    this._emitter = options.emitter;
    this._fxa = options.fxa;
    this._fxai = options.fxai || this._fxa._internal;
    this._fxaConfig = options.fxaConfig;
    this._weave = options.weave;
    this._stateMachine = new PairingStateMachine(this._emitter);
    this._setupListeners();
    this._flowTimeoutId = setTimeout(
      () => this._onFlowTimeout(),
      options.flowTimeout
    );
  }

  _onFlowTimeout() {
    log.warn(`The pairing flow ${this._channelId} timed out.`);
    this._onError(new Error("Timeout"));
    this.finalize();
  }

  _closeChannel() {
    if (!this._closed && !this._pairingChannel.closed) {
      this._pairingChannel.close();
      this._closed = true;
    }
  }

  finalize() {
    this._closeChannel();
    clearTimeout(this._flowTimeoutId);
    // Free up resources and let the GC do its thing.
    flows.delete(this._channelId);
  }

  _setupListeners() {
    this._pairingChannel.addEventListener(
      "message",
      ({ detail: { sender, data } }) =>
        this.onPairingChannelMessage(sender, data)
    );
    this._pairingChannel.addEventListener("error", event =>
      this._onPairingChannelError(event.detail.error)
    );
    this._emitter.on("view:Closed", () => this.onPrefViewClosed());
  }

  _onAbort() {
    this._stateMachine.currentState.hasAborted();
    this.finalize();
  }

  _onError(error) {
    this._stateMachine.currentState.hasErrored(error);
    this._closeChannel();
  }

  _onPairingChannelError(error) {
    log.error("Pairing channel error", error);
    this._onError(error);
  }

  // Any non-falsy returned value is sent back through WebChannel.
  async onWebChannelMessage(command) {
    const stateMachine = this._stateMachine;
    const curState = stateMachine.currentState;
    try {
      switch (command) {
        case COMMAND_PAIR_SUPP_METADATA:
          stateMachine.assertState(
            [PendingConfirmations, PendingLocalConfirmation],
            `Wrong state for ${command}`
          );
          const {
            ua,
            city,
            region,
            country,
            remote: ipAddress,
          } = curState.sender;
          return { ua, city, region, country, ipAddress };
        case COMMAND_PAIR_AUTHORIZE:
          stateMachine.assertState(
            [PendingConfirmations, PendingLocalConfirmation],
            `Wrong state for ${command}`
          );
          const {
            client_id,
            state,
            scope,
            code_challenge,
            code_challenge_method,
            keys_jwk,
          } = curState.oauthOptions;
          const authorizeParams = {
            client_id,
            access_type: "offline",
            state,
            scope,
            code_challenge,
            code_challenge_method,
            keys_jwk,
          };
          const codeAndState = await this._authorizeOAuthCode(authorizeParams);
          if (codeAndState.state != state) {
            throw new Error(`OAuth state mismatch`);
          }
          await this._pairingChannel.send({
            message: "pair:auth:authorize",
            data: {
              ...codeAndState,
            },
          });
          curState.localConfirmed();
          break;
        case COMMAND_PAIR_DECLINE:
          this._onAbort();
          break;
        case COMMAND_PAIR_HEARTBEAT:
          if (curState instanceof Errored || this._pairingChannel.closed) {
            return { err: curState.error.message || "Pairing channel closed" };
          }
          const suppAuthorized = !(
            curState instanceof PendingConfirmations ||
            curState instanceof PendingRemoteConfirmation
          );
          return { suppAuthorized };
        case COMMAND_PAIR_COMPLETE:
          this.finalize();
          break;
        default:
          throw new Error(`Received unknown WebChannel command: ${command}`);
      }
    } catch (e) {
      log.error(e);
      curState.hasErrored(e);
    }
    return {};
  }

  async onPairingChannelMessage(sender, payload) {
    const { message } = payload;
    const stateMachine = this._stateMachine;
    const curState = stateMachine.currentState;
    try {
      switch (message) {
        case "pair:supp:request":
          stateMachine.assertState(
            SuppConnectionPending,
            `Wrong state for ${message}`
          );
          const oauthUri = await this._fxaConfig.promiseOAuthURI();
          const { uid, email, avatar, displayName } =
            await this._fxa.getSignedInUser();
          const deviceName = this._weave.Service.clientsEngine.localName;
          await this._pairingChannel.send({
            message: "pair:auth:metadata",
            data: {
              email,
              avatar,
              displayName,
              deviceName,
            },
          });
          const {
            client_id,
            state,
            scope,
            code_challenge,
            code_challenge_method,
            keys_jwk,
          } = payload.data;
          const url = new URL(oauthUri);
          url.searchParams.append("client_id", client_id);
          url.searchParams.append("scope", scope);
          url.searchParams.append("email", email);
          url.searchParams.append("uid", uid);
          url.searchParams.append("channel_id", this._channelId);
          url.searchParams.append("redirect_uri", PAIRING_REDIRECT_URI);
          this._emitter.emit("view:SwitchToWebContent", url.href);
          curState.suppConnected(sender, {
            client_id,
            state,
            scope,
            code_challenge,
            code_challenge_method,
            keys_jwk,
          });
          break;
        case "pair:supp:authorize":
          stateMachine.assertState(
            [PendingConfirmations, PendingRemoteConfirmation],
            `Wrong state for ${message}`
          );
          curState.remoteConfirmed();
          break;
        default:
          throw new Error(
            `Received unknown Pairing Channel message: ${message}`
          );
      }
    } catch (e) {
      log.error(e);
      curState.hasErrored(e);
    }
  }

  onPrefViewClosed() {
    const curState = this._stateMachine.currentState;
    // We don't want to stop the pairing process in the later stages.
    if (
      curState instanceof SuppConnectionPending ||
      curState instanceof Aborted ||
      curState instanceof Errored
    ) {
      this.finalize();
    }
  }

  /**
   * Grant an OAuth authorization code for the connecting client.
   *
   * @param {Object} options
   * @param options.client_id
   * @param options.state
   * @param options.scope
   * @param options.access_type
   * @param options.code_challenge_method
   * @param options.code_challenge
   * @param [options.keys_jwe]
   * @returns {Promise<Object>} Object containing "code" and "state" properties.
   */
  _authorizeOAuthCode(options) {
    return this._fxa._withVerifiedAccountState(async state => {
      const { sessionToken } = await state.getUserAccountData(["sessionToken"]);
      const params = { ...options };
      if (params.keys_jwk) {
        const jwk = JSON.parse(
          new TextDecoder().decode(
            ChromeUtils.base64URLDecode(params.keys_jwk, { padding: "reject" })
          )
        );
        params.keys_jwe = await this._createKeysJWE(
          sessionToken,
          params.client_id,
          params.scope,
          jwk
        );
        delete params.keys_jwk;
      }
      try {
        return await this._fxai.fxAccountsClient.oauthAuthorize(
          sessionToken,
          params
        );
      } catch (err) {
        throw this._fxai._errorToErrorClass(err);
      }
    });
  }

  /**
   * Create a JWE to deliver keys to another client via the OAuth scoped-keys flow.
   *
   * This method is used to transfer key material to another client, by providing
   * an appropriately-encrypted value for the `keys_jwe` OAuth response parameter.
   * Since we're transferring keys from one client to another, two things must be
   * true:
   *
   *   * This client must actually have the key.
   *   * The other client must be allowed to request that key.
   *
   * @param {String} sessionToken the sessionToken to use when fetching key metadata
   * @param {String} clientId the client requesting access to our keys
   * @param {String} scopes Space separated requested scopes being requested
   * @param {Object} jwk Ephemeral JWK provided by the client for secure key transfer
   */
  async _createKeysJWE(sessionToken, clientId, scopes, jwk) {
    // This checks with the FxA server about what scopes the client is allowed.
    // Note that we pass the requesting client_id here, not our own client_id.
    const clientKeyData = await this._fxai.fxAccountsClient.getScopedKeyData(
      sessionToken,
      clientId,
      scopes
    );
    const scopedKeys = {};
    for (const scope of Object.keys(clientKeyData)) {
      const key = await this._fxai.keys.getKeyForScope(scope);
      if (!key) {
        throw new Error(`Key not available for scope "${scope}"`);
      }
      scopedKeys[scope] = key;
    }
    return lazy.jwcrypto.generateJWE(
      jwk,
      new TextEncoder().encode(JSON.stringify(scopedKeys))
    );
  }
}
