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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "log", () => {
  return console.createInstance({
    maxLogLevelPref:
      "privacy.trackingprotection.content.remote_settings.loglevel",
    prefix: "ContentClassifier",
  });
});

const COLLECTION_NAME = "content-classifier-lists";

/**
 * RemoteSettings client for the content classifier. Owns the filter
 * list bytes (via the RS attachments cache on disk), notifies the
 * C++ ContentClassifierService of list updates and removals, and
 * serves bytes back on demand via getListBytes().
 */
export class ContentClassifierRemoteSettingsClient {
  classID = Components.ID("{C7DDDBF2-8BC4-41A1-AC90-5144BEC5ABDF}");
  QueryInterface = ChromeUtils.generateQI([
    "nsIContentClassifierRemoteSettingsClient",
  ]);

  #rs = null;
  #onSyncCallback = null;
  #service = null;
  #initialized = false;

  constructor() {
    this.#rs = lazy.RemoteSettings(COLLECTION_NAME);
  }

  /**
   * Initialize the client and register a sync
   * listener for future updates. Resolves once the initial import has
   * finished (or failed).
   *
   * The service keeps a strong reference to this client via mRSClient
   * and we keep a strong reference back via #service. The resulting
   * cycle is broken explicitly when the service calls shutdown().
   *
   * @param {nsIContentClassifierService} service
   */
  async init(service) {
    if (!service) {
      throw new Error("Missing required argument service");
    }
    if (this.#initialized) {
      lazy.log.debug(`init: already initialized`);
      return;
    }
    this.#initialized = true;
    this.#service = service;

    lazy.log.debug(`init: starting import`);
    // Register the sync listener before the async import so a sync event
    // that fires during the import is not lost.
    this.#onSyncCallback = this.onSync.bind(this);
    this.#rs.on("sync", this.#onSyncCallback);
    await this.importAllLists();
    lazy.log.debug(`init: done, sync listener registered`);
  }

  /**
   * Shut down the client and unregister the sync listener.
   */
  shutdown() {
    lazy.log.debug(`shutdown`);
    if (this.#onSyncCallback) {
      this.#rs.off("sync", this.#onSyncCallback);
      this.#onSyncCallback = null;
    }
    this.#service = null;
    this.#initialized = false;
  }

  /**
   * Handle a RemoteSettings sync event. Notifies the service,
   * which then pulls list bytes lazily via
   * getListBytes when it actually rebuilds an engine.
   *
   * @param {object} event
   * @param {object} event.data
   * @param {Array} event.data.created  Newly added records.
   * @param {Array} event.data.updated  Records with {old, new} pairs.
   * @param {Array} event.data.deleted  Removed records.
   */
  async onSync({ data: { created = [], updated = [], deleted = [] } }) {
    let service = this.#service;
    if (!service) {
      return;
    }

    lazy.log.debug(
      `onSync: ${created.length} created, ${updated.length} updated, ${deleted.length} deleted`
    );

    for (let record of deleted) {
      try {
        await this.#rs.attachments.deleteDownloaded(record);
      } catch (e) {
        lazy.log.error(`Failed to delete attachment for "${record.Name}":`, e);
      }
    }

    let updatedNames = [
      ...updated.map(({ new: r }) => r.Name),
      ...created.map(r => r.Name),
    ];
    let removedNames = deleted.map(r => r.Name);
    service.onListsChanged(updatedNames, removedNames);
  }

  /**
   * Fetch all records from the collection and notify the service.
   */
  async importAllLists() {
    let service = this.#service;
    if (!service) {
      return;
    }

    let records = [];
    try {
      records = await this.#rs.get();
      lazy.log.debug(`importAllLists: got ${records.length} records`);
    } catch (error) {
      lazy.log.error(`Error importing lists:`, error);
    }

    service.onListsChanged(
      records.map(r => r.Name),
      []
    );
  }

  /**
   * Return a Uint8Array containing the bytes of the named list's
   * attachment. Throws if no record with that name exists or the
   * attachment cannot be obtained.
   *
   * @param {string} name  The Name field of the RemoteSettings record.
   * @returns {Promise<Uint8Array>}
   */
  async getListBytes(name) {
    let records = await this.#rs.get();
    let record = records.find(r => r.Name === name);
    if (!record) {
      throw new Error(`No record with name "${name}"`);
    }
    if (!record.attachment) {
      throw new Error(`Record "${name}" has no attachment`);
    }
    let result;
    try {
      result = await this.#rs.attachments.download(record, {
        fallbackToCache: true,
        fallbackToDump: true,
      });
    } catch (e) {
      lazy.log.error(`Failed to download attachment for "${name}":`, e);
      throw new Error(`Failed to download attachment for "${name}"`);
    }
    return new Uint8Array(result.buffer);
  }
}
