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

"use strict";

/* import-globals-from /toolkit/content/customElements.js */
/* import-globals-from aboutaddonsCommon.js */
/* exported loadView */

// Used by external callers to load a specific view into the manager
function loadView(viewId) {
  if (!gViewController.readyForLoadView) {
    throw new Error("loadView called before about:addons is initialized");
  }
  gViewController.loadView(viewId);
}

/**
 * Helper for saving and restoring the scroll offsets when a previously loaded
 * view is accessed again.
 */
var ScrollOffsets = {
  _key: null,
  _offsets: new Map(),
  canRestore: true,

  setView(historyEntryId) {
    this._key = historyEntryId;
    this.canRestore = true;
  },

  getPosition() {
    if (!this.canRestore) {
      return { top: 0, left: 0 };
    }
    let { scrollTop: top, scrollLeft: left } = document.documentElement;
    return { top, left };
  },

  save() {
    if (this._key) {
      this._offsets.set(this._key, this.getPosition());
    }
  },

  restore() {
    let { top = 0, left = 0 } = this._offsets.get(this._key) || {};
    window.scrollTo({ top, left, behavior: "auto" });
  },
};

var gViewController = {
  currentViewId: null,
  readyForLoadView: false,
  get defaultViewId() {
    if (!isDiscoverEnabled()) {
      return "addons://list/extension";
    }
    return "addons://discover/";
  },
  isLoading: true,
  // All historyEntryId values must be unique within one session, because the
  // IDs are used to map history entries to page state. It is not possible to
  // see whether a historyEntryId was used in history entries before this page
  // was loaded, so start counting from a random value to avoid collisions.
  // This is used for scroll offsets in aboutaddons.js
  nextHistoryEntryId: Math.floor(Math.random() * 2 ** 32),
  views: {},

  initialize(container) {
    this.container = container;

    window.addEventListener("popstate", this);
    window.addEventListener("unload", this, { once: true });
    Services.obs.addObserver(this, "EM-ping");
  },

  handleEvent(e) {
    if (e.type == "popstate") {
      this.renderState(e.state);
      return;
    }

    if (e.type == "unload") {
      Services.obs.removeObserver(this, "EM-ping");
      // eslint-disable-next-line no-useless-return
      return;
    }
  },

  observe(subject, topic) {
    if (topic == "EM-ping") {
      this.readyForLoadView = true;
      Services.obs.notifyObservers(window, "EM-pong");
    }
  },

  notifyEMLoaded() {
    this.readyForLoadView = true;
    Services.obs.notifyObservers(window, "EM-loaded");
  },

  notifyEMUpdateCheckFinished() {
    // Notify the observer about a completed update check (currently only used in tests).
    Services.obs.notifyObservers(null, "EM-update-check-finished");
  },

  defineView(viewName, renderFunction) {
    if (this.views[viewName]) {
      throw new Error(
        `about:addons view ${viewName} should not be defined twice`
      );
    }
    this.views[viewName] = renderFunction;
  },

  parseViewId(viewId) {
    const matchRegex = /^addons:\/\/([^\/]+)\/(.*)$/;
    const [, viewType, viewParam] = viewId.match(matchRegex) || [];
    return { type: viewType, param: decodeURIComponent(viewParam) };
  },

  loadView(viewId, replace = false) {
    viewId = viewId.startsWith("addons://") ? viewId : `addons://${viewId}`;
    if (viewId == this.currentViewId) {
      return Promise.resolve();
    }

    // Always rewrite history state instead of pushing incorrect state for initial load.
    replace = replace || !this.currentViewId;

    const state = {
      view: viewId,
      previousView: replace ? null : this.currentViewId,
      historyEntryId: ++this.nextHistoryEntryId,
    };
    if (replace) {
      history.replaceState(state, "");
    } else {
      history.pushState(state, "");
    }
    return this.renderState(state);
  },

  async renderState(state) {
    let { param, type } = this.parseViewId(state.view);

    if (!type || this.views[type] == null) {
      console.warn(`No view for ${type} ${param}, switching to default`);
      this.resetState();
      return;
    }

    ScrollOffsets.save();
    ScrollOffsets.setView(state.historyEntryId);

    this.currentViewId = state.view;
    this.isLoading = true;

    // Perform tasks before view load
    document.dispatchEvent(
      new CustomEvent("view-selected", {
        detail: { id: state.view, param, type },
      })
    );

    // Render the fragment
    this.container.setAttribute("current-view", type);
    let fragment = await this.views[type](param);

    // Clear and append the fragment
    if (fragment) {
      this.container.textContent = "";
      this.container.append(fragment);

      // Most content has been rendered at this point. The only exception are
      // recommendations in the discovery pane and extension/theme list, because
      // they rely on remote data. If loaded before, then these may be rendered
      // within one tick, so wait a full frame before restoring scroll offsets.
      await new Promise(resolve => {
        window.requestAnimationFrame(() => {
          window.requestAnimationFrame(async () => {
            // Ensure all our content is translated.
            if (document.hasPendingL10nMutations) {
              await new Promise(r => {
                document.addEventListener("L10nMutationsFinished", r, {
                  once: true,
                });
              });
            }
            ScrollOffsets.restore();
            resolve();
          });
        });
      });
    } else {
      // Reset to default view if no given content
      this.resetState();
      return;
    }

    this.isLoading = false;

    document.dispatchEvent(new CustomEvent("view-loaded"));
  },

  resetState() {
    return this.loadView(this.defaultViewId, true);
  },
};
