/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

// Stop updating jumplists after some idle time.
const IDLE_TIMEOUT_SECONDS = 5 * 60;

// Prefs
const PREF_TASKBAR_BRANCH = "browser.taskbar.lists.";
const PREF_TASKBAR_LEGACY_BACKEND = "legacyBackend";
const PREF_TASKBAR_ENABLED = "enabled";
const PREF_TASKBAR_ITEMCOUNT = "maxListItemCount";
const PREF_TASKBAR_FREQUENT = "frequent.enabled";
const PREF_TASKBAR_RECENT = "recent.enabled";
const PREF_TASKBAR_TASKS = "tasks.enabled";
const PREF_TASKBAR_REFRESH = "refreshInSeconds";

// Hash keys for pendingStatements.
const LIST_TYPE = {
  FREQUENT: 0,
  RECENT: 1,
};

/**
 * Exports
 */

const lazy = {};

/**
 * Smart getters
 */

ChromeUtils.defineLazyGetter(lazy, "_prefs", function () {
  return Services.prefs.getBranch(PREF_TASKBAR_BRANCH);
});

ChromeUtils.defineLazyGetter(lazy, "_stringBundle", function () {
  return Services.strings.createBundle(
    "chrome://browser/locale/taskbar.properties"
  );
});

XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "_idle",
  "@mozilla.org/widget/useridleservice;1",
  "nsIUserIdleService"
);
XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "_taskbarService",
  "@mozilla.org/windows-taskbar;1",
  "nsIWinTaskbar"
);

ChromeUtils.defineESModuleGetters(lazy, {
  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
});

/**
 * Global functions
 */

function _getString(name) {
  return lazy._stringBundle.GetStringFromName(name);
}

// Task list configuration data object.

var tasksCfg = [
  /**
   * Task configuration options: title, description, args, iconIndex, open, close.
   *
   * title       - Task title displayed in the list. (strings in the table are temp fillers.)
   * description - Tooltip description on the list item.
   * args        - Command line args to invoke the task.
   * iconIndex   - Optional win icon index into the main application for the
   *               list item.
   * open        - Boolean indicates if the command should be visible after the browser opens.
   * close       - Boolean indicates if the command should be visible after the browser closes.
   */
  // Open new tab
  {
    get title() {
      return _getString("taskbar.tasks.newTab.label");
    },
    get description() {
      return _getString("taskbar.tasks.newTab.description");
    },
    args: "-new-tab about:blank",
    iconIndex: 3, // New window icon
    open: true,
    close: true, // The jump list already has an app launch icon, but
    // we don't always update the list on shutdown.
    // Thus true for consistency.
  },

  // Open new window
  {
    get title() {
      return _getString("taskbar.tasks.newWindow.label");
    },
    get description() {
      return _getString("taskbar.tasks.newWindow.description");
    },
    args: "-browser",
    iconIndex: 2, // New tab icon
    open: true,
    close: true, // No point, but we don't always update the list on
    // shutdown. Thus true for consistency.
  },
];

// Open new private window
let privateWindowTask = {
  get title() {
    return _getString("taskbar.tasks.newPrivateWindow.label");
  },
  get description() {
    return _getString("taskbar.tasks.newPrivateWindow.description");
  },
  args: "-private-window",
  iconIndex: 4, // Private browsing mode icon
  open: true,
  close: true, // No point, but we don't always update the list on
  // shutdown. Thus true for consistency.
};

// Implementation

var Builder = class {
  constructor(builder) {
    this._builder = builder;
    this._tasks = null;
    this._pendingStatements = {};
    this._shuttingDown = false;
    // These are ultimately controlled by prefs, so we disable
    // everything until is read from there
    this._showTasks = false;
    this._showFrequent = false;
    this._showRecent = false;
    this._maxItemCount = 0;
    this._isBuilding = false;
  }

  refreshPrefs(showTasks, showFrequent, showRecent, maxItemCount) {
    this._showTasks = showTasks;
    this._showFrequent = showFrequent;
    this._showRecent = showRecent;
    this._maxItemCount = maxItemCount;
  }

  updateShutdownState(shuttingDown) {
    this._shuttingDown = shuttingDown;
  }

  delete() {
    delete this._builder;
  }

  /**
   * Constructs the tasks and recent history items to display in the JumpList,
   * and then sends those lists to the nsIJumpListBuilder to be written.
   *
   * @returns {Promise<undefined>}
   *   The Promise resolves once the JumpList has been written, and any
   *   items that the user remove from the recent history list have been
   *   removed from Places. The Promise may reject if any part of constructing
   *   the tasks or sending them to the builder thread failed.
   */
  async buildList() {
    if (!(this._builder instanceof Ci.nsIJumpListBuilder)) {
      console.error(
        "Expected nsIJumpListBuilder. The builder is of the wrong type."
      );
      return;
    }

    // anything to build?
    if (!this._showFrequent && !this._showRecent && !this._showTasks) {
      // don't leave the last list hanging on the taskbar.
      this._deleteActiveJumpList();
      return;
    }

    // Are we in the midst of building an earlier iteration of this list? If
    // so, bail out. Same if we're shutting down.
    if (this._isBuilding || this._shuttingDown) {
      return;
    }

    this._isBuilding = true;

    try {
      let removedURLs = await this._builder.checkForRemovals();
      if (removedURLs.length) {
        await this._clearHistory(removedURLs);
      }

      let selfPath = Services.dirsvc.get("XREExeF", Ci.nsIFile).path;

      let taskDescriptions = [];

      if (this._showTasks) {
        taskDescriptions = this._tasks.map(task => {
          return {
            title: task.title,
            description: task.description,
            path: selfPath,
            arguments: task.args,
            fallbackIconIndex: task.iconIndex,
          };
        });
      }

      let customTitle = "";
      let customDescriptions = [];

      if (this._showFrequent) {
        let conn = await lazy.PlacesUtils.promiseDBConnection();
        let rows = await conn.executeCached(
          "SELECT p.url, IFNULL(p.title, p.url) as title " +
            "FROM moz_places p WHERE p.hidden = 0 " +
            "AND EXISTS (" +
            "SELECT id FROM moz_historyvisits WHERE " +
            "place_id = p.id AND " +
            "visit_type NOT IN (" +
            "0, " +
            `${Ci.nsINavHistoryService.TRANSITION_EMBED}, ` +
            `${Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK}` +
            ")" +
            "LIMIT 1" +
            ") " +
            "ORDER BY p.visit_count DESC LIMIT :limit",
          {
            limit: this._maxItemCount,
          }
        );

        customDescriptions = rows.map(row => {
          let uri = Services.io.newURI(row.getResultByName("url"));
          let iconPath = "";
          try {
            iconPath = this._builder.obtainAndCacheFavicon(uri);
          } catch (e) {
            // obtainAndCacheFavicon may throw NS_ERROR_NOT_AVAILABLE if the
            // icon doesn't yet exist on the disk, but has been requested.
            // That's not fatal, so we'll just let it pass. Any other errors,
            // however, we'll abort on.
            if (e.result != Cr.NS_ERROR_NOT_AVAILABLE) {
              throw e;
            }
          }

          return {
            title: row.getResultByName("title"),
            description: row.getResultByName("title"),
            path: selfPath,
            arguments: row.getResultByName("url"),
            fallbackIconIndex: 1,
            iconPath,
          };
        });

        customTitle = _getString("taskbar.frequent.label");
      }

      if (!this._shuttingDown) {
        await this._builder.populateJumpList(
          taskDescriptions,
          customTitle,
          customDescriptions
        );
      }
    } catch (e) {
      console.error("buildList failed: ", e);
    } finally {
      this._isBuilding = false;
    }
  }

  /**
   * Legacy list building
   *
   * @note Async builders must add their mozIStoragePendingStatement to
   *       _pendingStatements object, using a different LIST_TYPE entry for
   *       each statement. Once finished they must remove it and call
   *       commitBuild().  When there will be no more _pendingStatements,
   *       commitBuild() will commit for real.
   */

  _hasPendingStatements() {
    return !!Object.keys(this._pendingStatements).length;
  }

  async buildListLegacy() {
    if (!(this._builder instanceof Ci.nsILegacyJumpListBuilder)) {
      console.error(
        "Expected nsILegacyJumpListBuilder. The builder is of the wrong type."
      );
      return;
    }

    if (
      (this._showFrequent || this._showRecent) &&
      this._hasPendingStatements()
    ) {
      // We were requested to update the list while another update was in
      // progress, this could happen at shutdown, idle or privatebrowsing.
      // Abort the current list building.
      for (let listType in this._pendingStatements) {
        this._pendingStatements[listType].cancel();
        delete this._pendingStatements[listType];
      }
      this._builder.abortListBuild();
    }

    // anything to build?
    if (!this._showFrequent && !this._showRecent && !this._showTasks) {
      // don't leave the last list hanging on the taskbar.
      this._deleteActiveJumpList();
      return;
    }

    await this._startBuild();

    if (this._showTasks) {
      this._buildTasks();
    }

    // Space for frequent items takes priority over recent.
    if (this._showFrequent) {
      this._buildFrequent();
    }

    if (this._showRecent) {
      this._buildRecent();
    }

    this._commitBuild();
  }

  /**
   * Taskbar api wrappers
   */

  async _startBuild() {
    this._builder.abortListBuild();
    let URIsToRemove = await this._builder.initListBuild();
    if (URIsToRemove.length) {
      // Prior to building, delete removed items from history.
      this._clearHistory(URIsToRemove);
    }
  }

  _commitBuild() {
    if (
      (this._showFrequent || this._showRecent) &&
      this._hasPendingStatements()
    ) {
      return;
    }

    this._builder.commitListBuild(succeed => {
      if (!succeed) {
        this._builder.abortListBuild();
      }
    });
  }

  _buildTasks() {
    var items = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
    this._tasks.forEach(function (task) {
      if (
        (this._shuttingDown && !task.close) ||
        (!this._shuttingDown && !task.open)
      ) {
        return;
      }
      var item = this._getHandlerAppItem(
        task.title,
        task.description,
        task.args,
        task.iconIndex,
        null
      );
      items.appendElement(item);
    }, this);

    if (items.length) {
      this._builder.addListToBuild(
        this._builder.JUMPLIST_CATEGORY_TASKS,
        items
      );
    }
  }

  _buildCustom(title, items) {
    if (items.length) {
      this._builder.addListToBuild(
        this._builder.JUMPLIST_CATEGORY_CUSTOMLIST,
        items,
        title
      );
    }
  }

  _buildFrequent() {
    // Windows supports default frequent and recent lists,
    // but those depend on internal windows visit tracking
    // which we don't populate. So we build our own custom
    // frequent and recent lists using our nav history data.

    var items = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
    // track frequent items so that we don't add them to
    // the recent list.
    this._frequentHashList = [];

    this._pendingStatements[LIST_TYPE.FREQUENT] = this._getHistoryResults(
      Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING,
      this._maxItemCount,
      function (aResult) {
        if (!aResult) {
          delete this._pendingStatements[LIST_TYPE.FREQUENT];
          // The are no more results, build the list.
          this._buildCustom(_getString("taskbar.frequent.label"), items);
          this._commitBuild();
          return;
        }

        let title = aResult.title || aResult.uri;
        let faviconPageUri = Services.io.newURI(aResult.uri);
        let shortcut = this._getHandlerAppItem(
          title,
          title,
          aResult.uri,
          1,
          faviconPageUri
        );
        items.appendElement(shortcut);
        this._frequentHashList.push(aResult.uri);
      },
      this
    );
  }

  _buildRecent() {
    var items = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
    // Frequent items will be skipped, so we select a double amount of
    // entries and stop fetching results at _maxItemCount.
    var count = 0;

    this._pendingStatements[LIST_TYPE.RECENT] = this._getHistoryResults(
      Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING,
      this._maxItemCount * 2,
      function (aResult) {
        if (!aResult) {
          // The are no more results, build the list.
          this._buildCustom(_getString("taskbar.recent.label"), items);
          delete this._pendingStatements[LIST_TYPE.RECENT];
          this._commitBuild();
          return;
        }

        if (count >= this._maxItemCount) {
          return;
        }

        // Do not add items to recent that have already been added to frequent.
        if (
          this._frequentHashList &&
          this._frequentHashList.includes(aResult.uri)
        ) {
          return;
        }

        let title = aResult.title || aResult.uri;
        let faviconPageUri = Services.io.newURI(aResult.uri);
        let shortcut = this._getHandlerAppItem(
          title,
          title,
          aResult.uri,
          1,
          faviconPageUri
        );
        items.appendElement(shortcut);
        count++;
      },
      this
    );
  }

  _deleteActiveJumpList() {
    if (this._builder instanceof Ci.nsIJumpListBuilder) {
      this._builder.clearJumpList();
    } else {
      this._builder.deleteActiveList();
    }
  }

  /**
   * Jump list item creation helpers
   */

  _getHandlerAppItem(name, description, args, iconIndex, faviconPageUri) {
    var file = Services.dirsvc.get("XREExeF", Ci.nsIFile);

    var handlerApp = Cc[
      "@mozilla.org/uriloader/local-handler-app;1"
    ].createInstance(Ci.nsILocalHandlerApp);
    handlerApp.executable = file;
    // handlers default to the leaf name if a name is not specified
    if (name && name.length) {
      handlerApp.name = name;
    }
    handlerApp.detailedDescription = description;
    handlerApp.appendParameter(args);

    var item = Cc[
      "@mozilla.org/windows-legacyjumplistshortcut;1"
    ].createInstance(Ci.nsILegacyJumpListShortcut);
    item.app = handlerApp;
    item.iconIndex = iconIndex;
    item.faviconPageUri = faviconPageUri;
    return item;
  }

  /**
   * Nav history helpers
   */

  _getHistoryResults(aSortingMode, aLimit, aCallback, aScope) {
    var options = lazy.PlacesUtils.history.getNewQueryOptions();
    options.maxResults = aLimit;
    options.sortingMode = aSortingMode;
    var query = lazy.PlacesUtils.history.getNewQuery();

    // Return the pending statement to the caller, to allow cancelation.
    return lazy.PlacesUtils.history.asyncExecuteLegacyQuery(query, options, {
      handleResult(aResultSet) {
        for (let row; (row = aResultSet.getNextRow()); ) {
          try {
            aCallback.call(aScope, {
              uri: row.getResultByIndex(1),
              title: row.getResultByIndex(2),
            });
          } catch (e) {}
        }
      },
      handleError(aError) {
        console.error(
          "Async execution error (",
          aError.result,
          "): ",
          aError.message
        );
      },
      handleCompletion() {
        aCallback.call(aScope, null);
      },
    });
  }

  /**
   * Removes URLs from history in Places that the user has requested to clear
   * from their Jump List. We must do this before recomputing which history
   * to put into the Jump List, because if we ever include items that have
   * recently been removed, Windows will not allow us to proceed.
   * Please see
   * https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-icustomdestinationlist-beginlist
   * for more details.
   *
   * The returned Promise never rejects, but may report console errors in the
   * event of removal failure.
   *
   * @param {string[]} uriSpecsToRemove
   *   The URLs to be removed from Places history.
   * @returns {Promise<undefined>}
   */
  _clearHistory(uriSpecsToRemove) {
    let URIsToRemove = uriSpecsToRemove
      .map(spec => {
        try {
          // in case we get a bad uri
          return Services.io.newURI(spec);
        } catch (e) {
          return null;
        }
      })
      .filter(uri => !!uri);

    if (URIsToRemove.length) {
      return lazy.PlacesUtils.history.remove(URIsToRemove).catch(console.error);
    }
    return Promise.resolve();
  }
};

export var WinTaskbarJumpList = {
  // We build two separate jump lists -- one for the regular Firefox icon
  // and one for the Private Browsing icon
  _builder: null,
  _pbBuilder: null,
  _builtPb: false,
  _shuttingDown: false,
  _useLegacyBackend: true,

  /**
   * Startup, shutdown, and update
   */

  startup: async function WTBJL_startup() {
    // We do a one-time startup read of the backend pref here because
    // we don't want to consider any bugs that occur if the pref is flipped
    // at runtime. We want the pref flip to only take effect on a restart.
    this._useLegacyBackend = lazy._prefs.getBoolPref(
      PREF_TASKBAR_LEGACY_BACKEND
    );

    // exit if initting the taskbar failed for some reason.
    if (!(await this._initTaskbar())) {
      return;
    }

    if (lazy.PrivateBrowsingUtils.enabled) {
      tasksCfg.push(privateWindowTask);
    }
    // Store our task list config data
    this._builder._tasks = tasksCfg;
    this._pbBuilder._tasks = tasksCfg;

    // retrieve taskbar related prefs.
    this._refreshPrefs();

    // observer for private browsing and our prefs branch
    this._initObs();

    // jump list refresh timer
    this._updateTimer();
  },

  update: function WTBJL_update() {
    // are we disabled via prefs? don't do anything!
    if (!this._enabled) {
      return;
    }

    if (this._shuttingDown) {
      return;
    }

    if (this._useLegacyBackend) {
      // we only need to do this once, but we do it here
      // to avoid main thread io on startup
      if (!this._builtPb) {
        this._pbBuilder.buildListLegacy();
        this._builtPb = true;
      }

      // do what we came here to do, update the taskbar jumplist
      this._builder.buildListLegacy();
    } else {
      this._builder.buildList();

      // We only ever need to do this once because the private browsing window
      // jumplist only ever shows the static task list, which never changes,
      // so it doesn't need to be updated over time.
      if (!this._builtPb) {
        this._pbBuilder.buildList();
        this._builtPb = true;
      }
    }
  },

  _shutdown: function WTBJL__shutdown() {
    this._builder.updateShutdownState(true);
    this._pbBuilder.updateShutdownState(true);
    this._shuttingDown = true;
    this._free();
  },

  /**
   * Prefs utilities
   */

  _refreshPrefs: function WTBJL__refreshPrefs() {
    this._enabled = lazy._prefs.getBoolPref(PREF_TASKBAR_ENABLED);
    var showTasks = lazy._prefs.getBoolPref(PREF_TASKBAR_TASKS);
    this._builder.refreshPrefs(
      showTasks,
      lazy._prefs.getBoolPref(PREF_TASKBAR_FREQUENT),
      lazy._prefs.getBoolPref(PREF_TASKBAR_RECENT),
      lazy._prefs.getIntPref(PREF_TASKBAR_ITEMCOUNT)
    );
    // showTasks is the only relevant pref for the Private Browsing Jump List
    // the others are are related to frequent/recent entries, which are
    // explicitly disabled for it
    this._pbBuilder.refreshPrefs(showTasks, false, false, 0);
  },

  /**
   * Init and shutdown utilities
   */

  _initTaskbar: async function WTBJL__initTaskbar() {
    let builder;
    let pbBuilder;

    if (this._useLegacyBackend) {
      builder = lazy._taskbarService.createLegacyJumpListBuilder(false);
      pbBuilder = lazy._taskbarService.createLegacyJumpListBuilder(true);
      if (
        !builder ||
        !builder.available ||
        !pbBuilder ||
        !pbBuilder.available
      ) {
        return false;
      }
    } else {
      builder = lazy._taskbarService.createJumpListBuilder(false);
      pbBuilder = lazy._taskbarService.createJumpListBuilder(true);
      if (!builder || !pbBuilder) {
        return false;
      }
      let [builderAvailable, pbBuilderAvailable] = await Promise.all([
        builder.isAvailable(),
        pbBuilder.isAvailable(),
      ]);
      if (!builderAvailable || !pbBuilderAvailable) {
        return false;
      }
    }

    this._builder = new Builder(builder);
    this._pbBuilder = new Builder(pbBuilder);

    return true;
  },

  _initObs: function WTBJL__initObs() {
    // If the browser is closed while in private browsing mode, the "exit"
    // notification is fired on quit-application-granted.
    // History cleanup can happen at profile-change-teardown.
    Services.obs.addObserver(this, "profile-before-change");
    Services.obs.addObserver(this, "browser:purge-session-history");
    lazy._prefs.addObserver("", this);
    this._placesObserver = new PlacesWeakCallbackWrapper(
      this.update.bind(this)
    );
    lazy.PlacesUtils.observers.addListener(
      ["history-cleared"],
      this._placesObserver
    );
  },

  _freeObs: function WTBJL__freeObs() {
    Services.obs.removeObserver(this, "profile-before-change");
    Services.obs.removeObserver(this, "browser:purge-session-history");
    lazy._prefs.removeObserver("", this);
    if (this._placesObserver) {
      lazy.PlacesUtils.observers.removeListener(
        ["history-cleared"],
        this._placesObserver
      );
    }
  },

  _updateTimer: function WTBJL__updateTimer() {
    if (this._enabled && !this._shuttingDown && !this._timer) {
      this._timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
      this._timer.initWithCallback(
        this,
        lazy._prefs.getIntPref(PREF_TASKBAR_REFRESH) * 1000,
        this._timer.TYPE_REPEATING_SLACK
      );
    } else if ((!this._enabled || this._shuttingDown) && this._timer) {
      this._timer.cancel();
      delete this._timer;
    }
  },

  _hasIdleObserver: false,
  _updateIdleObserver: function WTBJL__updateIdleObserver() {
    if (this._enabled && !this._shuttingDown && !this._hasIdleObserver) {
      lazy._idle.addIdleObserver(this, IDLE_TIMEOUT_SECONDS);
      this._hasIdleObserver = true;
    } else if (
      (!this._enabled || this._shuttingDown) &&
      this._hasIdleObserver
    ) {
      lazy._idle.removeIdleObserver(this, IDLE_TIMEOUT_SECONDS);
      this._hasIdleObserver = false;
    }
  },

  _free: function WTBJL__free() {
    this._freeObs();
    this._updateTimer();
    this._updateIdleObserver();
    this._builder.delete();
    this._pbBuilder.delete();
  },

  QueryInterface: ChromeUtils.generateQI([
    "nsINamed",
    "nsIObserver",
    "nsITimerCallback",
  ]),

  name: "WinTaskbarJumpList",

  notify: function WTBJL_notify() {
    // Add idle observer on the first notification so it doesn't hit startup.
    this._updateIdleObserver();
    Services.tm.idleDispatchToMainThread(() => {
      this.update();
    });
  },

  observe: function WTBJL_observe(aSubject, aTopic) {
    switch (aTopic) {
      case "nsPref:changed":
        if (this._enabled && !lazy._prefs.getBoolPref(PREF_TASKBAR_ENABLED)) {
          this._deleteActiveJumpList();
        }
        this._refreshPrefs();
        this._updateTimer();
        this._updateIdleObserver();
        Services.tm.idleDispatchToMainThread(() => {
          this.update();
        });
        break;

      case "profile-before-change":
        this._shutdown();
        break;

      case "browser:purge-session-history":
        this.update();
        break;
      case "idle":
        if (this._timer) {
          this._timer.cancel();
          delete this._timer;
        }
        break;

      case "active":
        this._updateTimer();
        break;
    }
  },
};
