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

/**
 * Provides functions to prevent multiple automatic downloads.
 */

import {
  Download,
  DownloadError,
} from "resource://gre/modules/DownloadCore.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
  DownloadList: "resource://gre/modules/DownloadList.sys.mjs",
  Downloads: "resource://gre/modules/Downloads.sys.mjs",
  DownloadsCommon:
    "moz-src:///browser/components/downloads/DownloadsCommon.sys.mjs",
});

/**
 * Each window tracks download spam independently, so one of these objects is
 * constructed for each window. This is responsible for tracking the spam and
 * updating the window's downloads UI accordingly.
 */
class WindowSpamProtection {
  constructor(window) {
    this._window = window;
  }

  /**
   * This map stores blocked spam downloads for the window, keyed by the
   * download's source URL. This is done so we can track the number of times a
   * given download has been blocked.
   *
   * @type {Map<string, DownloadSpam>}
   */
  _downloadSpamForUrl = new Map();

  /**
   * This set stores views that are waiting to have download notification
   * listeners attached. They will be attached when the spamList is created
   * (i.e. when the first spam download is blocked).
   *
   * @type {Set<object>}
   */
  _pendingViews = new Set();

  /**
   * Set to true when we first start _blocking downloads in the window. This is
   * used to lazily load the spamList. Spam downloads are rare enough that many
   * sessions will have no blocked downloads. So we don't want to create a
   * DownloadList unless we actually need it.
   *
   * @type {boolean}
   */
  _blocking = false;

  /**
   * A per-window DownloadList for blocked spam downloads. Registered views will
   * be sent notifications about downloads in this list, so that blocked spam
   * downloads can be represented in the UI. If spam downloads haven't been
   * blocked in the window, this will be undefined. See DownloadList.sys.mjs.
   *
   * @type {DownloadList | undefined}
   */
  get spamList() {
    if (!this._blocking) {
      return undefined;
    }
    if (!this._spamList) {
      this._spamList = new lazy.DownloadList();
    }
    return this._spamList;
  }

  /**
   * A per-window downloads indicator whose state depends on notifications from
   * DownloadLists registered in the window (for example, the visual state of
   * the downloads toolbar button). See DownloadsCommon.sys.mjs for more details.
   *
   * @type {DownloadsIndicatorData}
   */
  get indicator() {
    if (!this._indicator) {
      this._indicator = lazy.DownloadsCommon.getIndicatorData(this._window);
    }
    return this._indicator;
  }

  /**
   * Add a blocked download to the spamList or increment the count of an
   * existing blocked download, then notify listeners about this.
   *
   * @param {string} url
   */
  addDownloadSpam(url) {
    this._blocking = true;
    // Start listening on registered downloads views, if any exist.
    this._maybeAddViews();
    // If this URL is already paired with a DownloadSpam object, increment its
    // blocked downloads count by 1 and don't open the downloads panel.
    if (this._downloadSpamForUrl.has(url)) {
      let downloadSpam = this._downloadSpamForUrl.get(url);
      downloadSpam.blockedDownloadsCount += 1;
      this.indicator.onDownloadStateChanged(downloadSpam);
      return;
    }
    // Otherwise, create a new DownloadSpam object for the URL, add it to the
    // spamList, and open the downloads panel.
    let downloadSpam = new DownloadSpam(url);
    this.spamList.add(downloadSpam);
    this._downloadSpamForUrl.set(url, downloadSpam);
    this._notifyDownloadSpamAdded(downloadSpam);
  }

  /**
   * Notify the downloads panel that a new download has been added to the
   * spamList. This is invoked when a new DownloadSpam object is created.
   *
   * @param {DownloadSpam} downloadSpam
   */
  _notifyDownloadSpamAdded(downloadSpam) {
    let hasActiveDownloads = lazy.DownloadsCommon.summarizeDownloads(
      this.indicator._activeDownloads()
    ).numDownloading;
    if (
      !hasActiveDownloads &&
      this._window === lazy.BrowserWindowTracker.getTopWindow()
    ) {
      // If there are no active downloads, open the downloads panel.
      this._window.DownloadsPanel.showPanel();
    } else {
      // Otherwise, flash a taskbar/dock icon notification if available.
      this._window.getAttention();
    }
    this.indicator.onDownloadAdded(downloadSpam);
  }

  /**
   * Remove the download spam data for a given source URL.
   *
   * @param {string} url
   */
  removeDownloadSpamForUrl(url) {
    if (this._downloadSpamForUrl.has(url)) {
      let downloadSpam = this._downloadSpamForUrl.get(url);
      this.spamList.remove(downloadSpam);
      this.indicator.onDownloadRemoved(downloadSpam);
      this._downloadSpamForUrl.delete(url);
    }
  }

  /**
   * Set up a downloads view (e.g. the downloads panel) to receive notifications
   * about downloads in the spamList.
   *
   * @param {object} view An object that implements handlers for download
   *                      related notifications, like onDownloadAdded.
   */
  registerView(view) {
    if (!view || this.spamList?._views.has(view)) {
      return;
    }
    this._pendingViews.add(view);
    this._maybeAddViews();
  }

  /**
   * If any downloads have been blocked in the window, add download notification
   * listeners for each downloads view that has been registered.
   */
  _maybeAddViews() {
    if (this.spamList) {
      for (let view of this._pendingViews) {
        if (!this.spamList._views.has(view)) {
          this.spamList.addView(view);
        }
      }
      this._pendingViews.clear();
    }
  }

  /**
   * Remove download notification listeners for all views. This is invoked when
   * the window is closed.
   */
  removeAllViews() {
    if (this.spamList) {
      for (let view of this.spamList._views) {
        this.spamList.removeView(view);
      }
    }
    this._pendingViews.clear();
  }
}

/**
 * Responsible for detecting events related to downloads spam and notifying the
 * relevant window's WindowSpamProtection object. This is a singleton object,
 * constructed by DownloadIntegration.sys.mjs when the first download is blocked.
 */
export class DownloadSpamProtection {
  /**
   * Stores spam protection data per-window.
   *
   * @type {WeakMap<Window, WindowSpamProtection>}
   */
  _forWindowMap = new WeakMap();

  /**
   * Add download spam data for a given source URL in the window where the
   * download was blocked. This is invoked when a download is blocked by
   * nsExternalAppHandler::IsDownloadSpam
   *
   * @param {string} url
   * @param {Window} window
   */
  update(url, window) {
    if (window == null) {
      lazy.DownloadsCommon.log(
        "Download spam blocked in a non-chrome window. URL: ",
        url
      );
      return;
    }
    // Get the spam protection object for a given window or create one if it
    // does not already exist. Also attach notification listeners to any pending
    // downloads views.
    let wsp =
      this._forWindowMap.get(window) ?? new WindowSpamProtection(window);
    this._forWindowMap.set(window, wsp);
    wsp.addDownloadSpam(url);
  }

  /**
   * Get the spam list for a given window (provided it exists).
   *
   * @param {Window} window
   * @returns {DownloadList}
   */
  getSpamListForWindow(window) {
    return this._forWindowMap.get(window)?.spamList;
  }

  /**
   * Remove the download spam data for a given source URL in the passed window,
   * if any exists.
   *
   * @param {string} url
   * @param {Window} window
   */
  removeDownloadSpamForWindow(url, window) {
    let wsp = this._forWindowMap.get(window);
    wsp?.removeDownloadSpamForUrl(url);
  }

  /**
   * Create the spam protection object for a given window (if not already
   * created) and prepare to start listening for notifications on the passed
   * downloads view. The bulk of resources won't be expended until a download is
   * blocked. To add multiple views, call this method multiple times.
   *
   * @param {object} view An object that implements handlers for download
   *                      related notifications, like onDownloadAdded.
   * @param {Window} window
   */
  register(view, window) {
    let wsp =
      this._forWindowMap.get(window) ?? new WindowSpamProtection(window);
    // Try setting up the view now; it will be deferred if there's no spam.
    wsp.registerView(view);
    this._forWindowMap.set(window, wsp);
  }

  /**
   * Remove the spam protection object for a window when it is closed.
   *
   * @param {Window} window
   */
  unregister(window) {
    let wsp = this._forWindowMap.get(window);
    if (wsp) {
      // Stop listening on the view if it was previously set up.
      wsp.removeAllViews();
      this._forWindowMap.delete(window);
    }
  }
}

/**
 * Represents a special Download object for download spam.
 *
 * @extends Download
 */
class DownloadSpam extends Download {
  constructor(url) {
    super();
    this.hasBlockedData = true;
    this.stopped = true;
    this.error = new DownloadError({
      becauseBlockedByReputationCheck: true,
      reputationCheckVerdict: lazy.Downloads.Error.BLOCK_VERDICT_DOWNLOAD_SPAM,
    });
    this.target = { path: "" };
    this.source = { url };
    this.blockedDownloadsCount = 1;
  }
}
