// Sourced from https://github.com/apple/password-manager-resources/blob/5f6da89483e75cdc4165a6fc4756796e0ced7a21/tools/PasswordRulesParser.js
// Copyright (c) 2019 - 2020 Apple Inc. Licensed under MIT License.

export const PasswordRulesParser = {
  parsePasswordRules,
};

const Identifier = {
  ASCII_PRINTABLE: "ascii-printable",
  DIGIT: "digit",
  LOWER: "lower",
  SPECIAL: "special",
  UNICODE: "unicode",
  UPPER: "upper",
};

const RuleName = {
  ALLOWED: "allowed",
  MAX_CONSECUTIVE: "max-consecutive",
  REQUIRED: "required",
  MIN_LENGTH: "minlength",
  MAX_LENGTH: "maxlength",
};

const CHARACTER_CLASS_START_SENTINEL = "[";
const CHARACTER_CLASS_END_SENTINEL = "]";
const PROPERTY_VALUE_SEPARATOR = ",";
const PROPERTY_SEPARATOR = ";";
const PROPERTY_VALUE_START_SENTINEL = ":";

const SPACE_CODE_POINT = " ".codePointAt(0);

const SHOULD_NOT_BE_REACHED = "Should not be reached";

class Rule {
  constructor(name, value) {
    this._name = name;
    this.value = value;
  }
  get name() {
    return this._name;
  }
  toString() {
    return JSON.stringify(this);
  }
}

class NamedCharacterClass {
  constructor(name) {
    console.assert(_isValidRequiredOrAllowedPropertyValueIdentifier(name));
    this._name = name;
  }
  get name() {
    return this._name.toLowerCase();
  }
  toString() {
    return this._name;
  }
  toHTMLString() {
    return this._name;
  }
}

class CustomCharacterClass {
  constructor(characters) {
    console.assert(characters instanceof Array);
    this._characters = characters;
  }
  get characters() {
    return this._characters;
  }
  toString() {
    return `[${this._characters.join("")}]`;
  }
  toHTMLString() {
    return `[${this._characters.join("").replace('"', "&quot;")}]`;
  }
}

// MARK: Lexer functions

function _isIdentifierCharacter(c) {
  console.assert(c.length === 1);
  return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "-";
}

function _isASCIIDigit(c) {
  console.assert(c.length === 1);
  return c >= "0" && c <= "9";
}

function _isASCIIPrintableCharacter(c) {
  console.assert(c.length === 1);
  return c >= " " && c <= "~";
}

function _isASCIIWhitespace(c) {
  console.assert(c.length === 1);
  return c === " " || c === "\f" || c === "\n" || c === "\r" || c === "\t";
}

// MARK: ASCII printable character bit set and canonicalization functions

function _bitSetIndexForCharacter(c) {
  console.assert(c.length == 1);
  return c.codePointAt(0) - SPACE_CODE_POINT;
}

function _characterAtBitSetIndex(index) {
  return String.fromCodePoint(index + SPACE_CODE_POINT);
}

function _markBitsForNamedCharacterClass(bitSet, namedCharacterClass) {
  console.assert(bitSet instanceof Array);
  console.assert(namedCharacterClass.name !== Identifier.UNICODE);
  console.assert(namedCharacterClass.name !== Identifier.ASCII_PRINTABLE);
  if (namedCharacterClass.name === Identifier.UPPER) {
    bitSet.fill(
      true,
      _bitSetIndexForCharacter("A"),
      _bitSetIndexForCharacter("Z") + 1
    );
  } else if (namedCharacterClass.name === Identifier.LOWER) {
    bitSet.fill(
      true,
      _bitSetIndexForCharacter("a"),
      _bitSetIndexForCharacter("z") + 1
    );
  } else if (namedCharacterClass.name === Identifier.DIGIT) {
    bitSet.fill(
      true,
      _bitSetIndexForCharacter("0"),
      _bitSetIndexForCharacter("9") + 1
    );
  } else if (namedCharacterClass.name === Identifier.SPECIAL) {
    bitSet.fill(
      true,
      _bitSetIndexForCharacter(" "),
      _bitSetIndexForCharacter("/") + 1
    );
    bitSet.fill(
      true,
      _bitSetIndexForCharacter(":"),
      _bitSetIndexForCharacter("@") + 1
    );
    bitSet.fill(
      true,
      _bitSetIndexForCharacter("["),
      _bitSetIndexForCharacter("`") + 1
    );
    bitSet.fill(
      true,
      _bitSetIndexForCharacter("{"),
      _bitSetIndexForCharacter("~") + 1
    );
  } else {
    console.assert(false, SHOULD_NOT_BE_REACHED, namedCharacterClass);
  }
}

function _markBitsForCustomCharacterClass(bitSet, customCharacterClass) {
  for (let character of customCharacterClass.characters) {
    bitSet[_bitSetIndexForCharacter(character)] = true;
  }
}

function _canonicalizedPropertyValues(
  propertyValues,
  keepCustomCharacterClassFormatCompliant
) {
  let asciiPrintableBitSet = new Array(
    "~".codePointAt(0) - " ".codePointAt(0) + 1
  );

  for (let propertyValue of propertyValues) {
    if (propertyValue instanceof NamedCharacterClass) {
      if (propertyValue.name === Identifier.UNICODE) {
        return [new NamedCharacterClass(Identifier.UNICODE)];
      }

      if (propertyValue.name === Identifier.ASCII_PRINTABLE) {
        return [new NamedCharacterClass(Identifier.ASCII_PRINTABLE)];
      }

      _markBitsForNamedCharacterClass(asciiPrintableBitSet, propertyValue);
    } else if (propertyValue instanceof CustomCharacterClass) {
      _markBitsForCustomCharacterClass(asciiPrintableBitSet, propertyValue);
    }
  }

  let charactersSeen = [];

  function checkRange(start, end) {
    let temp = [];
    for (
      let i = _bitSetIndexForCharacter(start);
      i <= _bitSetIndexForCharacter(end);
      ++i
    ) {
      if (asciiPrintableBitSet[i]) {
        temp.push(_characterAtBitSetIndex(i));
      }
    }

    let result =
      temp.length ===
      _bitSetIndexForCharacter(end) - _bitSetIndexForCharacter(start) + 1;
    if (!result) {
      charactersSeen = charactersSeen.concat(temp);
    }
    return result;
  }

  let hasAllUpper = checkRange("A", "Z");
  let hasAllLower = checkRange("a", "z");
  let hasAllDigits = checkRange("0", "9");

  // Check for special characters, accounting for characters that are given special treatment (i.e. '-' and ']')
  let hasAllSpecial = false;
  let hasDash = false;
  let hasRightSquareBracket = false;
  let temp = [];
  for (
    let i = _bitSetIndexForCharacter(" ");
    i <= _bitSetIndexForCharacter("/");
    ++i
  ) {
    if (!asciiPrintableBitSet[i]) {
      continue;
    }

    let character = _characterAtBitSetIndex(i);
    if (keepCustomCharacterClassFormatCompliant && character === "-") {
      hasDash = true;
    } else {
      temp.push(character);
    }
  }
  for (
    let i = _bitSetIndexForCharacter(":");
    i <= _bitSetIndexForCharacter("@");
    ++i
  ) {
    if (asciiPrintableBitSet[i]) {
      temp.push(_characterAtBitSetIndex(i));
    }
  }
  for (
    let i = _bitSetIndexForCharacter("[");
    i <= _bitSetIndexForCharacter("`");
    ++i
  ) {
    if (!asciiPrintableBitSet[i]) {
      continue;
    }

    let character = _characterAtBitSetIndex(i);
    if (keepCustomCharacterClassFormatCompliant && character === "]") {
      hasRightSquareBracket = true;
    } else {
      temp.push(character);
    }
  }
  for (
    let i = _bitSetIndexForCharacter("{");
    i <= _bitSetIndexForCharacter("~");
    ++i
  ) {
    if (asciiPrintableBitSet[i]) {
      temp.push(_characterAtBitSetIndex(i));
    }
  }

  if (hasDash) {
    temp.unshift("-");
  }
  if (hasRightSquareBracket) {
    temp.push("]");
  }

  let numberOfSpecialCharacters =
    _bitSetIndexForCharacter("/") -
    _bitSetIndexForCharacter(" ") +
    1 +
    (_bitSetIndexForCharacter("@") - _bitSetIndexForCharacter(":") + 1) +
    (_bitSetIndexForCharacter("`") - _bitSetIndexForCharacter("[") + 1) +
    (_bitSetIndexForCharacter("~") - _bitSetIndexForCharacter("{") + 1);
  hasAllSpecial = temp.length === numberOfSpecialCharacters;
  if (!hasAllSpecial) {
    charactersSeen = charactersSeen.concat(temp);
  }

  let result = [];
  if (hasAllUpper && hasAllLower && hasAllDigits && hasAllSpecial) {
    return [new NamedCharacterClass(Identifier.ASCII_PRINTABLE)];
  }
  if (hasAllUpper) {
    result.push(new NamedCharacterClass(Identifier.UPPER));
  }
  if (hasAllLower) {
    result.push(new NamedCharacterClass(Identifier.LOWER));
  }
  if (hasAllDigits) {
    result.push(new NamedCharacterClass(Identifier.DIGIT));
  }
  if (hasAllSpecial) {
    result.push(new NamedCharacterClass(Identifier.SPECIAL));
  }
  if (charactersSeen.length) {
    result.push(new CustomCharacterClass(charactersSeen));
  }
  return result;
}

// MARK: Parser functions

function _indexOfNonWhitespaceCharacter(input, position = 0) {
  console.assert(position >= 0);
  console.assert(position <= input.length);

  let length = input.length;
  while (position < length && _isASCIIWhitespace(input[position])) {
    ++position;
  }

  return position;
}

function _parseIdentifier(input, position) {
  console.assert(position >= 0);
  console.assert(position < input.length);
  console.assert(_isIdentifierCharacter(input[position]));

  let length = input.length;
  let seenIdentifiers = [];
  do {
    let c = input[position];
    if (!_isIdentifierCharacter(c)) {
      break;
    }

    seenIdentifiers.push(c);
    ++position;
  } while (position < length);

  return [seenIdentifiers.join(""), position];
}

function _isValidRequiredOrAllowedPropertyValueIdentifier(identifier) {
  return (
    identifier && Object.values(Identifier).includes(identifier.toLowerCase())
  );
}

function _parseCustomCharacterClass(input, position) {
  console.assert(position >= 0);
  console.assert(position < input.length);
  console.assert(input[position] === CHARACTER_CLASS_START_SENTINEL);

  let length = input.length;
  ++position;
  if (position >= length) {
    console.error("Found end-of-line instead of character class character");
    return [null, position];
  }

  let initialPosition = position;
  let result = [];
  do {
    let c = input[position];
    if (!_isASCIIPrintableCharacter(c)) {
      ++position;
      continue;
    }

    if (c === "-" && position - initialPosition > 0) {
      // FIXME: Should this be an error?
      console.warn(
        "Ignoring '-'; a '-' may only appear as the first character in a character class"
      );
      ++position;
      continue;
    }

    result.push(c);
    ++position;
    if (c === CHARACTER_CLASS_END_SENTINEL) {
      break;
    }
  } while (position < length);

  if (
    (position < length && input[position] !== CHARACTER_CLASS_END_SENTINEL) ||
    (position == length && input[position - 1] == CHARACTER_CLASS_END_SENTINEL)
  ) {
    // Fix up result; we over consumed.
    result.pop();
    return [result, position];
  }

  if (position < length && input[position] == CHARACTER_CLASS_END_SENTINEL) {
    return [result, position + 1];
  }

  console.error("Found end-of-line instead of end of character class");
  return [null, position];
}

function _parsePasswordRequiredOrAllowedPropertyValue(input, position) {
  console.assert(position >= 0);
  console.assert(position < input.length);

  let length = input.length;
  let propertyValues = [];
  while (true) {
    if (_isIdentifierCharacter(input[position])) {
      let identifierStartPosition = position;
      var [propertyValue, position] = _parseIdentifier(input, position);
      if (!_isValidRequiredOrAllowedPropertyValueIdentifier(propertyValue)) {
        console.error(
          "Unrecognized property value identifier: " + propertyValue
        );
        return [null, identifierStartPosition];
      }
      propertyValues.push(new NamedCharacterClass(propertyValue));
    } else if (input[position] == CHARACTER_CLASS_START_SENTINEL) {
      var [propertyValue, position] = _parseCustomCharacterClass(
        input,
        position
      );
      if (propertyValue && propertyValue.length) {
        propertyValues.push(new CustomCharacterClass(propertyValue));
      }
    } else {
      console.error(
        "Failed to find start of property value: " + input.substr(position)
      );
      return [null, position];
    }

    position = _indexOfNonWhitespaceCharacter(input, position);
    if (position >= length || input[position] === PROPERTY_SEPARATOR) {
      break;
    }

    if (input[position] === PROPERTY_VALUE_SEPARATOR) {
      position = _indexOfNonWhitespaceCharacter(input, position + 1);
      if (position >= length) {
        console.error(
          "Found end-of-line instead of start of next property value"
        );
        return [null, position];
      }
      continue;
    }

    console.error(
      "Failed to find start of next property or property value: " +
        input.substr(position)
    );
    return [null, position];
  }
  return [propertyValues, position];
}

function _parsePasswordRule(input, position) {
  console.assert(position >= 0);
  console.assert(position < input.length);
  console.assert(_isIdentifierCharacter(input[position]));

  let length = input.length;

  let mayBeIdentifierStartPosition = position;
  var [identifier, position] = _parseIdentifier(input, position);
  if (!Object.values(RuleName).includes(identifier)) {
    console.error("Unrecognized property name: " + identifier);
    return [null, mayBeIdentifierStartPosition];
  }

  if (position >= length) {
    console.error("Found end-of-line instead of start of property value");
    return [null, position];
  }

  if (input[position] !== PROPERTY_VALUE_START_SENTINEL) {
    console.error(
      "Failed to find start of property value: " + input.substr(position)
    );
    return [null, position];
  }

  let property = { name: identifier, value: null };

  position = _indexOfNonWhitespaceCharacter(input, position + 1);
  // Empty value
  if (position >= length || input[position] === PROPERTY_SEPARATOR) {
    return [new Rule(property.name, property.value), position];
  }

  switch (identifier) {
    case RuleName.ALLOWED:
    case RuleName.REQUIRED: {
      var [
        propertyValue,
        position,
      ] = _parsePasswordRequiredOrAllowedPropertyValue(input, position);
      if (propertyValue) {
        property.value = propertyValue;
      }
      return [new Rule(property.name, property.value), position];
    }
    case RuleName.MAX_CONSECUTIVE: {
      var [propertyValue, position] = _parseMaxConsecutivePropertyValue(
        input,
        position
      );
      if (propertyValue) {
        property.value = propertyValue;
      }
      return [new Rule(property.name, property.value), position];
    }
    case RuleName.MIN_LENGTH:
    case RuleName.MAX_LENGTH: {
      var [propertyValue, position] = _parseMinLengthMaxLengthPropertyValue(
        input,
        position
      );
      if (propertyValue) {
        property.value = propertyValue;
      }
      return [new Rule(property.name, property.value), position];
    }
  }
  console.assert(false, SHOULD_NOT_BE_REACHED);
}

function _parseMinLengthMaxLengthPropertyValue(input, position) {
  return _parseInteger(input, position);
}

function _parseMaxConsecutivePropertyValue(input, position) {
  return _parseInteger(input, position);
}

function _parseInteger(input, position) {
  console.assert(position >= 0);
  console.assert(position < input.length);

  if (!_isASCIIDigit(input[position])) {
    console.error(
      "Failed to parse value of type integer; not a number: " +
        input.substr(position)
    );
    return [null, position];
  }

  let length = input.length;
  let initialPosition = position;
  let result = 0;
  do {
    result = 10 * result + parseInt(input[position], 10);
    ++position;
  } while (
    position < length &&
    input[position] !== PROPERTY_SEPARATOR &&
    _isASCIIDigit(input[position])
  );

  if (position >= length || input[position] === PROPERTY_SEPARATOR) {
    return [result, position];
  }

  console.error(
    "Failed to parse value of type integer; not a number: " +
      input.substr(initialPosition)
  );
  return [null, position];
}

function _parsePasswordRulesInternal(input) {
  let parsedProperties = [];
  let length = input.length;

  var position = _indexOfNonWhitespaceCharacter(input);
  while (position < length) {
    if (!_isIdentifierCharacter(input[position])) {
      console.warn(
        "Failed to find start of property: " + input.substr(position)
      );
      return parsedProperties;
    }

    var [parsedProperty, position] = _parsePasswordRule(input, position);
    if (parsedProperty && parsedProperty.value) {
      parsedProperties.push(parsedProperty);
    }

    position = _indexOfNonWhitespaceCharacter(input, position);
    if (position >= length) {
      break;
    }

    if (input[position] === PROPERTY_SEPARATOR) {
      position = _indexOfNonWhitespaceCharacter(input, position + 1);
      if (position >= length) {
        return parsedProperties;
      }

      continue;
    }

    console.error(
      "Failed to find start of next property: " + input.substr(position)
    );
    return null;
  }

  return parsedProperties;
}

function parsePasswordRules(input, formatRulesForMinifiedVersion) {
  let passwordRules = _parsePasswordRulesInternal(input) || [];

  // When formatting rules for minified version, we should keep the formatted rules
  // as similar to the input as possible. Avoid copying required rules to allowed rules.
  let suppressCopyingRequiredToAllowed = formatRulesForMinifiedVersion;

  let newPasswordRules = [];
  let newAllowedValues = [];
  let minimumMaximumConsecutiveCharacters = null;
  let maximumMinLength = 0;
  let minimumMaxLength = null;

  for (let rule of passwordRules) {
    switch (rule.name) {
      case RuleName.MAX_CONSECUTIVE:
        minimumMaximumConsecutiveCharacters = minimumMaximumConsecutiveCharacters
          ? Math.min(rule.value, minimumMaximumConsecutiveCharacters)
          : rule.value;
        break;

      case RuleName.MIN_LENGTH:
        maximumMinLength = Math.max(rule.value, maximumMinLength);
        break;

      case RuleName.MAX_LENGTH:
        minimumMaxLength = minimumMaxLength
          ? Math.min(rule.value, minimumMaxLength)
          : rule.value;
        break;

      case RuleName.REQUIRED:
        rule.value = _canonicalizedPropertyValues(
          rule.value,
          formatRulesForMinifiedVersion
        );
        newPasswordRules.push(rule);
        if (!suppressCopyingRequiredToAllowed) {
          newAllowedValues = newAllowedValues.concat(rule.value);
        }
        break;

      case RuleName.ALLOWED:
        newAllowedValues = newAllowedValues.concat(rule.value);
        break;
    }
  }

  newAllowedValues = _canonicalizedPropertyValues(
    newAllowedValues,
    suppressCopyingRequiredToAllowed
  );
  if (!suppressCopyingRequiredToAllowed && !newAllowedValues.length) {
    newAllowedValues = [new NamedCharacterClass(Identifier.ASCII_PRINTABLE)];
  }
  if (newAllowedValues.length) {
    newPasswordRules.push(new Rule(RuleName.ALLOWED, newAllowedValues));
  }

  if (minimumMaximumConsecutiveCharacters !== null) {
    newPasswordRules.push(
      new Rule(RuleName.MAX_CONSECUTIVE, minimumMaximumConsecutiveCharacters)
    );
  }

  if (maximumMinLength > 0) {
    newPasswordRules.push(new Rule(RuleName.MIN_LENGTH, maximumMinLength));
  }

  if (minimumMaxLength !== null) {
    newPasswordRules.push(new Rule(RuleName.MAX_LENGTH, minimumMaxLength));
  }

  return newPasswordRules;
}
