/**
 * Methods for getting and modifying attributes.
 *
 * @module cheerio/attributes
 */

import { text } from '../static.js';
import { domEach, camelCase, cssCase } from '../utils.js';
import { isTag, type AnyNode, type Element } from 'domhandler';
import type { Cheerio } from '../cheerio.js';
import { innerText, textContent } from 'domutils';
import { ElementType } from 'htmlparser2';
const hasOwn =
  // @ts-expect-error `hasOwn` is a standard object method
  (Object.hasOwn as (object: unknown, prop: string) => boolean) ??
  ((object: unknown, prop: string) =>
    Object.prototype.hasOwnProperty.call(object, prop));
const rspace = /\s+/;
const dataAttrPrefix = 'data-';

// Attributes that are booleans
const rboolean =
  /^(?:autofocus|autoplay|async|checked|controls|defer|disabled|hidden|loop|multiple|open|readonly|required|scoped|selected)$/i;
// Matches strings that look like JSON objects or arrays
const rbrace = /^{[^]*}$|^\[[^]*]$/;

/**
 * Gets a node's attribute. For boolean attributes, it will return the value's
 * name should it be set.
 *
 * Also supports getting the `value` of several form elements.
 *
 * @private
 * @category Attributes
 * @param elem - Element to get the attribute of.
 * @param name - Name of the attribute.
 * @param xmlMode - Disable handling of special HTML attributes.
 * @returns The attribute's value.
 */
function getAttr(
  elem: AnyNode,
  name: undefined,
  xmlMode?: boolean,
): Record<string, string> | undefined;
function getAttr(
  elem: AnyNode,
  name: string,
  xmlMode?: boolean,
): string | undefined;
function getAttr(
  elem: AnyNode,
  name: string | undefined,
  xmlMode?: boolean,
): Record<string, string> | string | undefined {
  if (!elem || !isTag(elem)) return undefined;

  elem.attribs ??= {};

  // Return the entire attribs object if no attribute specified
  if (!name) {
    return elem.attribs;
  }

  if (hasOwn(elem.attribs, name)) {
    // Get the (decoded) attribute
    return !xmlMode && rboolean.test(name) ? name : elem.attribs[name];
  }

  // Mimic the DOM and return text content as value for `option's`
  if (elem.name === 'option' && name === 'value') {
    return text(elem.children);
  }

  // Mimic DOM with default value for radios/checkboxes
  if (
    elem.name === 'input' &&
    (elem.attribs['type'] === 'radio' || elem.attribs['type'] === 'checkbox') &&
    name === 'value'
  ) {
    return 'on';
  }

  return undefined;
}

/**
 * Sets the value of an attribute. The attribute will be deleted if the value is
 * `null`.
 *
 * @private
 * @param el - The element to set the attribute on.
 * @param name - The attribute's name.
 * @param value - The attribute's value.
 */
function setAttr(el: Element, name: string, value: string | null) {
  if (value === null) {
    removeAttribute(el, name);
  } else {
    el.attribs[name] = `${value}`;
  }
}

/**
 * Method for getting attributes. Gets the attribute value for only the first
 * element in the matched set.
 *
 * @category Attributes
 * @example
 *
 * ```js
 * $('ul').attr('id');
 * //=> fruits
 * ```
 *
 * @param name - Name of the attribute.
 * @returns The attribute's value.
 * @see {@link https://api.jquery.com/attr/}
 */
export function attr<T extends AnyNode>(
  this: Cheerio<T>,
  name: string,
): string | undefined;
/**
 * Method for getting all attributes and their values of the first element in
 * the matched set.
 *
 * @category Attributes
 * @example
 *
 * ```js
 * $('ul').attr();
 * //=> { id: 'fruits' }
 * ```
 *
 * @returns The attribute's values.
 * @see {@link https://api.jquery.com/attr/}
 */
export function attr<T extends AnyNode>(
  this: Cheerio<T>,
): Record<string, string> | undefined;
/**
 * Method for setting attributes. Sets the attribute value for all elements in
 * the matched set. If you set an attribute's value to `null`, you remove that
 * attribute. You may also pass a `map` and `function`.
 *
 * @category Attributes
 * @example
 *
 * ```js
 * $('.apple').attr('id', 'favorite').prop('outerHTML');
 * //=> <li class="apple" id="favorite">Apple</li>
 * ```
 *
 * @param name - Name of the attribute.
 * @param value - The new value of the attribute.
 * @returns The instance itself.
 * @see {@link https://api.jquery.com/attr/}
 */
export function attr<T extends AnyNode>(
  this: Cheerio<T>,
  name: string,
  value?:
    | string
    | null
    | ((this: Element, i: number, attrib: string) => string | null),
): Cheerio<T>;
/**
 * Method for setting multiple attributes at once. Sets the attribute value for
 * all elements in the matched set. If you set an attribute's value to `null`,
 * you remove that attribute.
 *
 * @category Attributes
 * @example
 *
 * ```js
 * $('.apple').attr({ id: 'favorite' }).prop('outerHTML');
 * //=> <li class="apple" id="favorite">Apple</li>
 * ```
 *
 * @param values - Map of attribute names and values.
 * @returns The instance itself.
 * @see {@link https://api.jquery.com/attr/}
 */
export function attr<T extends AnyNode>(
  this: Cheerio<T>,
  values: Record<string, string | null>,
): Cheerio<T>;
export function attr<T extends AnyNode>(
  this: Cheerio<T>,
  name?: string | Record<string, string | null>,
  value?:
    | string
    | null
    | ((this: Element, i: number, attrib: string) => string | null),
): string | Cheerio<T> | undefined | Record<string, string> {
  // Set the value (with attr map support)
  if (typeof name === 'object' || value !== undefined) {
    if (typeof value === 'function') {
      if (typeof name !== 'string') {
        {
          throw new Error('Bad combination of arguments.');
        }
      }
      return domEach(this, (el, i) => {
        if (isTag(el)) setAttr(el, name, value.call(el, i, el.attribs[name]));
      });
    }
    return domEach(this, (el) => {
      if (!isTag(el)) return;

      if (typeof name === 'object') {
        for (const objName of Object.keys(name)) {
          const objValue = name[objName];
          setAttr(el, objName, objValue);
        }
      } else {
        setAttr(el, name!, value!);
      }
    });
  }

  return arguments.length > 1
    ? this
    : getAttr(this[0], name!, this.options.xmlMode);
}

/**
 * Gets a node's prop.
 *
 * @private
 * @category Attributes
 * @param el - Element to get the prop of.
 * @param name - Name of the prop.
 * @param xmlMode - Disable handling of special HTML attributes.
 * @returns The prop's value.
 */
function getProp(
  el: Element,
  name: string,
  xmlMode?: boolean,
): string | undefined | boolean | Element[keyof Element] {
  return name in el
    ? // @ts-expect-error TS doesn't like us accessing the value directly here.
      (el[name] as string | undefined)
    : !xmlMode && rboolean.test(name)
      ? getAttr(el, name, false) !== undefined
      : getAttr(el, name, xmlMode);
}

/**
 * Sets the value of a prop.
 *
 * @private
 * @param el - The element to set the prop on.
 * @param name - The prop's name.
 * @param value - The prop's value.
 * @param xmlMode - Disable handling of special HTML attributes.
 */
function setProp(el: Element, name: string, value: unknown, xmlMode?: boolean) {
  if (name in el) {
    // @ts-expect-error Overriding value
    el[name] = value;
  } else {
    setAttr(
      el,
      name,
      !xmlMode && rboolean.test(name)
        ? value
          ? ''
          : null
        : `${value as string}`,
    );
  }
}

interface StyleProp {
  length: number;
  [key: string]: string | number;
  [index: number]: string;
}

/**
 * Method for getting and setting properties. Gets the property value for only
 * the first element in the matched set.
 *
 * @category Attributes
 * @example
 *
 * ```js
 * $('input[type="checkbox"]').prop('checked');
 * //=> false
 *
 * $('input[type="checkbox"]').prop('checked', true).val();
 * //=> ok
 * ```
 *
 * @param name - Name of the property.
 * @returns If `value` is specified the instance itself, otherwise the prop's
 *   value.
 * @see {@link https://api.jquery.com/prop/}
 */
export function prop<T extends AnyNode>(
  this: Cheerio<T>,
  name: 'tagName' | 'nodeName',
): string | undefined;
export function prop<T extends AnyNode>(
  this: Cheerio<T>,
  name: 'innerHTML' | 'outerHTML' | 'innerText' | 'textContent',
): string | null;
/**
 * Get a parsed CSS style object.
 *
 * @param name - Name of the property.
 * @returns The style object, or `undefined` if the element has no `style`
 *   attribute.
 */
export function prop<T extends AnyNode>(
  this: Cheerio<T>,
  name: 'style',
): StyleProp | undefined;
/**
 * Resolve `href` or `src` of supported elements. Requires the `baseURI` option
 * to be set, and a global `URL` object to be part of the environment.
 *
 * @example With `baseURI` set to `'https://example.com'`:
 *
 * ```js
 * $('<img src="image.png">').prop('src');
 * //=> 'https://example.com/image.png'
 * ```
 *
 * @param name - Name of the property.
 * @returns The resolved URL, or `undefined` if the element is not supported.
 */
export function prop<T extends AnyNode>(
  this: Cheerio<T>,
  name: 'href' | 'src',
): string | undefined;
/**
 * Get a property of an element.
 *
 * @param name - Name of the property.
 * @returns The property's value.
 */
export function prop<T extends AnyNode, K extends keyof Element>(
  this: Cheerio<T>,
  name: K,
): Element[K];
/**
 * Set a property of an element.
 *
 * @param name - Name of the property.
 * @param value - Value to set the property to.
 * @returns The instance itself.
 */
export function prop<T extends AnyNode, K extends keyof Element>(
  this: Cheerio<T>,
  name: K,
  value:
    | Element[K]
    | ((this: Element, i: number, prop: K) => Element[keyof Element]),
): Cheerio<T>;
/**
 * Set multiple properties of an element.
 *
 * @example
 *
 * ```js
 * $('input[type="checkbox"]').prop({
 *   checked: true,
 *   disabled: false,
 * });
 * ```
 *
 * @param map - Object of properties to set.
 * @returns The instance itself.
 */
export function prop<T extends AnyNode>(
  this: Cheerio<T>,
  map: Record<string, string | Element[keyof Element] | boolean>,
): Cheerio<T>;
/**
 * Set a property of an element.
 *
 * @param name - Name of the property.
 * @param value - Value to set the property to.
 * @returns The instance itself.
 */
export function prop<T extends AnyNode>(
  this: Cheerio<T>,
  name: string,
  value:
    | string
    | boolean
    | null
    | ((this: Element, i: number, prop: string) => string | boolean),
): Cheerio<T>;
/**
 * Get a property of an element.
 *
 * @param name - The property's name.
 * @returns The property's value.
 */
export function prop<T extends AnyNode>(this: Cheerio<T>, name: string): string;
export function prop<T extends AnyNode>(
  this: Cheerio<T>,
  name: string | Record<string, string | Element[keyof Element] | boolean>,
  value?: unknown,
):
  | Cheerio<T>
  | string
  | boolean
  | undefined
  | null
  | Element[keyof Element]
  | StyleProp {
  if (typeof name === 'string' && value === undefined) {
    const el = this[0];

    if (!el) return undefined;

    switch (name) {
      case 'style': {
        const property = this.css() as StyleProp;
        const keys = Object.keys(property);
        for (let i = 0; i < keys.length; i++) {
          property[i] = keys[i];
        }

        property.length = keys.length;

        return property;
      }
      case 'tagName':
      case 'nodeName': {
        if (!isTag(el)) return undefined;
        return el.name.toUpperCase();
      }

      case 'href':
      case 'src': {
        if (!isTag(el)) return undefined;
        const prop = el.attribs?.[name];

        if (
          typeof URL !== 'undefined' &&
          ((name === 'href' && (el.tagName === 'a' || el.tagName === 'link')) ||
            (name === 'src' &&
              (el.tagName === 'img' ||
                el.tagName === 'iframe' ||
                el.tagName === 'audio' ||
                el.tagName === 'video' ||
                el.tagName === 'source'))) &&
          prop !== undefined &&
          this.options.baseURI
        ) {
          return new URL(prop, this.options.baseURI).href;
        }

        return prop;
      }

      case 'innerText': {
        return innerText(el);
      }

      case 'textContent': {
        return textContent(el);
      }

      case 'outerHTML': {
        if (el.type === ElementType.Root) return this.html();
        return this.clone().wrap('<container />').parent().html();
      }

      case 'innerHTML': {
        return this.html();
      }

      default: {
        if (!isTag(el)) return undefined;
        return getProp(el, name, this.options.xmlMode);
      }
    }
  }

  if (typeof name === 'object' || value !== undefined) {
    if (typeof value === 'function') {
      if (typeof name === 'object') {
        throw new TypeError('Bad combination of arguments.');
      }
      return domEach(this, (el, i) => {
        if (isTag(el)) {
          setProp(
            el,
            name,
            value.call(el, i, getProp(el, name, this.options.xmlMode)),
            this.options.xmlMode,
          );
        }
      });
    }

    return domEach(this, (el) => {
      if (!isTag(el)) return;

      if (typeof name === 'object') {
        for (const key of Object.keys(name)) {
          const val = name[key];
          setProp(el, key, val, this.options.xmlMode);
        }
      } else {
        setProp(el, name, value, this.options.xmlMode);
      }
    });
  }

  return undefined;
}

/**
 * An element with a data attribute.
 *
 * @private
 */
interface DataElement extends Element {
  /** The data attribute. */
  data?: Record<string, unknown>;
}

/**
 * Sets the value of a data attribute.
 *
 * @private
 * @param elem - The element to set the data attribute on.
 * @param name - The data attribute's name.
 * @param value - The data attribute's value.
 */
function setData(
  elem: DataElement,
  name: string | Record<string, unknown>,
  value?: unknown,
) {
  elem.data ??= {};

  if (typeof name === 'object') Object.assign(elem.data, name);
  else if (typeof name === 'string' && value !== undefined) {
    elem.data[name] = value;
  }
}

/**
 * Read _all_ HTML5 `data-*` attributes from the equivalent HTML5 `data-*`
 * attribute, and cache the value in the node's internal data store.
 *
 * @private
 * @category Attributes
 * @param el - Element to get the data attribute of.
 * @returns A map with all of the data attributes.
 */
function readAllData(el: DataElement): unknown {
  for (const domName of Object.keys(el.attribs)) {
    if (!domName.startsWith(dataAttrPrefix)) {
      continue;
    }

    const jsName = camelCase(domName.slice(dataAttrPrefix.length));

    if (!hasOwn(el.data, jsName)) {
      el.data![jsName] = parseDataValue(el.attribs[domName]);
    }
  }

  return el.data;
}

/**
 * Read the specified attribute from the equivalent HTML5 `data-*` attribute,
 * and (if present) cache the value in the node's internal data store.
 *
 * @private
 * @category Attributes
 * @param el - Element to get the data attribute of.
 * @param name - Name of the data attribute.
 * @returns The data attribute's value.
 */
function readData(el: DataElement, name: string): unknown {
  const domName = dataAttrPrefix + cssCase(name);
  const data = el.data!;

  if (hasOwn(data, name)) {
    return data[name];
  }

  if (hasOwn(el.attribs, domName)) {
    return (data[name] = parseDataValue(el.attribs[domName]));
  }

  return undefined;
}

/**
 * Coerce string data-* attributes to their corresponding JavaScript primitives.
 *
 * @private
 * @category Attributes
 * @param value - The value to parse.
 * @returns The parsed value.
 */
function parseDataValue(value: string): unknown {
  if (value === 'null') return null;
  if (value === 'true') return true;
  if (value === 'false') return false;
  const num = Number(value);
  if (value === String(num)) return num;
  if (rbrace.test(value)) {
    try {
      return JSON.parse(value);
    } catch {
      /* Ignore */
    }
  }
  return value;
}

/**
 * Method for getting data attributes, for only the first element in the matched
 * set.
 *
 * @category Attributes
 * @example
 *
 * ```js
 * $('<div data-apple-color="red"></div>').data('apple-color');
 * //=> 'red'
 * ```
 *
 * @param name - Name of the data attribute.
 * @returns The data attribute's value, or `undefined` if the attribute does not
 *   exist.
 * @see {@link https://api.jquery.com/data/}
 */
export function data<T extends AnyNode>(
  this: Cheerio<T>,
  name: string,
): unknown;
/**
 * Method for getting all of an element's data attributes, for only the first
 * element in the matched set.
 *
 * @category Attributes
 * @example
 *
 * ```js
 * $('<div data-apple-color="red"></div>').data();
 * //=> { appleColor: 'red' }
 * ```
 *
 * @returns A map with all of the data attributes.
 * @see {@link https://api.jquery.com/data/}
 */
export function data<T extends AnyNode>(
  this: Cheerio<T>,
): Record<string, unknown>;
/**
 * Method for setting data attributes, for only the first element in the matched
 * set.
 *
 * @category Attributes
 * @example
 *
 * ```js
 * const apple = $('.apple').data('kind', 'mac');
 *
 * apple.data('kind');
 * //=> 'mac'
 * ```
 *
 * @param name - Name of the data attribute.
 * @param value - The new value.
 * @returns The instance itself.
 * @see {@link https://api.jquery.com/data/}
 */
export function data<T extends AnyNode>(
  this: Cheerio<T>,
  name: string,
  value: unknown,
): Cheerio<T>;
/**
 * Method for setting multiple data attributes at once, for only the first
 * element in the matched set.
 *
 * @category Attributes
 * @example
 *
 * ```js
 * const apple = $('.apple').data({ kind: 'mac' });
 *
 * apple.data('kind');
 * //=> 'mac'
 * ```
 *
 * @param values - Map of names to values.
 * @returns The instance itself.
 * @see {@link https://api.jquery.com/data/}
 */
export function data<T extends AnyNode>(
  this: Cheerio<T>,
  values: Record<string, unknown>,
): Cheerio<T>;
export function data<T extends AnyNode>(
  this: Cheerio<T>,
  name?: string | Record<string, unknown>,
  value?: unknown,
): unknown {
  const elem = this[0];

  if (!elem || !isTag(elem)) return;

  const dataEl: DataElement = elem;
  dataEl.data ??= {};

  // Return the entire data object if no data specified
  if (name == null) {
    return readAllData(dataEl);
  }

  // Set the value (with attr map support)
  if (typeof name === 'object' || value !== undefined) {
    domEach(this, (el) => {
      if (isTag(el)) {
        if (typeof name === 'object') setData(el, name);
        else setData(el, name, value);
      }
    });
    return this;
  }

  return readData(dataEl, name);
}

/**
 * Method for getting the value of input, select, and textarea. Note: Support
 * for `map`, and `function` has not been added yet.
 *
 * @category Attributes
 * @example
 *
 * ```js
 * $('input[type="text"]').val();
 * //=> input_text
 * ```
 *
 * @returns The value.
 * @see {@link https://api.jquery.com/val/}
 */
export function val<T extends AnyNode>(
  this: Cheerio<T>,
): string | undefined | string[];
/**
 * Method for setting the value of input, select, and textarea. Note: Support
 * for `map`, and `function` has not been added yet.
 *
 * @category Attributes
 * @example
 *
 * ```js
 * $('input[type="text"]').val('test').prop('outerHTML');
 * //=> <input type="text" value="test"/>
 * ```
 *
 * @param value - The new value.
 * @returns The instance itself.
 * @see {@link https://api.jquery.com/val/}
 */
export function val<T extends AnyNode>(
  this: Cheerio<T>,
  value: string | string[],
): Cheerio<T>;
export function val<T extends AnyNode>(
  this: Cheerio<T>,
  value?: string | string[],
): string | string[] | Cheerio<T> | undefined {
  const querying = arguments.length === 0;
  const element = this[0];

  if (!element || !isTag(element)) return querying ? undefined : this;

  switch (element.name) {
    case 'textarea': {
      return this.text(value as string);
    }
    case 'select': {
      const option = this.find('option:selected');
      if (!querying) {
        if (this.attr('multiple') == null && typeof value === 'object') {
          return this;
        }

        this.find('option').removeAttr('selected');

        const values = typeof value === 'object' ? value : [value];
        for (const val of values) {
          this.find(`option[value="${val}"]`).attr('selected', '');
        }

        return this;
      }

      return this.attr('multiple')
        ? option.toArray().map((el) => text(el.children))
        : option.attr('value');
    }
    case 'input':
    case 'option': {
      return querying
        ? this.attr('value')
        : this.attr('value', value as string);
    }
  }

  return undefined;
}

/**
 * Remove an attribute.
 *
 * @private
 * @param elem - Node to remove attribute from.
 * @param name - Name of the attribute to remove.
 */
function removeAttribute(elem: Element, name: string) {
  if (!elem.attribs || !hasOwn(elem.attribs, name)) return;

  delete elem.attribs[name];
}

/**
 * Splits a space-separated list of names to individual names.
 *
 * @category Attributes
 * @param names - Names to split.
 * @returns - Split names.
 */
function splitNames(names?: string): string[] {
  return names ? names.trim().split(rspace) : [];
}

/**
 * Method for removing attributes by `name`.
 *
 * @category Attributes
 * @example
 *
 * ```js
 * $('.pear').removeAttr('class').prop('outerHTML');
 * //=> <li>Pear</li>
 *
 * $('.apple').attr('id', 'favorite');
 * $('.apple').removeAttr('id class').prop('outerHTML');
 * //=> <li>Apple</li>
 * ```
 *
 * @param name - Name of the attribute.
 * @returns The instance itself.
 * @see {@link https://api.jquery.com/removeAttr/}
 */
export function removeAttr<T extends AnyNode>(
  this: Cheerio<T>,
  name: string,
): Cheerio<T> {
  const attrNames = splitNames(name);

  for (const attrName of attrNames) {
    domEach(this, (elem) => {
      if (isTag(elem)) removeAttribute(elem, attrName);
    });
  }

  return this;
}

/**
 * Check to see if _any_ of the matched elements have the given `className`.
 *
 * @category Attributes
 * @example
 *
 * ```js
 * $('.pear').hasClass('pear');
 * //=> true
 *
 * $('apple').hasClass('fruit');
 * //=> false
 *
 * $('li').hasClass('pear');
 * //=> true
 * ```
 *
 * @param className - Name of the class.
 * @returns Indicates if an element has the given `className`.
 * @see {@link https://api.jquery.com/hasClass/}
 */
export function hasClass<T extends AnyNode>(
  this: Cheerio<T>,
  className: string,
): boolean {
  return this.toArray().some((elem) => {
    const clazz = isTag(elem) && elem.attribs['class'];
    let idx = -1;

    if (clazz && className.length > 0) {
      while ((idx = clazz.indexOf(className, idx + 1)) > -1) {
        const end = idx + className.length;

        if (
          (idx === 0 || rspace.test(clazz[idx - 1])) &&
          (end === clazz.length || rspace.test(clazz[end]))
        ) {
          return true;
        }
      }
    }

    return false;
  });
}

/**
 * Adds class(es) to all of the matched elements. Also accepts a `function`.
 *
 * @category Attributes
 * @example
 *
 * ```js
 * $('.pear').addClass('fruit').prop('outerHTML');
 * //=> <li class="pear fruit">Pear</li>
 *
 * $('.apple').addClass('fruit red').prop('outerHTML');
 * //=> <li class="apple fruit red">Apple</li>
 * ```
 *
 * @param value - Name of new class.
 * @returns The instance itself.
 * @see {@link https://api.jquery.com/addClass/}
 */
export function addClass<T extends AnyNode, R extends ArrayLike<T>>(
  this: R,
  value?:
    | string
    | ((this: Element, i: number, className: string) => string | undefined),
): R {
  // Support functions
  if (typeof value === 'function') {
    return domEach(this, (el, i) => {
      if (isTag(el)) {
        const className = el.attribs['class'] || '';
        addClass.call([el], value.call(el, i, className));
      }
    });
  }

  // Return if no value or not a string or function
  if (!value || typeof value !== 'string') return this;

  const classNames = value.split(rspace);
  const numElements = this.length;

  for (let i = 0; i < numElements; i++) {
    const el = this[i];
    // If selected element isn't a tag, move on
    if (!isTag(el)) continue;

    // If we don't already have classes — always set xmlMode to false here, as it doesn't matter for classes
    const className = getAttr(el, 'class', false);

    if (className) {
      let setClass = ` ${className} `;

      // Check if class already exists
      for (const cn of classNames) {
        const appendClass = `${cn} `;
        if (!setClass.includes(` ${appendClass}`)) setClass += appendClass;
      }

      setAttr(el, 'class', setClass.trim());
    } else {
      setAttr(el, 'class', classNames.join(' ').trim());
    }
  }

  return this;
}

/**
 * Removes one or more space-separated classes from the selected elements. If no
 * `className` is defined, all classes will be removed. Also accepts a
 * `function`.
 *
 * @category Attributes
 * @example
 *
 * ```js
 * $('.pear').removeClass('pear').prop('outerHTML');
 * //=> <li class="">Pear</li>
 *
 * $('.apple').addClass('red').removeClass().prop('outerHTML');
 * //=> <li class="">Apple</li>
 * ```
 *
 * @param name - Name of the class. If not specified, removes all elements.
 * @returns The instance itself.
 * @see {@link https://api.jquery.com/removeClass/}
 */
export function removeClass<T extends AnyNode, R extends ArrayLike<T>>(
  this: R,
  name?:
    | string
    | ((this: Element, i: number, className: string) => string | undefined),
): R {
  // Handle if value is a function
  if (typeof name === 'function') {
    return domEach(this, (el, i) => {
      if (isTag(el)) {
        removeClass.call([el], name.call(el, i, el.attribs['class'] || ''));
      }
    });
  }

  const classes = splitNames(name);
  const numClasses = classes.length;
  const removeAll = arguments.length === 0;

  return domEach(this, (el) => {
    if (!isTag(el)) return;

    if (removeAll) {
      // Short circuit the remove all case as this is the nice one
      el.attribs['class'] = '';
    } else {
      const elClasses = splitNames(el.attribs['class']);
      let changed = false;

      for (let j = 0; j < numClasses; j++) {
        const index = elClasses.indexOf(classes[j]);

        if (index !== -1) {
          elClasses.splice(index, 1);
          changed = true;

          /*
           * We have to do another pass to ensure that there are not duplicate
           * classes listed
           */
          j--;
        }
      }
      if (changed) {
        el.attribs['class'] = elClasses.join(' ');
      }
    }
  });
}

/**
 * Add or remove class(es) from the matched elements, depending on either the
 * class's presence or the value of the switch argument. Also accepts a
 * `function`.
 *
 * @category Attributes
 * @example
 *
 * ```js
 * $('.apple.green').toggleClass('fruit green red').prop('outerHTML');
 * //=> <li class="apple fruit red">Apple</li>
 *
 * $('.apple.green').toggleClass('fruit green red', true).prop('outerHTML');
 * //=> <li class="apple green fruit red">Apple</li>
 * ```
 *
 * @param value - Name of the class. Can also be a function.
 * @param stateVal - If specified the state of the class.
 * @returns The instance itself.
 * @see {@link https://api.jquery.com/toggleClass/}
 */
export function toggleClass<T extends AnyNode, R extends ArrayLike<T>>(
  this: R,
  value?:
    | string
    | ((
        this: Element,
        i: number,
        className: string,
        stateVal?: boolean,
      ) => string),
  stateVal?: boolean,
): R {
  // Support functions
  if (typeof value === 'function') {
    return domEach(this, (el, i) => {
      if (isTag(el)) {
        toggleClass.call(
          [el],
          value.call(el, i, el.attribs['class'] || '', stateVal),
          stateVal,
        );
      }
    });
  }

  // Return if no value or not a string or function
  if (!value || typeof value !== 'string') return this;

  const classNames = value.split(rspace);
  const numClasses = classNames.length;
  const state = typeof stateVal === 'boolean' ? (stateVal ? 1 : -1) : 0;
  const numElements = this.length;

  for (let i = 0; i < numElements; i++) {
    const el = this[i];
    // If selected element isn't a tag, move on
    if (!isTag(el)) continue;

    const elementClasses = splitNames(el.attribs['class']);

    // Check if class already exists
    for (let j = 0; j < numClasses; j++) {
      // Check if the class name is currently defined
      const index = elementClasses.indexOf(classNames[j]);

      // Add if stateValue === true or we are toggling and there is no value
      if (state >= 0 && index === -1) {
        elementClasses.push(classNames[j]);
      } else if (state <= 0 && index !== -1) {
        // Otherwise remove but only if the item exists
        elementClasses.splice(index, 1);
      }
    }

    el.attribs['class'] = elementClasses.join(' ');
  }

  return this;
}
