/*
 * Project:  NextGIS Mobile
 * Purpose:  Mobile GIS for Android.
 * Author:   Dmitry Baryshnikov (aka Bishop), [email protected]
 * Author:   Stanislav Petriakov, [email protected]
 * *****************************************************************************
 * Based on https://github.com/rweeks/util/blob/master/src/com/newbrightidea/util/RTree.java
 * @see https://github.com/rweeks/util
 * Copyright (c) 2015-2019 NextGIS, [email protected]
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser Public License for more details.
 *
 * You should have received a copy of the GNU Lesser Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package com.nextgis.maplib.datasource;

import com.nextgis.maplib.api.IGeometryCache;
import com.nextgis.maplib.api.IGeometryCacheItem;
import com.nextgis.maplib.util.FileUtil;
import com.nextgis.maplib.util.GeoConstants;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;

/**
 * Implementation of an arbitrary-dimension RTree. Based on R-Trees: A Dynamic
 * Index Structure for Spatial Searching (Antonn Guttmann, 1984)
 *
 * This class is not thread-safe.
 */
public class GeometryRTree implements IGeometryCache {

    public enum SeedPicker { LINEAR, QUADRATIC }
    private final SeedPicker seedPicker;

    private int maxEntries;
    private int minEntries;
    private Node root;

    private volatile int size;

    protected File mPath;
    protected boolean mHasEdits;

    /**
     * Creates a new RTree.
     *
     * @param maxEntries
     *          maximum number of entries per node
     * @param minEntries
     *          minimum number of entries per node (except for the root node)
     */
    public GeometryRTree(int maxEntries, int minEntries, SeedPicker seedPicker){
        if (minEntries > (maxEntries / 2)) throw new AssertionError();
        this.maxEntries = maxEntries;
        this.minEntries = minEntries;
        this.seedPicker = seedPicker;
        root = buildRoot(true);
        mHasEdits = false;
    }

    public GeometryRTree(int maxEntries, int minEntries){
        this(maxEntries, minEntries, SeedPicker.LINEAR);
    }

    private Node buildRoot(boolean asLeaf){
        return new Node(new GeoEnvelope(-GeoConstants.MERCATOR_MAX, GeoConstants.MERCATOR_MAX,
                                        -GeoConstants.MERCATOR_MAX, GeoConstants.MERCATOR_MAX),
                        asLeaf);
    }

    /**
     * Builds a new RTree using default parameters: maximum 50 entries per node
     * minimum 2 entries per node
     */
    public GeometryRTree(){
        this(8, 2, SeedPicker.QUADRATIC);
    }

    /**
     * @return the maximum number of entries per node
     */
    public int getMaxEntries()
    {
        return maxEntries;
    }

    /**
     * @return the minimum number of entries per node for all nodes except the
     *         root.
     */
    public int getMinEntries()
    {
        return minEntries;
    }

    @Override
    public boolean isItemExist(long featureId) {
        return isItemExist(featureId, root);
    }

    public boolean isItemExist(long featureId, Node n) {
        if (n.mLeaf)
        {
            for (Node e : n.mChildren){
                if (!e.isNode()) {
                    Entry entry = (Entry) e;
                    if (entry.getFeatureId() == featureId) {
                        return true;
                    }
                }
            }
        }
        else{
            for (Node c : n.mChildren)
            {
                if (isItemExist(featureId, c))
                {
                    return true;
                }
            }
        }
        return false;
    }

    @Override
    public IGeometryCacheItem addItem(long id, GeoEnvelope envelope) {
        mHasEdits = true;
        return insert(id, envelope);
    }

    @Override
    public IGeometryCacheItem getItem(long featureId) {
        return getItem(featureId, root);
    }

    @Override
    public void changeId(long oldFeatureId, long newFeatureId) {
        IGeometryCacheItem item = getItem(oldFeatureId);
        if(null != item)
            item.setFeatureId(newFeatureId);
    }

    @Override
    public synchronized void save(File path) {

        boolean isSameFile = null != mPath && mPath.equals(path);

        if(isSameFile && !mHasEdits)
            return;

        try {
            FileUtil.createDir(path.getParentFile());
            FileOutputStream fileOutputStream = new FileOutputStream(path);
            DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream);

            dataOutputStream.writeInt(maxEntries);
            dataOutputStream.writeInt(minEntries);
            dataOutputStream.writeInt(size);

            root.write(dataOutputStream);

            dataOutputStream.flush();
            dataOutputStream.close();
            fileOutputStream.close();

            mHasEdits = false;
        } catch (RuntimeException | IOException e) {
            e.printStackTrace();
        }
    }

    @Override
    public void load(File path) {
        clear();

        if (!path.exists()) {
            return;
        }

        mPath = path;

        try {
            FileInputStream fileInputStream = new FileInputStream(path);
            DataInputStream dataInputStream = new DataInputStream(fileInputStream);

            maxEntries = dataInputStream.readInt();
            minEntries = dataInputStream.readInt();
            size = dataInputStream.readInt();

            dataInputStream.readBoolean();
            root = new Node();
            root.read(dataInputStream);

            dataInputStream.close();
            fileInputStream.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public IGeometryCacheItem getItem(long featureId, Node n) {
        if (n.mLeaf){
            for (Node e : n.mChildren){
                if (!e.isNode()) {
                    Entry entry = (Entry) e;
                    if (entry.getFeatureId() == featureId){
                        return entry;
                    }
                }
            }
        }
        else{
            for (Node c : n.mChildren)
            {
                IGeometryCacheItem entry = getItem(featureId, c);
                if (null != entry){
                    return entry;
                }
            }
        }
        return null;
    }

    /**
     * @return the number of items in this tree.
     */
    public int size(){
        return size;
    }

    /**
     * Searches the RTree for objects overlapping with the given rectangle.
     *
     * @param extent
     *          the envelope to search
     * @return a list of objects whose envelopes overlap with the given
     *         envelope.
     */
    @Override
    public synchronized List<IGeometryCacheItem> search(GeoEnvelope extent){
        LinkedList<IGeometryCacheItem> results = new LinkedList<>();
        search(extent, root, results);
        return results;
    }

    @Override
    public synchronized List<IGeometryCacheItem> getAll() {
        LinkedList<IGeometryCacheItem> result = new LinkedList<>();
        getAll(root, result);
        return result;
    }

    protected void getAll(Node n, LinkedList<IGeometryCacheItem> results){
        if (n.mLeaf){
            for (Node e : n.mChildren){
                if (!e.isNode()) {
                    Entry entry = (Entry) e;
                    results.add(entry);
                }
            }
        }
        else{
            for (Node c : n.mChildren){
                getAll(c, results);
            }
        }
    }

    private void search(GeoEnvelope extent, Node n,
                        LinkedList<IGeometryCacheItem> results){
        if (n.mLeaf)
        {
            for (Node e : n.mChildren)
            {
                if (e.mCoords.intersects(extent) && e instanceof Entry) {
                    Entry entry = (Entry)e;
                    results.add(entry);
                }
            }
        }
        else
        {
            for (Node c : n.mChildren)
            {
                if (c.mCoords.intersects(extent))
                {
                    search(extent, c, results);
                }
            }
        }
    }

    /**
     * Deletes the entry associated with the given rectangle from the RTree
     *
     * @param featureId
     *          the feature id to delete
     * @return true if the entry was deleted from the RTree.
     */
    @Override
    public IGeometryCacheItem removeItem(long featureId){
        Node l = findLeaf(root, featureId);
        if ( l == null ) {
            return null;
        }

        mHasEdits = true;

        condenseTree(l);
            size--;

        if ( size == 0 ){
            root = buildRoot(true);
        }

        if(l instanceof IGeometryCache)
            return (IGeometryCacheItem) l;
        return null;
    }

    private Node findLeaf(Node n, long featureId){
        if (n.mLeaf){
            for (Node c : n.mChildren){
                if (!c.isNode() && ((Entry) c).mFeatureId == featureId)
                {
                    return c;
                }
            }
        }
        else{
            for (Node c : n.mChildren){
                Node result = findLeaf(c, featureId);
                if (result != null)
                {
                    return result;
                }
            }
        }
        return null;
    }

    private void condenseTree(Node n){
        Set<Node> q = new HashSet<>();
        while (n != root){
            if (n.mLeaf && (n.mChildren.size() < minEntries)){
                q.addAll(n.mChildren);
                n.mParent.mChildren.remove(n);
            }
            else if (!n.mLeaf && (n.mChildren.size() < minEntries)){
                // probably a more efficient way to do this...
                LinkedList<Node> toVisit = new LinkedList<>(n.mChildren);
                while (!toVisit.isEmpty()){
                    Node c = toVisit.removeFirst();
                    if (c.mLeaf){
                        q.addAll(c.mChildren);

                    }
                    else{
                        toVisit.addAll(c.mChildren);
                    }
                }
                n.mParent.mChildren.remove(n);
            }
            else{
                tighten(n);
            }
            n = n.mParent;
        }
        if ( root.mChildren.isEmpty() ){
            root = buildRoot(true);
        }
        else if ( (root.mChildren.size() == 1) && (!root.mLeaf) ){
            root = root.mChildren.get(0);
            root.mParent = null;
        }
        else{
            tighten(root);
        }

        for (Node ne : q){
            if (!ne.isNode()) {
                Entry e = (Entry) ne;
                insert(e.mFeatureId, e.mCoords);
            }
        }
        size -= q.size();
    }

    /**
     * Empties the RTree
     */
    public void clear(){
        root = buildRoot(true);
        mHasEdits = false;
        // let the GC take care of the rest.
    }

    /**
     * Inserts the given entry into the RTree, associated with the given
     * rectangle.
     *
     * @param featureId
     *          a feature identificator
     * @param envelope
     *          an envelope
     */
    public IGeometryCacheItem insert(long featureId, GeoEnvelope envelope){
        Entry e = new Entry(featureId, envelope);
        Node l = chooseLeaf(root, e);
        if(l == null)
            l = root;

        l.add(e);
        size++;
        if (l.mChildren.size() > maxEntries){
            Node[] splits = splitNode(l);
            adjustTree(splits[0], splits[1]);
        }
        else{
            adjustTree(l, null);
        }

        return e;
    }

    private void adjustTree(Node n, Node nn){
        if (n == root){
            if (nn != null){
                // build new root and add children.
                root = buildRoot(false);
                root.add(n);
                root.add(nn);
            }
            tighten(root);
            return;
        }
        tighten(n);
        if (nn != null){
            tighten(nn);
            if (n.mParent.mChildren.size() > maxEntries){
                Node[] splits = splitNode(n.mParent);
                adjustTree(splits[0], splits[1]);
            }
        }
        if (n.mParent != null){
            adjustTree(n.mParent, null);
        }
    }

    private Node[] splitNode(Node n){
        // TODO: this class probably calls "tighten" a little too often.
        // For instance the call at the end of the "while (!cc.isEmpty())" loop
        // could be modified and inlined because it's only adjusting for the addition
        // of a single node.  Left as-is for now for readability.
        @SuppressWarnings("unchecked")
        Node[] nn = new GeometryRTree.Node[]{ n, new Node(n.mCoords, n.mLeaf) };
        nn[1].mParent = n.mParent;
        if (nn[1].mParent != null){
            nn[1].mParent.add(nn[1]);
        }
        LinkedList<Node> cc = new LinkedList<>(n.mChildren);
        n.mChildren.clear();
        Node[] ss = seedPicker == SeedPicker.LINEAR ? lPickSeeds(cc) : qPickSeeds(cc);
        nn[0].add(ss[0]);
        nn[1].add(ss[1]);
        tighten(nn);
        while (!cc.isEmpty()){
            if ((nn[0].mChildren.size() >= minEntries) && (nn[1].mChildren.size() + cc.size() == minEntries)){
                nn[1].addAll(cc);
                cc.clear();
                tighten(nn); // Not sure this is required.
                return nn;
            }
            else if ((nn[1].mChildren.size() >= minEntries) && (nn[0].mChildren.size() + cc.size() == minEntries)){
                nn[0].addAll(cc);
                cc.clear();
                tighten(nn); // Not sure this is required.
                return nn;
            }

            Node c = seedPicker == SeedPicker.LINEAR ? lPickNext(cc) : qPickNext(cc, nn);
            if (c == null)
                return nn;

            Node preferred;
            double e0 = getRequiredExpansion(nn[0].mCoords, c);
            double e1 = getRequiredExpansion(nn[1].mCoords, c);
            if (e0 < e1){
                preferred = nn[0];
            }
            else if (e0 > e1){
                preferred = nn[1];
            }
            else{
                double a0 = nn[0].mCoords.getArea();
                double a1 = nn[1].mCoords.getArea();
                if (a0 < a1){
                    preferred = nn[0];
                }
                else if (e0 > a1){
                    preferred = nn[1];
                }
                else{
                    if (nn[0].mChildren.size() < nn[1].mChildren.size()){
                        preferred = nn[0];
                    }
                    else if (nn[0].mChildren.size() > nn[1].mChildren.size()){
                        preferred = nn[1];
                    }
                    else{
                        preferred = nn[(int) Math.round(Math.random())];
                    }
                }
            }
            preferred.add(c);
            tighten(preferred);
        }
        return nn;
    }

    // Implementation of Quadratic PickSeeds
    private Node[] qPickSeeds(LinkedList<Node> nn){
        @SuppressWarnings("unchecked")
        Node[] bestPair = new Node[2];
        double maxWaste = -1.0f * Double.MAX_VALUE;
        for (Node n1: nn){
            for (Node n2: nn){
                if (n1 == n2)
                    continue;
                double n1a = n1.mCoords.getArea();
                double n2a = n2.mCoords.getArea();
                double ja = 1.0f;
                double jc0 = Math.min(n1.mCoords.getMinX(), n2.mCoords.getMinX());
                double jc1 = Math.max(n1.mCoords.getMaxX(), n2.mCoords.getMaxX());
                ja *= (jc1 - jc0);
                jc0 = Math.min(n1.mCoords.getMinY(), n2.mCoords.getMinY());
                jc1 = Math.max(n1.mCoords.getMaxY(), n2.mCoords.getMaxY());
                ja *= (jc1 - jc0);

                double waste = ja - n1a - n2a;
                if ( waste > maxWaste )
                {
                    maxWaste = waste;
                    bestPair[0] = n1;
                    bestPair[1] = n2;
                }
            }
        }
        nn.remove(bestPair[0]);
        nn.remove(bestPair[1]);
        return bestPair;
    }

    /**
     * Implementation of QuadraticPickNext
     * @param cc the children to be divided between the new nodes, one item will be removed from this list.
     * @param nn the candidate nodes for the children to be added to.
     */
    private Node qPickNext(LinkedList<Node> cc, Node[] nn){
        double maxDiff = -1.0f * Double.MAX_VALUE;
        Node nextC = null;
        for ( Node c: cc )
        {
            double n0Exp = getRequiredExpansion(nn[0].mCoords, c);
            double n1Exp = getRequiredExpansion(nn[1].mCoords, c);
            double diff = Math.abs(n1Exp - n0Exp);
            if (diff > maxDiff){
                maxDiff = diff;
                nextC = c;
            }
        }
        assert (nextC != null) : "No node selected from qPickNext";
        cc.remove(nextC);
        return nextC;
    }

    // Implementation of LinearPickSeeds
    private Node[] lPickSeeds(LinkedList<Node> nn){
        @SuppressWarnings("unchecked")
        Node[] bestPair = new Node[2];
        boolean foundBestPair = false;
        double bestSep = 0.0f;

        double dimLb = Double.MAX_VALUE, dimMinUb = Double.MAX_VALUE;
        double dimUb = Double.MIN_VALUE, dimMaxLb = Double.MIN_VALUE;
        Node nMaxLb = null, nMinUb = null;
        for (Node n : nn)
        {
            if (n.mCoords.getMinX() < dimLb)
            {
                dimLb = n.mCoords.getMinX();
            }
            if (n.mCoords.getMaxX() > dimUb)
            {
                dimUb = n.mCoords.getMaxX();
            }
            if (n.mCoords.getMinX() > dimMaxLb)
            {
                dimMaxLb = n.mCoords.getMinX();
                nMaxLb = n;
            }
            if (n.mCoords.getMaxX() < dimMinUb)
            {
                dimMinUb = n.mCoords.getMaxX();
                nMinUb = n;
            }


        }
        double sep = (nMaxLb == nMinUb) ? -1.0f :
                Math.abs((dimMinUb - dimMaxLb) / (dimUb - dimLb));
        if (sep >= bestSep)
        {
            // FIXME
            // nMaxLb/nMinLb is null if dimMaxLb/dimMinUb is not set
            bestPair[0] = nMaxLb;
            bestPair[1] = nMinUb;
            bestSep = sep;
            foundBestPair = true;
        }

        dimLb = Double.MAX_VALUE;
        dimMinUb = Double.MAX_VALUE;
        dimUb = Double.MIN_VALUE;
        dimMaxLb = Double.MIN_VALUE;
        nMaxLb = null;
        nMinUb = null;

        for (Node n : nn)
        {
            if (n.mCoords.getMinY() < dimLb)
            {
                dimLb = n.mCoords.getMinY();
            }
            if (n.mCoords.getMaxY() > dimUb)
            {
                dimUb = n.mCoords.getMaxY();
            }
            if (n.mCoords.getMinY() > dimMaxLb)
            {
                dimMaxLb = n.mCoords.getMinY();
                nMaxLb = n;
            }
            if (n.mCoords.getMaxY() < dimMinUb)
            {
                dimMinUb = n.mCoords.getMaxY();
                nMinUb = n;
            }

        }
        sep = (nMaxLb == nMinUb) ? -1.0f :
                Math.abs((dimMinUb - dimMaxLb) / (dimUb - dimLb));
        if (sep >= bestSep)
        {
            bestPair[0] = nMaxLb;
            bestPair[1] = nMinUb;
            foundBestPair = true;
        }

        // In the degenerate case where all points are the same, the above
        // algorithm does not find a best pair.  Just pick the first 2
        // children.
        if ( !foundBestPair ){
            bestPair = new Node[] { nn.get(0), nn.get(1) };
        }
        nn.remove(bestPair[0]);
        nn.remove(bestPair[1]);
        return bestPair;
    }

    /**
     * Implementation of LinearPickNext
     * @param cc the children to be divided between the new nodes, one item will be removed from this list.
     */
    private Node lPickNext(LinkedList<Node> cc){
        return cc.removeFirst();
    }

    private void tighten(Node... nodes){
        if (nodes.length < 1) throw new AssertionError("Pass some nodes to tighten!");
        for (Node n: nodes) {
            if (n.mChildren.size() <= 0) throw new AssertionError("tighten() called on empty node!");
            n.mCoords.unInit();

            for (Node c : n.mChildren)
            {
                // we may have bulk-added a bunch of children to a node (eg. in
                // splitNode)
                // so here we just enforce the child->parent relationship.
                //c.mParent = n;

                n.mCoords.merge(c.mCoords);
            }
        }
    }

    private Node chooseLeaf(Node n, Entry e)
    {
        if (n == null)
            return null;

        if (n.mLeaf)
            return n;

        double minInc = Double.MAX_VALUE;
        Node next = null;
        for (Node c : n.mChildren) {
            double inc = getRequiredExpansion(c.mCoords, e);
            if (inc < minInc) {
                minInc = inc;
                next = c;
            } else if (inc == minInc) {
                if (next == null)
                    continue;

                double curArea = next.mCoords.getArea();
                double thisArea = c.mCoords.getArea();
                if (thisArea < curArea) {
                    next = c;
                }
            }
        }

        if (next == null)
            return n;

        return chooseLeaf(next, e);
    }

    /**
     * Returns the increase in area necessary for the given rectangle to cover the
     * given entry.
     */
    private double getRequiredExpansion(GeoEnvelope envelope, Node e)
    {
        double area = envelope.getArea();
        double deltaX = 0.0, deltaY = 0.0;
        if (envelope.mMaxX < e.mCoords.mMaxX){
            deltaX = e.mCoords.mMaxX - envelope.mMaxX;
        }
        else if (envelope.mMaxX > e.mCoords.mMaxX){
            deltaX = envelope.mMinX - e.mCoords.mMinX;
        }

        if (envelope.mMaxY < e.mCoords.mMaxY){
            deltaY = e.mCoords.mMaxY - envelope.mMaxY;
        }
        else if (envelope.mMaxY > e.mCoords.mMaxY){
            deltaY = envelope.mMinY - e.mCoords.mMinY;
        }

        double expanded = (envelope.width() + deltaX) * (envelope.height() + deltaY);
        return (expanded - area);
    }

    protected class Node
    {
        protected GeoEnvelope mCoords;
        protected LinkedList<Node> mChildren;
        protected boolean mLeaf;

        protected Node mParent;

        protected Node(){
            mCoords = new GeoEnvelope();
            mChildren = new LinkedList<>();
        }

        public int size(){
            return mChildren.size();
        }

        public void add(Node node){
            mChildren.add(node);
            node.setParent(this);
        }

        public void addAll(LinkedList<Node> children){
            for (Node node : children){
                add(node);
            }
        }

        protected Node(GeoEnvelope coords, boolean leaf)
        {
            mCoords = new GeoEnvelope(coords);
            mLeaf = leaf;
            mChildren = new LinkedList<>();
        }

        public void write(DataOutputStream stream) throws IOException {
            stream.writeBoolean(isNode());
            stream.writeBoolean(mLeaf);
            stream.writeDouble(mCoords.getMinX());
            stream.writeDouble(mCoords.getMinY());
            stream.writeDouble(mCoords.getMaxX());
            stream.writeDouble(mCoords.getMaxY());
            stream.writeInt(mChildren.size());
            for(Node node : mChildren){
                node.write(stream);
            }
        }

        public void read(DataInputStream stream) throws IOException {
            mLeaf = stream.readBoolean();
            double minX = stream.readDouble();
            double minY = stream.readDouble();
            double maxX = stream.readDouble();
            double maxY = stream.readDouble();
            mCoords.setMin(minX, minY);
            mCoords.setMax(maxX, maxY);
            int size = stream.readInt();
            for(int i = 0; i < size; i++){
                if(stream.readBoolean()){
                    Node childNode = new Node();
                    childNode.read(stream);
                    add(childNode);
                }
                else {
                    Entry childEntry = new Entry();
                    childEntry.read(stream);
                    add(childEntry);
                }
            }
        }

        protected boolean isNode(){ return true; }

        public void setParent(Node parent) {
            mParent = parent;
        }
    }

    protected class Entry extends Node implements IGeometryCacheItem
    {
        protected long mFeatureId;

        protected Entry()
        {
            super();
        }

        protected Entry(long featureId, GeoEnvelope envelope)
        {
            super(envelope, true);
            mFeatureId = featureId;
        }

        public boolean intersects(GeoEnvelope extent) {
            return mCoords.intersects(extent);
        }

        @Override
        public GeoEnvelope getEnvelope() {
            return mCoords;
        }

        @Override
        public long getFeatureId() {
            return mFeatureId;
        }

        @Override
        public void setFeatureId(long id) {
            mFeatureId = id;
        }

        @Override
        public void read(DataInputStream stream) throws IOException {
            super.read(stream);
            mFeatureId = stream.readLong();
        }

        @Override
        public void write(DataOutputStream stream) throws IOException {
            super.write(stream);
            stream.writeLong(mFeatureId);
        }

        @Override
        protected boolean isNode() {
            return false;
        }
    }
}