'use strict'

const warning = require('./deprecations')
const escapeValue = require('./utils/escape-value')
const isDottedDecimal = require('./utils/is-dotted-decimal')

/**
 * Implements a relative distinguished name as described in
 * https://www.rfc-editor.org/rfc/rfc4514.
 *
 * @example
 * const rdn = new RDN({cn: 'jdoe', givenName: 'John'})
 * rdn.toString() // 'cn=jdoe+givenName=John'
 */
class RDN {
  #attributes = new Map()

  /**
   * @param {object} rdn An object of key-values to use as RDN attribute
   * types and attribute values. Attribute values should be strings.
   */
  constructor (rdn = {}) {
    for (const [key, val] of Object.entries(rdn)) {
      this.setAttribute({ name: key, value: val })
    }
  }

  get [Symbol.toStringTag] () {
    return 'LdapRdn'
  }

  /**
   * The number attributes associated with the RDN.
   *
   * @returns {number}
   */
  get size () {
    return this.#attributes.size
  }

  /**
   * Very naive equality check against another RDN instance. In short, if they
   * do not have the exact same key names with the exact same values, then
   * this check will return `false`.
   *
   * @param {RDN} rdn
   *
   * @returns {boolean}
   *
   * @todo Should implement support for the attribute types listed in https://www.rfc-editor.org/rfc/rfc4514#section-3
   */
  equals (rdn) {
    if (Object.prototype.toString.call(rdn) !== '[object LdapRdn]') {
      return false
    }
    if (this.size !== rdn.size) {
      return false
    }

    for (const key of this.keys()) {
      if (rdn.has(key) === false) return false
      if (this.getValue(key) !== rdn.getValue(key)) return false
    }

    return true
  }

  /**
   * The value associated with the given attribute name.
   *
   * @param {string} name An attribute name associated with the RDN.
   *
   * @returns {*}
   */
  getValue (name) {
    return this.#attributes.get(name)?.value
  }

  /**
   * Determine if the RDN has a specific attribute assigned.
   *
   * @param {string} name The name of the attribute.
   *
   * @returns {boolean}
   */
  has (name) {
    return this.#attributes.has(name)
  }

  /**
   * All attribute names associated with the RDN.
   *
   * @returns {IterableIterator<string>}
   */
  keys () {
    return this.#attributes.keys()
  }

  /**
   * Define an attribute type and value on the RDN.
   *
   * @param {string} name
   * @param {string | import('@ldapjs/asn1').BerReader} value
   * @param {object} options Deprecated. All options will be ignored.
   *
   * @throws If any parameter is invalid.
   */
  setAttribute ({ name, value, options = {} }) {
    if (typeof name !== 'string') {
      throw Error('name must be a string')
    }

    const valType = Object.prototype.toString.call(value)
    if (typeof value !== 'string' && valType !== '[object BerReader]') {
      throw Error('value must be a string or BerReader')
    }
    if (Object.prototype.toString.call(options) !== '[object Object]') {
      throw Error('options must be an object')
    }

    const startsWithAlpha = str => /^[a-zA-Z]/.test(str) === true
    if (startsWithAlpha(name) === false && isDottedDecimal(name) === false) {
      throw Error('attribute name must start with an ASCII alpha character or be a numeric OID')
    }

    const attr = { value, name }
    for (const [key, val] of Object.entries(options)) {
      warning.emit('LDAP_DN_DEP_001')
      if (key === 'value') continue
      attr[key] = val
    }

    this.#attributes.set(name, attr)
  }

  /**
   * Convert the RDN to a string representation. If an attribute value is
   * an instance of `BerReader`, the value will be encoded appropriately.
   *
   * @example Dotted Decimal Type
   * const rdn = new RDN({
   *   cn: '#foo',
   *   '1.3.6.1.4.1.1466.0': '#04024869'
   * })
   * rnd.toString()
   * // => 'cn=\23foo+1.3.6.1.4.1.1466.0=#04024869'
   *
   * @example Unescaped Value
   * const rdn = new RDN({
   *   cn: '#foo'
   * })
   * rdn.toString({ unescaped: true })
   * // => 'cn=#foo'
   *
   * @param {object} [options]
   * @param {boolean} [options.unescaped=false] Return the unescaped version
   * of the RDN string.
   *
   * @returns {string}
   */
  toString ({ unescaped = false } = {}) {
    let result = ''
    const isHexEncodedValue = val => /^#([0-9a-fA-F]{2})+$/.test(val) === true

    for (const entry of this.#attributes.values()) {
      result += entry.name + '='

      if (isHexEncodedValue(entry.value)) {
        result += entry.value
      } else if (Object.prototype.toString.call(entry.value) === '[object BerReader]') {
        let encoded = '#'
        for (const byte of entry.value.buffer) {
          encoded += Number(byte).toString(16).padStart(2, '0')
        }
        result += encoded
      } else {
        result += unescaped === false ? escapeValue(entry.value) : entry.value
      }

      result += '+'
    }

    return result.substring(0, result.length - 1)
  }

  /**
   * @returns {string}
   *
   * @deprecated Use {@link toString}.
   */
  format () {
    // If we decide to add back support for this, we should do it as
    // `.toStringWithFormatting(options)`.
    warning.emit('LDAP_DN_DEP_002')
    return this.toString()
  }

  /**
   * @param {string} name
   * @param {string} value
   * @param {object} options
   *
   * @deprecated Use {@link setAttribute}.
   */
  set (name, value, options) {
    warning.emit('LDAP_DN_DEP_003')
    this.setAttribute({ name, value, options })
  }

  /**
   * Determine if an object is an instance of {@link RDN} or is at least
   * a RDN-like object. It is safer to perform a `toString` check.
   *
   * @example Valid Instance
   * const Rdn = new RDN()
   * RDN.isRdn(rdn) // true
   *
   * @example RDN-like Instance
   * const rdn = { name: 'cn', value: 'foo' }
   * RDN.isRdn(rdn) // true
   *
   * @example Preferred Check
   * let rdn = new RDN()
   * Object.prototype.toString.call(rdn) === '[object LdapRdn]' // true
   *
   * dn = { name: 'cn', value: 'foo' }
   * Object.prototype.toString.call(dn) === '[object LdapRdn]' // false
   *
   * @param {object} rdn
   * @returns {boolean}
   */
  static isRdn (rdn) {
    if (Object.prototype.toString.call(rdn) === '[object LdapRdn]') {
      return true
    }

    const isObject = Object.prototype.toString.call(rdn) === '[object Object]'
    if (isObject === false) {
      return false
    }

    if (typeof rdn.name === 'string' && typeof rdn.value === 'string') {
      return true
    }

    for (const value of Object.values(rdn)) {
      if (
        typeof value !== 'string' &&
        Object.prototype.toString.call(value) !== '[object BerReader]'
      ) return false
    }

    return true
  }
}

module.exports = RDN
