text: /*
   * Copyright (c) 2017 Digital Bazaar, Inc. All rights reserved.
   */
  'use strict';
  
  const graphTypes = require('./graphTypes');
  const types = require('./types');
  // TODO: move `IdentifierIssuer` to its own package
  const IdentifierIssuer = require('rdf-canonize').IdentifierIssuer;
  const JsonLdError = require('./JsonLdError');
  
  // constants
  const REGEX_LINK_HEADERS = /(?:<[^>]*?>|"[^"]*?"|[^,])+/g;
  const REGEX_LINK_HEADER = /\s*<([^>]*?)>\s*(?:;\s*(.*))?/;
  const REGEX_LINK_HEADER_PARAMS =
    /(.*?)=(?:(?:"([^"]*?)")|([^"]*?))\s*(?:(?:;\s*)|$)/g;
  
  const DEFAULTS = {
    headers: {
      accept: 'application/ld+json, application/json'
    }
  };
  
  const api = {};
  module.exports = api;
  api.IdentifierIssuer = IdentifierIssuer;
  
  // define setImmediate and nextTick
  // // nextTick implementation with browser-compatible fallback // //
  // from https://github.com/caolan/async/blob/master/lib/async.js
  
  // capture the global reference to guard against fakeTimer mocks
  const _setImmediate = typeof setImmediate === 'function' && setImmediate;
  
  const _delay = _setImmediate ?
    // not a direct alias (for IE10 compatibility)
    fn => _setImmediate(fn) :
    fn => setTimeout(fn, 0);
  
  if(typeof process === 'object' && typeof process.nextTick === 'function') {
    api.nextTick = process.nextTick;
  } else {
    api.nextTick = _delay;
  }
  api.setImmediate = _setImmediate ? _delay : api.nextTick;
  
  /**
   * Clones an object, array, or string/number. If a typed JavaScript object
   * is given, such as a Date, it will be converted to a string.
   *
   * @param value the value to clone.
   *
   * @return the cloned value.
   */
  api.clone = function(value) {
    if(value && typeof value === 'object') {
      let rval;
      if(types.isArray(value)) {
        rval = [];
        for(let i = 0; i < value.length; ++i) {
          rval[i] = api.clone(value[i]);
        }
      } else if(types.isObject(value)) {
        rval = {};
        for(const key in value) {
          rval[key] = api.clone(value[key]);
        }
      } else {
        rval = value.toString();
      }
      return rval;
    }
    return value;
  };
  
  /**
   * Builds an HTTP headers object for making a JSON-LD request from custom
   * headers and asserts the `accept` header isn't overridden.
   *
   * @param headers an object of headers with keys as header names and values
   *          as header values.
   *
   * @return an object of headers with a valid `accept` header.
   */
  api.buildHeaders = (headers = {}) => {
    const hasAccept = Object.keys(headers).some(
      h => h.toLowerCase() === 'accept');
  
    if(hasAccept) {
      throw new RangeError(
        'Accept header may not be specified; only "' +
        DEFAULTS.headers.accept + '" is supported.');
    }
  
    return Object.assign({'Accept': DEFAULTS.headers.accept}, headers);
  };
  
  /**
   * Parses a link header. The results will be key'd by the value of "rel".
   *
   * Link: <http://json-ld.org/contexts/person.jsonld>;
   * rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
   *
   * Parses as: {
   *   'http://www.w3.org/ns/json-ld#context': {
   *     target: http://json-ld.org/contexts/person.jsonld,
   *     type: 'application/ld+json'
   *   }
   * }
   *
   * If there is more than one "rel" with the same IRI, then entries in the
   * resulting map for that "rel" will be arrays.
   *
   * @param header the link header to parse.
   */
  api.parseLinkHeader = header => {
    const rval = {};
    // split on unbracketed/unquoted commas
    const entries = header.match(REGEX_LINK_HEADERS);
    for(let i = 0; i < entries.length; ++i) {
      let match = entries[i].match(REGEX_LINK_HEADER);
      if(!match) {
        continue;
      }
      const result = {target: match[1]};
      const params = match[2];
      while((match = REGEX_LINK_HEADER_PARAMS.exec(params))) {
        result[match[1]] = (match[2] === undefined) ? match[3] : match[2];
      }
      const rel = result['rel'] || '';
      if(Array.isArray(rval[rel])) {
        rval[rel].push(result);
      } else if(rel in rval) {
        rval[rel] = [rval[rel], result];
      } else {
        rval[rel] = result;
      }
    }
    return rval;
  };
  
  /**
   * Throws an exception if the given value is not a valid @type value.
   *
   * @param v the value to check.
   */
  api.validateTypeValue = v => {
    // can be a string or an empty object
    if(types.isString(v) || types.isEmptyObject(v)) {
      return;
    }
  
    // must be an array
    let isValid = false;
    if(types.isArray(v)) {
      // must contain only strings
      isValid = true;
      for(let i = 0; i < v.length; ++i) {
        if(!(types.isString(v[i]))) {
          isValid = false;
          break;
        }
      }
    }
  
    if(!isValid) {
      throw new JsonLdError(
        'Invalid JSON-LD syntax; "@type" value must a string, an array of ' +
        'strings, or an empty object.', 'jsonld.SyntaxError',
        {code: 'invalid type value', value: v});
    }
  };
  
  /**
   * Returns true if the given subject has the given property.
   *
   * @param subject the subject to check.
   * @param property the property to look for.
   *
   * @return true if the subject has the given property, false if not.
   */
  api.hasProperty = (subject, property) => {
    if(property in subject) {
      const value = subject[property];
      return (!types.isArray(value) || value.length > 0);
    }
    return false;
  };
  
  /**
   * Determines if the given value is a property of the given subject.
   *
   * @param subject the subject to check.
   * @param property the property to check.
   * @param value the value to check.
   *
   * @return true if the value exists, false if not.
   */
  api.hasValue = (subject, property, value) => {
    if(api.hasProperty(subject, property)) {
      let val = subject[property];
      const isList = graphTypes.isList(val);
      if(types.isArray(val) || isList) {
        if(isList) {
          val = val['@list'];
        }
        for(let i = 0; i < val.length; ++i) {
          if(api.compareValues(value, val[i])) {
            return true;
          }
        }
      } else if(!types.isArray(value)) {
        // avoid matching the set of values with an array value parameter
        return api.compareValues(value, val);
      }
    }
    return false;
  };
  
  /**
   * Adds a value to a subject. If the value is an array, all values in the
   * array will be added.
   *
   * @param subject the subject to add the value to.
   * @param property the property that relates the value to the subject.
   * @param value the value to add.
   * @param [options] the options to use:
   *        [propertyIsArray] true if the property is always an array, false
   *          if not (default: false).
   *        [allowDuplicate] true to allow duplicates, false not to (uses a
   *          simple shallow comparison of subject ID or value) (default: true).
   */
  api.addValue = (subject, property, value, options) => {
    options = options || {};
    if(!('propertyIsArray' in options)) {
      options.propertyIsArray = false;
    }
    if(!('allowDuplicate' in options)) {
      options.allowDuplicate = true;
    }
  
    if(types.isArray(value)) {
      if(value.length === 0 && options.propertyIsArray &&
        !(property in subject)) {
        subject[property] = [];
      }
      for(let i = 0; i < value.length; ++i) {
        api.addValue(subject, property, value[i], options);
      }
    } else if(property in subject) {
      // check if subject already has value if duplicates not allowed
      const hasValue = (!options.allowDuplicate &&
        api.hasValue(subject, property, value));
  
      // make property an array if value not present or always an array
      if(!types.isArray(subject[property]) &&
        (!hasValue || options.propertyIsArray)) {
        subject[property] = [subject[property]];
      }
  
      // add new value
      if(!hasValue) {
        subject[property].push(value);
      }
    } else {
      // add new value as set or single value
      subject[property] = options.propertyIsArray ? [value] : value;
    }
  };
  
  /**
   * Gets all of the values for a subject's property as an array.
   *
   * @param subject the subject.
   * @param property the property.
   *
   * @return all of the values for a subject's property as an array.
   */
  api.getValues = (subject, property) => [].concat(subject[property] || []);
  
  /**
   * Removes a property from a subject.
   *
   * @param subject the subject.
   * @param property the property.
   */
  api.removeProperty = (subject, property) => {
    delete subject[property];
  };
  
  /**
   * Removes a value from a subject.
   *
   * @param subject the subject.
   * @param property the property that relates the value to the subject.
   * @param value the value to remove.
   * @param [options] the options to use:
   *          [propertyIsArray] true if the property is always an array, false
   *            if not (default: false).
   */
  api.removeValue = (subject, property, value, options) => {
    options = options || {};
    if(!('propertyIsArray' in options)) {
      options.propertyIsArray = false;
    }
  
    // filter out value
    const values = api.getValues(subject, property).filter(
      e => !api.compareValues(e, value));
  
    if(values.length === 0) {
      api.removeProperty(subject, property);
    } else if(values.length === 1 && !options.propertyIsArray) {
      subject[property] = values[0];
    } else {
      subject[property] = values;
    }
  };
  
  /**
   * Relabels all blank nodes in the given JSON-LD input.
   *
   * @param input the JSON-LD input.
   * @param [options] the options to use:
   *          [issuer] an IdentifierIssuer to use to label blank nodes.
   */
  api.relabelBlankNodes = (input, options) => {
    options = options || {};
    const issuer = options.issuer || new IdentifierIssuer('_:b');
    return _labelBlankNodes(issuer, input);
  };
  
  /**
   * Compares two JSON-LD values for equality. Two JSON-LD values will be
   * considered equal if:
   *
   * 1. They are both primitives of the same type and value.
   * 2. They are both @values with the same @value, @type, @language,
   *   and @index, OR
   * 3. They both have @ids they are the same.
   *
   * @param v1 the first value.
   * @param v2 the second value.
   *
   * @return true if v1 and v2 are considered equal, false if not.
   */
  api.compareValues = (v1, v2) => {
    // 1. equal primitives
    if(v1 === v2) {
      return true;
    }
  
    // 2. equal @values
    if(graphTypes.isValue(v1) && graphTypes.isValue(v2) &&
      v1['@value'] === v2['@value'] &&
      v1['@type'] === v2['@type'] &&
      v1['@language'] === v2['@language'] &&
      v1['@index'] === v2['@index']) {
      return true;
    }
  
    // 3. equal @ids
    if(types.isObject(v1) &&
      ('@id' in v1) &&
      types.isObject(v2) &&
      ('@id' in v2)) {
      return v1['@id'] === v2['@id'];
    }
  
    return false;
  };
  
  /**
   * Compares two strings first based on length and then lexicographically.
   *
   * @param a the first string.
   * @param b the second string.
   *
   * @return -1 if a < b, 1 if a > b, 0 if a === b.
   */
  api.compareShortestLeast = (a, b) => {
    if(a.length < b.length) {
      return -1;
    }
    if(b.length < a.length) {
      return 1;
    }
    if(a === b) {
      return 0;
    }
    return (a < b) ? -1 : 1;
  };
  
  api.normalizeDocumentLoader = fn => {
    if(fn.length < 2) {
      return api.callbackify(fn);
    }
  
    return async function(url) {
      const callback = arguments[1];
      return new Promise((resolve, reject) => {
        try {
          fn(url, (err, remoteDoc) => {
            if(typeof callback === 'function') {
              return _invokeCallback(callback, err, remoteDoc);
            } else if(err) {
              reject(err);
            } else {
              resolve(remoteDoc);
            }
          });
        } catch(e) {
          if(typeof callback === 'function') {
            return _invokeCallback(callback, e);
          }
          reject(e);
        }
      });
    };
  };
  
  api.callbackify = fn => {
    return async function(...args) {
      const callback = args[args.length - 1];
      if(typeof callback === 'function') {
        args.pop();
      }
  
      let result;
      try {
        result = await fn.apply(null, args);
      } catch(e) {
        if(typeof callback === 'function') {
          return _invokeCallback(callback, e);
        }
        throw e;
      }
  
      if(typeof callback === 'function') {
        return _invokeCallback(callback, null, result);
      }
  
      return result;
    };
  };
  
  function _invokeCallback(callback, err, result) {
    // execute on next tick to prevent "unhandled rejected promise"
    // and simulate what would have happened in a promiseless API
    api.nextTick(() => callback(err, result));
  }
  
  /**
   * Labels the blank nodes in the given value using the given IdentifierIssuer.
   *
   * @param issuer the IdentifierIssuer to use.
   * @param element the element with blank nodes to rename.
   *
   * @return the element.
   */
  function _labelBlankNodes(issuer, element) {
    if(types.isArray(element)) {
      for(let i = 0; i < element.length; ++i) {
        element[i] = _labelBlankNodes(issuer, element[i]);
      }
    } else if(graphTypes.isList(element)) {
      element['@list'] = _labelBlankNodes(issuer, element['@list']);
    } else if(types.isObject(element)) {
      // relabel blank node
      if(graphTypes.isBlankNode(element)) {
        element['@id'] = issuer.getId(element['@id']);
      }
  
      // recursively apply to all keys
      const keys = Object.keys(element).sort();
      for(let ki = 0; ki < keys.length; ++ki) {
        const key = keys[ki];
        if(key !== '@id') {
          element[key] = _labelBlankNodes(issuer, element[key]);
        }
      }
    }
  
    return element;
  }
  