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

/**
 * This tests dynamic results.
 */

"use strict";

const DYNAMIC_TYPE_NAME = "test";

const DYNAMIC_TYPE_VIEW_TEMPLATE = {
  stylesheet: getRootDirectory(gTestPath) + "dynamicResult0.css",
  children: [
    {
      name: "selectable",
      tag: "span",
      attributes: {
        selectable: "true",
      },
    },
    {
      name: "text",
      tag: "span",
    },
    {
      name: "buttonBox",
      tag: "span",
      children: [
        {
          name: "button1",
          tag: "span",
          attributes: {
            role: "button",
            attribute_to_remove: "value",
          },
        },
        {
          name: "button2",
          tag: "span",
          attributes: {
            role: "button",
          },
        },
      ],
    },
  ],
};

const DUMMY_PAGE =
  "http://example.com/browser/browser/base/content/test/general/dummy_page.html";

// Tests the dynamic type registration functions and stylesheet loading.
add_task(async function registration() {
  // Get our test stylesheet URIs.
  let stylesheetURIs = [];
  for (let i = 0; i < 2; i++) {
    stylesheetURIs.push(
      Services.io.newURI(getRootDirectory(gTestPath) + `dynamicResult${i}.css`)
    );
  }

  // Maps from dynamic type names to their type.
  let viewTemplatesByName = {
    foo: {
      stylesheet: stylesheetURIs[0].spec,
      children: [
        {
          name: "text",
          tag: "span",
        },
      ],
    },
    bar: {
      stylesheet: stylesheetURIs[1].spec,
      children: [
        {
          name: "icon",
          tag: "span",
        },
        {
          name: "button",
          tag: "span",
          attributes: {
            role: "button",
          },
        },
      ],
    },
  };

  // First, open another window so that multiple windows are open when we add
  // the types so we can verify below that the stylesheets are added to all open
  // windows.
  let newWindows = [];
  newWindows.push(await BrowserTestUtils.openNewBrowserWindow());

  // Add the test dynamic types.
  for (let [name, viewTemplate] of Object.entries(viewTemplatesByName)) {
    UrlbarResult.addDynamicResultType(name);
    UrlbarView.addDynamicViewTemplate(name, viewTemplate);
  }

  // Get them back to make sure they were added.
  for (let name of Object.keys(viewTemplatesByName)) {
    let actualType = UrlbarResult.getDynamicResultType(name);
    // Types are currently just empty objects.
    Assert.deepEqual(actualType, {}, "Types should match");
  }

  // Their stylesheets should have been applied to all open windows.  There's no
  // good way to check this because:
  //
  // * nsIStyleSheetService has a function that returns whether a stylesheet has
  //   been loaded, but it's global and not per window.
  // * nsIDOMWindowUtils has functions to load stylesheets but not one to check
  //   whether a stylesheet has been loaded.
  // * document.stylesheets only contains stylesheets in the DOM.
  //
  // So instead we set a CSS variable on #urlbar in each of our stylesheets and
  // check that it's present.
  function getCSSVariables(windows) {
    let valuesByWindow = new Map();
    for (let win of windows) {
      let values = [];
      valuesByWindow.set(window, values);
      for (let i = 0; i < stylesheetURIs.length; i++) {
        let value = win
          .getComputedStyle(gURLBar.panel)
          .getPropertyValue(`--testDynamicResult${i}`);
        values.push((value || "").trim());
      }
    }
    return valuesByWindow;
  }
  function checkCSSVariables(windows) {
    for (let values of getCSSVariables(windows).values()) {
      for (let i = 0; i < stylesheetURIs.length; i++) {
        if (values[i].trim() !== `ok${i}`) {
          return false;
        }
      }
    }
    return true;
  }

  // The stylesheets are loaded asyncly, so we need to poll for it.
  await TestUtils.waitForCondition(() =>
    checkCSSVariables(BrowserWindowTracker.orderedWindows)
  );
  Assert.ok(true, "Stylesheets loaded in all open windows");

  // Open another window to make sure the stylesheets are loaded in it after we
  // added the new dynamic types.
  let newWin = await BrowserTestUtils.openNewBrowserWindow();
  newWindows.push(newWin);
  await TestUtils.waitForCondition(() => checkCSSVariables([newWin]));
  Assert.ok(true, "Stylesheets loaded in new window");

  // Remove the dynamic types.
  for (let name of Object.keys(viewTemplatesByName)) {
    UrlbarView.removeDynamicViewTemplate(name);
    UrlbarResult.removeDynamicResultType(name);
    let actualType = UrlbarResult.getDynamicResultType(name);
    Assert.equal(actualType, null, "Type should be unregistered");
  }

  // The stylesheets should be removed from all windows.
  let valuesByWindow = getCSSVariables(BrowserWindowTracker.orderedWindows);
  for (let values of valuesByWindow.values()) {
    for (let i = 0; i < stylesheetURIs.length; i++) {
      Assert.ok(!values[i], "Stylesheet should be removed");
    }
  }

  // Close the new windows.
  for (let win of newWindows) {
    await BrowserTestUtils.closeWindow(win);
  }
});

// Tests that the view is created correctly from the view template.
add_task(async function viewCreated() {
  await withDynamicTypeProvider(async () => {
    // Do a search.
    await UrlbarTestUtils.promiseAutocompleteResultPopup({
      window,
      value: "test",
      waitForFocus: SimpleTest.waitForFocus,
    });

    // Get the row.
    let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1);
    Assert.equal(
      row.result.type,
      UrlbarUtils.RESULT_TYPE.DYNAMIC,
      "row.result.type"
    );
    Assert.equal(
      row.getAttribute("dynamicType"),
      DYNAMIC_TYPE_NAME,
      "row[dynamicType]"
    );
    let inner = row.querySelector(".urlbarView-row-inner");
    Assert.ok(inner, ".urlbarView-row-inner should exist");

    // Check the DOM.
    checkDOM(inner, DYNAMIC_TYPE_VIEW_TEMPLATE.children);

    await UrlbarTestUtils.promisePopupClose(window);
  });
});

// Tests that the view is updated correctly.
async function checkViewUpdated(provider) {
  await withDynamicTypeProvider(async () => {
    // Test a few different search strings.  The dynamic result view will be
    // updated to reflect the current string.
    for (let searchString of ["test", "some other string", "and another"]) {
      // Do a search.
      await UrlbarTestUtils.promiseAutocompleteResultPopup({
        window,
        value: searchString,
        waitForFocus: SimpleTest.waitForFocus,
      });

      let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1);
      let text = row.querySelector(
        `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-text`
      );

      // The view's call to provider.getViewUpdate is async, so we need to make
      // sure the update has been applied before continuing to avoid
      // intermittent failures.
      await TestUtils.waitForCondition(
        () => text.getAttribute("searchString") == searchString
      );

      // The "searchString" attribute of these elements should be updated.
      let elementNames = ["selectable", "text", "button1", "button2"];
      for (let name of elementNames) {
        let element = row.querySelector(
          `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-${name}`
        );
        Assert.equal(
          element.getAttribute("searchString"),
          searchString,
          'element.getAttribute("searchString")'
        );
      }

      let button1 = row.querySelector(
        `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-button1`
      );

      Assert.equal(
        button1.hasAttribute("attribute_to_remove"),
        false,
        "Attribute should be removed"
      );

      // text.textContent should be updated.
      Assert.equal(
        text.textContent,
        `result.payload.searchString is: ${searchString}`,
        "text.textContent"
      );

      await UrlbarTestUtils.promisePopupClose(window);
    }
  }, provider);
}

add_task(async function checkViewUpdatedPlain() {
  await checkViewUpdated(new TestProvider());
});

add_task(async function checkViewUpdatedWDynamicViewTemplate() {
  /**
   * A dummy provider that provides the viewTemplate dynamically.
   */
  class TestShouldCallGetViewTemplateProvider extends TestProvider {
    getViewTemplateWasCalled = false;

    getViewTemplate() {
      this.getViewTemplateWasCalled = true;
      return DYNAMIC_TYPE_VIEW_TEMPLATE;
    }
  }

  let provider = new TestShouldCallGetViewTemplateProvider();
  Assert.ok(
    !provider.getViewTemplateWasCalled,
    "getViewTemplate has not yet been called for the provider"
  );
  Assert.ok(
    !UrlbarView.dynamicViewTemplatesByName.get(DYNAMIC_TYPE_NAME),
    "No template has been registered"
  );
  await checkViewUpdated(provider);
  Assert.ok(
    provider.getViewTemplateWasCalled,
    "getViewTemplate was called for the provider"
  );
});

// Tests that selection correctly moves through buttons and selectables in a
// dynamic result.
add_task(async function selection() {
  await withDynamicTypeProvider(async () => {
    // Add a visit so we have at least one result after the dynamic result.
    await PlacesUtils.history.clear();
    await PlacesTestUtils.addVisits("http://example.com/test");

    // Do a search.
    await UrlbarTestUtils.promiseAutocompleteResultPopup({
      window,
      value: "test",
      waitForFocus: SimpleTest.waitForFocus,
    });

    // Sanity check that the dynamic result is at index 1.
    let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1);
    Assert.equal(
      row.result.type,
      UrlbarUtils.RESULT_TYPE.DYNAMIC,
      "row.result.type"
    );

    // The heuristic result will be selected.  TAB from the heuristic through
    // all the selectable elements in the dynamic result.
    let selectables = ["selectable", "button1", "button2"];
    for (let name of selectables) {
      let element = row.querySelector(
        `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-${name}`
      );
      Assert.ok(element, "Sanity check element");
      EventUtils.synthesizeKey("KEY_Tab");
      Assert.equal(
        UrlbarTestUtils.getSelectedElement(window),
        element,
        `Selected element: ${name}`
      );
      Assert.equal(
        UrlbarTestUtils.getSelectedRowIndex(window),
        1,
        "Row at index 1 selected"
      );
      Assert.equal(UrlbarTestUtils.getSelectedRow(window), row, "Row selected");
    }

    // TAB again to select the result after the dynamic result.
    EventUtils.synthesizeKey("KEY_Tab");
    Assert.equal(
      UrlbarTestUtils.getSelectedRowIndex(window),
      2,
      "Row at index 2 selected"
    );
    Assert.notEqual(
      UrlbarTestUtils.getSelectedRow(window),
      row,
      "Row is not selected"
    );

    // SHIFT+TAB back through the dynamic result.
    for (let name of selectables.reverse()) {
      let element = row.querySelector(
        `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-${name}`
      );
      Assert.ok(element, "Sanity check element");
      EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true });
      Assert.equal(
        UrlbarTestUtils.getSelectedElement(window),
        element,
        `Selected element: ${name}`
      );
      Assert.equal(
        UrlbarTestUtils.getSelectedRowIndex(window),
        1,
        "Row at index 1 selected"
      );
      Assert.equal(UrlbarTestUtils.getSelectedRow(window), row, "Row selected");
    }

    // SHIFT+TAB again to select the heuristic result.
    EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true });
    Assert.equal(
      UrlbarTestUtils.getSelectedRowIndex(window),
      0,
      "Row at index 0 selected"
    );
    Assert.notEqual(
      UrlbarTestUtils.getSelectedRow(window),
      row,
      "Row is not selected"
    );

    await UrlbarTestUtils.promisePopupClose(window);
    await PlacesUtils.history.clear();
  });
});

// Tests picking elements in a dynamic result.
add_task(async function pick() {
  await withDynamicTypeProvider(async provider => {
    let selectables = ["selectable", "button1", "button2"];
    for (let i = 0; i < selectables.length; i++) {
      let selectable = selectables[i];

      // Do a search.
      await UrlbarTestUtils.promiseAutocompleteResultPopup({
        window,
        value: "test",
        waitForFocus: SimpleTest.waitForFocus,
      });

      // Sanity check that the dynamic result is at index 1.
      let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1);
      Assert.equal(
        row.result.type,
        UrlbarUtils.RESULT_TYPE.DYNAMIC,
        "row.result.type"
      );

      // The heuristic result will be selected.  TAB from the heuristic
      // to the selectable element.
      let element = row.querySelector(
        `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-${selectable}`
      );
      Assert.ok(element, "Sanity check element");
      EventUtils.synthesizeKey("KEY_Tab", { repeat: i + 1 });
      Assert.equal(
        UrlbarTestUtils.getSelectedElement(window),
        element,
        `Selected element: ${name}`
      );

      // Pick the element.
      let pickPromise = provider.promisePick();
      await UrlbarTestUtils.promisePopupClose(window, () =>
        EventUtils.synthesizeKey("KEY_Enter")
      );
      let [result, pickedElement] = await pickPromise;
      Assert.equal(result, row.result, "Picked result");
      Assert.equal(pickedElement, element, "Picked element");
    }
  });
});

// Tests picking elements in a dynamic result.
add_task(async function shouldNavigate() {
  /**
   * A dummy provider that providers results with a `shouldNavigate` property.
   */
  class TestShouldNavigateProvider extends TestProvider {
    /**
     * @param {object} context - Data regarding the context of the query.
     * @param {Function} addCallback - Function to add a result to the query.
     */
    async startQuery(context, addCallback) {
      for (let result of this._results) {
        result.payload.searchString = context.searchString;
        result.payload.shouldNavigate = true;
        result.payload.url = DUMMY_PAGE;
        addCallback(this, result);
      }
    }
  }

  await withDynamicTypeProvider(async provider => {
    // Do a search.
    await UrlbarTestUtils.promiseAutocompleteResultPopup({
      window,
      value: "test",
      waitForFocus: SimpleTest.waitForFocus,
    });

    // Sanity check that the dynamic result is at index 1.
    let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1);
    Assert.equal(
      row.result.type,
      UrlbarUtils.RESULT_TYPE.DYNAMIC,
      "row.result.type"
    );

    // The heuristic result will be selected.  TAB from the heuristic
    // to the selectable element.
    let element = row.querySelector(
      `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-selectable`
    );
    Assert.ok(element, "Sanity check element");
    EventUtils.synthesizeKey("KEY_Tab", { repeat: 1 });
    Assert.equal(
      UrlbarTestUtils.getSelectedElement(window),
      element,
      `Selected element: ${name}`
    );

    // Pick the element.
    let pickPromise = provider.promisePick();
    await UrlbarTestUtils.promisePopupClose(window, () =>
      EventUtils.synthesizeKey("KEY_Enter")
    );
    // Verify that onEngagement was still called.
    let [result, pickedElement] = await pickPromise;
    Assert.equal(result, row.result, "Picked result");
    Assert.equal(pickedElement, element, "Picked element");

    await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
    is(
      gBrowser.currentURI.spec,
      DUMMY_PAGE,
      "We navigated to payload.url when result selected"
    );

    BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:home");
    await BrowserTestUtils.browserLoaded(
      gBrowser.selectedBrowser,
      false,
      "about:home"
    );

    await UrlbarTestUtils.promiseAutocompleteResultPopup({
      window,
      value: "test",
      waitForFocus: SimpleTest.waitForFocus,
    });

    row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1);
    element = row.querySelector(
      `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-selectable`
    );

    pickPromise = provider.promisePick();
    EventUtils.synthesizeMouseAtCenter(element, {});
    [result, pickedElement] = await pickPromise;
    Assert.equal(result, row.result, "Picked result");
    Assert.equal(pickedElement, element, "Picked element");

    await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
    is(
      gBrowser.currentURI.spec,
      DUMMY_PAGE,
      "We navigated to payload.url when result is clicked"
    );
  }, new TestShouldNavigateProvider());
});

// Tests applying highlighting to a dynamic result.
add_task(async function highlighting() {
  /**
   * Provides a dynamic result with highlighted text.
   */
  class TestHighlightProvider extends TestProvider {
    startQuery(context, addCallback) {
      let result = Object.assign(
        new UrlbarResult(
          UrlbarUtils.RESULT_TYPE.DYNAMIC,
          UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL,
          ...UrlbarResult.payloadAndSimpleHighlights(context.tokens, {
            dynamicType: DYNAMIC_TYPE_NAME,
            text: ["Test title", UrlbarUtils.HIGHLIGHT.SUGGESTED],
          })
        ),
        { suggestedIndex: 1 }
      );
      addCallback(this, result);
    }

    getViewUpdate(result, idsByName) {
      return {};
    }
  }

  // Test that highlighting is applied.
  await withDynamicTypeProvider(async () => {
    await UrlbarTestUtils.promiseAutocompleteResultPopup({
      window,
      value: "test",
      waitForFocus: SimpleTest.waitForFocus,
    });

    let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1);
    Assert.equal(
      row.result.type,
      UrlbarUtils.RESULT_TYPE.DYNAMIC,
      "row.result.type"
    );
    let parentTextNode = row.querySelector(
      `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-text`
    );
    let highlightedTextNode = row.querySelector(
      `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-text > strong`
    );
    Assert.equal(parentTextNode.firstChild.textContent, "Test");
    Assert.equal(
      highlightedTextNode.textContent,
      " title",
      "The highlighting was applied successfully."
    );
  }, new TestHighlightProvider());

  /**
   * Provides a dynamic result with highlighted text that is then overridden.
   */
  class TestHighlightProviderOveridden extends TestHighlightProvider {
    getViewUpdate(result, idsByName) {
      return {
        text: {
          textContent: "Test title",
        },
      };
    }
  }

  // Test that highlighting is not applied when overridden from getViewUpdate.
  await withDynamicTypeProvider(async () => {
    // Do a search.
    await UrlbarTestUtils.promiseAutocompleteResultPopup({
      window,
      value: "test",
      waitForFocus: SimpleTest.waitForFocus,
    });

    let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1);
    Assert.equal(
      row.result.type,
      UrlbarUtils.RESULT_TYPE.DYNAMIC,
      "row.result.type"
    );
    let parentTextNode = row.querySelector(
      `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-text`
    );
    let highlightedTextNode = row.querySelector(
      `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-text > strong`
    );
    Assert.equal(
      parentTextNode.firstChild.textContent,
      "Test title",
      "No highlighting was applied"
    );
    Assert.ok(!highlightedTextNode, "The <strong> child node was deleted.");
  }, new TestHighlightProviderOveridden());
});

/**
 * Provides a dynamic result.
 */
class TestProvider extends UrlbarTestUtils.TestProvider {
  constructor() {
    super({
      results: [
        Object.assign(
          new UrlbarResult(
            UrlbarUtils.RESULT_TYPE.DYNAMIC,
            UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL,
            {
              dynamicType: DYNAMIC_TYPE_NAME,
            }
          ),
          { suggestedIndex: 1 }
        ),
      ],
    });
  }

  async startQuery(context, addCallback) {
    for (let result of this._results) {
      result.payload.searchString = context.searchString;
      addCallback(this, result);
    }
  }

  getViewUpdate(result, idsByName) {
    for (let child of DYNAMIC_TYPE_VIEW_TEMPLATE.children) {
      Assert.ok(idsByName.get(child.name), `idsByName contains ${child.name}`);
    }

    return {
      selectable: {
        textContent: "Selectable",
        attributes: {
          searchString: result.payload.searchString,
        },
      },
      text: {
        textContent: `result.payload.searchString is: ${result.payload.searchString}`,
        attributes: {
          searchString: result.payload.searchString,
        },
      },
      button1: {
        textContent: "Button 1",
        attributes: {
          searchString: result.payload.searchString,
          attribute_to_remove: null,
        },
      },
      button2: {
        textContent: "Button 2",
        attributes: {
          searchString: result.payload.searchString,
        },
      },
    };
  }

  onEngagement(isPrivate, state, queryContext, details) {
    if (this._pickPromiseResolve) {
      let { result, element } = details;
      this._pickPromiseResolve([result, element]);
      delete this._pickPromiseResolve;
      delete this._pickPromise;
    }
  }

  promisePick() {
    this._pickPromise = new Promise(resolve => {
      this._pickPromiseResolve = resolve;
    });
    return this._pickPromise;
  }
}

/**
 * Provides a dynamic result.
 *
 * @param {object} callback - Function that runs the body of the test.
 * @param {object} provider - The dummy provider to use.
 */
async function withDynamicTypeProvider(
  callback,
  provider = new TestProvider()
) {
  // Add a dynamic result type.
  UrlbarResult.addDynamicResultType(DYNAMIC_TYPE_NAME);
  if (!provider.getViewTemplate) {
    UrlbarView.addDynamicViewTemplate(
      DYNAMIC_TYPE_NAME,
      DYNAMIC_TYPE_VIEW_TEMPLATE
    );
  }

  // Add a provider of the dynamic type.
  UrlbarProvidersManager.registerProvider(provider);

  await callback(provider);

  // Clean up.
  UrlbarProvidersManager.unregisterProvider(provider);
  if (!provider.getViewTemplate) {
    UrlbarView.removeDynamicViewTemplate(DYNAMIC_TYPE_NAME);
  }
  UrlbarResult.removeDynamicResultType(DYNAMIC_TYPE_NAME);
}

function checkDOM(parentNode, expectedChildren) {
  info(
    `checkDOM: Checking parentNode id=${parentNode.id} className=${parentNode.className}`
  );
  for (let i = 0; i < expectedChildren.length; i++) {
    let child = expectedChildren[i];
    let actualChild = parentNode.children[i];
    info(`checkDOM: Checking expected child: ${JSON.stringify(child)}`);
    Assert.ok(actualChild, "actualChild should exist");
    Assert.equal(actualChild.tagName, child.tag, "child.tag");
    Assert.equal(actualChild.getAttribute("name"), child.name, "child.name");
    Assert.ok(
      actualChild.classList.contains(
        `urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-${child.name}`
      ),
      "child.name should be in classList"
    );
    // We have to use startsWith/endsWith since the middle of the ID is a random
    // number.
    Assert.ok(actualChild.id.startsWith("urlbarView-row-"));
    Assert.ok(
      actualChild.id.endsWith(child.name),
      "The child was assigned the correct ID."
    );
    for (let [name, value] of Object.entries(child.attributes || {})) {
      if (name == "attribute_to_remove") {
        Assert.equal(
          actualChild.hasAttribute(name),
          false,
          `attribute: ${name}`
        );
        continue;
      }
      Assert.equal(actualChild.getAttribute(name), value, `attribute: ${name}`);
    }
    for (let name of child.classList || []) {
      Assert.ok(actualChild.classList.contains(name), `classList: ${name}`);
    }
    if (child.children) {
      checkDOM(actualChild, child.children);
    }
  }
}
