text: "use strict";
  const async = require("async");
  const _ = require("underscore");
  const util = require("util");
  const EventEmitter = require("events").EventEmitter;
  const assert = require("node-opcua-assert").assert;
  
  
  const AttributeIds        = require("node-opcua-data-model").AttributeIds;
  const BrowseDirection     = require("node-opcua-data-model").BrowseDirection;
  const QualifiedName       = require("node-opcua-data-model").QualifiedName;
  const LocalizedText       = require("node-opcua-data-model").LocalizedText;
  const NodeClass           = require("node-opcua-data-model").NodeClass;
  
  const BrowseDescription   = require("node-opcua-service-browse").BrowseDescription;
  
  const resolveNodeId       = require("node-opcua-nodeid").resolveNodeId;
  const NodeId              = require("node-opcua-nodeid").NodeId;
  const sameNodeId          = require("node-opcua-nodeid").sameNodeId;
  const makeNodeId          = require("node-opcua-nodeid").makeNodeId;
  const StatusCodes         = require("node-opcua-status-code").StatusCodes;
  
  const browse_service     = require("node-opcua-service-browse");
  
  const lowerFirstLetter    = require("node-opcua-utils").lowerFirstLetter;
  
  const VariableIds = require("node-opcua-constants").VariableIds;
  
  const debugLog = require("node-opcua-debug").make_debugLog(__filename);
  
  //
  // some server do not expose the ReferenceType Node in their address space
  // ReferenceType are defined by the OPCUA standard and can be prepopulated in the crawler.
  // Pre-populating the ReferenceType node in the crawler will also reduce the network traffic.
  //
  const ReferenceTypeIds = require("node-opcua-constants").ReferenceTypeIds;
  
  /*=
   *
   * @param arr
   * @param maxNode
   * @private
   * @return {*}
   */
  function _fetch_elements(arr, maxNode) {
      assert(_.isArray(arr));
      assert(arr.length > 0);
      const high_limit = ( maxNode <= 0) ? arr.length : maxNode;
      const tmp = arr.splice(0, high_limit);
      assert(tmp.length > 0);
      return tmp;
  }
  
  /**
   * @class NodeCrawler
   * @param session
   * @constructor
   */
  function NodeCrawler(session) {
  
      const self = this;
  
      self.session = session;
      // verify that session object provides the expected methods (browse/read)
      assert(_.isFunction(session.browse));
      assert(_.isFunction(session.read));
  
      self.browseNameMap = {};
      self._nodesToReadEx = [];
      self._objectCache = {};
      self._objectToBrowse = [];
      self._objMap = {};
  
      this._initialize_referenceTypeId();
  
      self.q = async.queue(function (task, callback) {
  
          // use process next tick to relax the stack frame
          setImmediate(function () {
              task.func.call(self, task, function () {
                  self.resolve_deferred_browseNode();
                  self.resolve_deferred_readNode();
                  callback();
              });
          });
  
      }, 1);
  
      // MaxNodesPerRead from Server.ServerCapabilities.OperationLimits
      // VariableIds.ServerType_ServerCapabilities_OperationLimits_MaxNodesPerRead
      self.maxNodesPerRead = 0;
  
      //  MaxNodesPerBrowse from Server.ServerCapabilities.OperationLimits
      //  VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerBrowse
      self.maxNodesPerBrowse = 0; // 0 = no limits
  
      // statistics
      self.startTime = new Date();
      self.readCounter = 0;
      self.browseCounter = 0;
      self.transactionCounter = 0;
  }
  
  util.inherits(NodeCrawler, EventEmitter);
  
  NodeCrawler.prototype._initialize_referenceTypeId = function () {
  
      const self = this;
  
      function append_prepopulated_reference(browseName) {
  
          const nodeId = makeNodeId(ReferenceTypeIds[browseName], 0);
          assert(nodeId);
          const cacheNode = self._createCacheNode(nodeId);
          cacheNode.browseName = new QualifiedName({name: browseName});
      }
  
      //  References
      //  +->(hasSubtype) NoHierarchicalReferences
      //                  +->(hasSubtype) HasTypeDefinition
      //  +->(hasSubtype) HierarchicalReferences
      //                  +->(hasSubtype) HasChild/ChildOf
      //                                  +->(hasSubtype) Aggregates/AggregatedBy
      //                                                  +-> HasProperty/PropertyOf
      //                                                  +-> HasComponent/ComponentOf
      //                                                  +-> HasHistoricalConfiguration/HistoricalConfigurationOf
      //                                 +->(hasSubtype) HasSubtype/HasSupertype
      //                  +->(hasSubtype) Organizes/OrganizedBy
      //                  +->(hasSubtype) HasEventSource/EventSourceOf
      append_prepopulated_reference("HasTypeDefinition");
      append_prepopulated_reference("HasChild");
      append_prepopulated_reference("HasProperty");
      append_prepopulated_reference("HasComponent");
      append_prepopulated_reference("HasHistoricalConfiguration");
      append_prepopulated_reference("HasSubtype");
      append_prepopulated_reference("Organizes");
      append_prepopulated_reference("HasEventSource");
  
  };
  
  NodeCrawler.prototype._readOperationalLimits = function (callback) {
  
      const self = this;
      const n1 = makeNodeId(VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerRead);
      const n2 = makeNodeId(VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerBrowse);
      const nodesToRead = [
          {nodeId: n1, attributeId: AttributeIds.Value},
          {nodeId: n2, attributeId: AttributeIds.Value}
      ];
      self.transactionCounter++;
      self.session.read(nodesToRead, function (err, dataValues) {
          if (!err) {
              if (dataValues[0].statusCode.equals(StatusCodes.Good)) {
                  self.maxNodesPerRead = dataValues[0].value.value;
              }
              // ensure we have a sensible maxNodesPerRead value in case the server doesn't specify one
              self.maxNodesPerRead = self.maxNodesPerRead || 500;
  
              if (dataValues[1].statusCode.equals(StatusCodes.Good)) {
                  self.maxNodesPerBrowse = dataValues[1].value.value;
              }
              // ensure we have a sensible maxNodesPerBrowse value in case the server doesn't specify one
              self.maxNodesPerBrowse = self.maxNodesPerBrowse || 500;
  
          }
          callback(err);
      });
  };
  
  function make_node_attribute_key(nodeId, attributeId) {
      const key = nodeId.toString() + "_" + attributeId.toString();
      return key;
  }
  
  NodeCrawler.prototype.set_cache_NodeAttribute = function (nodeId, attributeId, value) {
      const self = this;
      const key = make_node_attribute_key(nodeId, attributeId);
      self.browseNameMap[key] = value;
  };
  
  NodeCrawler.prototype.has_cache_NodeAttribute = function (nodeId, attributeId) {
      const self = this;
      const key = make_node_attribute_key(nodeId, attributeId);
      return self.browseNameMap.hasOwnProperty(key);
  };
  
  NodeCrawler.prototype.get_cache_NodeAttribute = function (nodeId, attributeId) {
      const self = this;
      const key = make_node_attribute_key(nodeId, attributeId);
      return self.browseNameMap[key];
  };
  
  /**
   * request a read operation for a Node+Attribute in the future, provides a callback
   *
   * @method _defer_readNode
   * @param nodeId {NodeId}
   * @param attributeId {AttributeId}
   * @param {function} callback
   * @param {Error|null} callback.err
   * @param {string} callback.dataValue
   * @private
   */
  
  
  
  NodeCrawler.prototype._defer_readNode = function (nodeId, attributeId, callback) {
      const self = this;
  
      nodeId = resolveNodeId(nodeId);
      assert(nodeId instanceof NodeId);
  
      const key = make_node_attribute_key(nodeId, attributeId);
  
      if (self.has_cache_NodeAttribute(nodeId, attributeId)) {
          callback(null, self.get_cache_NodeAttribute(nodeId, attributeId));
      } else {
          self.browseNameMap[key] = "?";
          self._nodesToReadEx.push({
              nodeToRead: {
                  nodeId: nodeId,
                  attributeId: attributeId
              },
              action: function (dataValue) {
                  self.set_cache_NodeAttribute(nodeId, attributeId, dataValue);
                  callback(null, dataValue);
              }
          });
      }
  };
  
  /**
   * perform pending read Node operation
   * @method _resolve_deferred_readNode
   * @param callback {Function}
   * @private
   */
  NodeCrawler.prototype._resolve_deferred_readNode = function (callback) {
  
      const self = this;
      if (self._nodesToReadEx.length === 0) {
          // nothing to read
          callback();
          return;
      }
  
      const _nodesToReadEx = _fetch_elements(self._nodesToReadEx, self.maxNodesPerRead);
  
      const nodesToRead = _nodesToReadEx.map(function (e) {
          return e.nodeToRead;
      });
  
      self.readCounter += nodesToRead.length;
      self.transactionCounter++;
  
      self.session.read(nodesToRead, function (err, dataValues /*, diagnostics*/) {
  
          if (!err) {
  
              for (const pair of _.zip(_nodesToReadEx, dataValues)) {
                  const _nodeToReadEx = pair[0];
  
                  const dataValue = pair[1];
                  assert(dataValue.hasOwnProperty("statusCode"));
                  if (dataValue.statusCode.equals(StatusCodes.Good)) {
                      if (dataValue.value === null) {
                          _nodeToReadEx.action(null);
                      }
                      else {
                          _nodeToReadEx.action(dataValue.value.value);
                      }
                  } else {
                      _nodeToReadEx.action({name: dataValue.statusCode.key});
                  }
  
              }
          }
          callback(err);
      });
  };
  
  NodeCrawler.prototype._resolve_deferred = function (comment, collection, method) {
      const self = this;
  
      if (collection.length > 0) {
          self._push_task("adding operation " + comment, {
              params: {},
              func: function (task, callback) {
                  method.call(self, callback);
              }
          });
      }
  };
  
  NodeCrawler.prototype.resolve_deferred_readNode = function () {
      this._resolve_deferred("read_node", this._nodesToReadEx, this._resolve_deferred_readNode);
  };
  
  NodeCrawler.prototype.resolve_deferred_browseNode = function () {
      this._resolve_deferred("browse_node", this._objectToBrowse, this._resolve_deferred_browseNode);
  };
  
  const pendingBrowseName = new QualifiedName({name: "pending"});
  function CacheNode(nodeId) {
      /**
       * @property nodeId
       * @type NodeId
       */
      this.nodeId = nodeId;
      /**
       * @property browseName
       * @type     QualifiedName
       */
      this.browseName = pendingBrowseName;
      /**
       * @property references
       * @type ReferenceDescription[]
       */
      this.references = [];
  }
  
  function w(s, l) {
      return (s + "                                                                ").substr(0, l);
  }
  CacheNode.prototype.toString = function () {
      let str = w(this.nodeId.toString(), 20);
      str += " " + w(this.browseName.toString(), 30);
      str += " typeDef : " + w((this.typeDefinition ? this.typeDefinition.toString() : ""), 30);
      str += " nodeClass : " + w((this.nodeClass ? this.nodeClass.toString() : ""), 12);
      return str;
  };
  
  NodeCrawler.prototype._getCacheNode = function (nodeId) {
      const self = this;
      const key = resolveNodeId(nodeId).toString();
      const cacheNode = self._objectCache[key];
      return cacheNode;
  };
  
  NodeCrawler.prototype._createCacheNode = function (nodeId) {
      const self = this;
      const key = resolveNodeId(nodeId).toString();
      let cacheNode = self._objectCache[key];
      if (cacheNode) {
          throw new Error("NodeCrawler#_createCacheNode : cache node should not exist already : " + nodeId.toString());
      }
      cacheNode = new CacheNode(nodeId);
      assert(!self._objectCache.hasOwnProperty(key));
      self._objectCache[key] = cacheNode;
      return cacheNode;
  };
  
  /**
   * perform a deferred browse
   * instead of calling session.browse directly, this function add the request to a list
   * so that request can be grouped and send in one single browse command to the server.
   *
   * @method _defer_browse_node
   * @private
   * @param cacheNode                {CacheNode}
   * @param referenceTypeId       {string|ReferenceType}
   * @param actionOnBrowse        {Function}
   * @param actionOnBrowse.err    {Error|null}
   * @param actionOnBrowse.object {CacheNode}
   *
   */
  NodeCrawler.prototype._defer_browse_node = function (cacheNode, referenceTypeId, actionOnBrowse) {
      const self = this;
      self._objectToBrowse.push({
          cacheNode: cacheNode,
          nodeId: cacheNode.nodeId,
          referenceTypeId: referenceTypeId,
          action: function (object) {
              assert(object === cacheNode);
              assert(_.isArray(object.references));
              assert(cacheNode.browseName.name !== "pending");
              actionOnBrowse(null, cacheNode);
          }
      });
  };
  
  const referencesId = resolveNodeId("References");
  const hierarchicalReferencesId = resolveNodeId("HierarchicalReferences");
  const hasTypeDefinitionId = resolveNodeId("HasTypeDefinition");
  
  
  function dedup_reference(references) {
      const results = [];
      const dedup = {};
      for (const reference of  references) {
          const key = reference.referenceTypeId.toString() + reference.nodeId.toString();
          if (dedup[key]) {
              console.log(" Warning => Duplicated reference found  !!!! please contact the server vendor");
              console.log(reference.toString());
              continue;
          }
          dedup[key] = reference;
          results.push(reference);
      }
      return results;
  }
  /**
   * @method _process_single_browseResult
   * @param _objectToBrowse
   * @param browseResult {BrowseResult}
   * @private
   */
  NodeCrawler.prototype._process_single_browseResult = function (_objectToBrowse, browseResult) {
  
      const self = this;
  
      assert(browseResult.continuationPoint === null, "NodeCrawler doesn't support continuation point yet");
  
      const cacheNode = _objectToBrowse.cacheNode;
  
      // note : some OPCUA may expose duplicated reference, they need to be filtered out
      // dedup reference
  
      cacheNode.references = dedup_reference(browseResult.references);
  
      const tmp = browseResult.references.filter(function (x) {
          return sameNodeId(x.referenceTypeId, hasTypeDefinitionId);
      });
      if (tmp.length) {
          cacheNode.typeDefinition = tmp[0].nodeId;
      }
  
      async.parallel([
  
            function (callback) {
                if (cacheNode.browseName !== pendingBrowseName) {
                    return callback();
                }
                self._defer_readNode(cacheNode.nodeId, AttributeIds.BrowseName, function (err, browseName) {
                    cacheNode.browseName = browseName;
                    callback();
                });
            },
            function (callback) {
                if (cacheNode.displayName) {
                    return callback();
                }
                self._defer_readNode(cacheNode.nodeId, AttributeIds.DisplayName, function (err, value) {
                    if (err) {
                        return callback(err);
                    }
                    if (!(value instanceof LocalizedText)) {
                        console.log(cacheNode.toString())
                    }
                    assert(value instanceof LocalizedText);
                    cacheNode.displayName = value;
                    setImmediate(callback);
                });
            },
            function (callback) {
                // only if nodeClass is Variable
                if (cacheNode.nodeClass !== NodeClass.Variable) {
                    return callback();
                }
                // read dataType and DataType if node is a variable
                self._defer_readNode(cacheNode.nodeId, AttributeIds.DataType, function (err, dataType) {
  
                    if (!(dataType instanceof NodeId)) {
                        return callback();
                    }
                    cacheNode.dataType = dataType;
                    callback();
                });
            },
            function (callback) {
                if (cacheNode.nodeClass !== NodeClass.Variable) {
                    return callback();
                }
                self._defer_readNode(cacheNode.nodeId, AttributeIds.Value, function (err, value) {
                    cacheNode.dataValue = value;
                    callback();
                });
            },
            function (callback) {
                if (cacheNode.nodeClass !== NodeClass.Variable) {
                    return callback();
                }
                self._defer_readNode(cacheNode.nodeId, AttributeIds.MinimumSamplingInterval, function (err, value) {
                    cacheNode.minimumSamplingInterval = value;
                    callback();
                });
            },
            function (callback) {
                if (cacheNode.nodeClass !== NodeClass.Variable) {
                    return callback();
                }
                self._defer_readNode(cacheNode.nodeId, AttributeIds.AccessLevel, function (err, value) {
                    cacheNode.accessLevel = value;
                    callback();
                });
            },
            function (callback) {
                if (cacheNode.nodeClass !== NodeClass.Variable) {
                    return callback();
                }
                self._defer_readNode(cacheNode.nodeId, AttributeIds.UserAccessLevel, function (err, value) {
                    cacheNode.userAccessLevel = value;
                    callback();
                });
            }
        ], function (err) {
            if (err) {
                console.log("ERRROR".red, err);
            }
            _objectToBrowse.action(cacheNode);
        }
      );
  };
  
  NodeCrawler.prototype._process_browse_response = function (task, callback) {
      /* jshint validthis: true */
      const self = this;
      const objectsToBrowse = task.param.objectsToBrowse;
      const browseResults = task.param.browseResults;
      for (const pair of _.zip(objectsToBrowse, browseResults)) {
          const objectToBrowse = pair[0];
          const browseResult = pair[1];
          assert(browseResult instanceof browse_service.BrowseResult);
          self._process_single_browseResult(objectToBrowse, browseResult);
      }
      setImmediate(callback);
  };
  
  const makeResultMask = require("node-opcua-data-model").makeResultMask;
  
  //                         "ReferenceType | IsForward | BrowseName | NodeClass | DisplayName | TypeDefinition"
  const resultMask = makeResultMask("ReferenceType | IsForward | BrowseName | DisplayName | NodeClass | TypeDefinition");
  
  NodeCrawler.prototype._resolve_deferred_browseNode = function (callback) {
  
      const self = this;
  
      if (self._objectToBrowse.length === 0) {
          callback();
          return;
      }
  
      const objectsToBrowse = _fetch_elements(self._objectToBrowse, self.maxNodesPerBrowse);
  
      const nodesToBrowse = objectsToBrowse.map(function (e) {
          assert(e.hasOwnProperty("referenceTypeId"));
          return new BrowseDescription({
              browseDirection: BrowseDirection.Forward,
              referenceTypeId: e.referenceTypeId,
              includeSubtypes: true,
              nodeId: e.nodeId,
              resultMask: resultMask
          });
      });
  
      self.browseCounter += nodesToBrowse.length;
      self.transactionCounter++;
  
      self.session.browse(nodesToBrowse, function (err, browseResults /*, diagnostics*/) {
  
          if (!err) {
              assert(browseResults.length === nodesToBrowse.length);
              self._unshift_task("process browseResults", {
                  param: {
                      objectsToBrowse: objectsToBrowse,
                      browseResults: browseResults
                  },
                  func: NodeCrawler.prototype._process_browse_response
              });
  
          } else {
              console.log("ERROR = ", err);
          }
          callback(err);
  
      });
  };
  
  /**
   * @method _unshift_task
   * add a task on top of the queue (high priority)
   * @param name {string}
   * @param task
   * @private
   */
  NodeCrawler.prototype._unshift_task = function (name, task) {
      const self = this;
      assert(_.isFunction(task.func));
      task.comment = "S:" + name;
      assert(task.func.length === 2);
      self.q.unshift(task);
  };
  
  /**
   * @method _push_task
   * add a task at the bottom of the queue (low priority)
   * @param name {string}
   * @param task
   * @private
   */
  NodeCrawler.prototype._push_task = function (name, task) {
      const self = this;
      assert(_.isFunction(task.func));
      task.comment = "P:" + name;
      assert(task.func.length === 2);
      self.q.push(task);
  };
  
  
  NodeCrawler.follow = function (crawler, cacheNode, userData) {
      for (const reference of cacheNode.references) {
          crawler.followReference(cacheNode, reference, userData);
      }
  };
  /***
   * @method _emit_on_crawled
   * @param cacheNode
   * @param userData
   * @param [userData.onBrowsed=null] {Function}
   * @private
   */
  NodeCrawler.prototype._emit_on_crawled = function (cacheNode, userData) {
      const self = this;
      self.emit("browsed", cacheNode, userData);
  };
  
  
  NodeCrawler.prototype._crawl_task = function (task, callback) {
  
      const self = this;
  
      const cacheNode = task.params.cacheNode;
      let nodeId = task.params.cacheNode.nodeId;
  
      nodeId = resolveNodeId(nodeId);
      const key = nodeId.toString();
  
      if (self._visited_node.hasOwnProperty(key)) {
          console.log("skipping already visited", key);
          callback();
          return;// already visited
      }
      // mark as visited to avoid infinite recursion
      self._visited_node[key] = true;
  
      function browse_node_action(err, cacheNode) {
          if (!err) {
              for (let i = 0; i < cacheNode.references.length; i++) {
  
                  const reference = cacheNode.references[i];
                  // those ones come for free
                  if (!self.has_cache_NodeAttribute(reference.nodeId, AttributeIds.BrowseName)) {
                      self.set_cache_NodeAttribute(reference.nodeId, AttributeIds.BrowseName, reference.browseName);
                  }
                  if (!self.has_cache_NodeAttribute(reference.nodeId, AttributeIds.DisplayName)) {
                      self.set_cache_NodeAttribute(reference.nodeId, AttributeIds.DisplayName, reference.displayName);
                  }
                  if (!self.has_cache_NodeAttribute(reference.nodeId, AttributeIds.NodeClass)) {
                      self.set_cache_NodeAttribute(reference.nodeId, AttributeIds.NodeClass, reference.nodeClass);
                  }
              }
              self._emit_on_crawled(cacheNode, task.params.userData);
              const userData = task.params.userData;
              if (userData.onBrowse) {
                  userData.onBrowse(self, cacheNode, userData);
              }
  
          }
      }
  
      self._defer_browse_node(cacheNode, referencesId, browse_node_action);
      callback();
  };
  
  NodeCrawler.prototype._add_crawl_task = function (cacheNode, userData) {
      /* jshint validthis: true */
      const self = this;
      assert(cacheNode instanceof CacheNode);
      assert(userData);
      assert(_.isObject(self));
      assert(_.isObject(self._crawled));
  
      const key = cacheNode.nodeId.toString();
      if (self._crawled.hasOwnProperty(key)) {
          return;
      }
      self._crawled[key] = 1;
  
      self._push_task("_crawl task", {
          params: {
              cacheNode: cacheNode,
              userData: userData
          },
          func: NodeCrawler.prototype._crawl_task
      });
  };
  
  NodeCrawler.prototype._inner_crawl = function (nodeId, userData, end_callback) {
  
      const self = this;
      assert(_.isObject(userData));
      assert(_.isFunction(end_callback));
      assert(!self._visited_node);
      assert(!self._crawled);
      self._visited_node = {};
      self._crawled = {};
  
      let _has_ended = false;
      self.q.drain = function () {
  
          if (!_has_ended) {
  
              _has_ended = true;
  
              self._visited_node = null;
              self._crawled = null;
  
              self.emit("end");
              end_callback();
          }
      };
  
  
      let cacheNode = self._getCacheNode(nodeId);
      if (!cacheNode) {
          cacheNode = self._createCacheNode(nodeId);
      }
  
      assert(cacheNode.nodeId.toString() === nodeId.toString());
  
      // ----------------------- Read missing essential information about node
      // such as nodeClass, typeDefinition browseName, displayName
      // this sequence is only necessary on the top node being crawled,
      // as browseName,displayName,nodeClass will be provided by ReferenceDescription later on for child nodes
      //
      async.parallel([
          function (callback) {
              self._defer_readNode(cacheNode.nodeId, AttributeIds.BrowseName, function (err, value) {
                  if (err) {
                      return callback(err);
                  }
                  assert(value instanceof QualifiedName);
                  cacheNode.browseName = value;
                  setImmediate(callback);
              });
          },
          function (callback) {
              self._defer_readNode(cacheNode.nodeId, AttributeIds.NodeClass, function (err, value) {
                  if (err) {
                      return callback(err);
                  }
                  cacheNode.nodeClass = NodeClass.get(value);
                  setImmediate(callback);
              });
          },
          function (callback) {
              self._defer_readNode(cacheNode.nodeId, AttributeIds.DisplayName, function (err, value) {
                  if (err) {
                      return callback(err);
                  }
                  assert(value instanceof LocalizedText);
                  cacheNode.displayName = value;
                  setImmediate(callback);
              });
          },
          function (callback) {
              self._resolve_deferred_readNode(callback);
          }
      ], function (err) {
          self._add_crawl_task(cacheNode, userData);
      });
  
  
  };
  
  /**
   * @method crawl
   * @param nodeId {NodeId}
   * @param userData {Object}
   * @param userData.onBrowse           {Function}
   * @param userData.onBrowse.crawler   {NodeCrawler}
   * @param userData.onBrowse.cacheNode {CacheNode}
   * @param end_callback {Function}
   */
  NodeCrawler.prototype.crawl = function (nodeId, userData, end_callback) {
      assert(_.isFunction(end_callback));
      const self = this;
      self._readOperationalLimits(function (err) {
  
          if (err) {
              return end_callback(err);
          }
          self._inner_crawl(nodeId, userData, end_callback);
      });
  };
  
  
  function _setExtraReference(task, callback) {
      const params = task.params;
      params.userData.setExtraReference(params.parentNode, params.reference, params.childCacheNode, params.userData);
      callback();
  }
  
  NodeCrawler.prototype.followReference = function (parentNode, reference, userData) {
  
      assert(reference instanceof browse_service.ReferenceDescription);
  
      const crawler = this;
  
      let childCacheNodeRef = crawler._getCacheNode(reference.referenceTypeId);
      if (!childCacheNodeRef) {
          childCacheNodeRef = crawler._createCacheNode(reference.referenceTypeId);
          crawler._add_crawl_task(childCacheNodeRef, userData);
      }
  
      let childCacheNode = crawler._getCacheNode(reference.nodeId);
      if (!childCacheNode) {
          childCacheNode = crawler._createCacheNode(reference.nodeId);
          childCacheNode.browseName = reference.browseName;
          childCacheNode.displayName = reference.DisplayName;
          childCacheNode.typeDefinition = reference.typeDefinition;
          childCacheNode.nodeClass = reference.nodeClass;
  
          //xx console.log("HERE !",childCacheNode.toString());
          crawler._add_crawl_task(childCacheNode, userData);
      } else {
  
          if (userData.setExtraReference) {
  
              crawler._push_task("setExtraRef", {
                  params: {
                      parentNode: parentNode,
                      reference: reference,
                      childCacheNode: childCacheNode,
                      userData: userData
                  },
                  func: _setExtraReference
              });
          }
      }
  };
  
  NodeCrawler.prototype.dispose = function () {
      const self = this;
      self.session = null;
      self.browseNameMap = null;
      self._nodesToReadEx = null;
      self._objectCache = null;
      self._objectToBrowse = null;
      self._objMap = null;
      self.q = null;
  };
  
  //---------------------------------------------------------------------------------------
  
  NodeCrawler.prototype.read = function (nodeId, callback) {
  
      const self = this;
  
      try {
          nodeId = resolveNodeId(nodeId);
      } catch (err) {
          callback(err);
      }
  
      function remove_cycle(object, callback) {
  
  
          const visitedNodeIds = {};
  
          function hasBeenVisited(e) {
              const key = e.nodeId.toString();
              return visitedNodeIds[key];
          }
  
          function setVisited(e) {
              const key = e.nodeId.toString();
              return visitedNodeIds[key] = e;
          }
  
          function mark_array(arr) {
              if (!arr) {
                  return;
              }
              assert(_.isArray(arr));
              for (let index = 0; index < arr.length; index++) {
                  const e = arr[index];
                  if (hasBeenVisited(e)) {
                      return;
                  } else {
                      setVisited(e);
                      explorerObject(e);
                  }
              }
          }
  
          function explorerObject(obj) {
              mark_array(obj.organizes);
              mark_array(obj.hasComponent);
              mark_array(obj.hasNotifier);
              mark_array(obj.hasProperty);
          }
  
  
          explorerObject(object);
          callback(null, object);
      }
  
      function simplify_object(objMap, object, final_callback) {
  
          assert(_.isFunction(final_callback));
  
          const queue = async.queue(function (task, callback) {
  
              setImmediate(function () {
                  assert(_.isFunction(task.func));
                  task.func(task.data, callback);
              });
          }, 1);
  
          const key = object.nodeId.toString();
  
          function add_for_reconstruction(object, extra_func) {
              assert(_.isFunction(extra_func));
              assert(typeof object.nodeId.toString() === "string");
              queue.push({
                  data: object,
                  func: function (data, callback) {
  
                      _reconstruct_manageable_object(data, function (err, obj) {
                          extra_func(err, obj);
                          callback(err);
                      });
                  }
              });
          }
  
          function _reconstruct_manageable_object(object, callback) {
  
              assert(_.isFunction(callback));
              assert(object);
              assert(object.nodeId);
  
              const key = object.nodeId.toString();
  
              if (objMap.hasOwnProperty(key)) {
                  return callback(null, objMap[key]);
              }
              /* reconstruct a more manageable object
               * var obj = {
               *    browseName: "Objects",
               *    organises : [
               *       {
               *            browseName: "Server",
               *            hasComponent: [
               *            ]
               *            hasProperty: [
               *            ]
               *       }
               *    ]
               * }
               */
              const obj = {
                  browseName: object.browseName.name,
                  nodeId: object.nodeId.toString()
              };
  
              // Append nodeClass
              if (object.nodeClass) {
                  obj.nodeClass = object.nodeClass.toString();
              }
              if (object.dataType) {
                  obj.dataType = object.dataType.toString();
                  obj.dataTypeName = object.dataTypeName;
                  //xx  console.log("dataTypeObj",object.dataTypeObj.browseName);
              }
              if (object.dataValue) {
                  if (object.dataValue instanceof Array || object.dataValue.length > 10) {
                      // too much verbosity here
                  } else {
                      obj.dataValue = object.dataValue.toString();
                  }
              }
              objMap[key] = obj;
  
              const referenceMap = obj;
  
              object.references = object.references || [];
  
  
              object.references.map(function (ref) {
  
                  assert(ref);
                  const ref_index = ref.referenceTypeId.toString();
  
                  const referenceType = self._objectCache[ref_index];
  
                  if (!referenceType) {
                      console.log(("Unknown reference type " + ref_index).red.bold);
                      console.log(util.inspect(object, {colorize: true, depth: 10}));
                  }
                  const reference = self._objectCache[ref.nodeId.toString()];
                  if (!reference) {
                      console.log(ref.nodeId.toString(), "bn=", ref.browseName.toString(), "class =", ref.nodeClass.toString(), ref.typeDefinition.toString());
                      console.log("#_reconstruct_manageable_object: Cannot find reference", ref.nodeId.toString(), "in cache");
                  }
                  // Extract nodeClass so it can be appended
                  reference.nodeClass = ref.$nodeClass;
  
                  const refName = lowerFirstLetter(referenceType.browseName.name);
  
                  if (refName === "hasTypeDefinition") {
                      obj.typeDefinition = reference.browseName.name;
                  } else {
                      if (!referenceMap[refName]) {
                          referenceMap[refName] = [];
                      }
                      add_for_reconstruction(reference, function (err, mobject) {
                          if (!err) {
                              referenceMap[refName].push(mobject);
                          }
                      });
                  }
              });
              callback(null, obj);
  
          }
  
          add_for_reconstruction(object, function () {
          });
  
          queue.drain = function () {
              const object = self._objMap[key];
              remove_cycle(object, callback);
          };
      }
  
      const key = nodeId.toString();
  
      // check if object has already been crawled
      if (self._objMap.hasOwnProperty(key)) {
          const object = self._objMap[key];
          return callback(null, object);
      }
  
      const userData = {
          onBrowse: NodeCrawler.follow
      };
  
  
      self.crawl(nodeId, userData, function () {
  
          if (self._objectCache.hasOwnProperty(key)) {
  
              const cacheNode = self._objectCache[key];
              assert(cacheNode.browseName.name !== "pending");
  
              simplify_object(self._objMap, cacheNode, callback);
  
          } else {
              callback(new Error("Cannot find nodeid" + key));
          }
      });
  };
  
  
  exports.NodeCrawler = NodeCrawler;
  