/**
 * @license
 * Copyright 2020 Google Inc.
 * SPDX-License-Identifier: Apache-2.0
 */

import assert from 'assert';

import expect from 'expect';
import {TimeoutError} from 'puppeteer';
import type {ElementHandle} from 'puppeteer-core/internal/api/ElementHandle.js';

import {getTestState, setupTestBrowserHooks} from './mocha-utils.js';
import {attachFrame, detachFrame} from './utils.js';

describe('AriaQueryHandler', () => {
  setupTestBrowserHooks();

  describe('parseAriaSelector', () => {
    it('should handle non-breaking spaces', async () => {
      const {page} = await getTestState();
      await page.setContent(
        '<button id="btn" role="button"><span>&nbsp;</span><span>&nbsp;</span>Submit button and some spaces</button>',
      );
      const expectFound = async (button: ElementHandle | null) => {
        assert(button);
        const id = await button.evaluate(button => {
          return button.id;
        });
        expect(id).toBe('btn');
      };
      {
        using button = await page.$(
          'aria/\u00A0\u00A0Submit button and some spaces',
        );
        await expectFound(button);
      }
      {
        using button = await page.$('aria/Submit button and some spaces');
        expect(button).toBe(null);
      }
    });
    it('should handle non-breaking spaces', async () => {
      const {page} = await getTestState();
      await page.setContent(
        '<button id="btn" role="button">  Submit button and some spaces</button>',
      );
      const expectFound = async (button: ElementHandle | null) => {
        assert(button);
        const id = await button.evaluate(button => {
          return button.id;
        });
        expect(id).toBe('btn');
      };
      {
        using button = await page.$('aria/ubmit button and some spaces');
        expect(button).toBe(null);
      }
      {
        using button = await page.$('aria/Submit button and some spaces');
        await expectFound(button);
      }
    });
    it('should handle zero width spaces', async () => {
      const {page} = await getTestState();
      await page.setContent(
        '<button id="btn" role="button"><span>&ZeroWidthSpace;</span><span>&ZeroWidthSpace;</span>Submit button and some spaces</button>',
      );
      const expectFound = async (button: ElementHandle | null) => {
        assert(button);
        const id = await button.evaluate(button => {
          return button.id;
        });
        expect(id).toBe('btn');
      };
      {
        using button = await page.$(
          'aria/\u200B\u200BSubmit button and some spaces',
        );
        await expectFound(button);
      }
      {
        using button = await page.$('aria/Submit button and some spaces');
        expect(button).toBe(null);
      }
    });
    it('should find button', async () => {
      const {page} = await getTestState();
      await page.setContent(
        '<button id="btn" role="button"> Submit  button   and some spaces  </button>',
      );
      const expectFound = async (button: ElementHandle | null) => {
        assert(button);
        const id = await button.evaluate(button => {
          return button.id;
        });
        expect(id).toBe('btn');
      };
      {
        using button = await page.$(
          'aria/Submit button and some spaces[role="button"]',
        );
        await expectFound(button);
      }
      {
        using button = await page.$(
          "aria/Submit button and some spaces[role='button']",
        );
        await expectFound(button);
      }
      {
        using button = await page.$(
          'aria/  Submit button and some spaces[role="button"]',
        );
        expect(button).toBe(null);
      }
      {
        using button = await page.$(
          'aria/Submit button and some spaces  [role="button"]',
        );
        expect(button).toBe(null);
      }
      {
        using button = await page.$(
          'aria/Submit  button   and  some  spaces   [  role  =  "button" ] ',
        );
        expect(button).toBe(null);
      }
      {
        using button = await page.$(
          'aria/[role="button"]Submit button and some spaces',
        );
        await expectFound(button);
      }
      {
        using button = await page.$(
          'aria/Submit button [role="button"]and some spaces',
        );
        await expectFound(button);
      }
      {
        using button = await page.$(
          'aria/[name="  Submit  button and some  spaces"][role="button"]',
        );
        expect(button).toBe(null);
      }
      {
        using button = await page.$(
          "aria/[name='  Submit  button and some  spaces'][role='button']",
        );
        expect(button).toBe(null);
      }
      {
        using button = await page.$(
          'aria/ignored[name="Submit button and some spaces"][role="button"]',
        );
        await expectFound(button);
        await expect(page.$('aria/smth[smth="true"]')).rejects.toThrow(
          'Unknown aria attribute "smth" in selector',
        );
      }
    });
  });

  describe('queryOne', () => {
    it('should find button by role', async () => {
      const {page} = await getTestState();
      await page.setContent(
        '<div id="div"><button id="btn" role="button">Submit</button></div>',
      );
      using button = (await page.$(
        'aria/[role="button"]',
      )) as ElementHandle<HTMLButtonElement>;
      const id = await button!.evaluate(button => {
        return button.id;
      });
      expect(id).toBe('btn');
    });

    it('should find button by name and role', async () => {
      const {page} = await getTestState();
      await page.setContent(
        '<div id="div"><button id="btn" role="button">Submit</button></div>',
      );
      using button = (await page.$(
        'aria/Submit[role="button"]',
      )) as ElementHandle<HTMLButtonElement>;
      const id = await button!.evaluate(button => {
        return button.id;
      });
      expect(id).toBe('btn');
    });

    it('should find first matching element', async () => {
      const {page} = await getTestState();
      await page.setContent(
        `
        <div role="menu" id="mnu1" aria-label="menu div"></div>
        <div role="menu" id="mnu2" aria-label="menu div"></div>
        `,
      );
      using div = (await page.$(
        'aria/menu div',
      )) as ElementHandle<HTMLDivElement>;
      const id = await div!.evaluate(div => {
        return div.id;
      });
      expect(id).toBe('mnu1');
    });

    it('should find by name', async () => {
      const {page} = await getTestState();
      await page.setContent(
        `
        <div role="menu" id="mnu1" aria-label="menu-label1">menu div</div>
        <div role="menu" id="mnu2" aria-label="menu-label2">menu div</div>
        `,
      );
      using menu = (await page.$(
        'aria/menu-label1',
      )) as ElementHandle<HTMLDivElement>;
      const id = await menu!.evaluate(div => {
        return div.id;
      });
      expect(id).toBe('mnu1');
    });

    it('should find 2nd element by name', async () => {
      const {page} = await getTestState();
      await page.setContent(
        `
        <div role="menu" id="mnu1" aria-label="menu-label1">menu div</div>
        <div role="menu" id="mnu2" aria-label="menu-label2">menu div</div>
        `,
      );
      using menu = (await page.$(
        'aria/menu-label2',
      )) as ElementHandle<HTMLDivElement>;
      const id = await menu!.evaluate(div => {
        return div.id;
      });
      expect(id).toBe('mnu2');
    });
  });

  describe('queryAll', () => {
    it('should find menu by name', async () => {
      const {page} = await getTestState();
      await page.setContent(
        `
        <div role="menu" id="mnu1" aria-label="menu div"></div>
        <div role="menu" id="mnu2" aria-label="menu div"></div>
        `,
      );
      const divs = (await page.$$('aria/menu div')) as Array<
        ElementHandle<HTMLDivElement>
      >;
      const ids = await Promise.all(
        divs.map(n => {
          return n.evaluate(div => {
            return div.id;
          });
        }),
      );
      expect(ids.join(', ')).toBe('mnu1, mnu2');
    });
  });
  describe('queryAllArray', () => {
    it('$$eval should handle many elements', async function () {
      this.timeout(40_000);

      const {page} = await getTestState();
      await page.setContent('');
      await page.evaluate(
        `
        for (var i = 0; i <= 10000; i++) {
            const button = document.createElement('button');
            button.textContent = i;
            document.body.appendChild(button);
        }
        `,
      );
      const sum = await page.$$eval('aria/[role="button"]', buttons => {
        return buttons.reduce((acc, button) => {
          return acc + Number(button.textContent);
        }, 0);
      });
      expect(sum).toBe(50005000);
    });
  });

  describe('waitForSelector (aria)', function () {
    const addElement = (tag: string) => {
      return document.body.appendChild(document.createElement(tag));
    };

    it('should immediately resolve promise if node exists', async () => {
      const {page, server} = await getTestState();
      await page.goto(server.EMPTY_PAGE);
      await page.evaluate(addElement, 'button');
      await page.waitForSelector('aria/[role="button"]');
    });

    it('should work for ElementHandle.waitForSelector', async () => {
      const {page, server} = await getTestState();
      await page.goto(server.EMPTY_PAGE);
      await page.evaluate(() => {
        return (document.body.innerHTML = `<div><button>test</button></div>`);
      });
      using element = (await page.$('div'))!;
      await element!.waitForSelector('aria/test');
    });

    it('should persist query handler bindings across reloads', async () => {
      const {page, server} = await getTestState();
      await page.goto(server.EMPTY_PAGE);
      await page.evaluate(addElement, 'button');
      await page.waitForSelector('aria/[role="button"]');
      await page.reload();
      await page.evaluate(addElement, 'button');
      await page.waitForSelector('aria/[role="button"]');
    });

    it('should persist query handler bindings across navigations', async () => {
      const {page, server} = await getTestState();

      // Reset page but make sure that execution context ids start with 1.
      await page.goto('data:text/html,');
      await page.goto(server.EMPTY_PAGE);
      await page.evaluate(addElement, 'button');
      await page.waitForSelector('aria/[role="button"]');

      // Reset page but again make sure that execution context ids start with 1.
      await page.goto('data:text/html,');
      await page.goto(server.EMPTY_PAGE);
      await page.evaluate(addElement, 'button');
      await page.waitForSelector('aria/[role="button"]');
    });

    it('should work independently of `exposeFunction`', async () => {
      const {page, server} = await getTestState();
      await page.goto(server.EMPTY_PAGE);
      await page.exposeFunction('ariaQuerySelector', (a: number, b: number) => {
        return a + b;
      });
      await page.evaluate(addElement, 'button');
      await page.waitForSelector('aria/[role="button"]');
      const result = await page.evaluate('globalThis.ariaQuerySelector(2,8)');
      expect(result).toBe(10);
    });

    it('should work with removed MutationObserver', async () => {
      const {page} = await getTestState();

      await page.evaluate(() => {
        // @ts-expect-error This is the point of the test.
        return delete window.MutationObserver;
      });
      const [handle] = await Promise.all([
        page.waitForSelector('aria/anything'),
        page.setContent(`<h1>anything</h1>`),
      ]);
      assert(handle);
      expect(
        await page.evaluate(x => {
          return x.textContent;
        }, handle),
      ).toBe('anything');
    });

    it('should resolve promise when node is added', async () => {
      const {page, server} = await getTestState();

      await page.goto(server.EMPTY_PAGE);
      const frame = page.mainFrame();
      const watchdog = frame.waitForSelector('aria/[role="heading"]');
      await frame.evaluate(addElement, 'br');
      await frame.evaluate(addElement, 'h1');
      using elementHandle = (await watchdog)!;
      const tagName = await (
        await elementHandle.getProperty('tagName')
      ).jsonValue();
      expect(tagName).toBe('H1');
    });

    it('should work when node is added through innerHTML', async () => {
      const {page, server} = await getTestState();

      await page.goto(server.EMPTY_PAGE);
      const watchdog = page.waitForSelector('aria/name');
      await page.evaluate(addElement, 'span');
      await page.evaluate(() => {
        return (document.querySelector('span')!.innerHTML =
          '<h3><div aria-label="name"></div></h3>');
      });
      await watchdog;
    });

    it('Page.waitForSelector is shortcut for main frame', async () => {
      const {page, server} = await getTestState();

      await page.goto(server.EMPTY_PAGE);
      await attachFrame(page, 'frame1', server.EMPTY_PAGE);
      const otherFrame = page.frames()[1];
      const watchdog = page.waitForSelector('aria/[role="button"]');
      await otherFrame!.evaluate(addElement, 'button');
      await page.evaluate(addElement, 'button');
      using elementHandle = await watchdog;
      expect(elementHandle!.frame).toBe(page.mainFrame());
    });

    it('should run in specified frame', async () => {
      const {page, server} = await getTestState();

      await attachFrame(page, 'frame1', server.EMPTY_PAGE);
      await attachFrame(page, 'frame2', server.EMPTY_PAGE);
      const frame1 = page.frames()[1];
      const frame2 = page.frames()[2];
      const waitForSelectorPromise = frame2!.waitForSelector(
        'aria/[role="button"]',
      );
      await frame1!.evaluate(addElement, 'button');
      await frame2!.evaluate(addElement, 'button');
      using elementHandle = await waitForSelectorPromise;
      expect(elementHandle!.frame).toBe(frame2);
    });

    it('should throw when frame is detached', async () => {
      const {page, server} = await getTestState();

      await attachFrame(page, 'frame1', server.EMPTY_PAGE);
      const frame = page.frames()[1];
      let waitError!: Error;
      const waitPromise = frame!
        .waitForSelector('aria/does-not-exist')
        .catch(error => {
          return (waitError = error);
        });
      await detachFrame(page, 'frame1');
      await waitPromise;
      expect(waitError).toBeTruthy();
      expect(waitError.message).atLeastOneToContain([
        'waitForFunction failed: frame got detached.',
        'Browsing context already closed.',
      ]);
    });

    it('should survive cross-process navigation', async () => {
      const {page, server} = await getTestState();

      let imgFound = false;
      const waitForSelector = page
        .waitForSelector('aria/[role="image"]')
        .then(() => {
          return (imgFound = true);
        });
      await page.goto(server.EMPTY_PAGE);
      expect(imgFound).toBe(false);
      await page.reload();
      expect(imgFound).toBe(false);
      await page.goto(server.CROSS_PROCESS_PREFIX + '/grid.html');
      await waitForSelector;
      expect(imgFound).toBe(true);
    });

    it('should wait for visible', async () => {
      const {page} = await getTestState();

      let divFound = false;
      const waitForSelector = page
        .waitForSelector('aria/name', {visible: true})
        .then(() => {
          return (divFound = true);
        });
      await page.setContent(
        `<div aria-label='name' style='display: none; visibility: hidden;'>1</div>`,
      );
      expect(divFound).toBe(false);
      await page.evaluate(() => {
        return document.querySelector('div')!.style.removeProperty('display');
      });
      expect(divFound).toBe(false);
      await page.evaluate(() => {
        return document
          .querySelector('div')!
          .style.removeProperty('visibility');
      });
      expect(await waitForSelector).toBe(true);
      expect(divFound).toBe(true);
    });

    it('should wait for visible recursively', async () => {
      const {page} = await getTestState();

      let divVisible = false;
      const waitForSelector = page
        .waitForSelector('aria/inner', {visible: true})
        .then(() => {
          return (divVisible = true);
        })
        .catch(() => {
          return (divVisible = false);
        });
      await page.setContent(
        `<div style='display: none; visibility: hidden;'><div aria-label="inner">hi</div></div>`,
      );
      expect(divVisible).toBe(false);
      await page.evaluate(() => {
        return document.querySelector('div')!.style.removeProperty('display');
      });
      expect(divVisible).toBe(false);
      await page.evaluate(() => {
        return document
          .querySelector('div')!
          .style.removeProperty('visibility');
      });
      expect(await waitForSelector).toBe(true);
      expect(divVisible).toBe(true);
    });

    it('hidden should wait for visibility: hidden', async () => {
      const {page} = await getTestState();

      let divHidden = false;
      await page.setContent(
        `<div role='button' style='display: block;'>text</div>`,
      );
      const waitForSelector = page
        .waitForSelector('aria/[role="button"]', {hidden: true})
        .then(() => {
          return (divHidden = true);
        })
        .catch(() => {
          return (divHidden = false);
        });
      await page.waitForSelector('aria/[role="button"]'); // do a round trip
      expect(divHidden).toBe(false);
      await page.evaluate(() => {
        return document
          .querySelector('div')!
          .style.setProperty('visibility', 'hidden');
      });
      expect(await waitForSelector).toBe(true);
      expect(divHidden).toBe(true);
    });

    it('hidden should wait for display: none', async () => {
      const {page} = await getTestState();

      let divHidden = false;
      await page.setContent(
        `<div role='main' style='display: block;'>text</div>`,
      );
      const waitForSelector = page
        .waitForSelector('aria/[role="main"]', {hidden: true})
        .then(() => {
          return (divHidden = true);
        })
        .catch(() => {
          return (divHidden = false);
        });
      await page.waitForSelector('aria/[role="main"]'); // do a round trip
      expect(divHidden).toBe(false);
      await page.evaluate(() => {
        return document
          .querySelector('div')!
          .style.setProperty('display', 'none');
      });
      expect(await waitForSelector).toBe(true);
      expect(divHidden).toBe(true);
    });

    it('hidden should wait for removal', async () => {
      const {page} = await getTestState();

      await page.setContent(`<div role='main'>text</div>`);
      let divRemoved = false;
      const waitForSelector = page
        .waitForSelector('aria/[role="main"]', {hidden: true})
        .then(() => {
          return (divRemoved = true);
        })
        .catch(() => {
          return (divRemoved = false);
        });
      await page.waitForSelector('aria/[role="main"]'); // do a round trip
      expect(divRemoved).toBe(false);
      await page.evaluate(() => {
        return document.querySelector('div')!.remove();
      });
      expect(await waitForSelector).toBe(true);
      expect(divRemoved).toBe(true);
    });

    it('should return null if waiting to hide non-existing element', async () => {
      const {page} = await getTestState();

      using handle = await page.waitForSelector('aria/non-existing', {
        hidden: true,
      });
      expect(handle).toBe(null);
    });

    it('should respect timeout', async () => {
      const {page} = await getTestState();

      const error = await page
        .waitForSelector('aria/[role="button"]', {
          timeout: 10,
        })
        .catch(error => {
          return error;
        });
      expect(error.message).toContain(
        'Waiting for selector `[role="button"]` failed: Waiting failed: 10ms exceeded',
      );
      expect(error).toBeInstanceOf(TimeoutError);
    });

    it('should have an error message specifically for awaiting an element to be hidden', async () => {
      const {page} = await getTestState();

      await page.setContent(`<div role='main'>text</div>`);
      const promise = page.waitForSelector('aria/[role="main"]', {
        hidden: true,
        timeout: 10,
      });
      await expect(promise).rejects.toMatchObject({
        message:
          'Waiting for selector `[role="main"]` failed: Waiting failed: 10ms exceeded',
      });
    });

    it('should respond to node attribute mutation', async () => {
      const {page} = await getTestState();

      let divFound = false;
      const waitForSelector = page
        .waitForSelector('aria/zombo')
        .then(() => {
          return (divFound = true);
        })
        .catch(() => {
          return (divFound = false);
        });
      await page.setContent(`<div aria-label='notZombo'></div>`);
      expect(divFound).toBe(false);
      await page.evaluate(() => {
        return document
          .querySelector('div')!
          .setAttribute('aria-label', 'zombo');
      });
      expect(await waitForSelector).toBe(true);
    });

    it('should return the element handle', async () => {
      const {page} = await getTestState();

      const waitForSelector = page.waitForSelector('aria/zombo').catch(err => {
        return err;
      });
      await page.setContent(`<div aria-label='zombo'>anything</div>`);
      expect(
        await page.evaluate(
          x => {
            return x?.textContent;
          },
          await waitForSelector,
        ),
      ).toBe('anything');
    });

    it('should have correct stack trace for timeout', async () => {
      const {page} = await getTestState();

      let error!: Error;
      await page.waitForSelector('aria/zombo', {timeout: 10}).catch(error_ => {
        return (error = error_);
      });
      expect(error!.stack).toContain(
        'Waiting for selector `zombo` failed: Waiting failed: 10ms exceeded',
      );
    });
  });

  describe('queryOne (Chromium web test)', () => {
    async function setupPage(): ReturnType<typeof getTestState> {
      const state = await getTestState();
      await state.page.setContent(
        `
          <h2 id="shown">title</h2>
          <h2 id="hidden" aria-hidden="true">title</h2>
          <div id="node1" aria-labelledby="node2"></div>
          <div id="node2" aria-label="bar"></div>
          <div id="node3" aria-label="foo"></div>
          <div id="node4" class="container">
          <div id="node5" role="button" aria-label="foo"></div>
          <div id="node6" role="button" aria-label="foo"></div>
          <!-- Accessible name not available when element is hidden -->
          <div id="node7" hidden role="button" aria-label="foo"></div>
          <div id="node8" role="button" aria-label="bar"></div>
          </div>
          <button id="node10">text content</button>
          <h1 id="node11">text content</h1>
          <!-- Accessible name not available when role is "presentation" -->
          <h1 id="node12" role="presentation">text content</h1>
          <!-- Elements inside shadow dom should be found -->
          <script>
          const div = document.createElement('div');
          const shadowRoot = div.attachShadow({mode: 'open'});
          const h1 = document.createElement('h1');
          h1.textContent = 'text content';
          h1.id = 'node13';
          shadowRoot.appendChild(h1);
          document.documentElement.appendChild(div);
          </script>
          <img id="node20" src="" alt="Accessible Name">
          <input id="node21" type="submit" value="Accessible Name">
          <label id="node22" for="node23">Accessible Name</label>
          <!-- Accessible name for the <input> is "Accessible Name" -->
          <input id="node23">
          <div id="node24" title="Accessible Name"></div>
          <div role="tree">
          <div role="treeitem" id="node30">
          <div role="treeitem" id="node31">
          <div role="treeitem" id="node32">item1</div>
          <div role="treeitem" id="node33">item2</div>
          </div>
          <div role="treeitem" id="node34">item3</div>
          </div>
          </div>
          <!-- Accessible name for the <div> is "item1 item2 item3" -->
          <div aria-describedby="node30"></div>
          `,
      );
      return state;
    }
    const getIds = async (elements: ElementHandle[]) => {
      return await Promise.all(
        elements.map(element => {
          return element.evaluate(element => {
            return element.id;
          });
        }),
      );
    };
    it('should find by name "foo"', async () => {
      const {page} = await setupPage();
      const found = await page.$$('aria/foo');
      const ids = await getIds(found);
      expect(ids).toEqual(['node3', 'node5', 'node6']);
    });
    it('should find by name "bar"', async () => {
      const {page} = await setupPage();
      const found = await page.$$('aria/bar');
      const ids = await getIds(found);
      expect(ids).toEqual(['node1', 'node2', 'node8']);
    });
    it('should find treeitem by name', async () => {
      const {page} = await setupPage();
      const found = await page.$$('aria/item1 item2 item3');
      const ids = await getIds(found);
      expect(ids).toEqual(['node30']);
    });
    it('should find by role "button"', async () => {
      const {page} = await setupPage();
      const found = (await page.$$('aria/[role="button"]')) as Array<
        ElementHandle<HTMLButtonElement>
      >;
      const ids = await getIds(found);
      expect(ids).toEqual(['node5', 'node6', 'node8', 'node10', 'node21']);
    });
    it('should find by role "heading"', async () => {
      const {page} = await setupPage();
      const found = await page.$$('aria/[role="heading"]');
      const ids = await getIds(found);
      expect(ids).toEqual(['shown', 'node11', 'node13']);
    });
    it('should find both ignored and unignored', async () => {
      const {page} = await setupPage();
      const found = await page.$$('aria/title');
      const ids = await getIds(found);
      expect(ids).toEqual(['shown']);
    });
  });
});
