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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  BackgroundPageThumbs: "resource://gre/modules/BackgroundPageThumbs.sys.mjs",
  PageThumbsStorage: "resource://gre/modules/PageThumbs.sys.mjs",
  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
  clearTimeout: "resource://gre/modules/Timer.sys.mjs",
  setTimeout: "resource://gre/modules/Timer.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "logger", function () {
  return lazy.PlacesUtils.getLogger({ prefix: "Previews" });
});

// Toggling Places previews requires a restart, because a database trigger
// filling up tombstones is enabled on the database only when the pref is set
// on startup.
ChromeUtils.defineLazyGetter(lazy, "previewsEnabled", function () {
  return Services.prefs.getBoolPref("places.previews.enabled", false);
});

// Preview deletions are done in chunks of this size.
const DELETE_CHUNK_SIZE = 50;
// This is the time between deletion chunks.
const DELETE_TIMEOUT_MS = 60000;

// The folder inside the profile folder where to store previews.
const PREVIEWS_DIRECTORY = "places-previews";

// 64 = SHA-256 hex digest length (32 bytes * 2 hex chars per byte).
const PREVIEW_FILENAME_RE = /^[a-f0-9]{64}\.webp$/;

// How old a preview file should be before we replace it.
const DAYS_BEFORE_REPLACEMENT = 30;

/**
 * This extends Set to only keep the latest 100 entries.
 */
class LimitedSet extends Set {
  #limit = 100;
  add(key) {
    super.add(key);
    let oversize = this.size - this.#limit;
    if (oversize > 0) {
      for (let entry of this) {
        if (oversize-- <= 0) {
          break;
        }
        this.delete(entry);
      }
    }
    return this;
  }
}

/**
 * This class handles previews deletion from tombstones in the database.
 * Deletion happens in chunks, each chunk runs after DELETE_TIMEOUT_MS, and
 * the process is interrupted once there's nothing more to delete.
 * Any page removal operations on the Places database will restart the timer.
 */
class DeletionHandler {
  #timeoutId = null;
  #shutdownProgress = {};

  /**
   * This can be set by tests to speed up the deletion process, otherwise the
   * product should just use the default value.
   */
  #timeout = DELETE_TIMEOUT_MS;
  get timeout() {
    return this.#timeout;
  }
  set timeout(val) {
    if (this.#timeoutId) {
      lazy.clearTimeout(this.#timeoutId);
      this.#timeoutId = null;
    }
    this.#timeout = val;
    this.ensureRunning();
  }

  constructor() {
    // Clear any pending timeouts on shutdown.
    lazy.PlacesUtils.history.shutdownClient.jsclient.addBlocker(
      "PlacesPreviews.sys.mjs::DeletionHandler",
      async () => {
        this.#shutdownProgress.shuttingDown = true;
        lazy.clearTimeout(this.#timeoutId);
        this.#timeoutId = null;
      },
      { fetchState: () => this.#shutdownProgress }
    );
  }

  /**
   * This should be invoked everytime we expect there are tombstones to
   * handle. If deletion is already pending, this is a no-op.
   */
  ensureRunning() {
    if (this.#timeoutId || this.#shutdownProgress.shuttingDown) {
      return;
    }
    this.#timeoutId = lazy.setTimeout(() => {
      this.#timeoutId = null;
      ChromeUtils.idleDispatch(() => {
        this.#deleteChunk().catch(ex =>
          lazy.logger.error("Error during previews deletion:" + ex)
        );
      });
    }, this.timeout);
  }

  /**
   * Deletes a chunk of previews.
   */
  async #deleteChunk() {
    if (this.#shutdownProgress.shuttingDown) {
      return;
    }
    // Select tombstones, delete images, then delete tombstones. This order
    // ensures that in case of problems we'll try again in the future.
    let db = await lazy.PlacesUtils.promiseDBConnection();
    let count;
    let hashes = (
      await db.executeCached(
        `SELECT hash, (SELECT count(*) FROM moz_previews_tombstones) AS count
         FROM moz_previews_tombstones LIMIT ${DELETE_CHUNK_SIZE}`
      )
    ).map(r => {
      if (count === undefined) {
        count = r.getResultByName("count");
      }
      return r.getResultByName("hash");
    });
    if (!count || this.#shutdownProgress.shuttingDown) {
      // There's nothing to delete, or it's too late.
      return;
    }

    let deleted = [];
    for (let hash of hashes) {
      let filePath = PlacesPreviews.getPathForHash(hash);
      try {
        await IOUtils.remove(filePath);
        PlacesPreviews.onDelete(filePath);
        deleted.push(hash);
      } catch (ex) {
        if (DOMException.isInstance(ex) && ex.name == "NotFoundError") {
          deleted.push(hash);
        } else {
          lazy.logger.error("Unable to delete file: " + filePath);
        }
      }
      if (this.#shutdownProgress.shuttingDown) {
        return;
      }
    }
    // Delete hashes from tombstones.
    let params = deleted.reduce((p, c, i) => {
      p["hash" + i] = c;
      return p;
    }, {});
    await lazy.PlacesUtils.withConnectionWrapper(
      "PlacesPreviews.sys.mjs::ExpirePreviews",
      async db => {
        await db.execute(
          `DELETE FROM moz_previews_tombstones WHERE hash in
            (${Object.keys(params)
              .map(p => `:${p}`)
              .join(",")})`,
          params
        );
      }
    );

    if (count > DELETE_CHUNK_SIZE) {
      this.ensureRunning();
    }
  }
}

/**
 * Handles previews for Places urls.
 * Previews are stored in WebP format, using SHA256 hash of the page url in hex
 * format. All the previews are saved into a "places-previews" folder under
 * the roaming profile folder.
 */
export const PlacesPreviews = new (class extends EventEmitter {
  #placesObserver = null;
  #deletionHandler = null;
  // This is used as a cache to avoid fetching the same preview multiple
  // times in a short timeframe.
  #recentlyUpdatedPreviews = new LimitedSet();

  fileExtension = ".webp";
  fileContentType = "image/webp";

  constructor() {
    super();
    // Observe page removals and delete previews when necessary.
    this.#placesObserver = new PlacesWeakCallbackWrapper(
      this.handlePlacesEvents.bind(this)
    );
    PlacesObservers.addListener(
      ["history-cleared", "page-removed"],
      this.#placesObserver
    );

    // Start deletion in case it was interruped during the previous session,
    // it will end once there's nothing more to delete.
    this.#deletionHandler = new DeletionHandler();
    this.#deletionHandler.ensureRunning();
  }

  handlePlacesEvents(events) {
    for (const event of events) {
      if (
        event.type == "history-cleared" ||
        (event.type == "page-removed" && event.isRemovedFromStore)
      ) {
        this.#deletionHandler.ensureRunning();
        return;
      }
    }
  }

  /**
   * Whether the feature is enabled. Use this instead of directly checking
   * the pref, since it requires a restart.
   */
  get enabled() {
    return lazy.previewsEnabled;
  }

  /**
   * Returns the path to the previews folder.
   *
   * @returns {string} The path to the previews folder.
   */
  getPath() {
    return PathUtils.join(
      Services.dirsvc.get("ProfD", Ci.nsIFile).path,
      PREVIEWS_DIRECTORY
    );
  }

  /**
   * Returns the file path of the preview for the given url.
   * This doesn't guarantee the file exists.
   *
   * @param {string} url Address of the page.
   * @returns {string} File path of the preview for the given url.
   */
  getPathForUrl(url) {
    return PathUtils.join(
      this.getPath(),
      lazy.PlacesUtils.sha256(url, { format: "hex" }) + this.fileExtension
    );
  }

  /**
   * Returns the file path of the preview having the given hash.
   *
   * @param {string} hash SHA256 hash in hex format.
   * @returns {string } File path of the preview having the given hash.
   */
  getPathForHash(hash) {
    return PathUtils.join(this.getPath(), hash + this.fileExtension);
  }

  /**
   * Returns the moz-page-thumb: url to show the preview for the given url.
   *
   * @param {string} url Address of the page.
   * @returns {string} Preview url for the given page url.
   */
  getPageThumbURL(url) {
    return (
      "moz-page-thumb://" +
      "places-previews" +
      "/?url=" +
      encodeURIComponent(url) +
      "&revision=" +
      lazy.PageThumbsStorage.getRevision(url)
    );
  }

  /**
   * Updates the preview for the given page url. The update happens in
   * background, using a windowless browser with very conservative privacy
   * settings. Due to this, it may not look exactly like the page that the user
   * is normally facing when logged in. See BackgroundPageThumbs.sys.mjs for
   * additional details.
   * Unless `forceUpdate` is set, the preview is not updated if:
   * - It was already fetched recently
   * - The stored preview is younger than DAYS_BEFORE_REPLACEMENT
   * The previem image is encoded using WebP.
   *
   * @param {string} url The address of the page.
   * @param {object} options
   * @param {boolean} [options.forceUpdate]
   *   Whether to update the preview regardless.
   * @returns {Promise<boolean>} Whether a preview is available and ready.
   */
  async update(url, { forceUpdate = false } = {}) {
    if (!this.enabled) {
      return false;
    }
    let filePath = this.getPathForUrl(url);
    if (!forceUpdate) {
      if (this.#recentlyUpdatedPreviews.has(filePath)) {
        lazy.logger.debug("Skipping update because recently updated");
        return true;
      }
      try {
        let fileInfo = await IOUtils.stat(filePath);
        if (
          fileInfo.lastModified >
          Date.now() - DAYS_BEFORE_REPLACEMENT * 86400000
        ) {
          // File is recent enough.
          this.#recentlyUpdatedPreviews.add(filePath);
          lazy.logger.debug("Skipping update because file is recent");
          return true;
        }
      } catch (ex) {
        // If the file doesn't exist, we always update it.
        if (!DOMException.isInstance(ex) || ex.name != "NotFoundError") {
          lazy.logger.error("Error while trying to stat() preview" + ex);
          return false;
        }
      }
    }

    let buffer = await new Promise(resolve => {
      let observer = (subject, topic, errorUrl) => {
        if (errorUrl == url) {
          resolve(null);
        }
      };
      Services.obs.addObserver(observer, "page-thumbnail:error");
      lazy.BackgroundPageThumbs.capture(url, {
        dontStore: true,
        contentType: this.fileContentType,
        onDone: (url, reason, handle) => {
          Services.obs.removeObserver(observer, "page-thumbnail:error");
          resolve(handle?.data);
        },
      });
    });
    if (!buffer) {
      lazy.logger.error("Unable to fetch preview: " + url);
      return false;
    }
    try {
      await IOUtils.makeDirectory(this.getPath(), { ignoreExisting: true });
      await IOUtils.write(filePath, new Uint8Array(buffer), {
        tmpPath: filePath + ".tmp",
      });
    } catch (ex) {
      lazy.logger.error("Unable to create preview: " + ex);
      return false;
    }
    this.#recentlyUpdatedPreviews.add(filePath);
    return true;
  }

  /**
   * Removes orphan previews that are not tracked by Places.
   * Orphaning should normally not happen, but unexpected manipulation (e.g. the
   * user touching the profile folder, or third party applications) could cause
   * it.
   * This method is slow, because it has to go through all the Places stored
   * pages, thus it's suggested to only run it as periodic maintenance.
   *
   * @returns {Promise<boolean>} Whether orphans deletion ran.
   */
  async deleteOrphans() {
    if (!this.enabled) {
      return false;
    }

    // From the previews directory, get all the files whose name matches our
    // format.  Avoid any other filenames, also for safety reasons, since we are
    // injecting them into SQL.
    let files = await IOUtils.getChildren(this.getPath());
    let hashes = files
      .map(f => PathUtils.filename(f))
      .filter(f => PREVIEW_FILENAME_RE.test(f))
      .map(n => n.substring(0, n.lastIndexOf(".")));

    if (!hashes.length) {
      return true;
    }

    await lazy.PlacesUtils.withConnectionWrapper(
      "PlacesPreviews.sys.mjs::deleteOrphans",
      async db => {
        await db.executeTransaction(async () => {
          for (let chunk of lazy.PlacesUtils.chunkArray(
            hashes,
            db.variableLimit
          )) {
            let params = chunk.reduce((p, h, i) => {
              p["hash" + i] = h;
              return p;
            }, {});
            await db.execute(
              `
              WITH files(hash) AS (
                VALUES ${Object.keys(params)
                  .map(p => `(:${p})`)
                  .join(", ")}
              )
              INSERT OR IGNORE INTO moz_previews_tombstones
                SELECT hash FROM files
                EXCEPT
                SELECT sha256hex(url) FROM moz_places
              `,
              params
            );
          }
        });
      }
    );
    this.#deletionHandler.ensureRunning();
    return true;
  }

  /**
   * This is invoked by #deletionHandler every time a preview file is removed.
   *
   * @param {string} filePath The path of the deleted file.
   */
  onDelete(filePath) {
    this.#recentlyUpdatedPreviews.delete(filePath);
    this.emit("places-preview-deleted", filePath);
  }

  /**
   * Used by tests to change the deletion timeout between chunks.
   *
   * @param {number} timeout New timeout in milliseconds.
   */
  testSetDeletionTimeout(timeout) {
    if (timeout === null) {
      this.#deletionHandler.timeout = DELETE_TIMEOUT_MS;
    } else {
      this.#deletionHandler.timeout = timeout;
    }
  }
})();

/**
 * Used to exposes nsIPlacesPreviewsHelperService to the moz-page-thumb protocol
 * cpp implementation.
 */
export function PlacesPreviewsHelperService() {}

PlacesPreviewsHelperService.prototype = {
  classID: Components.ID("{bd0a4d3b-ff26-4d4d-9a62-a513e1c1bf92}"),
  QueryInterface: ChromeUtils.generateQI(["nsIPlacesPreviewsHelperService"]),

  getFilePathForURL(url) {
    return PlacesPreviews.getPathForUrl(url);
  },
};
