package mhtree;

import cz.muni.fi.disa.similarityoperators.cover.AbstractRepresentation.PrecomputedDistances;
import messif.algorithms.Algorithm;
import messif.buckets.BucketDispatcher;
import messif.buckets.BucketStorageException;
import messif.buckets.impl.MemoryStorageBucket;
import messif.objects.LocalAbstractObject;
import messif.operations.data.InsertOperation;
import messif.operations.query.ApproxKNNQueryOperation;
import messif.operations.query.KNNQueryOperation;
import mhtree.benchmarking.SearchState;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.BitSet;
import java.util.IntSummaryStatistics;
import java.util.List;
import java.util.PriorityQueue;
import java.util.stream.Collectors;

import static cz.muni.fi.disa.similarityoperators.cover.AbstractRepresentation.Ranked.getRankedIndices;
import java.util.Iterator;
import java.util.NoSuchElementException;
import messif.algorithms.AlgorithmMethodException;
import messif.objects.util.AbstractObjectIterator;
import messif.operations.AnswerType;
import messif.operations.data.BulkInsertOperation;
import messif.operations.query.RangeQueryOperation;

/**
 * MH-Tree is a metric index utilizing metric hulls.
 */
public class MHTree extends Algorithm implements Serializable {

    /**
     * Serialization ID
     */
    private static final long serialVersionUID = 42L;

    /**
     * The minimal number of objects in a bucket of a leaf node.
     */
    private final int bucketCapacity;

    /**
     * The maximal degree of an internal node.
     */
    private final int arity;
    /**
     * Specifies which method to use when adding a new object.
     */
    private final InsertType insertType;
    /**
     * Specifies how to measure distance between an object and a node.
     */
    private final ObjectToNodeDistance objectToNodeDistance;
    /**
     * A dispatcher for maintaining a set of local buckets.
     */
    private final BucketDispatcher bucketDispatcher;
    /**
     * The root node of MH-Tree.
     */
    private Node root;

    /**
     * Create a new MH-Tree.
     *
     * @param builder the result of MH-Tree bulk-loading algorithm
     */
    @AlgorithmConstructor(description = "MH-Tree", arguments = {
            "MH-Tree builder object",
    })
    private MHTree(MHTreeBuilder builder) {
        super("Metric Hull Tree");

        bucketCapacity = builder.bucketCapacity;
        arity = builder.arity;

        bucketDispatcher = builder.bucketDispatcher;
        insertType = builder.insertType;
        objectToNodeDistance = builder.objectToNodeDistance;

        root = builder.root;
    }

    /** Cloning constructor -- to use the builder feature and support MESSIF controll file too. */
    private MHTree(MHTree origTree) {
        super("Metric Hull Tree");

        bucketCapacity = origTree.bucketCapacity;
        arity = origTree.arity;
        root = origTree.root;
        bucketDispatcher = origTree.bucketDispatcher;

        insertType = InsertType.INCREMENTAL;
        objectToNodeDistance = ObjectToNodeDistance.NEAREST;
    }
    
    /**
     * Create a new instance of MH-Tree.
     * @param algorithmName
     * @param bucketCapacity capacity in objects
     * @param arity internal node capacity (in hulls -- tree arity)
     * @param insertType algorithm to insert objects (hull computation algorithm)
     * @param objectToNodeDistance function of ranking an object to a hull
     * @throws IllegalArgumentException 
     */
    @AlgorithmConstructor(description = "MH-Tree", arguments = {
            "Algorithm name", "bucket capacity in objeces", "internal node arity", "type of updating hull upon inserting an object",
            "type of ranking function of objects to nodes (their hulls)"
    })
    public MHTree(String algorithmName, int bucketCapacity, int arity, InsertType insertType, ObjectToNodeDistance objectToNodeDistance) throws IllegalArgumentException {
        super(algorithmName);

        this.bucketCapacity = bucketCapacity;
        this.arity = arity;
        this.insertType = insertType;
        this.objectToNodeDistance = objectToNodeDistance;

        this.bucketDispatcher = new BucketDispatcher(Integer.MAX_VALUE, Long.MAX_VALUE, 2L * bucketCapacity - 1, 0, false, MemoryStorageBucket.class, null);
    }

    /**
     * Create a new instance of MH-Tree.
     * 
     * Objects are inserted using {@link InsertType#INCREMENTAL}.
     * Distance to rank node in the priority queue is {@link ObjectToNodeDistance#NEAREST}.
     * 
     * @param bucketCapacity capacity in objects
     * @param arity internal node capacity (in hulls -- tree arity)
     * @throws IllegalArgumentException 
     */
    @AlgorithmConstructor(description = "MH-Tree", arguments = {
            "bucket capacity in objeces", "internal node arity"
    })
    public MHTree(int bucketCapacity, int arity) throws IllegalArgumentException {
        this("Metric Hull Tree", bucketCapacity, arity, InsertType.INCREMENTAL, ObjectToNodeDistance.NEAREST);
    }


    /**
     * Create a new instance of MH-Tree and bulk-load it with data.For bulk-loading this variant is used:
 - Insert type is {@link InsertType#GREEDY}.
     * 
     * - Node's hull rank to an object is {@link ObjectToNodeDistance#NEAREST}.
     * - Hull merging method is {@link MergingMethod#HULL_BASED_MERGE}.
     * 
     * If new objects are inserted into the structure, the following settings is used then:
     * Objects are inserted using {@link InsertType#INCREMENTAL}.
     * Distance to rank node in the priority queue is {@link ObjectToNodeDistance#NEAREST}.
     * 
     * @param bucketCapacity capacity in objects
     * @param arity internal node capacity (in hulls -- tree arity)
     * @param dataIterator data to insert into the tree by bulk-loading
     * @throws IllegalArgumentException 
     * @throws messif.buckets.BucketStorageException 
     */
    @AlgorithmConstructor(description = "MH-Tree", arguments = {
            "bucket capacity in objeces", "internal node arity", "data to bulk-load"
    })
    public MHTree(int bucketCapacity, int arity, Iterator<LocalAbstractObject> dataIterator) throws IllegalArgumentException, BucketStorageException {
        this(new MHTreeBuilder(dataIterator, bucketCapacity, arity).build());
    }
    
    public void rangeSearch(RangeQueryOperation operation) {
        if (operation.getRadius() != 0)
            throw new UnsupportedOperationException("Range query with non-zero radius is not supported yet!");
        
        exactMatchSearch(operation);
    }

    private void exactMatchSearch(RangeQueryOperation operation) {
        // Calculate node overlaps...
        final LocalAbstractObject q = operation.getQueryObject();
        int acc = 0;
        
        try {
            PriorityQueue<ObjectToNodeDistanceRank> queue = new PriorityQueue<>();
            //if (root.isCovered(q))
            queue.add(new ObjectToNodeDistanceRank(q, root, 1));

            while (!queue.isEmpty()) {
                Node node = queue.remove().getNode();
                acc++;

                if (node.isLeaf()) {
                    for (LocalAbstractObject object : node.getObjects()) {
                        if (q.getDistance(object) <= operation.getRadius()) {
                            operation.addToAnswer(object);
                            return;
                        }
                    }
                } else {
                    ObjectToNodeDistanceRank closestNotCovered = null;
                    double bestDistNotCovered = Double.MAX_VALUE;
                    boolean isCovered = false;
                    for (Node child : ((InternalNode) node).getChildren()) {
                        final ObjectToNodeDistanceRank rank = new ObjectToNodeDistanceRank(q, child, 1);
                        if (child.isCovered(q)) {
                            queue.add(rank);
                            isCovered = true;
                        } else if (!isCovered) {
                            if (rank.getDistance() < bestDistNotCovered) {
                                bestDistNotCovered = rank.getDistance();
                                closestNotCovered = rank;
                            }
                        }
                    }
                    if (!isCovered && closestNotCovered != null && queue.isEmpty())        // Add one best child if the object is not covered by any child.
                        queue.add(closestNotCovered);
                }
            }
        } finally {
            operation.endOperation();        
            operation.setParameter("AccessedNodes", acc);
        }
    }
    
    public void kNN(KNNQueryOperation operation) {
        kNNSearch(operation, null);
    }

    public void approxKNN(ApproxKNNQueryOperation operation) {
        SearchState searchState = (SearchState) operation.suppData;

        while (!searchState.queue.isEmpty()) {
            if (searchState.approximateState != null && searchState.approximateState.stop()) {
                break;
            }

            Node node = searchState.queue.remove().getNode();

            if (node.isLeaf()) {
                for (LocalAbstractObject object : node.getObjects()) {
                    if (!operation.isAnswerFull() || searchState.queryObject.getDistance(object) < operation.getAnswerDistance()) {
                        operation.addToAnswer(object);
                    }
                }

                if (searchState.approximateState != null) {
                    searchState.approximateState.update((LeafNode) node);
                }
            } else {
                for (Node child : ((InternalNode) node).getChildren()) {
                    searchState.queue.add(new ObjectToNodeDistanceRank(searchState.queryObject, child, operation.getK()));
                }
            }
        }

        operation.endOperation();
    }

    public void kNNSearch(KNNQueryOperation operation, ApproximateState approximateState) {
        LocalAbstractObject queryObject = operation.getQueryObject();

        PriorityQueue<ObjectToNodeDistanceRank> queue = new PriorityQueue<>();
        queue.add(new ObjectToNodeDistanceRank(queryObject, root, operation.getK()));

        while (!queue.isEmpty()) {
            if (approximateState != null && approximateState.stop()) {
                break;
            }

            Node node = queue.remove().getNode();

            if (node.isLeaf()) {
                for (LocalAbstractObject object : node.getObjects()) {
                    if (!operation.isAnswerFull() || queryObject.getDistance(object) < operation.getAnswerDistance()) {
                        operation.addToAnswer(object);
                    }
                }

                if (approximateState != null) {
                    approximateState.update((LeafNode) node);
                }
            } else {
                for (Node child : ((InternalNode) node).getChildren()) {
                    queue.add(new ObjectToNodeDistanceRank(queryObject, child, operation.getK()));
                }
            }
        }

        operation.endOperation();
    }

    /**
     * Returns the number of objects in the tree.
     *
     * @return the number of objects in the tree
     */
    public int getObjectCount() {
        return bucketDispatcher.getObjectCount();
    }

    /** Fills an empty instance of MH-tree with data by bulk-loading */
    public void bulkInsert(BulkInsertOperation operation) {
        throw new UnsupportedOperationException("Not supported this way yet -- use MHTreeBuilder.");
    }
    
    /**
     * Inserts a new object into the tree.
     *
     * @param operation contains the inserted object
     * @throws BucketStorageException when adding the object into a node
     */
    public void insert(InsertOperation operation) throws BucketStorageException {
        LocalAbstractObject object = operation.getInsertedObject();

        // Empty tree
        if (root == null) {
            List<LocalAbstractObject> objects = new ArrayList<>();
            objects.add(object);

            root = new LeafNode(new PrecomputedDistances(objects), bucketDispatcher.createBucket(), insertType, objectToNodeDistance);
            operation.endOperation();
            return;
        }

        Node node = root;

        while (!node.isLeaf()) {
            node.addObject(object);

            node = ((InternalNode) node).getNearestChild(object);
        }

        LeafNode leaf = (LeafNode) node;
        leaf.addObject(object);

        if (leaf.getBucket().isSoftCapacityExceeded()) {
            // Split leaf node

            List<Node> split = split(leaf);
            InternalNode parent = leaf.getParent();

            if (parent == null) {
                // Leaf node is root

                InternalNode internalNode = new InternalNode(leaf.getHull(), insertType, objectToNodeDistance, split);
                split.forEach(s -> s.setParent(internalNode));
                root = internalNode;
            } else {
                // Leaf node has parent

                List<Node> parentChildren = parent.getChildren();

                if (parent.getNumberOfChildren() == arity) {
                    // The parent node is full

                    parentChildren.remove(leaf);
                    InternalNode internalNode = new InternalNode(leaf.getHull(), insertType, objectToNodeDistance, split);
                    split.forEach(s -> s.setParent(internalNode));
                    parentChildren.add(internalNode);
                } else {
                    // The parent can accommodate another leaf node

                    parentChildren.remove(leaf);
                    split.forEach(s -> s.setParent(parent));
                    parentChildren.addAll(split);
                }
            }

            bucketDispatcher.removeBucket(leaf.getBucket().getBucketID());
        }

        operation.endOperation();
    }

    private List<Node> split(LeafNode leaf) throws BucketStorageException {
        PrecomputedDistances objectDistances = new PrecomputedDistances(leaf.getObjects());

        BitSet notProcessedObjectIndices = new BitSet(objectDistances.getObjectCount());
        notProcessedObjectIndices.set(0, objectDistances.getObjectCount());

        List<Integer> objectIndices = new ArrayList<>(bucketCapacity);

        // Select the furthest object (the outlier)
        int furthestIndex = Utils.maxDistanceIndex(objectDistances.getDistances(), notProcessedObjectIndices);
        notProcessedObjectIndices.clear(furthestIndex);
        objectIndices.add(furthestIndex);

        // Select the rest of the objects up to the total of bucketCapacity with respect to the building of a hull
        objectIndices.addAll(MHTreeBuilder.findClosestObjects(furthestIndex, bucketCapacity - 1, notProcessedObjectIndices, objectDistances));

        List<LocalAbstractObject> halfOfObjects = objectIndices
                .stream()
                .map(objectDistances::getObject)
                .collect(Collectors.toList());

        List<LocalAbstractObject> secondHalf = notProcessedObjectIndices
                .stream()
                .mapToObj(objectDistances::getObject)
                .collect(Collectors.toList());

        return new ArrayList<>(Arrays.asList(
                new LeafNode(objectDistances.getSubset(halfOfObjects), bucketDispatcher.createBucket(), insertType, objectToNodeDistance),
                new LeafNode(objectDistances.getSubset(secondHalf), bucketDispatcher.createBucket(), insertType, objectToNodeDistance)
        ));
    }

    /**
     * Returns a list of nodes.
     *
     * @return a list of nodes
     */
    public List<Node> getNodes() {
        List<Node> nodes = new ArrayList<>();
        root.gatherNodes(nodes);
        return nodes;
    }

    /**
     * Returns a list of leaf nodes.
     *
     * @return a list of leaf nodes
     */
    public List<LeafNode> getLeafNodes() {
        List<LeafNode> leafNodes = new ArrayList<>();
        root.gatherLeafNodes(leafNodes);
        return leafNodes;
    }
    
    public AbstractObjectIterator<LocalAbstractObject> getAllObjects() {
        final List<LeafNode> leaves = getLeafNodes();
        final Iterator<LeafNode> itL = leaves.iterator();
        
        return new AbstractObjectIterator<LocalAbstractObject>() {
            LocalAbstractObject lastReturnedO = null;

            LeafNode curL = null;
            Iterator<LocalAbstractObject> itO = null;
            LocalAbstractObject curO = null;
            // Get a next object ahead
            { setNext(); }
            
            private void setNext() {
                if (itO != null && itO.hasNext()) {
                    curO = itO.next();
                } else if (itL.hasNext()) {
                    curL = itL.next();
                    itO = curL.getObjects().iterator();
                    if (itO.hasNext())
                        curO = itO.next();
                    else 
                        curO = null;
                } else {
                    curO = null;        // No next object available
                }
            }
            
            @Override
            public LocalAbstractObject getCurrentObject() throws NoSuchElementException {
                return lastReturnedO;
            }

            @Override
            public boolean hasNext() {
                return (curO != null);
            }

            @Override
            public LocalAbstractObject next() {
                lastReturnedO = curO;
                setNext();
                return lastReturnedO;
            }
        };
    }

    /**
     * Prints statistics about the tree.
     */
    public void printStatistics() {
        List<Node> nodes = getNodes();

        IntSummaryStatistics nodeHullObjects = nodes
                .stream()
                .mapToInt(Node::getHullObjectCount)
                .summaryStatistics();

        IntSummaryStatistics leafNodeObjects = getLeafNodes()
                .stream()
                .mapToInt(LeafNode::getObjectCount)
                .summaryStatistics();

        System.out.println("Bucket capacity: " + bucketCapacity);
        System.out.println("Node degree: " + arity);
        System.out.println("Object to node distance measurement: " + objectToNodeDistance);
        System.out.println("Insert type: " + insertType);

        System.out.println("Number of objects: " + bucketDispatcher.getObjectCount());
        System.out.println("Number of nodes: " + nodes.size());
        System.out.println("Number of internal nodes: " + (nodes.size() - leafNodeObjects.getCount()));
        System.out.println("Height: " + root.getHeight());
        System.out.println("Number of leaf nodes: " + leafNodeObjects.getCount());

        System.out.printf("Number of hull objects per node - min: %d, avg: %.2f, max: %d, sum: %d\n",
                nodeHullObjects.getMin(),
                nodeHullObjects.getAverage(),
                nodeHullObjects.getMax(),
                nodeHullObjects.getSum());

        System.out.printf("Number of stored objects per leaf node - min: %d, avg: %.2f, max: %d\n",
                leafNodeObjects.getMin(),
                leafNodeObjects.getAverage(),
                leafNodeObjects.getMax());
    }

    public Node getRoot() {
        return root;
    }

    @Override
    public String toString() {
        return "MHTree{" +
                "bucketCapacity=" + bucketCapacity +
                ", nodeDegree=" + arity +
                ", insertType=" + insertType +
                ", objectToNodeDistance=" + objectToNodeDistance +
                '}';
    }
    
    /** Computes fat factor of the tree.
     * 
     * @return fat-factor value
     */
    public float getFatFactor() {
        long accessed = 0; // I_c
        int notFound = 0;
        
        final AbstractObjectIterator<LocalAbstractObject> iterObjs = getAllObjects();
        
        try {
            while (iterObjs.hasNext()) {
                LocalAbstractObject o = iterObjs.next();

                RangeQueryOperation op = new RangeQueryOperation(o, 0f, AnswerType.ORIGINAL_OBJECTS);
                execute(false, op);
                
                accessed += (long)op.getParameter("AccessedNodes", Integer.class);
                if (op.getAnswerCount() == 0)
                    notFound++;
            }
        } catch (AlgorithmMethodException | NoSuchMethodException ex) {
        }

        if (notFound > 0)
            System.out.println("Fat factor computation: " + notFound + " exact-match queries did not find an object!");
        
        final long objects = getObjectCount();
        final int height = root.getHeight();
        final int totalNodes = getNodes().size();
        
        return (float)(accessed - height * objects) / (float)(objects * (totalNodes - height));
    }

    /**
     * Represents the MH-Tree bulk-loading algorithm.
     * 
     * Defaults:
     * Insert type is {@link InsertType#GREEDY}.
     * Node's hull rank to an object is {@link ObjectToNodeDistance#NEAREST}.
     * Hull merging method is {@link MergingMethod#HULL_BASED_MERGE}.
     */
    public static class MHTreeBuilder {

        /**
         * List of object used by the MH-Tree bulk-loading algorithm.
         */
        private final List<LocalAbstractObject> objects;

        /**
         * The minimal number of objects in a bucket of a leaf node.
         */
        private final int bucketCapacity;

        /**
         * The maximal degree of an internal node.
         */
        private final int arity;

        /**
         * Specifies which method to use when adding a new object.
         */
        private InsertType insertType;

        /**
         * Specifies how to measure distance between an object and a node.
         */
        private ObjectToNodeDistance objectToNodeDistance;

        /**
         * A dispatcher for maintaining a set of local buckets.
         */
        private BucketDispatcher bucketDispatcher;

        /**
         * Specifies the merging method.
         */
        private MergingMethod mergingMethod;

        /**
         * Contains the object distance matrix.
         */
        private PrecomputedDistances objectDistances;

        /**
         * Stores intermediate nodes needed during the algorithm.
         */
        private Node[] nodes;

        /**
         * Identifies which indices in {@link #nodes} are valid.
         */
        private BitSet validNodeIndices;

        /**
         * Contains the node distance matrix.
         */
        private PrecomputedNodeDistances nodeDistances;

        /**
         * The root node of MH-Tree.
         */
        private Node root;

        /**
         * Creates a new MH-Tree builder corresponding to the bulk-loading algorithm.
         *
         * Defaults:
         * Insert type is {@link InsertType#GREEDY}.
         * Node's hull rank to an object is {@link ObjectToNodeDistance#NEAREST}.
         * Hull merging method is {@link MergingMethod#HULL_BASED_MERGE}.
         * 
         * @param objectsIter    iterator of objects
         * @param bucketCapacity leaf node bucket capacity
         * @param arity          arity
         */
        public MHTreeBuilder(Iterator<LocalAbstractObject> objectsIter, int bucketCapacity, int arity) {
            this(iterToList(objectsIter), bucketCapacity, arity);
        }

        /**
         * Creates a new MH-Tree builder corresponding to the bulk-loading algorithm.
         *
         * Defaults:
         * Insert type is {@link InsertType#GREEDY}.
         * Node's hull rank to an object is {@link ObjectToNodeDistance#NEAREST}.
         * Hull merging method is {@link MergingMethod#HULL_BASED_MERGE}.
         * 
         * @param objects        list of objects
         * @param bucketCapacity leaf node bucket capacity
         * @param arity          arity
         */
        public MHTreeBuilder(List<LocalAbstractObject> objects, int bucketCapacity, int arity) {
            if (objects == null) {
                throw new NullPointerException("List of objects is null");
            }

            if (objects.size() < 3) {
                throw new IllegalArgumentException("Number of objects to insert cannot by smaller than 3");
            }

            if (bucketCapacity < 3) {
                throw new IllegalArgumentException("Bucket capacity cannot be smaller than 3 objects");
            }

            if (arity < 2) {
                throw new IllegalArgumentException("Arity cannot be smaller than 2");
            }

            this.objects = objects;
            this.bucketCapacity = bucketCapacity;
            this.arity = arity;

            this.insertType = InsertType.GREEDY;
            this.objectToNodeDistance = ObjectToNodeDistance.NEAREST;
            this.bucketDispatcher = new BucketDispatcher(Integer.MAX_VALUE, Long.MAX_VALUE, 2L * bucketCapacity - 1, 0, false, MemoryStorageBucket.class, null);
            this.mergingMethod = MergingMethod.HULL_BASED_MERGE;
        }

        private static List<LocalAbstractObject> iterToList(final Iterator<LocalAbstractObject> iter) {
            List<LocalAbstractObject> lst = new ArrayList<>();
            iter.forEachRemaining(o -> lst.add(o));
            return lst;
        }
        
        /**
         * Returns the closest objects with respect to the building of a hull.
         * The number of returned indices is specified by {@code numberOfObjects}.
         * Note that the returned indices are already set as invalid in {@code validObjectIndices}.
         *
         * @param objectIndex        the initial object index to which the closest objects are found
         * @param numberOfObjects    the number of return object indices
         * @param validObjectIndices object indices which are taken into consideration
         * @return a list of closest object indices with respect to the building of a hull
         */
        private static List<Integer> findClosestObjects(int objectIndex, int numberOfObjects, BitSet validObjectIndices, PrecomputedDistances objectDistances) {
            List<Integer> objectIndices = new ArrayList<>(1 + numberOfObjects);
            objectIndices.add(objectIndex);

            while (objectIndices.size() - 1 != numberOfObjects) {
                int index = findClosestObjectIndex(objectIndices, validObjectIndices, objectDistances);

                objectIndices.add(index);
                validObjectIndices.clear(index);
            }

            return objectIndices.subList(1, objectIndices.size());
        }

        /**
         * Returns the closest object index to {@code indices} with respect to the building of a hull.
         *
         * @param indices            list of object indices
         * @param validObjectIndices object indices which are taken into consideration
         * @return the closest object index to {@code indices} with respect to the building of a hull
         */
        private static int findClosestObjectIndex(List<Integer> indices, BitSet validObjectIndices, PrecomputedDistances objectDistances) {
            double minDistance = Double.MAX_VALUE;
            int closestObjectIndex = -1;

            for (int index : indices) {
                int candidateIndex = objectDistances.minDistInArray(objectDistances.getDistances(index), validObjectIndices);
                double distanceSum = indices
                        .stream()
                        .mapToDouble(i -> objectDistances.getDistance(i, candidateIndex))
                        .sum();

                if (distanceSum < minDistance) {
                    minDistance = distanceSum;
                    closestObjectIndex = candidateIndex;
                }
            }

            return closestObjectIndex;
        }

        /**
         * Specifies which insert type should be used.
         *
         * @param insertType the insert type
         * @return the MH-Tree builder object
         */
        public MHTreeBuilder insertType(InsertType insertType) {
            this.insertType = insertType;
            return this;
        }

        /**
         * Specifies which object to node distance should be used.
         *
         * @param objectToNodeDistance the object to node distance measure
         * @return the MH-Tree builder object
         */
        public MHTreeBuilder objectToNodeDistance(ObjectToNodeDistance objectToNodeDistance) {
            this.objectToNodeDistance = objectToNodeDistance;
            return this;
        }

        /**
         * Specifies which bucket dispatcher should be used.
         *
         * @param bucketDispatcher the bucket dispatcher
         * @return the MH-Tree builder object
         */
        public MHTreeBuilder bucketDispatcher(BucketDispatcher bucketDispatcher) {
            this.bucketDispatcher = bucketDispatcher;
            return this;
        }

        /**
         * Specifies which merging method should be used.
         *
         * @param mergingMethod the merging method
         * @return the MH-Tree builder object
         */
        public MHTreeBuilder mergingMethod(MergingMethod mergingMethod) {
            this.mergingMethod = mergingMethod;
            return this;
        }
        
        /**
         * Builds an MH-Tree based on specified parameters.
         *
         * @return an MH-Tree based on specified parameters
         * @throws BucketStorageException when creating a new bucket or adding an object to the nearest node
         */
        public MHTree build() throws BucketStorageException {
            nodes = new Node[objects.size() / bucketCapacity];

            validNodeIndices = new BitSet(nodes.length);
            validNodeIndices.set(0, nodes.length);

            PrecomputedDistances.COMPUTATION_THREADS = 12;
            objectDistances = new PrecomputedDistances(objects);

            // Every object is stored in the root
            if (objects.size() <= bucketCapacity) {
                root = new LeafNode(objectDistances, bucketDispatcher.createBucket(), insertType, objectToNodeDistance);
                return new MHTree(this);
            }

            System.out.println("Creating leaf nodes...");
            createLeafNodes(bucketCapacity);

            System.out.println("Matrix of node distances...");
            nodeDistances = new PrecomputedNodeDistances();

            System.out.println("Creatin MH-tree...");
            root = createRoot(arity);

            objectDistances = null;

            return new MHTree(this);
        }

        /**
         * Creates a root of an MH-Tree.
         *
         * @param arity arity
         * @return a root of an MH-Tree.
         */
        private Node createRoot(int arity) {
            while (validNodeIndices.cardinality() != 1) {
                BitSet notProcessedNodeIndices = (BitSet) validNodeIndices.clone();

                while (!notProcessedNodeIndices.isEmpty()) {
                    if (notProcessedNodeIndices.cardinality() <= arity) {
                        mergeNodes(notProcessedNodeIndices);
                        break;
                    }

                    // Select the furthest node
                    int furthestNodeIndex = nodeDistances.getFurthestIndex(notProcessedNodeIndices);
                    notProcessedNodeIndices.clear(furthestNodeIndex);

                    // Select the rest of the nodes up to the total of arity
                    List<Integer> closestNodeIndices = Arrays
                            .stream(getRankedIndices(notProcessedNodeIndices, nodeDistances.getDistances(furthestNodeIndex)))
                            .limit(arity - 1)
                            .map(node -> node.index)
                            .collect(Collectors.toList());

                    closestNodeIndices.forEach(notProcessedNodeIndices::clear);

                    mergeNodes(furthestNodeIndex, closestNodeIndices);
                }
            }

            return nodes[validNodeIndices.nextSetBit(0)];
        }

        /**
         * Populates the {@link #nodes} by leaf nodes created on {@link #objects}.
         *
         * @param bucketCapacity bucket capacity of a leaf node
         * @throws BucketStorageException when creating a new bucket or adding an object to the nearest node
         */
        private void createLeafNodes(int bucketCapacity) throws BucketStorageException {
            BitSet notProcessedObjectIndices = new BitSet(objectDistances.getObjectCount());
            notProcessedObjectIndices.set(0, objectDistances.getObjectCount());

            for (int nodeIndex = 0; !notProcessedObjectIndices.isEmpty(); nodeIndex++) {
                if (notProcessedObjectIndices.cardinality() < bucketCapacity) {
                    // Add the rest of the objects to their nearest nodes
                    for (int i = notProcessedObjectIndices.nextSetBit(0); i >= 0; i = notProcessedObjectIndices.nextSetBit(i + 1)) {
                        LocalAbstractObject object = objectDistances.getObject(i);
                        nodes[getClosestNodeIndex(object)].addObject(object);
                    }

                    return;
                }

                List<Integer> objectIndices = new ArrayList<>(bucketCapacity);

                // Select the furthest object (the outlier)
                int furthestIndex = Utils.maxDistanceIndex(objectDistances.getDistances(), notProcessedObjectIndices);
                notProcessedObjectIndices.clear(furthestIndex);
                objectIndices.add(furthestIndex);

                // Select the rest of the objects up to the total of bucketCapacity with respect to the building of a hull
                objectIndices.addAll(findClosestObjects(furthestIndex, bucketCapacity - 1, notProcessedObjectIndices, objectDistances));

                List<LocalAbstractObject> objs = objectIndices
                        .stream()
                        .map(objectDistances::getObject)
                        .collect(Collectors.toList());

                nodes[nodeIndex] = new LeafNode(objectDistances.getSubset(objs), bucketDispatcher.createBucket(), insertType, objectToNodeDistance);
            }
        }

        /**
         * Returns an index of the nearest node in {@link #nodes}.
         *
         * @param object an object
         * @return an index of the nearest node in {@link #nodes}
         */
        private int getClosestNodeIndex(LocalAbstractObject object) {
            double minDistance = Double.MAX_VALUE;
            int closestNodeIndex = -1;

            for (int candidateIndex = 0; candidateIndex < nodes.length; candidateIndex++) {
                double distance = ObjectToNodeDistance.NEAREST.getDistance(object, nodes[candidateIndex], objectDistances);
                // Vlasta: NOT THIS!!!! >>>  double distance = objectToNodeDistance.getDistance(object, nodes[candidateIndex], objectDistances);

                if (distance < minDistance) {
                    minDistance = distance;
                    closestNodeIndex = candidateIndex;
                }
            }

            return closestNodeIndex;
        }

        /**
         * Merges nodes in {@link #nodes} specified by indices in {@link #validNodeIndices}.
         * The new node is placed on the first valid index in {@link #validNodeIndices}.
         *
         * @param nodeIndices a list of node indices  to be merged
         */
        private void mergeNodes(BitSet nodeIndices) {
            List<Integer> indices = nodeIndices
                    .stream()
                    .boxed()
                    .collect(Collectors.toList());
            int parentNodeIndex = indices.remove(0);
            mergeNodes(parentNodeIndex, indices);
        }

        /**
         * Merges nodes in {@link #nodes} specified by indices in {@link #validNodeIndices} and {@code parentNodeIndex}.
         * The new node is placed on the {@code parentNodeIndex} in {@link #nodes}.
         *
         * @param parentNodeIndex the index where the new node is placed
         * @param nodeIndices     a list of indices which are merged together with {@code parentNodeIndex} into a new node
         */
        private void mergeNodes(int parentNodeIndex, List<Integer> nodeIndices) {
            if (nodeIndices.isEmpty())
                return;

            nodeIndices.add(parentNodeIndex);

            List<Node> children = nodeIndices
                    .stream()
                    .map(i -> this.nodes[i])
                    .collect(Collectors.toList());

            InternalNode parent = Node.createParent(children, objectDistances, insertType, objectToNodeDistance, mergingMethod);

            children.forEach(child -> child.setParent(parent));

            nodeIndices.forEach(index -> {
                validNodeIndices.clear(index);
                this.nodes[index] = null;
            });

            this.nodes[parentNodeIndex] = parent;
            validNodeIndices.set(parentNodeIndex);

            nodeDistances.updateNodeDistances(parentNodeIndex);
        }

        /**
         * {@link PrecomputedNodeDistances} contains methods for computing, updating,
         * and retrieving distance between nodes stored in {@link #nodes}.
         */
        private class PrecomputedNodeDistances {
            /**
             * Node distance matrix for nodes in {@link #nodes}.
             */
            private final float[][] distances;

            /**
             * Creates a new node distance matrix and precomputes distances for nodes in {@link #nodes}.
             */
            PrecomputedNodeDistances() {
                distances = new float[nodes.length][nodes.length];

                computeNodeDistances();
            }

            /**
             * Returns the distances to node on index {@code i}.
             *
             * @param i an index of node in {@link #nodes}
             * @return the distances to node on index {@code i}
             */
            private float[] getDistances(int i) {
                return distances[i];
            }

            /**
             * Updates distances for node on index {@code nodeIndex}.
             *
             * @param nodeIndex the index of the node
             */
            private void updateNodeDistances(int nodeIndex) {
                validNodeIndices
                        .stream()
                        .forEach(index -> {
                            float distance = computeDistanceBetweenNodes(nodeIndex, index);

                            distances[nodeIndex][index] = distance;
                            distances[index][nodeIndex] = distance;
                        });
            }

            /**
             * Returns the maximal distance from the node distance matrix
             * using only the valid indices specified by {@code validIndices}.
             *
             * @param validIndices the valid node indices
             * @return the maximal distance from the node distance matrix
             */
            private int getFurthestIndex(BitSet validIndices) {
                return Utils.maxDistanceIndex(distances, validIndices);
            }

            /**
             * Computes distances between nodes in {@link #nodes}, stores the result in {@link #distances}.
             */
            private void computeNodeDistances() {
                for (int i = 0; i < nodes.length; i++) {
                    for (int j = i + 1; j < nodes.length; j++) {
                        float distance = computeDistanceBetweenNodes(i, j);

                        distances[i][j] = distance;
                        distances[j][i] = distance;
                    }
                }
            }

            /**
             * Computes and returns the distance between nodes on indices i and j in {@link #nodes}.
             *
             * @param i an index of node in {@link #nodes}
             * @param j an index of node in {@link #nodes}
             * @return the distance between nodes on indices i and j in {@link #nodes}.
             */
            private float computeDistanceBetweenNodes(int i, int j) {
                float distance = Float.MAX_VALUE;

                for (LocalAbstractObject firstHullObject : nodes[i].getHullObjects())
                    for (LocalAbstractObject secondHullObject : nodes[j].getHullObjects())
                        distance = Math.min(distance, objectDistances.getDistance(firstHullObject, secondHullObject));

                return distance;
            }
        }
    }
}
