"use strict";

add_setup(async function () {
  /*
     This test is specific to the legacy sidebar which is entirely distinct from the
     implementation for the new sidebar.
     Equivalent test coverage for the new synced tabs sidebar are located at
     browser/components/sidebar/tests/browser/browser_syncedtabs_sidebar.js
  */
  await SpecialPowers.pushPrefEnv({
    set: [["sidebar.revamp", false]],
  });
});

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

const FIXTURE = [
  {
    id: "7cqCr77ptzX3",
    type: "client",
    lastModified: 1492201200,
    name: "zcarter's Nightly on MacBook-Pro-25",
    clientType: "desktop",
    tabs: [
      {
        type: "tab",
        title:
          "Firefox for Android — Mobile Web browser — More ways to customize and protect your privacy — Mozilla",
        url: "https://www.mozilla.org/en-US/firefox/android/?utm_source=firefox-browser&utm_medium=firefox-browser&utm_campaign=synced-tabs-sidebar",
        icon: "chrome://global/skin/icons/defaultFavicon.svg",
        client: "7cqCr77ptzX3",
        lastUsed: 1452124677,
      },
    ],
  },
  {
    id: "2xU5h-4bkWqA",
    type: "client",
    lastModified: 1492201200,
    name: "laptop",
    clientType: "desktop",
    tabs: [
      {
        type: "tab",
        title:
          "Firefox for iOS — Mobile Web browser for your iPhone, iPad and iPod touch — Mozilla",
        url: "https://www.mozilla.org/en-US/firefox/ios/?utm_source=firefox-browser&utm_medium=firefox-browser&utm_campaign=synced-tabs-sidebar",
        icon: "cached-favicon:https://www.mozilla.org/media/img/firefox/favicon.dc6635050bf5.ico",
        client: "2xU5h-4bkWqA",
        lastUsed: 1451519425,
      },
      {
        type: "tab",
        title: "Firefox Nightly First Run Page",
        url: "https://www.mozilla.org/en-US/firefox/nightly/firstrun/?oldversion=45.0a1",
        icon: "cached-favicon:https://www.mozilla.org/media/img/firefox/favicon-nightly.560395bbb2e1.png",
        client: "2xU5h-4bkWqA",
        lastUsed: 1451519420,
      },
      {
        // Should appear first for this client.
        type: "tab",
        title: "Mozilla Developer Network",
        url: "https://developer.mozilla.org/en-US/",
        icon: "cached-favicon:https://developer.cdn.mozilla.net/static/img/favicon32.e02854fdcf73.png",
        client: "2xU5h-4bkWqA",
        lastUsed: 1451519725,
      },
    ],
  },
  {
    id: "OL3EJCsdb2JD",
    type: "client",
    lastModified: 1492201200,
    name: "desktop",
    clientType: "desktop",
    tabs: [],
  },
];

function setupSyncedTabsStubs({
  uiState = { status: UIState.STATUS_SIGNED_IN, syncEnabled: true },
  isConfiguredToSyncTabs = true,
  hasSyncedThisSession = true,
  tabClients = Cu.cloneInto(FIXTURE, {}),
} = {}) {
  sinon.stub(UIState, "get").returns(uiState);
  sinon.stub(SyncedTabs._internal, "getTabClients").resolves(tabClients);
  sinon.stub(SyncedTabs._internal, "syncTabs").resolves();
  sinon
    .stub(SyncedTabs._internal, "isConfiguredToSyncTabs")
    .value(isConfiguredToSyncTabs);
  sinon
    .stub(SyncedTabs._internal, "hasSyncedThisSession")
    .value(hasSyncedThisSession);
}

async function testClean() {
  sinon.restore();
  await new Promise(resolve => {
    window.SidebarController.browser.contentWindow.addEventListener(
      "unload",
      function () {
        resolve();
      },
      { once: true }
    );
    SidebarController.hide();
  });
}

add_task(async function testSyncedTabsSidebarList() {
  await SidebarController.show("viewTabsSidebar");

  Assert.equal(
    SidebarController.currentID,
    "viewTabsSidebar",
    "Sidebar should have SyncedTabs loaded"
  );

  let syncedTabsDeckComponent =
    SidebarController.browser.contentWindow.syncedTabsDeckComponent;

  Assert.ok(syncedTabsDeckComponent, "component exists");

  setupSyncedTabsStubs();

  await syncedTabsDeckComponent.updatePanel();
  // This is a hacky way of waiting for the view to render. The view renders
  // after the following promise (a different instance of which is triggered
  // in updatePanel) resolves, so we wait for it here as well
  await syncedTabsDeckComponent.tabListComponent._store.getData();

  Assert.ok(SyncedTabs._internal.getTabClients.called, "get clients called");

  let selectedPanel = syncedTabsDeckComponent.container.querySelector(
    ".sync-state.selected"
  );

  Assert.ok(
    selectedPanel.classList.contains("tabs-container"),
    "tabs panel is selected"
  );

  Assert.equal(
    selectedPanel.querySelectorAll(".tab").length,
    4,
    "four tabs listed"
  );
  Assert.equal(
    selectedPanel.querySelectorAll(".client").length,
    3,
    "three clients listed"
  );
  Assert.equal(
    selectedPanel.querySelectorAll(".client")[2].querySelectorAll(".empty")
      .length,
    1,
    "third client is empty"
  );

  // Verify that the tabs are sorted by last used time.
  var expectedTabIndices = [[0], [2, 0, 1]];
  Array.prototype.forEach.call(
    selectedPanel.querySelectorAll(".client"),
    (clientNode, i) => {
      checkItem(clientNode, FIXTURE[i]);
      Array.prototype.forEach.call(
        clientNode.querySelectorAll(".tab"),
        (tabNode, j) => {
          let tabIndex = expectedTabIndices[i][j];
          checkItem(tabNode, FIXTURE[i].tabs[tabIndex]);
        }
      );
    }
  );
});

add_task(testClean);

add_task(async function testSyncedTabsSidebarFilteredList() {
  await SidebarController.show("viewTabsSidebar");
  let syncedTabsDeckComponent =
    window.SidebarController.browser.contentWindow.syncedTabsDeckComponent;

  Assert.ok(syncedTabsDeckComponent, "component exists");

  setupSyncedTabsStubs();

  await syncedTabsDeckComponent.updatePanel();

  let filterInput =
    syncedTabsDeckComponent._window.document.querySelector(".tabsFilter");
  filterInput.value = "filter text";
  filterInput.blur();

  // This is a hacky way of waiting for the view to render. The view renders
  // after the following promise (a different instance of which is triggered
  // in updatePanel) resolves, so we wait for it here as well
  await syncedTabsDeckComponent.tabListComponent._store.getData("filter text");

  let selectedPanel = syncedTabsDeckComponent.container.querySelector(
    ".sync-state.selected"
  );
  Assert.ok(
    selectedPanel.classList.contains("tabs-container"),
    "tabs panel is selected"
  );

  Assert.equal(
    selectedPanel.querySelectorAll(".tab").length,
    4,
    "four tabs listed"
  );
  Assert.equal(
    selectedPanel.querySelectorAll(".client").length,
    0,
    "no clients are listed"
  );

  Assert.equal(
    filterInput.value,
    "filter text",
    "filter text box has correct value"
  );

  // Tabs should not be sorted when filter is active.
  let FIXTURE_TABS = FIXTURE.reduce(
    (prev, client) => prev.concat(client.tabs),
    []
  );

  Array.prototype.forEach.call(
    selectedPanel.querySelectorAll(".tab"),
    (tabNode, i) => {
      checkItem(tabNode, FIXTURE_TABS[i]);
    }
  );

  // Removing the filter should resort tabs.
  FIXTURE_TABS.sort((a, b) => b.lastUsed - a.lastUsed);
  await syncedTabsDeckComponent.tabListComponent._store.getData();
  Array.prototype.forEach.call(
    selectedPanel.querySelectorAll(".tab"),
    (tabNode, i) => {
      checkItem(tabNode, FIXTURE_TABS[i]);
    }
  );
});

add_task(testClean);

add_task(async function testSyncedTabsSidebarStatus() {
  await SidebarController.show("viewTabsSidebar");
  let syncedTabsDeckComponent =
    window.SidebarController.browser.contentWindow.syncedTabsDeckComponent;

  Assert.ok(syncedTabsDeckComponent, "component exists");

  setupSyncedTabsStubs({
    uiState: { status: UIState.STATUS_NOT_CONFIGURED },
    isConfiguredToSyncTabs: false,
    hasSyncedThisSession: false,
    tabClients: [],
  });
  await syncedTabsDeckComponent.updatePanel();
  let selectedPanel = syncedTabsDeckComponent.container.querySelector(
    ".sync-state.selected"
  );
  Assert.ok(
    selectedPanel.classList.contains("notAuthedInfo"),
    "not-authed panel is selected"
  );
  sinon.restore();

  setupSyncedTabsStubs({
    uiState: { status: UIState.STATUS_NOT_VERIFIED },
    isConfiguredToSyncTabs: false,
    hasSyncedThisSession: false,
    tabClients: [],
  });
  await syncedTabsDeckComponent.updatePanel();
  selectedPanel = syncedTabsDeckComponent.container.querySelector(
    ".sync-state.selected"
  );
  Assert.ok(
    selectedPanel.classList.contains("unverified"),
    "unverified panel is selected"
  );
  sinon.restore();

  setupSyncedTabsStubs({
    uiState: { status: UIState.STATUS_SIGNED_IN, syncEnabled: false },
    isConfiguredToSyncTabs: false,
    hasSyncedThisSession: false,
    tabClients: [],
  });
  await syncedTabsDeckComponent.updatePanel();
  selectedPanel = syncedTabsDeckComponent.container.querySelector(
    ".sync-state.selected"
  );
  Assert.ok(
    selectedPanel.classList.contains("syncDisabled"),
    "sync disabled panel is selected"
  );
  sinon.restore();

  setupSyncedTabsStubs({
    uiState: { status: UIState.STATUS_LOGIN_FAILED },
    isConfiguredToSyncTabs: false,
    hasSyncedThisSession: false,
    tabClients: [],
  });
  await syncedTabsDeckComponent.updatePanel();
  selectedPanel = syncedTabsDeckComponent.container.querySelector(
    ".sync-state.selected"
  );
  Assert.ok(
    selectedPanel.classList.contains("reauth"),
    "reauth panel is selected"
  );
  sinon.restore();

  setupSyncedTabsStubs({
    isConfiguredToSyncTabs: false,
    hasSyncedThisSession: false,
    tabClients: [],
  });
  await syncedTabsDeckComponent.updatePanel();
  selectedPanel = syncedTabsDeckComponent.container.querySelector(
    ".sync-state.selected"
  );
  Assert.ok(
    selectedPanel.classList.contains("tabs-disabled"),
    "tabs disabled panel is selected"
  );
  sinon.restore();

  setupSyncedTabsStubs({
    isConfiguredToSyncTabs: true,
    hasSyncedThisSession: false,
    tabClients: [],
  });
  await syncedTabsDeckComponent.updatePanel();
  selectedPanel = syncedTabsDeckComponent.container.querySelector(
    ".sync-state.selected"
  );
  Assert.ok(
    selectedPanel.classList.contains("tabs-fetching"),
    "tabs fetch panel is selected"
  );
  sinon.restore();

  setupSyncedTabsStubs({
    isConfiguredToSyncTabs: true,
    hasSyncedThisSession: true,
    tabClients: [],
  });
  await syncedTabsDeckComponent.updatePanel();
  selectedPanel = syncedTabsDeckComponent.container.querySelector(
    ".sync-state.selected"
  );
  Assert.ok(
    selectedPanel.classList.contains("singleDeviceInfo"),
    "tabs fetch panel is selected"
  );
  sinon.restore();

  setupSyncedTabsStubs({
    isConfiguredToSyncTabs: true,
    hasSyncedThisSession: true,
    tabClients: [{ id: "mock" }],
  });
  await syncedTabsDeckComponent.updatePanel();
  selectedPanel = syncedTabsDeckComponent.container.querySelector(
    ".sync-state.selected"
  );
  Assert.ok(
    selectedPanel.classList.contains("tabs-container"),
    "tabs panel is selected"
  );
});

add_task(testClean);

add_task(async function testSyncedTabsSidebarContextMenu() {
  await SidebarController.show("viewTabsSidebar");
  let syncedTabsDeckComponent =
    window.SidebarController.browser.contentWindow.syncedTabsDeckComponent;

  Assert.ok(syncedTabsDeckComponent, "component exists");

  setupSyncedTabsStubs();

  await syncedTabsDeckComponent.updatePanel();
  // This is a hacky way of waiting for the view to render. The view renders
  // after the following promise (a different instance of which is triggered
  // in updatePanel) resolves, so we wait for it here as well
  await syncedTabsDeckComponent.tabListComponent._store.getData();

  info("Right-clicking the search box should show text-related actions");
  let filterMenuItems = [
    "menuitem[cmd=cmd_undo]",
    "menuseparator",
    // We don't check whether the commands are enabled due to platform
    // differences. On OS X and Windows, "cut" and "copy" are always enabled
    // for HTML inputs; on Linux, they're only enabled if text is selected.
    "menuitem[cmd=cmd_cut]",
    "menuitem[cmd=cmd_copy]",
    "menuitem[cmd=cmd_paste]",
    "menuitem[cmd=cmd_delete]",
    "menuseparator",
    "menuitem[cmd=cmd_selectAll]",
    "menuseparator",
    "menuitem#syncedTabsRefreshFilter",
  ];
  await testContextMenu(
    syncedTabsDeckComponent,
    "#SyncedTabsSidebarTabsFilterContext",
    ".tabsFilter",
    filterMenuItems
  );

  info("Right-clicking a tab should show additional actions");
  let tabMenuItems = [
    ["menuitem#syncedTabsOpenSelected", { hidden: false }],
    ["menuitem#syncedTabsOpenSelectedInTab", { hidden: false }],
    [
      "menu#syncedTabsOpenSelectedInContainerTab",
      {
        hidden:
          !Services.prefs.getBoolPref("privacy.userContext.enabled", false) ||
          PrivateBrowsingUtils.isWindowPrivate(window),
      },
    ],
    ["menuitem#syncedTabsOpenSelectedInWindow", { hidden: false }],
    ["menuitem#syncedTabsOpenSelectedInPrivateWindow", { hidden: false }],
    ["menuseparator", { hidden: false }],
    ["menuitem#syncedTabsBookmarkSelected", { hidden: false }],
    ["menuitem#syncedTabsCopySelected", { hidden: false }],
    ["menuseparator", { hidden: false }],
    ["menuitem#syncedTabsOpenAllInTabs", { hidden: true }],
    ["menuitem#syncedTabsManageDevices", { hidden: true }],
    ["menuitem#syncedTabsRefresh", { hidden: false }],
  ];
  await testContextMenu(
    syncedTabsDeckComponent,
    "#SyncedTabsSidebarContext",
    "#tab-7cqCr77ptzX3-0",
    tabMenuItems
  );

  info(
    "Right-clicking a client should show the Open All in Tabs and Manage devices actions"
  );
  let sidebarMenuItems = [
    ["menuitem#syncedTabsOpenSelected", { hidden: true }],
    ["menuitem#syncedTabsOpenSelectedInTab", { hidden: true }],
    ["menu#syncedTabsOpenSelectedInContainerTab", { hidden: true }],
    ["menuitem#syncedTabsOpenSelectedInWindow", { hidden: true }],
    ["menuitem#syncedTabsOpenSelectedInPrivateWindow", { hidden: true }],
    ["menuseparator", { hidden: true }],
    ["menuitem#syncedTabsBookmarkSelected", { hidden: true }],
    ["menuitem#syncedTabsCopySelected", { hidden: true }],
    ["menuseparator", { hidden: true }],
    ["menuitem#syncedTabsOpenAllInTabs", { hidden: false }],
    ["menuitem#syncedTabsManageDevices", { hidden: false }],
    ["menuitem#syncedTabsRefresh", { hidden: false }],
  ];
  await testContextMenu(
    syncedTabsDeckComponent,
    "#SyncedTabsSidebarContext",
    "#item-7cqCr77ptzX3",
    sidebarMenuItems
  );

  info(
    "Right-clicking a client without any tabs should not show the Open All in Tabs action"
  );
  let menuItems = [
    ["menuitem#syncedTabsOpenSelected", { hidden: true }],
    ["menuitem#syncedTabsOpenSelectedInTab", { hidden: true }],
    ["menu#syncedTabsOpenSelectedInContainerTab", { hidden: true }],
    ["menuitem#syncedTabsOpenSelectedInWindow", { hidden: true }],
    ["menuitem#syncedTabsOpenSelectedInPrivateWindow", { hidden: true }],
    ["menuseparator", { hidden: true }],
    ["menuitem#syncedTabsBookmarkSelected", { hidden: true }],
    ["menuitem#syncedTabsCopySelected", { hidden: true }],
    ["menuseparator", { hidden: true }],
    ["menuitem#syncedTabsOpenAllInTabs", { hidden: true }],
    ["menuitem#syncedTabsManageDevices", { hidden: false }],
    ["menuitem#syncedTabsRefresh", { hidden: false }],
  ];
  await testContextMenu(
    syncedTabsDeckComponent,
    "#SyncedTabsSidebarContext",
    "#item-OL3EJCsdb2JD",
    menuItems
  );
});

add_task(testClean);

function checkItem(node, item) {
  Assert.ok(node.classList.contains("item"), "Node should have .item class");
  if (item.client) {
    // tab items
    Assert.equal(
      node.querySelector(".item-title").textContent,
      item.title,
      "Node's title element's text should match item title"
    );
    Assert.ok(node.classList.contains("tab"), "Node should have .tab class");
    Assert.equal(
      node.dataset.url,
      item.url,
      "Node's URL should match item URL"
    );
    Assert.equal(
      node.getAttribute("title"),
      item.title + "\n" + item.url,
      "Tab node should have correct title attribute"
    );
  } else {
    // client items
    Assert.equal(
      node.querySelector(".item-title").textContent,
      item.name,
      "Node's title element's text should match client name"
    );
    Assert.ok(
      node.classList.contains("client"),
      "Node should have .client class"
    );
    Assert.equal(node.dataset.id, item.id, "Node's ID should match item ID");
  }
}

async function testContextMenu(
  syncedTabsDeckComponent,
  contextSelector,
  triggerSelector,
  menuSelectors
) {
  let contextMenu = document.querySelector(contextSelector);
  let triggerElement =
    syncedTabsDeckComponent._window.document.querySelector(triggerSelector);
  let isClosed = triggerElement.classList.contains("closed");

  let promisePopupShown = BrowserTestUtils.waitForEvent(
    contextMenu,
    "popupshown"
  );

  let chromeWindow = triggerElement.ownerGlobal.top;
  let rect = triggerElement.getBoundingClientRect();
  let contentRect =
    chromeWindow.SidebarController.browser.getBoundingClientRect();
  // The offsets in `rect` are relative to the content window, but
  // `synthesizeMouseAtPoint` calls `nsIDOMWindowUtils.sendMouseEvent`,
  // which interprets the offsets relative to the containing *chrome* window.
  // This means we need to account for the width and height of any elements
  // outside the `browser` element, like `#sidebar-header`.
  let offsetX = contentRect.x + rect.x + rect.width / 2;
  let offsetY = contentRect.y + rect.y + rect.height / 4;

  await EventUtils.synthesizeMouseAtPoint(
    offsetX,
    offsetY,
    {
      type: "contextmenu",
      button: 2,
    },
    chromeWindow
  );
  await promisePopupShown;
  is(
    triggerElement.classList.contains("closed"),
    isClosed,
    "Showing the context menu shouldn't toggle the tab list"
  );
  let menuitemClicked = await checkChildren(contextMenu, menuSelectors);
  if (!menuitemClicked) {
    let promisePopupHidden = BrowserTestUtils.waitForEvent(
      contextMenu,
      "popuphidden"
    );
    contextMenu.hidePopup();
    await promisePopupHidden;
  }
}

function checkChildren(node, selectors) {
  is(node.children.length, selectors.length, "Menu item count doesn't match");
  let containerMenuShown;
  for (let index = 0; index < node.children.length; index++) {
    let child = node.children[index];
    let [selector, props] = [].concat(selectors[index]);
    ok(selector, `Node at ${index} should have selector`);
    ok(child.matches(selector), `Node ${index} should match ${selector}`);
    if (props) {
      Object.keys(props).forEach(prop => {
        is(child[prop], props[prop], `${prop} value at ${index} should match`);
      });
    }
    if (
      selector === "menu#syncedTabsOpenSelectedInContainerTab" &&
      !props.hidden
    ) {
      containerMenuShown = child;
    }
  }
  if (containerMenuShown) {
    return testContainerMenu(containerMenuShown);
  }
  return false;
}

async function testContainerMenu(menu) {
  await SpecialPowers.pushPrefEnv({
    set: [["privacy.userContext.enabled", true]],
  });
  let menupopup = menu.getElementsByTagName("menupopup")[0];
  let menupopupShown = BrowserTestUtils.waitForEvent(menupopup, "popupshown");
  menu.openMenu(true);
  await menupopupShown;
  let shown = [1, 2, 3, 4];
  let hidden = [0];
  for (let id of shown) {
    ok(
      menupopup.querySelector(`menuitem[data-usercontextid="${id}"]`),
      `User context id ${id} should exist`
    );
  }
  for (let id of hidden) {
    ok(
      !menupopup.querySelector(`menuitem[data-usercontextid="${id}"]`),
      `User context id ${id} shouldn't exist`
    );
  }
  const newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser);
  menupopup.activateItem(
    menupopup.querySelector(
      `menuitem[data-usercontextid="${shown[shown.length - 1]}"]`
    )
  );
  let newTab = await newTabPromise;
  ok(
    newTab.hasAttribute("usercontextid"),
    `Tab with usercontextid = ${shown[shown.length - 1]} should be opened`
  );
  registerCleanupFunction(() => BrowserTestUtils.removeTab(newTab));
  return true;
}
