/* 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 { DAPReportController, Task } from "./DAPReportController.sys.mjs";

let lazy = {};
ChromeUtils.defineLazyGetter(lazy, "logConsole", function () {
  return console.createInstance({
    prefix: "DAPIncrementality",
    maxLogLevelPref: "toolkit.telemetry.dap.logLevel",
  });
});
ChromeUtils.defineESModuleGetters(lazy, {
  NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
  IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs",
});

const DB_VERSION = 1;
const DB_NAME = "DAPIncrementality";
const REFERRER_STORE = "referrer";

export const DAPIncrementality = new (class {
  config = null;
  dapReportContoller = null;

  get db() {
    return this._db || (this._db = this.#createOrOpenDb());
  }

  async #createOrOpenDb() {
    try {
      return await this.#openDatabase();
    } catch {
      throw new Error("DAPIncrementality unable to load database.");
    }
  }

  async #openDatabase() {
    return await lazy.IndexedDB.open(DB_NAME, DB_VERSION, db => {
      if (!db.objectStoreNames.contains(REFERRER_STORE)) {
        db.createObjectStore(REFERRER_STORE, { keyPath: "taskId" });
      }
    });
  }

  async #recordReferrer(bucket) {
    try {
      const value = {
        taskId: this.config.taskId,
        bucket,
      };
      const tx = (await this.db).transaction(REFERRER_STORE, "readwrite");
      await tx.objectStore(REFERRER_STORE).put(value);
      await tx.done;
    } catch (err) {
      if (err.name === "NotFoundError") {
        lazy.logConsole.error(`Object store ${REFERRER_STORE} not found`);
      } else {
        lazy.logConsole.error("IndexedDB access error:", err);
      }
    }
  }

  async #deleteReferrer() {
    const tx = (await this.db).transaction([REFERRER_STORE], "readwrite");
    const referrerStore = tx.objectStore(REFERRER_STORE);
    referrerStore.delete(this.config.taskId);
    await tx.done;
  }

  async #getReferrer() {
    const tx = (await this.db).transaction(REFERRER_STORE, "readonly");
    const value = await tx.objectStore(REFERRER_STORE).get(this.config.taskId);
    await tx.done;
    return value;
  }

  async #processReferrerMeasurement(event) {
    let eventUrlProcessed = false;
    if (!event.hidden) {
      for (const pattern of this.config.targetUrlPatterns) {
        if (pattern.matches(event.url)) {
          eventUrlProcessed = true;
          const referrer = await this.#getReferrer();

          let success = false;
          if (referrer === undefined) {
            if (this.config.unknownReferrerBucket != null) {
              await this.dapReportContoller.recordMeasurement(
                this.config.taskId,
                this.config.unknownReferrerBucket
              );
            }
          } else {
            success = await this.dapReportContoller.recordMeasurement(
              this.config.taskId,
              referrer.bucket
            );
            if (success) {
              await this.#deleteReferrer();
            }
          }
          break;
        }
      }
    }

    // Greedy matching
    if (!eventUrlProcessed) {
      for (const referrerUrlPattern of this.config.referrerUrlPatterns) {
        if (referrerUrlPattern.pattern.matches(event.url)) {
          if (
            event.transitionType == lazy.PlacesUtils.history.TRANSITIONS.TYPED
          ) {
            await this.#recordReferrer(referrerUrlPattern.bucket);
          }
          break;
        }
      }
    }
  }

  async #processUrlVisit(event) {
    if (!event.hidden) {
      for (const visitUrlPattern of this.config.visitUrlPatterns) {
        if (visitUrlPattern.pattern.matches(event.url)) {
          await this.dapReportContoller.recordMeasurement(
            this.config.taskId,
            visitUrlPattern.bucket
          );
          break;
        }
      }
    }
  }

  async startup() {
    if (
      Services.startup.isInOrBeyondShutdownPhase(
        Ci.nsIAppStartup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
      )
    ) {
      lazy.logConsole.warn(
        "DAPIncrementality startup not possible due to shutdown."
      );
      return;
    }

    const placesTypes = ["page-visited", "history-cleared", "page-removed"];
    const placesListener = async events => {
      for (const event of events) {
        // Prioritizing data deletion.
        switch (event.type) {
          case "history-cleared":
          case "page-removed": {
            await this.#deleteReferrer();
            await this.dapReportContoller.deleteState();
            break;
          }
          case "page-visited": {
            const measurementType =
              lazy.NimbusFeatures.dapIncrementality.getVariable(
                "measurementType"
              ) || "";

            // Only one type of measurement can be configured for an experiment.
            const handlers = {
              visitMeasurement: () => this.#processUrlVisit(event),
              referrerMeasurement: () =>
                this.#processReferrerMeasurement(event),
            };

            (
              (await handlers[measurementType]) ||
              (() => {
                lazy.logConsole.debug(
                  `No handler for measurementType "${measurementType}"`
                );
              })
            )();
          }
        }
      }
    };

    lazy.NimbusFeatures.dapIncrementality.onUpdate(async () => {
      if (this.config !== null) {
        await this.dapReportContoller.cleanup(30 * 1000, "nimbus-update");
        await this.#deleteReferrer();

        this.config = null;
        this.dapReportContoller = null;
      }

      // Clear registered callbacks
      lazy.PlacesUtils.observers.removeListener(placesTypes, placesListener);

      // If we have an active Nimbus configuration, register this experiment.
      const measurementType =
        lazy.NimbusFeatures.dapIncrementality.getVariable("measurementType") ||
        "";

      if (
        ["visitMeasurement", "referrerMeasurement"].includes(measurementType)
      ) {
        this.initialize();
        lazy.PlacesUtils.observers.addListener(placesTypes, placesListener);

        let tasks = {};
        const task = new Task({
          taskId: this.config.taskId,
          bits: 8,
          vdaf: "histogram",
          length: this.config.length,
          timePrecision: this.config.timePrecision,
          defaultMeasurement: 0,
        });

        tasks[this.config.taskId] = task;
        this.dapReportContoller = new DAPReportController({
          tasks,
          options: {
            windowDays: 7,
            submissionIntervalMins: 240,
          },
        });
        this.dapReportContoller.startTimedSubmission();
      }
    });
  }

  initialize() {
    lazy.logConsole.debug("...Initialize experiment");
    let taskId = lazy.NimbusFeatures.dapIncrementality.getVariable("taskId");
    let length = lazy.NimbusFeatures.dapIncrementality.getVariable("length");
    let timePrecision =
      lazy.NimbusFeatures.dapIncrementality.getVariable("timePrecision");
    let unknownReferrerBucket =
      lazy.NimbusFeatures.dapIncrementality.getVariable(
        "unknownReferrerBucket"
      );

    this.config = {
      taskId,
      length,
      timePrecision,
      visitUrlPatterns: [],
      referrerUrlPatterns: [],
      targetUrlPatterns: [],
      unknownReferrerBucket,
    };

    let referrerUrls =
      lazy.NimbusFeatures.dapIncrementality.getVariable("referrerUrls") || "";

    for (const referrerUrl of referrerUrls) {
      let mpattern = new MatchPattern(referrerUrl.url);
      this.config.referrerUrlPatterns.push({
        pattern: mpattern,
        bucket: referrerUrl.bucket,
      });
    }

    let targetUrlsString =
      lazy.NimbusFeatures.dapIncrementality.getVariable("targetUrls") || "";
    const targetUrls = targetUrlsString.split(",").filter(Boolean);

    for (const url of targetUrls) {
      let mpattern = new MatchPattern(url);
      this.config.targetUrlPatterns.push(mpattern);
    }

    let visitCountUrls =
      lazy.NimbusFeatures.dapIncrementality.getVariable("visitCountUrls") || "";

    for (const visitCountUrl of visitCountUrls) {
      let mpattern = new MatchPattern(visitCountUrl.url);
      this.config.visitUrlPatterns.push({
        pattern: mpattern,
        bucket: visitCountUrl.bucket,
      });
    }
  }
})();
