/**
 * parser.js
 */

/* import */
import * as cssTree from 'css-tree';
import { getType } from './utility.js';

/* constants */
import {
  ATTR_SELECTOR,
  BIT_01,
  BIT_02,
  BIT_04,
  BIT_08,
  BIT_16,
  BIT_32,
  BIT_FFFF,
  CLASS_SELECTOR,
  COMBINATOR,
  DUO,
  HEX,
  ID_SELECTOR,
  KEYS_LOGICAL,
  KEYS_PS_CLASS_SUPPORTED,
  NEST_SELECTOR,
  NTH,
  PS_CLASS_SELECTOR,
  PS_ELEMENT_SELECTOR,
  SELECTOR,
  SYNTAX_ERR,
  TYPE_SELECTOR
} from './constant.js';
const AST_SORT_ORDER = new Map([
  [PS_ELEMENT_SELECTOR, BIT_01],
  [ID_SELECTOR, BIT_02],
  [CLASS_SELECTOR, BIT_04],
  [TYPE_SELECTOR, BIT_08],
  [ATTR_SELECTOR, BIT_16],
  [PS_CLASS_SELECTOR, BIT_32]
]);
const KEYS_PS_NTH = new Set([
  'first-child',
  'first-of-type',
  'last-child',
  'last-of-type',
  'only-child',
  'only-of-type'
]);
const KEYS_PS_CLASS_STATE = new Set([
  'checked',
  'closed',
  'disabled',
  'empty',
  'enabled',
  'in-range',
  'indeterminate',
  'invalid',
  'open',
  'out-of-range',
  'placeholder-shown',
  'read-only',
  'read-write',
  'valid',
  ...KEYS_PS_NTH
]);
const KEYS_SHADOW_HOST = new Set(['host', 'host-context']);
const U_FFFD = '\uFFFD';

/* regexp */
const REG_EMPTY_PS_FUNC =
  /(?<=:(?:dir|has|host(?:-context)?|is|lang|not|nth-(?:last-)?(?:child|of-type)|where))\(\s+\)/g;
const REG_SHADOW_PS_ELEMENT = /^part|slotted$/;

/**
 * Unescapes a CSS selector string.
 * @param {string} selector - The CSS selector to unescape.
 * @returns {string} The unescaped selector string.
 */
export const unescapeSelector = (selector = '') => {
  if (typeof selector === 'string' && selector.indexOf('\\', 0) >= 0) {
    const arr = selector.split('\\');
    const selectorItems = [arr[0]];
    const l = arr.length;
    for (let i = 1; i < l; i++) {
      const item = arr[i];
      if (item === '' && i === l - 1) {
        selectorItems.push(U_FFFD);
      } else if (item === '') {
        // Empty segment at non-last position means \\ (escaped backslash)
        selectorItems.push('\\');
        // skip the next segment which is the remainder after \\
        i++;
        if (i < l) {
          selectorItems.push(arr[i]);
        }
      } else {
        const hexExists = /^([\da-f]{1,6}\s?)/i.exec(item);
        if (hexExists) {
          const [, hex] = hexExists;
          let str;
          try {
            const low = parseInt('D800', HEX);
            const high = parseInt('DFFF', HEX);
            const deci = parseInt(hex, HEX);
            if (deci === 0 || (deci >= low && deci <= high)) {
              str = U_FFFD;
            } else {
              str = String.fromCodePoint(deci);
            }
          } catch (e) {
            str = U_FFFD;
          }
          let postStr = '';
          if (item.length > hex.length) {
            postStr = item.substring(hex.length);
          }
          selectorItems.push(`${str}${postStr}`);
        } else if (/^[\n\r\f]/.test(item)) {
          // whitespace
          selectorItems.push(`\\${item}`);
        } else {
          selectorItems.push(item);
        }
      }
    }
    return selectorItems.join('');
  }
  return selector;
};

/**
 * Preprocesses a selector string according to the specification.
 * @see https://drafts.csswg.org/css-syntax-3/#input-preprocessing
 * @param {string} value - The value to preprocess.
 * @returns {string} The preprocessed selector string.
 */
export const preprocess = value => {
  // Non-string values will be converted to string.
  if (typeof value !== 'string') {
    if (value === undefined || value === null) {
      return getType(value).toLowerCase();
    } else if (Array.isArray(value)) {
      return value.join(',');
    } else if (Object.hasOwn(value, 'toString')) {
      return value.toString();
    } else {
      throw new DOMException(`Invalid selector ${value}`, SYNTAX_ERR);
    }
  }
  let selector = value;
  let index = 0;
  while (index >= 0) {
    // @see https://drafts.csswg.org/selectors/#id-selectors
    index = selector.indexOf('#', index);
    if (index < 0) {
      break;
    }
    const preHash = selector.substring(0, index + 1);
    let postHash = selector.substring(index + 1);
    const codePoint = postHash.codePointAt(0);
    if (codePoint > BIT_FFFF) {
      const str = `\\${codePoint.toString(HEX)} `;
      if (postHash.length === DUO) {
        postHash = str;
      } else {
        postHash = `${str}${postHash.substring(DUO)}`;
      }
    }
    selector = `${preHash}${postHash}`;
    index++;
  }
  selector = selector
    .replace(/\f|\r\n?/g, '\n')
    .replace(/[\0\uD800-\uDFFF]|\\$/g, U_FFFD);
  if (selector === '&') {
    return '';
  }
  return selector;
};

/**
 * Creates an Abstract Syntax Tree (AST) from a CSS selector string.
 * @param {string} sel - The CSS selector string.
 * @param {string} context - Parse what part of CSS.
 * @returns {object} The parsed AST object.
 */
export const parseSelector = (sel, context = 'selectorList') => {
  const selector = preprocess(sel);
  // invalid selectors
  if (/^$|^\s*>|,\s*$/.test(selector)) {
    throw new DOMException(`Invalid selector ${selector}`, SYNTAX_ERR);
  }
  try {
    return cssTree.parse(selector, { context });
  } catch (e) {
    const { message } = e;
    if (
      /^(?:"\]"|Attribute selector [()\s,=~^$*|]+) is expected$/.test(
        message
      ) &&
      !selector.endsWith(']')
    ) {
      const index = selector.lastIndexOf('[');
      const selPart = selector.substring(index);
      if (selPart.includes('"')) {
        const quotes = selPart.match(/"/g).length;
        if (quotes % 2) {
          return parseSelector(`${selector}"]`);
        }
        return parseSelector(`${selector}]`);
      }
      return parseSelector(`${selector}]`);
    } else if (message === '")" is expected') {
      // workaround for https://github.com/csstree/csstree/issues/283
      if (REG_EMPTY_PS_FUNC.test(selector)) {
        return parseSelector(`${selector.replaceAll(REG_EMPTY_PS_FUNC, '()')}`);
      } else if (!selector.endsWith(')')) {
        return parseSelector(`${selector})`);
      } else {
        throw new DOMException(`Invalid selector ${selector}`, SYNTAX_ERR);
      }
    } else {
      throw new DOMException(`Invalid selector ${selector}`, SYNTAX_ERR);
    }
  }
};

/**
 * Walks the provided AST to collect selector branches and gather information
 * about its contents.
 * @param {object} ast - The AST to traverse.
 * @param {boolean} toObject - True if converts ast to object, false otherwise.
 * @param {function(object): void} [callback] - Optional callback for each node.
 * @returns {{branches: Array<object>, info: object}} An object containing the selector branches and info.
 */
export const walkAST = (ast = {}, toObject = false, callback = null) => {
  const branches = new Set();
  const info = {
    hasForgivenPseudoFunc: false,
    hasHasPseudoFunc: false,
    hasLogicalPseudoFunc: false,
    hasNestedSelector: false,
    hasNestingSelector: false,
    hasNotPseudoFunc: false,
    hasNthChildOfSelector: false,
    hasStatePseudoClass: false,
    hasUnsupportedPseudoClass: false
  };
  const opt = {
    enter(node) {
      if (typeof callback === 'function') {
        callback(node);
      }
      switch (node.type) {
        case CLASS_SELECTOR: {
          if (/^-?\d/.test(node.name)) {
            throw new DOMException(
              `Invalid selector .${node.name}`,
              SYNTAX_ERR
            );
          }
          break;
        }
        case ID_SELECTOR: {
          if (/^-?\d/.test(node.name)) {
            throw new DOMException(
              `Invalid selector #${node.name}`,
              SYNTAX_ERR
            );
          }
          break;
        }
        case PS_CLASS_SELECTOR: {
          const name = node.name.toLowerCase();
          if (KEYS_LOGICAL.has(name)) {
            info.hasNestedSelector = true;
            info.hasLogicalPseudoFunc = true;
            if (name === 'has') {
              info.hasHasPseudoFunc = true;
            } else if (name === 'not') {
              info.hasNotPseudoFunc = true;
            } else {
              info.hasForgivenPseudoFunc = true;
            }
          } else if (KEYS_PS_CLASS_STATE.has(name)) {
            info.hasStatePseudoClass = true;
          } else if (
            KEYS_SHADOW_HOST.has(name) &&
            Array.isArray(node.children) &&
            node.children.length
          ) {
            info.hasNestedSelector = true;
          } else if (!KEYS_PS_CLASS_SUPPORTED.has(name)) {
            info.hasUnsupportedPseudoClass = true;
          }
          break;
        }
        case PS_ELEMENT_SELECTOR: {
          if (REG_SHADOW_PS_ELEMENT.test(node.name)) {
            info.hasNestedSelector = true;
          }
          break;
        }
        case NTH: {
          if (node.selector) {
            info.hasNestedSelector = true;
            info.hasNthChildOfSelector = true;
          }
          break;
        }
        case SELECTOR: {
          branches.add(node.children);
          break;
        }
        case NEST_SELECTOR: {
          info.hasNestingSelector = true;
          break;
        }
        default:
      }
    }
  };
  const clonedAst = cssTree.clone(ast);
  cssTree.walk(toObject ? cssTree.toPlainObject(clonedAst) : clonedAst, opt);
  if (info.hasNestedSelector === true) {
    cssTree.findAll(clonedAst, (node, item, list) => {
      if (list) {
        if (node.type === PS_CLASS_SELECTOR && KEYS_LOGICAL.has(node.name)) {
          const itemList = list.filter(i => {
            const { name, type } = i;
            return type === PS_CLASS_SELECTOR && KEYS_LOGICAL.has(name);
          });
          for (const { children } of itemList) {
            // SelectorList
            for (const { children: grandChildren } of children) {
              // Selector
              for (const { children: greatGrandChildren } of grandChildren) {
                if (branches.has(greatGrandChildren)) {
                  branches.delete(greatGrandChildren);
                }
              }
            }
          }
        } else if (
          node.type === PS_CLASS_SELECTOR &&
          KEYS_SHADOW_HOST.has(node.name) &&
          Array.isArray(node.children) &&
          node.children.length
        ) {
          const itemList = list.filter(i => {
            const { children, name, type } = i;
            const res =
              type === PS_CLASS_SELECTOR &&
              KEYS_SHADOW_HOST.has(name) &&
              Array.isArray(children) &&
              children.length;
            return res;
          });
          for (const { children } of itemList) {
            // Selector
            for (const { children: grandChildren } of children) {
              if (branches.has(grandChildren)) {
                branches.delete(grandChildren);
              }
            }
          }
        } else if (
          node.type === PS_ELEMENT_SELECTOR &&
          REG_SHADOW_PS_ELEMENT.test(node.name)
        ) {
          const itemList = list.filter(i => {
            const { name, type } = i;
            const res =
              type === PS_ELEMENT_SELECTOR && REG_SHADOW_PS_ELEMENT.test(name);
            return res;
          });
          for (const { children } of itemList) {
            // Selector
            for (const { children: grandChildren } of children) {
              if (branches.has(grandChildren)) {
                branches.delete(grandChildren);
              }
            }
          }
        } else if (node.type === NTH && node.selector) {
          const itemList = list.filter(i => {
            const { selector, type } = i;
            const res = type === NTH && selector;
            return res;
          });
          for (const { selector } of itemList) {
            const { children } = selector;
            // Selector
            for (const { children: grandChildren } of children) {
              if (branches.has(grandChildren)) {
                branches.delete(grandChildren);
              }
            }
          }
        }
      }
    });
  }
  return {
    info,
    branches: [...branches]
  };
};

/**
 * Comparison function for sorting AST nodes based on specificity.
 * @param {object} a - The first AST node.
 * @param {object} b - The second AST node.
 * @returns {number} -1, 0 or 1, depending on the sort order.
 */
export const compareASTNodes = (a, b) => {
  const bitA = AST_SORT_ORDER.get(a.type);
  const bitB = AST_SORT_ORDER.get(b.type);
  if (bitA === bitB) {
    return 0;
  } else if (bitA > bitB) {
    return 1;
  } else {
    return -1;
  }
};

/**
 * Sorts a collection of AST nodes based on CSS specificity rules.
 * @param {Array<object>} asts - A collection of AST nodes to sort.
 * @returns {Array<object>} A new array containing the sorted AST nodes.
 */
export const sortAST = asts => {
  const arr = [...asts];
  if (arr.length > 1) {
    arr.sort(compareASTNodes);
  }
  return arr;
};

/**
 * Parses a type selector's name, which may include a namespace prefix.
 * @param {string} selector - The type selector name (e.g., 'ns|E' or 'E').
 * @returns {{prefix: string, localName: string}} An object with `prefix` and
 * `localName` properties.
 */
export const parseAstName = selector => {
  let prefix;
  let localName;
  if (selector && typeof selector === 'string') {
    if (selector.indexOf('|') > -1) {
      [prefix, localName] = selector.split('|');
    } else {
      prefix = '*';
      localName = selector;
    }
  } else {
    throw new DOMException(`Invalid selector ${selector}`, SYNTAX_ERR);
  }
  return {
    prefix,
    localName
  };
};

/**
 * Extracts the rightmost subject keys (id, class, tag) from a CSS selector AST.
 * @param {object} ast - The AST representation of the CSS selector.
 * @returns {Array<{id: string|null, className: string|null, tag: string|null}>} The list of extracted keys for each selector group.
 */
export const extractSubjectsAst = ast => {
  const subjects = [];
  if (ast?.type === 'SelectorList') {
    for (const selectorNode of ast.children) {
      let idKey = null;
      let classKey = null;
      let tagKey = null;
      let current = selectorNode.children.tail;
      while (current) {
        const node = current.data;
        if (node.type === COMBINATOR) {
          break;
        }
        if (node.type === ID_SELECTOR) {
          idKey = idKey ?? unescapeSelector(node.name);
        } else if (node.type === CLASS_SELECTOR) {
          classKey = classKey ?? unescapeSelector(node.name);
        } else if (node.type === TYPE_SELECTOR) {
          const { localName } = parseAstName(unescapeSelector(node.name));
          if (localName !== '*') {
            tagKey = tagKey ?? localName.toLowerCase();
          }
        }
        current = current.prev;
      }
      subjects.push({ id: idKey, className: classKey, tag: tagKey });
    }
  }
  return subjects;
};

/* Re-exported from css-tree. */
export { find as findAST, generate as generateCSS } from 'css-tree';
