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

/**
 * This module exports the TabsSetupFlowManager singleton, which manages the state and
 * diverse inputs which drive the Firefox View synced tabs setup flow
 */

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  Log: "resource://gre/modules/Log.sys.mjs",
  SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs",
  SyncedTabsErrorHandler:
    "resource:///modules/firefox-view-synced-tabs-error-handler.sys.mjs",
  UIState: "resource://services-sync/UIState.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "syncUtils", () => {
  return ChromeUtils.importESModule("resource://services-sync/util.sys.mjs")
    .Utils;
});

ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
  return ChromeUtils.importESModule(
    "resource://gre/modules/FxAccounts.sys.mjs"
  ).getFxAccountsSingleton();
});

const SYNC_TABS_PREF = "services.sync.engine.tabs";
const TOPIC_TABS_CHANGED = "services.sync.tabs.changed";
const LOGGING_PREF = "browser.tabs.firefox-view.logLevel";
const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.changed";
const TOPIC_DEVICESTATE_CHANGED = "firefox-view.devicestate.changed";
const TOPIC_DEVICELIST_UPDATED = "fxaccounts:devicelist_updated";
const NETWORK_STATUS_CHANGED = "network:offline-status-changed";
const SYNC_SERVICE_ERROR = "weave:service:sync:error";
const FXA_DEVICE_CONNECTED = "fxaccounts:device_connected";
const FXA_DEVICE_DISCONNECTED = "fxaccounts:device_disconnected";
const SYNC_SERVICE_FINISHED = "weave:service:sync:finish";
const PRIMARY_PASSWORD_UNLOCKED = "passwordmgr-crypto-login";

function openTabInWindow(window, url) {
  // Null checks as the passed window might be closing, particularly in tests.
  const ownerGlobal = window.docShell?.chromeEventHandler?.ownerGlobal;
  ownerGlobal?.switchToTabHavingURI(url, true, {});
}

export const TabsSetupFlowManager = new (class {
  constructor() {
    this.QueryInterface = ChromeUtils.generateQI(["nsIObserver"]);

    this.setupState = new Map();
    this.resetInternalState();
    this._currentSetupStateName = "";
    this.syncIsConnected = lazy.UIState.get().syncEnabled;
    this.didFxaTabOpen = false;

    this.registerSetupState({
      uiStateIndex: 0,
      name: "error-state",
      exitConditions: () => {
        return lazy.SyncedTabsErrorHandler.isSyncReady();
      },
    });
    this.registerSetupState({
      uiStateIndex: 1,
      name: "not-signed-in",
      exitConditions: () => {
        return this.fxaSignedIn;
      },
    });
    this.registerSetupState({
      uiStateIndex: 2,
      name: "connect-secondary-device",
      exitConditions: () => {
        return this.secondaryDeviceConnected;
      },
    });
    this.registerSetupState({
      uiStateIndex: 3,
      name: "disabled-tab-sync",
      exitConditions: () => {
        return this.syncTabsPrefEnabled;
      },
    });
    this.registerSetupState({
      uiStateIndex: 4,
      name: "synced-tabs-loaded",
      exitConditions: () => {
        // This is the end state
        return false;
      },
    });

    Services.obs.addObserver(this, lazy.UIState.ON_UPDATE);
    Services.obs.addObserver(this, TOPIC_DEVICELIST_UPDATED);
    Services.obs.addObserver(this, NETWORK_STATUS_CHANGED);
    Services.obs.addObserver(this, SYNC_SERVICE_ERROR);
    Services.obs.addObserver(this, SYNC_SERVICE_FINISHED);
    Services.obs.addObserver(this, TOPIC_TABS_CHANGED);
    Services.obs.addObserver(this, PRIMARY_PASSWORD_UNLOCKED);
    Services.obs.addObserver(this, FXA_DEVICE_CONNECTED);
    Services.obs.addObserver(this, FXA_DEVICE_DISCONNECTED);

    // this.syncTabsPrefEnabled will track the value of the tabs pref
    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "syncTabsPrefEnabled",
      SYNC_TABS_PREF,
      false,
      () => {
        this.maybeUpdateUI(true);
      }
    );

    this._lastFxASignedIn = this.fxaSignedIn;
    this.logger.debug(
      "TabsSetupFlowManager constructor, fxaSignedIn:",
      this._lastFxASignedIn
    );
    this.onSignedInChange();
  }

  resetInternalState() {
    // assign initial values for all the managed internal properties
    delete this._lastFxASignedIn;
    this._currentSetupStateName = "not-signed-in";
    this._shouldShowSuccessConfirmation = false;
    this._didShowMobilePromo = false;
    this.abortWaitingForTabs();

    Services.obs.notifyObservers(null, TOPIC_DEVICESTATE_CHANGED);

    // keep track of what is connected so we can respond to changes
    this._deviceStateSnapshot = {
      mobileDeviceConnected: this.mobileDeviceConnected,
      secondaryDeviceConnected: this.secondaryDeviceConnected,
    };
    // keep track of tab-pickup-container instance visibilities
    this._viewVisibilityStates = new Map();
  }

  get isPrimaryPasswordLocked() {
    return lazy.syncUtils.mpLocked();
  }

  uninit() {
    Services.obs.removeObserver(this, lazy.UIState.ON_UPDATE);
    Services.obs.removeObserver(this, TOPIC_DEVICELIST_UPDATED);
    Services.obs.removeObserver(this, NETWORK_STATUS_CHANGED);
    Services.obs.removeObserver(this, SYNC_SERVICE_ERROR);
    Services.obs.removeObserver(this, SYNC_SERVICE_FINISHED);
    Services.obs.removeObserver(this, TOPIC_TABS_CHANGED);
    Services.obs.removeObserver(this, PRIMARY_PASSWORD_UNLOCKED);
    Services.obs.removeObserver(this, FXA_DEVICE_CONNECTED);
    Services.obs.removeObserver(this, FXA_DEVICE_DISCONNECTED);
  }
  get hasVisibleViews() {
    return Array.from(this._viewVisibilityStates.values()).reduce(
      (hasVisible, visibility) => {
        return hasVisible || visibility == "visible";
      },
      false
    );
  }
  get currentSetupState() {
    return this.setupState.get(this._currentSetupStateName);
  }
  get isTabSyncSetupComplete() {
    return this.currentSetupState.uiStateIndex >= 4;
  }
  get uiStateIndex() {
    return this.currentSetupState.uiStateIndex;
  }
  get fxaSignedIn() {
    let { UIState } = lazy;
    let syncState = UIState.get();
    return (
      UIState.isReady() &&
      syncState.status === UIState.STATUS_SIGNED_IN &&
      // syncEnabled just checks the "services.sync.username" pref has a value
      syncState.syncEnabled
    );
  }

  get secondaryDeviceConnected() {
    if (!this.fxaSignedIn) {
      return false;
    }
    let recentDevices = lazy.fxAccounts.device?.recentDeviceList?.length;
    return recentDevices > 1;
  }
  get mobileDeviceConnected() {
    if (!this.fxaSignedIn) {
      return false;
    }
    let mobileClients = lazy.fxAccounts.device.recentDeviceList?.filter(
      device => device.type == "mobile" || device.type == "tablet"
    );
    return mobileClients?.length > 0;
  }
  get shouldShowMobilePromo() {
    return (
      this.syncIsConnected &&
      this.fxaSignedIn &&
      this.currentSetupState.uiStateIndex >= 4 &&
      !this.mobileDeviceConnected &&
      !this.mobilePromoDismissedPref
    );
  }
  get shouldShowMobileConnectedSuccess() {
    return (
      this.currentSetupState.uiStateIndex >= 3 &&
      this._shouldShowSuccessConfirmation &&
      this.mobileDeviceConnected
    );
  }
  get logger() {
    if (!this._log) {
      let setupLog = lazy.Log.repository.getLogger("FirefoxView.TabsSetup");
      setupLog.manageLevelFromPref(LOGGING_PREF);
      setupLog.addAppender(
        new lazy.Log.ConsoleAppender(new lazy.Log.BasicFormatter())
      );
      this._log = setupLog;
    }
    return this._log;
  }

  registerSetupState(state) {
    this.setupState.set(state.name, state);
  }

  async observe(subject, topic, data) {
    switch (topic) {
      case lazy.UIState.ON_UPDATE:
        this.logger.debug("Handling UIState update");
        this.syncIsConnected = lazy.UIState.get().syncEnabled;
        if (this._lastFxASignedIn !== this.fxaSignedIn) {
          this.onSignedInChange();
        } else {
          await this.maybeUpdateUI();
        }
        this._lastFxASignedIn = this.fxaSignedIn;
        break;
      case TOPIC_DEVICELIST_UPDATED: {
        this.logger.debug("Handling observer notification:", topic, data);
        const { deviceStateChanged, deviceAdded } = await this.refreshDevices();
        if (deviceStateChanged) {
          await this.maybeUpdateUI(true);
        }
        if (deviceAdded && this.secondaryDeviceConnected) {
          this.logger.debug("device was added");
          this._deviceAddedResultsNeverSeen = true;
          if (this.hasVisibleViews) {
            this.startWaitingForNewDeviceTabs();
          }
        }
        break;
      }
      case FXA_DEVICE_CONNECTED:
      case FXA_DEVICE_DISCONNECTED:
        await lazy.fxAccounts.device.refreshDeviceList({ ignoreCached: true });
        await this.maybeUpdateUI(true);
        break;
      case SYNC_SERVICE_ERROR:
        this.logger.debug(`Handling ${SYNC_SERVICE_ERROR}`);
        if (lazy.UIState.get().status == lazy.UIState.STATUS_SIGNED_IN) {
          this.abortWaitingForTabs();
          await this.maybeUpdateUI(true);
        }
        break;
      case NETWORK_STATUS_CHANGED:
        this.abortWaitingForTabs();
        await this.maybeUpdateUI(true);
        break;
      case SYNC_SERVICE_FINISHED:
        this.logger.debug(`Handling ${SYNC_SERVICE_FINISHED}`);
        // We intentionally leave any empty-tabs timestamp
        // as we may be still waiting for a sync that delivers some tabs
        this._waitingForNextTabSync = false;
        await this.maybeUpdateUI(true);
        break;
      case TOPIC_TABS_CHANGED:
        this.stopWaitingForTabs();
        break;
      case PRIMARY_PASSWORD_UNLOCKED:
        this.logger.debug(`Handling ${PRIMARY_PASSWORD_UNLOCKED}`);
        this.tryToClearError();
        break;
    }
  }

  updateViewVisibility(instanceId, visibility) {
    const wasVisible = this.hasVisibleViews;
    this.logger.debug(
      `updateViewVisibility for instance: ${instanceId}, visibility: ${visibility}`
    );
    if (visibility == "unloaded") {
      this._viewVisibilityStates.delete(instanceId);
    } else {
      this._viewVisibilityStates.set(instanceId, visibility);
    }
    const isVisible = this.hasVisibleViews;
    if (isVisible && !wasVisible) {
      // If we're already timing waiting for tabs from a newly-added device
      // we might be able to stop
      if (this._noTabsVisibleFromAddedDeviceTimestamp) {
        return this.stopWaitingForNewDeviceTabs();
      }
      if (this._deviceAddedResultsNeverSeen) {
        // If this is the first time a view has been visible since a device was added
        // we may want to start the empty-tabs visible timer
        return this.startWaitingForNewDeviceTabs();
      }
    }
    if (!isVisible) {
      this.logger.debug(
        "Resetting timestamp and tabs pending flags as there are no visible views"
      );
      // if there's no view visible, we're not really waiting anymore
      this.abortWaitingForTabs();
    }
    return null;
  }

  get waitingForTabs() {
    return (
      // signed in & at least 1 other device is syncing indicates there's something to wait for
      this.secondaryDeviceConnected && this._waitingForNextTabSync
    );
  }

  abortWaitingForTabs() {
    this._waitingForNextTabSync = false;
    // also clear out the device-added / tabs pending flags
    this._noTabsVisibleFromAddedDeviceTimestamp = 0;
    this._deviceAddedResultsNeverSeen = false;
  }

  startWaitingForTabs() {
    if (!this._waitingForNextTabSync) {
      this._waitingForNextTabSync = true;
      Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED);
    }
  }

  async stopWaitingForTabs() {
    const wasWaiting = this.waitingForTabs;
    if (this.hasVisibleViews && this._deviceAddedResultsNeverSeen) {
      await this.stopWaitingForNewDeviceTabs();
    }
    this._waitingForNextTabSync = false;
    if (wasWaiting) {
      Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED);
    }
  }

  async onSignedInChange() {
    this.logger.debug("onSignedInChange, fxaSignedIn:", this.fxaSignedIn);
    // update UI to make the state change
    await this.maybeUpdateUI(true);
    if (!this.fxaSignedIn) {
      // As we just signed out, ensure the waiting flag is reset for next time around
      this.abortWaitingForTabs();
      return;
    }

    // Now we need to figure out if we have recently synced tabs to show
    // Or, if we are going to need to trigger a tab sync for them
    const recentTabs = await lazy.SyncedTabs.getRecentTabs(50);

    if (!this.fxaSignedIn) {
      // We got signed-out in the meantime. We should get an ON_UPDATE which will put us
      // back in the right state, so we just do nothing here
      return;
    }

    // When SyncedTabs has resolved the getRecentTabs promise,
    // we also know we can update devices-related internal state
    const { deviceStateChanged } = await this.refreshDevices();
    if (deviceStateChanged) {
      this.logger.debug(
        "onSignedInChange, after refreshDevices, calling maybeUpdateUI"
      );
      // give the UI an opportunity to update as secondaryDeviceConnected or
      // mobileDeviceConnected have changed value
      await this.maybeUpdateUI(true);
    }

    // If we can't get recent tabs, we need to trigger a request for them
    const tabSyncNeeded = !recentTabs?.length;
    this.logger.debug("onSignedInChange, tabSyncNeeded:", tabSyncNeeded);

    if (tabSyncNeeded) {
      this.startWaitingForTabs();
      this.logger.debug(
        "isPrimaryPasswordLocked:",
        this.isPrimaryPasswordLocked
      );
      this.logger.debug("onSignedInChange, no recentTabs, calling syncTabs");
      // If the syncTabs call rejects or resolves false we need to clear the waiting
      // flag and update UI
      this.syncTabs()
        .catch(ex => {
          this.logger.debug("onSignedInChange, syncTabs rejected:", ex);
          this.stopWaitingForTabs();
        })
        .then(willSync => {
          if (!willSync) {
            this.logger.debug("onSignedInChange, no tab sync expected");
            this.stopWaitingForTabs();
          }
        });
    }
  }

  async startWaitingForNewDeviceTabs() {
    // if we're already waiting for tabs, don't reset
    if (this._noTabsVisibleFromAddedDeviceTimestamp) {
      return;
    }

    // take a timestamp whenever the latest device is added and we have 0 tabs to show,
    // allowing us to track how long we show an empty list after a new device is added
    const hasRecentTabs = (await lazy.SyncedTabs.getRecentTabs(1)).length;
    if (this.hasVisibleViews && !hasRecentTabs) {
      this._noTabsVisibleFromAddedDeviceTimestamp = Date.now();
      this.logger.debug(
        "New device added with 0 synced tabs to show, storing timestamp:",
        this._noTabsVisibleFromAddedDeviceTimestamp
      );
    }
  }

  async stopWaitingForNewDeviceTabs() {
    if (!this._noTabsVisibleFromAddedDeviceTimestamp) {
      return;
    }
    const recentTabs = await lazy.SyncedTabs.getRecentTabs(1);
    if (recentTabs.length) {
      // We have been waiting for > 0 tabs after a newly-added device, record
      // the time elapsed
      const elapsed = Date.now() - this._noTabsVisibleFromAddedDeviceTimestamp;
      this.logger.debug(
        "stopWaitingForTabs, resetting _noTabsVisibleFromAddedDeviceTimestamp and recording telemetry:",
        Math.round(elapsed / 1000)
      );
      this._noTabsVisibleFromAddedDeviceTimestamp = 0;
      this._deviceAddedResultsNeverSeen = false;
    } else {
      // we are still waiting for some tabs to show...
      this.logger.debug(
        "stopWaitingForTabs: Still no recent tabs, we are still waiting"
      );
    }
  }

  async refreshDevices() {
    // If current device not found in recent device list, refresh device list
    if (
      !lazy.fxAccounts.device.recentDeviceList?.some(
        device => device.isCurrentDevice
      )
    ) {
      await lazy.fxAccounts.device.refreshDeviceList({ ignoreCached: true });
    }

    // compare new values to the previous values
    const mobileDeviceConnected = this.mobileDeviceConnected;
    const secondaryDeviceConnected = this.secondaryDeviceConnected;
    const oldDevicesCount = this._deviceStateSnapshot?.devicesCount ?? 0;
    const devicesCount = lazy.fxAccounts.device?.recentDeviceList?.length ?? 0;

    this.logger.debug(
      `refreshDevices, mobileDeviceConnected: ${mobileDeviceConnected}, `,
      `secondaryDeviceConnected: ${secondaryDeviceConnected}`
    );

    let deviceStateChanged =
      this._deviceStateSnapshot.mobileDeviceConnected !=
        mobileDeviceConnected ||
      this._deviceStateSnapshot.secondaryDeviceConnected !=
        secondaryDeviceConnected;
    if (
      mobileDeviceConnected &&
      !this._deviceStateSnapshot.mobileDeviceConnected
    ) {
      // a mobile device was added, show success if we previously showed the promo
      this._shouldShowSuccessConfirmation = this._didShowMobilePromo;
    } else if (
      !mobileDeviceConnected &&
      this._deviceStateSnapshot.mobileDeviceConnected
    ) {
      // no mobile device connected now, reset
      this._shouldShowSuccessConfirmation = false;
    }
    this._deviceStateSnapshot = {
      mobileDeviceConnected,
      secondaryDeviceConnected,
      devicesCount,
    };
    if (deviceStateChanged) {
      this.logger.debug("refreshDevices: device state did change");
      if (!secondaryDeviceConnected) {
        this.logger.debug(
          "We lost a device, now claim sync hasn't worked before."
        );
        Services.obs.notifyObservers(null, TOPIC_DEVICESTATE_CHANGED);
      }
    } else {
      this.logger.debug("refreshDevices: no device state change");
    }
    return {
      deviceStateChanged,
      deviceAdded: oldDevicesCount < devicesCount,
    };
  }

  async maybeUpdateUI(forceUpdate = false) {
    let nextSetupStateName = this._currentSetupStateName;
    let errorState = null;
    let stateChanged = false;

    // state transition conditions
    for (let state of this.setupState.values()) {
      nextSetupStateName = state.name;
      if (!state.exitConditions()) {
        this.logger.debug(
          "maybeUpdateUI, conditions not met to exit state: ",
          nextSetupStateName
        );
        break;
      }
    }

    let setupState = this.currentSetupState;
    const state = this.setupState.get(nextSetupStateName);
    const uiStateIndex = state.uiStateIndex;

    if (
      uiStateIndex == 0 ||
      nextSetupStateName != this._currentSetupStateName
    ) {
      setupState = state;
      this._currentSetupStateName = nextSetupStateName;
      stateChanged = true;
    }
    this.logger.debug(
      "maybeUpdateUI, will notify update?:",
      stateChanged,
      forceUpdate
    );
    if (stateChanged || forceUpdate) {
      if (this.shouldShowMobilePromo) {
        this._didShowMobilePromo = true;
      }
      if (uiStateIndex == 0) {
        // Use idleDispatch() to give observers a chance to resolve before
        // determining the new state.
        errorState = await new Promise(resolve => {
          ChromeUtils.idleDispatch(() => {
            resolve(lazy.SyncedTabsErrorHandler.getErrorType());
          });
        });
        this.logger.debug("maybeUpdateUI, in error state:", errorState);
      }
      Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED, errorState);
    }
    if ("function" == typeof setupState.enter) {
      setupState.enter();
    }
  }

  async openFxASignup(window) {
    if (!(await lazy.fxAccounts.constructor.canConnectAccount())) {
      return;
    }
    const url =
      await lazy.fxAccounts.constructor.config.promiseConnectAccountURI(
        "fx-view"
      );
    this.didFxaTabOpen = true;
    openTabInWindow(window, url, true);
  }

  async openFxAPairDevice(window) {
    const url = await lazy.fxAccounts.constructor.config.promisePairingURI({
      entrypoint: "fx-view",
    });
    this.didFxaTabOpen = true;
    openTabInWindow(window, url, true);
  }

  syncOpenTabs() {
    // Flip the pref on.
    // The observer should trigger re-evaluating state and advance to next step
    Services.prefs.setBoolPref(SYNC_TABS_PREF, true);
  }

  async syncOnPageReload() {
    if (lazy.UIState.isReady() && this.fxaSignedIn) {
      this.startWaitingForTabs();
      await this.syncTabs(true);
    }
  }

  tryToClearError() {
    if (lazy.UIState.isReady() && this.fxaSignedIn) {
      this.startWaitingForTabs();
      if (this.isPrimaryPasswordLocked) {
        lazy.syncUtils.ensureMPUnlocked();
      }
      this.logger.debug("tryToClearError: triggering new tab sync");
      this.syncTabs();
      Services.tm.dispatchToMainThread(() => {});
    } else {
      this.logger.debug(
        `tryToClearError: unable to sync, isReady: ${lazy.UIState.isReady()}, fxaSignedIn: ${
          this.fxaSignedIn
        }`
      );
    }
  }
  // For easy overriding in tests
  syncTabs(force = false) {
    return lazy.SyncedTabs.syncTabs(force);
  }
})();
