/* Any copyright is dedicated to the Public Domain.
   http://creativecommons.org/publicdomain/zero/1.0/ */

const {
  getFirefoxViewURL,
  switchToWindow,
  withFirefoxView,
  assertFirefoxViewTab,
  assertFirefoxViewTabSelected,
  openFirefoxViewTab,
  closeFirefoxViewTab,
  isFirefoxViewTabSelectedInWindow,
  init: FirefoxViewTestUtilsInit,
} = ChromeUtils.importESModule(
  "resource://testing-common/FirefoxViewTestUtils.sys.mjs"
);

/* exported testVisibility */

const { ASRouter } = ChromeUtils.importESModule(
  "resource:///modules/asrouter/ASRouter.sys.mjs"
);
const { UIState } = ChromeUtils.importESModule(
  "resource://services-sync/UIState.sys.mjs"
);
const { sinon } = ChromeUtils.importESModule(
  "resource://testing-common/Sinon.sys.mjs"
);
const { FeatureCalloutMessages } = ChromeUtils.importESModule(
  "resource:///modules/asrouter/FeatureCalloutMessages.sys.mjs"
);
const { TelemetryTestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/TelemetryTestUtils.sys.mjs"
);
const { NonPrivateTabs } = ChromeUtils.importESModule(
  "resource:///modules/OpenTabs.sys.mjs"
);
// shut down the open tabs module after each test so we don't get debounced events bleeding into the next
registerCleanupFunction(() => NonPrivateTabs.stop());

const triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL;
const { SessionStoreTestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/SessionStoreTestUtils.sys.mjs"
);
SessionStoreTestUtils.init(this, window);
FirefoxViewTestUtilsInit(this, window);

ChromeUtils.defineESModuleGetters(this, {
  AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.sys.mjs",
  BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
  SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs",
  TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs",
});

/**
 * URLs used for browser_recently_closed_tabs_keyboard and
 * browser_firefoxview_accessibility
 */
const URLs = [
  "http://mochi.test:8888/browser/",
  "https://www.example.com/",
  "https://example.net/",
  "https://example.org/",
  "about:robots",
  "https://www.mozilla.org/",
];

const syncedTabsData1 = [
  {
    id: 1,
    type: "client",
    name: "My desktop",
    clientType: "desktop",
    lastModified: 1655730486760,
    tabs: [
      {
        type: "tab",
        title: "Sandboxes - Sinon.JS",
        url: "https://sinonjs.org/releases/latest/sandbox/",
        icon: "https://sinonjs.org/assets/images/favicon.png",
        lastUsed: 1655391592, // Thu Jun 16 2022 14:59:52 GMT+0000
        client: 1,
      },
      {
        type: "tab",
        title: "Internet for people, not profits - Mozilla",
        url: "https://www.mozilla.org/",
        icon: "https://www.mozilla.org/media/img/favicons/mozilla/favicon.d25d81d39065.ico",
        lastUsed: 1655730486, // Mon Jun 20 2022 13:08:06 GMT+0000
        client: 1,
      },
    ],
  },
  {
    id: 2,
    type: "client",
    name: "My iphone",
    clientType: "phone",
    lastModified: 1655727832930,
    tabs: [
      {
        type: "tab",
        title: "The Guardian",
        url: "https://www.theguardian.com/",
        icon: "page-icon:https://www.theguardian.com/",
        lastUsed: 1655291890, // Wed Jun 15 2022 11:18:10 GMT+0000
        client: 2,
      },
      {
        type: "tab",
        title: "The Times",
        url: "https://www.thetimes.co.uk/",
        icon: "page-icon:https://www.thetimes.co.uk/",
        lastUsed: 1655727485, // Mon Jun 20 2022 12:18:05 GMT+0000
        client: 2,
      },
    ],
  },
];

async function clearAllParentTelemetryEvents() {
  // Clear everything.
  await TestUtils.waitForCondition(() => {
    Services.telemetry.clearEvents();
    let events = Services.telemetry.snapshotEvents(
      Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
      true
    ).parent;
    return !events || !events.length;
  });
}

function testVisibility(browser, expected) {
  const { document } = browser.contentWindow;
  for (let [selector, shouldBeVisible] of Object.entries(
    expected.expectedVisible
  )) {
    const elem = document.querySelector(selector);
    if (shouldBeVisible) {
      ok(
        BrowserTestUtils.isVisible(elem),
        `Expected ${selector} to be visible`
      );
    } else {
      ok(BrowserTestUtils.isHidden(elem), `Expected ${selector} to be hidden`);
    }
  }
}

async function waitForElementVisible(browser, selector, isVisible = true) {
  const { document } = browser.contentWindow;
  const elem = document.querySelector(selector);
  if (!isVisible && !elem) {
    return;
  }
  ok(elem, `Got element with selector: ${selector}`);

  await BrowserTestUtils.waitForMutationCondition(
    elem,
    {
      attributeFilter: ["hidden"],
    },
    () => {
      return isVisible
        ? BrowserTestUtils.isVisible(elem)
        : BrowserTestUtils.isHidden(elem);
    }
  );
}

async function waitForVisibleSetupStep(browser, expected) {
  const { document } = browser.contentWindow;

  const deck = document.querySelector(".sync-setup-container");
  const nextStepElem = deck.querySelector(expected.expectedVisible);
  const stepElems = deck.querySelectorAll(".setup-step");

  await BrowserTestUtils.waitForMutationCondition(
    deck,
    {
      attributeFilter: ["selected-view"],
    },
    () => {
      return BrowserTestUtils.isVisible(nextStepElem);
    }
  );

  for (let elem of stepElems) {
    if (elem == nextStepElem) {
      ok(
        BrowserTestUtils.isVisible(elem),
        `Expected ${elem.id || elem.className} to be visible`
      );
    } else {
      ok(
        BrowserTestUtils.isHidden(elem),
        `Expected ${elem.id || elem.className} to be hidden`
      );
    }
  }
}

var gMockFxaDevices = null;
var gUIStateStatus;
var gSandbox;
function setupSyncFxAMocks({ fxaDevices = null, state, syncEnabled = true }) {
  gUIStateStatus = state || UIState.STATUS_SIGNED_IN;
  if (gSandbox) {
    gSandbox.restore();
  }
  const sandbox = (gSandbox = sinon.createSandbox());
  gMockFxaDevices = fxaDevices;
  sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => fxaDevices);
  sandbox.stub(UIState, "get").callsFake(() => {
    return {
      status: gUIStateStatus,
      syncEnabled,
      email:
        gUIStateStatus === UIState.STATUS_NOT_CONFIGURED
          ? undefined
          : "email@example.com",
    };
  });

  return sandbox;
}

function setupRecentDeviceListMocks() {
  const sandbox = sinon.createSandbox();
  sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => [
    {
      id: 1,
      name: "My desktop",
      isCurrentDevice: true,
      type: "desktop",
      availableCommands: {},
    },
    {
      id: 2,
      name: "My iphone",
      type: "mobile",
      availableCommands: {},
    },
  ]);

  sandbox.stub(UIState, "get").returns({
    status: UIState.STATUS_SIGNED_IN,
    syncEnabled: true,
    email: "email@example.com",
  });

  // whatever was passed in was the "found" client
  sandbox
    .stub(SyncedTabs._internal, "_getClientFxaDeviceId")
    .callsFake(clientId => clientId);

  return sandbox;
}

function getMockTabData(clients) {
  return SyncedTabs._internal._createRecentTabsList(clients, 10);
}

async function setupListState(browser) {
  // Skip the synced tabs sign up flow to get to a loaded list state
  await SpecialPowers.pushPrefEnv({
    set: [["services.sync.engine.tabs", true]],
  });

  UIState.refresh();
  const recentFetchTime = Math.floor(Date.now() / 1000);
  info("updating lastFetch:" + recentFetchTime);
  Services.prefs.setIntPref("services.sync.lastTabFetch", recentFetchTime);

  Services.obs.notifyObservers(null, UIState.ON_UPDATE);

  await waitForElementVisible(browser, "#tabpickup-steps", false);
  await waitForElementVisible(browser, "#tabpickup-tabs-container", true);

  const tabsContainer = browser.contentWindow.document.querySelector(
    "#tabpickup-tabs-container"
  );
  await tabsContainer.tabListAdded;
  await BrowserTestUtils.waitForMutationCondition(
    tabsContainer,
    { attributeFilter: ["class"], attributes: true },
    () => {
      return !tabsContainer.classList.contains("loading");
    }
  );
  info("tabsContainer isn't loading anymore, returning");
}

async function touchLastTabFetch() {
  // lastTabFetch stores a timestamp in *seconds*.
  const nowSeconds = Math.floor(Date.now() / 1000);
  info("updating lastFetch:" + nowSeconds);
  Services.prefs.setIntPref("services.sync.lastTabFetch", nowSeconds);
  // wait so all pref observers can complete
  await TestUtils.waitForTick();
}

let gUIStateSyncEnabled;
function setupMocks({ fxaDevices = null, state, syncEnabled = true }) {
  gUIStateStatus = state || UIState.STATUS_SIGNED_IN;
  gUIStateSyncEnabled = syncEnabled;
  if (gSandbox) {
    gSandbox.restore();
  }
  const sandbox = (gSandbox = sinon.createSandbox());
  gMockFxaDevices = fxaDevices;
  sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => fxaDevices);
  sandbox.stub(UIState, "get").callsFake(() => {
    return {
      status: gUIStateStatus,
      // Sometimes syncEnabled is not present on UIState, for example when the user signs
      // out the state is just { status: "not_configured" }
      ...(gUIStateSyncEnabled != undefined && {
        syncEnabled: gUIStateSyncEnabled,
      }),
    };
  });

  // whatever was passed in was the "found" client
  sandbox
    .stub(SyncedTabs._internal, "_getClientFxaDeviceId")
    .callsFake(clientId => clientId);
  // This is converting the device list to a client list.
  // There are two primary differences:
  // 1. The client list doesn't return the current device.
  // 2. It uses clientType instead of type.
  let tabClients = fxaDevices ? [...fxaDevices] : [];
  for (let client of tabClients) {
    client.clientType = client.type;
  }
  tabClients = tabClients.filter(device => !device.isCurrentDevice);
  sandbox.stub(SyncedTabs, "getTabClients").callsFake(() => {
    return Promise.resolve(tabClients);
  });
  return sandbox;
}

async function tearDown(sandbox) {
  sandbox?.restore();
  Services.prefs.clearUserPref("services.sync.lastTabFetch");
}

/**
 * Helper function to open and close a tab so the recently
 * closed tabs list can have data.
 *
 * @param {string} url
 * @returns {Promise} Promise that resolves when the session store
 * has been updated after closing the tab.
 */
async function open_then_close(url, win = window) {
  return SessionStoreTestUtils.openAndCloseTab(win, url);
}

/**
 * Clears session history. Used to clear out the recently closed tabs list.
 *
 */
function clearHistory() {
  Services.obs.notifyObservers(null, "browser:purge-session-history");
}

/**
 * Cleanup function for tab pickup tests.
 *
 */
function cleanup_tab_pickup() {
  Services.prefs.clearUserPref("services.sync.engine.tabs");
  Services.prefs.clearUserPref("services.sync.lastTabFetch");
}

function isFirefoxViewTabSelected(win = window) {
  return isFirefoxViewTabSelectedInWindow(win);
}

function promiseAllButPrimaryWindowClosed() {
  let windows = [];
  for (let win of BrowserWindowTracker.orderedWindows) {
    if (win != window) {
      windows.push(win);
    }
  }
  return Promise.all(windows.map(BrowserTestUtils.closeWindow));
}

registerCleanupFunction(() => {
  // ensure all the stubs are restored, regardless of any exceptions
  // that might have prevented it
  gSandbox?.restore();
});

async function navigateToViewAndWait(document, view) {
  info(`navigateToViewAndWait, for ${view}`);
  const navigation = document.querySelector("moz-page-nav");
  const win = document.ownerGlobal;
  SimpleTest.promiseFocus(win);
  let navButton = Array.from(navigation.pageNavButtons).find(pageNavButton => {
    return pageNavButton.view === view;
  });
  const namedDeck = document.querySelector("named-deck");

  await BrowserTestUtils.waitForCondition(
    () => navButton.getBoundingClientRect().height,
    `Waiting for ${view} button to be clickable`
  );

  EventUtils.synthesizeMouseAtCenter(navButton, {}, win);

  await BrowserTestUtils.waitForCondition(() => {
    let selectedView = Array.from(namedDeck.children).find(
      child => child.slot == "selected"
    );
    return (
      namedDeck.selectedViewName == view &&
      selectedView?.getBoundingClientRect().height
    );
  }, `Waiting for ${view} to be visible`);
}

/**
 * Switch to the Firefox View tab.
 *
 * @param {Window} [win]
 *   The window to use, if specified. Defaults to the global window instance.
 * @returns {Promise<MozTabbrowserTab>}
 *   The tab switched to.
 */
async function switchToFxViewTab(win = window) {
  await switchToWindow(win);
  return BrowserTestUtils.switchTab(win.gBrowser, win.FirefoxViewHandler.tab);
}

function isElInViewport(element) {
  const boundingRect = element.getBoundingClientRect();
  return (
    boundingRect.top >= 0 &&
    boundingRect.left >= 0 &&
    boundingRect.bottom <=
      (window.innerHeight || document.documentElement.clientHeight) &&
    boundingRect.right <=
      (window.innerWidth || document.documentElement.clientWidth)
  );
}

// TODO once we port over old tests, helpers and cleanup old firefox view
// we should decide whether to keep this or openFirefoxViewTab.
async function clickFirefoxViewButton(win) {
  await BrowserTestUtils.synthesizeMouseAtCenter(
    "#firefox-view-button",
    { type: "mousedown" },
    win.browsingContext
  );
}

/**
 * Wait for and assert telemetry events.
 *
 * @param {Array} eventDetails
 *   Nested array of event details
 */
async function telemetryEvent(eventDetails) {
  await TestUtils.waitForCondition(
    () => {
      let events = Services.telemetry.snapshotEvents(
        Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
        false
      ).parent;
      return events && events.length >= 1;
    },
    "Waiting for firefoxview_next telemetry event.",
    200,
    100
  );

  TelemetryTestUtils.assertEvents(
    eventDetails,
    { category: "firefoxview_next" },
    { clear: true, process: "parent" }
  );
}

function setSortOption(component, value) {
  info(`Sort by ${value}.`);
  const el = component.optionsContainer.querySelector(
    `input[value='${value}']`
  );
  EventUtils.synthesizeMouseAtCenter(el, {}, el.ownerGlobal);
}

/**
 * Select the Open Tabs view-page in the Firefox View tab.
 */
async function navigateToOpenTabs(browser) {
  const document = browser.contentDocument;
  if (document.querySelector("named-deck").selectedViewName != "opentabs") {
    await navigateToViewAndWait(browser.contentDocument, "opentabs");
  }
}

function getOpenTabsCards(openTabs) {
  return openTabs.shadowRoot.querySelectorAll("view-opentabs-card");
}

function getOpenTabsComponent(browser) {
  return browser.contentDocument.querySelector("named-deck > view-opentabs");
}

async function getTabRowsForCard(card) {
  await TestUtils.waitForCondition(
    () => card.tabList.rowEls.length,
    "Wait for the card's tab list to have rows"
  );
  return card.tabList.rowEls;
}

async function click_recently_closed_tab_item(itemElem, itemProperty = "") {
  // Make sure the firefoxview tab still has focus
  is(
    itemElem.ownerDocument.location.href,
    "about:firefoxview#recentlyclosed",
    "about:firefoxview is the selected tab and showing the Recently closed view page"
  );

  // Scroll to the tab element to ensure dismiss button is visible
  itemElem.scrollIntoView();
  is(isElInViewport(itemElem), true, "Tab is visible in viewport");
  let clickTarget;
  switch (itemProperty) {
    case "dismiss":
      clickTarget = itemElem.secondaryButtonEl;
      break;
    default:
      clickTarget = itemElem.mainEl;
      break;
  }

  const closedObjectsChangePromise = TestUtils.topicObserved(
    "sessionstore-closed-objects-changed"
  );
  EventUtils.synthesizeMouseAtCenter(clickTarget, {}, itemElem.ownerGlobal);
  await closedObjectsChangePromise;
}

async function waitForRecentlyClosedTabsList(doc) {
  let recentlyClosedComponent = doc.querySelector(
    "view-recentlyclosed:not([slot=recentlyclosed])"
  );
  // Check that the tabs list is rendered
  await TestUtils.waitForCondition(() => {
    return recentlyClosedComponent.cardEl;
  });
  let cardContainer = recentlyClosedComponent.cardEl;
  let cardMainSlotNode = Array.from(
    cardContainer?.mainSlot?.assignedNodes()
  )[0];
  await TestUtils.waitForCondition(() => {
    return cardMainSlotNode.rowEls.length;
  });
  return [cardMainSlotNode, cardMainSlotNode.rowEls];
}

async function add_new_tab(URL) {
  let tabChangeRaised = BrowserTestUtils.waitForEvent(
    NonPrivateTabs,
    "TabChange"
  );
  let tab = BrowserTestUtils.addTab(gBrowser, URL);
  // wait so we can reliably compare the tab URL
  await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
  await tabChangeRaised;
  return tab;
}

function isActiveElement(expectedLinkEl) {
  return expectedLinkEl.getRootNode().activeElement == expectedLinkEl;
}

function cleanupTabs() {
  while (gBrowser.tabs.length > 1) {
    BrowserTestUtils.removeTab(gBrowser.tabs[0]);
  }
}
