"use strict";

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