/* 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;
        }
      }
    }
  }

  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",
      description: "firefoxview-syncedtabs-signin-description",
      buttonLabel: "firefoxview-syncedtabs-signin-primarybutton",
    },
    "add-device": {
      header: "firefoxview-syncedtabs-adddevice-header",
      description: "firefoxview-syncedtabs-adddevice-description",
      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,
      headerIconUrl,
      mainImageUrl;
    let descriptionArray;
    if (error) {
      let link;
      ({ header, description, link, buttonLabel } =
        SyncedTabsErrorHandler.getFluentStringsForErrorType(errorState));
      action = `${errorState}`;
      headerIconUrl = "chrome://global/skin/icons/info-filled.svg";
      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-error.svg";
      descriptionArray = [description];
    }
    return {
      action,
      buttonLabel,
      descriptionArray,
      descriptionLink,
      error,
      header,
      headerIconUrl,
      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].tabs))
        : this.getTabItems(renderInfo[id].tabs);
    }
    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;
  }

  getTabItems(tabs) {
    return tabs?.map(tab => ({
      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,
    }));
  }

  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, 50, {
      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);
  }
}
