/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  ASRouterTargeting:
    // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
    "resource:///modules/asrouter/ASRouterTargeting.sys.mjs",
  ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs",
  ClientEnvironmentBase:
    "resource://gre/modules/components-utils/ClientEnvironment.sys.mjs",
  FilterExpressions:
    "resource://gre/modules/components-utils/FilterExpressions.sys.mjs",
  TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
  clearTimeout: "resource://gre/modules/Timer.sys.mjs",
  setTimeout: "resource://gre/modules/Timer.sys.mjs",
});

const DEFAULT_TIMEOUT = 5000;
const ERROR_TYPES = {
  ATTRIBUTE_ERROR: "AttributeError",
  TIMEOUT: "AttributeTimeout",
};

const TargetingEnvironment = {
  get locale() {
    return lazy.ASRouterTargeting.Environment.locale;
  },

  get localeLanguageCode() {
    return lazy.ASRouterTargeting.Environment.localeLanguageCode;
  },

  get region() {
    return lazy.ASRouterTargeting.Environment.region;
  },

  get userId() {
    return lazy.ClientEnvironment.userId;
  },

  get version() {
    return AppConstants.MOZ_APP_VERSION_DISPLAY;
  },

  get channel() {
    const { settings } = lazy.TelemetryEnvironment.currentEnvironment;
    return settings.update.channel;
  },

  get platform() {
    return AppConstants.platform;
  },

  get os() {
    return lazy.ClientEnvironmentBase.os;
  },
};

export class TargetingContext {
  #telemetrySource = null;

  constructor(customContext, options = { source: null }) {
    if (customContext) {
      this.ctx = new Proxy(customContext, {
        get: (customCtx, prop) => {
          if (prop in TargetingEnvironment) {
            return TargetingEnvironment[prop];
          }
          return customCtx[prop];
        },
      });
    } else {
      this.ctx = TargetingEnvironment;
    }

    // Used in telemetry to report where the targeting expression is coming from
    this.#telemetrySource = options.source;
  }

  setTelemetrySource(source) {
    if (source) {
      this.#telemetrySource = source;
    }
  }

  _sendUndesiredEvent({ event, value }) {
    let extra = { value };
    if (this.#telemetrySource) {
      extra.source = this.#telemetrySource;
    }

    Glean.messagingExperiments["targeting" + event].record(extra);
  }

  /**
   * Wrap each property of context[key] with a Proxy that captures errors and
   * timeouts
   *
   * @param {{[key: string]: TargetingGetters} | TargetingGetters} context
   * @param {string} key Namespace value found in `context` param
   * @returns {TargetingGetters} Wrapped context where getter report errors and timeouts
   */
  createContextWithTimeout(context, key = null) {
    const timeoutDuration = key ? context[key].timeout : context.timeout;
    const logUndesiredEvent = (event, key, prop) => {
      const value = key ? `${key}.${prop}` : prop;
      this._sendUndesiredEvent({ event, value });
      console.error(`${event}: ${value}`);
    };

    return new Proxy(context, {
      get(target, prop) {
        // eslint-disable-next-line no-async-promise-executor
        return new Promise(async (resolve, reject) => {
          // Create timeout cb to record attribute resolution taking too long.
          let timeout = lazy.setTimeout(() => {
            logUndesiredEvent(ERROR_TYPES.TIMEOUT, key, prop);
            reject(
              new Error(
                `${prop} targeting getter timed out after ${
                  timeoutDuration || DEFAULT_TIMEOUT
                }ms`
              )
            );
          }, timeoutDuration || DEFAULT_TIMEOUT);

          try {
            resolve(await (key ? target[key][prop] : target[prop]));
          } catch (error) {
            logUndesiredEvent(ERROR_TYPES.ATTRIBUTE_ERROR, key, prop);
            reject(error);
            console.error(error);
          } finally {
            lazy.clearTimeout(timeout);
          }
        });
      },
    });
  }

  /**
   * Merge all evaluation contexts and wrap the getters with timeouts
   *
   * @param {{[key: string]: TargetingGetters}[]} contexts
   * @returns {{[key: string]: TargetingGetters}} Object that follows the pattern of `namespace: getters`
   */
  mergeEvaluationContexts(contexts) {
    let context = {};
    for (let c of contexts) {
      for (let envNamespace of Object.keys(c)) {
        // Take the provided context apart, replace it with a proxy
        context[envNamespace] = this.createContextWithTimeout(c, envNamespace);
      }
    }

    return context;
  }

  /**
   * Merge multiple TargetingGetters objects without accidentally evaluating
   *
   * @param {TargetingGetters[]} ...contexts
   * @returns {Proxy<TargetingGetters>}
   */
  static combineContexts(...contexts) {
    return new Proxy(
      {},
      {
        get(target, prop) {
          for (let context of contexts) {
            if (prop in context) {
              return context[prop];
            }
          }

          return null;
        },
      }
    );
  }

  /**
   * Evaluate JEXL expressions with default `TargetingEnvironment` and custom
   * provided targeting contexts
   *
   * @example
   * eval(
   *   "ctx.locale == 'en-US' && customCtx.foo == 42",
   *   { customCtx: { foo: 42 } }
   * ); // true
   *
   * @param {string} expression JEXL expression
   * @param {{[key: string]: TargetingGetters}[]} ...contexts Additional custom context
   *        objects where the keys act as namespaces for the different getters
   *
   * @returns {promise} Evaluation result
   */
  eval(expression, ...contexts) {
    return lazy.FilterExpressions.eval(
      expression,
      this.mergeEvaluationContexts([{ ctx: this.ctx }, ...contexts])
    );
  }

  /**
   * Evaluate JEXL expressions with default provided targeting context
   *
   * @example
   * new TargetingContext({ bar: 42 });
   * evalWithDefault(
   *   "bar == 42",
   * ); // true
   *
   * @param {string} expression JEXL expression
   * @returns {promise} Evaluation result
   */
  evalWithDefault(expression) {
    return lazy.FilterExpressions.eval(
      expression,
      this.createContextWithTimeout(this.ctx)
    );
  }
}
