/* eslint max-len: ["error", 80] */
"use strict";

loadTestSubscript("head_disco.js");

// The response to the discovery API, as documented at:
// https://addons-server.readthedocs.io/en/latest/topics/api/discovery.html
//
// The test is designed to easily verify whether the discopane works with the
// latest AMO API, by replacing API_RESPONSE_FILE's content with latest AMO API
// response, e.g. from https://addons.allizom.org/api/v4/discovery/?lang=en-US
// The response must contain at least one theme, and one extension.

const API_RESPONSE_FILE = PathUtils.join(
  Services.dirsvc.get("CurWorkD", Ci.nsIFile).path,
  // Trim empty component from splitting with trailing slash.
  ...RELATIVE_DIR.split("/").filter(c => c.length),
  "discovery",
  "api_response.json"
);

const AMO_TEST_HOST = "rewritten-for-testing.addons.allizom.org";

const ArrayBufferInputStream = Components.Constructor(
  "@mozilla.org/io/arraybuffer-input-stream;1",
  "nsIArrayBufferInputStream",
  "setData"
);

const amoServer = AddonTestUtils.createHttpServer({ hosts: [AMO_TEST_HOST] });

amoServer.registerFile(
  "/png",
  new FileUtils.File(
    PathUtils.join(
      Services.dirsvc.get("CurWorkD", Ci.nsIFile).path,
      ...`${RELATIVE_DIR}discovery/small-1x1.png`.split("/")
    )
  )
);
amoServer.registerPathHandler("/dummy", (request, response) => {
  response.write("Dummy");
});

// `result` is an element in the `results` array from AMO's discovery API,
// stored in API_RESPONSE_FILE.
function getTestExpectationFromApiResult(result) {
  return {
    typeIsTheme: result.addon.type === "statictheme",
    addonName: result.addon.name,
    authorName: result.addon.authors[0].name,
    editorialBody: result.description_text,
    dailyUsers: result.addon.average_daily_users,
    rating: result.addon.ratings.average,
  };
}

// A helper to declare a response to discovery API requests.
class DiscoveryAPIHandler {
  constructor(responseText) {
    this.setResponseText(responseText);
    this.requestCount = 0;

    // Overwrite the previous discovery response handler.
    amoServer.registerPathHandler("/discoapi", this);
  }

  setResponseText(responseText) {
    this.responseBody = new TextEncoder().encode(responseText).buffer;
  }

  // Suspend discovery API requests until unblockResponses is called.
  blockNextResponses() {
    this._unblockPromise = new Promise(resolve => {
      this.unblockResponses = resolve;
    });
  }

  unblockResponses() {
    throw new Error("You need to call blockNextResponses first!");
  }

  // nsIHttpRequestHandler::handle
  async handle(request, response) {
    ++this.requestCount;

    response.setHeader("Cache-Control", "no-cache", false);
    response.processAsync();
    await this._unblockPromise;

    let body = this.responseBody;
    let binStream = new ArrayBufferInputStream(body, 0, body.byteLength);
    response.bodyOutputStream.writeFrom(binStream, body.byteLength);
    response.finish();
  }
}

// Retrieve the list of visible action elements inside a document or container.
function getVisibleActions(documentOrElement) {
  return Array.from(documentOrElement.querySelectorAll("[action]")).filter(
    elem =>
      elem.getAttribute("action") !== "page-options" &&
      elem.offsetWidth &&
      elem.offsetHeight
  );
}

function getActionName(actionElement) {
  return actionElement.getAttribute("action");
}

function getCardByAddonId(win, addonId) {
  for (let card of win.document.querySelectorAll("recommended-addon-card")) {
    if (card.addonId === addonId) {
      return card;
    }
  }
  return null;
}

// Switch to a different view so we can switch back to the discopane later.
async function switchToNonDiscoView(win) {
  // Listeners registered while the discopane was the active view continue to be
  // active when the view switches to the extensions list, because both views
  // share the same document.
  win.gViewController.loadView("addons://list/extension");
  await wait_for_view_load(win);
  ok(
    win.document.querySelector("addon-list"),
    "Should be at the extension list view"
  );
}

// Switch to the discopane and wait until it has fully rendered, including any
// cards from the discovery API.
async function switchToDiscoView(win) {
  is(
    getDiscoveryElement(win),
    null,
    "Cannot switch to discopane when the discopane is already shown"
  );
  win.gViewController.loadView("addons://discover/");
  await wait_for_view_load(win);
  await promiseDiscopaneUpdate(win);
}

// Wait until all images in the DOM have successfully loaded.
// There must be at least one `<img>` in the document.
// Returns the number of loaded images.
async function waitForAllImagesLoaded(win) {
  let imgs = Array.from(
    win.document.querySelectorAll("discovery-pane img[src]")
  );
  function areAllImagesLoaded() {
    let loadCount = imgs.filter(img => img.naturalWidth).length;
    info(`Loaded ${loadCount} out of ${imgs.length} images`);
    return loadCount === imgs.length;
  }
  if (!areAllImagesLoaded()) {
    await promiseEvent(win.document, "load", true, areAllImagesLoaded);
  }
  return imgs.length;
}

// Install an add-on by clicking on the card.
// The promise resolves once the card has been updated.
async function testCardInstall(card) {
  Assert.deepEqual(
    getVisibleActions(card).map(getActionName),
    ["install-addon"],
    "Should have an Install button before install"
  );

  let installButton =
    card.querySelector("[data-l10n-id='install-extension-button']") ||
    card.querySelector("[data-l10n-id='install-theme-button']");

  let updatePromise = promiseEvent(card, "disco-card-updated");
  installButton.click();
  await updatePromise;

  Assert.deepEqual(
    getVisibleActions(card).map(getActionName),
    ["manage-addon"],
    "Should have a Manage button after install"
  );
}

// Uninstall the add-on (not via the card, since it has no uninstall button).
// The promise resolves once the card has been updated.
async function testAddonUninstall(card) {
  Assert.deepEqual(
    getVisibleActions(card).map(getActionName),
    ["manage-addon"],
    "Should have a Manage button before uninstall"
  );

  let addon = await AddonManager.getAddonByID(card.addonId);

  let updatePromise = promiseEvent(card, "disco-card-updated");
  await addon.uninstall();
  await updatePromise;

  Assert.deepEqual(
    getVisibleActions(card).map(getActionName),
    ["install-addon"],
    "Should have an Install button after uninstall"
  );
}

add_setup(async function () {
  await SpecialPowers.pushPrefEnv({
    set: [
      [
        "extensions.getAddons.discovery.api_url",
        `http://${AMO_TEST_HOST}/discoapi`,
      ],
      // Disable non-discopane recommendations to avoid unexpected discovery
      // API requests.
      ["extensions.htmlaboutaddons.recommendations.enabled", false],
      // Disable the telemetry client ID (and its associated UI warning).
      // browser_html_discover_view_clientid.js covers this functionality.
      ["browser.discovery.enabled", false],
      // Disable mixed-content upgrading as this test is expecting an HTTP load
      ["security.mixed_content.upgrade_display_content", false],
    ],
  });
});

// Test that the discopane can be loaded and that meaningful results are shown.
// This relies on response data from the AMO API, stored in API_RESPONSE_FILE.
add_task(async function discopane_with_real_api_data() {
  const apiText = await readAPIResponseFixture(
    AMO_TEST_HOST,
    API_RESPONSE_FILE
  );
  let apiHandler = new DiscoveryAPIHandler(apiText);

  const apiResultArray = JSON.parse(apiText).results;
  ok(apiResultArray.length, `Mock has ${apiResultArray.length} results`);

  apiHandler.blockNextResponses();
  let win = await loadInitialView("discover");

  Assert.deepEqual(
    getVisibleActions(win.document).map(getActionName),
    [],
    "The AMO button should be invisible when the AMO API hasn't responded"
  );

  apiHandler.unblockResponses();
  await promiseDiscopaneUpdate(win);

  let actionElements = getVisibleActions(win.document);
  Assert.deepEqual(
    actionElements.map(getActionName),
    [
      // Expecting an install button for every result.
      ...new Array(apiResultArray.length).fill("install-addon"),
      "open-amo",
    ],
    "All add-on cards should be rendered, with AMO button at the end."
  );

  let imgCount = await waitForAllImagesLoaded(win);
  is(imgCount, apiResultArray.length, "Expected an image for every result");

  // Check that the cards have the expected content.
  let cards = Array.from(
    win.document.querySelectorAll("recommended-addon-card")
  );
  is(cards.length, apiResultArray.length, "Every API result has a card");
  for (let [i, card] of cards.entries()) {
    let expectations = getTestExpectationFromApiResult(apiResultArray[i]);
    info(`Expectations for card ${i}: ${JSON.stringify(expectations)}`);

    let checkContent = (selector, expectation) => {
      let text = card.querySelector(selector).textContent;
      is(text, expectation, `Content of selector "${selector}"`);
    };
    checkContent(".disco-addon-name", expectations.addonName);
    await win.document.l10n.translateFragment(card);
    checkContent(
      ".disco-addon-author [data-l10n-name='author']",
      expectations.authorName
    );

    let amoListingLink = card.querySelector(".disco-addon-author a");
    ok(
      amoListingLink.search.includes("utm_source=firefox-browser"),
      `Listing link should have attribution parameter, url=${amoListingLink}`
    );

    let actions = getVisibleActions(card);
    is(actions.length, 1, "Card should only have one install button");
    let installButton = actions[0];
    if (expectations.typeIsTheme) {
      // Theme button + screenshot
      ok(
        installButton.matches("[data-l10n-id='install-theme-button'"),
        "Has theme install button"
      );
      ok(
        card.querySelector(".card-heading-image").offsetWidth,
        "Preview image must be visible"
      );
    } else {
      // Extension button + extended description.
      ok(
        installButton.matches("[data-l10n-id='install-extension-button'"),
        "Has extension install button"
      );
      checkContent(".disco-description-main", expectations.editorialBody);

      let mozFiveStar = card.querySelector("moz-five-star");
      if (expectations.rating) {
        is(mozFiveStar.rating, expectations.rating, "Expected rating value");
        ok(mozFiveStar.offsetWidth, "Rating element is visible");
      } else {
        is(mozFiveStar.offsetWidth, 0, "Rating element is not visible");
      }

      let userCountElem = card.querySelector(".disco-user-count");
      if (expectations.dailyUsers) {
        Assert.deepEqual(
          win.document.l10n.getAttributes(userCountElem),
          { id: "user-count", args: { dailyUsers: expectations.dailyUsers } },
          "Card count should be rendered"
        );
      } else {
        is(userCountElem.offsetWidth, 0, "User count element is not visible");
      }
    }
  }

  is(apiHandler.requestCount, 1, "Discovery API should be fetched once");

  await closeView(win);
});

// Test whether extensions and themes can be installed from the discopane.
// Also checks that items in the list do not change position after installation,
// and that they are shown at the bottom of the list when the discopane is
// reopened.
add_task(async function install_from_discopane() {
  const apiText = await readAPIResponseFixture(
    AMO_TEST_HOST,
    API_RESPONSE_FILE
  );
  const apiResultArray = JSON.parse(apiText).results;
  let getAddonIdByAMOAddonType = type =>
    apiResultArray.find(r => r.addon.type === type).addon.guid;
  const FIRST_EXTENSION_ID = getAddonIdByAMOAddonType("extension");
  const FIRST_THEME_ID = getAddonIdByAMOAddonType("statictheme");

  let apiHandler = new DiscoveryAPIHandler(apiText);

  let win = await loadInitialView("discover");
  await promiseDiscopaneUpdate(win);
  await waitForAllImagesLoaded(win);

  // Test extension install.
  let installExtensionPromise = promiseAddonInstall(amoServer, {
    manifest: {
      name: "My Awesome Add-on",
      description: "Test extension install button",
      browser_specific_settings: { gecko: { id: FIRST_EXTENSION_ID } },
      permissions: ["<all_urls>"],
    },
  });
  await testCardInstall(getCardByAddonId(win, FIRST_EXTENSION_ID));
  await installExtensionPromise;

  // Test theme install.
  let installThemePromise = promiseAddonInstall(amoServer, {
    manifest: {
      name: "My Fancy Theme",
      description: "Test theme install button",
      browser_specific_settings: { gecko: { id: FIRST_THEME_ID } },
      theme: {
        colors: {
          tab_selected: "red",
        },
      },
    },
  });
  let promiseThemeChange = promiseObserved("lightweight-theme-styling-update");
  await testCardInstall(getCardByAddonId(win, FIRST_THEME_ID));
  await installThemePromise;
  await promiseThemeChange;

  // After installing, the cards should have manage buttons instead of install
  // buttons. The cards should still be at the top of the pane (and not be
  // moved to the bottom).
  Assert.deepEqual(
    getVisibleActions(win.document).map(getActionName),
    [
      "manage-addon",
      "manage-addon",
      ...new Array(apiResultArray.length - 2).fill("install-addon"),
      "open-amo",
    ],
    "The Install buttons should be replaced with Manage buttons"
  );

  // End of the testing installation from a card.

  // Click on the Manage button to verify that it does something useful,
  // and in order to be able to force the discovery pane to be rendered again.
  let loaded = waitForViewLoad(win);
  getCardByAddonId(win, FIRST_EXTENSION_ID)
    .querySelector("[action='manage-addon']")
    .click();
  await loaded;
  {
    let addonCard = win.document.querySelector(
      `addon-card[addon-id="${FIRST_EXTENSION_ID}"]`
    );
    ok(addonCard, "Add-on details should be shown");
    ok(addonCard.expanded, "The card should have been expanded");
    // TODO bug 1540253: Check that the "recommended" badge is visible.
  }

  // Now we are going to force an updated rendering and check that the cards are
  // in the expected order, and then test uninstallation of the above add-ons.
  await switchToDiscoView(win);
  await waitForAllImagesLoaded(win);

  Assert.deepEqual(
    getVisibleActions(win.document).map(getActionName),
    [
      ...new Array(apiResultArray.length - 2).fill("install-addon"),
      "manage-addon",
      "manage-addon",
      "open-amo",
    ],
    "Already-installed add-ons should be rendered at the end of the list"
  );

  promiseThemeChange = promiseObserved("lightweight-theme-styling-update");
  await testAddonUninstall(getCardByAddonId(win, FIRST_THEME_ID));
  await promiseThemeChange;
  await testAddonUninstall(getCardByAddonId(win, FIRST_EXTENSION_ID));

  is(apiHandler.requestCount, 1, "Discovery API should be fetched once");

  await closeView(win);
});

// Tests that the page is able to switch views while the discopane is loading,
// without inadvertently replacing the page when the request finishes.
add_task(async function discopane_navigate_while_loading() {
  let apiHandler = new DiscoveryAPIHandler(`{"results": []}`);

  apiHandler.blockNextResponses();
  let win = await loadInitialView("discover");

  let updatePromise = promiseDiscopaneUpdate(win);
  let didUpdateDiscopane = false;
  updatePromise.then(() => {
    didUpdateDiscopane = true;
  });

  // Switch views while the request is pending.
  await switchToNonDiscoView(win);

  is(
    didUpdateDiscopane,
    false,
    "discopane should still not be updated because the request is blocked"
  );
  is(
    getDiscoveryElement(win),
    null,
    "Discopane should be removed after switching to the extension list"
  );

  // Release pending requests, to verify that completing the request will not
  // cause changes to the visible view. The updatePromise will still resolve
  // though, because the event is dispatched to the removed `<discovery-pane>`.
  apiHandler.unblockResponses();

  await updatePromise;
  ok(
    win.document.querySelector("addon-list"),
    "Should still be at the extension list view"
  );
  is(
    getDiscoveryElement(win),
    null,
    "Discopane should not be in the document when it is not the active view"
  );

  is(apiHandler.requestCount, 1, "Discovery API should be fetched once");

  await closeView(win);
});

// Tests that invalid responses are handled correctly and not cached.
// Also verifies that the response is cached as long as the page is active,
// but not when the page is fully reloaded.
add_task(async function discopane_cache_api_responses() {
  const INVALID_RESPONSE_BODY = `{"This is some": invalid} JSON`;
  let apiHandler = new DiscoveryAPIHandler(INVALID_RESPONSE_BODY);

  let expectedErrMsg;
  try {
    JSON.parse(INVALID_RESPONSE_BODY);
    ok(false, "JSON.parse should have thrown");
  } catch (e) {
    expectedErrMsg = e.message;
  }

  let invalidResponseHandledPromise = new Promise(resolve => {
    Services.console.registerListener(function listener(msg) {
      if (msg.message.includes(expectedErrMsg)) {
        resolve();
        Services.console.unregisterListener(listener);
      }
    });
  });

  let win = await loadInitialView("discover"); // Request #1
  await promiseDiscopaneUpdate(win);

  info("Waiting for expected error");
  await invalidResponseHandledPromise;
  is(apiHandler.requestCount, 1, "Discovery API should be fetched once");

  Assert.deepEqual(
    getVisibleActions(win.document).map(getActionName),
    ["open-amo"],
    "The AMO button should be visible even when the response was invalid"
  );

  // Change to a valid response, so that the next response will be cached.
  apiHandler.setResponseText(`{"results": []}`);

  await switchToNonDiscoView(win);
  await switchToDiscoView(win); // Request #2

  is(
    apiHandler.requestCount,
    2,
    "Should fetch new data because an invalid response should not be cached"
  );

  await switchToNonDiscoView(win);
  await switchToDiscoView(win);
  await closeView(win);

  is(
    apiHandler.requestCount,
    2,
    "The previous response was valid and should have been reused"
  );

  // Now open a new about:addons page and verify that a new API request is sent.
  let anotherWin = await loadInitialView("discover");
  await promiseDiscopaneUpdate(anotherWin);
  await closeView(anotherWin);

  is(apiHandler.requestCount, 3, "discovery API should be requested again");
});

add_task(async function discopane_no_cookies() {
  let requestPromise = new Promise(resolve => {
    amoServer.registerPathHandler("/discoapi", resolve);
  });
  const cv = Services.cookies.add(
    AMO_TEST_HOST,
    "/",
    "name",
    "value",
    false,
    false,
    false,
    Date.now() / 1000 + 600,
    {},
    Ci.nsICookie.SAMESITE_UNSET,
    Ci.nsICookie.SCHEME_HTTP
  );
  is(cv.result, Ci.nsICookieValidation.eOK, "Valid cookie");
  let win = await loadInitialView("discover");
  let request = await requestPromise;
  ok(!request.hasHeader("Cookie"), "discovery API should not receive cookies");
  await closeView(win);
});

// The CSP of about:addons whitelists http:, but not data:, hence we are
// loading a little red data: image which gets blocked by the CSP.
add_task(async function csp_img_src() {
  const RED_DATA_IMAGE =
    "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAA" +
    "AHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==";

  // Minimal API response to get the image in recommended-addon-card to render.
  const DUMMY_EXTENSION_ID = "dummy-csp@extensionid";
  const apiResponse = {
    results: [
      {
        addon: {
          guid: DUMMY_EXTENSION_ID,
          type: "extension",
          authors: [
            {
              name: "Some CSP author",
            },
          ],
          url: `http://${AMO_TEST_HOST}/dummy`,
          icon_url: RED_DATA_IMAGE,
        },
      },
    ],
  };

  let apiHandler = new DiscoveryAPIHandler(JSON.stringify(apiResponse));
  apiHandler.blockNextResponses();
  let win = await loadInitialView("discover");

  let cspPromise = new Promise(resolve => {
    win.addEventListener("securitypolicyviolation", e => {
      // non http(s) loads only report the scheme
      is(e.blockedURI, "data", "CSP: blocked URI");
      is(e.violatedDirective, "img-src", "CSP: violated directive");
      resolve();
    });
  });

  apiHandler.unblockResponses();
  await cspPromise;

  await closeView(win);
});

add_task(async function checkDiscopaneNotice() {
  await SpecialPowers.pushPrefEnv({
    set: [
      ["browser.discovery.enabled", true],
      // Enabling the Data Upload pref may upload data.
      // Point data reporting services to localhost so the data doesn't escape.
      ["toolkit.telemetry.server", "https://localhost:1337"],
      ["telemetry.fog.test.localhost_port", -1],
      ["datareporting.healthreport.uploadEnabled", true],
      ["extensions.htmlaboutaddons.recommendations.enabled", true],
      ["extensions.recommendations.hideNotice", false],
      // Disable mixed-content upgrading as this test is expecting an HTTP load
      ["security.mixed_content.upgrade_display_content", false],
    ],
  });

  let win = await loadInitialView("extension");
  let messageBar = win.document.querySelector(
    "moz-message-bar.discopane-notice"
  );
  ok(messageBar, "Recommended notice should exist in extensions view");
  is(
    messageBar.getAttribute("role"),
    "alert",
    "Recommended notice is an alert"
  );
  await switchToDiscoView(win);
  messageBar = win.document.querySelector("moz-message-bar.discopane-notice");
  ok(messageBar, "Recommended notice should exist in disco view");

  messageBar.closeButton.click();
  messageBar = win.document.querySelector("moz-message-bar.discopane-notice");
  ok(!messageBar, "Recommended notice should not exist in disco view");
  await switchToNonDiscoView(win);
  messageBar = win.document.querySelector("moz-message-bar.discopane-notice");
  ok(!messageBar, "Recommended notice should not exist in extensions view");

  await closeView(win);
});
