/*
 * Copyright 2020 New Relic Corporation. All rights reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

'use strict'

const NO_MATCH = -Infinity
const EXACT_MATCH = Infinity
const DESTINATIONS = {
  NONE: 0x00,
  TRANS_EVENT: 0x01,
  TRANS_TRACE: 0x02,
  ERROR_EVENT: 0x04,
  BROWSER_EVENT: 0x08,
  SPAN_EVENT: 0x10,
  TRANS_SEGMENT: 0x20
}
DESTINATIONS.TRANS_SCOPE =
  DESTINATIONS.TRANS_EVENT |
  DESTINATIONS.TRANS_TRACE |
  DESTINATIONS.ERROR_EVENT |
  DESTINATIONS.BROWSER_EVENT

DESTINATIONS.SEGMENT_SCOPE = DESTINATIONS.SPAN_EVENT | DESTINATIONS.TRANS_SEGMENT

DESTINATIONS.TRANS_COMMON =
  DESTINATIONS.TRANS_EVENT | DESTINATIONS.TRANS_TRACE | DESTINATIONS.ERROR_EVENT

DESTINATIONS.LIMITED = DESTINATIONS.TRANS_TRACE | DESTINATIONS.ERROR_EVENT

const TRANS_SCOPE_DETAILS = [
  { id: DESTINATIONS.TRANS_EVENT, key: 'TRANS_EVENT', name: 'transaction_events' },
  { id: DESTINATIONS.TRANS_TRACE, key: 'TRANS_TRACE', name: 'transaction_tracer' },
  { id: DESTINATIONS.ERROR_EVENT, key: 'ERROR_EVENT', name: 'error_collector' },
  { id: DESTINATIONS.BROWSER_EVENT, key: 'BROWSER_EVENT', name: 'browser_monitoring' }
]

const SEGMENT_SCOPE_DETAILS = [
  { id: DESTINATIONS.SPAN_EVENT, key: 'SPAN_EVENT', name: 'span_events' },
  { id: DESTINATIONS.TRANS_SEGMENT, key: 'TRANS_SEGMENT', name: 'transaction_segments' }
]

const DESTINATION_DETAILS = [...TRANS_SCOPE_DETAILS, ...SEGMENT_SCOPE_DETAILS]

module.exports = exports = AttributeFilter
exports.DESTINATIONS = DESTINATIONS

/**
 * Parses configuration for filtering attributes and provides way to test keys
 * against the configuration.
 *
 * @class
 * @private
 * @param {object} config - The configuration object for the agent.
 */
function AttributeFilter(config) {
  this.config = config
  this._rules = Object.create(null)
  this._cache = Object.create(null)
  this._cachedCount = 0
  this._enabledDestinations = DESTINATIONS.NONE

  const updater = this.update.bind(this)

  // Add the global rules.
  config.on('attributes.enabled', updater)
  config.on('attributes.include', updater)
  config.on('attributes.exclude', updater)
  this._rules.global = Object.create(null)

  // And all the destination rules.
  DESTINATION_DETAILS.forEach(function forEachDestination(dest) {
    config.on(dest.name + '.attributes.enabled', updater)
    config.on(dest.name + '.attributes.include', updater)
    config.on(dest.name + '.attributes.exclude', updater)
    this._rules[dest.name] = Object.create(null)
  }, this)

  // Now pull in all the rules.
  this.update()
}

/**
 * Tests a given key against the global and destination transaction filters.
 *
 * @param {DESTINATIONS}  destinations  - The locations the attribute wants to be put.
 * @param {string}        key           - The name of the attribute to test.
 * @returns {DESTINATIONS} The destinations the attribute should be put.
 */
AttributeFilter.prototype.filterTransaction = filterTransaction
function filterTransaction(destinations, key) {
  return this._filter(TRANS_SCOPE_DETAILS, destinations, key)
}

/**
 * Tests a given key against the global and destination segment filters.
 *
 * @param {DESTINATIONS}  destinations  - The locations the attribute wants to be put.
 * @param {string}        key           - The name of the attribute to test.
 * @returns {DESTINATIONS} The destinations the attribute should be put.
 */
AttributeFilter.prototype.filterSegment = function filterSegment(destinations, key) {
  return this._filter(SEGMENT_SCOPE_DETAILS, destinations, key)
}

/**
 * Tests a given key against all global and destination filters.
 *
 * @param {DESTINATIONS}  destinations  - The locations the attribute wants to be put.
 * @param {string}        key           - The name of the attribute to test.
 * @returns {DESTINATIONS} The destinations the attribute should be put.
 */
AttributeFilter.prototype.filterAll = function filterSegment(destinations, key) {
  return this._filter(DESTINATION_DETAILS, destinations, key)
}

/**
 * Tests a given key against the global and destination filters.
 *
 * @param {Array}         scope         - The destination details for filtering.
 * @param {DESTINATIONS}  destinations  - The locations the attribute wants to be put.
 * @param {string}        key           - The name of the attribute to test.
 * @returns {DESTINATIONS} The destinations the attribute should be put.
 */
AttributeFilter.prototype._filter = function _filter(scope, destinations, key) {
  // This method could be easily memoized since for a given destination and key
  // the result will always be the same until a configuration update happens. A
  // given application will also have a controllable set of destinations and
  // keys to check.

  // First, see if attributes are even enabled for this destination.
  if (!this.config.attributes.enabled) {
    return DESTINATIONS.NONE
  }

  // These are lazy computed to avoid calculating them for cached results.
  let globalInclude = null
  let globalExclude = null

  // Iterate over each destination and see if the rules apply.
  for (let i = 0; i < scope.length; ++i) {
    const dest = scope[i]
    const destId = dest.id
    const destName = dest.name
    if (!(this._enabledDestinations & destId)) {
      destinations &= ~destId // Remove this destination.
      continue
    }

    // Check for a cached result for this key.
    let result = this._cache[destName][key]

    if (result === undefined) {
      if (globalInclude === null) {
        globalInclude = _matchRules(this._rules.global.include, key)
        globalExclude = _matchRules(this._rules.global.exclude, key)
      }

      // Freshly calculate this attribute.
      result = _doTest(globalInclude, globalExclude, this._rules[destName], key)
      if (this._cachedCount < this.config.attributes.filter_cache_limit) {
        this._cache[destName][key] = result
        ++this._cachedCount
      }
    }

    destinations = _updateDestinations(destinations, destId, result)
  }

  return destinations
}

/**
 * Updates all the rules the given filter has access to.
 */
AttributeFilter.prototype.update = function update() {
  // Update the global rules.
  this._rules.global.include = _importRules(
    this.config.attributes.include_enabled ? this.config.attributes.include : []
  )
  this._rules.global.exclude = _importRules(this.config.attributes.exclude)
  this._cache = Object.create(null)
  this._cachedCount = 0

  // And all the destination rules.
  DESTINATION_DETAILS.forEach(function forEachDestination(dest) {
    const name = dest.name
    if (!this.config[name].attributes.enabled) {
      return
    }

    this._enabledDestinations |= dest.id
    this._rules[name].include = _importRules(
      this.config.attributes.include_enabled ? this.config[name].attributes.include : []
    )
    this._rules[name].exclude = _importRules(this.config[name].attributes.exclude)
    this._cache[name] = Object.create(null)
  }, this)
}

/**
 * Applies the global and destination rules to this key.
 *
 * @private
 * @param {number} globalInclude Global inclusion match
 * @param {number} globalExclude Global exclusion match
 * @param {object} destConfig Destination specific include/exclude rules
 * @param {string} key The attribute name
 * @returns {boolean|number} True if this key is explicitly included, false if it is
 *  explicitly excluded, or `NO_MATCH` if no rule applies.
 */
function _doTest(globalInclude, globalExclude, destConfig, key) {
  // Check for exclusion of the attribute.
  if (globalExclude === EXACT_MATCH) {
    return false
  }
  const destExclude = _matchRules(destConfig.exclude, key)
  if (destExclude === EXACT_MATCH) {
    return false
  }

  // Then check for inclusion of the attribute.
  if (globalInclude === EXACT_MATCH) {
    return true
  }
  const destInclude = _matchRules(destConfig.include, key)
  if (destInclude === EXACT_MATCH) {
    return true
  }

  // Did any rule match this key? If not, this is a no-match.
  if (
    globalExclude === NO_MATCH &&
    globalInclude === NO_MATCH &&
    destExclude === NO_MATCH &&
    destInclude === NO_MATCH
  ) {
    return NO_MATCH
  }

  // Something has matched this key, so compare the strength of any wildcard
  // matches that have happened.
  return (
    // If destination include is a better match than either exclude, it's in!
    (destInclude > destExclude && destInclude >= globalExclude) ||
    // If global include is a better match than either exclude, it's in!
    (globalInclude > destExclude && globalInclude > globalExclude)
  )
}

/**
 * Tests the given key against the given rule set.
 *
 * This method assumes that the rule set is sorted from best possible match to
 * least possible match. Unsorted lists may result in a lesser score being given
 * to the value.
 *
 * @private
 * @param {Array.<string>}  rules - The set of rules to match against.
 * @param {string}          key   - The name of the attribute to look for.
 * @returns {number} The strength of the match, from `0` for no-match to `Infinity`
 *  for exact matches.
 */
function _matchRules(rules, key) {
  if (rules.exact && rules.exact.test(key)) {
    return EXACT_MATCH
  }

  const wildcard = rules.wildcard
  if (!wildcard) {
    return NO_MATCH
  }

  wildcard.lastIndex = 0
  return wildcard.test(key) ? wildcard.lastIndex + 1 : NO_MATCH
}

/**
 * Converts the raw rules into a set of regular expressions to test against.
 *
 * @private
 * @param {Array.<string>} rules - The set of rules to compose.
 * @returns {object} An object with `exact` and `wildcard` properties which are
 * `RegExp` instances for testing keys.
 */
function _importRules(rules) {
  const out = {
    exact: null,
    wildcard: null
  }
  const exactRules = []
  const wildcardRules = []
  rules.forEach(function separateRules(rule) {
    if (rule[rule.length - 1] === '*') {
      wildcardRules.push(rule)
    } else {
      exactRules.push(rule)
    }
  })

  if (exactRules.length) {
    out.exact = new RegExp('^' + _convertRulesToRegex(exactRules) + '$')
  }
  if (wildcardRules.length) {
    // The 'g' option is what makes the RegExp set `lastIndex` which we use to
    // test the strength of the match.
    out.wildcard = new RegExp('^' + _convertRulesToRegex(wildcardRules), 'g')
  }
  return out
}

/**
 * Converts an array of attribute rules into a regular expression string.
 *
 * `["foo.bar", "foo.bang"]` becomes "(?:foo\.(?:bar|bang))"
 *
 * @private
 * @param {Array.<string>} rules - The set of rules compose into a regex.
 * @returns {string} The rules composed into a single regular expression string.
 */
function _convertRulesToRegex(rules) {
  return (
    '(?:' +
    rules
      .sort(function ruleSorter(a, b) {
        // Step 1) Sort the rules according to match-ability. This way the regex
        // will test the rules with the highest possible strength before weaker rules.

        if (a[a.length - 1] !== '*') {
          // If `a` is an exact rule, it should be moved up.
          return -1
        } else if (b[b.length - 1] !== '*') {
          // If `b` is an exact rule and `a` is not, `b` should be moved up.
          return 1
        }

        // Both `a` and `b` are wildcard rules, so the rule with greater length
        // should be moved up.
        return b.length - a.length
      })
      .map(function ruleSplitter(rule) {
        // Step 2) Escape regex special characters and split the rules into arrays.

        // 'foo.bar' => ['foo', 'bar']
        // 'foo.bang*' => ['foo', 'bang\\*']
        // 'fizz.bang' => ['fizz', 'bang']
        // '*' => ['\\*']
        return rule
          .replace(/([.*+?|\\^$()\[\]])/g, function cleaner(m) {
            return '\\' + m
          })
          .split('.')
      })
      .reduce(function ruleTransformer(collection, ruleParts) {
        // Step 3) Merge the split rules into a single nested array, deduplicating
        // rule sections as we go.

        // ['foo', 'bar'] => [['foo\\.', ['bar']]]
        // ['foo', 'bang\\*'] => [['foo\\.', ['bar'], ['bang']]]
        // ['fizz', 'bang'] => [['foo\\.', ['bar'], ['bang']], ['fizz\\.', ['bang']]]
        // ['\\*'] => [['foo\\.', ['bar'], ['bang']], ['fizz\\.', ['bang']], ['']]
        add(collection, ruleParts, 0)
        return collection
        function add(c, r, i) {
          let v = r[i]
          if (i !== r.length - 1) {
            v += '.'
          } else if (/\\\*$/.test(v)) {
            v = v.substr(0, v.length - 2)
          }

          const idx = c.findIndex(function findV(a) {
            return a[0] === v
          })
          let part = c[idx]

          if (idx === -1) {
            part = [v]
            c.push(part)
          }
          if (i !== r.length - 1) {
            add(part, r, i + 1)
          }
        }
      }, [])
      .map(function rulesToRegex(part) {
        // Step 4) Merge each of the transformed rules into a regex.

        // ['foo\\.', ['bar', 'bang']] => 'foo\\.(?:bar|bang)'
        // ['fizz\\.', ['bang']] => 'fizz\\.(?:bang)'
        // [''] => ''
        return mapper(part)
        function mapper(p) {
          if (typeof p === 'string') {
            return p
          } else if (p.length === 1) {
            return mapper(p[0])
          }
          const first = mapper(p.shift()) // shift === pop_front
          return first + '(?:' + p.map(mapper).join('|') + ')'
        }
      })
      .join('|') +
    ')'
  ) // Step 5) Merge all the regex strings into one.
}

/**
 * Helper method for updating our list of destinations
 *
 * @private
 * @param {object} destinations list of destination ids
 * @param {string} id destination id
 * @param {boolean} result whether or not we found a match
 * @returns {object} potentially modified list of destinations
 */
function _updateDestinations(destinations, id, result) {
  if (result === NO_MATCH) {
    // No match, no-op.
  } else if (result) {
    destinations |= id // Positive match, add it in.
  } else {
    destinations &= ~id // Negative match, remove it.
  }

  return destinations
}
