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

requestLongerTimeout(2);

const { FxAccounts } = ChromeUtils.importESModule(
  "resource://gre/modules/FxAccounts.sys.mjs"
);
let { SyncedTabs } = ChromeUtils.importESModule(
  "resource://services-sync/SyncedTabs.sys.mjs"
);
let { UIState } = ChromeUtils.importESModule(
  "resource://services-sync/UIState.sys.mjs"
);

ChromeUtils.defineESModuleGetters(this, {
  UITour: "moz-src:///browser/components/uitour/UITour.sys.mjs",
});

const DECKINDEX_TABS = 0;
const DECKINDEX_FETCHING = 1;
const DECKINDEX_TABSDISABLED = 2;
const DECKINDEX_NOCLIENTS = 3;

const SAMPLE_TAB_URL = "https://example.com/";

var initialLocation = gBrowser.currentURI.spec;
var newTab = null;

// A helper to notify there are new tabs. Returns a promise that is resolved
// once the UI has been updated.
function updateTabsPanel() {
  let promiseTabsUpdated = promiseObserverNotified(
    "synced-tabs-menu:test:tabs-updated"
  );
  Services.obs.notifyObservers(null, SyncedTabs.TOPIC_TABS_CHANGED);
  return promiseTabsUpdated;
}

// This is the mock we use for SyncedTabs.sys.mjs - tests may override various
// functions.
let mockedInternal = {
  get isConfiguredToSyncTabs() {
    return true;
  },
  getTabClients() {
    return Promise.resolve([]);
  },
  syncTabs() {
    return Promise.resolve();
  },
  hasSyncedThisSession: false,
};

add_setup(async function () {
  const getSignedInUser = FxAccounts.config.getSignedInUser;

  FxAccounts.config.getSignedInUser = async () =>
    Promise.resolve({ uid: "uid", email: "foo@bar.com" });

  Services.prefs.setCharPref(
    "identity.fxaccounts.remote.root",
    "https://example.com/"
  );

  // Mock the global fxAccounts object used by gSync
  const origWindowFxAccounts = window.fxAccounts;
  window.fxAccounts = {
    getSignedInUser: async () => ({ uid: "uid", email: "foo@bar.com" }),
    hasLocalSession: async () => true,
    keys: {
      canGetKeyForScope: async () => true,
    },
    device: {
      recentDeviceList: null,
    },
  };

  let oldInternal = SyncedTabs._internal;
  SyncedTabs._internal = mockedInternal;

  let origNotifyStateUpdated = UIState._internal.notifyStateUpdated;
  // Sync start-up will interfere with our tests, don't let UIState send UI updates.
  UIState._internal.notifyStateUpdated = () => {};

  // Force gSync initialization
  gSync.init();

  registerCleanupFunction(() => {
    FxAccounts.config.getSignedInUser = getSignedInUser;
    Services.prefs.clearUserPref("identity.fxaccounts.remote.root");
    UIState._internal.notifyStateUpdated = origNotifyStateUpdated;
    window.fxAccounts = origWindowFxAccounts;
    SyncedTabs._internal = oldInternal;
  });
});

// The test expects the about:preferences#sync page to open in the current tab
async function openPrefsFromMenuPanel(expectedPanelId, entryPoint) {
  info("Check Sync button functionality");
  CustomizableUI.addWidgetToArea(
    "sync-button",
    CustomizableUI.AREA_FIXED_OVERFLOW_PANEL
  );

  await waitForOverflowButtonShown();

  // check the button's functionality
  await document.getElementById("nav-bar").overflowable.show();

  if (entryPoint == "uitour") {
    UITour.tourBrowsersByWindow.set(window, new Set());
    UITour.tourBrowsersByWindow.get(window).add(gBrowser.selectedBrowser);
  }

  let syncButton = document.getElementById("sync-button");
  ok(syncButton, "The Sync button was added to the Panel Menu");

  let tabsUpdatedPromise = promiseObserverNotified(
    "synced-tabs-menu:test:tabs-updated"
  );
  syncButton.click();
  let syncPanel = document.getElementById("PanelUI-remotetabs");
  let viewShownPromise = BrowserTestUtils.waitForEvent(syncPanel, "ViewShown");
  await Promise.all([tabsUpdatedPromise, viewShownPromise]);
  ok(syncPanel.getAttribute("visible"), "Sync Panel is in view");

  // Sync is not configured - verify that state is reflected.
  let subpanel = document.getElementById(expectedPanelId);
  ok(!subpanel.hidden, "sync setup element is visible");

  // Find and click the "setup" button.
  let setupButton = subpanel.querySelector(".PanelUI-remotetabs-button");
  setupButton.click();

  await new Promise(resolve => {
    let handler = async e => {
      if (
        e.originalTarget != gBrowser.selectedBrowser.contentDocument ||
        e.target.location.href == "about:blank"
      ) {
        info("Skipping spurious 'load' event for " + e.target.location.href);
        return;
      }
      gBrowser.selectedBrowser.removeEventListener("load", handler, true);
      resolve();
    };
    gBrowser.selectedBrowser.addEventListener("load", handler, true);
  });
  newTab = gBrowser.selectedTab;

  is(
    gBrowser.currentURI.spec,
    "about:preferences?entrypoint=" + entryPoint + "#sync",
    "Firefox Sync preference page opened with `menupanel` entrypoint"
  );
  ok(!isOverflowOpen(), "The panel closed");

  if (isOverflowOpen()) {
    await hideOverflow();
  }
}

async function asyncCleanup() {
  // reset the panel UI to the default state
  await resetCustomization();
  ok(CustomizableUI.inDefaultState, "The panel UI is in default state again.");

  // restore the tabs
  BrowserTestUtils.addTab(gBrowser, initialLocation);
  gBrowser.removeTab(newTab);
  UITour.tourBrowsersByWindow.delete(window);
}

// When Sync is not setup.
add_task(async function () {
  gSync.updateAllUI({ status: UIState.STATUS_NOT_CONFIGURED });
  await openPrefsFromMenuPanel("PanelUI-remotetabs-setupsync", "synced-tabs");
});
add_task(asyncCleanup);

// When an account is connected by Sync is not enabled.
add_task(async function () {
  gSync.updateAllUI({ status: UIState.STATUS_SIGNED_IN, syncEnabled: false });
  await openPrefsFromMenuPanel(
    "PanelUI-remotetabs-syncdisabled",
    "synced-tabs"
  );
});
add_task(asyncCleanup);

// When Sync is configured in an unverified state.
add_task(async function () {
  gSync.updateAllUI({
    status: UIState.STATUS_NOT_VERIFIED,
    email: "foo@bar.com",
  });
  await openPrefsFromMenuPanel("PanelUI-remotetabs-unverified", "synced-tabs");
});
add_task(asyncCleanup);

// When Sync is configured in a "needs reauthentication" state.
add_task(async function () {
  gSync.updateAllUI({
    status: UIState.STATUS_LOGIN_FAILED,
    email: "foo@bar.com",
  });
  await openPrefsFromMenuPanel("PanelUI-remotetabs-reauthsync", "synced-tabs");
});

// Test the Connect Another Device button
add_task(async function () {
  gSync.updateAllUI({
    status: UIState.STATUS_SIGNED_IN,
    syncEnabled: true,
    email: "foo@bar.com",
    lastSync: new Date(),
  });

  let button = document.getElementById(
    "PanelUI-remotetabs-connect-device-button"
  );
  ok(button, "found the button");

  await document.getElementById("nav-bar").overflowable.show();
  // Actually show the fxa view:
  let shown = BrowserTestUtils.waitForEvent(
    document.getElementById("PanelUI-remotetabs"),
    "ViewShown"
  );
  PanelUI.showSubView(
    "PanelUI-remotetabs",
    document.getElementById("sync-button")
  );
  await shown;

  let promiseTabOpened = BrowserTestUtils.waitForNewTab(
    gBrowser,
    url =>
      url.startsWith("https://example.com/connect_another_device") &&
      url.includes("entrypoint=synced-tabs")
  );
  button.click();
  // the panel should have been closed.
  ok(!isOverflowOpen(), "click closed the panel");
  await promiseTabOpened;

  gBrowser.removeTab(gBrowser.selectedTab);
});

// Test the "Sync Now" button
add_task(async function () {
  gSync.updateAllUI({
    status: UIState.STATUS_SIGNED_IN,
    syncEnabled: true,
    email: "foo@bar.com",
    lastSync: new Date(),
  });

  await document.getElementById("nav-bar").overflowable.show();
  let tabsUpdatedPromise = promiseObserverNotified(
    "synced-tabs-menu:test:tabs-updated"
  );
  let syncPanel = document.getElementById("PanelUI-remotetabs");
  let viewShownPromise = BrowserTestUtils.waitForEvent(syncPanel, "ViewShown");
  let syncButton = document.getElementById("sync-button");
  syncButton.click();
  await Promise.all([tabsUpdatedPromise, viewShownPromise]);
  ok(syncPanel.getAttribute("visible"), "Sync Panel is in view");

  let subpanel = document.getElementById("PanelUI-remotetabs-main");
  ok(!subpanel.hidden, "main pane is visible");
  let deck = document.getElementById("PanelUI-remotetabs-deck");

  // The widget is still fetching tabs, as we've neutered everything that
  // provides them
  is(deck.selectedIndex, DECKINDEX_FETCHING, "first deck entry is visible");

  // Tell the widget there are tabs available, but with zero clients.
  mockedInternal.getTabClients = () => {
    return Promise.resolve([]);
  };
  mockedInternal.hasSyncedThisSession = true;
  await updateTabsPanel();
  // The UI should be showing the "no clients" pane.
  is(
    deck.selectedIndex,
    DECKINDEX_NOCLIENTS,
    "no-clients deck entry is visible"
  );

  // Tell the widget there are tabs available - we have 3 clients, one with no
  // tabs.
  mockedInternal.getTabClients = () => {
    return Promise.resolve([
      {
        id: "guid_mobile",
        type: "client",
        name: "My Phone",
        lastModified: 1492201200,
        tabs: [],
      },
      {
        id: "guid_desktop",
        type: "client",
        name: "My Desktop",
        lastModified: 1492201200,
        tabs: [
          {
            title: "http://example.com/10",
            lastUsed: 10, // the most recent
          },
          {
            title: "http://example.com/1",
            lastUsed: 1, // the least recent.
          },
          {
            title: "http://example.com/5",
            lastUsed: 5,
          },
        ],
      },
      {
        id: "guid_second_desktop",
        name: "My Other Desktop",
        lastModified: 1492201200,
        tabs: [
          {
            title: "http://example.com/6",
            icon: "http://example.com/favicon.ico",
            lastUsed: 6,
          },
        ],
      },
    ]);
  };
  await updateTabsPanel();

  // The UI should be showing tabs!
  is(deck.selectedIndex, DECKINDEX_TABS, "no-clients deck entry is visible");
  let tabList = document.getElementById("PanelUI-remotetabs-tabslist");
  let node = tabList.firstElementChild;
  // First entry should be the client with the most-recent tab.
  is(node.nodeName, "vbox");
  let currentClient = node;
  node = node.firstElementChild;
  is(node.getAttribute("itemtype"), "client", "node is a client entry");
  is(node.textContent, "My Desktop", "correct client");
  // Next node is an hbox, that contains the tab and potentially
  // a button for closing the tab remotely
  node = node.nextElementSibling;
  is(node.nodeName, "hbox");
  // Next entry is the most-recent tab
  let childNode = node.firstElementChild;
  is(childNode.getAttribute("itemtype"), "tab", "node is a tab");
  is(childNode.getAttribute("label"), "http://example.com/10");

  // Next entry is the next-most-recent tab
  node = node.nextElementSibling;
  is(node.nodeName, "hbox");
  childNode = node.firstElementChild;
  is(childNode.getAttribute("itemtype"), "tab", "node is a tab");
  is(childNode.getAttribute("label"), "http://example.com/5");

  // Next entry is the least-recent tab from the first client.
  node = node.nextElementSibling;
  is(node.nodeName, "hbox");
  childNode = node.firstElementChild;
  is(childNode.getAttribute("itemtype"), "tab", "node is a tab");
  is(childNode.getAttribute("label"), "http://example.com/1");
  node = node.nextElementSibling;
  is(node, null, "no more siblings");

  // Next is a toolbarseparator between the clients.
  node = currentClient.nextElementSibling;
  is(node.nodeName, "toolbarseparator");

  // Next is the container for client 2.
  node = node.nextElementSibling;
  is(node.nodeName, "vbox");
  currentClient = node;

  // Next is the client with 1 tab.
  node = node.firstElementChild;
  is(node.getAttribute("itemtype"), "client", "node is a client entry");
  is(node.textContent, "My Other Desktop", "correct client");
  // Its single tab
  node = node.nextElementSibling;
  is(node.nodeName, "hbox");
  childNode = node.firstElementChild;
  is(childNode.getAttribute("itemtype"), "tab", "node is a tab");
  is(childNode.getAttribute("label"), "http://example.com/6");
  // In the browser the URL will have been re-written to a "moz-remote-image:" URL - however, the way we
  // mock the remote tabs bypasses that. Tests for that functionality are in sync's test_syncedtabs.js test.
  is(
    childNode.getAttribute("image"),
    "http://example.com/favicon.ico",
    "image url is correct"
  );
  node = node.nextElementSibling;
  is(node, null, "no more siblings");

  // Next is a toolbarseparator between the clients.
  node = currentClient.nextElementSibling;
  is(node.nodeName, "toolbarseparator");

  // Next is the container for client 3.
  node = node.nextElementSibling;
  is(node.nodeName, "vbox");
  currentClient = node;

  // Next is the client with no tab.
  node = node.firstElementChild;
  is(node.getAttribute("itemtype"), "client", "node is a client entry");
  is(node.textContent, "My Phone", "correct client");
  // There is a single node saying there's no tabs for the client.
  node = node.nextElementSibling;
  is(node.nodeName, "label", "node is a label");
  is(node.getAttribute("itemtype"), null, "node is neither a tab nor a client");

  node = node.nextElementSibling;
  is(node, null, "no more siblings");
  is(currentClient.nextElementSibling, null, "no more clients");

  // Check accessibility. There should be containers for each client, with an
  // aria attribute that identifies the client name.
  let clientContainers = [
    ...tabList.querySelectorAll("[aria-labelledby]").values(),
  ];
  let labelIds = clientContainers.map(container =>
    container.getAttribute("aria-labelledby")
  );
  let labels = labelIds.map(id => document.getElementById(id).textContent);
  Assert.deepEqual(labels.sort(), [
    "My Desktop",
    "My Other Desktop",
    "My Phone",
  ]);

  let didSync = false;
  let oldDoSync = gSync.doSync;
  gSync.doSync = function () {
    didSync = true;
    gSync.doSync = oldDoSync;
  };

  let syncNowButton = document.getElementById("PanelUI-remotetabs-syncnow");
  is(syncNowButton.disabled, false);
  syncNowButton.click();
  ok(didSync, "clicking the button called the correct function");

  await hideOverflow();

  await SpecialPowers.popPrefEnv();
});

// Test the pagination capabilities (Show More/All tabs)
add_task(async function () {
  mockedInternal.getTabClients = () => {
    return Promise.resolve([
      {
        id: "guid_desktop",
        type: "client",
        name: "My Desktop",
        lastModified: 1492201200,
        tabs: (function () {
          let allTabsDesktop = [];
          // We choose 77 tabs, because TABS_PER_PAGE is 25, which means
          // on the second to last page we should have 22 items shown
          // (because we have to show at least NEXT_PAGE_MIN_TABS=5 tabs on the last page)
          for (let i = 1; i <= 77; i++) {
            allTabsDesktop.push({ title: "Tab #" + i, url: SAMPLE_TAB_URL });
          }
          return allTabsDesktop;
        })(),
      },
    ]);
  };

  gSync.updateAllUI({
    status: UIState.STATUS_SIGNED_IN,
    syncEnabled: true,
    lastSync: new Date(),
    email: "foo@bar.com",
  });

  await document.getElementById("nav-bar").overflowable.show();
  let tabsUpdatedPromise = promiseObserverNotified(
    "synced-tabs-menu:test:tabs-updated"
  );
  let syncPanel = document.getElementById("PanelUI-remotetabs");
  let viewShownPromise = BrowserTestUtils.waitForEvent(syncPanel, "ViewShown");
  let syncButton = document.getElementById("sync-button");
  syncButton.click();
  await Promise.all([tabsUpdatedPromise, viewShownPromise]);

  // Check pre-conditions
  ok(syncPanel.getAttribute("visible"), "Sync Panel is in view");
  let subpanel = document.getElementById("PanelUI-remotetabs-main");
  ok(!subpanel.hidden, "main pane is visible");
  let deck = document.getElementById("PanelUI-remotetabs-deck");
  is(deck.selectedIndex, DECKINDEX_TABS, "we should be showing tabs");

  function checkTabsPage(tabsShownCount, showMoreLabel) {
    let tabList = document.getElementById("PanelUI-remotetabs-tabslist");
    let node = tabList.firstElementChild.firstElementChild;
    is(node.getAttribute("itemtype"), "client", "node is a client entry");
    is(node.textContent, "My Desktop", "correct client");
    for (let i = 0; i < tabsShownCount; i++) {
      node = node.nextElementSibling;
      is(node.nodeName, "hbox");
      let childNode = node.firstElementChild;
      is(childNode.getAttribute("itemtype"), "tab", "node is a tab");
      is(
        childNode.getAttribute("label"),
        "Tab #" + (i + 1),
        "the tab is the correct one"
      );
      is(
        childNode.getAttribute("targetURI"),
        SAMPLE_TAB_URL,
        "url is the correct one"
      );
    }
    let showMoreButton;
    if (showMoreLabel) {
      node = showMoreButton = node.nextElementSibling;
      is(
        node.getAttribute("itemtype"),
        "showmorebutton",
        "node is a show more button"
      );
      is(node.getAttribute("label"), showMoreLabel);
    }
    node = node.nextElementSibling;
    is(node, null, "no more entries");

    return showMoreButton;
  }

  async function checkCanOpenURL() {
    let tabList = document.getElementById("PanelUI-remotetabs-tabslist");
    let node =
      tabList.firstElementChild.firstElementChild.nextElementSibling
        .firstElementChild;
    let promiseTabOpened = BrowserTestUtils.waitForLocationChange(
      gBrowser,
      SAMPLE_TAB_URL
    );
    node.click();
    await promiseTabOpened;
  }

  let showMoreButton;
  function clickShowMoreButton() {
    let promise = promiseObserverNotified("synced-tabs-menu:test:tabs-updated");
    showMoreButton.click();
    return promise;
  }

  showMoreButton = checkTabsPage(25, "Show more tabs");
  await clickShowMoreButton();

  checkTabsPage(77, null);
  /* calling this will close the overflow menu */
  await checkCanOpenURL();
});
