// Tests that keyboard navigation in the search panel works as designed.

const searchPopup = document.getElementById("PopupSearchAutoComplete");

const kValues = ["foo1", "foo2", "foo3"];
const kUserValue = "foo";

function getOpenSearchItems() {
  let os = [];

  let addEngineList = searchPopup.searchOneOffsContainer.querySelector(
    ".search-add-engines"
  );
  for (
    let item = addEngineList.firstElementChild;
    item;
    item = item.nextElementSibling
  ) {
    os.push(item);
  }

  return os;
}

let searchbar;
let textbox;

async function checkHeader(engine) {
  // The header can be updated after getting the engine, so we may have to
  // wait for it.
  let header = searchPopup.searchbarEngineName;
  if (!header.getAttribute("value").includes(engine.name)) {
    await new Promise(resolve => {
      let observer = new MutationObserver(() => {
        observer.disconnect();
        resolve();
      });
      observer.observe(searchPopup.searchbarEngineName, {
        attributes: true,
        attributeFilter: ["value"],
      });
    });
  }
  Assert.ok(
    header.getAttribute("value").includes(engine.name),
    "Should have the correct engine name displayed in the header"
  );
}

add_setup(async function () {
  searchbar = await gCUITestUtils.addSearchBar();
  textbox = searchbar.textbox;

  await SearchTestUtils.promiseNewSearchEngine({
    url: getRootDirectory(gTestPath) + "testEngine.xml",
    setAsDefault: true,
  });
  // First cleanup the form history in case other tests left things there.
  info("cleanup the search history");
  await FormHistory.update({ op: "remove", fieldname: "searchbar-history" });

  info("adding search history values: " + kValues);
  let addOps = kValues.map(value => {
    return { op: "add", fieldname: "searchbar-history", value };
  });
  await FormHistory.update(addOps);

  textbox.value = kUserValue;

  registerCleanupFunction(async () => {
    gCUITestUtils.removeSearchBar();
  });
});

add_task(async function test_arrows() {
  let promise = promiseEvent(searchPopup, "popupshown");
  info("Opening search panel");
  searchbar.focus();
  await promise;
  is(
    textbox.mController.searchString,
    kUserValue,
    "The search string should be 'foo'"
  );

  // Check the initial state of the panel before sending keyboard events.
  is(searchPopup.matchCount, kValues.length, "There should be 3 suggestions");
  is(searchPopup.selectedIndex, -1, "no suggestion should be selected");

  // The tests will be less meaningful if the first, second, last, and
  // before-last one-off buttons aren't different. We should always have more
  // than 4 default engines, but it's safer to check this assumption.
  let oneOffs = getOneOffs();
  ok(oneOffs.length >= 4, "we have at least 4 one-off buttons displayed");

  ok(!textbox.selectedButton, "no one-off button should be selected");

  // The down arrow should first go through the suggestions.
  for (let i = 0; i < kValues.length; ++i) {
    EventUtils.synthesizeKey("KEY_ArrowDown");
    is(
      searchPopup.selectedIndex,
      i,
      "the suggestion at index " + i + " should be selected"
    );
    is(
      textbox.value,
      kValues[i],
      "the textfield value should be " + kValues[i]
    );
    await checkHeader(Services.search.defaultEngine);
  }

  // Pressing down again should remove suggestion selection and change the text
  // field value back to what the user typed, and select the first one-off.
  EventUtils.synthesizeKey("KEY_ArrowDown");
  is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
  is(
    textbox.value,
    kUserValue,
    "the textfield value should be back to initial value"
  );

  // now cycle through the one-off items, the first one is already selected.
  for (let i = 0; i < oneOffs.length; ++i) {
    let oneOffButton = oneOffs[i];
    is(
      textbox.selectedButton,
      oneOffButton,
      "the one-off button #" + (i + 1) + " should be selected"
    );
    await checkHeader(oneOffButton.engine);
    EventUtils.synthesizeKey("KEY_ArrowDown");
  }

  ok(
    textbox.selectedButton.classList.contains("search-setting-button"),
    "the settings item should be selected"
  );
  await checkHeader(Services.search.defaultEngine);
  EventUtils.synthesizeKey("KEY_ArrowDown");

  // We should now be back to the initial situation.
  is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
  ok(!textbox.selectedButton, "no one-off button should be selected");
  await checkHeader(Services.search.defaultEngine);

  info("now test the up arrow key");
  EventUtils.synthesizeKey("KEY_ArrowUp");
  ok(
    textbox.selectedButton.classList.contains("search-setting-button"),
    "the settings item should be selected"
  );
  await checkHeader(Services.search.defaultEngine);

  // cycle through the one-off items, the first one is already selected.
  for (let i = oneOffs.length; i; --i) {
    EventUtils.synthesizeKey("KEY_ArrowUp");
    let oneOffButton = oneOffs[i - 1];
    is(
      textbox.selectedButton,
      oneOffButton,
      "the one-off button #" + i + " should be selected"
    );
    await checkHeader(oneOffButton.engine);
  }

  // Another press on up should clear the one-off selection and select the
  // last suggestion.
  EventUtils.synthesizeKey("KEY_ArrowUp");
  ok(!textbox.selectedButton, "no one-off button should be selected");

  for (let i = kValues.length - 1; i >= 0; --i) {
    is(
      searchPopup.selectedIndex,
      i,
      "the suggestion at index " + i + " should be selected"
    );
    is(
      textbox.value,
      kValues[i],
      "the textfield value should be " + kValues[i]
    );
    await checkHeader(Services.search.defaultEngine);
    EventUtils.synthesizeKey("KEY_ArrowUp");
  }

  is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
  is(
    textbox.value,
    kUserValue,
    "the textfield value should be back to initial value"
  );
});

add_task(async function test_typing_clears_button_selection() {
  is(
    Services.focus.focusedElement,
    textbox,
    "the search bar should be focused"
  ); // from the previous test.
  ok(!textbox.selectedButton, "no button should be selected");

  EventUtils.synthesizeKey("KEY_ArrowUp");
  ok(
    textbox.selectedButton.classList.contains("search-setting-button"),
    "the settings item should be selected"
  );

  // Type a character.
  EventUtils.sendString("a");
  ok(!textbox.selectedButton, "the settings item should be de-selected");

  // Remove the character.
  EventUtils.synthesizeKey("KEY_Backspace");
});

add_task(async function test_tab() {
  is(
    Services.focus.focusedElement,
    textbox,
    "the search bar should be focused"
  ); // from the previous test.

  let oneOffs = getOneOffs();
  ok(!textbox.selectedButton, "no one-off button should be selected");

  // Pressing tab should select the first one-off without selecting suggestions.
  // now cycle through the one-off items, the first one is already selected.
  for (let i = 0; i < oneOffs.length; ++i) {
    EventUtils.synthesizeKey("KEY_Tab");
    is(
      textbox.selectedButton,
      oneOffs[i],
      "the one-off button #" + (i + 1) + " should be selected"
    );
  }
  is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
  is(textbox.value, kUserValue, "the textfield value should be unmodified");

  // One more <tab> selects the settings button.
  EventUtils.synthesizeKey("KEY_Tab");
  ok(
    textbox.selectedButton.classList.contains("search-setting-button"),
    "the settings item should be selected"
  );

  // Pressing tab again should close the panel...
  let promise = promiseEvent(searchPopup, "popuphidden");
  EventUtils.synthesizeKey("KEY_Tab");
  await promise;

  // ... and move the focus out of the searchbox.
  isnot(
    Services.focus.focusedElement,
    textbox.inputField,
    "the search bar no longer be focused"
  );
});

add_task(async function test_shift_tab() {
  // First reopen the panel.
  let promise = promiseEvent(searchPopup, "popupshown");
  info("Opening search panel");
  searchbar.focus();
  await promise;

  let oneOffs = getOneOffs();
  ok(!textbox.selectedButton, "no one-off button should be selected");

  // Press up once to select the last button.
  EventUtils.synthesizeKey("KEY_ArrowUp");
  ok(
    textbox.selectedButton.classList.contains("search-setting-button"),
    "the settings item should be selected"
  );

  // Press up again to select the last one-off button.
  EventUtils.synthesizeKey("KEY_ArrowUp");

  // Pressing shift+tab should cycle through the one-off items.
  for (let i = oneOffs.length - 1; i >= 0; --i) {
    is(
      textbox.selectedButton,
      oneOffs[i],
      "the one-off button #" + (i + 1) + " should be selected"
    );
    if (i) {
      EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true });
    }
  }
  is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
  is(textbox.value, kUserValue, "the textfield value should be unmodified");

  // Pressing shift+tab again should close the panel...
  promise = promiseEvent(searchPopup, "popuphidden");
  EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true });
  await promise;

  // ... and move the focus out of the searchbox.
  isnot(
    Services.focus.focusedElement,
    textbox.inputField,
    "the search bar no longer be focused"
  );
});

add_task(async function test_alt_down() {
  // First refocus the panel.
  let promise = promiseEvent(searchPopup, "popupshown");
  info("Opening search panel");
  searchbar.focus();
  await promise;

  // close the panel using the escape key.
  promise = promiseEvent(searchPopup, "popuphidden");
  EventUtils.synthesizeKey("KEY_Escape");
  await promise;

  // check that alt+down opens the panel...
  promise = promiseEvent(searchPopup, "popupshown");
  EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true });
  await promise;

  // ... and does nothing else.
  ok(!textbox.selectedButton, "no one-off button should be selected");
  is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
  is(textbox.value, kUserValue, "the textfield value should be unmodified");

  // Pressing alt+down should select the first one-off without selecting suggestions
  // and cycle through the one-off items.
  let oneOffs = getOneOffs();
  for (let i = 0; i < oneOffs.length; ++i) {
    EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true });
    is(
      textbox.selectedButton,
      oneOffs[i],
      "the one-off button #" + (i + 1) + " should be selected"
    );
    is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
  }

  // One more alt+down keypress and nothing should be selected.
  EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true });
  ok(!textbox.selectedButton, "no one-off button should be selected");

  // another one and the first one-off should be selected.
  EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true });
  is(
    textbox.selectedButton,
    oneOffs[0],
    "the first one-off button should be selected"
  );
});

add_task(async function test_alt_up() {
  // close the panel using the escape key.
  let promise = promiseEvent(searchPopup, "popuphidden");
  EventUtils.synthesizeKey("KEY_Escape");
  await promise;
  ok(
    !textbox.selectedButton,
    "no one-off button should be selected after closing the panel"
  );

  // check that alt+up opens the panel...
  promise = promiseEvent(searchPopup, "popupshown");
  EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true });
  await promise;

  // ... and does nothing else.
  ok(!textbox.selectedButton, "no one-off button should be selected");
  is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
  is(textbox.value, kUserValue, "the textfield value should be unmodified");

  // Pressing alt+up should select the last one-off without selecting suggestions
  // and cycle up through the one-off items.
  let oneOffs = getOneOffs();
  for (let i = oneOffs.length - 1; i >= 0; --i) {
    EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true });
    is(
      textbox.selectedButton,
      oneOffs[i],
      "the one-off button #" + (i + 1) + " should be selected"
    );
    is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
  }

  // One more alt+down keypress and nothing should be selected.
  EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true });
  ok(!textbox.selectedButton, "no one-off button should be selected");

  // another one and the last one-off should be selected.
  EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true });
  is(
    textbox.selectedButton,
    oneOffs[oneOffs.length - 1],
    "the last one-off button should be selected"
  );

  // Cleanup for the next test.
  EventUtils.synthesizeKey("KEY_ArrowDown");
  ok(
    textbox.selectedButton.classList.contains("search-setting-button"),
    "the settings item should be selected"
  );
  EventUtils.synthesizeKey("KEY_ArrowDown");
  ok(!textbox.selectedButton, "no one-off should be selected anymore");
});

add_task(async function test_accel_down() {
  // Pressing accel+down should select the next visible search engine, without
  // selecting suggestions.
  let engines = await Services.search.getVisibleEngines();
  let current = Services.search.defaultEngine;
  let currIdx = -1;
  for (let i = 0, l = engines.length; i < l; ++i) {
    if (engines[i].name == current.name) {
      currIdx = i;
      break;
    }
  }
  for (let i = 0, l = engines.length; i < l; ++i) {
    EventUtils.synthesizeKey("KEY_ArrowDown", { accelKey: true });
    await SearchTestUtils.promiseSearchNotification(
      "engine-default",
      "browser-search-engine-modified"
    );
    let expected = engines[++currIdx % engines.length];
    is(
      Services.search.defaultEngine.name,
      expected.name,
      "Default engine should have changed"
    );
    is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
  }
  Services.search.defaultEngine = current;
});

add_task(async function test_accel_up() {
  // Pressing accel+down should select the previous visible search engine, without
  // selecting suggestions.
  let engines = await Services.search.getVisibleEngines();
  let current = Services.search.defaultEngine;
  let currIdx = -1;
  for (let i = 0, l = engines.length; i < l; ++i) {
    if (engines[i].name == current.name) {
      currIdx = i;
      break;
    }
  }
  for (let i = 0, l = engines.length; i < l; ++i) {
    EventUtils.synthesizeKey("KEY_ArrowUp", { accelKey: true });
    await SearchTestUtils.promiseSearchNotification(
      "engine-default",
      "browser-search-engine-modified"
    );
    let expected =
      engines[--currIdx < 0 ? (currIdx = engines.length - 1) : currIdx];
    is(
      Services.search.defaultEngine.name,
      expected.name,
      "Default engine should have changed"
    );
    is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
  }
  Services.search.defaultEngine = current;
});

add_task(async function test_tab_and_arrows() {
  // Check the initial state is as expected.
  ok(!textbox.selectedButton, "no one-off button should be selected");
  is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
  is(textbox.value, kUserValue, "the textfield value should be unmodified");

  // After pressing down, the first sugggestion should be selected.
  EventUtils.synthesizeKey("KEY_ArrowDown");
  is(searchPopup.selectedIndex, 0, "first suggestion should be selected");
  is(textbox.value, kValues[0], "the textfield value should have changed");
  ok(!textbox.selectedButton, "no one-off button should be selected");

  // After pressing tab, the first one-off should be selected,
  // and no suggestion should be selected.
  let oneOffs = getOneOffs();
  EventUtils.synthesizeKey("KEY_Tab");
  is(
    textbox.selectedButton,
    oneOffs[0],
    "the first one-off button should be selected"
  );
  is(searchPopup.selectedIndex, -1, "no suggestion should be selected");

  // After pressing down, the second one-off should be selected.
  EventUtils.synthesizeKey("KEY_ArrowDown");
  is(
    textbox.selectedButton,
    oneOffs[1],
    "the second one-off button should be selected"
  );
  is(searchPopup.selectedIndex, -1, "no suggestion should be selected");

  // After pressing right, the third one-off should be selected.
  EventUtils.synthesizeKey("KEY_ArrowRight");
  is(
    textbox.selectedButton,
    oneOffs[2],
    "the third one-off button should be selected"
  );
  is(searchPopup.selectedIndex, -1, "no suggestion should be selected");

  // After pressing left, the second one-off should be selected again.
  EventUtils.synthesizeKey("KEY_ArrowLeft");
  is(
    textbox.selectedButton,
    oneOffs[1],
    "the second one-off button should be selected again"
  );
  is(searchPopup.selectedIndex, -1, "no suggestion should be selected");

  // After pressing up, the first one-off should be selected again.
  EventUtils.synthesizeKey("KEY_ArrowUp");
  is(
    textbox.selectedButton,
    oneOffs[0],
    "the first one-off button should be selected again"
  );
  is(searchPopup.selectedIndex, -1, "no suggestion should be selected");

  // After pressing up again, the last suggestion should be selected.
  // the textfield value back to the user-typed value, and still the first one-off
  // selected.
  EventUtils.synthesizeKey("KEY_ArrowUp");
  is(
    searchPopup.selectedIndex,
    kValues.length - 1,
    "last suggestion should be selected"
  );
  is(
    textbox.value,
    kValues[kValues.length - 1],
    "the textfield value should match the suggestion"
  );
  is(textbox.selectedButton, null, "no one-off button should be selected");

  // Now pressing down should select the first one-off.
  EventUtils.synthesizeKey("KEY_ArrowDown");
  is(
    textbox.selectedButton,
    oneOffs[0],
    "the first one-off button should be selected"
  );
  is(searchPopup.selectedIndex, -1, "there should be no selected suggestion");

  // Finally close the panel.
  let promise = promiseEvent(searchPopup, "popuphidden");
  searchPopup.hidePopup();
  await promise;
});

add_task(async function test_open_search() {
  let rootDir = getRootDirectory(gTestPath);
  await BrowserTestUtils.openNewForegroundTab(
    gBrowser,
    rootDir + "opensearch.html"
  );

  let promise = promiseEvent(searchPopup, "popupshown");
  info("Opening search panel");
  searchbar.focus();
  await promise;

  let engines = searchPopup.querySelectorAll(
    ".searchbar-engine-one-off-add-engine"
  );
  is(engines.length, 3, "the opensearch.html page exposes 3 engines");

  // Check that there's initially no selection.
  is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
  ok(!textbox.selectedButton, "no button should be selected");

  // Pressing up once selects the setting button...
  EventUtils.synthesizeKey("KEY_ArrowUp");
  ok(
    textbox.selectedButton.classList.contains("search-setting-button"),
    "the settings item should be selected"
  );

  // ...and then pressing up selects open search engines.
  for (let i = engines.length; i; --i) {
    EventUtils.synthesizeKey("KEY_ArrowUp");
    let selectedButton = textbox.selectedButton;
    is(
      selectedButton,
      engines[i - 1],
      "the engine #" + i + " should be selected"
    );
    ok(
      selectedButton.classList.contains("searchbar-engine-one-off-add-engine"),
      "the button is themed as an add engine"
    );
  }

  // Pressing up again should select the last one-off button.
  EventUtils.synthesizeKey("KEY_ArrowUp");
  const allOneOffs = getOneOffs();
  is(
    textbox.selectedButton,
    allOneOffs[allOneOffs.length - engines.length - 1],
    "the last one-off button should be selected"
  );

  info("now check that the down key navigates open search items as expected");
  for (let i = 0; i < engines.length; ++i) {
    EventUtils.synthesizeKey("KEY_ArrowDown");
    is(
      textbox.selectedButton,
      engines[i],
      "the engine #" + (i + 1) + " should be selected"
    );
  }

  // Pressing down on the last engine item selects the settings button.
  EventUtils.synthesizeKey("KEY_ArrowDown");
  ok(
    textbox.selectedButton.classList.contains("search-setting-button"),
    "the settings item should be selected"
  );

  promise = promiseEvent(searchPopup, "popuphidden");
  searchPopup.hidePopup();
  await promise;

  gBrowser.removeCurrentTab();
});

add_task(async function cleanup() {
  info("removing search history values: " + kValues);
  let removeOps = kValues.map(value => {
    return { op: "remove", fieldname: "searchbar-history", value };
  });
  await FormHistory.update(removeOps);

  textbox.value = "";
});
