'use strict';

const genericConst = require('./generic-const.cjs');
const genericAnPlusB = require('./generic-an-plus-b.cjs');
const genericUrange = require('./generic-urange.cjs');
const types = require('../tokenizer/types.cjs');
const charCodeDefinitions = require('../tokenizer/char-code-definitions.cjs');
const utils = require('../tokenizer/utils.cjs');

const calcFunctionNames = ['calc(', '-moz-calc(', '-webkit-calc('];
const balancePair = new Map([
  [types.Function, types.RightParenthesis],
  [types.LeftParenthesis, types.RightParenthesis],
  [types.LeftSquareBracket, types.RightSquareBracket],
  [types.LeftCurlyBracket, types.RightCurlyBracket],
]);

// units
const LENGTH = [
  // absolute length units https://www.w3.org/TR/css-values-3/#lengths
  'cm',
  'mm',
  'q',
  'in',
  'pt',
  'pc',
  'px',
  // font-relative length units https://drafts.csswg.org/css-values-4/#font-relative-lengths
  'em',
  'rem',
  'ex',
  'rex',
  'cap',
  'rcap',
  'ch',
  'rch',
  'ic',
  'ric',
  'lh',
  'rlh',
  // viewport-percentage lengths https://drafts.csswg.org/css-values-4/#viewport-relative-lengths
  'vw',
  'svw',
  'lvw',
  'dvw',
  'vh',
  'svh',
  'lvh',
  'dvh',
  'vi',
  'svi',
  'lvi',
  'dvi',
  'vb',
  'svb',
  'lvb',
  'dvb',
  'vmin',
  'svmin',
  'lvmin',
  'dvmin',
  'vmax',
  'svmax',
  'lvmax',
  'dvmax',
  // container relative lengths https://drafts.csswg.org/css-contain-3/#container-lengths
  'cqw',
  'cqh',
  'cqi',
  'cqb',
  'cqmin',
  'cqmax',
];
const ANGLE = ['deg', 'grad', 'rad', 'turn']; // https://www.w3.org/TR/css-values-3/#angles
const TIME = ['s', 'ms']; // https://www.w3.org/TR/css-values-3/#time
const FREQUENCY = ['hz', 'khz']; // https://www.w3.org/TR/css-values-3/#frequency
const RESOLUTION = ['dpi', 'dpcm', 'dppx', 'x']; // https://www.w3.org/TR/css-values-3/#resolution
const FLEX = ['fr']; // https://drafts.csswg.org/css-grid/#fr-unit
const DECIBEL = ['db']; // https://www.w3.org/TR/css3-speech/#mixing-props-voice-volume
const SEMITONES = ['st']; // https://www.w3.org/TR/css3-speech/#voice-props-voice-pitch

// safe char code getter
function charCodeAt(str, index) {
  return index < str.length ? str.charCodeAt(index) : 0;
}

function eqStr(actual, expected) {
  return utils.cmpStr(actual, 0, actual.length, expected);
}

function eqStrAny(actual, expected) {
  for (let i = 0; i < expected.length; i++) {
    if (eqStr(actual, expected[i])) {
      return true;
    }
  }

  return false;
}

// IE postfix hack, i.e. 123\0 or 123px\9
function isPostfixIeHack(str, offset) {
  if (offset !== str.length - 2) {
    return false;
  }

  return (
    charCodeAt(str, offset) === 0x005c && // U+005C REVERSE SOLIDUS (\)
    charCodeDefinitions.isDigit(charCodeAt(str, offset + 1))
  );
}

function outOfRange(opts, value, numEnd) {
  if (opts && opts.type === 'Range') {
    const num = Number(
      numEnd !== undefined && numEnd !== value.length ?
        value.substr(0, numEnd)
      : value
    );

    if (isNaN(num)) {
      return true;
    }

    // FIXME: when opts.min is a string it's a dimension, skip a range validation
    // for now since it requires a type covertation which is not implmented yet
    if (opts.min !== null && num < opts.min && typeof opts.min !== 'string') {
      return true;
    }

    // FIXME: when opts.max is a string it's a dimension, skip a range validation
    // for now since it requires a type covertation which is not implmented yet
    if (opts.max !== null && num > opts.max && typeof opts.max !== 'string') {
      return true;
    }
  }

  return false;
}

function consumeFunction(token, getNextToken) {
  let balanceCloseType = 0;
  let balanceStash = [];
  let length = 0;

  // balanced token consuming
  scan: do {
    switch (token.type) {
      case types.RightCurlyBracket:
      case types.RightParenthesis:
      case types.RightSquareBracket:
        if (token.type !== balanceCloseType) {
          break scan;
        }

        balanceCloseType = balanceStash.pop();

        if (balanceStash.length === 0) {
          length++;
          break scan;
        }

        break;

      case types.Function:
      case types.LeftParenthesis:
      case types.LeftSquareBracket:
      case types.LeftCurlyBracket:
        balanceStash.push(balanceCloseType);
        balanceCloseType = balancePair.get(token.type);
        break;
    }

    length++;
  } while ((token = getNextToken(length)));

  return length;
}

// TODO: implement
// can be used wherever <length>, <frequency>, <angle>, <time>, <percentage>, <number>, or <integer> values are allowed
// https://drafts.csswg.org/css-values/#calc-notation
function calc(next) {
  return function (token, getNextToken, opts) {
    if (token === null) {
      return 0;
    }

    if (
      token.type === types.Function &&
      eqStrAny(token.value, calcFunctionNames)
    ) {
      return consumeFunction(token, getNextToken);
    }

    return next(token, getNextToken, opts);
  };
}

function tokenType(expectedTokenType) {
  return function (token) {
    if (token === null || token.type !== expectedTokenType) {
      return 0;
    }

    return 1;
  };
}

// =========================
// Complex types
//

// https://drafts.csswg.org/css-values-4/#custom-idents
// 4.2. Author-defined Identifiers: the <custom-ident> type
// Some properties accept arbitrary author-defined identifiers as a component value.
// This generic data type is denoted by <custom-ident>, and represents any valid CSS identifier
// that would not be misinterpreted as a pre-defined keyword in that property’s value definition.
//
// See also: https://developer.mozilla.org/en-US/docs/Web/CSS/custom-ident
function customIdent(token) {
  if (token === null || token.type !== types.Ident) {
    return 0;
  }

  const name = token.value.toLowerCase();

  // The CSS-wide keywords are not valid <custom-ident>s
  if (eqStrAny(name, genericConst.cssWideKeywords)) {
    return 0;
  }

  // The default keyword is reserved and is also not a valid <custom-ident>
  if (eqStr(name, 'default')) {
    return 0;
  }

  // TODO: ignore property specific keywords (as described https://developer.mozilla.org/en-US/docs/Web/CSS/custom-ident)
  // Specifications using <custom-ident> must specify clearly what other keywords
  // are excluded from <custom-ident>, if any—for example by saying that any pre-defined keywords
  // in that property’s value definition are excluded. Excluded keywords are excluded
  // in all ASCII case permutations.

  return 1;
}

// https://drafts.csswg.org/css-variables/#typedef-custom-property-name
// A custom property is any property whose name starts with two dashes (U+002D HYPHEN-MINUS), like --foo.
// The <custom-property-name> production corresponds to this: it’s defined as any valid identifier
// that starts with two dashes, except -- itself, which is reserved for future use by CSS.
// NOTE: Current implementation treat `--` as a valid name since most (all?) major browsers treat it as valid.
function customPropertyName(token) {
  // ... defined as any valid identifier
  if (token === null || token.type !== types.Ident) {
    return 0;
  }

  // ... that starts with two dashes (U+002D HYPHEN-MINUS)
  if (
    charCodeAt(token.value, 0) !== 0x002d ||
    charCodeAt(token.value, 1) !== 0x002d
  ) {
    return 0;
  }

  return 1;
}

// https://drafts.csswg.org/css-color-4/#hex-notation
// The syntax of a <hex-color> is a <hash-token> token whose value consists of 3, 4, 6, or 8 hexadecimal digits.
// In other words, a hex color is written as a hash character, "#", followed by some number of digits 0-9 or
// letters a-f (the case of the letters doesn’t matter - #00ff00 is identical to #00FF00).
function hexColor(token) {
  if (token === null || token.type !== types.Hash) {
    return 0;
  }

  const length = token.value.length;

  // valid values (length): #rgb (4), #rgba (5), #rrggbb (7), #rrggbbaa (9)
  if (length !== 4 && length !== 5 && length !== 7 && length !== 9) {
    return 0;
  }

  for (let i = 1; i < length; i++) {
    if (!charCodeDefinitions.isHexDigit(charCodeAt(token.value, i))) {
      return 0;
    }
  }

  return 1;
}

function idSelector(token) {
  if (token === null || token.type !== types.Hash) {
    return 0;
  }

  if (
    !charCodeDefinitions.isIdentifierStart(
      charCodeAt(token.value, 1),
      charCodeAt(token.value, 2),
      charCodeAt(token.value, 3)
    )
  ) {
    return 0;
  }

  return 1;
}

// https://drafts.csswg.org/css-syntax/#any-value
// It represents the entirety of what a valid declaration can have as its value.
function declarationValue(token, getNextToken) {
  if (!token) {
    return 0;
  }

  let balanceCloseType = 0;
  let balanceStash = [];
  let length = 0;

  // The <declaration-value> production matches any sequence of one or more tokens,
  // so long as the sequence does not contain ...
  scan: do {
    switch (token.type) {
      // ... <bad-string-token>, <bad-url-token>,
      case types.BadString:
      case types.BadUrl:
        break scan;

      // ... unmatched <)-token>, <]-token>, or <}-token>,
      case types.RightCurlyBracket:
      case types.RightParenthesis:
      case types.RightSquareBracket:
        if (token.type !== balanceCloseType) {
          break scan;
        }

        balanceCloseType = balanceStash.pop();
        break;

      // ... or top-level <semicolon-token> tokens
      case types.Semicolon:
        if (balanceCloseType === 0) {
          break scan;
        }

        break;

      // ... or <delim-token> tokens with a value of "!"
      case types.Delim:
        if (balanceCloseType === 0 && token.value === '!') {
          break scan;
        }

        break;

      case types.Function:
      case types.LeftParenthesis:
      case types.LeftSquareBracket:
      case types.LeftCurlyBracket:
        balanceStash.push(balanceCloseType);
        balanceCloseType = balancePair.get(token.type);
        break;
    }

    length++;
  } while ((token = getNextToken(length)));

  return length;
}

// https://drafts.csswg.org/css-syntax/#any-value
// The <any-value> production is identical to <declaration-value>, but also
// allows top-level <semicolon-token> tokens and <delim-token> tokens
// with a value of "!". It represents the entirety of what valid CSS can be in any context.
function anyValue(token, getNextToken) {
  if (!token) {
    return 0;
  }

  let balanceCloseType = 0;
  let balanceStash = [];
  let length = 0;

  // The <any-value> production matches any sequence of one or more tokens,
  // so long as the sequence ...
  scan: do {
    switch (token.type) {
      // ... does not contain <bad-string-token>, <bad-url-token>,
      case types.BadString:
      case types.BadUrl:
        break scan;

      // ... unmatched <)-token>, <]-token>, or <}-token>,
      case types.RightCurlyBracket:
      case types.RightParenthesis:
      case types.RightSquareBracket:
        if (token.type !== balanceCloseType) {
          break scan;
        }

        balanceCloseType = balanceStash.pop();
        break;

      case types.Function:
      case types.LeftParenthesis:
      case types.LeftSquareBracket:
      case types.LeftCurlyBracket:
        balanceStash.push(balanceCloseType);
        balanceCloseType = balancePair.get(token.type);
        break;
    }

    length++;
  } while ((token = getNextToken(length)));

  return length;
}

// =========================
// Dimensions
//

function dimension(type) {
  if (type) {
    type = new Set(type);
  }

  return function (token, getNextToken, opts) {
    if (token === null || token.type !== types.Dimension) {
      return 0;
    }

    const numberEnd = utils.consumeNumber(token.value, 0);

    // check unit
    if (type !== null) {
      // check for IE postfix hack, i.e. 123px\0 or 123px\9
      const reverseSolidusOffset = token.value.indexOf('\\', numberEnd);
      const unit =
        (
          reverseSolidusOffset === -1 ||
          !isPostfixIeHack(token.value, reverseSolidusOffset)
        ) ?
          token.value.substr(numberEnd)
        : token.value.substring(numberEnd, reverseSolidusOffset);

      if (type.has(unit.toLowerCase()) === false) {
        return 0;
      }
    }

    // check range if specified
    if (outOfRange(opts, token.value, numberEnd)) {
      return 0;
    }

    return 1;
  };
}

// =========================
// Percentage
//

// §5.5. Percentages: the <percentage> type
// https://drafts.csswg.org/css-values-4/#percentages
function percentage(token, getNextToken, opts) {
  // ... corresponds to the <percentage-token> production
  if (token === null || token.type !== types.Percentage) {
    return 0;
  }

  // check range if specified
  if (outOfRange(opts, token.value, token.value.length - 1)) {
    return 0;
  }

  return 1;
}

// =========================
// Numeric
//

// https://drafts.csswg.org/css-values-4/#numbers
// The value <zero> represents a literal number with the value 0. Expressions that merely
// evaluate to a <number> with the value 0 (for example, calc(0)) do not match <zero>;
// only literal <number-token>s do.
function zero(next) {
  if (typeof next !== 'function') {
    next = function () {
      return 0;
    };
  }

  return function (token, getNextToken, opts) {
    if (token !== null && token.type === types.Number) {
      if (Number(token.value) === 0) {
        return 1;
      }
    }

    return next(token, getNextToken, opts);
  };
}

// § 5.3. Real Numbers: the <number> type
// https://drafts.csswg.org/css-values-4/#numbers
// Number values are denoted by <number>, and represent real numbers, possibly with a fractional component.
// ... It corresponds to the <number-token> production
function number(token, getNextToken, opts) {
  if (token === null) {
    return 0;
  }

  const numberEnd = utils.consumeNumber(token.value, 0);
  const isNumber = numberEnd === token.value.length;
  if (!isNumber && !isPostfixIeHack(token.value, numberEnd)) {
    return 0;
  }

  // check range if specified
  if (outOfRange(opts, token.value, numberEnd)) {
    return 0;
  }

  return 1;
}

// §5.2. Integers: the <integer> type
// https://drafts.csswg.org/css-values-4/#integers
function integer(token, getNextToken, opts) {
  // ... corresponds to a subset of the <number-token> production
  if (token === null || token.type !== types.Number) {
    return 0;
  }

  // The first digit of an integer may be immediately preceded by `-` or `+` to indicate the integer’s sign.
  let i =
    (
      charCodeAt(token.value, 0) === 0x002b || // U+002B PLUS SIGN (+)
      charCodeAt(token.value, 0) === 0x002d
    ) ?
      1
    : 0; // U+002D HYPHEN-MINUS (-)

  // When written literally, an integer is one or more decimal digits 0 through 9 ...
  for (; i < token.value.length; i++) {
    if (!charCodeDefinitions.isDigit(charCodeAt(token.value, i))) {
      return 0;
    }
  }

  // check range if specified
  if (outOfRange(opts, token.value, i)) {
    return 0;
  }

  return 1;
}

const genericSyntaxes = {
  // token types
  'ident-token': tokenType(types.Ident),
  'function-token': tokenType(types.Function),
  'at-keyword-token': tokenType(types.AtKeyword),
  'hash-token': tokenType(types.Hash),
  'string-token': tokenType(types.String),
  'bad-string-token': tokenType(types.BadString),
  'url-token': tokenType(types.Url),
  'bad-url-token': tokenType(types.BadUrl),
  'delim-token': tokenType(types.Delim),
  'number-token': tokenType(types.Number),
  'percentage-token': tokenType(types.Percentage),
  'dimension-token': tokenType(types.Dimension),
  'whitespace-token': tokenType(types.WhiteSpace),
  'CDO-token': tokenType(types.CDO),
  'CDC-token': tokenType(types.CDC),
  'colon-token': tokenType(types.Colon),
  'semicolon-token': tokenType(types.Semicolon),
  'comma-token': tokenType(types.Comma),
  '[-token': tokenType(types.LeftSquareBracket),
  ']-token': tokenType(types.RightSquareBracket),
  '(-token': tokenType(types.LeftParenthesis),
  ')-token': tokenType(types.RightParenthesis),
  '{-token': tokenType(types.LeftCurlyBracket),
  '}-token': tokenType(types.RightCurlyBracket),

  // token type aliases
  string: tokenType(types.String),
  ident: tokenType(types.Ident),

  // complex types
  'custom-ident': customIdent,
  'custom-property-name': customPropertyName,
  'hex-color': hexColor,
  'id-selector': idSelector, // element( <id-selector> )
  'an-plus-b': genericAnPlusB,
  urange: genericUrange,
  'declaration-value': declarationValue,
  'any-value': anyValue,

  // dimensions
  dimension: calc(dimension(null)),
  angle: calc(dimension(ANGLE)),
  decibel: calc(dimension(DECIBEL)),
  frequency: calc(dimension(FREQUENCY)),
  flex: calc(dimension(FLEX)),
  length: calc(zero(dimension(LENGTH))),
  resolution: calc(dimension(RESOLUTION)),
  semitones: calc(dimension(SEMITONES)),
  time: calc(dimension(TIME)),

  // percentage
  percentage: calc(percentage),

  // numeric
  zero: zero(),
  number: calc(number),
  integer: calc(integer),
};

module.exports = genericSyntaxes;