/*******************************************************************************
 * Copyright (c) 2015 VoyagerSearch and others
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Apache License, Version 2.0 which
 * accompanies this distribution and is available at
 *    http://www.apache.org/licenses/LICENSE-2.0.txt
 ******************************************************************************/

package org.locationtech.spatial4j.io;

import org.locationtech.spatial4j.context.SpatialContext;
import org.locationtech.spatial4j.context.SpatialContextFactory;
import org.locationtech.spatial4j.distance.DistanceUtils;
import org.locationtech.spatial4j.exception.InvalidShapeException;
import org.locationtech.spatial4j.shape.Circle;
import org.locationtech.spatial4j.shape.Point;
import org.locationtech.spatial4j.shape.Shape;
import org.locationtech.spatial4j.shape.ShapeFactory;
import org.noggit.JSONParser;

import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.List;

public class GeoJSONReader implements ShapeReader {

  protected static final String BUFFER = "buffer";
  protected static final String BUFFER_UNITS = "buffer_units";

  protected final SpatialContext ctx;
  protected final ShapeFactory shapeFactory;

  public GeoJSONReader(SpatialContext ctx, SpatialContextFactory factory) {
    this.ctx = ctx;
    this.shapeFactory = ctx.getShapeFactory();
  }

  @Override
  public String getFormatName() {
    return ShapeIO.GeoJSON;
  }

  @Override
  public final Shape read(Reader reader) throws IOException, ParseException {
    return readShape(new JSONParser(reader));
  }

  @Override
  public Shape read(Object value) throws IOException, ParseException, InvalidShapeException {
    String v = value.toString().trim();
    return read(new StringReader(v));
  }

  @Override
  public Shape readIfSupported(Object value) throws InvalidShapeException {
    String v = value.toString().trim();
    if (!(v.startsWith("{") && v.endsWith("}"))) {
      return null;
    }
    try {
      return read(new StringReader(v));
    } catch (IOException | ParseException ex) {
    }
    return null;
  }

  // --------------------------------------------------------------
  // Read GeoJSON
  // --------------------------------------------------------------


  protected void readCoordXYZ(JSONParser parser, ShapeFactory.PointsBuilder pointsBuilder) throws IOException, ParseException {
    assert (parser.lastEvent() == JSONParser.ARRAY_START);

    double x = Double.NaN, y = Double.NaN, z = Double.NaN;
    int idx = 0;

    int evt = parser.nextEvent();
    while (evt != JSONParser.EOF) {
      switch (evt) {
        case JSONParser.LONG:
        case JSONParser.NUMBER:
        case JSONParser.BIGNUMBER:
          double value = parser.getDouble();
          switch(idx) {
            case 0: x = value; break;
            case 1: y = value; break;
            case 2: z = value; break;
          }
          idx++;
          break;

        case JSONParser.ARRAY_END:
          if (idx <= 2) { // don't have a 'z'
            pointsBuilder.pointXY(shapeFactory.normX(x), shapeFactory.normY(y));
          } else {
            pointsBuilder.pointXYZ(shapeFactory.normX(x), shapeFactory.normY(y), shapeFactory.normZ(z));
          }
          return;

        case JSONParser.STRING:
        case JSONParser.BOOLEAN:
        case JSONParser.NULL:
        case JSONParser.OBJECT_START:
        case JSONParser.OBJECT_END:
        case JSONParser.ARRAY_START:
        default:
          throw new ParseException("Unexpected " + JSONParser.getEventString(evt),
              (int) parser.getPosition());
      }
      evt = parser.nextEvent();
    }
  }

  protected void readCoordListXYZ(JSONParser parser, ShapeFactory.PointsBuilder pointsBuilder) throws IOException, ParseException {
    assert (parser.lastEvent() == JSONParser.ARRAY_START);

    int evt = parser.nextEvent();
    while (evt != JSONParser.EOF) {
      switch (evt) {
        case JSONParser.ARRAY_START:
          readCoordXYZ(parser, pointsBuilder); // reads until ARRAY_END
          break;

        case JSONParser.ARRAY_END:
          return;

        default:
          throw new ParseException("Unexpected " + JSONParser.getEventString(evt),
              (int) parser.getPosition());
      }
      evt = parser.nextEvent();
    }
  }

  protected void readUntilEvent(JSONParser parser, final int event) throws IOException {
    int evt = parser.lastEvent();
    while (true) {
      if (evt == event || evt == JSONParser.EOF) {
        return;
      }
      evt = parser.nextEvent();
    }
  }

  protected Shape readPoint(JSONParser parser) throws IOException, ParseException {
    assert (parser.lastEvent() == JSONParser.ARRAY_START);
    OnePointsBuilder onePointsBuilder = new OnePointsBuilder(shapeFactory);
    readCoordXYZ(parser, onePointsBuilder);
    Point point = onePointsBuilder.getPoint();
    readUntilEvent(parser, JSONParser.OBJECT_END);
    return point;
  }

  protected Shape readLineString(JSONParser parser) throws IOException, ParseException {
    assert (parser.lastEvent() == JSONParser.ARRAY_START);
    ShapeFactory.LineStringBuilder builder = shapeFactory.lineString();
    readCoordListXYZ(parser, builder);

    // check for buffer field
    builder.buffer(readDistance(BUFFER, BUFFER_UNITS, parser));

    Shape out = builder.build();
    readUntilEvent(parser, JSONParser.OBJECT_END);
    return out;
  }

  protected Circle readCircle(JSONParser parser) throws IOException, ParseException {
    assert (parser.lastEvent() == JSONParser.ARRAY_START);
    OnePointsBuilder onePointsBuilder = new OnePointsBuilder(shapeFactory);
    readCoordXYZ(parser, onePointsBuilder);
    Point point = onePointsBuilder.getPoint();

    return shapeFactory.circle(point, readDistance("radius", "radius_units", parser));
  }

  /**
   * Helper method to read a up until a distance value (radius, buffer) and it's corresponding unit are found.
   * <p>
   * This method returns 0 if no distance value is found. This method currently only handles distance units of "km".
   * </p>
   * @param distProperty The name of the property containing the distance value.
   * @param distUnitsProperty The name of the property containing the distance unit. 
   */
  protected double readDistance(String distProperty, String distUnitsProperty, JSONParser parser) throws IOException {
    double dist = 0;

    String key = null;

    int event = JSONParser.OBJECT_END;
    int evt = parser.lastEvent();
    while (true) {
      if (evt == event || evt == JSONParser.EOF) {
        break;
      }
      evt = parser.nextEvent();
      if(parser.wasKey()) {
        key = parser.getString();
      }
      else if(evt==JSONParser.NUMBER || evt==JSONParser.LONG) {
        if(distProperty.equals(key)) {
          dist = parser.getDouble();
        }
      }
      else if(evt==JSONParser.STRING) {
        if(distUnitsProperty.equals(key)) {
          String units = parser.getString();
          //TODO: support for more units?
          if("km".equals(units)) {
            // Convert KM to degrees
            dist =
                DistanceUtils.dist2Degrees(dist, DistanceUtils.EARTH_MEAN_RADIUS_KM);
          }
        }
      }
    }

    return shapeFactory.normDist(dist);
  }

  protected Shape readShape(JSONParser parser) throws IOException, ParseException {
    String type = null;

    String key = null;
    int evt = parser.nextEvent();
    while (evt != JSONParser.EOF) {
      switch (evt) {
        case JSONParser.STRING:
          if (parser.wasKey()) {
            key = parser.getString();
          } else {
            if ("type".equals(key)) {
              type = parser.getString();
            } else {
              throw new ParseException("Unexpected String Value for key: " + key,
                      (int) parser.getPosition());
            }
          }
          break;

        case JSONParser.ARRAY_START:
          if ("coordinates".equals(key)) {
            Shape shape = readShapeFromCoordinates(type, parser);
            readUntilEvent(parser, JSONParser.OBJECT_END);
            return shape;
          } else if ("geometries".equals(key)) {
            List<Shape> shapes = new ArrayList<>();
            int sub = parser.nextEvent();
            while (sub != JSONParser.EOF) {
              if (sub == JSONParser.OBJECT_START) {
                Shape s = readShape(parser);
                if (s != null) {
                  shapes.add(s);
                }
              } else if (sub == JSONParser.OBJECT_END) {
                break;
              }
              sub = parser.nextEvent();
            }
            return ctx.makeCollection(shapes);
          }
          else {
            throw new ParseException("Unknown type: "+type,
                (int) parser.getPosition());
          }

        case JSONParser.ARRAY_END:
          break;

        case JSONParser.OBJECT_START:
          if (key != null) {
           // System.out.println("Unexpected object: " + key);
          }
          break;

        case JSONParser.LONG:
        case JSONParser.NUMBER:
        case JSONParser.BIGNUMBER:
        case JSONParser.BOOLEAN:
        case JSONParser.NULL:
        case JSONParser.OBJECT_END:
         // System.out.println(">>>>>" + JSONParser.getEventString(evt) + " :: " + key);
          break;

        default:
          throw new ParseException("Unexpected " + JSONParser.getEventString(evt),
              (int) parser.getPosition());
      }
      evt = parser.nextEvent();
    }
    throw new RuntimeException("unable to parse shape");
  }

  protected Shape readShapeFromCoordinates(String type, JSONParser parser) throws IOException, ParseException {
    switch(type) {
      case "Point":
        return readPoint(parser);
      case "LineString":
        return readLineString(parser);
      case "Circle":
        return readCircle(parser);
      case "Polygon":
        return readPolygon(parser, shapeFactory.polygon()).buildOrRect();
      case "MultiPoint":
        return readMultiPoint(parser);
      case "MultiLineString":
        return readMultiLineString(parser);
      case "MultiPolygon":
        return readMultiPolygon(parser);
      default:
        throw new ParseException("Unable to make shape type: " + type,
                (int) parser.getPosition());
    }
  }

  protected ShapeFactory.PolygonBuilder readPolygon(JSONParser parser, ShapeFactory.PolygonBuilder polygonBuilder) throws IOException, ParseException {
    assert (parser.lastEvent() == JSONParser.ARRAY_START);
    boolean firstRing = true;
    int evt = parser.nextEvent();
    while (true) {
      switch (evt) {
        case JSONParser.ARRAY_START:
          if (firstRing) {
            readCoordListXYZ(parser, polygonBuilder);
            firstRing = false;
          } else {
            ShapeFactory.PolygonBuilder.HoleBuilder holeBuilder = polygonBuilder.hole();
            readCoordListXYZ(parser, holeBuilder);
            holeBuilder.endHole();
          }
          break;
        case JSONParser.ARRAY_END:
          return polygonBuilder;
        default:
          throw new ParseException("Unexpected " + JSONParser.getEventString(evt),
                  (int) parser.getPosition());
      }
      evt = parser.nextEvent();
    }
  }

  protected Shape readMultiPoint(JSONParser parser) throws IOException, ParseException {
    assert (parser.lastEvent() == JSONParser.ARRAY_START);
    ShapeFactory.MultiPointBuilder builder = shapeFactory.multiPoint();
    readCoordListXYZ(parser, builder);
    return builder.build();
  }

  protected Shape readMultiLineString(JSONParser parser) throws IOException, ParseException {
    assert (parser.lastEvent() == JSONParser.ARRAY_START);
    // TODO need Spatial4j LineString interface
    ShapeFactory.MultiLineStringBuilder builder = shapeFactory.multiLineString();
    int evt = parser.nextEvent();
    while (true) {
      switch (evt) {
        case JSONParser.ARRAY_START:
          ShapeFactory.LineStringBuilder lineStringBuilder = builder.lineString();
          readCoordListXYZ(parser, lineStringBuilder);
          builder.add(lineStringBuilder);
          break;
        case JSONParser.ARRAY_END:
          return builder.build();
        default:
          throw new ParseException("Unexpected " + JSONParser.getEventString(evt),
                  (int) parser.getPosition());
      }
      evt = parser.nextEvent();
    }
  }

  protected Shape readMultiPolygon(JSONParser parser) throws IOException, ParseException {
    assert (parser.lastEvent() == JSONParser.ARRAY_START);
    // TODO need Spatial4j Polygon interface
    ShapeFactory.MultiPolygonBuilder builder = shapeFactory.multiPolygon();
    int evt = parser.nextEvent();
    while (true) {
      switch (evt) {
        case JSONParser.ARRAY_START:
          ShapeFactory.PolygonBuilder polygonBuilder = readPolygon(parser, builder.polygon());
          builder.add(polygonBuilder);
          break;
        case JSONParser.ARRAY_END:
          return builder.build();
        default:
          throw new ParseException("Unexpected " + JSONParser.getEventString(evt),
                  (int) parser.getPosition());
      }
      evt = parser.nextEvent();
    }
  }
}
