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

const { AddonTestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/AddonTestUtils.sys.mjs"
);

AddonTestUtils.initMochitest(this);

const server = AddonTestUtils.createHttpServer();

const initialAutoUpdate = AddonManager.autoUpdateDefault;
registerCleanupFunction(() => {
  AddonManager.autoUpdateDefault = initialAutoUpdate;
});

add_setup(async function () {
  await SpecialPowers.pushPrefEnv({
    set: [["extensions.checkUpdateSecurity", false]],
  });

  Services.telemetry.clearEvents();
  registerCleanupFunction(() => {
    cleanupPendingNotifications();
  });
});

function loadDetailView(win, id) {
  let doc = win.document;
  let card = doc.querySelector(`addon-card[addon-id="${id}"]`);
  let loaded = waitForViewLoad(win);
  EventUtils.synthesizeMouseAtCenter(
    card.querySelector(".addon-name-link"),
    { clickCount: 1 },
    win
  );
  return loaded;
}

add_task(async function testChangeAutoUpdates() {
  let id = "test@mochi.test";
  let extension = ExtensionTestUtils.loadExtension({
    manifest: {
      name: "Test",
      browser_specific_settings: { gecko: { id } },
    },
    // Use permanent so the add-on can be updated.
    useAddonManager: "permanent",
  });

  await extension.startup();
  let addon = await AddonManager.getAddonByID(id);

  let win = await loadInitialView("extension");
  let doc = win.document;

  let getInputs = () => ({
    default: updatesRow.querySelector('input[value="1"]'),
    on: updatesRow.querySelector('input[value="2"]'),
    off: updatesRow.querySelector('input[value="0"]'),
    checkForUpdate: updatesRow.querySelector('[action="update-check"]'),
  });

  await loadDetailView(win, id);

  let card = doc.querySelector(`addon-card[addon-id="${id}"]`);
  ok(card.querySelector("addon-details"), "The card now has details");

  let updatesRow = card.querySelector(".addon-detail-row-updates");
  let inputs = getInputs(updatesRow);
  is(addon.applyBackgroundUpdates, 1, "Default is set");
  ok(inputs.default.checked, "The default option is selected");
  ok(inputs.checkForUpdate.hidden, "Update check is hidden");

  inputs.on.click();
  is(addon.applyBackgroundUpdates, "2", "Updates are now enabled");
  ok(inputs.on.checked, "The on option is selected");
  ok(inputs.checkForUpdate.hidden, "Update check is hidden");

  inputs.off.click();
  is(addon.applyBackgroundUpdates, "0", "Updates are now disabled");
  ok(inputs.off.checked, "The off option is selected");
  ok(!inputs.checkForUpdate.hidden, "Update check is visible");

  // Go back to the list view and check the details view again.
  let loaded = waitForViewLoad(win);
  doc.querySelector(".back-button").click();
  await loaded;

  // Load the detail view again.
  await loadDetailView(win, id);

  card = doc.querySelector(`addon-card[addon-id="${id}"]`);
  updatesRow = card.querySelector(".addon-detail-row-updates");
  inputs = getInputs(updatesRow);

  ok(inputs.off.checked, "Off is still selected");

  // Disable global updates.
  let updated = BrowserTestUtils.waitForEvent(card, "update");
  AddonManager.autoUpdateDefault = false;
  await updated;

  // Updates are still the same.
  is(addon.applyBackgroundUpdates, "0", "Updates are now disabled");
  ok(inputs.off.checked, "The off option is selected");
  ok(!inputs.checkForUpdate.hidden, "Update check is visible");

  // Check default.
  inputs.default.click();
  is(addon.applyBackgroundUpdates, "1", "Default is set");
  ok(inputs.default.checked, "The default option is selected");
  ok(!inputs.checkForUpdate.hidden, "Update check is visible");

  inputs.on.click();
  is(addon.applyBackgroundUpdates, "2", "Updates are now enabled");
  ok(inputs.on.checked, "The on option is selected");
  ok(inputs.checkForUpdate.hidden, "Update check is hidden");

  // Enable updates again.
  updated = BrowserTestUtils.waitForEvent(card, "update");
  AddonManager.autoUpdateDefault = true;
  await updated;

  await closeView(win);
  await extension.unload();
});

async function setupExtensionWithUpdate(
  id,
  { releaseNotes, cancelUpdate } = {}
) {
  let serverHost = `http://localhost:${server.identity.primaryPort}`;
  let updatesPath = `/ext-updates-${id}.json`;

  let baseManifest = {
    name: "Updates",
    icons: { 48: "an-icon.png" },
    browser_specific_settings: {
      gecko: {
        id,
        update_url: serverHost + updatesPath,
      },
    },
  };

  let updateXpi = AddonTestUtils.createTempWebExtensionFile({
    manifest: {
      ...baseManifest,
      version: "2",
      // Include a permission in the updated extension, to make
      // sure that we trigger the permission prompt as expected
      // (and that we can accept or cancel the update by observing
      // the underlying observerService notification).
      permissions: ["http://*.example.com/*"],
    },
  });

  let releaseNotesExtra = {};
  if (releaseNotes) {
    let notesPath = "/notes.txt";
    server.registerPathHandler(notesPath, (request, response) => {
      if (releaseNotes == "ERROR") {
        response.setStatusLine(null, 404, "Not Found");
      } else {
        response.setStatusLine(null, 200, "OK");
        response.write(releaseNotes);
      }
      response.processAsync();
      response.finish();
    });
    releaseNotesExtra.update_info_url = serverHost + notesPath;
  }

  let xpiFilename = `/update-${id}.xpi`;
  server.registerFile(xpiFilename, updateXpi);
  AddonTestUtils.registerJSON(server, updatesPath, {
    addons: {
      [id]: {
        updates: [
          {
            version: "2",
            update_link: serverHost + xpiFilename,
            ...releaseNotesExtra,
          },
        ],
      },
    },
  });

  handlePermissionPrompt({ addonId: id, reject: cancelUpdate });

  let extension = ExtensionTestUtils.loadExtension({
    manifest: {
      ...baseManifest,
      version: "1",
    },
    // Use permanent so the add-on can be updated.
    useAddonManager: "permanent",
  });
  await extension.startup();
  return extension;
}

function disableAutoUpdates(card) {
  // Check button should be hidden.
  let updateCheckButton = card.querySelector('button[action="update-check"]');
  ok(updateCheckButton.hidden, "The button is initially hidden");

  // Disable updates, update check button is now visible.
  card.querySelector('input[name="autoupdate"][value="0"]').click();
  ok(!updateCheckButton.hidden, "The button is now visible");

  // There shouldn't be an update shown to the user.
  assertUpdateState({ card, shown: false });
}

function checkForUpdate(card, expected) {
  let updateCheckButton = card.querySelector('button[action="update-check"]');
  let updateFound = BrowserTestUtils.waitForEvent(card, expected);
  updateCheckButton.click();
  return updateFound;
}

function installUpdate(card, expected) {
  // Install the update.
  let updateInstalled = BrowserTestUtils.waitForEvent(card, expected);
  let updated = BrowserTestUtils.waitForEvent(card, "update");
  card.querySelector('panel-item[action="install-update"]').click();
  return Promise.all([updateInstalled, updated]);
}

async function findUpdatesForAddonId(id) {
  let addon = await AddonManager.getAddonByID(id);
  await new Promise(resolve => {
    addon.findUpdates(
      { onUpdateAvailable: resolve },
      AddonManager.UPDATE_WHEN_USER_REQUESTED
    );
  });
}

function assertUpdateState({
  card,
  shown,
  expanded = true,
  releaseNotes = false,
}) {
  let menuButton = card.querySelector(".more-options-button");
  Assert.equal(
    menuButton.classList.contains("more-options-button-badged"),
    shown,
    "The menu button is badged"
  );
  let installButton = card.querySelector('panel-item[action="install-update"]');
  Assert.notEqual(
    installButton.hidden,
    shown,
    `The install button is ${shown ? "hidden" : "shown"}`
  );
  if (expanded) {
    let updateCheckButton = card.querySelector('button[action="update-check"]');
    Assert.equal(
      updateCheckButton.hidden,
      shown,
      `The update check button is ${shown ? "hidden" : "shown"}`
    );

    let { tabGroup } = card.details;
    is(tabGroup.hidden, false, "The tab group is shown");
    let notesBtn = tabGroup.querySelector('[name="release-notes"]');
    is(
      notesBtn.hidden,
      !releaseNotes,
      `The release notes button is ${releaseNotes ? "shown" : "hidden"}`
    );
  }
}

add_task(async function testUpdateAvailable() {
  let id = "update@mochi.test";
  let extension = await setupExtensionWithUpdate(id);

  let win = await loadInitialView("extension");
  let doc = win.document;

  await loadDetailView(win, id);

  let card = doc.querySelector("addon-card");

  // Disable updates and then check.
  disableAutoUpdates(card);
  await checkForUpdate(card, "update-found");

  // There should now be an update.
  assertUpdateState({ card, shown: true });

  // The version was 1.
  let versionRow = card.querySelector(".addon-detail-row-version");
  is(versionRow.lastChild.textContent, "1", "The version started as 1");

  await installUpdate(card, "update-installed");

  // The version is now 2.
  versionRow = card.querySelector(".addon-detail-row-version");
  is(versionRow.lastChild.textContent, "2", "The version has updated");

  // No update is shown again.
  assertUpdateState({ card, shown: false });

  // Check for updates again, there shouldn't be an update.
  await checkForUpdate(card, "no-update");

  await closeView(win);
  await extension.unload();
});

add_task(async function testReleaseNotesLoad() {
  Services.telemetry.clearEvents();
  let id = "update-with-notes@mochi.test";
  let extension = await setupExtensionWithUpdate(id, {
    releaseNotes: `
      <html xmlns="http://www.w3.org/1999/xhtml">
        <head><link rel="stylesheet" href="remove-me.css"/></head>
        <body>
          <script src="no-scripts.js"></script>
          <h1>My release notes</h1>
          <img src="http://example.com/tracker.png"/>
          <ul>
            <li onclick="alert('hi')">A thing</li>
          </ul>
          <a href="http://example.com/">Go somewhere</a>
        </body>
      </html>
    `,
  });

  let win = await loadInitialView("extension");
  let doc = win.document;

  await loadDetailView(win, id);

  let card = doc.querySelector("addon-card");
  let { deck, tabGroup } = card.details;

  // Disable updates and then check.
  disableAutoUpdates(card);
  await checkForUpdate(card, "update-found");

  // There should now be an update.
  assertUpdateState({ card, shown: true, releaseNotes: true });

  info("Check release notes");
  let notesBtn = tabGroup.querySelector('[name="release-notes"]');
  let notes = card.querySelector("update-release-notes");
  let loading = BrowserTestUtils.waitForEvent(notes, "release-notes-loading");
  let loaded = BrowserTestUtils.waitForEvent(notes, "release-notes-loaded");
  // Don't use notesBtn.click() since it causes an assertion to fail.
  // See bug 1551621 for more info.
  EventUtils.synthesizeMouseAtCenter(notesBtn, {}, win);
  await loading;
  is(
    doc.l10n.getAttributes(notes.firstElementChild).id,
    "release-notes-loading",
    "The loading message is shown"
  );
  await loaded;
  info("Checking HTML release notes");
  let [h1, ul, a] = notes.children;
  is(h1.tagName, "H1", "There's a heading");
  is(h1.textContent, "My release notes", "The heading has content");
  is(ul.tagName, "UL", "There's a list");
  is(ul.children.length, 1, "There's one item in the list");
  let [li] = ul.children;
  is(li.tagName, "LI", "There's a list item");
  is(li.textContent, "A thing", "The text is set");
  ok(!li.hasAttribute("onclick"), "The onclick was removed");
  ok(!notes.querySelector("link"), "The link tag was removed");
  ok(!notes.querySelector("script"), "The script tag was removed");
  is(a.textContent, "Go somewhere", "The link text is preserved");
  is(a.href, "http://example.com/", "The link href is preserved");

  info("Verify the link opened in a new tab");
  let tabOpened = BrowserTestUtils.waitForNewTab(gBrowser, a.href);
  a.click();
  let tab = await tabOpened;
  BrowserTestUtils.removeTab(tab);

  let originalContent = notes.innerHTML;

  info("Switch away and back to release notes");
  // Load details view.
  let detailsBtn = tabGroup.querySelector('.tab-button[name="details"]');
  let viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
  detailsBtn.click();
  await viewChanged;

  // Load release notes again, verify they weren't loaded.
  viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
  let notesCached = BrowserTestUtils.waitForEvent(
    notes,
    "release-notes-cached"
  );
  notesBtn.click();
  await viewChanged;
  await notesCached;
  is(notes.innerHTML, originalContent, "The content didn't change");

  info("Install the update to clean it up");
  await installUpdate(card, "update-installed");

  // There's no more update but release notes are still shown.
  assertUpdateState({ card, shown: false, releaseNotes: true });

  await closeView(win);
  await extension.unload();
});

add_task(async function testReleaseNotesError() {
  let id = "update-with-notes-error@mochi.test";
  let extension = await setupExtensionWithUpdate(id, { releaseNotes: "ERROR" });

  let win = await loadInitialView("extension");
  let doc = win.document;

  await loadDetailView(win, id);

  let card = doc.querySelector("addon-card");
  let { deck, tabGroup } = card.details;

  // Disable updates and then check.
  disableAutoUpdates(card);
  await checkForUpdate(card, "update-found");

  // There should now be an update.
  assertUpdateState({ card, shown: true, releaseNotes: true });

  info("Check release notes");
  let notesBtn = tabGroup.querySelector('[name="release-notes"]');
  let notes = card.querySelector("update-release-notes");
  let loading = BrowserTestUtils.waitForEvent(notes, "release-notes-loading");
  let errored = BrowserTestUtils.waitForEvent(notes, "release-notes-error");
  // Don't use notesBtn.click() since it causes an assertion to fail.
  // See bug 1551621 for more info.
  EventUtils.synthesizeMouseAtCenter(notesBtn, {}, win);
  await loading;
  is(
    doc.l10n.getAttributes(notes.firstElementChild).id,
    "release-notes-loading",
    "The loading message is shown"
  );
  await errored;
  is(
    doc.l10n.getAttributes(notes.firstElementChild).id,
    "release-notes-error",
    "The error message is shown"
  );

  info("Switch away and back to release notes");
  // Load details view.
  let detailsBtn = tabGroup.querySelector('.tab-button[name="details"]');
  let viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
  detailsBtn.click();
  await viewChanged;

  // Load release notes again, verify they weren't loaded.
  viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
  let notesCached = BrowserTestUtils.waitForEvent(
    notes,
    "release-notes-cached"
  );
  notesBtn.click();
  await viewChanged;
  await notesCached;

  info("Install the update to clean it up");
  await installUpdate(card, "update-installed");

  await closeView(win);
  await extension.unload();
});

add_task(async function testUpdateCancelled() {
  let id = "update@mochi.test";
  let extension = await setupExtensionWithUpdate(id, { cancelUpdate: true });

  let win = await loadInitialView("extension");
  let doc = win.document;

  await loadDetailView(win, "update@mochi.test");
  let card = doc.querySelector("addon-card");

  // Disable updates and then check.
  disableAutoUpdates(card);
  await checkForUpdate(card, "update-found");

  // There should now be an update.
  assertUpdateState({ card, shown: true });

  // The add-on starts as version 1.
  let versionRow = card.querySelector(".addon-detail-row-version");
  is(versionRow.lastChild.textContent, "1", "The version started as 1");

  // Force the install to be cancelled.
  let install = card.updateInstall;
  ok(install, "There was an install found");

  await installUpdate(card, "update-cancelled");

  // The add-on is still version 1.
  versionRow = card.querySelector(".addon-detail-row-version");
  is(versionRow.lastChild.textContent, "1", "The version hasn't changed");

  // The update has been removed.
  assertUpdateState({ card, shown: false });

  await closeView(win);
  await extension.unload();
});

add_task(async function testAvailableUpdates() {
  let ids = ["update1@mochi.test", "update2@mochi.test", "update3@mochi.test"];
  let addons = await Promise.all(ids.map(id => setupExtensionWithUpdate(id)));

  // Disable global add-on updates.
  AddonManager.autoUpdateDefault = false;

  let win = await loadInitialView("extension");
  let doc = win.document;
  let updatesMessage = doc.getElementById("updates-message");
  let categoryUtils = new CategoryUtilities(win);

  let availableCat = categoryUtils.get("available-updates");

  ok(availableCat.hidden, "Available updates is hidden");
  is(availableCat.badgeCount, 0, "There are no updates");
  ok(updatesMessage, "There is an updates message");
  is_element_hidden(updatesMessage, "The message is hidden");
  ok(!updatesMessage.message.textContent, "The message is empty");
  ok(!updatesMessage.button.textContent, "The button is empty");

  // Check for all updates.
  let updatesFound = TestUtils.topicObserved("EM-update-check-finished");
  doc.querySelector('#page-options [action="check-for-updates"]').click();

  is_element_visible(updatesMessage, "The message is visible");
  ok(!updatesMessage.message.textContent, "The message is empty");
  ok(updatesMessage.button.hidden, "The view updates button is hidden");

  // Make sure the message gets populated by fluent.
  await TestUtils.waitForCondition(
    () => updatesMessage.message.textContent,
    "wait for message text"
  );

  await updatesFound;

  // The button should be visible, and should get some text from fluent.
  ok(!updatesMessage.button.hidden, "The view updates button is visible");
  await TestUtils.waitForCondition(
    () => updatesMessage.button.textContent,
    "wait for button text"
  );

  // Wait for the available updates count to finalize, it's async.
  await BrowserTestUtils.waitForCondition(() => availableCat.badgeCount == 3);

  // The category shows the correct update count.
  ok(!availableCat.hidden, "Available updates is visible");
  is(availableCat.badgeCount, 3, "There are 3 updates");

  // Go to the available updates page.
  let loaded = waitForViewLoad(win);
  availableCat.click();
  await loaded;

  // Check the updates are shown.
  let cards = doc.querySelectorAll("addon-card");
  is(cards.length, 3, "There are 3 cards");

  // Each card should have an update.
  for (let card of cards) {
    assertUpdateState({ card, shown: true, expanded: false });
  }

  // Check the detail page for the first add-on.
  await loadDetailView(win, ids[0]);
  is(
    categoryUtils.getSelectedViewId(),
    "addons://list/extension",
    "The extensions category is selected"
  );

  // Go back to the last view.
  loaded = waitForViewLoad(win);
  doc.querySelector(".back-button").click();
  await loaded;

  // We're back on the updates view.
  is(
    categoryUtils.getSelectedViewId(),
    "addons://updates/available",
    "The available updates category is selected"
  );

  // Find the cards again.
  cards = doc.querySelectorAll("addon-card");
  is(cards.length, 3, "There are 3 cards");

  // Install the first update.
  await installUpdate(cards[0], "update-installed");
  assertUpdateState({ card: cards[0], shown: false, expanded: false });

  // The count goes down but the card stays.
  is(availableCat.badgeCount, 2, "There are only 2 updates now");
  is(
    doc.querySelectorAll("addon-card").length,
    3,
    "All 3 cards are still visible on the updates page"
  );

  // Install the other two updates.
  await installUpdate(cards[1], "update-installed");
  assertUpdateState({ card: cards[1], shown: false, expanded: false });
  await installUpdate(cards[2], "update-installed");
  assertUpdateState({ card: cards[2], shown: false, expanded: false });

  // The count goes down but the card stays.
  is(availableCat.badgeCount, 0, "There are no more updates");
  is(
    doc.querySelectorAll("addon-card").length,
    3,
    "All 3 cards are still visible on the updates page"
  );

  // Enable global add-on updates again.
  AddonManager.autoUpdateDefault = true;

  await closeView(win);
  await Promise.all(addons.map(addon => addon.unload()));
});

add_task(async function testUpdatesShownOnLoad() {
  let id = "has-update@mochi.test";
  let addon = await setupExtensionWithUpdate(id);

  // Find the update for our addon.
  AddonManager.autoUpdateDefault = false;
  await findUpdatesForAddonId(id);

  let win = await loadInitialView("extension");
  let categoryUtils = new CategoryUtilities(win);
  let updatesButton = categoryUtils.get("available-updates");

  ok(!updatesButton.hidden, "The updates button is shown");
  is(updatesButton.badgeCount, 1, "There is an update");

  let loaded = waitForViewLoad(win);
  updatesButton.click();
  await loaded;

  let cards = win.document.querySelectorAll("addon-card");

  is(cards.length, 1, "There is one update card");

  let card = cards[0];
  is(card.addon.id, id, "The update is for the expected add-on");

  await installUpdate(card, "update-installed");

  ok(!updatesButton.hidden, "The updates button is still shown");
  is(updatesButton.badgeCount, 0, "There are no more updates");

  info("Check that the updates section is hidden when re-opened");
  await closeView(win);
  win = await loadInitialView("extension");
  categoryUtils = new CategoryUtilities(win);
  updatesButton = categoryUtils.get("available-updates");

  ok(updatesButton.hidden, "Available updates is hidden");
  is(updatesButton.badgeCount, 0, "There are no updates");

  AddonManager.autoUpdateDefault = true;
  await closeView(win);
  await addon.unload();
});

add_task(async function testPromptOnBackgroundUpdateCheck() {
  const id = "test-prompt-on-background-check@mochi.test";
  const extension = await setupExtensionWithUpdate(id);

  AddonManager.autoUpdateDefault = false;

  const addon = await AddonManager.getAddonByID(id);
  await AddonTestUtils.promiseFindAddonUpdates(
    addon,
    AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
  );
  let win = await loadInitialView("extension");

  let card = getAddonCard(win, id);

  const promisePromptInfo = promisePermissionPrompt(id);
  await installUpdate(card, "update-installed");
  const promptInfo = await promisePromptInfo;
  ok(promptInfo, "Got a permission prompt as expected");

  AddonManager.autoUpdateDefault = true;

  await closeView(win);
  await extension.unload();
});

add_task(async function testNoUpdateAvailableOnUnrelatedAddonCards() {
  let idNoUpdate = "no-update@mochi.test";

  let extensionNoUpdate = ExtensionTestUtils.loadExtension({
    useAddonManager: "temporary",
    manifest: {
      name: "TestAddonNoUpdate",
      browser_specific_settings: { gecko: { id: idNoUpdate } },
    },
  });
  await extensionNoUpdate.startup();

  let win = await loadInitialView("extension");

  let cardNoUpdate = getAddonCard(win, idNoUpdate);
  ok(cardNoUpdate, `Got AddonCard for ${idNoUpdate}`);

  // Assert that there is not an update badge
  assertUpdateState({ card: cardNoUpdate, shown: false, expanded: false });

  // Trigger a onNewInstall event by install another unrelated addon.
  const XPI_URL = `${SECURE_TESTROOT}../xpinstall/amosigned.xpi`;
  let install = await AddonManager.getInstallForURL(XPI_URL);
  await AddonManager.installAddonFromAOM(
    gBrowser.selectedBrowser,
    win.document.documentURIObject,
    install
  );

  // Cancel the install used to trigger the onNewInstall install event.
  await install.cancel();
  // Assert that the previously installed addon isn't marked with the
  // update available badge after installing an unrelated addon.
  assertUpdateState({ card: cardNoUpdate, shown: false, expanded: false });

  await closeView(win);
  await extensionNoUpdate.unload();
});
