/* 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 lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
  ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs",
  SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs",
  SyncedTabsManagement: "resource://services-sync/SyncedTabs.sys.mjs",
  COMMAND_CLOSETAB: "resource://gre/modules/FxAccountsCommon.sys.mjs",
});

import { SyncedTabsErrorHandler } from "resource:///modules/firefox-view-synced-tabs-error-handler.sys.mjs";
import { TabsSetupFlowManager } from "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs";
import { searchTabList } from "chrome://browser/content/firefoxview/search-helpers.mjs";

const SYNCED_TABS_CHANGED = "services.sync.tabs.changed";
const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.changed";

/**
 * The controller for synced tabs components.
 *
 * @implements {ReactiveController}
 */
export class SyncedTabsController {
  /**
   * @type {boolean}
   */
  contextMenu;
  currentSetupStateIndex = -1;
  currentSyncedTabs = [];
  devices = [];
  /**
   * The current error state as determined by `SyncedTabsErrorHandler`.
   *
   * @type {number}
   */
  errorState = null;
  /**
   * Component associated with this controller.
   *
   * @type {ReactiveControllerHost}
   */
  host;
  /**
   * @type {Function}
   */
  pairDeviceCallback;
  searchQuery = "";
  /**
   * @type {Function}
   */
  signupCallback;

  /**
   * Construct a new SyncedTabsController.
   *
   * @param {ReactiveControllerHost} host
   * @param {object} options
   * @param {boolean} [options.contextMenu]
   *   Whether synced tab items have a secondary context menu.
   * @param {Function} [options.pairDeviceCallback]
   *   The function to call when the pair device window is opened.
   * @param {Function} [options.signupCallback]
   *   The function to call when the signup window is opened.
   */
  constructor(host, { contextMenu, pairDeviceCallback, signupCallback } = {}) {
    this.contextMenu = contextMenu;
    this.pairDeviceCallback = pairDeviceCallback;
    this.signupCallback = signupCallback;
    this.observe = this.observe.bind(this);
    this.host = host;
    this.host.addController(this);
    // Track tabs requested close per device but not-yet-sent,
    // it'll be in the form of {fxaDeviceId: Set(urls)}
    this._pendingCloseTabs = new Map();
    // The last closed URL, for undo purposes
    this.lastClosedURL = null;
  }

  hostConnected() {
    this.host.addEventListener("click", this);
  }

  hostDisconnected() {
    this.host.removeEventListener("click", this);
  }

  get isSyncedTabsLoaded() {
    return this.currentSetupStateIndex === 4;
  }

  addSyncObservers() {
    Services.obs.addObserver(this.observe, SYNCED_TABS_CHANGED);
    Services.obs.addObserver(this.observe, TOPIC_SETUPSTATE_CHANGED);
  }

  removeSyncObservers() {
    Services.obs.removeObserver(this.observe, SYNCED_TABS_CHANGED);
    Services.obs.removeObserver(this.observe, TOPIC_SETUPSTATE_CHANGED);
  }

  handleEvent(event) {
    if (event.type == "click" && event.target.dataset.action) {
      const { ErrorType } = SyncedTabsErrorHandler;
      switch (event.target.dataset.action) {
        case `${ErrorType.SYNC_ERROR}`:
        case `${ErrorType.NETWORK_OFFLINE}`:
        case `${ErrorType.PASSWORD_LOCKED}`: {
          TabsSetupFlowManager.tryToClearError();
          break;
        }
        case `${ErrorType.SIGNED_OUT}`:
        case "sign-in": {
          TabsSetupFlowManager.openFxASignup(event.target.ownerGlobal);
          this.signupCallback?.();
          break;
        }
        case "add-device": {
          TabsSetupFlowManager.openFxAPairDevice(event.target.ownerGlobal);
          this.pairDeviceCallback?.();
          break;
        }
        case "sync-tabs-disabled": {
          TabsSetupFlowManager.syncOpenTabs(event.target);
          break;
        }
        case `${ErrorType.SYNC_DISCONNECTED}`: {
          const win = event.target.ownerGlobal;
          const { switchToTabHavingURI } =
            win.docShell.chromeEventHandler.ownerGlobal;
          switchToTabHavingURI(
            "about:preferences?action=choose-what-to-sync#sync",
            true,
            {}
          );
          break;
        }
      }
    } else if (event.type == "click" && event.composedTarget.href) {
      const { switchToTabHavingURI } =
        event.view.browsingContext.topChromeWindow;
      switchToTabHavingURI(event.composedTarget.href, true, {
        ignoreFragment: "whenComparingAndReplace",
      });
    }
  }

  async observe(_, topic, errorState) {
    if (topic == TOPIC_SETUPSTATE_CHANGED) {
      await this.updateStates(errorState);
    }
    if (topic == SYNCED_TABS_CHANGED) {
      // Usually this means we performed a sync, so clear the
      // "in-queue" things as those most likely got flushed
      this._pendingCloseTabs = new Map();
      this.lastClosedURL = null;
      await this.getSyncedTabData();
    }
  }

  async updateStates(errorState) {
    let stateIndex = TabsSetupFlowManager.uiStateIndex;
    errorState = errorState || SyncedTabsErrorHandler.getErrorType();

    if (stateIndex == 4 && this.currentSetupStateIndex !== stateIndex) {
      // trigger an initial request for the synced tabs list
      await this.getSyncedTabData();
    }

    this.currentSetupStateIndex = stateIndex;
    this.errorState = errorState;
    this.host.requestUpdate();
  }

  actionMappings = {
    "sign-in": {
      header: "firefoxview-syncedtabs-signin-header-2",
      description: "firefoxview-syncedtabs-signin-description-2",
      buttonLabel: "firefoxview-syncedtabs-signin-primarybutton-2",
    },
    "add-device": {
      header: "firefoxview-syncedtabs-adddevice-header-2",
      description: "firefoxview-syncedtabs-adddevice-description-2",
      buttonLabel: "firefoxview-syncedtabs-adddevice-primarybutton",
      descriptionLink: {
        name: "url",
        url: "https://support.mozilla.org/kb/how-do-i-set-sync-my-computer#w_connect-additional-devices-to-sync",
      },
    },
    "sync-tabs-disabled": {
      header: "firefoxview-syncedtabs-synctabs-header",
      description: "firefoxview-syncedtabs-synctabs-description",
      buttonLabel: "firefoxview-tabpickup-synctabs-primarybutton",
    },
    loading: {
      header: "firefoxview-syncedtabs-loading-header",
      description: "firefoxview-syncedtabs-loading-description",
    },
  };

  #getMessageCardForState({ error = false, action, errorState }) {
    errorState = errorState || this.errorState;
    let header, description, descriptionLink, buttonLabel, mainImageUrl;
    let descriptionArray;
    if (error) {
      let link;
      ({ header, description, link, buttonLabel } =
        SyncedTabsErrorHandler.getFluentStringsForErrorType(errorState));
      action = `${errorState}`;
      mainImageUrl =
        "chrome://browser/content/firefoxview/synced-tabs-error.svg";
      descriptionArray = [description];
      if (errorState == "password-locked") {
        descriptionLink = {};
        // This is ugly, but we need to special case this link so we can
        // coexist with the old view.
        descriptionArray.push("firefoxview-syncedtab-password-locked-link");
        descriptionLink.name = "syncedtab-password-locked-link";
        descriptionLink.url = link.href;
      }
    } else {
      header = this.actionMappings[action].header;
      description = this.actionMappings[action].description;
      buttonLabel = this.actionMappings[action].buttonLabel;
      descriptionLink = this.actionMappings[action].descriptionLink;
      mainImageUrl =
        "chrome://browser/content/firefoxview/synced-tabs-empty.svg";
      descriptionArray = [description];
    }
    return {
      action,
      buttonLabel,
      descriptionArray,
      descriptionLink,
      error,
      header,
      mainImageUrl,
    };
  }

  getRenderInfo() {
    let renderInfo = {};
    for (let tab of this.currentSyncedTabs) {
      if (!(tab.client in renderInfo)) {
        renderInfo[tab.client] = {
          name: tab.device,
          deviceType: tab.deviceType,
          canClose: !!tab.availableCommands[lazy.COMMAND_CLOSETAB],
          tabs: [],
        };
      }
      renderInfo[tab.client].tabs.push(tab);
    }

    // Add devices without tabs
    for (let device of this.devices) {
      if (!(device.id in renderInfo)) {
        renderInfo[device.id] = {
          name: device.name,
          deviceType: device.clientType,
          tabs: [],
        };
      }
    }

    for (let id in renderInfo) {
      renderInfo[id].tabItems = this.searchQuery
        ? searchTabList(this.searchQuery, this.getTabItems(renderInfo[id]))
        : this.getTabItems(renderInfo[id]);
    }
    return renderInfo;
  }

  getMessageCard() {
    switch (this.currentSetupStateIndex) {
      case 0 /* error-state */:
        if (this.errorState) {
          return this.#getMessageCardForState({ error: true });
        }
        return this.#getMessageCardForState({ action: "loading" });
      case 1 /* not-signed-in */:
        if (Services.prefs.prefHasUserValue("services.sync.lastversion")) {
          // If this pref is set, the user has signed out of sync.
          // This path is also taken if we are disconnected from sync. See bug 1784055
          return this.#getMessageCardForState({
            error: true,
            errorState: "signed-out",
          });
        }
        return this.#getMessageCardForState({ action: "sign-in" });
      case 2 /* connect-secondary-device*/:
        return this.#getMessageCardForState({ action: "add-device" });
      case 3 /* disabled-tab-sync */:
        return this.#getMessageCardForState({ action: "sync-tabs-disabled" });
      case 4 /* synced-tabs-loaded*/:
        // There seems to be an edge case where sync says everything worked
        // fine but we have no devices.
        if (!this.devices.length) {
          return this.#getMessageCardForState({ action: "add-device" });
        }
    }
    return null;
  }

  /**
   * Turn renderInfo into a list of tabs for syncedtabs-tab-list
   *
   * @param {object} renderInfo
   * @param {Array<object>} [renderInfo.tabs]
   *   tabs to display to the user
   * @param {string} [renderInfo.name]
   *   The name of the device for use when the user hovers over
   *   the close button for context
   * @param {boolean} [renderInfo.canClose]
   *   Whether the list should support remotely closing tabs
   */
  getTabItems({ tabs, name, canClose }) {
    return tabs
      ?.map(tab => {
        let tabItem = {
          icon: tab.icon,
          title: tab.title,
          time: tab.lastUsed * 1000,
          url: tab.url,
          fxaDeviceId: tab.fxaDeviceId,
          primaryL10nId: "firefoxview-tabs-list-tab-button",
          primaryL10nArgs: JSON.stringify({ targetURI: tab.url }),
          secondaryL10nId: this.contextMenu
            ? "fxviewtabrow-options-menu-button"
            : undefined,
          secondaryL10nArgs: this.contextMenu
            ? JSON.stringify({ tabTitle: tab.title })
            : undefined,
        };
        // We don't want to show the option to close remotely if the
        // device doesn't support it
        if (!canClose) {
          return tabItem;
        }

        // If this item has been requested to be closed, show
        // the undo instead until removed from the list
        if (tabItem.url === this.lastClosedURL) {
          tabItem.tertiaryL10nId = "text-action-undo";
          tabItem.tertiaryActionClass = "undo-button";
          tabItem.tertiaryL10nArgs = null;
          tabItem.closeRequested = true;
        } else {
          // Otherwise default to showing the close/dismiss button
          tabItem.tertiaryL10nId = "synced-tabs-context-close-tab-title";
          tabItem.tertiaryL10nArgs = JSON.stringify({ deviceName: name });
          tabItem.tertiaryActionClass = "dismiss-button";
          tabItem.closeRequested = false;
        }
        return tabItem;
      })
      .filter(
        item =>
          !this.isURLQueuedToClose(item.fxaDeviceId, item.url) ||
          item.url === this.lastClosedURL
      );
  }

  updateTabsList(syncedTabs) {
    if (!syncedTabs.length) {
      this.currentSyncedTabs = syncedTabs;
    }

    const tabsToRender = syncedTabs;

    // Return early if new tabs are the same as previous ones
    if (lazy.ObjectUtils.deepEqual(tabsToRender, this.currentSyncedTabs)) {
      return;
    }

    this.currentSyncedTabs = tabsToRender;
    this.host.requestUpdate();
  }

  async getSyncedTabData() {
    this.devices = await lazy.SyncedTabs.getTabClients();
    let tabs = await lazy.SyncedTabs.createRecentTabsList(this.devices, 5000, {
      removeAllDupes: false,
      removeDeviceDupes: true,
    });

    this.updateTabsList(tabs);
  }

  // Wrappers and helpful methods for SyncedTabManagement
  // so FxView and Sidebar don't need to import
  requestCloseRemoteTab(fxaDeviceId, url) {
    if (!this._pendingCloseTabs.has(fxaDeviceId)) {
      this._pendingCloseTabs.set(fxaDeviceId, new Set());
    }
    this._pendingCloseTabs.get(fxaDeviceId).add(url);
    this.lastClosedURL = url;
    lazy.SyncedTabsManagement.enqueueTabToClose(fxaDeviceId, url);
  }

  removePendingTabToClose(fxaDeviceId, url) {
    const urls = this._pendingCloseTabs.get(fxaDeviceId);
    if (urls) {
      urls.delete(url);
      if (!urls.size) {
        this._pendingCloseTabs.delete(fxaDeviceId);
      }
    }
    this.lastClosedURL = null;
    lazy.SyncedTabsManagement.removePendingTabToClose(fxaDeviceId, url);
  }

  isURLQueuedToClose(fxaDeviceId, url) {
    const urls = this._pendingCloseTabs.get(fxaDeviceId);
    return urls && urls.has(url);
  }
}
