import { describe, it, expect, beforeEach } from 'vitest';
import { load, type CheerioAPI, type Cheerio } from '../index.js';
import type { Element } from 'domhandler';
import {
  cheerio,
  script,
  fruits,
  vegetables,
  food,
  chocolates,
  inputs,
  mixedText,
} from '../__fixtures__/fixtures.js';

function withClass(attr: string) {
  return cheerio(`<div class="${attr}"></div>`);
}

describe('$(...)', () => {
  describe('.attr', () => {
    let $: CheerioAPI;

    beforeEach(() => {
      $ = load(fruits);
    });

    it('() : should get all the attributes', () => {
      const attrs = $('ul').attr();
      expect(attrs).toHaveProperty('id', 'fruits');
    });

    it('(invalid key) : invalid attr should get undefined', () => {
      const attr = $('.apple').attr('lol');
      expect(attr).toBeUndefined();
    });

    it('(valid key) : valid attr should get value', () => {
      const cls = $('.apple').attr('class');
      expect(cls).toBe('apple');
    });

    it('(valid key) : valid attr should get name when boolean', () => {
      const attr = $('<input name=email autofocus>').attr('autofocus');
      expect(attr).toBe('autofocus');
    });

    it('(key, value) : should set one attr', () => {
      const $pear = $('.pear').attr('id', 'pear');
      expect($('#pear')).toHaveLength(1);
      expect($pear).toBeInstanceOf($);
    });

    it('(key, value) : should set multiple attr', () => {
      const $el = cheerio('<div></div> <div></div>').attr(
        'class',
        'pear',
      ) as Cheerio<Element>;

      expect($el[0].attribs).toHaveProperty('class', 'pear');
      expect($el[1].attribs).toBeUndefined();
      expect($el[2].attribs).toHaveProperty('class', 'pear');
    });

    it('(key, value) : should return an empty object for an empty object', () => {
      const $src = $().attr('key', 'value');
      expect($src.length).toBe(0);
      expect($src[0]).toBeUndefined();
    });

    it('(map) : object map should set multiple attributes', () => {
      $('.apple').attr({
        id: 'apple',
        style: 'color:red;',
        'data-url': 'http://apple.com',
      });
      const attrs = $('.apple').attr();
      expect(attrs).toHaveProperty('id', 'apple');
      expect(attrs).toHaveProperty('style', 'color:red;');
      expect(attrs).toHaveProperty('data-url', 'http://apple.com');
    });

    it('(map, val) : should throw with wrong combination of arguments', () => {
      expect(() =>
        $('.apple').attr(
          {
            id: 'apple',
            style: 'color:red;',
            'data-url': 'http://apple.com',
          } as never,
          () => '',
        ),
      ).toThrow('Bad combination of arguments.');
    });

    it('(key, function) : should call the function and update the attribute with the return value', () => {
      const $fruits = $('#fruits');
      $fruits.attr('id', (index, value) => {
        expect(index).toBe(0);
        expect(value).toBe('fruits');
        return 'ninja';
      });
      const attrs = $fruits.attr();
      expect(attrs).toHaveProperty('id', 'ninja');
    });

    it('(key, function) : should ignore text nodes', () => {
      const $text = $(mixedText);
      $text.attr('class', () => 'ninja');
      const className = $text.attr('class');
      expect(className).toBe('ninja');
    });

    it('(key, value) : should correctly encode then decode unsafe values', () => {
      const $apple = $('.apple');
      $apple.attr(
        'href',
        'http://github.com/"><script>alert("XSS!")</script><br',
      );
      expect($apple.attr('href')).toBe(
        'http://github.com/"><script>alert("XSS!")</script><br',
      );

      $apple.attr(
        'href',
        'http://github.com/"><script>alert("XSS!")</script><br',
      );
      expect($apple.html()).not.toContain('<script>alert("XSS!")</script>');
    });

    it('(key, value) : should coerce values to a string', () => {
      const $apple = $('.apple');
      $apple.attr('data-test', 1 as never);
      expect($apple[0].attribs['data-test']).toBe('1');
      expect($apple.attr('data-test')).toBe('1');
    });

    it('(key, value) : handle removed boolean attributes', () => {
      const $apple = $('.apple');
      $apple.attr('autofocus', 'autofocus');
      expect($apple.attr('autofocus')).toBe('autofocus');
      $apple.removeAttr('autofocus');
      expect($apple.attr('autofocus')).toBeUndefined();
    });

    it('(key, value) : should remove non-boolean attributes with names or values similar to boolean ones', () => {
      const $apple = $('.apple');
      $apple.attr('data-autofocus', 'autofocus');
      expect($apple.attr('data-autofocus')).toBe('autofocus');
      $apple.removeAttr('data-autofocus');
      expect($apple.attr('data-autofocus')).toBeUndefined();
    });

    it('(key, value) : should remove attributes when called with null value', () => {
      const $pear = $('.pear').attr('autofocus', 'autofocus');
      expect($pear.attr('autofocus')).toBe('autofocus');
      $pear.attr('autofocus', null);
      expect($pear.attr('autofocus')).toBeUndefined();
    });

    it('(map) : should remove attributes with null values', () => {
      const $pear = $('.pear').attr({
        autofocus: 'autofocus',
        style: 'color:red',
      });
      expect($pear.attr('autofocus')).toBe('autofocus');
      expect($pear.attr('style')).toBe('color:red');
      $pear.attr({ autofocus: null, style: 'color:blue' });
      expect($pear.attr('autofocus')).toBeUndefined();
      expect($pear.attr('style')).toBe('color:blue');
    });

    it('(chaining) setting value and calling attr returns result', () => {
      const pearAttr = $('.pear').attr('foo', 'bar').attr('foo');
      expect(pearAttr).toBe('bar');
    });

    it('(chaining) setting attr to null returns a $', () => {
      const $pear = $('.pear').attr('foo', null);
      expect($pear).toBeInstanceOf($);
    });

    it('(chaining) setting attr to undefined returns a $', () => {
      const $pear = $('.pear').attr('foo', undefined);
      expect($('.pear')).toHaveLength(1);
      expect($('.pear').attr('foo')).toBeUndefined();
      expect($pear).toBeInstanceOf($);
    });

    it("(bool) shouldn't treat boolean attributes differently in XML mode", () => {
      const $xml = $.load(`<input checked=checked disabled=yes />`, {
        xml: true,
      })('input');

      expect($xml.attr('checked')).toBe('checked');
      expect($xml.attr('disabled')).toBe('yes');
    });
  });

  describe('.prop', () => {
    let $: CheerioAPI;
    let checkbox: Cheerio<Element>;

    beforeEach(() => {
      $ = load(inputs);
      checkbox = $('input[name=checkbox_on]');
    });

    it('(valid key) : valid prop should get value', () => {
      expect(checkbox.prop('checked')).toBe(true);
      checkbox.css('display', 'none');
      expect(checkbox.prop('style')).toHaveProperty('display', 'none');
      expect(checkbox.prop('style')).toHaveLength(1);
      expect(checkbox.prop('style')).toContain('display');
      expect(checkbox.prop('tagName')).toBe('INPUT');
      expect(checkbox.prop('nodeName')).toBe('INPUT');
    });

    it('(valid key) : should return on empty collection', () => {
      expect($(undefined).prop('checked')).toBeUndefined();
      expect($(undefined).prop('style')).toBeUndefined();
      expect($(undefined).prop('tagName')).toBeUndefined();
      expect($(undefined).prop('nodeName')).toBeUndefined();
    });

    it('(invalid key) : invalid prop should get undefined', () => {
      expect(checkbox.prop('lol')).toBeUndefined();
      expect(checkbox.prop(4 as never)).toBeUndefined();
      expect(checkbox.prop(true as never)).toBeUndefined();
    });

    it('(key, value) : should set prop', () => {
      expect(checkbox.prop('checked')).toBe(true);
      checkbox.prop('checked', false);
      expect(checkbox.prop('checked')).toBe(false);
      checkbox.prop('checked', true);
      expect(checkbox.prop('checked')).toBe(true);
    });

    it('(key, value) : should update attribute', () => {
      expect(checkbox.prop('checked')).toBe(true);
      expect(checkbox.attr('checked')).toBe('checked');
      checkbox.prop('checked', false);
      expect(checkbox.prop('checked')).toBe(false);
      expect(checkbox.attr('checked')).toBeUndefined();
      checkbox.prop('checked', true);
      expect(checkbox.prop('checked')).toBe(true);
      expect(checkbox.attr('checked')).toBe('checked');
    });

    it('(key, value) : should update namespace', () => {
      const imgs = $('<img>\n\n<img>\n\n<img>');
      const nsHtml = 'http://www.w3.org/1999/xhtml';
      imgs.prop('src', '#').prop('namespace', nsHtml);
      expect(imgs.prop('namespace')).toBe(nsHtml);
      imgs.prop('attribs', null);
      expect(imgs.prop('src')).toBeUndefined();
      expect(imgs.prop('data-foo')).toBeUndefined();
    });

    it('(key, value) : should ignore empty collection', () => {
      expect($(undefined).prop('checked')).toBeUndefined();
      $(undefined).prop('checked', true);
      expect($(undefined).prop('checked')).toBeUndefined();
    });

    it('(map) : object map should set multiple props', () => {
      checkbox.prop({
        id: 'check',
        checked: false,
      });
      expect(checkbox.prop('id')).toBe('check');
      expect(checkbox.prop('checked')).toBe(false);
    });

    it('(map, val) : should throw with wrong combination of arguments', () => {
      expect(() =>
        $('.apple').prop(
          {
            id: 'check',
            checked: false,
          } as never,
          () => '',
        ),
      ).toThrow('Bad combination of arguments.');
    });

    it('(key, function) : should call the function and update the prop with the return value', () => {
      checkbox.prop('checked', (index, value) => {
        expect(index).toBe(0);
        expect(value).toBe(true);
        return false;
      });
      expect(checkbox.prop('checked')).toBe(false);
    });

    it('(key, value) : should support chaining after setting props', () => {
      expect(checkbox.prop('checked', false)).toBe(checkbox);
    });

    it('(invalid element/tag) : prop should return undefined', () => {
      expect($(undefined).prop('prop')).toBeUndefined();
      expect($(null as never).prop('prop')).toBeUndefined();
    });

    it('("href") : should resolve links with `baseURI`', () => {
      const $ = load(
        `
          <a id="1" href="http://example.org">example1</a>
          <a id="2" href="//example.org">example2</a>
          <a id="3" href="/example.org">example3</a>
          <a id="4" href="example.org">example4</a>
        `,
        { baseURI: 'http://example.com/page/1' },
      );

      expect($('#1').prop('href')).toBe('http://example.org/');
      expect($('#2').prop('href')).toBe('http://example.org/');
      expect($('#3').prop('href')).toBe('http://example.com/example.org');
      expect($('#4').prop('href')).toBe('http://example.com/page/example.org');

      expect($(undefined).prop('href')).toBeUndefined();
    });

    it('("href") : should skip values without an href', () => {
      const $ = load('<a id="1">example1</a>');
      expect($('#1').prop('href')).toBeUndefined();
    });

    it('("src") : should resolve links with `baseURI`', () => {
      const $ = load(
        `
          <img id="1" src="http://example.org/image.png">
          <iframe id="2" src="//example.org/page.html"></iframe>
          <audio id="3" src="/example.org/song.mp3"></audio>
          <source id="4" src="example.org/image.png">
        `,
        { baseURI: 'http://example.com/page/1' },
      );

      expect($('#1').prop('src')).toBe('http://example.org/image.png');
      expect($('#2').prop('src')).toBe('http://example.org/page.html');
      expect($('#3').prop('src')).toBe(
        'http://example.com/example.org/song.mp3',
      );
      expect($('#4').prop('src')).toBe(
        'http://example.com/page/example.org/image.png',
      );

      expect($(undefined).prop('src')).toBeUndefined();
    });

    it('("outerHTML") : should render properly', () => {
      const outerHtml = '<div><a></a></div>';
      const $a = $(outerHtml);

      expect($a.prop('outerHTML')).toBe(outerHtml);

      expect($(undefined).prop('outerHTML')).toBeUndefined();
    });

    it('("outerHTML") : should support root nodes', () => {
      const $ = load('<div></div>');
      expect($.root().prop('outerHTML')).toBe(
        '<html><head></head><body><div></div></body></html>',
      );
    });

    it('("innerHTML") : should render properly', () => {
      const $a = $('<div><a></a></div>');

      expect($a.prop('innerHTML')).toBe('<a></a>');

      expect($(undefined).prop('innerHTML')).toBeUndefined();
    });

    it('("textContent") : should render properly', () => {
      expect($('select').children().prop('textContent')).toBe(
        'Option not selected',
      );

      expect($(script).prop('textContent')).toBe('A  var foo = "bar";B');

      expect($(undefined).prop('textContent')).toBeUndefined();
    });

    it('("textContent") : should include style and script tags', () => {
      const $ = load(
        '<body>Welcome <div>Hello, testing text function,<script>console.log("hello")</script></div><style type="text/css">.cf-hidden { display: none; }</style>End of message</body>',
      );
      expect($('body').prop('textContent')).toBe(
        'Welcome Hello, testing text function,console.log("hello").cf-hidden { display: none; }End of message',
      );
      expect($('style').prop('textContent')).toBe(
        '.cf-hidden { display: none; }',
      );
      expect($('script').prop('textContent')).toBe('console.log("hello")');
    });

    it('("innerText") : should render properly', () => {
      expect($('select').children().prop('innerText')).toBe(
        'Option not selected',
      );

      expect($(script).prop('innerText')).toBe('AB');

      expect($(undefined).prop('innerText')).toBeUndefined();
    });

    it('("innerText") : should omit style and script tags', () => {
      const $ = load(
        '<body>Welcome <div>Hello, testing text function,<script>console.log("hello")</script></div><style type="text/css">.cf-hidden { display: none; }</style>End of message</body>',
      );
      expect($('body').prop('innerText')).toBe(
        'Welcome Hello, testing text function,End of message',
      );
      expect($('style').prop('innerText')).toBe('');
      expect($('script').prop('innerText')).toBe('');
    });

    it('(inherited properties) : prop should support inherited properties', () => {
      expect($('select').prop('childNodes')).toBe($('select')[0].childNodes);
    });

    it('(key) : should skip text nodes', () => {
      const $text = load(mixedText);
      const $body = $text($text('body')[0].children);

      expect($text($body[1]).prop('tagName')).toBeUndefined();

      $body.prop('test-name', () => 'tester');
      expect($text('body').html()).toBe(
        '<a test-name="tester">1</a>TEXT<b test-name="tester">2</b>',
      );
    });

    it("(bool) shouldn't treat boolean attributes differently in XML mode", () => {
      const $xml = $.load(`<input checked=checked disabled=yes />`, {
        xml: true,
      })('input');

      expect($xml.prop('checked')).toBe('checked');
      expect($xml.prop('disabled')).toBe('yes');
    });
  });

  describe('.data', () => {
    let $: CheerioAPI;

    beforeEach(() => {
      $ = load(chocolates);
    });

    it('() : should get all data attributes initially declared in the markup', () => {
      const data = $('.linth').data();
      expect(data).toStrictEqual({
        highlight: 'Lindor',
        origin: 'swiss',
      });
    });

    it('() : should get all data set via `data`', () => {
      const $el = cheerio('<div>');
      $el.data('a', 1);
      $el.data('b', 2);

      expect($el.data()).toStrictEqual({
        a: 1,
        b: 2,
      });
    });

    it('() : should get all data attributes initially declared in the markup merged with all data additionally set via `data`', () => {
      const $el = cheerio('<div data-a="a" data-b="b">');
      $el.data('b', 'b-modified');
      $el.data('c', 'c');

      expect($el.data()).toStrictEqual({
        a: 'a',
        b: 'b-modified',
        c: 'c',
      });
    });

    it('() : no data attribute should return an empty object', () => {
      const data = $('.cailler').data();
      expect(Object.keys(data)).toHaveLength(0);
      expect($('.free').data()).toBeUndefined();
    });

    it('(invalid key) : invalid data attribute should return `undefined`', () => {
      const data = $('.frey').data('lol');
      expect(data).toBeUndefined();
    });

    it('(valid key) : valid data attribute should get value', () => {
      const highlight = $('.linth').data('highlight');
      const origin = $('.linth').data('origin');

      expect(highlight).toBe('Lindor');
      expect(origin).toBe('swiss');
    });

    it('(key) : should translate camel-cased key values to hyphen-separated versions', () => {
      const $el = cheerio(
        '<div data--three-word-attribute="a" data-foo-Bar_BAZ-="b">',
      );

      expect($el.data('ThreeWordAttribute')).toBe('a');
      expect($el.data('fooBar_baz-')).toBe('b');
    });

    it('(key) : should retrieve object values', () => {
      const data = {};
      const $el = cheerio('<div>');

      $el.data('test', data);

      expect($el.data('test')).toBe(data);
    });

    it('(key) : should parse JSON data derived from the markup', () => {
      const $el = cheerio('<div data-json="[1, 2, 3]">');

      expect($el.data('json')).toStrictEqual([1, 2, 3]);
    });

    it('(key) : should not parse JSON data set via the `data` API', () => {
      const $el = cheerio('<div>');
      $el.data('json', '[1, 2, 3]');

      expect($el.data('json')).toBe('[1, 2, 3]');
    });

    // See https://api.jquery.com/data/ and https://bugs.jquery.com/ticket/14523
    it('(key) : should ignore the markup value after the first access', () => {
      const $el = cheerio('<div data-test="a">');

      expect($el.data('test')).toBe('a');

      $el.attr('data-test', 'b');

      expect($el.data('test')).toBe('a');
    });

    it('(key) : should recover from malformed JSON', () => {
      const $el = cheerio('<div data-custom="{{templatevar}}">');

      expect($el.data('custom')).toBe('{{templatevar}}');
    });

    it('("") : should accept the empty string as a name', () => {
      const $el = cheerio('<div data-="a">');

      expect($el.data('')).toBe('a');
    });

    it('(hyphen key) : data addribute with hyphen should be camelized ;-)', () => {
      const data = $('.frey').data();
      expect(data).toStrictEqual({
        taste: 'sweet',
        bestCollection: 'Mahony',
      });
    });

    it('(key, value) : should set data attribute', () => {
      // Adding as object.
      const a = $('.frey').data({
        balls: 'giandor',
      });
      // Adding as string.
      const b = $('.linth').data('snack', 'chocoletti');

      expect(() => {
        a.data(4 as never, 'throw');
      }).not.toThrow();
      expect(a.data('balls')).toStrictEqual('giandor');
      expect(b.data('snack')).toStrictEqual('chocoletti');
    });

    it('(key, value) : should set data for all elements in the selection', () => {
      $('li').data('foo', 'bar');

      expect($('li').eq(0).data('foo')).toStrictEqual('bar');
      expect($('li').eq(1).data('foo')).toStrictEqual('bar');
      expect($('li').eq(2).data('foo')).toStrictEqual('bar');
    });

    it('(map) : object map should set multiple data attributes', () => {
      const { data } = $('.linth').data({
        id: 'Cailler',
        flop: 'Pippilotti Rist',
        top: 'Frigor',
        url: 'http://www.cailler.ch/',
      })[0] as never;

      expect(data).toHaveProperty('id', 'Cailler');
      expect(data).toHaveProperty('flop', 'Pippilotti Rist');
      expect(data).toHaveProperty('top', 'Frigor');
      expect(data).toHaveProperty('url', 'http://www.cailler.ch/');
    });

    describe('(attr) : data-* attribute type coercion :', () => {
      it('boolean', () => {
        const $el = cheerio('<div data-bool="true">');
        expect($el.data('bool')).toBe(true);
      });

      it('number', () => {
        const $el = cheerio('<div data-number="23">');
        expect($el.data('number')).toBe(23);
      });

      it('number (scientific notation is not coerced)', () => {
        const $el = cheerio('<div data-sci="1E10">');
        expect($el.data('sci')).toBe('1E10');
      });

      it('null', () => {
        const $el = cheerio('<div data-null="null">');
        expect($el.data('null')).toBe(null);
      });

      it('object', () => {
        const $el = cheerio('<div data-obj=\'{ "a": 45 }\'>');
        expect($el.data('obj')).toStrictEqual({ a: 45 });
      });

      it('array', () => {
        const $el = cheerio('<div data-array="[1, 2, 3]">');
        expect($el.data('array')).toStrictEqual([1, 2, 3]);
      });
    });

    it('(key, value) : should skip text nodes', () => {
      const $text = load(mixedText);
      const $body = $text($text('body')[0].children);

      $body.data('snack', 'chocoletti');

      expect($text('b').data('snack')).toBe('chocoletti');
    });
  });

  describe('.val', () => {
    let $: CheerioAPI;

    beforeEach(() => {
      $ = load(inputs);
    });

    it('(): on div should get undefined', () => {
      expect($('<div>').val()).toBeUndefined();
    });

    it('(): on select should get value', () => {
      const val = $('select#one').val();
      expect(val).toBe('option_selected');
    });
    it('(): on select with no value should get text', () => {
      const val = $('select#one-valueless').val();
      expect(val).toBe('Option selected');
    });
    it('(): on select with no value should get converted HTML', () => {
      const val = $('select#one-html-entity').val();
      expect(val).toBe('Option <selected>');
    });
    it('(): on select with no value should get text content', () => {
      const val = $('select#one-nested').val();
      expect(val).toBe('Option selected');
    });
    it('(): on option should get value', () => {
      const val = $('select#one option').eq(0).val();
      expect(val).toBe('option_not_selected');
    });
    it('(): on text input should get value', () => {
      const val = $('input[type="text"]').val();
      expect(val).toBe('input_text');
    });
    it('(): on checked checkbox should get value', () => {
      const val = $('input[name="checkbox_on"]').val();
      expect(val).toBe('on');
    });
    it('(): on unchecked checkbox should get value', () => {
      const val = $('input[name="checkbox_off"]').val();
      expect(val).toBe('off');
    });
    it('(): on valueless checkbox should get value', () => {
      const val = $('input[name="checkbox_valueless"]').val();
      expect(val).toBe('on');
    });
    it('(): on radio should get value', () => {
      const val = $('input[type="radio"]').val();
      expect(val).toBe('off');
    });
    it('(): on valueless radio should get value', () => {
      const val = $('input[name="radio_valueless"]').val();
      expect(val).toBe('on');
    });
    it('(): on multiple select should get an array of values', () => {
      const val = $('select#multi').val();
      expect(val).toStrictEqual(['2', '3']);
    });
    it('(): on multiple select with no value attribute should get an array of text content', () => {
      const val = $('select#multi-valueless').val();
      expect(val).toStrictEqual(['2', '3']);
    });
    it('(): with no selector matches should return nothing', () => {
      const val = $('.nasty').val();
      expect(val).toBeUndefined();
    });
    it('(invalid value): should only handle arrays when it has the attribute multiple', () => {
      const val = $('select#one').val([]);
      expect(val).not.toBeUndefined();
    });
    it('(value): on empty set should get `this`', () => {
      const $empty = $([]);
      expect($empty.val('test')).toBe($empty);
    });
    it('(value): on input text should set value', () => {
      const element = $('input[type="text"]').val('test');
      expect(element.val()).toBe('test');
    });
    it('(value): on select should set value', () => {
      const element = $('select#one').val('option_not_selected');
      expect(element.val()).toBe('option_not_selected');
    });
    it('(value): on option should set value', () => {
      const element = $('select#one option').eq(0).val('option_changed');
      expect(element.val()).toBe('option_changed');
    });
    it('(value): on radio should set value', () => {
      const element = $('input[name="radio"]').val('off');
      expect(element.val()).toBe('off');
    });
    it('(value): on radio with special characters should set value', () => {
      const element = $('input[name="radio[brackets]"]').val('off');
      expect(element.val()).toBe('off');
    });
    it('(values): on multiple select should set multiple values', () => {
      const element = $('select#multi').val(['1', '3', '4']);
      expect(element.val()).toHaveLength(3);
    });
  });

  describe('.removeAttr', () => {
    let $: CheerioAPI;

    beforeEach(() => {
      $ = load(fruits);
    });

    it('(key) : should remove a single attr', () => {
      const $fruits = $('#fruits');
      expect($fruits.attr('id')).not.toBeUndefined();
      $fruits.removeAttr('id');
      expect($fruits.attr('id')).toBeUndefined();
    });

    it('(key key) : should remove multiple attrs', () => {
      const $apple = $('.apple');
      $apple.attr('id', 'favorite');
      $apple.attr('size', 'small');

      expect($apple.attr('id')).toBe('favorite');
      expect($apple.attr('class')).toBe('apple');
      expect($apple.attr('size')).toBe('small');
      $apple.removeAttr('id class');
      expect($apple.attr('id')).toBeUndefined();
      expect($apple.attr('class')).toBeUndefined();
      expect($apple.attr('size')).toBe('small');
    });

    it('(key) : should return cheerio object', () => {
      const obj = $('ul').removeAttr('id');
      expect(obj).toBeInstanceOf($);
    });

    it('(key) : should skip text nodes', () => {
      const $text = load(mixedText);
      const $body = $text($text('body')[0].children);

      $body.addClass(() => 'test');

      expect($text('body').html()).toBe(
        '<a class="test">1</a>TEXT<b class="test">2</b>',
      );

      $body.removeAttr('class');

      expect($text('body').html()).toBe(mixedText);
    });
  });

  describe('.hasClass', () => {
    let $: CheerioAPI;

    beforeEach(() => {
      $ = load(fruits);
    });

    it('(valid class) : should return true', () => {
      const cls = $('.apple').hasClass('apple');
      expect(cls).toBe(true);

      expect(withClass('foo').hasClass('foo')).toBe(true);
      expect(withClass('foo bar').hasClass('foo')).toBe(true);
      expect(withClass('bar foo').hasClass('foo')).toBe(true);
      expect(withClass('bar foo bar').hasClass('foo')).toBe(true);
    });

    it('(invalid class) : should return false', () => {
      const cls = $('#fruits').hasClass('fruits');
      expect(cls).toBe(false);
      expect(withClass('foo-bar').hasClass('foo')).toBe(false);
      expect(withClass('foo-bar').hasClass('foo')).toBe(false);
      expect(withClass('foo-bar').hasClass('foo-ba')).toBe(false);
    });

    it('should check multiple classes', () => {
      // Add a class
      $('.apple').addClass('red');
      expect($('.apple').hasClass('apple')).toBe(true);
      expect($('.apple').hasClass('red')).toBe(true);

      // Remove one and test again
      $('.apple').removeClass('apple');
      expect($('li').eq(0).hasClass('apple')).toBe(false);
    });

    it('(empty string argument) : should return false', () => {
      expect(withClass('foo').hasClass('')).toBe(false);
      expect(withClass('foo bar').hasClass('')).toBe(false);
      expect(withClass('foo bar').removeClass('foo').hasClass('')).toBe(false);
    });
  });

  describe('.addClass', () => {
    let $: CheerioAPI;

    beforeEach(() => {
      $ = load(fruits);
    });

    it('(first class) : should add the class to the element', () => {
      const $fruits = $('#fruits');
      $fruits.addClass('fruits');
      const cls = $fruits.hasClass('fruits');
      expect(cls).toBe(true);
    });

    it('(single class) : should add the class to the element', () => {
      $('.apple').addClass('fruit');
      const cls = $('.apple').hasClass('fruit');
      expect(cls).toBe(true);
    });

    it('(class): adds classes to many selected items', () => {
      $('li').addClass('fruit');
      expect($('.apple').hasClass('fruit')).toBe(true);
      expect($('.orange').hasClass('fruit')).toBe(true);
      expect($('.pear').hasClass('fruit')).toBe(true);

      // Mixed with text nodes
      const $red = $('<html>\n<ul id=one>\n</ul>\t</html>').addClass('red');
      expect($red).toHaveLength(3);
      expect($red[0].type).toBe('text');
      expect($red[1].type).toBe('tag');
      expect($red[2].type).toBe('text');
      expect($red.hasClass('red')).toBe(true);
    });

    it('(class class class) : should add multiple classes to the element', () => {
      $('.apple').addClass('fruit red tasty');
      expect($('.apple').hasClass('apple')).toBe(true);
      expect($('.apple').hasClass('fruit')).toBe(true);
      expect($('.apple').hasClass('red')).toBe(true);
      expect($('.apple').hasClass('tasty')).toBe(true);
    });

    it('(fn) : should add classes returned from the function', () => {
      const $fruits = $('#fruits').children().add($('#fruits'));
      const args: [i: number, className: string][] = [];
      const thisVals: Element[] = [];
      const toAdd = ['main', 'apple red', '', undefined];

      $fruits.addClass(function (...myArgs) {
        args.push(myArgs);
        thisVals.push(this);
        return toAdd[myArgs[0]];
      });

      expect(args).toStrictEqual([
        [0, ''],
        [1, 'apple'],
        [2, 'orange'],
        [3, 'pear'],
      ]);
      expect(thisVals).toStrictEqual([
        $fruits[0],
        $fruits[1],
        $fruits[2],
        $fruits[3],
      ]);
      expect($fruits.eq(0).hasClass('main')).toBe(true);
      expect($fruits.eq(0).hasClass('apple')).toBe(false);
      expect($fruits.eq(1).hasClass('apple')).toBe(true);
      expect($fruits.eq(1).hasClass('red')).toBe(true);
      expect($fruits.eq(2).hasClass('orange')).toBe(true);
      expect($fruits.eq(3).hasClass('pear')).toBe(true);
    });
  });

  describe('.removeClass', () => {
    let $: CheerioAPI;

    beforeEach(() => {
      $ = load(fruits);
    });

    it('() : should remove all the classes', () => {
      $('.pear').addClass('fruit');
      $('.pear').removeClass();
      expect($('.pear').attr('class')).toBeUndefined();
    });

    it('("") : should not modify class list', () => {
      const $fruits = $('#fruits');
      $fruits.children().removeClass('');
      expect($('.apple')).toHaveLength(1);
    });

    it('(invalid class) : should not remove anything', () => {
      $('.pear').removeClass('fruit');
      expect($('.pear').hasClass('pear')).toBe(true);
    });

    it('(no class attribute) : should not throw an exception', () => {
      const $vegetables = cheerio(vegetables);

      expect(() => {
        $('li', $vegetables).removeClass('vegetable');
      }).not.toThrow();
    });

    it('(single class) : should remove a single class from the element', () => {
      $('.pear').addClass('fruit');
      expect($('.pear').hasClass('fruit')).toBe(true);
      $('.pear').removeClass('fruit');
      expect($('.pear').hasClass('fruit')).toBe(false);
      expect($('.pear').hasClass('pear')).toBe(true);

      // Remove one class from set
      const $li = $('li').removeClass('orange');
      expect($li.eq(0).attr('class')).toBe('apple');
      expect($li.eq(1).attr('class')).toBe('');
      expect($li.eq(2).attr('class')).toBe('pear');

      // Mixed with text nodes
      const $red = $('<html>\n<ul class=one>\n</ul>\t</html>').removeClass(
        'one',
      );
      expect($red).toHaveLength(3);
      expect($red[0].type).toBe('text');
      expect($red[1].type).toBe('tag');
      expect($red[2].type).toBe('text');
      expect($red.eq(1).attr('class')).toBe('');
      expect($red.eq(1).prop('tagName')).toBe('UL');
    });

    it('(single class) : should remove a single class from multiple classes on the element', () => {
      $('.pear').addClass('fruit green tasty');
      expect($('.pear').hasClass('fruit')).toBe(true);
      expect($('.pear').hasClass('green')).toBe(true);
      expect($('.pear').hasClass('tasty')).toBe(true);

      $('.pear').removeClass('green');
      expect($('.pear').hasClass('fruit')).toBe(true);
      expect($('.pear').hasClass('green')).toBe(false);
      expect($('.pear').hasClass('tasty')).toBe(true);
    });

    it('(class class class) : should remove multiple classes from the element', () => {
      $('.apple').addClass('fruit red tasty');
      expect($('.apple').hasClass('apple')).toBe(true);
      expect($('.apple').hasClass('fruit')).toBe(true);
      expect($('.apple').hasClass('red')).toBe(true);
      expect($('.apple').hasClass('tasty')).toBe(true);

      $('.apple').removeClass('apple red tasty');
      expect($('.fruit').hasClass('apple')).toBe(false);
      expect($('.fruit').hasClass('red')).toBe(false);
      expect($('.fruit').hasClass('tasty')).toBe(false);
      expect($('.fruit').hasClass('fruit')).toBe(true);
    });

    it('(class) : should remove all occurrences of a class name', () => {
      const $div = cheerio('<div class="x x y x z"></div>');
      expect($div.removeClass('x').hasClass('x')).toBe(false);
    });

    it('(fn) : should remove classes returned from the function', () => {
      const $fruits = $('#fruits').children();
      const args: [number, string][] = [];
      const thisVals: Element[] = [];
      const toAdd = ['apple red', '', undefined];

      $fruits.removeClass(function (...myArgs) {
        args.push(myArgs);
        thisVals.push(this);
        return toAdd[myArgs[0]];
      });

      expect(args).toStrictEqual([
        [0, 'apple'],
        [1, 'orange'],
        [2, 'pear'],
      ]);
      expect(thisVals).toStrictEqual([$fruits[0], $fruits[1], $fruits[2]]);
      expect($fruits.eq(0).hasClass('apple')).toBe(false);
      expect($fruits.eq(0).hasClass('red')).toBe(false);
      expect($fruits.eq(1).hasClass('orange')).toBe(true);
      expect($fruits.eq(2).hasClass('pear')).toBe(true);
    });

    it('(fn) : should no op elements without attributes', () => {
      const $inputs = $(inputs);
      const val = $inputs.removeClass(() => 'tasty');
      expect(val).toHaveLength(15);
    });

    it('(fn) : should skip text nodes', () => {
      const $text = load(mixedText);
      const $body = $text($text('body')[0].children);

      $body.addClass(() => 'test');

      expect($text('body').html()).toBe(
        '<a class="test">1</a>TEXT<b class="test">2</b>',
      );

      $body.removeClass(() => 'test');

      expect($text('body').html()).toBe(
        '<a class="">1</a>TEXT<b class="">2</b>',
      );
    });
  });

  describe('.toggleClass', () => {
    let $: CheerioAPI;

    beforeEach(() => {
      $ = load(fruits);
    });

    it('(class class) : should toggle multiple classes from the element', () => {
      $('.apple').addClass('fruit');
      expect($('.apple').hasClass('apple')).toBe(true);
      expect($('.apple').hasClass('fruit')).toBe(true);
      expect($('.apple').hasClass('red')).toBe(false);

      $('.apple').toggleClass('apple red');
      expect($('.fruit').hasClass('apple')).toBe(false);
      expect($('.fruit').hasClass('red')).toBe(true);
      expect($('.fruit').hasClass('fruit')).toBe(true);

      // Mixed with text nodes
      const $red = $('<html>\n<ul class=one>\n</ul>\t</html>').toggleClass(
        'red',
      );
      expect($red).toHaveLength(3);
      expect($red.hasClass('red')).toBe(true);
      expect($red.hasClass('one')).toBe(true);
      $red.toggleClass('one');
      expect($red.hasClass('red')).toBe(true);
      expect($red.hasClass('one')).toBe(false);
    });

    it('(class class, true) : should add multiple classes to the element', () => {
      $('.apple').addClass('fruit');
      expect($('.apple').hasClass('apple')).toBe(true);
      expect($('.apple').hasClass('fruit')).toBe(true);
      expect($('.apple').hasClass('red')).toBe(false);

      $('.apple').toggleClass('apple red', true);
      expect($('.fruit').hasClass('apple')).toBe(true);
      expect($('.fruit').hasClass('red')).toBe(true);
      expect($('.fruit').hasClass('fruit')).toBe(true);
    });

    it('(class true) : should add only one instance of class', () => {
      $('.apple').toggleClass('tasty', true);
      $('.apple').toggleClass('tasty', true);
      expect($('.apple').attr('class')).toMatch(/tasty/g);
    });

    it('(class class, false) : should remove multiple classes from the element', () => {
      $('.apple').addClass('fruit');
      expect($('.apple').hasClass('apple')).toBe(true);
      expect($('.apple').hasClass('fruit')).toBe(true);
      expect($('.apple').hasClass('red')).toBe(false);

      $('.apple').toggleClass('apple red', false);
      expect($('.fruit').hasClass('apple')).toBe(false);
      expect($('.fruit').hasClass('red')).toBe(false);
      expect($('.fruit').hasClass('fruit')).toBe(true);
    });

    it('(fn) : should toggle classes returned from the function', () => {
      const $ = load(food);

      $('.apple').addClass('fruit');
      $('.carrot').addClass('vegetable');
      expect($('.apple').hasClass('fruit')).toBe(true);
      expect($('.apple').hasClass('vegetable')).toBe(false);
      expect($('.orange').hasClass('fruit')).toBe(false);
      expect($('.orange').hasClass('vegetable')).toBe(false);
      expect($('.carrot').hasClass('fruit')).toBe(false);
      expect($('.carrot').hasClass('vegetable')).toBe(true);
      expect($('.sweetcorn').hasClass('fruit')).toBe(false);
      expect($('.sweetcorn').hasClass('vegetable')).toBe(false);

      $('li').toggleClass(function () {
        return $(this).parent().is('#fruits') ? 'fruit' : 'vegetable';
      });
      expect($('.apple').hasClass('fruit')).toBe(false);
      expect($('.apple').hasClass('vegetable')).toBe(false);
      expect($('.orange').hasClass('fruit')).toBe(true);
      expect($('.orange').hasClass('vegetable')).toBe(false);
      expect($('.carrot').hasClass('fruit')).toBe(false);
      expect($('.carrot').hasClass('vegetable')).toBe(false);
      expect($('.sweetcorn').hasClass('fruit')).toBe(false);
      expect($('.sweetcorn').hasClass('vegetable')).toBe(true);
    });

    it('(fn) : should work with no initial class attribute', () => {
      const $inputs = load(inputs);
      $inputs('input, select').toggleClass(function () {
        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- `get` should never return undefined here.
        return $inputs(this).get(0)!.tagName === 'select'
          ? 'selectable'
          : 'inputable';
      });
      expect($inputs('.selectable')).toHaveLength(6);
      expect($inputs('.inputable')).toHaveLength(9);
    });

    it('(fn) : should skip text nodes', () => {
      const $text = load(mixedText);
      const $body = $text($text('body')[0].children);

      $body.toggleClass(() => 'test');

      expect($text('body').html()).toBe(
        '<a class="test">1</a>TEXT<b class="test">2</b>',
      );

      $body.toggleClass(() => 'test');

      expect($text('body').html()).toBe(
        '<a class="">1</a>TEXT<b class="">2</b>',
      );
    });

    it('(invalid) : should be a no-op for invalid inputs', () => {
      const original = $('.apple');
      const testAgainst = original.attr('class');
      expect(original.toggleClass().attr('class')).toStrictEqual(testAgainst);

      for (const value of [undefined, true, false, null, 0, 1, {}]) {
        expect(
          original.toggleClass(value as never).attr('class'),
        ).toStrictEqual(testAgainst);
      }
    });
  });
});
