/**
 * css-calc-var
 */

import { calc, conversionOptions as CalcOptions } from '@csstools/css-calc';
import { CSSToken, TokenType, tokenize } from '@csstools/css-tokenizer';
import { createCacheKey, getCache, setCache } from './cache';
import { isString, isStringOrNumber } from './common';
import { isColor } from './resolve';
import { resolveLengthInPixels, roundToPrecision } from './util';
import { MatchedRegExp, Options } from './typedef';

/* constants */
import {
  ANGLE,
  FN_VAR,
  LENGTH,
  NUM,
  SYN_FN_CALC,
  SYN_FN_MATH_START,
  SYN_FN_VAR,
  SYN_FN_VAR_START,
  VAL_SPEC
} from './constant';

const {
  CloseParen: PAREN_CLOSE,
  Comment: COMMENT,
  Dimension: DIM,
  EOF,
  Function: FUNC,
  Ident: IDENT,
  OpenParen: PAREN_OPEN,
  Whitespace: W_SPACE
} = TokenType;
const NAMESPACE = 'css-calc-var';

/* numeric constants */
const TRIA = 3;
const HEX = 16;
const MAX_PCT = 100;

/* regexp */
const REG_FN_CALC = new RegExp(SYN_FN_CALC);
const REG_FN_CALC_NUM = new RegExp(`^calc\\((${NUM})\\)$`);
const REG_FN_MATH_START = new RegExp(SYN_FN_MATH_START);
const REG_FN_VAR = new RegExp(SYN_FN_VAR);
const REG_FN_VAR_START = new RegExp(SYN_FN_VAR_START);
const REG_OPERATOR = /\s[*+/-]\s/;
const REG_PAREN_OPEN = /\($/;
const REG_TYPE_DIM = new RegExp(`^(${NUM})(${ANGLE}|${LENGTH})$`);
const REG_TYPE_DIM_PCT = new RegExp(`^(${NUM})(${ANGLE}|${LENGTH}|%)$`);
const REG_TYPE_PCT = new RegExp(`^(${NUM})%$`);
const REG_CSS_WIDE_KEYWORD = /^(?:inherit|initial|revert(?:-layer)?|unset)$/;

/**
 * @type CalcASTNode - AST node for calc()
 */
type CalcASTNode = string | CalcASTNode[];

/**
 * Calclator
 */
export class Calculator {
  /* private */
  // number
  #hasNum: boolean;
  #numSum: number[];
  #numMul: number[];
  // percentage
  #hasPct: boolean;
  #pctSum: number[];
  #pctMul: number[];
  // dimension
  #hasDim: boolean;
  #dimSum: string[];
  #dimSub: string[];
  #dimMul: string[];
  #dimDiv: string[];
  // et cetra
  #hasEtc: boolean;
  #etcSum: string[];
  #etcSub: string[];
  #etcMul: string[];
  #etcDiv: string[];
  // calc options
  #calcOpts: CalcOptions;

  /**
   * constructor
   */
  constructor() {
    // number
    this.#hasNum = false;
    this.#numSum = [];
    this.#numMul = [];
    // percentage
    this.#hasPct = false;
    this.#pctSum = [];
    this.#pctMul = [];
    // dimension
    this.#hasDim = false;
    this.#dimSum = [];
    this.#dimSub = [];
    this.#dimMul = [];
    this.#dimDiv = [];
    // et cetra
    this.#hasEtc = false;
    this.#etcSum = [];
    this.#etcSub = [];
    this.#etcMul = [];
    this.#etcDiv = [];
    // calc options
    this.#calcOpts = {
      toCanonicalUnits: true
    };
  }

  get hasNum() {
    return this.#hasNum;
  }

  set hasNum(value: boolean) {
    this.#hasNum = !!value;
  }

  get numSum() {
    return this.#numSum;
  }

  get numMul() {
    return this.#numMul;
  }

  get hasPct() {
    return this.#hasPct;
  }

  set hasPct(value: boolean) {
    this.#hasPct = !!value;
  }

  get pctSum() {
    return this.#pctSum;
  }

  get pctMul() {
    return this.#pctMul;
  }

  get hasDim() {
    return this.#hasDim;
  }

  set hasDim(value: boolean) {
    this.#hasDim = !!value;
  }

  get dimSum() {
    return this.#dimSum;
  }

  get dimSub() {
    return this.#dimSub;
  }

  get dimMul() {
    return this.#dimMul;
  }

  get dimDiv() {
    return this.#dimDiv;
  }

  get hasEtc() {
    return this.#hasEtc;
  }

  set hasEtc(value: boolean) {
    this.#hasEtc = !!value;
  }

  get etcSum() {
    return this.#etcSum;
  }

  get etcSub() {
    return this.#etcSub;
  }

  get etcMul() {
    return this.#etcMul;
  }

  get etcDiv() {
    return this.#etcDiv;
  }

  /**
   * clear values
   * @returns void
   */
  clear() {
    // number
    this.#hasNum = false;
    this.#numSum.length = 0;
    this.#numMul.length = 0;
    // percentage
    this.#hasPct = false;
    this.#pctSum.length = 0;
    this.#pctMul.length = 0;
    // dimension
    this.#hasDim = false;
    this.#dimSum.length = 0;
    this.#dimSub.length = 0;
    this.#dimMul.length = 0;
    this.#dimDiv.length = 0;
    // et cetra
    this.#hasEtc = false;
    this.#etcSum.length = 0;
    this.#etcSub.length = 0;
    this.#etcMul.length = 0;
    this.#etcDiv.length = 0;
  }

  /**
   * sort values
   * @param values - values
   * @returns sorted values
   */
  sort(values: string[] = []): string[] {
    const arr = [...values];
    if (arr.length > 1) {
      arr.sort((a, b) => {
        let res;
        if (REG_TYPE_DIM_PCT.test(a) && REG_TYPE_DIM_PCT.test(b)) {
          const [, valA, unitA] = a.match(REG_TYPE_DIM_PCT) as MatchedRegExp;
          const [, valB, unitB] = b.match(REG_TYPE_DIM_PCT) as MatchedRegExp;
          if (unitA === unitB) {
            if (Number(valA) === Number(valB)) {
              res = 0;
            } else if (Number(valA) > Number(valB)) {
              res = 1;
            } else {
              res = -1;
            }
          } else if (unitA > unitB) {
            res = 1;
          } else {
            res = -1;
          }
        } else {
          if (a === b) {
            res = 0;
          } else if (a > b) {
            res = 1;
          } else {
            res = -1;
          }
        }
        return res;
      });
    }
    return arr;
  }

  /**
   * multiply values
   * @returns resolved value
   */
  multiply(): string {
    const value = [];
    let num;
    if (this.#hasNum) {
      num = 1;
      for (const i of this.#numMul) {
        num *= i;
        if (num === 0 || !Number.isFinite(num) || Number.isNaN(num)) {
          break;
        }
      }
      if (!this.#hasPct && !this.#hasDim && !this.hasEtc) {
        if (Number.isFinite(num)) {
          num = roundToPrecision(num, HEX);
        }
        value.push(num);
      }
    }
    if (this.#hasPct) {
      if (typeof num !== 'number') {
        num = 1;
      }
      for (const i of this.#pctMul) {
        num *= i;
        if (num === 0 || !Number.isFinite(num) || Number.isNaN(num)) {
          break;
        }
      }
      if (Number.isFinite(num)) {
        num = `${roundToPrecision(num, HEX)}%`;
      }
      if (!this.#hasDim && !this.hasEtc) {
        value.push(num);
      }
    }
    if (this.#hasDim) {
      let dim = '';
      let mul = '';
      let div = '';
      if (this.#dimMul.length) {
        if (this.#dimMul.length === 1) {
          [mul] = this.#dimMul as [string];
        } else {
          mul = `${this.sort(this.#dimMul).join(' * ')}`;
        }
      }
      if (this.#dimDiv.length) {
        if (this.#dimDiv.length === 1) {
          [div] = this.#dimDiv as [string];
        } else {
          div = `${this.sort(this.#dimDiv).join(' * ')}`;
        }
      }
      if (Number.isFinite(num)) {
        if (mul) {
          if (div) {
            if (div.includes('*')) {
              dim = calc(`calc(${num} * ${mul} / (${div}))`, this.#calcOpts);
            } else {
              dim = calc(`calc(${num} * ${mul} / ${div})`, this.#calcOpts);
            }
          } else {
            dim = calc(`calc(${num} * ${mul})`, this.#calcOpts);
          }
        } else if (div.includes('*')) {
          dim = calc(`calc(${num} / (${div}))`, this.#calcOpts);
        } else {
          dim = calc(`calc(${num} / ${div})`, this.#calcOpts);
        }
        value.push(dim.replace(/^calc/, ''));
      } else {
        if (!value.length && num !== undefined) {
          value.push(num);
        }
        if (mul) {
          if (div) {
            if (div.includes('*')) {
              dim = calc(`calc(${mul} / (${div}))`, this.#calcOpts);
            } else {
              dim = calc(`calc(${mul} / ${div})`, this.#calcOpts);
            }
          } else {
            dim = calc(`calc(${mul})`, this.#calcOpts);
          }
          if (value.length) {
            value.push('*', dim.replace(/^calc/, ''));
          } else {
            value.push(dim.replace(/^calc/, ''));
          }
        } else {
          dim = calc(`calc(${div})`, this.#calcOpts);
          if (value.length) {
            value.push('/', dim.replace(/^calc/, ''));
          } else {
            value.push('1', '/', dim.replace(/^calc/, ''));
          }
        }
      }
    }
    if (this.#hasEtc) {
      if (this.#etcMul.length) {
        if (!value.length && num !== undefined) {
          value.push(num);
        }
        const mul = this.sort(this.#etcMul).join(' * ');
        if (value.length) {
          value.push(`* ${mul}`);
        } else {
          value.push(`${mul}`);
        }
      }
      if (this.#etcDiv.length) {
        const div = this.sort(this.#etcDiv).join(' * ');
        if (div.includes('*')) {
          if (value.length) {
            value.push(`/ (${div})`);
          } else {
            value.push(`1 / (${div})`);
          }
        } else if (value.length) {
          value.push(`/ ${div}`);
        } else {
          value.push(`1 / ${div}`);
        }
      }
    }
    if (value.length) {
      return value.join(' ');
    }
    return '';
  }

  /**
   * sum values
   * @returns resolved value
   */
  sum(): string {
    const value = [];
    if (this.#hasNum) {
      let num = 0;
      for (const i of this.#numSum) {
        num += i;
        if (!Number.isFinite(num) || Number.isNaN(num)) {
          break;
        }
      }
      value.push(num);
    }
    if (this.#hasPct) {
      let num: number | string = 0;
      for (const i of this.#pctSum) {
        num += i;
        if (!Number.isFinite(num)) {
          break;
        }
      }
      if (Number.isFinite(num)) {
        num = `${num}%`;
      }
      if (value.length) {
        value.push(`+ ${num}`);
      } else {
        value.push(num);
      }
    }
    if (this.#hasDim) {
      let dim, sum, sub;
      if (this.#dimSum.length) {
        sum = this.sort(this.#dimSum).join(' + ');
      }
      if (this.#dimSub.length) {
        sub = this.sort(this.#dimSub).join(' + ');
      }
      if (sum) {
        if (sub) {
          if (sub.includes('-')) {
            dim = calc(`calc(${sum} - (${sub}))`, this.#calcOpts);
          } else {
            dim = calc(`calc(${sum} - ${sub})`, this.#calcOpts);
          }
        } else {
          dim = calc(`calc(${sum})`, this.#calcOpts);
        }
      } else {
        dim = calc(`calc(-1 * (${sub}))`, this.#calcOpts);
      }
      if (value.length) {
        value.push('+', dim.replace(/^calc/, ''));
      } else {
        value.push(dim.replace(/^calc/, ''));
      }
    }
    if (this.#hasEtc) {
      if (this.#etcSum.length) {
        const sum = this.sort(this.#etcSum)
          .map(item => {
            let res;
            if (
              REG_OPERATOR.test(item) &&
              !item.startsWith('(') &&
              !item.endsWith(')')
            ) {
              res = `(${item})`;
            } else {
              res = item;
            }
            return res;
          })
          .join(' + ');
        if (value.length) {
          if (this.#etcSum.length > 1) {
            value.push(`+ (${sum})`);
          } else {
            value.push(`+ ${sum}`);
          }
        } else {
          value.push(`${sum}`);
        }
      }
      if (this.#etcSub.length) {
        const sub = this.sort(this.#etcSub)
          .map(item => {
            let res;
            if (
              REG_OPERATOR.test(item) &&
              !item.startsWith('(') &&
              !item.endsWith(')')
            ) {
              res = `(${item})`;
            } else {
              res = item;
            }
            return res;
          })
          .join(' + ');
        if (value.length) {
          if (this.#etcSub.length > 1) {
            value.push(`- (${sub})`);
          } else {
            value.push(`- ${sub}`);
          }
        } else if (this.#etcSub.length > 1) {
          value.push(`-1 * (${sub})`);
        } else {
          value.push(`-1 * ${sub}`);
        }
      }
    }
    if (value.length) {
      return value.join(' ');
    }
    return '';
  }
}

/**
 * sort calc values
 * @param values - values to sort
 * @param [finalize] - finalize values
 * @returns sorted values
 */
export const sortCalcValues = (
  values: (number | string)[] = [],
  finalize: boolean = false
): string => {
  if (values.length < TRIA) {
    throw new Error(`Unexpected array length ${values.length}.`);
  }
  const start = values.shift();
  if (!isString(start) || !start.endsWith('(')) {
    throw new Error(`Unexpected token ${start}.`);
  }
  const end = values.pop();
  if (end !== ')') {
    throw new Error(`Unexpected token ${end}.`);
  }
  if (values.length === 1) {
    const [value] = values;
    if (!isStringOrNumber(value)) {
      throw new Error(`Unexpected token ${value}.`);
    }
    return `${start}${value}${end}`;
  }
  const sortedValues = [];
  const cal = new Calculator();
  let operator: string = '';
  const l = values.length;
  // Array traversal optimization for operator check
  let hasAddSub = false;
  for (let i = 0; i < l; i++) {
    const value = values[i];
    if (!isStringOrNumber(value)) {
      throw new Error(`Unexpected token ${value}.`);
    }
    if (value === '*' || value === '/') {
      operator = value;
    } else if (value === '+' || value === '-') {
      const sortedValue = cal.multiply();
      if (sortedValue) {
        sortedValues.push(sortedValue, value);
      }
      // Mark presence of + or -
      hasAddSub = true;
      cal.clear();
      operator = '';
    } else {
      const numValue = Number(value);
      const strValue = `${value}`;
      switch (operator) {
        case '/': {
          if (Number.isFinite(numValue)) {
            cal.hasNum = true;
            cal.numMul.push(1 / numValue);
          } else if (REG_TYPE_PCT.test(strValue)) {
            const [, val] = strValue.match(REG_TYPE_PCT) as MatchedRegExp;
            cal.hasPct = true;
            cal.pctMul.push((MAX_PCT * MAX_PCT) / Number(val));
          } else if (REG_TYPE_DIM.test(strValue)) {
            cal.hasDim = true;
            cal.dimDiv.push(strValue);
          } else {
            cal.hasEtc = true;
            cal.etcDiv.push(strValue);
          }
          break;
        }
        case '*':
        default: {
          if (Number.isFinite(numValue)) {
            cal.hasNum = true;
            cal.numMul.push(numValue);
          } else if (REG_TYPE_PCT.test(strValue)) {
            const [, val] = strValue.match(REG_TYPE_PCT) as MatchedRegExp;
            cal.hasPct = true;
            cal.pctMul.push(Number(val));
          } else if (REG_TYPE_DIM.test(strValue)) {
            cal.hasDim = true;
            cal.dimMul.push(strValue);
          } else {
            cal.hasEtc = true;
            cal.etcMul.push(strValue);
          }
        }
      }
    }
    if (i === l - 1) {
      const sortedValue = cal.multiply();
      if (sortedValue) {
        sortedValues.push(sortedValue);
      }
      cal.clear();
      operator = '';
    }
  }
  let resolvedValue = '';
  if (finalize && hasAddSub) {
    const finalizedValues = [];
    cal.clear();
    operator = '';
    const l = sortedValues.length;
    for (let i = 0; i < l; i++) {
      const value = sortedValues[i];
      if (isStringOrNumber(value)) {
        if (value === '+' || value === '-') {
          operator = value;
        } else {
          const numValue = Number(value);
          const strValue = `${value}`;
          switch (operator) {
            case '-': {
              if (Number.isFinite(numValue)) {
                cal.hasNum = true;
                cal.numSum.push(-1 * numValue);
              } else if (REG_TYPE_PCT.test(strValue)) {
                const [, val] = strValue.match(REG_TYPE_PCT) as MatchedRegExp;
                cal.hasPct = true;
                cal.pctSum.push(-1 * Number(val));
              } else if (REG_TYPE_DIM.test(strValue)) {
                cal.hasDim = true;
                cal.dimSub.push(strValue);
              } else {
                cal.hasEtc = true;
                cal.etcSub.push(strValue);
              }
              break;
            }
            case '+':
            default: {
              if (Number.isFinite(numValue)) {
                cal.hasNum = true;
                cal.numSum.push(numValue);
              } else if (REG_TYPE_PCT.test(strValue)) {
                const [, val] = strValue.match(REG_TYPE_PCT) as MatchedRegExp;
                cal.hasPct = true;
                cal.pctSum.push(Number(val));
              } else if (REG_TYPE_DIM.test(strValue)) {
                cal.hasDim = true;
                cal.dimSum.push(strValue);
              } else {
                cal.hasEtc = true;
                cal.etcSum.push(strValue);
              }
            }
          }
        }
      }
      if (i === l - 1) {
        const sortedValue = cal.sum();
        if (sortedValue) {
          finalizedValues.push(sortedValue);
        }
        cal.clear();
        operator = '';
      }
    }
    resolvedValue = finalizedValues.join(' ').replace(/\+\s-/g, '- ');
  } else {
    resolvedValue = sortedValues.join(' ').replace(/\+\s-/g, '- ');
  }
  if (
    resolvedValue.startsWith('(') &&
    resolvedValue.endsWith(')') &&
    resolvedValue.lastIndexOf('(') === 0 &&
    resolvedValue.indexOf(')') === resolvedValue.length - 1
  ) {
    resolvedValue = resolvedValue.substring(1, resolvedValue.length - 1);
  }
  return `${start}${resolvedValue}${end}`;
};

/**
 * sort math function terms
 * @param expr - unwrapped calc expression
 * @returns sorted expression
 */
export const sortMathFnTerms = (expr: string): string => {
  const tokens = tokenize({ css: expr });
  const terms: string[] = [];
  let currentTerm = '';
  let currentSign = '';
  let depth = 0;
  for (const token of tokens) {
    const [type, value] = token as [TokenType, string];
    if (type === PAREN_OPEN || type === FUNC) {
      depth++;
    } else if (type === PAREN_CLOSE) {
      depth--;
    }
    if (depth === 0 && (value === '+' || value === '-')) {
      if (currentTerm.trim()) {
        terms.push((currentSign === '-' ? '-' : '') + currentTerm.trim());
      }
      currentTerm = '';
      currentSign = value;
      continue;
    }
    currentTerm += value;
  }
  if (currentTerm.trim()) {
    terms.push((currentSign === '-' ? '-' : '') + currentTerm.trim());
  }
  terms.sort((a, b) => {
    const reg = /^(-?(?:\d+(?:\.\d+)?|\.\d+))([a-z%]*)$/i;
    const matchA = a.match(reg);
    const matchB = b.match(reg);
    if (matchA && matchB) {
      const numA = Number(matchA[1] || '0');
      const numB = Number(matchB[1] || '0');
      const unitA = matchA[2] || '';
      const unitB = matchB[2] || '';
      if (unitA === unitB) {
        return numA - numB;
      }
      return unitA > unitB ? 1 : -1;
    }
    if (a === b) return 0;
    return a > b ? 1 : -1;
  });
  const firstTerm = terms[0];
  if (firstTerm === undefined) {
    return expr;
  }
  let res: string = firstTerm;
  for (let i = 1; i < terms.length; i++) {
    const term = terms[i];
    if (term !== undefined) {
      if (term.startsWith('-')) {
        res += ' - ' + term.substring(1);
      } else {
        res += ' + ' + term;
      }
    }
  }
  return res;
};

/**
 * resolve AST node
 * @param node - AST node
 * @param isRoot - is root node
 * @returns resolved value
 */
const resolveNode = (node: CalcASTNode[], isRoot: boolean): string => {
  const firstItemRaw = node[0];
  const isCommaMathFnNode =
    isString(firstItemRaw) && REG_FN_MATH_START.test(firstItemRaw);
  const flatItems: string[] = [];
  for (const item of node) {
    if (Array.isArray(item)) {
      flatItems.push(resolveNode(item, isCommaMathFnNode));
    } else {
      flatItems.push(item);
    }
  }
  const hasComma = flatItems.includes(',');
  const firstItem = flatItems[0] || '';
  const isCommaMathFn =
    isString(firstItem) && REG_FN_MATH_START.test(firstItem);
  if (hasComma && isCommaMathFn && flatItems[flatItems.length - 1] === ')') {
    const fnName = flatItems.shift() as string;
    const fnEnd = flatItems.pop() as string;
    const args: string[][] = [];
    let currentArg: string[] = [];
    for (const item of flatItems) {
      if (item === ',') {
        args.push(currentArg);
        currentArg = [];
      } else {
        currentArg.push(item);
      }
    }
    if (currentArg.length) {
      args.push(currentArg);
    }
    const resolvedArgs = args.map(argNodes => {
      if (argNodes.length >= TRIA) {
        const temp = ['calc(', ...argNodes, ')'];
        const sorted = sortCalcValues(temp, true);
        const unwrapped = sorted.substring(5, sorted.length - 1);
        return sortMathFnTerms(unwrapped);
      } else if (argNodes.length === 1) {
        const item = argNodes[0];
        if (isString(item) && item.startsWith('calc(') && item.endsWith(')')) {
          const unwrapped = item.substring(5, item.length - 1);
          return sortMathFnTerms(unwrapped);
        }
      }
      return argNodes.join('');
    });
    return `${fnName}${resolvedArgs.join(', ')}${fnEnd}`;
  }
  if (isRoot) {
    if (!hasComma) {
      if (flatItems.length >= TRIA) {
        return sortCalcValues(flatItems, true);
      }
      const first = flatItems[0] || '';
      if (isString(first) && first.endsWith('(')) {
        const joined = flatItems.join('');
        return joined.startsWith('calc(') || /^[a-z-]+\(/.test(joined)
          ? joined
          : `calc(${joined})`;
      }
      return isString(first) &&
        (first.startsWith('calc(') || /^[a-z-]+\(/.test(first))
        ? first
        : `calc(${first})`;
    }
    return flatItems.join('').replace(/,\s*/g, ', ');
  }
  if (flatItems.length >= TRIA && !hasComma) {
    let serialized = sortCalcValues(flatItems, false);
    if (REG_FN_VAR_START.test(serialized)) {
      serialized = calc(serialized, { toCanonicalUnits: true });
    }
    return serialized;
  }
  return flatItems.join('').replace(/,\s*/g, ', ');
};

/**
 * serialize calc
 * @param value - CSS value
 * @param [opt] - options
 * @returns serialized value
 */
export const serializeCalc = (value: string, opt: Options = {}): string => {
  const { format = '' } = opt;
  if (isString(value)) {
    if (!REG_FN_VAR_START.test(value) || format !== VAL_SPEC) {
      return value;
    }
    value = value.toLowerCase().trim();
  } else {
    throw new TypeError(`${value} is not a string.`);
  }
  const cacheKey: string = createCacheKey(
    {
      namespace: NAMESPACE,
      name: 'serializeCalc',
      value
    },
    opt
  );
  const cachedResult = getCache(cacheKey);
  if (cachedResult !== false) {
    return cachedResult.item as string;
  }
  const items: string[] = tokenize({ css: value })
    .map((token: CSSToken): string => {
      const [type, val] = token as [TokenType, string];
      let res = '';
      if (type !== W_SPACE && type !== COMMENT) {
        res = val;
      }
      return res;
    })
    .filter(v => v);
  const stack: CalcASTNode[][] = [[]];
  for (const item of items) {
    if (REG_PAREN_OPEN.test(item)) {
      const newNode: CalcASTNode[] = [item];
      const parent = stack[stack.length - 1];
      if (parent) {
        parent.push(newNode);
      }
      stack.push(newNode);
    } else if (item === ')') {
      if (stack.length > 1) {
        const currentLevel = stack.pop();
        if (currentLevel) {
          currentLevel.push(item);
        }
      } else {
        const root = stack[0];
        if (root) {
          root.push(item);
        }
      }
    } else {
      const parent = stack[stack.length - 1];
      if (parent) {
        parent.push(item);
      }
    }
  }
  let serializedCalc = '';
  const rootItems = stack[0];
  if (rootItems) {
    if (rootItems.length === 1 && Array.isArray(rootItems[0])) {
      serializedCalc = resolveNode(rootItems[0], true);
    } else {
      serializedCalc = resolveNode(rootItems, true);
    }
  }
  setCache(cacheKey, serializedCalc);
  return serializedCalc;
};

/**
 * resolve dimension
 * @param token - CSS token
 * @param [opt] - options
 * @returns resolved value
 */
export const resolveDimension = (
  token: CSSToken,
  opt: Options = {}
): string | null => {
  if (!Array.isArray(token)) {
    throw new TypeError(`${token} is not an array.`);
  }
  const [, , , , detail = {}] = token;
  const { unit, value } = detail as {
    unit: string;
    value: number;
  };
  if (unit === 'px') {
    return `${value}${unit}`;
  }
  const pixelValue = resolveLengthInPixels(Number(value), unit, opt);
  if (Number.isFinite(pixelValue)) {
    return `${roundToPrecision(pixelValue, HEX)}px`;
  }
  return null;
};

/**
 * parse CSS calc() tokens
 * @param tokens - CSS tokens
 * @param [opt] - options
 * @returns parsed tokens
 */
export const parseCalcTokens = (
  tokens: CSSToken[],
  opt: Options = {}
): string[] => {
  if (!Array.isArray(tokens)) {
    throw new TypeError(`${tokens} is not an array.`);
  }
  const { format = '' } = opt;
  const mathFunc = new Set();
  let nest = 0;
  const res: string[] = [];
  for (const token of tokens) {
    if (!Array.isArray(token)) {
      throw new TypeError(`${token} is not an array.`);
    }
    const [type = '', value = ''] = token as [TokenType, string];
    switch (type) {
      case DIM: {
        if (format === VAL_SPEC && !mathFunc.has(nest)) {
          res.push(value);
        } else {
          const resolvedValue = resolveDimension(token, opt);
          if (isString(resolvedValue)) {
            res.push(resolvedValue);
          } else {
            res.push(value);
          }
        }
        break;
      }
      case FUNC:
      case PAREN_OPEN: {
        res.push(value);
        nest++;
        if (REG_FN_MATH_START.test(value)) {
          mathFunc.add(nest);
        }
        break;
      }
      case PAREN_CLOSE: {
        if (res.length) {
          const lastValue = res[res.length - 1];
          if (lastValue === ' ') {
            res.splice(-1, 1, value);
          } else {
            res.push(value);
          }
        } else {
          res.push(value);
        }
        if (mathFunc.has(nest)) {
          mathFunc.delete(nest);
        }
        nest--;
        break;
      }
      case W_SPACE: {
        if (res.length) {
          const lastValue = res[res.length - 1];
          if (
            isString(lastValue) &&
            !lastValue.endsWith('(') &&
            lastValue !== ' '
          ) {
            res.push(value);
          }
        }
        break;
      }
      default: {
        if (type !== COMMENT && type !== EOF) {
          res.push(value);
        }
      }
    }
  }
  return res;
};

/**
 * CSS calc()
 * @param value - CSS value including calc()
 * @param [opt] - options
 * @returns resolved value
 */
export const cssCalc = (value: string, opt: Options = {}): string => {
  const { format = '' } = opt;
  if (isString(value)) {
    if (REG_FN_VAR.test(value)) {
      if (format === VAL_SPEC) {
        return value;
      } else {
        const resolvedValue = resolveVar(value, opt);
        if (isString(resolvedValue)) {
          return resolvedValue;
        } else {
          return '';
        }
      }
    } else if (!REG_FN_CALC.test(value)) {
      return value;
    }
    value = value.toLowerCase().trim();
  } else {
    throw new TypeError(`${value} is not a string.`);
  }
  const cacheKey: string = createCacheKey(
    {
      namespace: NAMESPACE,
      name: 'cssCalc',
      value
    },
    opt
  );
  const cachedResult = getCache(cacheKey);
  if (cachedResult !== false) {
    return cachedResult.item as string;
  }
  const tokens = tokenize({ css: value });
  const values = parseCalcTokens(tokens, opt);
  let resolvedValue: string = calc(values.join(''), {
    toCanonicalUnits: true
  });
  if (REG_FN_VAR_START.test(value)) {
    if (REG_TYPE_DIM_PCT.test(resolvedValue)) {
      const [, val, unit] = resolvedValue.match(
        REG_TYPE_DIM_PCT
      ) as MatchedRegExp;
      resolvedValue = `${roundToPrecision(Number(val), HEX)}${unit}`;
    }
    // wrap with `calc()`
    if (
      resolvedValue &&
      !REG_FN_VAR_START.test(resolvedValue) &&
      format === VAL_SPEC
    ) {
      resolvedValue = `calc(${resolvedValue})`;
    }
  }
  if (format === VAL_SPEC) {
    if (/\s[-+*/]\s/.test(resolvedValue) && !resolvedValue.includes('NaN')) {
      resolvedValue = serializeCalc(resolvedValue, opt);
    } else if (REG_FN_CALC_NUM.test(resolvedValue)) {
      const [, val] = resolvedValue.match(REG_FN_CALC_NUM) as MatchedRegExp;
      resolvedValue = `calc(${roundToPrecision(Number(val), HEX)})`;
    }
  }
  setCache(cacheKey, resolvedValue);
  return resolvedValue;
};

/**
 * resolve custom property
 * @param tokens - CSS tokens
 * @param [opt] - options
 * @returns result - [tokens, resolvedValue]
 */
export function resolveCustomProperty(
  tokens: CSSToken[],
  opt: Options = {}
): [CSSToken[], string] {
  if (!Array.isArray(tokens)) {
    throw new TypeError(`${tokens} is not an array.`);
  }
  const { customProperty = {} } = opt;
  const items: string[] = [];
  while (tokens.length) {
    const token = tokens.shift();
    if (!token) {
      break;
    }
    if (!Array.isArray(token)) {
      throw new TypeError(`${token} is not an array.`);
    }
    const [type, value] = token as [TokenType, string];
    // end of var()
    if (type === PAREN_CLOSE) {
      break;
    }
    // nested var()
    if (value === FN_VAR) {
      const [, item] = resolveCustomProperty(tokens, opt);
      if (item) {
        items.push(item);
      }
    } else if (type === IDENT) {
      if (value.startsWith('--')) {
        let item;
        if (Object.hasOwn(customProperty, value)) {
          item = customProperty[value] as string;
        } else if (typeof customProperty.callback === 'function') {
          item = customProperty.callback(value);
        }
        if (item) {
          items.push(item);
        }
      } else if (value) {
        items.push(value);
      }
    }
  }
  let resolveAsColor = false;
  if (items.length > 1) {
    resolveAsColor = isColor(items[items.length - 1]);
  }
  let resolvedValue = '';
  for (let item of items) {
    item = item.trim();
    if (REG_FN_VAR.test(item)) {
      // recurse resolveVar()
      const resolvedItem = resolveVar(item, opt);
      if (isString(resolvedItem)) {
        if (!resolveAsColor || isColor(resolvedItem)) {
          resolvedValue = resolvedItem;
        }
      }
    } else if (REG_FN_CALC.test(item)) {
      item = cssCalc(item, opt);
      if (!resolveAsColor || isColor(item)) {
        resolvedValue = item;
      }
    } else if (item && !REG_CSS_WIDE_KEYWORD.test(item)) {
      if (!resolveAsColor || isColor(item)) {
        resolvedValue = item;
      }
    }
    if (resolvedValue) {
      break;
    }
  }
  return [tokens, resolvedValue];
}

/**
 * parse CSS var() tokens
 * @param tokens - CSS tokens
 * @param [opt] - options
 * @returns parsed tokens
 */
export function parseVarTokens(
  tokens: CSSToken[],
  opt: Options = {}
): string[] | null {
  const res: string[] = [];
  while (tokens.length) {
    const token = tokens.shift();
    if (!token) break;
    const [type = '', value = ''] = token as [TokenType, string];
    if (value === FN_VAR) {
      const [, resolvedValue] = resolveCustomProperty(tokens, opt);
      if (!resolvedValue) {
        return null;
      }
      res.push(resolvedValue);
    } else {
      switch (type) {
        case PAREN_CLOSE: {
          if (res.length) {
            if (res[res.length - 1] === ' ') {
              res[res.length - 1] = value;
            } else {
              res.push(value);
            }
          } else {
            res.push(value);
          }
          break;
        }
        case W_SPACE: {
          if (res.length) {
            const lastValue = res[res.length - 1];
            if (
              isString(lastValue) &&
              !lastValue.endsWith('(') &&
              lastValue !== ' '
            ) {
              res.push(value);
            }
          }
          break;
        }
        default: {
          if (type !== COMMENT && type !== EOF) {
            res.push(value);
          }
        }
      }
    }
  }
  return res;
}

/**
 * resolve CSS var()
 * @param value - CSS value including var()
 * @param [opt] - options
 * @returns resolved value
 */
export function resolveVar(value: string, opt: Options = {}): string | null {
  const { format = '' } = opt;
  if (isString(value)) {
    if (!REG_FN_VAR.test(value) || format === VAL_SPEC) {
      return value;
    }
    value = value.trim();
  } else {
    throw new TypeError(`${value} is not a string.`);
  }
  const cacheKey: string = createCacheKey(
    {
      namespace: NAMESPACE,
      name: 'resolveVar',
      value
    },
    opt
  );
  const cachedResult = getCache(cacheKey);
  if (cachedResult !== false) {
    return cachedResult.item as string | null;
  }
  const tokens = tokenize({ css: value });
  const values = parseVarTokens(tokens, opt);
  if (Array.isArray(values)) {
    let color = values.join('');
    if (REG_FN_CALC.test(color)) {
      color = cssCalc(color, opt);
    }
    setCache(cacheKey, color);
    return color;
  } else {
    setCache(cacheKey, null);
    return null;
  }
}

/**
 * CSS var()
 * @param value - CSS value including var()
 * @param [opt] - options
 * @returns resolved value
 */
export const cssVar = (value: string, opt: Options = {}): string => {
  const resolvedValue = resolveVar(value, opt);
  if (isString(resolvedValue)) {
    return resolvedValue;
  }
  return '';
};
