/*!
 * Copyright 2015 Apereo Foundation (AF) Licensed under the
 * Educational Community License, Version 2.0 (the "License"); you may
 * not use this file except in compliance with the License. You may
 * obtain a copy of the License at
 *
 *     http://opensource.org/licenses/ECL-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an "AS IS"
 * BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
 * or implied. See the License for the specific language governing
 * permissions and limitations under the License.
 */

/* eslint-disable unicorn/filename-case */
import _ from 'underscore';

/**
 * Indexes domains so that suffix-matching can be efficiently performed. An entry can be add to
 * the index which is keyed by a domain (e.g., `cam.ac.uk`) and the value must be some string
 * value (e.g., a tenant alias). When indexed, the following operations can be performed
 * efficiently:
 *
 *  * The index will ensure no domains can be set that are a prefix or a descendent of another
 *    domain already in the index. Therefore, if `cam.ac.uk` is set, the index will guarantee that
 *    `uk.ac` cannot be set (it is a prefix) and that `caret.cam.ac.uk` cannot be set (it is a
 *    descendant)
 *  * `match` - Given a domain, the index can efficiently locate a unique prefix that exists in the
 *    index. For example, if key -> value of `cam.ac.uk` -> `"cam"` is set in the index, then
 *    looking for a match on `cam.ac.uk` or `caret.cam.ac.uk` will both return the value `"cam"`.
 *    However matching `ac.uk` will return nothing, as matching *only* matches on prefixes that
 *    exist in the index
 *  * `conflict` - Given a domain, the index can efficiently test whether or not the domain will be
 *    able to be set as a key in the index. A domain can be set if the `match` operation returns no
 *    results, AND if there are no descendants of the domain. E.g., if `cam.ac.uk` exists, then all
 *    of `ca.uk`, `cam.ac.uk` and `caret.cam.ac.uk` would result in a conflict with `cam.ac.uk`
 *  * `update` - Provides the ability to efficiently set an email domain in the index to a string
 *    value. Once set, all subsequent attempts to set a domain that is a suffix or descendant of the
 *    domain will result in a conflict
 */
const EmailDomainIndex = function() {
  const index = {};

  /*!
   * Find all string values that are set to domains that are descendants of the given email
   * domain
   *
   * @param  {String}     emailDomain     The email domain for which to find descendant values
   * @return {String[]}                   The values (not a domain, the value that was set to the email domain) associated descendants of the specified domain
   */
  const _find = function(emailDomain) {
    if (!emailDomain) {
      return [];
    }

    let chain = _.chain(index);
    _.each(_split(emailDomain), part => {
      chain = chain.oaeGet(part);
    });
    return _findStringLeaves(chain.value());
  };

  /*!
   * Set the given string alias to the given email domain
   *
   * @param  {String}     alias           The string alias to set
   * @param  {String}     emailDomain     The email domain to use as the key
   * @return {String}                     If specified, indicates that there was a conflict. The value will be one of potentially many string values that the domain conflicted with. If `false`y, it indicates that setting the value was successful and there were no conflicts
   */
  const _set = function(alias, emailDomain) {
    if (!emailDomain) {
      return;
    }

    // First find all descendant values. If there are any, it's a conflict
    const existing = _find(emailDomain);
    if (!_.isEmpty(existing)) {
      return _.first(existing);
    }

    // Set the segment value. If we come across a match along the way, it is a conflict and we
    // instead return the conflict value
    let segment = index;
    const parts = _split(emailDomain);
    _.each(parts.slice(0, -1), part => {
      if (_.isString(segment)) {
        // If we already found a leaf node, bail. This is a conflict
        return;
      }

      segment[part] = segment[part] || {};
      segment = segment[part];
    });

    const lastPart = _.last(parts);
    if (_.isString(segment)) {
      // If we found a leaf node, we can't set anything as we'll overwrite an existing entry
      return segment;
    }

    if (_.isString(segment[lastPart])) {
      // If this domain is an exact match to an existing domain, we cannot override, it should
      // be deleted first instead. Result in a conflict
      return segment[lastPart];
    }

    // If we didn't find a leaf node, we've instantiated all the index segments up to the
    // leaf alias, so set it on the index
    segment[lastPart] = alias;

    // Indicate we successfully set without finding a conflict
  };

  /*!
   * @see EmailDomainIndex.match
   */
  const _match = function(emailDomain) {
    if (!emailDomain) {
      return null;
    }

    // Walk up the domain tree until we find a string leaf
    let result = index;
    _.each(_split(emailDomain), part => {
      if (_.isString(result)) {
        // If the result is a tenant alias string, we have found a tenant
        return;
      }

      if (!result) {
        // If we reached the end without finding a string leaf, we have exhausted the tree
        return;
      }

      result = result[part];
    });

    return _.isString(result) ? result : null;
  };

  /*!
   * @see EmailDomainIndex.conflict
   */
  const _conflict = function(alias, emailDomain) {
    // If there is an existing match for this email domain that is not this tenant alias, we
    // return with the alias that it conflicts with. We cannot proceed with the update
    const match = _match(emailDomain);
    if (match && match !== alias) {
      return match;
    }

    // If there are existing tenants with email domains that ours would match with (e.g.,
    // the domain is `cam.ac.uk` and there exists a `caret.cam.ac.uk`), then we cannot
    // proceed as it will be a conflict
    const existing = _.without(_find(emailDomain), alias);
    if (!_.isEmpty(existing)) {
      return _.first(existing);
    }

    // Otherwise, there is no conflict
  };

  /*!
   * Delete a domain from the index
   *
   * @param  {String}     oldEmailDomain  The email domain to remove from the index
   */
  const _delete = function(oldEmailDomain) {
    if (!oldEmailDomain) {
      return;
    }

    const parts = _split(oldEmailDomain);
    let segment = index;
    _.each(parts.slice(0, -1), part => {
      if (!segment) {
        return;
      }

      segment = segment[part];
    });

    if (segment) {
      delete segment[_.last(parts)];
    }
  };

  return {
    /**
     * Determine if the specified email domain results in a conflict. An `alias` is provided to
     * indicate that any conflict with only the specified value is not considered a conflict. For
     * example, if there is an existing entry of `cam.ac.uk` -> `"cam"`, then testing for conflict
     * with `"cam"` / `caret.cam.ac.uk` will not result in a conflict. This is important to test
     * updates without having to first remove a key
     *
     * @param  {String}     alias           The string value to exclude from conflict detection
     * @param  {String}     emailDomain     The domain to check for conflict
     * @return {String}                     If specified, it is one of potential many aliases that conflicted. If `false`y, there was no conflict
     */
    conflict(alias, emailDomain) {
      return _conflict(alias, _toLowerCase(emailDomain));
    },

    /**
     * Find a unique value that is set as a prefix of the specified domain
     *
     * @param  {String}     emailDomain     The domain to use to find a match
     * @return {String}                     The alias that was set for the matching domain, if any. If there are no matches, this will return `false`y
     */
    match(emailDomain) {
      return _match(_toLowerCase(emailDomain));
    },

    /**
     * Delete a domain from the index
     *
     * @param  {String}     emailDomain  The email domain to remove from the index
     */
    delete(emailDomain) {
      _delete(emailDomain);
    },

    /**
     * Update given alias in the email domain index. This will remove the specified email domain
     * (if any) and set the current email domain. If the final state of the index would result
     * in conflicting domains as described in the index summary documentation, then this method
     * will return one of potentially conflict values (i.e., aliases)
     *
     * @param  {String}     alias               The alias value to set
     * @param  {String}     emailDomain         The email domain to associate to the specified alias
     * @param  {String}     [oldEmailDomain]    The previous email domain to which the alias was associated, if any
     */
    update(alias, emailDomain, oldEmailDomain) {
      emailDomain = _toLowerCase(emailDomain);
      oldEmailDomain = _toLowerCase(oldEmailDomain);

      // Do nothing if there is no change to the email domain
      if (emailDomain === oldEmailDomain) {
        return;
      }

      // First ensure there are no conflicts
      const conflict = _conflict(alias, emailDomain);
      if (conflict) {
        return conflict;
      }

      // We have ruled out potential conflicts. Go ahead with the update
      _delete(oldEmailDomain);
      _set(alias, emailDomain);
    }
  };
};

/**
 * Given a node in the domain index (a string leaf or an object segment), find all string leaves
 * that descend from it. For example, if the index contains 2 entries: `caret.cam.ac.uk -> 'caret'`
 * and `library.cam.ac.uk -> 'library'`, and the index segment represented by `uk.ac.cam` is
 * provided, then this will return ['cam', 'library']
 *
 * @param  {Object}     obj     The index node to search
 * @api private
 */
const _findStringLeaves = function(obj, _leaves) {
  _leaves = _leaves || [];
  if (!obj) {
    // If we've reached the end of the search, return the aggregated `_leaves`
    return _leaves;
  }

  if (_.isString(obj)) {
    // If we have arrived at a leaf, aggregate the leaf node and return the array
    _leaves.push(obj);
    return _leaves;
  }

  // We have an object / index segment. Recursively search it for all leaf nodes
  _.each(obj, val => {
    _findStringLeaves(val, _leaves);
  });

  // Return all the aggregated string leaf nodes
  return _leaves;
};

/**
 * Lower case the given string if specified
 *
 * @param  {String}     [str]   The string to lower-case
 * @return {String}             The string lower cased. If the string was falsey, will be returned verbatim
 * @api private
 */
const _toLowerCase = function(str) {
  if (str) {
    return str.toLowerCase();
  }

  return str;
};

/**
 * Split the given domain into domain parts starting from the highest level domain, down to the
 * bottom. For example, if `cam.ac.uk` is provided, then `['uk', 'ac', 'cam']` is the result
 *
 * @param  {String}     emailDomain     The domain to split
 * @return {String[]}                   The domain ports ordered, ordered top-down
 * @api private
 */
const _split = function(emailDomain) {
  return emailDomain.split('.').reverse();
};

export default EmailDomainIndex;
