/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

/* import-globals-from ../../mochitest/text.js */
/* import-globals-from ../../mochitest/attributes.js */
loadScripts({ name: "attributes.js", dir: MOCHITESTS_DIR });

const boldAttrs = { "font-weight": "700" };
const highlightAttrs = { mark: "true" };
const fragmentAttrs = highlightAttrs;
const spellingAttrs = { invalid: "spelling" };
const grammarAttrs = { invalid: "grammar" };
const snippet = `
<p id="first">The first phrase.</p>
<p id="second">The <i>second <b>phrase.</b></i></p>
`;

/**
 * Returns a promise that resolves once the attribute ranges match. If
 * shouldWaitForEvent is true, we first wait for a text attribute change event.
 */
async function waitForTextAttrRanges(
  acc,
  ranges,
  attrs,
  shouldWaitForEvent = true
) {
  if (shouldWaitForEvent) {
    await waitForEvent(EVENT_TEXT_ATTRIBUTE_CHANGED);
  }
  await untilCacheOk(
    () => textAttrRangesMatch(acc, ranges, attrs),
    `Attr ranges match: ${JSON.stringify(ranges)}`
  );
}

/**
 * Test a text fragment within a single node.
 */
addAccessibleTask(
  snippet,
  async function testTextFragmentSingleNode(browser, docAcc) {
    const first = findAccessibleChildByID(docAcc, "first");
    ok(
      textAttrRangesMatch(
        first,
        [
          [4, 16], // "first phrase"
        ],
        fragmentAttrs
      ),
      "first attr ranges correct"
    );
    const second = findAccessibleChildByID(docAcc, "second");
    ok(
      textAttrRangesMatch(second, [], fragmentAttrs),
      "second attr ranges correct"
    );
  },
  { chrome: true, topLevel: true, urlSuffix: "#:~:text=first%20phrase" }
);

/**
 * Test a text fragment crossing nodes.
 */
addAccessibleTask(
  snippet,
  async function testTextFragmentCrossNode(browser, docAcc) {
    const first = findAccessibleChildByID(docAcc, "first");
    ok(
      textAttrRangesMatch(first, [], fragmentAttrs),
      "first attr ranges correct"
    );
    const second = findAccessibleChildByID(docAcc, "second");
    ok(
      textAttrRangesMatch(
        second,
        [
          // This run is split because of the bolded word.
          [4, 11], // "second "
          [11, 17], // "phrase"
        ],
        fragmentAttrs
      ),
      "second attr ranges correct"
    );
    // Ensure bold is still exposed in the presence of a fragment.
    testTextAttrs(
      second,
      11,
      { ...fragmentAttrs, ...boldAttrs },
      {},
      11,
      17,
      true
    ); // "phrase"
    testTextAttrs(second, 17, boldAttrs, {}, 17, 18, true); // "."
  },
  { chrome: true, topLevel: true, urlSuffix: "#:~:text=second%20phrase" }
);

/**
 * Test scrolling to a text fragment on the same page. This also tests that the
 * scrolling start event is fired.
 */
add_task(async function testTextFragmentSamePage() {
  // We use add_task here because we need to verify that an
  // event is fired, but it might be fired before document load complete, so we
  // could miss it if we used addAccessibleTask.
  const docUrl = snippetToURL(snippet);
  const initialUrl = docUrl + "#:~:text=first%20phrase";
  let scrolled = waitForEvent(
    EVENT_SCROLLING_START,
    event =>
      event.accessible.role == ROLE_TEXT_LEAF &&
      getAccessibleDOMNodeID(event.accessible.parent) == "first"
  );
  await BrowserTestUtils.withNewTab(initialUrl, async function (browser) {
    info("Waiting for scroll to first");
    const first = (await scrolled).accessible.parent;
    info("Checking ranges");
    await waitForTextAttrRanges(
      first,
      [
        [4, 16], // "first phrase"
      ],
      fragmentAttrs,
      false
    );
    const second = first.nextSibling;
    await waitForTextAttrRanges(second, [], fragmentAttrs, false);

    info("Navigating to second");
    // The text fragment begins with the text "second", which is the second
    // child of the `second` Accessible.
    scrolled = waitForEvent(EVENT_SCROLLING_START, second.getChildAt(1));
    let rangeCheck = waitForTextAttrRanges(
      second,
      [
        [4, 11], // "second "
        [11, 17], // "phrase"
      ],
      fragmentAttrs,
      true
    );
    await invokeContentTask(browser, [], () => {
      content.location.hash = "#:~:text=second%20phrase";
    });
    await scrolled;
    info("Checking ranges");
    await rangeCheck;
    // XXX DOM should probably remove the highlight from "first phrase" since
    // we've navigated to "second phrase". For now, this test expects the
    // current DOM behaviour: "first" is still highlighted.
    await waitForTextAttrRanges(
      first,
      [
        [4, 16], // "first phrase"
      ],
      fragmentAttrs,
      false
    );
  });
});

/**
 * Test custom highlight mutations.
 */
addAccessibleTask(
  snippet,
  async function testCustomHighlightMutations(browser, docAcc) {
    info("Checking initial highlight");
    const first = findAccessibleChildByID(docAcc, "first");
    ok(
      textAttrRangesMatch(
        first,
        [
          [4, 9], // "first"
        ],
        highlightAttrs
      ),
      "first attr ranges correct"
    );
    const second = findAccessibleChildByID(docAcc, "second");
    ok(
      textAttrRangesMatch(second, [], highlightAttrs),
      "second attr ranges correct"
    );

    info("Adding range2 to highlight1");
    let rangeCheck = waitForTextAttrRanges(
      first,
      [
        [0, 3], // "The "
        [4, 9], // "first"
      ],
      highlightAttrs,
      true
    );
    await invokeContentTask(browser, [], () => {
      content.firstText = content.document.getElementById("first").firstChild;
      // Highlight the word "The".
      content.range2 = new content.Range();
      content.range2.setStart(content.firstText, 0);
      content.range2.setEnd(content.firstText, 3);
      content.highlight1 = content.CSS.highlights.get("highlight1");
      content.highlight1.add(content.range2);
    });
    await rangeCheck;

    info("Adding highlight2");
    rangeCheck = waitForTextAttrRanges(
      first,
      [
        [0, 3], // "The "
        [4, 9], // "first"
        [10, 16], // "phrase"
      ],
      highlightAttrs,
      true
    );
    await invokeContentTask(browser, [], () => {
      // Highlight the word "phrase".
      const range3 = new content.Range();
      range3.setStart(content.firstText, 10);
      range3.setEnd(content.firstText, 16);
      const highlight2 = new content.Highlight(range3);
      content.CSS.highlights.set("highlight2", highlight2);
    });
    await rangeCheck;

    info("Removing range2");
    rangeCheck = waitForTextAttrRanges(
      first,
      [
        [4, 9], // "first"
        [10, 16], // "phrase"
      ],
      highlightAttrs,
      true
    );
    await invokeContentTask(browser, [], () => {
      content.highlight1.delete(content.range2);
    });
    await rangeCheck;

    info("Removing highlight1");
    rangeCheck = waitForTextAttrRanges(
      first,
      [
        [10, 16], // "phrase"
      ],
      highlightAttrs,
      true
    );
    await invokeContentTask(browser, [], () => {
      content.CSS.highlights.delete("highlight1");
    });
    await rangeCheck;
  },
  {
    chrome: true,
    topLevel: true,
    contentSetup: async function contentSetup() {
      const firstText = content.document.getElementById("first").firstChild;
      // Highlight the word "first".
      const range1 = new content.Range();
      range1.setStart(firstText, 4);
      range1.setEnd(firstText, 9);
      const highlight1 = new content.Highlight(range1);
      content.CSS.highlights.set("highlight1", highlight1);
    },
  }
);

/**
 * Test custom highlight types.
 */
addAccessibleTask(
  snippet,
  async function testCustomHighlightTypes(browser, docAcc) {
    const first = findAccessibleChildByID(docAcc, "first");
    ok(
      textAttrRangesMatch(
        first,
        [
          [0, 3], // "the"
        ],
        highlightAttrs
      ),
      "first highlight ranges correct"
    );
    ok(
      textAttrRangesMatch(
        first,
        [
          [4, 9], // "first"
        ],
        spellingAttrs
      ),
      "first spelling ranges correct"
    );
    ok(
      textAttrRangesMatch(
        first,
        [
          [10, 16], // "phrase"
        ],
        grammarAttrs
      ),
      "first grammar ranges correct"
    );
    const second = findAccessibleChildByID(docAcc, "second");
    ok(
      textAttrRangesMatch(second, [], highlightAttrs),
      "second highlight ranges correct"
    );
  },
  {
    chrome: true,
    topLevel: true,
    contentSetup: async function contentSetup() {
      const firstText = content.document.getElementById("first").firstChild;
      // Highlight the word "The".
      const range1 = new content.Range();
      range1.setStart(firstText, 0);
      range1.setEnd(firstText, 3);
      const highlight = new content.Highlight(range1);
      content.CSS.highlights.set("highlight", highlight);

      // Make the word "first" a spelling error.
      const range2 = new content.Range();
      range2.setStart(firstText, 4);
      range2.setEnd(firstText, 9);
      const spelling = new content.Highlight(range2);
      spelling.type = "spelling-error";
      content.CSS.highlights.set("spelling", spelling);

      // Make the word "phrase" a grammar error.
      const range3 = new content.Range();
      range3.setStart(firstText, 10);
      range3.setEnd(firstText, 16);
      const grammar = new content.Highlight(range3);
      grammar.type = "grammar-error";
      content.CSS.highlights.set("grammar", grammar);
    },
  }
);

/**
 * Test overlapping custom highlights.
 */
addAccessibleTask(
  snippet,
  async function testCustomHighlightOverlapping(browser, docAcc) {
    const first = findAccessibleChildByID(docAcc, "first");
    ok(
      textAttrRangesMatch(
        first,
        [
          [0, 3], // "the"
          [4, 6], // "fi"
          [6, 7], // "r"
          [7, 9], // "st"
          [10, 12], // "ph"
          [12, 15], // "ras"
          [15, 16], // "e"
        ],
        highlightAttrs
      ),
      "first highlight ranges correct"
    );
    ok(
      textAttrRangesMatch(
        first,
        [
          [0, 3], // "the"
          [4, 6], // "fi"
          [6, 7], // "r"
          [7, 9], // "st"
          [12, 15], // "ras"
        ],
        spellingAttrs
      ),
      "first spelling ranges correct"
    );
    const second = findAccessibleChildByID(docAcc, "second");
    ok(
      textAttrRangesMatch(
        second,
        [
          [4, 7], // "sec"
          [7, 8], // "o"
          [8, 10], // "nd"
          [11, 13], // "ph"
          [13, 16], // "ras"
          [16, 17], // "e"
        ],
        highlightAttrs
      ),
      "second highlight ranges correct"
    );
    ok(
      textAttrRangesMatch(
        second,
        [
          [4, 7], // "sec"
          [8, 10], // "nd"
        ],
        spellingAttrs
      ),
      "second spelling ranges correct"
    );
  },
  {
    chrome: true,
    topLevel: true,
    contentSetup: async function contentSetup() {
      const firstText = content.document.getElementById("first").firstChild;
      // Make the word "The" both a highlight and a spelling error.
      const range1 = new content.Range();
      range1.setStart(firstText, 0);
      range1.setEnd(firstText, 3);
      const highlight1 = new content.Highlight(range1);
      content.CSS.highlights.set("highlight1", highlight1);
      const spelling = new content.Highlight(range1);
      spelling.type = "spelling-error";
      content.CSS.highlights.set("spelling", spelling);

      // Highlight the word "first".
      const range2 = new content.Range();
      range2.setStart(firstText, 4);
      range2.setEnd(firstText, 9);
      highlight1.add(range2);
      // Make "fir" a spelling error.
      const range3 = new content.Range();
      range3.setStart(firstText, 4);
      range3.setEnd(firstText, 7);
      spelling.add(range3);
      // Make "rst" a spelling error.
      const range4 = new content.Range();
      range4.setStart(firstText, 6);
      range4.setEnd(firstText, 9);
      spelling.add(range4);

      // Highlight the word "phrase".
      const range5 = new content.Range();
      range5.setStart(firstText, 10);
      range5.setEnd(firstText, 16);
      highlight1.add(range5);
      // Make "ras" a spelling error.
      const range6 = new content.Range();
      range6.setStart(firstText, 12);
      range6.setEnd(firstText, 15);
      spelling.add(range6);

      const secondText = content.document.querySelector("#second i").firstChild;
      // Highlight the word "second".
      const range7 = new content.Range();
      range7.setStart(secondText, 0);
      range7.setEnd(secondText, 6);
      highlight1.add(range7);
      // Make "sec" a spelling error.
      const range8 = new content.Range();
      range8.setStart(secondText, 0);
      range8.setEnd(secondText, 3);
      spelling.add(range8);
      // Make "nd" a spelling error.
      const range9 = new content.Range();
      range9.setStart(secondText, 4);
      range9.setEnd(secondText, 6);
      spelling.add(range9);

      const phrase2Text =
        content.document.querySelector("#second b").firstChild;
      // Highlight the word "phrase".
      const range10 = new content.Range();
      range10.setStart(phrase2Text, 0);
      range10.setEnd(phrase2Text, 6);
      highlight1.add(range10);
      // Highlight "ras" using a different Highlight.
      const range11 = new content.Range();
      range11.setStart(phrase2Text, 2);
      range11.setEnd(phrase2Text, 5);
      const highlight2 = new content.Highlight(range11);
      content.CSS.highlights.set("highlight2", highlight2);
    },
  }
);
