text: /**
   * Utilities
   * @module utils
   * @borrows module:auth_token as generate_auth_token
   */
  
  const crypto = require("crypto");
  const querystring = require("querystring");
  const urlParse = require("url").parse;
  
  // Functions used internally
  const compact = require("lodash/compact");
  const first = require("lodash/first");
  const isFunction = require("lodash/isFunction");
  const isPlainObject = require("lodash/isPlainObject");
  const last = require("lodash/last");
  const map = require("lodash/map");
  const take = require("lodash/take");
  const at = require("lodash/at");
  
  // Exposed by the module
  const clone = require("lodash/clone");
  const extend = require("lodash/extend");
  const filter = require("lodash/filter");
  const includes = require("lodash/includes");
  const isArray = require("lodash/isArray");
  const isEmpty = require("lodash/isEmpty");
  const isNumber = require("lodash/isNumber");
  const isObject = require("lodash/isObject");
  const isString = require("lodash/isString");
  const isUndefined = require("lodash/isUndefined");
  
  const smart_escape = require("./encoding/smart_escape");
  const consumeOption = require('./parsing/consumeOption');
  const toArray = require('./parsing/toArray');
  let { base64EncodeURL } = require('./encoding/base64EncodeURL');
  const encodeDoubleArray = require('./encoding/encodeDoubleArray');
  
  const config = require("../config");
  const generate_token = require("../auth_token");
  const utf8_encode = require('./utf8_encode');
  const crc32 = require('./crc32');
  const ensurePresenceOf = require('./ensurePresenceOf');
  const ensureOption = require('./ensureOption').defaults(config());
  const entries = require('./entries');
  const isRemoteUrl = require('./isRemoteUrl');
  
  exports = module.exports;
  const utils = module.exports;
  
  try {
    // eslint-disable-next-line global-require
    utils.VERSION = require('../../package.json').version;
  } catch (error) {
    utils.VERSION = '';
  }
  
  function generate_auth_token(options) {
    let token_options = Object.assign({}, config().auth_token, options);
    return generate_token(token_options);
  }
  
  exports.CF_SHARED_CDN = "d3jpl91pxevbkh.cloudfront.net";
  exports.OLD_AKAMAI_SHARED_CDN = "cloudinary-a.akamaihd.net";
  exports.AKAMAI_SHARED_CDN = "res.cloudinary.com";
  exports.SHARED_CDN = exports.AKAMAI_SHARED_CDN;
  exports.USER_AGENT = `CloudinaryNodeJS/${exports.VERSION}`;
  
  // Add platform information to the USER_AGENT header
  // This is intended for platform information and not individual applications!
  exports.userPlatform = "";
  
  function getUserAgent() {
    return isEmpty(utils.userPlatform) ? `${utils.USER_AGENT}` : `${utils.userPlatform} ${utils.USER_AGENT}`;
  }
  
  const {
    DEFAULT_RESPONSIVE_WIDTH_TRANSFORMATION,
    DEFAULT_POSTER_OPTIONS,
    DEFAULT_VIDEO_SOURCE_TYPES,
    CONDITIONAL_OPERATORS,
    PREDEFINED_VARS,
    LAYER_KEYWORD_PARAMS,
    TRANSFORMATION_PARAMS,
    SIMPLE_PARAMS,
  } = require('./consts');
  
  function textStyle(layer) {
    let keywords = [];
    let style = "";
    Object.keys(LAYER_KEYWORD_PARAMS).forEach((attr) => {
      let default_value = LAYER_KEYWORD_PARAMS[attr];
      let attr_value = layer[attr] || default_value;
      if (attr_value !== default_value) {
        keywords.push(attr_value);
      }
    });
  
    Object.keys(layer).forEach((attr) => {
      if (attr === "letter_spacing" || attr === "line_spacing") {
        keywords.push(`${attr}_${layer[attr]}`);
      }
      if (attr === "font_hinting") {
        keywords.push(`${attr.split("_").pop()}_${layer[attr]}`);
      }
      if (attr === "font_antialiasing") {
        keywords.push(`antialias_${layer[attr]}`);
      }
    });
  
    if (layer.hasOwnProperty("font_size" || "font_family") || !isEmpty(keywords)) {
      if (!layer.font_size) throw `Must supply font_size for text in overlay/underlay`;
      if (!layer.font_family) throw `Must supply font_family for text in overlay/underlay`;
      keywords.unshift(layer.font_size);
      keywords.unshift(layer.font_family);
      style = compact(keywords).join("_");
    }
    return style;
  }
  
  /**
   * Normalize an offset value
   * @param {String} expression a decimal value which may have a 'p' or '%' postfix. E.g. '35%', '0.4p'
   * @return {Object|String} a normalized String of the input value if possible otherwise the value itself
   */
  function normalize_expression(expression) {
    if (!isString(expression) || expression.length === 0 || expression.match(/^!.+!$/)) {
      return expression;
    }
    const operators = "\\|\\||>=|<=|&&|!=|>|=|<|/|-|\\+|\\*";
    const pattern = "((" + operators + ")(?=[ _])|" + Object.keys(PREDEFINED_VARS).join("|") + ")";
    const replaceRE = new RegExp(pattern, "g");
    expression = expression.replace(replaceRE, function (match) {
      return CONDITIONAL_OPERATORS[match] || PREDEFINED_VARS[match];
    });
    return expression.replace(/[ _]+/g, '_');
  }
  
  /**
   * Parse custom_function options
   * @private
   * @param {object|*} customFunction a custom function object containing function_type and source values
   * @return {string|*} custom_function transformation string
   */
  function process_custom_function(customFunction) {
    if (!isObject(customFunction)) {
      return customFunction;
    }
    if (customFunction.function_type === "remote") {
      return [customFunction.function_type, base64EncodeURL(customFunction.source)].join(":");
    }
    return [customFunction.function_type, customFunction.source].join(":");
  }
  
  /**
   * Parse custom_pre_function options
   * @private
   * @param {object|*} customPreFunction a custom function object containing function_type and source values
   * @return {string|*} custom_pre_function transformation string
   */
  function process_custom_pre_function(customPreFunction) {
    let result = process_custom_function(customPreFunction);
    return utils.isString(result) ? `pre:${result}` : null;
  }
  
  /**
   * Parse "if" parameter
   * Translates the condition if provided.
   * @private
   * @return {string} "if_" + ifValue
   */
  function process_if(ifValue) {
    return ifValue ? "if_" + normalize_expression(ifValue) : ifValue;
  }
  
  /**
   * Parse layer options
   * @private
   * @param {object|*} layer The layer to parse.
   * @return {string} layer transformation string
   */
  function process_layer(layer) {
    let result = '';
    if (isPlainObject(layer)) {
      if (layer.resource_type === "fetch" || (layer.url != null)) {
        result = `fetch:${base64EncodeURL(layer.url)}`;
      } else {
        let public_id = layer.public_id;
        let format = layer.format;
        let resource_type = layer.resource_type || "image";
        let type = layer.type || "upload";
        let text = layer.text;
        let style = null;
        let components = [];
        const noPublicId = isEmpty(public_id);
        if (!noPublicId) {
          public_id = public_id.replace(new RegExp("/", 'g'), ":");
          if (format != null) {
            public_id = `${public_id}.${format}`;
          }
        }
        if (isEmpty(text) && resource_type !== "text") {
          if (noPublicId) {
            throw "Must supply public_id for resource_type layer_parameter";
          }
          if (resource_type === "subtitles") {
            style = textStyle(layer);
          }
        } else {
          resource_type = "text";
          type = null;
          // type is ignored for text layers
          style = textStyle(layer);
          if (!isEmpty(text)) {
            const noStyle = isEmpty(style);
            if (!(noPublicId || noStyle) || (noPublicId && noStyle)) {
              throw "Must supply either style parameters or a public_id when providing text parameter in a text overlay/underlay";
            }
            let re = /\$\([a-zA-Z]\w*\)/g;
            let start = 0;
            let textSource = smart_escape(decodeURIComponent(text), /[,\/]/g);
            text = "";
            for (let res = re.exec(textSource); res; res = re.exec(textSource)) {
              text += smart_escape(textSource.slice(start, res.index));
              text += res[0];
              start = res.index + res[0].length;
            }
            text += encodeURIComponent(textSource.slice(start));
          }
        }
        if (resource_type !== "image") {
          components.push(resource_type);
        }
        if (type !== "upload") {
          components.push(type);
        }
        components.push(style);
        components.push(public_id);
        components.push(text);
        result = compact(components).join(":");
      }
    } else if (/^fetch:.+/.test(layer)) {
      result = `fetch:${base64EncodeURL(layer.substr(6))}`;
    } else {
      result = layer;
    }
    return result;
  }
  
  /**
   * Parse radius options
   * @private
   * @param {Array<string|number>|string|number} radius The radius to parse
   * @return {string} radius transformation string
   */
  function process_radius(radius) {
    if (!radius) {
      return radius;
    }
    if (!isArray(radius)) {
      radius = [radius];
    }
    if (radius.length === 0 || radius.length > 4) {
      throw new Error("Radius array should contain between 1 and 4 values");
    }
    if (radius.findIndex(x => x === null) >= 0) {
      throw new Error("Corner: Cannot be null");
    }
    return radius.map(normalize_expression).join(':');
  }
  
  function build_upload_params(options) {
    let params = {
      access_mode: options.access_mode,
      allowed_formats: options.allowed_formats && toArray(options.allowed_formats).join(","),
      async: utils.as_safe_bool(options.async),
      backup: utils.as_safe_bool(options.backup),
      callback: options.callback,
      colors: utils.as_safe_bool(options.colors),
      discard_original_filename: utils.as_safe_bool(options.discard_original_filename),
      eager: utils.build_eager(options.eager),
      eager_async: utils.as_safe_bool(options.eager_async),
      eager_notification_url: options.eager_notification_url,
      exif: utils.as_safe_bool(options.exif),
      faces: utils.as_safe_bool(options.faces),
      folder: options.folder,
      format: options.format,
      image_metadata: utils.as_safe_bool(options.image_metadata),
      invalidate: utils.as_safe_bool(options.invalidate),
      moderation: options.moderation,
      notification_url: options.notification_url,
      overwrite: utils.as_safe_bool(options.overwrite),
      phash: utils.as_safe_bool(options.phash),
      proxy: options.proxy,
      public_id: options.public_id,
      quality_analysis: utils.as_safe_bool(options.quality_analysis),
      responsive_breakpoints: utils.generate_responsive_breakpoints_string(options.responsive_breakpoints),
      return_delete_token: utils.as_safe_bool(options.return_delete_token),
      timestamp: options.timestamp || exports.timestamp(),
      transformation: utils.generate_transformation_string(clone(options)),
      type: options.type,
      unique_filename: utils.as_safe_bool(options.unique_filename),
      upload_preset: options.upload_preset,
      use_filename: utils.as_safe_bool(options.use_filename),
      quality_override: options.quality_override,
    };
    return utils.updateable_resource_params(options, params);
  }
  
  function encode_key_value(arg) {
    if (!isObject(arg)) { return arg; }
    return entries(arg).map(([k, v]) => `${k}=${v}`).join('|');
  }
  
  function encode_context(arg) {
    if (!isObject(arg)) { return arg; }
    return entries(arg).map(([k, v]) => `${k}=${v.replace(/([=|])/g, '\\$&')}`).join('|');
  }
  
  function build_eager(transformations) {
    return toArray(transformations)
      .map((transformation) => {
        const transformationString = utils.generate_transformation_string(clone(transformation));
        const format = transformation.format;
        return format == null ? transformationString : `${transformationString}/${format}`;
      }).join('|');
  }
  /**
   * Build the custom headers for the request
   * @private
   * @param headers
   * @return {Array<string>|object|string} An object of name and value,
   *         an array of header strings, or a string of headers
   */
  function build_custom_headers(headers) {
    switch (true) {
      case headers == null:
        return void 0;
      case isArray(headers):
        return headers.join("\n");
      case isObject(headers):
        return entries(headers).map(([k, v]) => `${k}:${v}`).join("\n");
      default:
        return headers;
    }
  }
  
  function generate_transformation_string(options) {
    if (utils.isString(options)) {
      return options;
    }
    if (isArray(options)) {
      return options.map(t => utils.generate_transformation_string(clone(t))).filter(utils.present).join('/');
    }
  
    let responsive_width = consumeOption(options, "responsive_width", config().responsive_width);
    let width = options.width;
    let height = options.height;
    let size = consumeOption(options, "size");
    if (size) {
      [width, height] = size.split("x");
      [options.width, options.height] = [width, height];
    }
    let has_layer = options.overlay || options.underlay;
    let crop = consumeOption(options, "crop");
    let angle = toArray(consumeOption(options, "angle")).join(".");
    let no_html_sizes = has_layer || utils.present(angle) || crop === "fit" || crop === "limit" || responsive_width;
    if (width && (width.toString().indexOf("auto") === 0 || no_html_sizes || parseFloat(width) < 1)) {
      delete options.width;
    }
    if (height && (no_html_sizes || parseFloat(height) < 1)) {
      delete options.height;
    }
    let background = consumeOption(options, "background");
    background = background && background.replace(/^#/, "rgb:");
    let color = consumeOption(options, "color");
    color = color && color.replace(/^#/, "rgb:");
    let base_transformations = toArray(consumeOption(options, "transformation", []));
    let named_transformation = [];
    if (base_transformations.some(isObject)) {
      base_transformations = base_transformations.map(tr => utils.generate_transformation_string(
        isObject(tr) ? clone(tr) : { transformation: tr }
      ));
    } else {
      named_transformation = base_transformations.join(".");
      base_transformations = [];
    }
    let effect = consumeOption(options, "effect");
    if (isArray(effect)) {
      effect = effect.join(":");
    } else if (isObject(effect)) {
      effect = entries(effect).map(
        ([key, value]) => `${key}:${value}`,
      );
    }
    let border = consumeOption(options, "border");
    if (isObject(border)) {
      border = `${border.width != null ? border.width : 2}px_solid_${(border.color != null ? border.color : "black").replace(/^#/, 'rgb:')}`;
    } else if (/^\d+$/.exec(border)) { // fallback to html border attributes
      options.border = border;
      border = void 0;
    }
    let flags = toArray(consumeOption(options, "flags")).join(".");
    let dpr = consumeOption(options, "dpr", config().dpr);
    if (options.offset != null) {
      [options.start_offset, options.end_offset] = split_range(consumeOption(options, "offset"));
    }
    let overlay = process_layer(consumeOption(options, "overlay"));
    let radius = process_radius(consumeOption(options, "radius"));
    let underlay = process_layer(consumeOption(options, "underlay"));
    let ifValue = process_if(consumeOption(options, "if"));
    let custom_function = process_custom_function(consumeOption(options, "custom_function"));
    let custom_pre_function = process_custom_pre_function(consumeOption(options, "custom_pre_function"));
    let fps = consumeOption(options, 'fps');
    if (isArray(fps)) {
      fps = fps.join('-');
    }
    let params = {
      a: normalize_expression(angle),
      ar: normalize_expression(consumeOption(options, "aspect_ratio")),
      b: background,
      bo: border,
      c: crop,
      co: color,
      dpr: normalize_expression(dpr),
      e: normalize_expression(effect),
      fl: flags,
      fn: custom_function || custom_pre_function,
      fps: fps,
      h: normalize_expression(height),
      ki: normalize_expression(consumeOption(options, "keyframe_interval")),
      l: overlay,
      o: normalize_expression(consumeOption(options, "opacity")),
      q: normalize_expression(consumeOption(options, "quality")),
      r: radius,
      t: named_transformation,
      u: underlay,
      w: normalize_expression(width),
      x: normalize_expression(consumeOption(options, "x")),
      y: normalize_expression(consumeOption(options, "y")),
      z: normalize_expression(consumeOption(options, "zoom")),
    };
  
    SIMPLE_PARAMS.forEach(([name, short]) => {
      let value = consumeOption(options, name);
      if (value !== undefined) {
        params[short] = value;
      }
    });
    if (params.vc != null) {
      params.vc = process_video_params(params.vc);
    }
    ["so", "eo", "du"].forEach((short) => {
      if (params[short] !== undefined) {
        params[short] = norm_range_value(params[short]);
      }
    });
  
    let variablesParam = consumeOption(options, "variables", []);
    let variables = entries(options)
      .filter(([key, value]) => key.startsWith('$'))
      .map(([key, value]) => {
        delete options[key];
        return `${key}_${normalize_expression(value)}`;
      }).sort().concat(
        variablesParam.map(([name, value]) => `${name}_${normalize_expression(value)}`),
      ).join(',');
  
    let transformations = entries(params)
      .filter(([key, value]) => utils.present(value))
      .map(([key, value]) => key + '_' + value)
      .sort()
      .join(',');
  
    let raw_transformation = consumeOption(options, 'raw_transformation');
    transformations = compact([ifValue, variables, transformations, raw_transformation]).join(",");
    base_transformations.push(transformations);
    transformations = base_transformations;
    if (responsive_width) {
      let responsive_width_transformation = config().responsive_width_transformation
        || DEFAULT_RESPONSIVE_WIDTH_TRANSFORMATION;
  
      transformations.push(utils.generate_transformation_string(clone(responsive_width_transformation)));
    }
    if (String(width).startsWith("auto") || responsive_width) {
      options.responsive = true;
    }
    if (dpr === "auto") {
      options.hidpi = true;
    }
    return filter(transformations, utils.present).join("/");
  }
  
  function updateable_resource_params(options, params = {}) {
    if (options.access_control != null) {
      params.access_control = utils.jsonArrayParam(options.access_control);
    }
    if (options.auto_tagging != null) {
      params.auto_tagging = options.auto_tagging;
    }
    if (options.background_removal != null) {
      params.background_removal = options.background_removal;
    }
    if (options.categorization != null) {
      params.categorization = options.categorization;
    }
    if (options.context != null) {
      params.context = utils.encode_context(options.context);
    }
    if (options.metadata != null) {
      params.metadata = utils.encode_context(options.metadata);
    }
    if (options.custom_coordinates != null) {
      params.custom_coordinates = encodeDoubleArray(options.custom_coordinates);
    }
    if (options.detection != null) {
      params.detection = options.detection;
    }
    if (options.face_coordinates != null) {
      params.face_coordinates = encodeDoubleArray(options.face_coordinates);
    }
    if (options.headers != null) {
      params.headers = utils.build_custom_headers(options.headers);
    }
    if (options.notification_url != null) {
      params.notification_url = options.notification_url;
    }
    if (options.ocr != null) {
      params.ocr = options.ocr;
    }
    if (options.raw_convert != null) {
      params.raw_convert = options.raw_convert;
    }
    if (options.similarity_search != null) {
      params.similarity_search = options.similarity_search;
    }
    if (options.tags != null) {
      params.tags = toArray(options.tags).join(",");
    }
    if (options.quality_override != null) {
      params.quality_override = options.quality_override;
    }
    return params;
  }
  
  /**
   * A list of keys used by the url() function.
   * @private
   */
  const URL_KEYS = [
    'api_secret',
    'auth_token',
    'cdn_subdomain',
    'cloud_name',
    'cname',
    'format',
    'private_cdn',
    'resource_type',
    'secure',
    'secure_cdn_subdomain',
    'secure_distribution',
    'shorten',
    'sign_url',
    'ssl_detected',
    'type',
    'url_suffix',
    'use_root_path',
    'version',
  ];
  
  /**
   * Create a new object with only URL parameters
   * @param {object} options The source object
   * @return {Object} An object containing only URL parameters
   */
  
  function extractUrlParams(options) {
    return utils.only(options, ...URL_KEYS);
  }
  
  /**
   * Create a new object with only transformation parameters
   * @param {object} options The source object
   * @return {Object} An object containing only transformation parameters
   */
  
  function extractTransformationParams(options) {
    return utils.only(options, ...TRANSFORMATION_PARAMS);
  }
  
  /**
   * Handle the format parameter for fetch urls
   * @private
   * @param options url and transformation options. This argument may be changed by the function!
   */
  
  function patchFetchFormat(options = {}) {
    if (options.type === "fetch") {
      if (options.fetch_format == null) {
        options.fetch_format = consumeOption(options, "format");
      }
    }
  }
  
  function url(public_id, options = {}) {
    let signature, source_to_sign;
    utils.patchFetchFormat(options);
    let type = consumeOption(options, "type", null);
    let transformation = utils.generate_transformation_string(options);
  
    let resource_type = consumeOption(options, "resource_type", "image");
    let version = consumeOption(options, "version");
    let force_version = consumeOption(options, "force_version", config().force_version);
    if (force_version == null) {
      force_version = true;
    }
    let format = consumeOption(options, "format");
    let cloud_name = consumeOption(options, "cloud_name", config().cloud_name);
    if (!cloud_name) {
      throw "Unknown cloud_name";
    }
    let private_cdn = consumeOption(options, "private_cdn", config().private_cdn);
    let secure_distribution = consumeOption(options, "secure_distribution", config().secure_distribution);
    let secure = consumeOption(options, "secure", null);
    let ssl_detected = consumeOption(options, "ssl_detected", config().ssl_detected);
    if (secure === null) {
      secure = ssl_detected || config().secure;
    }
    let cdn_subdomain = consumeOption(options, "cdn_subdomain", config().cdn_subdomain);
    let secure_cdn_subdomain = consumeOption(options, "secure_cdn_subdomain", config().secure_cdn_subdomain);
    let cname = consumeOption(options, "cname", config().cname);
    let shorten = consumeOption(options, "shorten", config().shorten);
    let sign_url = consumeOption(options, "sign_url", config().sign_url);
    let api_secret = consumeOption(options, "api_secret", config().api_secret);
    let url_suffix = consumeOption(options, "url_suffix");
    let use_root_path = consumeOption(options, "use_root_path", config().use_root_path);
    let auth_token = consumeOption(options, "auth_token");
    if (auth_token !== false) {
      auth_token = exports.merge(config().auth_token, auth_token);
    }
    let preloaded = /^(image|raw)\/([a-z0-9_]+)\/v(\d+)\/([^#]+)$/.exec(public_id);
    if (preloaded) {
      resource_type = preloaded[1];
      type = preloaded[2];
      version = preloaded[3];
      public_id = preloaded[4];
    }
    let original_source = public_id;
    if (public_id == null) {
      return original_source;
    }
    public_id = public_id.toString();
    if (type === null && public_id.match(/^https?:\//i)) {
      return original_source;
    }
    [resource_type, type] = finalize_resource_type(resource_type, type, url_suffix, use_root_path, shorten);
    [public_id, source_to_sign] = finalize_source(public_id, format, url_suffix);
  
    if (version == null && force_version && source_to_sign.indexOf("/") >= 0 && !source_to_sign.match(/^v[0-9]+/) && !source_to_sign.match(/^https?:\//)) {
      version = 1;
    }
    if (version != null) {
      version = `v${version}`;
    } else {
      version = null;
    }
  
    transformation = transformation.replace(/([^:])\/\//g, '$1/');
    if (sign_url && isEmpty(auth_token)) {
      let to_sign = [transformation, source_to_sign].filter(function (part) {
        return (part != null) && part !== '';
      }).join('/');
      try {
        for (let i = 0; to_sign !== decodeURIComponent(to_sign) && i < 10; i++) {
          to_sign = decodeURIComponent(to_sign);
        }
        // eslint-disable-next-line no-empty
      } catch (error) {
      }
      let shasum = crypto.createHash('sha1');
      shasum.update(utf8_encode(to_sign + api_secret), 'binary');
      signature = shasum.digest('base64').replace(/\//g, '_').replace(/\+/g, '-').substring(0, 8);
      signature = `s--${signature}--`;
    }
    let prefix = unsigned_url_prefix(
      public_id,
      cloud_name,
      private_cdn,
      cdn_subdomain,
      secure_cdn_subdomain,
      cname,
      secure,
      secure_distribution,
    );
    let resultUrl = [prefix, resource_type, type, signature, transformation, version, public_id].filter(function (part) {
      return (part != null) && part !== '';
    }).join('/').replace(' ', '%20');
    if (sign_url && !isEmpty(auth_token)) {
      auth_token.url = urlParse(resultUrl).path;
      let token = generate_token(auth_token);
      resultUrl += `?${token}`;
    }
    return resultUrl;
  }
  
  function video_url(public_id, options) {
    options = extend({
      resource_type: 'video',
    }, options);
    return utils.url(public_id, options);
  }
  
  function finalize_source(source, format, url_suffix) {
    let source_to_sign;
    source = source.replace(/([^:])\/\//g, '$1/');
    if (source.match(/^https?:\//i)) {
      source = smart_escape(source);
      source_to_sign = source;
    } else {
      source = encodeURIComponent(decodeURIComponent(source)).replace(/%3A/g, ":").replace(/%2F/g, "/");
      source_to_sign = source;
      if (url_suffix) {
        if (url_suffix.match(/[\.\/]/)) {
          throw new Error('url_suffix should not include . or /');
        }
        source = source + '/' + url_suffix;
      }
      if (format != null) {
        source = source + '.' + format;
        source_to_sign = source_to_sign + '.' + format;
      }
    }
    return [source, source_to_sign];
  }
  
  function video_thumbnail_url(public_id, options) {
    options = extend({}, DEFAULT_POSTER_OPTIONS, options);
    return utils.url(public_id, options);
  }
  
  function finalize_resource_type(resource_type, type, url_suffix, use_root_path, shorten) {
    if (type == null) {
      type = 'upload';
    }
    if (url_suffix != null) {
      if (resource_type === 'image' && type === 'upload') {
        resource_type = "images";
        type = null;
      } else if (resource_type === 'image' && type === 'private') {
        resource_type = 'private_images';
        type = null;
      } else if (resource_type === 'image' && type === 'authenticated') {
        resource_type = 'authenticated_images';
        type = null;
      } else if (resource_type === 'raw' && type === 'upload') {
        resource_type = 'files';
        type = null;
      } else if (resource_type === 'video' && type === 'upload') {
        resource_type = 'videos';
        type = null;
      } else {
        throw new Error("URL Suffix only supported for image/upload, image/private, image/authenticated, video/upload and raw/upload");
      }
    }
    if (use_root_path) {
      if ((resource_type === 'image' && type === 'upload') || (resource_type === 'images' && (type == null))) {
        resource_type = null;
        type = null;
      } else {
        throw new Error("Root path only supported for image/upload");
      }
    }
    if (shorten && resource_type === 'image' && type === 'upload') {
      resource_type = 'iu';
      type = null;
    }
    return [resource_type, type];
  }
  // cdn_subdomain and secure_cdn_subdomain
  // 1) Customers in shared distribution (e.g. res.cloudinary.com)
  //    if cdn_domain is true uses res-[1-5].cloudinary.com for both http and https.
  //    Setting secure_cdn_subdomain to false disables this for https.
  // 2) Customers with private cdn
  //    if cdn_domain is true uses cloudname-res-[1-5].cloudinary.com for http
  //    if secure_cdn_domain is true uses cloudname-res-[1-5].cloudinary.com for https
  //      (please contact support if you require this)
  // 3) Customers with cname
  //    if cdn_domain is true uses a[1-5].cname for http.
  //    For https, uses the same naming scheme as 1 for shared distribution and as 2 for private distribution.
  
  function unsigned_url_prefix(
    source,
    cloud_name,
    private_cdn,
    cdn_subdomain,
    secure_cdn_subdomain,
    cname,
    secure,
    secure_distribution,
  ) {
    let prefix;
    if (cloud_name.indexOf("/") === 0) {
      return '/res' + cloud_name;
    }
    let shared_domain = !private_cdn;
    if (secure) {
      if ((secure_distribution == null) || secure_distribution === exports.OLD_AKAMAI_SHARED_CDN) {
        secure_distribution = private_cdn ? cloud_name + "-res.cloudinary.com" : exports.SHARED_CDN;
      }
      if (shared_domain == null) {
        shared_domain = secure_distribution === exports.SHARED_CDN;
      }
      if ((secure_cdn_subdomain == null) && shared_domain) {
        secure_cdn_subdomain = cdn_subdomain;
      }
      if (secure_cdn_subdomain) {
        secure_distribution = secure_distribution.replace('res.cloudinary.com', 'res-' + ((crc32(source) % 5) + 1 + '.cloudinary.com'));
      }
      prefix = 'https://' + secure_distribution;
    } else if (cname) {
      let subdomain = cdn_subdomain ? 'a' + ((crc32(source) % 5) + 1) + '.' : '';
      prefix = 'http://' + subdomain + cname;
    } else {
      let cdn_part = private_cdn ? cloud_name + '-' : '';
      let subdomain_part = cdn_subdomain ? '-' + ((crc32(source) % 5) + 1) : '';
      let host = [cdn_part, 'res', subdomain_part, '.cloudinary.com'].join('');
      prefix = 'http://' + host;
    }
    if (shared_domain) {
      prefix += '/' + cloud_name;
    }
    return prefix;
  }
  
  function api_url(action = 'upload', options = {}) {
    let cloudinary = ensureOption(options, "upload_prefix", "https://api.cloudinary.com");
    let cloud_name = ensureOption(options, "cloud_name");
    let resource_type = options.resource_type || "image";
    return [cloudinary, "v1_1", cloud_name, resource_type, action].join("/");
  }
  
  function random_public_id() {
    return crypto.randomBytes(12).toString('base64').replace(/[^a-z0-9]/g, "");
  }
  
  function signed_preloaded_image(result) {
    return `${result.resource_type}/upload/v${result.version}/${filter([result.public_id, result.format], utils.present).join(".")}#${result.signature}`;
  }
  
  function api_sign_request(params_to_sign, api_secret) {
    let to_sign = entries(params_to_sign).filter(
      ([k, v]) => utils.present(v),
    ).map(
      ([k, v]) => `${k}=${toArray(v).join(",")}`,
    ).sort().join("&");
    let shasum = crypto.createHash('sha1');
    shasum.update(utf8_encode(to_sign + api_secret), 'binary');
    return shasum.digest('hex');
  }
  
  function clear_blank(hash) {
    let filtered_hash = {};
    entries(hash).filter(
      ([k, v]) => utils.present(v),
    ).forEach(
      ([k, v]) => { filtered_hash[k] = v; },
    );
    return filtered_hash;
  }
  
  function merge(hash1, hash2) {
    return { ...hash1, ...hash2 };
  }
  
  function sign_request(params, options = {}) {
    let apiKey = ensureOption(options, 'api_key');
    let apiSecret = ensureOption(options, 'api_secret');
    params = exports.clear_blank(params);
    params.signature = exports.api_sign_request(params, apiSecret);
    params.api_key = apiKey;
    return params;
  }
  
  function webhook_signature(data, timestamp, options = {}) {
    ensurePresenceOf({ data, timestamp });
  
    let api_secret = ensureOption(options, 'api_secret');
    let shasum = crypto.createHash('sha1');
    shasum.update(data + timestamp + api_secret, 'binary');
    return shasum.digest('hex');
  }
  
  /**
   * Verifies the authenticity of a notification signature
   *
   * @param {string} body JSON of the request's body
   * @param {number} timestamp Unix timestamp. Can be retrieved from the X-Cld-Timestamp header
   * @param {string} signature Actual signature. Can be retrieved from the X-Cld-Signature header
   * @param {number} [valid_for=7200] The desired time in seconds for considering the request valid
   *
   * @return {boolean}
   */
  function verifyNotificationSignature(body, timestamp, signature, valid_for = 7200) {
    // verify that signature is valid for the given timestamp
    if (timestamp < Date.now() - valid_for) {
      return false;
    }
    const payload_hash = utils.webhook_signature(body, timestamp, { api_secret: config().api_secret });
    return signature === payload_hash;
  }
  
  function process_request_params(params, options) {
    if ((options.unsigned != null) && options.unsigned) {
      params = exports.clear_blank(params);
      delete params.timestamp;
    } else if (options.signature) {
      params = exports.clear_blank(options);
    } else {
      params = exports.sign_request(params, options);
    }
    return params;
  }
  
  function private_download_url(public_id, format, options = {}) {
    let params = exports.sign_request({
      timestamp: options.timestamp || exports.timestamp(),
      public_id: public_id,
      format: format,
      type: options.type,
      attachment: options.attachment,
      expires_at: options.expires_at,
    }, options);
    return exports.api_url("download", options) + "?" + querystring.stringify(params);
  }
  
  /**
   * Utility method that uses the deprecated ZIP download API.
   * @deprecated Replaced by {download_zip_url} that uses the more advanced and robust archive generation and download API
   */
  
  function zip_download_url(tag, options = {}) {
    let params = exports.sign_request({
      timestamp: options.timestamp || exports.timestamp(),
      tag: tag,
      transformation: utils.generate_transformation_string(options),
    }, options);
    return exports.api_url("download_tag.zip", options) + "?" + hashToQuery(params);
  }
  
  /**
   * Returns a URL that when invokes creates an archive and returns it.
   * @param {object} options
   * @param {string} [options.resource_type="image"] The resource type of files to include in the archive.
   *   Must be one of :image | :video | :raw
   * @param {string} [options.type="upload"] The specific file type of resources: :upload|:private|:authenticated
   * @param {string|Array} [options.tags] list of tags to include in the archive
   * @param {string|Array<string>} [options.public_ids] list of public_ids to include in the archive
   * @param {string|Array<string>} [options.prefixes]  list of prefixes of public IDs (e.g., folders).
   * @param {string|Array<string>} [options.fully_qualified_public_ids] list of fully qualified public_ids to include
   *   in the archive.
   * @param {string|Array<string>} [options.transformations]  list of transformations.
   *   The derived images of the given transformations are included in the archive. Using the string representation of
   *   multiple chained transformations as we use for the 'eager' upload parameter.
   * @param {string} [options.mode="create"] return the generated archive file or to store it as a raw resource and
   *   return a JSON with URLs for accessing the archive. Possible values: :download, :create
   * @param {string} [options.target_format="zip"]
   * @param {string} [options.target_public_id]  public ID of the generated raw resource.
   *   Relevant only for the create mode. If not specified, random public ID is generated.
   * @param {boolean} [options.flatten_folders=false] If true, flatten public IDs with folders to be in the root
   *   of the archive. Add numeric counter to the file name in case of a name conflict.
   * @param {boolean} [options.flatten_transformations=false] If true, and multiple transformations are given,
   *   flatten the folder structure of derived images and store the transformation details on the file name instead.
   * @param {boolean} [options.use_original_filename] Use the original file name of included images
   *   (if available) instead of the public ID.
   * @param {boolean} [options.async=false] If true, return immediately and perform archive creation in the background.
   *   Relevant only for the create mode.
   * @param {string} [options.notification_url] URL to send an HTTP post request (webhook) to when the
   *   archive creation is completed.
   * @param {string|Array<string>} [options.target_tags=] Allows assigning one or more tags to the generated archive file
   *   (for later housekeeping via the admin API).
   * @param {string} [options.keep_derived=false] keep the derived images used for generating the archive
   * @return {String} archive url
   */
  function download_archive_url(options = {}) {
    let cloudinary_params = exports.sign_request(exports.archive_params(merge(options, {
      mode: "download",
    })), options);
    return exports.api_url("generate_archive", options) + "?" + hashToQuery(cloudinary_params);
  }
  
  /**
   * Returns a URL that when invokes creates an zip archive and returns it.
   * @see download_archive_url
   */
  
  function download_zip_url(options = {}) {
    return exports.download_archive_url(merge(options, {
      target_format: "zip",
    }));
  }
  
  /**
   * Render the key/value pair as an HTML tag attribute
   * @private
   * @param {string} key
   * @param {string|boolean|number} [value]
   * @return {string} A string representing the HTML attribute
   */
  function join_pair(key, value) {
    if (!value) { return void 0; }
    return value === true ? key : key + "='" + value + "'";
  }
  
  /**
   * If the given value is a string, replaces single or double quotes with character entities
   * @private
   * @param {*} value The string to encode quotes in
   * @return {*} Encoded string or original value if not a string
   */
  function escapeQuotes(value) {
    return isString(value) ? value.replace('"', '&#34;').replace("'", '&#39;') : value;
  }
  
  /**
   *
   * @param attrs
   * @return {*}
   */
  exports.html_attrs = function html_attrs(attrs) {
    return filter(map(attrs, function (value, key) {
      return join_pair(key, escapeQuotes(value));
    })).sort().join(" ");
  };
  
  const CLOUDINARY_JS_CONFIG_PARAMS = ['api_key', 'cloud_name', 'private_cdn', 'secure_distribution', 'cdn_subdomain'];
  
  function cloudinary_js_config() {
    let params = utils.only(config(), ...CLOUDINARY_JS_CONFIG_PARAMS);
    return `<script type='text/javascript'>\n$.cloudinary.config(${JSON.stringify(params)});\n</script>`;
  }
  
  function v1_result_adapter(callback) {
    if (callback == null) { return undefined; }
    return function (result) {
      if (result.error != null) {
        return callback(result.error);
      }
      return callback(void 0, result);
    };
  }
  
  function v1_adapter(name, num_pass_args, v1) {
    return function (...args) {
      let pass_args = take(args, num_pass_args);
      let options = args[num_pass_args];
      let callback = args[num_pass_args + 1];
      if ((callback == null) && isFunction(options)) {
        callback = options;
        options = {};
      }
      callback = v1_result_adapter(callback);
      args = pass_args.concat([callback, options]);
      return v1[name].apply(this, args);
    };
  }
  
  function v1_adapters(exports, v1, mapping) {
    return Object.keys(mapping).map((name) => {
      let num_pass_args = mapping[name];
      exports[name] = v1_adapter(name, num_pass_args, v1);
      return exports[name];
    });
  }
  
  function as_safe_bool(value) {
    if (value == null) {
      return void 0;
    }
    if (value === true || value === 'true' || value === '1') {
      value = 1;
    }
    if (value === false || value === 'false' || value === '0') {
      value = 0;
    }
    return value;
  }
  
  const NUMBER_PATTERN = "([0-9]*)\\.([0-9]+)|([0-9]+)";
  
  const OFFSET_ANY_PATTERN = `(${NUMBER_PATTERN})([%pP])?`;
  const RANGE_VALUE_RE = RegExp(`^${OFFSET_ANY_PATTERN}$`);
  const OFFSET_ANY_PATTERN_RE = RegExp(`(${OFFSET_ANY_PATTERN})\\.\\.(${OFFSET_ANY_PATTERN})`);
  
  // Split a range into the start and end values
  function split_range(range) { // :nodoc:
    switch (range.constructor) {
      case String:
        if (!OFFSET_ANY_PATTERN_RE.test(range)) {
          return range;
        }
        return range.split("..");
      case Array:
        return [first(range), last(range)];
      default:
        return [null, null];
    }
  }
  
  function norm_range_value(value) { // :nodoc:
    let offset = String(value).match(RANGE_VALUE_RE);
    if (offset) {
      let modifier = offset[5] ? 'p' : '';
      value = `${offset[1] || offset[4]}${modifier}`;
    }
    return value;
  }
  
  /**
   * A video codec parameter can be either a String or a Hash.
   * @param {Object} param <code>vc_<codec>[ : <profile> : [<level>]]</code>
   *                       or <code>{ codec: 'h264', profile: 'basic', level: '3.1' }</code>
   * @return {String} <code><codec> : <profile> : [<level>]]</code> if a Hash was provided
   *                   or the param if a String was provided.
   *                   Returns null if param is not a Hash or String
   */
  function process_video_params(param) {
    switch (param.constructor) {
      case Object: {
        let video = "";
        if ('codec' in param) {
          video = param.codec;
          if ('profile' in param) {
            video += ":" + param.profile;
            if ('level' in param) {
              video += ":" + param.level;
            }
          }
        }
        return video;
      }
      case String:
        return param;
      default:
        return null;
    }
  }
  /**
   * Returns a Hash of parameters used to create an archive
   * @private
   * @param {object} options
   * @return {object} Archive API parameters
   */
  
  function archive_params(options = {}) {
    return {
      allow_missing: exports.as_safe_bool(options.allow_missing),
      async: exports.as_safe_bool(options.async),
      expires_at: options.expires_at,
      flatten_folders: exports.as_safe_bool(options.flatten_folders),
      flatten_transformations: exports.as_safe_bool(options.flatten_transformations),
      keep_derived: exports.as_safe_bool(options.keep_derived),
      mode: options.mode,
      notification_url: options.notification_url,
      prefixes: options.prefixes && toArray(options.prefixes),
      fully_qualified_public_ids: options.fully_qualified_public_ids && toArray(options.fully_qualified_public_ids),
      public_ids: options.public_ids && toArray(options.public_ids),
      skip_transformation_name: exports.as_safe_bool(options.skip_transformation_name),
      tags: options.tags && toArray(options.tags),
      target_format: options.target_format,
      target_public_id: options.target_public_id,
      target_tags: options.target_tags && toArray(options.target_tags),
      timestamp: options.timestamp || exports.timestamp(),
      transformations: utils.build_eager(options.transformations),
      type: options.type,
      use_original_filename: exports.as_safe_bool(options.use_original_filename),
    };
  }
  
  exports.create_source_tag = function create_source_tag(src, source_type, codecs = null) {
    let video_type = source_type === 'ogv' ? 'ogg' : source_type;
    let mime_type = `video/${video_type}`;
    if (!isEmpty(codecs)) {
      let codecs_str = isArray(codecs) ? codecs.join(', ') : codecs;
      mime_type += `; codecs=${codecs_str}`;
    }
    return `<source ${utils.html_attrs({ src, type: mime_type })}>`;
  };
  
  function build_explicit_api_params(public_id, options = {}) {
    return [exports.build_upload_params(extend({}, { public_id }, options))];
  }
  
  function generate_responsive_breakpoints_string(breakpoints) {
    if (breakpoints == null) {
      return null;
    }
    breakpoints = clone(breakpoints);
    if (!isArray(breakpoints)) {
      breakpoints = [breakpoints];
    }
    for (let j = 0; j < breakpoints.length; j++) {
      let breakpoint_settings = breakpoints[j];
      if (breakpoint_settings != null) {
        if (breakpoint_settings.transformation) {
          breakpoint_settings.transformation = utils.generate_transformation_string(
            clone(breakpoint_settings.transformation),
          );
        }
      }
    }
    return JSON.stringify(breakpoints);
  }
  
  function build_streaming_profiles_param(options = {}) {
    let params = utils.only(options, "display_name", "representations");
    if (isArray(params.representations)) {
      params.representations = JSON.stringify(params.representations.map(
        r => ({
          transformation: utils.generate_transformation_string(r.transformation),
        }),
      ));
    }
    return params;
  }
  
  function hashToParameters(hash) {
    return entries(hash).reduce((parameters, [key, value]) => {
      if (isArray(value)) {
        key = key.endsWith('[]') ? key : key + '[]';
        const items = value.map(v => [key, v]);
        parameters = parameters.concat(items);
      } else {
        parameters.push([key, value]);
      }
      return parameters;
    }, []);
  }
  
  /**
   * Convert a hash of values to a URI query string.
   * Array values are spread as individual parameters.
   * @param {object} hash Key-value parameters
   * @return {string} A URI query string.
   */
  function hashToQuery(hash) {
    return hashToParameters(hash).map(
      ([key, value]) => `${querystring.escape(key)}=${querystring.escape(value)}`,
    ).join('&');
  }
  
  /**
   * Verify that the parameter `value` is defined and it's string value is not zero.
   * <br>This function should not be confused with `isEmpty()`.
   * @private
   * @param {string|number} value The value to check.
   * @return {boolean} True if the value is defined and not empty.
   */
  
  function present(value) {
    return value != null && ("" + value).length > 0;
  }
  
  /**
   * Returns a new object with key values from source based on the keys.
   * `null` or `undefined` values are not copied.
   * @private
   * @param {object} source The object to pick values from.
   * @param {...string} keys One or more keys to copy from source.
   * @return {object} A new object with the required keys and values.
   */
  
  function only(source, ...keys) {
    let result = {};
    if (source) {
      for (let j = 0; j < keys.length; j++) {
        let key = keys[j];
        if (source[key] != null) {
          result[key] = source[key];
        }
      }
    }
    return result;
  }
  
  /**
   * Returns a JSON array as String.
   * Yields the array before it is converted to JSON format
   * @private
   * @param {object|String|Array<object>} data
   * @param {function(*):*} [modifier] called with the array before the array is stringified
   * @return {String|null} a JSON array string or `null` if data is `null`
   */
  
  function jsonArrayParam(data, modifier) {
    if (!data) {
      return null;
    }
    if (isString(data)) {
      data = JSON.parse(data);
    }
    if (!isArray(data)) {
      data = [data];
    }
    if (isFunction(modifier)) {
      data = modifier(data);
    }
    return JSON.stringify(data);
  }
  
  /**
   * Empty function - do nothing
   *
   */
  exports.NOP = function () {};
  exports.generate_auth_token = generate_auth_token;
  exports.getUserAgent = getUserAgent;
  exports.build_upload_params = build_upload_params;
  exports.timestamp = () => Math.floor(new Date().getTime() / 1000);
  exports.option_consume = consumeOption; // for backwards compatibility
  exports.build_array = toArray; // for backwards compatibility
  exports.encode_double_array = encodeDoubleArray;
  exports.encode_key_value = encode_key_value;
  exports.encode_context = encode_context;
  exports.build_eager = build_eager;
  exports.build_custom_headers = build_custom_headers;
  exports.generate_transformation_string = generate_transformation_string;
  exports.updateable_resource_params = updateable_resource_params;
  exports.extractUrlParams = extractUrlParams;
  exports.extractTransformationParams = extractTransformationParams;
  exports.patchFetchFormat = patchFetchFormat;
  exports.url = url;
  exports.video_url = video_url;
  exports.video_thumbnail_url = video_thumbnail_url;
  exports.api_url = api_url;
  exports.random_public_id = random_public_id;
  exports.signed_preloaded_image = signed_preloaded_image;
  exports.api_sign_request = api_sign_request;
  exports.clear_blank = clear_blank;
  exports.merge = merge;
  exports.sign_request = sign_request;
  exports.webhook_signature = webhook_signature;
  exports.verifyNotificationSignature = verifyNotificationSignature;
  exports.process_request_params = process_request_params;
  exports.private_download_url = private_download_url;
  exports.zip_download_url = zip_download_url;
  exports.download_archive_url = download_archive_url;
  exports.download_zip_url = download_zip_url;
  exports.cloudinary_js_config = cloudinary_js_config;
  exports.v1_adapters = v1_adapters;
  exports.as_safe_bool = as_safe_bool;
  exports.archive_params = archive_params;
  exports.build_explicit_api_params = build_explicit_api_params;
  exports.generate_responsive_breakpoints_string = generate_responsive_breakpoints_string;
  exports.build_streaming_profiles_param = build_streaming_profiles_param;
  exports.hashToParameters = hashToParameters;
  exports.present = present;
  exports.only = only;
  exports.jsonArrayParam = jsonArrayParam;
  // was exported before, so kept for backwards compatibility
  exports.DEFAULT_POSTER_OPTIONS = DEFAULT_POSTER_OPTIONS;
  exports.DEFAULT_VIDEO_SOURCE_TYPES = DEFAULT_VIDEO_SOURCE_TYPES;
  
  Object.assign(module.exports, {
    at,
    clone,
    extend,
    filter,
    includes,
    isArray,
    isEmpty,
    isNumber,
    isObject,
    isRemoteUrl,
    isString,
    isUndefined,
    keys: source => Object.keys(source),
    ensurePresenceOf,
  });
  