/**
 * @license
 * Copyright 2018 Google Inc.
 * SPDX-License-Identifier: Apache-2.0
 */
import assert from 'node:assert';

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

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

describe('Query handler tests', function () {
  setupTestBrowserHooks();

  describe('Pierce selectors', function () {
    async function setUpPage(): ReturnType<typeof getTestState> {
      const state = await getTestState();
      await state.page.setContent(
        `<script>
         const div = document.createElement('div');
         const shadowRoot = div.attachShadow({mode: 'open'});
         const div1 = document.createElement('div');
         div1.textContent = 'Hello';
         div1.className = 'foo';
         const div2 = document.createElement('div');
         div2.textContent = 'World';
         div2.className = 'foo';
         shadowRoot.appendChild(div1);
         shadowRoot.appendChild(div2);
         document.documentElement.appendChild(div);
         </script>`,
      );
      return state;
    }
    it('should find first element in shadow', async () => {
      const {page} = await setUpPage();
      using div = (await page.$('pierce/.foo')) as ElementHandle<HTMLElement>;
      const text = await div.evaluate(element => {
        return element.textContent;
      });
      expect(text).toBe('Hello');
    });
    it('should find all elements in shadow', async () => {
      const {page} = await setUpPage();
      const divs = (await page.$$('pierce/.foo')) as Array<
        ElementHandle<HTMLElement>
      >;
      const text = await Promise.all(
        divs.map(div => {
          return div.evaluate(element => {
            return element.textContent;
          });
        }),
      );
      expect(text.join(' ')).toBe('Hello World');
    });
    it('should find first child element', async () => {
      const {page} = await setUpPage();
      using parentElement = (await page.$('html > div'))!;
      using childElement = (await parentElement.$(
        'pierce/div',
      )) as ElementHandle<HTMLElement>;
      const text = await childElement.evaluate(element => {
        return element.textContent;
      });
      expect(text).toBe('Hello');
    });
    it('should find all child elements', async () => {
      const {page} = await setUpPage();
      using parentElement = (await page.$('html > div'))!;
      const childElements = (await parentElement.$$('pierce/div')) as Array<
        ElementHandle<HTMLElement>
      >;
      const text = await Promise.all(
        childElements.map(div => {
          return div.evaluate(element => {
            return element.textContent;
          });
        }),
      );
      expect(text.join(' ')).toBe('Hello World');
    });
  });

  describe('Text selectors', function () {
    describe('in Page', function () {
      it('should query existing element', async () => {
        const {page} = await getTestState();

        await page.setContent(html`<section>test</section>`);

        expect(await page.$('text/test')).toBeTruthy();
        expect(await page.$$('text/test')).toHaveLength(1);
      });
      it('should return empty array for non-existing element', async () => {
        const {page} = await getTestState();

        expect(await page.$('text/test')).toBeFalsy();
        expect(await page.$$('text/test')).toHaveLength(0);
      });
      it('should return first element', async () => {
        const {page} = await getTestState();

        await page.setContent(html`<div id="1">a</div> <div>a</div>`);

        using element = await page.$('text/a');
        expect(
          await element?.evaluate(e => {
            return e.id;
          }),
        ).toBe('1');
      });
      it('should return multiple elements', async () => {
        const {page} = await getTestState();

        await page.setContent(html`<div>a</div> <div>a</div>`);

        const elements = await page.$$('text/a');
        expect(elements).toHaveLength(2);
      });
      it('should pierce shadow DOM', async () => {
        const {page} = await getTestState();

        await page.evaluate(() => {
          const div = document.createElement('div');
          const shadow = div.attachShadow({mode: 'open'});
          const diva = document.createElement('div');
          shadow.append(diva);
          const divb = document.createElement('div');
          shadow.append(divb);
          diva.innerHTML = 'a';
          divb.innerHTML = 'b';
          document.body.append(div);
        });

        using element = await page.$('text/a');
        expect(
          await element?.evaluate(e => {
            return e.textContent;
          }),
        ).toBe('a');
      });
      it('should query deeply nested text', async () => {
        const {page} = await getTestState();

        await page.setContent(
          html`<div>
            <div>a</div>
            <div>b</div>
          </div>`,
        );

        using element = await page.$('text/a');
        expect(
          await element?.evaluate(e => {
            return e.textContent;
          }),
        ).toBe('a');
      });
      it('should query inputs', async () => {
        const {page} = await getTestState();

        await page.setContent(html`<input value="a" />`);

        using element = (await page.$(
          'text/a',
        )) as ElementHandle<HTMLInputElement>;
        expect(
          await element?.evaluate(e => {
            return e.value;
          }),
        ).toBe('a');
      });
      it('should not query radio', async () => {
        const {page} = await getTestState();

        await page.setContent(html`<radio value="a"></radio>`);

        expect(await page.$('text/a')).toBeNull();
      });
      it('should query text spanning multiple elements', async () => {
        const {page} = await getTestState();

        await page.setContent(html`<div><span>a</span> <span>b</span></div>`);

        using element = await page.$('text/a b');
        expect(
          await element?.evaluate(e => {
            return e.textContent;
          }),
        ).toBe('a b');
      });
      it('should clear caches', async () => {
        const {page} = await getTestState();

        await page.setContent(
          html`<div id="target1">text</div>
            <input
              id="target2"
              value="text"
            />
            <div id="target3">text</div>`,
        );
        using div = (await page.$('#target1')) as ElementHandle<HTMLDivElement>;
        using input = (await page.$(
          '#target2',
        )) as ElementHandle<HTMLInputElement>;

        await div.evaluate(div => {
          div.textContent = 'text';
        });
        expect(
          await page.$eval(`text/text`, e => {
            return e.id;
          }),
        ).toBe('target1');
        await div.evaluate(div => {
          div.textContent = 'foo';
        });
        expect(
          await page.$eval(`text/text`, e => {
            return e.id;
          }),
        ).toBe('target2');
        await input.evaluate(input => {
          input.value = '';
        });
        await input.type('foo');
        expect(
          await page.$eval(`text/text`, e => {
            return e.id;
          }),
        ).toBe('target3');

        await div.evaluate(div => {
          div.textContent = 'text';
        });
        await input.evaluate(input => {
          input.value = '';
        });
        await input.type('text');
        expect(
          await page.$$eval(`text/text`, es => {
            return es.length;
          }),
        ).toBe(3);
        await div.evaluate(div => {
          div.textContent = 'foo';
        });
        expect(
          await page.$$eval(`text/text`, es => {
            return es.length;
          }),
        ).toBe(2);
        await input.evaluate(input => {
          input.value = '';
        });
        await input.type('foo');
        expect(
          await page.$$eval(`text/text`, es => {
            return es.length;
          }),
        ).toBe(1);
      });
    });
    describe('in ElementHandles', function () {
      it('should query existing element', async () => {
        const {page} = await getTestState();

        await page.setContent(html`<div class="a"><span>a</span></div>`);

        using elementHandle = (await page.$('div'))!;
        expect(await elementHandle.$(`text/a`)).toBeTruthy();
        expect(await elementHandle.$$(`text/a`)).toHaveLength(1);
      });

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

        await page.setContent(html`<div class="a"></div>`);

        using elementHandle = (await page.$('div'))!;
        expect(await elementHandle.$(`text/a`)).toBeFalsy();
        expect(await elementHandle.$$(`text/a`)).toHaveLength(0);
      });
    });
  });

  describe('XPath selectors', function () {
    describe('in Page', function () {
      it('should query existing element', async () => {
        const {page} = await getTestState();

        await page.setContent(html`<section>test</section>`);

        expect(await page.$('xpath/html/body/section')).toBeTruthy();
        expect(await page.$$('xpath/html/body/section')).toHaveLength(1);
      });
      it('should return empty array for non-existing element', async () => {
        const {page} = await getTestState();

        expect(
          await page.$('xpath/html/body/non-existing-element'),
        ).toBeFalsy();
        expect(
          await page.$$('xpath/html/body/non-existing-element'),
        ).toHaveLength(0);
      });
      it('should return first element', async () => {
        const {page} = await getTestState();

        await page.setContent(html`<div>a</div> <div></div>`);

        using element = await page.$('xpath/html/body/div');
        expect(
          await element?.evaluate(e => {
            return e.textContent === 'a';
          }),
        ).toBeTruthy();
      });
      it('should return multiple elements', async () => {
        const {page} = await getTestState();

        await page.setContent(html`<div></div> <div></div>`);

        const elements = await page.$$('xpath/html/body/div');
        expect(elements).toHaveLength(2);
      });
    });
    describe('in ElementHandles', function () {
      it('should query existing element', async () => {
        const {page} = await getTestState();

        await page.setContent(html`<div class="a">a<span></span></div>`);

        using elementHandle = (await page.$('div'))!;
        expect(await elementHandle.$(`xpath/span`)).toBeTruthy();
        expect(await elementHandle.$$(`xpath/span`)).toHaveLength(1);
      });

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

        await page.setContent(html`<div class="a">a</div>`);

        using elementHandle = (await page.$('div'))!;
        expect(await elementHandle.$(`xpath/span`)).toBeFalsy();
        expect(await elementHandle.$$(`xpath/span`)).toHaveLength(0);
      });
    });
  });

  describe('P selectors', () => {
    beforeEach(async () => {
      Puppeteer.clearCustomQueryHandlers();
    });

    it('should work with CSS selectors', async () => {
      const {server, page} = await getTestState();
      await page.goto(`${server.PREFIX}/p-selectors.html`);
      using element = await page.$('div > button');
      assert(element, 'Could not find element');
      expect(
        await element.evaluate(element => {
          return element.id === 'b';
        }),
      ).toBeTruthy();

      using root = await page.$('div');
      using button = await root!.$('& > button');
      assert(button, 'Could not find element');
      expect(
        await button.evaluate(element => {
          return element.id === 'b';
        }),
      ).toBeTruthy();

      // Should parse more complex CSS selectors. Listing a few problematic
      // cases from bug reports.
      for (const selector of [
        '.user_row[data-user-id="\\38 "]:not(.deactivated_user)',
        `input[value='Search']:not([class='hidden'])`,
        `[data-test-id^="test-"]:not([data-test-id^="test-foo"])`,
        `& > table`,
      ]) {
        await page.$$(selector);
      }
    });

    it('should work with puppeteer pseudo classes', async () => {
      const {server, page} = await getTestState();
      await page.goto(`${server.PREFIX}/p-selectors.html`);
      using element = await page.$('button::-p-text(world)');
      assert(element, 'Could not find element');
      expect(
        await element.evaluate(element => {
          return element.id === 'b';
        }),
      ).toBeTruthy();
    });

    it('should work with deep combinators', async () => {
      const {server, page} = await getTestState();
      await page.goto(`${server.PREFIX}/p-selectors.html`);
      {
        using element = await page.$('div >>>> div');
        assert(element, 'Could not find element');
        expect(
          await element.evaluate(element => {
            return element.id === 'c';
          }),
        ).toBeTruthy();
      }
      {
        const elements = await page.$$('div >>> div');
        assert(elements[1], 'Could not find element');
        expect(
          await elements[1]?.evaluate(element => {
            return element.id === 'd';
          }),
        ).toBeTruthy();
      }
      {
        const elements = await page.$$('#c >>>> div');
        assert(elements[0], 'Could not find element');
        expect(
          await elements[0]?.evaluate(element => {
            return element.id === 'd';
          }),
        ).toBeTruthy();
      }
      {
        const elements = await page.$$('#c >>> div');
        assert(elements[0], 'Could not find element');
        expect(
          await elements[0]?.evaluate(element => {
            return element.id === 'd';
          }),
        ).toBeTruthy();
      }
    });

    it('should work with text selectors', async () => {
      const {server, page} = await getTestState();
      await page.goto(`${server.PREFIX}/p-selectors.html`);
      using element = await page.$('div ::-p-text(world)');
      assert(element, 'Could not find element');
      expect(
        await element.evaluate(element => {
          return element.id === 'b';
        }),
      ).toBeTruthy();
    });

    it('should work ARIA selectors', async () => {
      const {server, page} = await getTestState();
      await page.goto(`${server.PREFIX}/p-selectors.html`);
      using element = await page.$('div ::-p-aria(world)');
      assert(element, 'Could not find element');
      expect(
        await element.evaluate(element => {
          return element.id === 'b';
        }),
      ).toBeTruthy();
    });

    it('should work for ARIA selectors in multiple isolated worlds', async () => {
      const {server, page} = await getTestState();
      await page.goto(`${server.PREFIX}/p-selectors.html`);
      using element = await page.waitForSelector('::-p-aria(world)');
      assert(element, 'Could not find element');
      expect(
        await element.evaluate(element => {
          return element.id === 'b';
        }),
      ).toBeTruthy();
      // $ would add ARIA query handler to the main world.
      await element.$('::-p-aria(world)');
      using element2 = await page.waitForSelector('::-p-aria(world)');
      assert(element2, 'Could not find element');
      expect(
        await element2.evaluate(element => {
          return element.id === 'b';
        }),
      ).toBeTruthy();
    });

    it('should work ARIA selectors with role', async () => {
      const {server, page} = await getTestState();
      await page.goto(`${server.PREFIX}/p-selectors.html`);
      using element = await page.$('::-p-aria(world[role="button"])');
      assert(element, 'Could not find element');
      expect(
        await element.evaluate(element => {
          return element.id === 'b';
        }),
      ).toBeTruthy();
    });

    it('should work ARIA selectors with name and role', async () => {
      const {server, page} = await getTestState();
      await page.goto(`${server.PREFIX}/p-selectors.html`);
      using element = await page.$('::-p-aria([name="world"][role="button"])');
      assert(element, 'Could not find element');
      expect(
        await element.evaluate(element => {
          return element.id === 'b';
        }),
      ).toBeTruthy();
    });

    it('should work XPath selectors', async () => {
      const {server, page} = await getTestState();
      await page.goto(`${server.PREFIX}/p-selectors.html`);
      using element = await page.$('div ::-p-xpath(//button)');
      assert(element, 'Could not find element');
      expect(
        await element.evaluate(element => {
          return element.id === 'b';
        }),
      ).toBeTruthy();
    });

    it('should work with custom selectors', async () => {
      Puppeteer.registerCustomQueryHandler('div', {
        queryOne() {
          return document.querySelector('div');
        },
      });

      const {server, page} = await getTestState();
      await page.goto(`${server.PREFIX}/p-selectors.html`);
      using element = await page.$('::-p-div');
      assert(element, 'Could not find element');
      expect(
        await element.evaluate(element => {
          return element.id === 'a';
        }),
      ).toBeTruthy();
    });

    it('should work with custom selectors with args', async () => {
      const {server, page} = await getTestState();
      await page.goto(`${server.PREFIX}/p-selectors.html`);
      Puppeteer.registerCustomQueryHandler('div', {
        queryOne(_, selector) {
          if (selector === 'true') {
            return document.querySelector('div');
          } else {
            return document.querySelector('button');
          }
        },
      });

      {
        using element = await page.$('::-p-div(true)');
        assert(element, 'Could not find element');
        expect(
          await element.evaluate(element => {
            return element.id === 'a';
          }),
        ).toBeTruthy();
      }
      {
        using element = await page.$('::-p-div("true")');
        assert(element, 'Could not find element');
        expect(
          await element.evaluate(element => {
            return element.id === 'a';
          }),
        ).toBeTruthy();
      }
      {
        using element = await page.$("::-p-div('true')");
        assert(element, 'Could not find element');
        expect(
          await element.evaluate(element => {
            return element.id === 'a';
          }),
        ).toBeTruthy();
      }
      {
        using element = await page.$('::-p-div');
        assert(element, 'Could not find element');
        expect(
          await element.evaluate(element => {
            return element.id === 'b';
          }),
        ).toBeTruthy();
      }
    });

    it('should work with :hover', async () => {
      const {server, page} = await getTestState();
      await page.goto(`${server.PREFIX}/p-selectors.html`);
      using button = await page.$('div ::-p-text(world)');
      assert(button, 'Could not find element');
      await button.hover();

      using button2 = await page.$('div ::-p-text(world):hover');
      assert(button2, 'Could not find element');
      const value = await button2.evaluate(span => {
        return {textContent: span.textContent, tagName: span.tagName};
      });
      expect(value).toMatchObject({textContent: 'world', tagName: 'BUTTON'});
    });

    it('should work with selector lists', async () => {
      const {server, page} = await getTestState();
      await page.goto(`${server.PREFIX}/p-selectors.html`);
      const elements = await page.$$('div, ::-p-text(world)');
      expect(elements).toHaveLength(3);
    });

    const permute = <T>(inputs: T[]): T[][] => {
      const results: T[][] = [];
      for (let i = 0; i < inputs.length; ++i) {
        const permutation = permute(
          inputs.slice(0, i).concat(inputs.slice(i + 1)),
        );
        const value = inputs[i] as T;
        if (permutation.length === 0) {
          results.push([value]);
          continue;
        }
        for (const part of permutation) {
          results.push([value].concat(part));
        }
      }
      return results;
    };

    it('should match querySelector* ordering', async () => {
      const {server, page} = await getTestState();
      await page.goto(`${server.PREFIX}/p-selectors.html`);
      for (const list of permute(['div', 'button', 'span'])) {
        const elements = await page.$$(
          list
            .map(selector => {
              return selector === 'button' ? '::-p-text(world)' : selector;
            })
            .join(','),
        );
        const actual = await Promise.all(
          elements.map(element => {
            return element.evaluate(element => {
              return element.id;
            });
          }),
        );
        expect(actual.join()).toStrictEqual('a,b,f,c');
      }
    });

    it('should not have duplicate elements from selector lists', async () => {
      const {server, page} = await getTestState();
      await page.goto(`${server.PREFIX}/p-selectors.html`);
      const elements = await page.$$('::-p-text(world), button');
      expect(elements).toHaveLength(1);
    });

    it('should handle escapes', async () => {
      const {server, page} = await getTestState();
      await page.goto(`${server.PREFIX}/p-selectors.html`);
      using element = await page.$(
        ':scope >>> ::-p-text(My name is Jun \\(pronounced like "June"\\))',
      );
      expect(element).toBeTruthy();
      using element2 = await page.$(
        ':scope >>> ::-p-text("My name is Jun (pronounced like \\"June\\")")',
      );
      expect(element2).toBeTruthy();
      using element3 = await page.$(
        ':scope >>> ::-p-text(My name is Jun \\(pronounced like "June"\\)")',
      );
      expect(element3).toBeFalsy();
      using element4 = await page.$(
        ':scope >>> ::-p-text("My name is Jun \\(pronounced like "June"\\))',
      );
      expect(element4).toBeFalsy();
    });
  });
});
