/* vim: se cin sw=2 ts=2 et filetype=javascript :
 * 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 kStorageVersion = 1;

let lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
  EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
  JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "logConsole", () => {
  return console.createInstance({
    prefix: "TaskbarTabs",
    maxLogLevel: "Warn",
  });
});

/**
 * Returns a JSON schema validator for Taskbar Tabs persistent storage.
 *
 * @returns {Promise<Validator>} Resolves to JSON schema validator for Taskbar Tab's persistent storage.
 */
async function getJsonSchema() {
  const kJsonSchema =
    "chrome://browser/content/taskbartabs/TaskbarTabs.1.schema.json";
  let res = await fetch(kJsonSchema);
  let obj = await res.json();
  return new lazy.JsonSchema.Validator(obj);
}

/**
 * Storage class for a single Taskbar Tab's persistent storage.
 */
class TaskbarTab {
  // Unique identifier for the Taskbar Tab.
  #id;
  // List of hosts associated with this Taskbar tab.
  #scopes = [];
  // Container the Taskbar Tab is opened in when opened from the Taskbar.
  #userContextId;
  // URL opened when a Taskbar Tab is opened from the Taskbar.
  #startUrl;

  constructor({ id, scopes, startUrl, userContextId }) {
    this.#id = id;
    this.#scopes = scopes;
    this.#userContextId = userContextId;
    this.#startUrl = startUrl;
  }

  get id() {
    return this.#id;
  }

  get scopes() {
    return [...this.#scopes];
  }

  get userContextId() {
    return this.#userContextId;
  }

  get startUrl() {
    return this.#startUrl;
  }

  /**
   * Whether the provided URL is navigable from the Taskbar Tab.
   *
   * @param {nsIURL} aUrl - The URL to navigate to.
   * @returns {boolean} `true` if the URL is navigable from the Taskbar Tab associated to the ID.
   * @throws {Error} If `aId` is not a valid Taskbar Tabs ID.
   */
  isScopeNavigable(aUrl) {
    let baseDomain = Services.eTLD.getBaseDomain(aUrl);

    for (const scope of this.#scopes) {
      let scopeBaseDomain = Services.eTLD.getBaseDomainFromHost(scope.hostname);

      // Domains in the same base domain are valid navigation targets.
      if (baseDomain === scopeBaseDomain) {
        lazy.logConsole.info(`${aUrl} is navigable for scope ${scope}.`);
        return true;
      }
    }

    lazy.logConsole.info(
      `${aUrl} is not navigable for Taskbar Tab ID ${this.#id}.`
    );
    return false;
  }

  toJSON() {
    return {
      id: this.id,
      scopes: this.scopes,
      userContextId: this.userContextId,
      startUrl: this.startUrl,
    };
  }
}

export const kTaskbarTabsRegistryEvents = Object.freeze({
  created: "created",
  removed: "removed",
});

/**
 * Storage class for Taskbar Tabs feature's persistent storage.
 */
export class TaskbarTabsRegistry {
  // List of registered Taskbar Tabs.
  #taskbarTabs = [];
  // Signals when Taskbar Tabs have been created or removed.
  #emitter = new lazy.EventEmitter();

  /**
   * Initializes a Taskbar Tabs Registry, optionally loading from a file.
   *
   * @param {object} [init] - Initialization context.
   * @param {nsIFile} [init.loadFile] - Optional file to load.
   */
  static async create({ loadFile } = {}) {
    let registry = new TaskbarTabsRegistry();
    if (loadFile) {
      await registry.#load(loadFile);
    }

    return registry;
  }

  /**
   * Loads the stored Taskbar Tabs.
   *
   * @param {nsIFile} aFile - File to load from.
   */
  async #load(aFile) {
    if (!aFile.exists()) {
      lazy.logConsole.error(`File ${aFile.path} does not exist.`);
      return;
    }

    lazy.logConsole.info(`Loading file ${aFile.path} for Taskbar Tabs.`);

    const [schema, jsonObject] = await Promise.all([
      getJsonSchema(),
      IOUtils.readJSON(aFile.path),
    ]);

    if (!schema.validate(jsonObject).valid) {
      throw new Error(
        `JSON from file ${aFile.path} is invalid for the Taskbar Tabs Schema.`
      );
    }
    if (jsonObject.version > kStorageVersion) {
      throw new Error(`File ${aFile.path} has an unrecognized version.
          Current Version: ${kStorageVersion}
          File Version: ${jsonObject.version}`);
    }
    this.#taskbarTabs = jsonObject.taskbarTabs.map(tt => new TaskbarTab(tt));
  }

  toJSON() {
    return {
      version: kStorageVersion,
      taskbarTabs: this.#taskbarTabs.map(tt => {
        return tt.toJSON();
      }),
    };
  }

  /**
   * Finds or creates a Taskbar Tab based on the provided URL and container.
   *
   * @param {nsIURL} aUrl - The URL to match or derive the scope and start URL from.
   * @param {number} aUserContextId - The container to start a Taskbar Tab in.
   * @returns {TaskbarTab} The matching or created Taskbar Tab.
   */
  findOrCreateTaskbarTab(aUrl, aUserContextId) {
    let taskbarTab = this.findTaskbarTab(aUrl, aUserContextId);
    if (taskbarTab) {
      return taskbarTab;
    }

    let id = Services.uuid.generateUUID().toString().slice(1, -1);
    taskbarTab = new TaskbarTab({
      id,
      scopes: [{ hostname: aUrl.host }],
      userContextId: aUserContextId,
      startUrl: aUrl.prePath,
    });
    this.#taskbarTabs.push(taskbarTab);

    lazy.logConsole.info(`Created Taskbar Tab with ID ${id}`);

    this.#emitter.emit(kTaskbarTabsRegistryEvents.created, taskbarTab);

    return taskbarTab;
  }

  /**
   * Removes a Taskbar Tab.
   *
   * @param {string} aId - The ID of the TaskbarTab to remove.
   */
  removeTaskbarTab(aId) {
    let tts = this.#taskbarTabs;
    const i = tts.findIndex(tt => {
      return tt.id === aId;
    });

    if (i > -1) {
      lazy.logConsole.info(`Removing Taskbar Tab Id ${tts[i].id}`);
      let removed = tts.splice(i, 1);

      this.#emitter.emit(kTaskbarTabsRegistryEvents.removed, removed[0]);
    } else {
      lazy.logConsole.error(`Taskbar Tab ID ${aId} not found.`);
    }
  }

  /**
   * Searches for an existing Taskbar Tab matching the URL and Container.
   *
   * @param {nsIURL} aUrl - The URL to match.
   * @param {number} aUserContextId - The container to match.
   * @returns {TaskbarTab|null} The matching Taskbar Tab, or null if none match.
   */
  findTaskbarTab(aUrl, aUserContextId) {
    for (const tt of this.#taskbarTabs) {
      for (const scope of tt.scopes) {
        if (aUrl.host === scope.hostname) {
          if (aUserContextId !== tt.userContextId) {
            lazy.logConsole.info(
              `Matched TaskbarTab for URL ${aUrl.host} to ${scope.hostname}, but container ${aUserContextId} mismatched ${tt.userContextId}.`
            );
          } else {
            lazy.logConsole.info(
              `Matched TaskbarTab for URL ${aUrl.host} to ${scope.hostname} with container ${aUserContextId}.`
            );
            return tt;
          }
        }
      }
    }

    lazy.logConsole.info(
      `No matching TaskbarTab found for URL ${aUrl.host} and container ${aUserContextId}.`
    );
    return null;
  }

  /**
   * Retrieves the Taskbar Tab matching the ID.
   *
   * @param {string} aId - The ID of the Taskbar Tab.
   * @returns {TaskbarTab} The matching Taskbar Tab.
   * @throws {Error} If `aId` is not a valid Taskbar Tab ID.
   */
  getTaskbarTab(aId) {
    const tt = this.#taskbarTabs.find(aTaskbarTab => {
      return aTaskbarTab.id === aId;
    });
    if (!tt) {
      lazy.logConsole.error(`Taskbar Tab Id ${aId} not found.`);
      throw new Error(`Taskbar Tab Id ${aId} is invalid.`);
    }

    return tt;
  }

  /**
   * Passthrough to `EventEmitter.on`.
   *
   * @param  {...any} args - Same as `EventEmitter.on`.
   */
  on(...args) {
    return this.#emitter.on(...args);
  }

  /**
   * Passthrough to `EventEmitter.off`
   *
   * @param  {...any} args - Same as `EventEmitter.off`
   */
  off(...args) {
    return this.#emitter.off(...args);
  }

  /**
   * Resets the in-memory Taskbar Tabs state for tests.
   */
  resetForTests() {
    this.#taskbarTabs = [];
  }
}

/**
 * Monitor for the Taskbar Tabs Registry that updates the save file as it
 * changes.
 *
 * Note: this intentionally does not save on schema updates to allow for
 * gracefall rollback to an earlier version of Firefox where possible. This is
 * desirable in cases where a user has unintentioally opened a profile on a
 * newer version of Firefox, or has reverted an update.
 */
export class TaskbarTabsRegistryStorage {
  // The registry to save.
  #registry;
  // The file saved to.
  #saveFile;
  // Promise queue to ensure that async writes don't occur out of order.
  #saveQueue = Promise.resolve();

  /**
   * @param {TaskbarTabsRegistry} aRegistry - The registry to serialize.
   * @param {nsIFile} aSaveFile - The save file to update.
   */
  constructor(aRegistry, aSaveFile) {
    this.#registry = aRegistry;
    this.#saveFile = aSaveFile;
  }

  /**
   * Serializes the Taskbar Tabs Registry into a JSON file.
   *
   * Note: file writes are strictly ordered, ensuring the sequence of serialized
   * object writes reflects the latest state even if any individual write
   * serializes the registry in a newer state than when it's associated event
   * was emitted.
   *
   * @returns {Promise} Resolves once the current save operation completes.
   */
  save() {
    this.#saveQueue = this.#saveQueue
      .finally(async () => {
        lazy.logConsole.info(`Updating Taskbar Tabs storage file.`);

        const schema = await getJsonSchema();

        // Copy the JSON object to prevent awaits after validation risking
        // TOCTOU if the registry changes..
        let json = this.#registry.toJSON();

        let result = schema.validate(json);
        if (!result.valid) {
          throw new Error(
            "Generated invalid JSON for the Taskbar Tabs Schema:\n" +
              JSON.stringify(result.errors)
          );
        }

        await IOUtils.makeDirectory(this.#saveFile.parent.path);
        await IOUtils.writeJSON(this.#saveFile.path, json);

        lazy.logConsole.info(`Tasbkar Tabs storage file updated.`);
      })
      .catch(e => {
        lazy.logConsole.error(`Error writing Taskbar Tabs file: ${e}`);
      });

    lazy.AsyncShutdown.profileBeforeChange.addBlocker(
      "Taskbar Tabs: finalizing registry serialization to disk.",
      this.#saveQueue
    );

    return this.#saveQueue;
  }
}
