/*
 * Copyright (C) 2013-2015 F(X)yz, 
 * Sean Phillips, Jason Pollastrini and Jose Pereda
 * All rights reserved.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General 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 General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package org.fxyz.shapes.primitives;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import java.util.stream.DoubleStream;
import java.util.stream.IntStream;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Bounds;
import javafx.geometry.Point2D;
import javafx.scene.DepthTest;
import javafx.scene.shape.CullFace;
import javafx.scene.shape.DrawMode;
import javafx.scene.shape.TriangleMesh;
import org.fxyz.geometry.Face3;
import org.fxyz.geometry.Point3D;
import org.fxyz.utils.FloatCollector;
import org.poly2tri.Poly2Tri;
import org.poly2tri.polygon.Polygon;
import org.poly2tri.polygon.PolygonPoint;
import org.poly2tri.polygon.PolygonSet;
import org.poly2tri.triangulation.TriangulationPoint;
import org.poly2tri.triangulation.delaunay.DelaunayTriangle;

/**
 *
 * @author José Pereda 
 */
public class TriangulatedMesh extends TexturedMesh {

    private List<Point3D> pointsExterior;
    private List<List<Point3D>> pointsHoles;
    private final static int DEFAULT_LEVEL = 1;
    private final static double DEFAULT_HEIGHT = 1d;
    private final static double DEFAULT_HOLE_RADIUS = 0d;

    public TriangulatedMesh(List<Point3D> points) {
        this(points,DEFAULT_LEVEL,DEFAULT_HEIGHT,DEFAULT_HOLE_RADIUS);
    }
    public TriangulatedMesh(List<Point3D> points, List<List<Point3D>> pointsHole) {
        this(points,pointsHole,DEFAULT_LEVEL,DEFAULT_HEIGHT,DEFAULT_HOLE_RADIUS);
    }
    
    public TriangulatedMesh(List<Point3D> points, double height) {
        this(points,DEFAULT_LEVEL,height,DEFAULT_HOLE_RADIUS);
    }

    public TriangulatedMesh(List<Point3D> points, double height, double holeRadius) {
        this(points,DEFAULT_LEVEL,height,holeRadius);
    }

    public TriangulatedMesh(List<Point3D> points, int level, double height, double holeRadius) {
        this(points,null,level,height,holeRadius);
    }
    public TriangulatedMesh(List<Point3D> points, List<List<Point3D>> pointsHole, int level, double height, double holeRadius) {
        this(points,pointsHole,level,height,holeRadius,null);
    }
    public TriangulatedMesh(List<Point3D> points, List<List<Point3D>> pointsHole, int level, double height, double holeRadius, Bounds bounds) {
        this.pointsExterior=points;
        this.pointsHoles=pointsHole;
        setLevel(level);
        setHeight(height);
        setHoleRadius(holeRadius);
        setBounds(bounds);
        
        updateMesh();
        setCullFace(CullFace.BACK);
        setDrawMode(DrawMode.FILL);
        setDepthTest(DepthTest.ENABLE);
    }
    
    @Override
    protected final void updateMesh(){   
        setMesh(null);
        mesh=createMesh(level.get());
        setMesh(mesh);
    }
    
    private final DoubleProperty height = new SimpleDoubleProperty(DEFAULT_HEIGHT){
        @Override
        protected void invalidated() {
            if(mesh!=null){
                updateMesh();
            }
        }
    };
    
    public double getHeight() {
        return height.get();
    }

    public final void setHeight(double value) {
        height.set(value);
    }

    public DoubleProperty heightProperty() {
        return height;
    }
    
    private final IntegerProperty level = new SimpleIntegerProperty(DEFAULT_LEVEL){

        @Override
        protected void invalidated() {
            if(mesh!=null){
                updateMesh();
            }
        }
        
    };

    public final int getLevel() {
        return level.get();
    }

    public final void setLevel(int value) {
        level.set(value);
    }

    public final IntegerProperty levelProperty() {
        return level;
    }
    private final DoubleProperty holeRadius = new SimpleDoubleProperty(DEFAULT_HOLE_RADIUS){
        @Override
        protected void invalidated() {
            if(mesh!=null){
                updateMesh();
            }
        }
    };

    public double getHoleRadius() {
        return holeRadius.get();
    }

    public final void setHoleRadius(double value) {
        holeRadius.set(value);
    }

    public DoubleProperty holeRadiusProperty() {
        return holeRadius;
    }
    private final ObjectProperty<Bounds> bounds = new SimpleObjectProperty<Bounds>(){
        @Override
        protected void invalidated() {
            if(mesh!=null){
                updateMesh();
            }
        }
    };

    public Bounds getBounds() {
        return bounds.get();
    }

    public final void setBounds(Bounds value) {
        bounds.set(value);
    }

    public ObjectProperty<Bounds> boundsProperty() {
        return bounds;
    }
    
    private int numVertices, numTexCoords, numFaces;
    private float[] points0, texCoord0;
    private int[] faces0;
    private List<Point2D> texCoord1;
    
    private List<TriangulationPoint> points1;
    private final List<List<PolygonPoint>> holes=new ArrayList<>();
    private List<TriangulationPoint> steiner;
    private int extPoints;
    private int steinerPoints=0*8;
    private int numHoles=0;
    private final List<Integer> holePoints=new ArrayList<>();
    private final double EPSILON = 0.001;
    private double maxX=0d, maxY=0d, minX=0d, minY=0d;
    
    private TriangleMesh createMesh(int level){
        TriangleMesh m0=null;
        if(level>0){
            m0 = createMesh(level-1);
        }
        
        if(level==0){
            //check for duplicates or too close
            List<Integer> duplicates=IntStream.range(0, pointsExterior.size()).boxed()
                .filter(i->pointsExterior.get(i).substract(pointsExterior.get(i==pointsExterior.size()-1?0:i+1)).magnitude()<100*EPSILON)
                .map(i->i).collect(Collectors.toList());
            duplicates.stream().sorted(Collections.reverseOrder()).forEach(i->pointsExterior.remove(i.intValue()));
            
            List<PolygonPoint> list = pointsExterior.stream().map(p->new PolygonPoint(p.x, p.y)).collect(Collectors.toList());
            Polygon poly=new Polygon(list);
            
            if(bounds.get()!=null){
                maxX=bounds.get().getMaxX();
                minX=bounds.get().getMinX();
                maxY=bounds.get().getMaxY();
                minY=bounds.get().getMinY();
            } else {
                maxX = pointsExterior.stream().mapToDouble(p->p.x).max().getAsDouble();
                maxY = pointsExterior.stream().mapToDouble(p->p.y).max().getAsDouble();
                minX = pointsExterior.stream().mapToDouble(p->p.x).min().getAsDouble();
                minY = pointsExterior.stream().mapToDouble(p->p.y).min().getAsDouble();
            }
            double rad = getHoleRadius();
            
            if(pointsHoles!=null){
                steinerPoints=0;
                numHoles=pointsHoles.size();
                
                // holes
                pointsHoles.forEach(pHole->{
                    // hole                
                    List<PolygonPoint> hole = pHole.stream().distinct()
                            .map(p->new PolygonPoint(p.x,p.y))
                            .collect(Collectors.toList());
                    holePoints.add(hole.size());
                    Polygon polyIn = new Polygon(hole);
                    poly.addHole(polyIn);
                    holes.add(hole);
                });
            } else if(rad>0d){
                steinerPoints=0;
                numHoles=1;
                int num=200;
                holePoints.add(num);
                
                // circular hole                
                List<PolygonPoint> hole = IntStream.range(0,num)
                        .mapToObj(i->new PolygonPoint((maxX+minX)/2d+rad*Math.cos((num-i)*2d*Math.PI/num),
                                                      (maxY+minY)/2d+rad*Math.sin((num-i)*2d*Math.PI/num)))
                        .collect(Collectors.toList());
                Polygon polyIn = new Polygon(hole);
                poly.addHole(polyIn);
                holes.add(hole);
            } else {
                double radSteiner = Math.sqrt(Math.pow(maxX-minX,2)+Math.pow(maxY-minY,2))/8d;
                // steiner points
                steiner = IntStream.range(0,steinerPoints)
                    .mapToObj(i->new PolygonPoint((maxX+minX)/2d+radSteiner*Math.cos(i*2d*Math.PI/steinerPoints),
                                                  (maxY+minY)/2d+radSteiner*Math.sin(i*2d*Math.PI/steinerPoints)))
                    .collect(Collectors.toList());
            
                poly.addSteinerPoints(steiner);
            }
            
            PolygonSet ps = new PolygonSet(poly);
            Poly2Tri.triangulate(ps);
            
            Polygon polRes = ps.getPolygons().get(0);
            List<DelaunayTriangle> tri = polRes.getTriangles();
            points1 = polRes.getPoints();
            extPoints=points1.size();
            if(pointsHoles!=null || rad>0d){
                holes.forEach(hole->hole.forEach(points1::add));
            } else {
                steiner.forEach(points1::add);
            }
            
            int totalHolePoints=holePoints.stream().reduce(0, Integer::sum);
            int numPoints=extPoints+steinerPoints+totalHolePoints;

            FloatCollector pointsBottom = points1.stream()
                    .flatMapToDouble(p->DoubleStream.of(p.getX(),p.getY(),0d))
                    .collect(()->new FloatCollector(points1.size()*3),FloatCollector::add,FloatCollector::join);
            FloatCollector pointsTop = points1.stream()
                    .flatMapToDouble(p->DoubleStream.of(p.getX(),p.getY(),height.get()))
                    .collect(()->new FloatCollector(points1.size()*3),FloatCollector::add,FloatCollector::join);
            pointsBottom.join(pointsTop);
            points0=pointsBottom.toArray();
            numVertices=points0.length/3;

            FloatCollector texBottom = points1.stream()
                    .flatMapToDouble(p->DoubleStream.of((p.getX()-minX)/(maxX-minX),(p.getY()-minY)/(maxY-minY)))
                    .collect(()->new FloatCollector(points1.size()*2),FloatCollector::add,FloatCollector::join);
            FloatCollector texTop = points1.stream()
                    .flatMapToDouble(p->DoubleStream.of((p.getX()-minX)/(maxX-minX),(p.getY()-minY)/(maxY-minY)))
                    .collect(()->new FloatCollector(points1.size()*2),FloatCollector::add,FloatCollector::join);
            texBottom.join(texTop);
            texCoord0=texBottom.toArray();
            numTexCoords=texCoord0.length/2;
            
            texCoord1 = IntStream.range(0, numTexCoords)
                    .mapToObj(i -> new Point2D(texCoord0[2*i], texCoord0[2*i+1]))
                    .collect(Collectors.toList());
            List<int[]> listIndices = tri.stream().map((DelaunayTriangle t)->{
                int[] pIndex=new int[3];
                for(int j=0; j<3; j++){
                    final TriangulationPoint dt = t.points[j];
                    int[] toArray = IntStream.range(0,points1.size())
                            .filter(i->points1.get(i).equals(dt))
                            .toArray();
                    if(toArray.length>0){
                        pIndex[j]=toArray[0];
                    } else {
                        System.out.println("Error "+points1);
                    }
                }
                return pIndex;
            }).collect(Collectors.toList());
            
            // faces
            
            // base
            IntStream streamBottom = listIndices.stream()
                    .map(i->IntStream.of(i[0], i[0], i[2], i[2], i[1], i[1]))
                    .flatMapToInt(i->i);
            // top
            IntStream streamTop = listIndices.stream()
                    .map(i->IntStream.of(numPoints+i[0], numPoints+i[0], numPoints+i[1], numPoints+i[1], numPoints+i[2], numPoints+i[2]))
                    .flatMapToInt(i->i);
            
            // vertical, exterior
            IntStream streamExtWalls = IntStream.range(0, extPoints-1)
                    .mapToObj(i->IntStream.of(i,i,i+1,i+1,i+1+numPoints,i+1+numPoints,
                                              i,i,i+1+numPoints,i+1+numPoints,i+numPoints,i+numPoints))
                    .flatMapToInt(i->i);
            // vertical, exterior, close polygon
            IntStream streamExtWallsClose = IntStream.of(extPoints-1,extPoints-1,0,0,0+numPoints,0+numPoints,
                    extPoints-1,extPoints-1,0+numPoints,0+numPoints,numPoints+extPoints-1,numPoints+extPoints-1);
            if(totalHolePoints>0){
                // vertical, interior
                // holes
                int acuHolePoints0=extPoints+steinerPoints, acuHolePoints1;
                IntStream streamIntWalls=IntStream.empty();
                for(List<PolygonPoint> hole:holes){
                    acuHolePoints1=acuHolePoints0+hole.size()-1;
                    IntStream streamIntWallsHole = IntStream.range(acuHolePoints0, acuHolePoints1)
                            .mapToObj(i->IntStream.of(i,i,i+1+numPoints,i+1+numPoints,i+1,i+1,
                                                      i,i,i+numPoints,i+numPoints,i+1+numPoints,i+1+numPoints))
                            .flatMapToInt(i->i);
                    streamIntWalls=IntStream.concat(streamIntWalls,streamIntWallsHole);
                    acuHolePoints0=acuHolePoints1+1;
                }
                
                // vertical, interior, close holes
                // holes
                acuHolePoints0=extPoints+steinerPoints;
                IntStream streamIntWallsClose=IntStream.empty();
                for(List<PolygonPoint> hole:holes){
                    acuHolePoints1=acuHolePoints0+hole.size()-1;
                    IntStream streamIntWallsCloseHole = IntStream.of(acuHolePoints1,acuHolePoints1,
                            numPoints+acuHolePoints0,numPoints+acuHolePoints0,
                            acuHolePoints0,acuHolePoints0,
                            acuHolePoints1,acuHolePoints1,
                            numPoints+acuHolePoints1,numPoints+acuHolePoints1,
                            numPoints+acuHolePoints0,numPoints+acuHolePoints0);
                    streamIntWallsClose=IntStream.concat(streamIntWallsClose,streamIntWallsCloseHole);
                    acuHolePoints0=acuHolePoints1+1;
                }
                faces0=IntStream.concat(streamBottom, 
                    IntStream.concat(streamTop,IntStream.concat(streamExtWalls,
                        IntStream.concat(streamExtWallsClose,IntStream.concat(streamIntWalls,streamIntWallsClose)
                                )))).toArray(); 
            } else {
                faces0=IntStream.concat(streamBottom, 
                    IntStream.concat(streamTop,IntStream.concat(streamExtWalls,streamExtWallsClose))).toArray();
            }
            
            numFaces=faces0.length/6;
        } else if(m0!=null) {
            points0=new float[numVertices*m0.getPointElementSize()];
            m0.getPoints().toArray(points0);
            texCoord0=new float[numTexCoords*m0.getTexCoordElementSize()];
            m0.getTexCoords().toArray(texCoord0);
            faces0=new int[numFaces*m0.getFaceElementSize()];
            m0.getFaces().toArray(faces0);
        }
        
        List<Point3D> points1 = IntStream.range(0, numVertices)
                        .mapToObj(i -> new Point3D(points0[3*i], points0[3*i+1], points0[3*i+2]))
                        .collect(Collectors.toList());
        
        texCoord1 = IntStream.range(0, numTexCoords)
                    .mapToObj(i -> new Point2D(texCoord0[2*i], texCoord0[2*i+1]))
                    .collect(Collectors.toList());
        
        List<Face3> faces1 = IntStream.range(0, numFaces)
                    .mapToObj(i -> new Face3(faces0[6*i], faces0[6*i+2], faces0[6*i+4]))
                    .collect(Collectors.toList());

        index.set(points1.size());
        map.clear();
        listFaces.clear();
        listVertices.clear();
        listVertices.addAll(points1);

        faces1.forEach(face->{
            int v1=face.p0;
            int v2=face.p1;
            int v3=face.p2;
            if(level>0){
                int a = getMiddle(v1,points1.get(v1),v2,points1.get(v2));
                int b = getMiddle(v2,points1.get(v2),v3,points1.get(v3));
                int c = getMiddle(v3,points1.get(v3),v1,points1.get(v1));

                listFaces.add(new Face3(v1,a,c));
                listFaces.add(new Face3(v2,b,a));
                listFaces.add(new Face3(v3,c,b));
                listFaces.add(new Face3(a,b,c));
            } else {
                listFaces.add(new Face3(v1,v2,v3));
            }
        });
        map.clear();
        numVertices=listVertices.size();
        numFaces=listFaces.size();
         
        List<Face3> textures1 = IntStream.range(0, faces0.length/6)
                    .mapToObj(i -> new Face3(faces0[6*i+1], faces0[6*i+3], faces0[6*i+5]))
                    .collect(Collectors.toList());

        index.set(texCoord1.size());
        listTextures.clear();
        textures1.forEach(face->{
            int v1=face.p0;
            int v2=face.p1;
            int v3=face.p2;
            if(level>0){
                int a = getMiddle(v1,texCoord1.get(v1),v2,texCoord1.get(v2));
                int b = getMiddle(v2,texCoord1.get(v2),v3,texCoord1.get(v3));
                int c = getMiddle(v3,texCoord1.get(v3),v1,texCoord1.get(v1));

                listTextures.add(new Face3(v1,a,c));
                listTextures.add(new Face3(v2,b,a));
                listTextures.add(new Face3(v3,c,b));
                listTextures.add(new Face3(a,b,c));
            } else {
                listTextures.add(new Face3(v1,v2,v3));
            }
        });
        map.clear();

        texCoord0=texCoord1.stream().flatMapToDouble(p->DoubleStream.of(p.getX(),p.getY()))
                .collect(()->new FloatCollector(texCoord1.size()*2), FloatCollector::add, FloatCollector::join).toArray();
        numTexCoords=texCoord0.length/2;
        textureCoords=texCoord0;
        if(level==getLevel()){
            areaMesh.setWidth(maxX-minX);
            areaMesh.setHeight(maxY-minY);
            rectMesh.setWidth((int)Math.sqrt(texCoord0.length));
            rectMesh.setHeight(texCoord0.length/((int)Math.sqrt(texCoord0.length)));
            
            smoothingGroups=getSmoothingGroups(listVertices, listFaces);
        }
        return createMesh();
    }
    
    private final AtomicInteger index = new AtomicInteger();
    private final HashMap<String, Integer> map = new HashMap<>();

    private int getMiddle(int v1, Point3D p1, int v2, Point3D p2){
        String key = ""+Math.min(v1,v2)+"_"+Math.max(v1,v2);
        if(map.get(key)!=null){
            return map.get(key);
        }

        listVertices.add(p1.add(p2).multiply(0.5f));

        map.put(key,index.get());
        return index.getAndIncrement();
    }
    
    private int getMiddle(int v1, Point2D p1, int v2, Point2D p2){
        String key = ""+Math.min(v1,v2)+"_"+Math.max(v1,v2);
        if(map.get(key)!=null){
            return map.get(key);
        }

        texCoord1.add(p1.add(p2).multiply(0.5f));

        map.put(key,index.get());
        return index.getAndIncrement();
    }
    
    private int[] getSmoothingGroups(List<Point3D> points, List<Face3> faces){
        return faces.stream().mapToInt(f->{
                Point3D a = points.get(f.p0);
                Point3D b = points.get(f.p1);
                Point3D c = points.get(f.p2);
                float nz= b.substract(a).crossProduct((c.substract(a))).normalize().z;
                return (nz<-0.99?1:nz>0.99?2:4);
            }).toArray();
    }
    
    
}