// TODO: Move duplicated logic between geometry layrs into GeometryLayer

// TODO: Look at ways to drop unneeded references to array buffers, etc to
// reduce memory footprint

// TODO: Support dynamic updating / hiding / animation of geometry
//
// This could be pretty hard as it's all packed away within BufferGeometry and
// may even be merged by another layer (eg. GeoJSONLayer)
//
// How much control should this layer support? Perhaps a different or custom
// layer would be better suited for animation, for example.

// TODO: Allow _setBufferAttributes to use a custom function passed in to
// generate a custom mesh

import Layer from '../Layer';
import extend from 'lodash.assign';
import THREE from 'three';
import Geo from '../../geo/Geo';
import {latLon as LatLon} from '../../geo/LatLon';
import {point as Point} from '../../geo/Point';
import earcut from 'earcut';
import extrudePolygon from '../../util/extrudePolygon';
import PickingMaterial from '../../engine/PickingMaterial';
import Buffer from '../../util/Buffer';

class PolygonLayer extends Layer {
  constructor(coordinates, options) {
    var defaults = {
      output: true,
      interactive: false,
      // Custom material override
      //
      // TODO: Should this be in the style object?
      polygonMaterial: null,
      onPolygonMesh: null,
      onBufferAttributes: null,
      // This default style is separate to Util.GeoJSON.defaultStyle
      style: {
        color: '#ffffff',
        transparent: false,
        opacity: 1,
        blending: THREE.NormalBlending,
        height: 0
      }
    };

    var _options = extend({}, defaults, options);

    super(_options);

    // Return coordinates as array of polygons so it's easy to support
    // MultiPolygon features (a single polygon would be a MultiPolygon with a
    // single polygon in the array)
    this._coordinates = (PolygonLayer.isSingle(coordinates)) ? [coordinates] : coordinates;
  }

  _onAdd(world) {
    return new Promise((resolve, reject) => {
      this._setCoordinates();

      if (this._options.interactive) {
        // Only add to picking mesh if this layer is controlling output
        //
        // Otherwise, assume another component will eventually add a mesh to
        // the picking scene
        if (this.isOutput()) {
          this._pickingMesh = new THREE.Object3D();
          this.addToPicking(this._pickingMesh);
        }

        this._setPickingId();
        this._addPickingEvents();
      }

      PolygonLayer.SetBufferAttributes(this._projectedCoordinates, this._options).then((result) => {
        this._bufferAttributes = Buffer.mergeAttributes(result.attributes);

        if (result.outlineAttributes.length > 0) {
          this._outlineBufferAttributes = Buffer.mergeAttributes(result.outlineAttributes);
        }

        this._flat = result.flat;

        if (this.isOutput()) {
          var attributeLengths = {
            positions: 3,
            normals: 3,
            colors: 3,
            tops: 1
          };

          if (this._options.interactive) {
            attributeLengths.pickingIds = 1;
          }

          var style = this._options.style;

          // Set mesh if not merging elsewhere
          PolygonLayer.SetMesh(this._bufferAttributes, attributeLengths, this._flat, style, this._options, this._world._environment._skybox).then((result) => {
            // Output mesh
            this.add(result.mesh);

            if (result.pickingMesh) {
              this._pickingMesh.add(result.pickingMesh);
            }
          });
        }

        result.attributes = null;
        result.outlineAttributes = null;
        result = null;

        resolve(this);
      }).catch(reject);
    });
  }

  // Return center of polygon as a LatLon
  //
  // This is used for things like placing popups / UI elements on the layer
  //
  // TODO: Find proper center position instead of returning first coordinate
  // SEE: https://github.com/Leaflet/Leaflet/blob/master/src/layer/vector/Polygon.js#L15
  getCenter() {
    return this._center;
  }

  // Return polygon bounds in geographic coordinates
  //
  // TODO: Implement getBounds()
  getBounds() {}

  // Get unique ID for picking interaction
  _setPickingId() {
    this._pickingId = this.getPickingId();
  }

  // Set up and re-emit interaction events
  _addPickingEvents() {
    // TODO: Find a way to properly remove this listener on destroy
    this._world.on('pick-' + this._pickingId, (point2d, point3d, intersects) => {
      // Re-emit click event from the layer
      this.emit('click', this, point2d, point3d, intersects);
    });
  }

  // Create and store reference to THREE.BufferAttribute data for this layer
  static SetBufferAttributes(coordinates, options) {
    return new Promise((resolve) => {
      var height = 0;

      // Convert height into world units
      if (options.style.height && options.style.height !== 0) {
        height = Geo.metresToWorld(options.style.height, options.pointScale);
      }

      var colour = new THREE.Color();
      colour.set(options.style.color);

      // Light and dark colours used for poor-mans AO gradient on object sides
      var light = new THREE.Color(0xffffff);
      var shadow  = new THREE.Color(0x666666);

      var flat = true;

      var outlineAttributes = [];

      // For each polygon
      var attributes = coordinates.map(_coordinates => {
        // Convert coordinates to earcut format
        var _earcut = PolygonLayer.ToEarcut(_coordinates);

        // Triangulate faces using earcut
        var faces = PolygonLayer.Triangulate(_earcut.vertices, _earcut.holes, _earcut.dimensions);

        var groupedVertices = [];
        for (i = 0, il = _earcut.vertices.length; i < il; i += _earcut.dimensions) {
          groupedVertices.push(_earcut.vertices.slice(i, i + _earcut.dimensions));
        }

        var extruded = extrudePolygon(groupedVertices, faces, {
          bottom: 0,
          top: height
        });

        var topColor = colour.clone().multiply(light);
        var bottomColor = colour.clone().multiply(shadow);

        var _vertices = extruded.positions;
        var _faces = [];
        var _colours = [];
        var _tops = [];

        var _colour;
        extruded.top.forEach((face, fi) => {
          _colour = [];

          _colour.push([colour.r, colour.g, colour.b]);
          _colour.push([colour.r, colour.g, colour.b]);
          _colour.push([colour.r, colour.g, colour.b]);

          _tops.push([true, true, true]);

          _faces.push(face);
          _colours.push(_colour);
        });

        if (extruded.sides) {
          flat = false;

          // Set up colours for every vertex with poor-mans AO on the sides
          extruded.sides.forEach((face, fi) => {
            _colour = [];

            // First face is always bottom-bottom-top
            if (fi % 2 === 0) {
              _colour.push([bottomColor.r, bottomColor.g, bottomColor.b]);
              _colour.push([bottomColor.r, bottomColor.g, bottomColor.b]);
              _colour.push([topColor.r, topColor.g, topColor.b]);

              _tops.push([false, false, true]);
            // Reverse winding for the second face
            // top-top-bottom
            } else {
              _colour.push([topColor.r, topColor.g, topColor.b]);
              _colour.push([topColor.r, topColor.g, topColor.b]);
              _colour.push([bottomColor.r, bottomColor.g, bottomColor.b]);

              _tops.push([true, true, false]);
            }

            _faces.push(face);
            _colours.push(_colour);
          });
        }

        // Skip bottom as there's no point rendering it
        // allFaces.push(extruded.faces);

        var polygon = {
          vertices: _vertices,
          faces: _faces,
          colours: _colours,
          tops: _tops,
          facesCount: _faces.length
        };

        if (options.style.outline) {
          var outlineColour = new THREE.Color();
          outlineColour.set(options.style.outlineColor || 0x000000);

          outlineAttributes.push(PolygonLayer.Set2DOutline(_coordinates, outlineColour));
        }

        if (options.interactive && options.pickingId) {
          // Inject picking ID
          polygon.pickingId = options.pickingId;
        }

        // Convert polygon representation to proper attribute arrays
        return PolygonLayer.ToAttributes(polygon);
      });

      resolve({
        attributes: attributes,
        outlineAttributes: outlineAttributes,
        flat: flat
      });
    });
  }

  getBufferAttributes() {
    return this._bufferAttributes;
  }

  getOutlineBufferAttributes() {
    return this._outlineBufferAttributes;
  }

  // Used by external components to clear some memory when the attributes
  // are no longer required to be stored in this layer
  //
  // For example, you would want to clear the attributes here after merging them
  // using something like the GeoJSONLayer
  clearBufferAttributes() {
    this._bufferAttributes = null;
    this._outlineBufferAttributes = null;
  }

  // Threshold angle is currently in rads
  static Set2DOutline(coordinates, colour) {
    var _vertices = [];

    coordinates.forEach((ring) => {
      var _ring = ring.map((coordinate) => {
        return [coordinate.x, 0, coordinate.y];
      });

      // Add in duplicate vertices for line segments to work
      var verticeCount = _ring.length;
      var first = true;
      while (--verticeCount) {
        if (first || verticeCount === 0) {
          first = false;
          continue;
        }

        _ring.splice(verticeCount + 1, 0, _ring[verticeCount]);
      }

      _vertices = _vertices.concat(_ring);
    });

    _colour = [colour.r, colour.g, colour.b];

    var vertices = new Float32Array(_vertices.length * 3);
    var colours = new Float32Array(_vertices.length * 3);

    var lastIndex = 0;

    for (var i = 0; i < _vertices.length; i++) {
      var ax = _vertices[i][0];
      var ay = _vertices[i][1];
      var az = _vertices[i][2];

      var c1 = _colour;

      vertices[lastIndex * 3 + 0] = ax;
      vertices[lastIndex * 3 + 1] = ay;
      vertices[lastIndex * 3 + 2] = az;

      colours[lastIndex * 3 + 0] = c1[0];
      colours[lastIndex * 3 + 1] = c1[1];
      colours[lastIndex * 3 + 2] = c1[2];

      lastIndex++;
    }

    var attributes = {
      positions: vertices,
      colors: colours
    };

    return attributes;
  }

  // Used by external components to clear some memory when the coordinates
  // are no longer required to be stored in this layer
  //
  // For example, you would want to clear the coordinates here after this
  // layer is merged in something like the GeoJSONLayer
  clearCoordinates() {
    this._coordinates = null;
    this._projectedCoordinates = null;
  }

  static SetMesh(attributes, attributeLengths, flat, style, options, skybox) {
    var geometry = new THREE.BufferGeometry();

    for (var key in attributes) {
      geometry.addAttribute(key.slice(0, -1), new THREE.BufferAttribute(attributes[key], attributeLengths[key]));
    }

    geometry.computeBoundingBox();

    var material;
    if (options.polygonMaterial && options.polygonMaterial instanceof THREE.Material) {
      material = options.polygonMaterial;
    } else if (!skybox) {
      material = new THREE.MeshPhongMaterial({
        vertexColors: THREE.VertexColors,
        side: THREE.BackSide,
        transparent: style.transparent,
        opacity: style.opacity,
        blending: style.blending
      });
    } else {
      material = new THREE.MeshStandardMaterial({
        vertexColors: THREE.VertexColors,
        side: THREE.BackSide,
        transparent: style.transparent,
        opacity: style.opacity,
        blending: style.blending
      });
      material.roughness = 1;
      material.metalness = 0.1;
      material.envMapIntensity = 3;
      material.envMap = skybox.getRenderTarget();
    }

    var mesh;

    // Pass mesh through callback, if defined
    if (typeof options.onPolygonMesh === 'function') {
      mesh = options.onPolygonMesh(geometry, material);
    } else {
      mesh = new THREE.Mesh(geometry, material);

      mesh.castShadow = true;
      mesh.receiveShadow = true;
    }

    if (flat || style.renderOrder !== undefined) {
      if (!style.ignoreDepth) {
        material.depthWrite = false;
      }

      var renderOrder = (style.renderOrder !== undefined) ? style.renderOrder : 3;
      mesh.renderOrder = renderOrder;
    }

    if (options.interactive) {
      material = new PickingMaterial();
      material.side = THREE.BackSide;

      var pickingMesh = new THREE.Mesh(geometry, material);
    }

    return Promise.resolve({
      mesh: mesh,
      pickingMesh: pickingMesh
    });
  }

  // Convert and project coordinates
  //
  // TODO: Calculate bounds
  _setCoordinates() {
    this._bounds = [];
    this._coordinates = this._convertCoordinates(this._coordinates);

    this._projectedBounds = [];
    this._projectedCoordinates = this._projectCoordinates();

    this._center = this._coordinates[0][0][0];
  }

  // Recursively convert input coordinates into LatLon objects
  //
  // Calculate geographic bounds at the same time
  //
  // TODO: Calculate geographic bounds
  _convertCoordinates(coordinates) {
    return coordinates.map(_coordinates => {
      return _coordinates.map(ring => {
        return ring.map(coordinate => {
          return LatLon(coordinate[1], coordinate[0]);
        });
      });
    });
  }

  // Recursively project coordinates into world positions
  //
  // Calculate world bounds, offset and pointScale at the same time
  //
  // TODO: Calculate world bounds
  _projectCoordinates() {
    var point;
    return this._coordinates.map(_coordinates => {
      return _coordinates.map(ring => {
        return ring.map(latlon => {
          point = this._world.latLonToPoint(latlon);

          // TODO: Is offset ever being used or needed?
          if (!this._offset) {
            this._offset = Point(0, 0);
            this._offset.x = -1 * point.x;
            this._offset.y = -1 * point.y;

            this._options.pointScale = this._world.pointScale(latlon);
          }

          return point;
        });
      });
    });
  }

  // Convert coordinates array to something earcut can understand
  static ToEarcut(coordinates) {
    var dim = 2;
    var result = {vertices: [], holes: [], dimensions: dim};
    var holeIndex = 0;

    for (var i = 0; i < coordinates.length; i++) {
      for (var j = 0; j < coordinates[i].length; j++) {
        // for (var d = 0; d < dim; d++) {
        result.vertices.push(coordinates[i][j].x);
        result.vertices.push(coordinates[i][j].y);
        // }
      }
      if (i > 0) {
        holeIndex += coordinates[i - 1].length;
        result.holes.push(holeIndex);
      }
    }

    return result;
  }

  // Triangulate earcut-based input using earcut
  static Triangulate(contour, holes, dim) {
    // console.time('earcut');

    var faces = earcut(contour, holes, dim);
    var result = [];

    for (i = 0, il = faces.length; i < il; i += 3) {
      result.push(faces.slice(i, i + 3));
    }

    // console.timeEnd('earcut');

    return result;
  }

  // Transform polygon representation into attribute arrays that can be used by
  // THREE.BufferGeometry
  //
  // TODO: Can this be simplified? It's messy and huge
  static ToAttributes(polygon) {
    // Three components per vertex per face (3 x 3 = 9)
    var positions = new Float32Array(polygon.facesCount * 9);
    var normals = new Float32Array(polygon.facesCount * 9);
    var colours = new Float32Array(polygon.facesCount * 9);

    // One component per vertex per face (1 x 3 = 3)
    var tops = new Float32Array(polygon.facesCount * 3);

    var pickingIds;
    if (polygon.pickingId) {
      // One component per vertex per face (1 x 3 = 3)
      pickingIds = new Float32Array(polygon.facesCount * 3);
    }

    var pA = new THREE.Vector3();
    var pB = new THREE.Vector3();
    var pC = new THREE.Vector3();

    var cb = new THREE.Vector3();
    var ab = new THREE.Vector3();

    var index;

    var _faces = polygon.faces;
    var _vertices = polygon.vertices;
    var _colour = polygon.colours;
    var _tops = polygon.tops;

    var _pickingId;
    if (pickingIds) {
      _pickingId = polygon.pickingId;
    }

    var lastIndex = 0;

    for (var i = 0; i < _faces.length; i++) {
      // Array of vertex indexes for the face
      index = _faces[i][0];

      var ax = _vertices[index][0];
      var ay = _vertices[index][1];
      var az = _vertices[index][2];

      var c1 = _colour[i][0];
      var t1 = _tops[i][0];

      index = _faces[i][1];

      var bx = _vertices[index][0];
      var by = _vertices[index][1];
      var bz = _vertices[index][2];

      var c2 = _colour[i][1];
      var t2 = _tops[i][1];

      index = _faces[i][2];

      var cx = _vertices[index][0];
      var cy = _vertices[index][1];
      var cz = _vertices[index][2];

      var c3 = _colour[i][2];
      var t3 = _tops[i][2];

      // Flat face normals
      // From: http://threejs.org/examples/webgl_buffergeometry.html
      pA.set(ax, ay, az);
      pB.set(bx, by, bz);
      pC.set(cx, cy, cz);

      cb.subVectors(pC, pB);
      ab.subVectors(pA, pB);
      cb.cross(ab);

      cb.normalize();

      var nx = cb.x;
      var ny = cb.y;
      var nz = cb.z;

      positions[lastIndex * 9 + 0] = ax;
      positions[lastIndex * 9 + 1] = ay;
      positions[lastIndex * 9 + 2] = az;

      normals[lastIndex * 9 + 0] = nx;
      normals[lastIndex * 9 + 1] = ny;
      normals[lastIndex * 9 + 2] = nz;

      colours[lastIndex * 9 + 0] = c1[0];
      colours[lastIndex * 9 + 1] = c1[1];
      colours[lastIndex * 9 + 2] = c1[2];

      positions[lastIndex * 9 + 3] = bx;
      positions[lastIndex * 9 + 4] = by;
      positions[lastIndex * 9 + 5] = bz;

      normals[lastIndex * 9 + 3] = nx;
      normals[lastIndex * 9 + 4] = ny;
      normals[lastIndex * 9 + 5] = nz;

      colours[lastIndex * 9 + 3] = c2[0];
      colours[lastIndex * 9 + 4] = c2[1];
      colours[lastIndex * 9 + 5] = c2[2];

      positions[lastIndex * 9 + 6] = cx;
      positions[lastIndex * 9 + 7] = cy;
      positions[lastIndex * 9 + 8] = cz;

      normals[lastIndex * 9 + 6] = nx;
      normals[lastIndex * 9 + 7] = ny;
      normals[lastIndex * 9 + 8] = nz;

      colours[lastIndex * 9 + 6] = c3[0];
      colours[lastIndex * 9 + 7] = c3[1];
      colours[lastIndex * 9 + 8] = c3[2];

      tops[lastIndex * 3 + 0] = t1;
      tops[lastIndex * 3 + 1] = t2;
      tops[lastIndex * 3 + 2] = t3;

      if (pickingIds) {
        pickingIds[lastIndex * 3 + 0] = _pickingId;
        pickingIds[lastIndex * 3 + 1] = _pickingId;
        pickingIds[lastIndex * 3 + 2] = _pickingId;
      }

      lastIndex++;
    }

    var attributes = {
      positions: positions,
      normals: normals,
      colors: colours,
      tops: tops
    };

    if (pickingIds) {
      attributes.pickingIds = pickingIds;
    }

    return attributes;
  }

  // Returns true if the polygon is flat (has no height)
  isFlat() {
    return this._flat;
  }

  // Returns true if coordinates refer to a single geometry
  //
  // For example, not coordinates for a MultiPolygon GeoJSON feature
  static isSingle(coordinates) {
    return !Array.isArray(coordinates[0][0][0]);
  }

  // TODO: Make sure this is cleaning everything
  destroy() {
    if (this._pickingMesh) {
      // TODO: Properly dispose of picking mesh
      this._pickingMesh = null;
    }

    this.clearCoordinates();
    this.clearBufferAttributes();

    // Run common destruction logic from parent
    super.destroy();
  }
}

export default PolygonLayer;

var noNew = function(coordinates, options) {
  return new PolygonLayer(coordinates, options);
};

export {noNew as polygonLayer};
