package nxm.sys.lib;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import nxm.sys.inc.InternalUseOnly;
import nxm.sys.inc.MapKBT;
import nxm.sys.inc.MidasReference;
import nxm.sys.inc.PlotFile;
import nxm.sys.lib.GeoJSONObjects.GeoJSONGroup;

/** Loads a GeoJSON File
 *  - GeoJSONObjects are created for each Point, LineString, and Polygon
 *  - GeometryCollections, MiltPoints, MultiLineStrings, and MultiPolygons
 *    are handled by associated the components via a GeoJSONGroup
 *
 *  @author Jeanette McCall
 */
@InternalUseOnly("Experimental Prototype in NeXtMidas 4.1.3")
public final class GeoJSONFile extends TextFile implements GeoJSONFileModSwap, PlotFile {

  /** Holds all the GeoJSON objects loaded from the file */
  private GeoJSONObjects jsonObjects= new GeoJSONObjects();
  /** Holds the entire GeopJSON in MApKBT form */
  private MapKBT geoJSONMap = null;

  /** try to find shapes in regular JSON files that have type and coordinates */
  private boolean enableJSONSearch = false;

  //////////////////////////////////////////
  // ------ AUTO NAMING SUPPORT VALUES -----
  //////////////////////////////////////////

  // counters used in auto-naming Features
  private int unnamedPointCnt = 0;
  private int unnamedLineStringCnt = 0;
  private int unnamedPolygonCnt = 0;
  private int unnamedGeoCollection = 0;

  private static final Table nameMatch =
      new Table("{\"Point\"=PNT_,\"LineString\"=LSTR_,\"Polygon\"=POLY_,"
          + "\"MultiPoint\"=PNT_,\"MultiLineString\"=LSTR_,\"MultiPolygon\"=POLY_}",
          Table.CASESENSITIVE);


  /** Items in MultiPolygons, MultiPoints, MultiLineStrings, and GeometryColllections
   *  have an extra letter added to their name to keep them as individually identifiable
   *  objects within the group
   */
  private static final int MULTI_START_LETTER = (byte)'a';

  ///////////////////////////////////////////////////////////////
  // Values required by PlotFile methods, but not used internally
  ///////////////////////////////////////////////////////////////
  private double xstart   = 0;
  private double xdelta   = 1;
  private double ystart   = 0;
  private double ydelta   = 1;
  private double timecode = 0;
  private int    xunits   = 0;
  private int    xframe   = 1;
  private int    yunits   = 0;
  private int    yframe   = 1;
  private String format   = "UNK";


  /** GeoJSONFile Default Constructor */
  public GeoJSONFile () { }

  /** GeoJSONFile Constructor
      @param filename name of GeoJSONFile as a String
   */
  public GeoJSONFile (String filename) {
    init(null,filename);
  }

  /** GeoJSONFile Constructor
      @param fn name of GeoJSONFile as a NeXtMidas FileName object
   */
  public GeoJSONFile (FileName fn) {
    init (null,fn);
  }

  /** Constructor
   *  @param ref      Midas Reference
   *  @param filename file name of GeoJSON file
   *  @since 3.5.0
   */
  public GeoJSONFile (MidasReference ref, Object filename) {
    init(ref,filename);
  }

  /** Get GeoJSONObjects
   *  @return GeoJSONObjects
   */
  public GeoJSONObjects getGeoJSONObjects () {
    return jsonObjects;
  }

  @SuppressWarnings("rawtypes")
  public boolean open () {
    super.open();
    geoJSONMap = JsonUtilities.readJsonFile(this);
    String outerType = geoJSONMap.getS("type");
    if (outerType != null) {
      switch (outerType) {
        case "Feature":
          addFeature(geoJSONMap);
          break;
        case "FeatureCollection":
          List<Object> features = geoJSONMap.getList("features");
          for (Object featObj : features) {
            if (featObj instanceof Map) {
              addFeature((Map)featObj);
            }
            else {
              Shell.warning("Skipping unsupported GeoJSON feature entry that is not a Map:"+featObj);
            }
          }
          break;
        case "Topology":
          throw new MidasException("TopoJSON is not currently supported");
        default:
          if (enableJSONSearch) {
            Object coorObj = geoJSONMap.get("coordinates");
            if ((coorObj != null) && (coorObj instanceof ArrayList)) {
              addFeature (outerType, null, (ArrayList)coorObj);
            }
          }
          else Shell.warning("Unsupported GeoJSON type: "+outerType);
      }
    }
    if (enableJSONSearch) trySupportNonStandardGeoJSON(geoJSONMap);
    return true;
  }

  /** High level method that splits off GeometryCollection handling
   *  from the other Feature handling
   *
   *  @param featureMap Map with GeoJSON Feature
   */
  @SuppressWarnings("rawtypes")
  private void addFeature (Map featureMap) {

    // get the feature's properties, if they exist
    Object propertiesObj = featureMap.get("properties");
    Map properties =  ((propertiesObj==null) || !(propertiesObj instanceof Map)) ? null : (Map) propertiesObj;

    Object geometry = featureMap.get("geometry");
    if (geometry instanceof Map) {
      String type = ((Map)geometry).get("type").toString();
      if ("GeometryCollection".equals(type)) {
        addGeometries ((Map)geometry, properties);
      }
      else {
        ArrayList coordinates = getCoordinates((Map)geometry);
        addFeature(type, properties, coordinates);
      }
    }
  }


  @SuppressWarnings("rawtypes")
  private void addFeature (String type, Map properties, ArrayList coordinates) {
    addFeature (type, properties, coordinates, null);
  }

  /** Add the Feature as a GeoJSONObject, with the subclass based on Feature type
   *
   *  @param type          Name of GeoJSON type
   *  @param properties    properties
   *  @param coordinates   ArrayList with coordinates
   *  @param geomCollGroup null, unless this is a peice of a GeometryCollection
   */
  @SuppressWarnings("rawtypes")
  private void addFeature (String type, Map properties, ArrayList coordinates, GeoJSONGroup geomCollGroup) {
    ArrayListFacts facts = getArrayListFacts(coordinates);
    //System.out.println("type:"+type+" facts:"+facts+" coords:"+coordinates);
    double[] flatCoords;

    String name = (properties!=null) ?
        properties.getOrDefault("name", "").toString() : "";
    switch (type) {
      case "Point":
        if (name=="") name = nameMatch.get(type).toString()+unnamedPointCnt++;
        flatCoords = getFlatArray(coordinates, facts);
        jsonObjects.addGeoJSONPoint(name, flatCoords, facts.atomsPerElem, properties, geomCollGroup);
        break;
      case "LineString":
        if (name=="") name = nameMatch.get(type).toString()+unnamedLineStringCnt++;
        flatCoords = getFlatArray(coordinates, facts);
        jsonObjects.addGeoJSONLineString(name, flatCoords, facts.atomsPerElem, properties, geomCollGroup);
        break;
      case "Polygon":
        if (name=="") name = nameMatch.get(type).toString()+unnamedPolygonCnt++;
        addPolygon(name,coordinates,facts,properties,geomCollGroup);
        break;
      case "MultiPoint":
        if (name=="") name = nameMatch.get(type).toString()+unnamedPointCnt++;
        if (facts.dimension==1) { // just 1 point
          flatCoords = getFlatArray(coordinates, facts);
          jsonObjects.addGeoJSONPoint(name, flatCoords, facts.atomsPerElem, properties, null);
        }
        else {
          GeoJSONGroup group = new GeoJSONObjects.GeoJSONGroup(name,"GeoJSON"+type);
          for (int set = 0; set < facts.dimension; set++) {
            ArrayListFacts modFacts = new ArrayListFacts(facts);
            modFacts.dimension = 1;
            modFacts.numSets = facts.dimension;
            modFacts.numElements = facts.numElements / modFacts.numSets;
            String modName = name + (char)(MULTI_START_LETTER+set);
            flatCoords = getFlatArray((ArrayList)coordinates.get(set), modFacts);
            jsonObjects.addGeoJSONPoint(modName, flatCoords, modFacts.atomsPerElem, properties, group);
          }
        }
        break;
      case "MultiLineString":
        if (name=="") name = nameMatch.get(type).toString()+unnamedLineStringCnt++;
        if (facts.numSets==1) { // just 1 line string
          flatCoords = getFlatArray(coordinates, facts);
          jsonObjects.addGeoJSONLineString(name, flatCoords, facts.atomsPerElem, properties, null);
        }
        else {
          GeoJSONGroup group = new GeoJSONObjects.GeoJSONGroup(name,"GeoJSON"+type);
          for (int set = 0; set < facts.numSets; set++) {
            ArrayListFacts modFacts = new ArrayListFacts(facts);
            modFacts.dimension = facts.dimension - 1;
            modFacts.numElements = facts.numElements / facts.numSets;
            String modName = name + (char)(MULTI_START_LETTER+set);
            flatCoords = getFlatArray((ArrayList)coordinates.get(set), modFacts);
            jsonObjects.addGeoJSONLineString(modName, flatCoords, modFacts.atomsPerElem, properties, group);
          }
        }
        break;
      case "MultiPolygon":
        if (name=="") name = nameMatch.get(type).toString()+unnamedPolygonCnt++;
        if (facts.numSets==1) { // just 1 polygon
          addPolygon(name,coordinates,facts,properties,null);
        }
        else {
          GeoJSONGroup group = new GeoJSONObjects.GeoJSONGroup(name,"GeoJSON"+type);
          for (int set = 0; set < facts.numSets; set++) {
            ArrayList thisPolyArrayList = (ArrayList)coordinates.get(set);
            ArrayListFacts modFacts = getArrayListFacts(thisPolyArrayList);
            String modName = name + (char)(MULTI_START_LETTER+set);
            addPolygon(modName,thisPolyArrayList,modFacts,properties,group);
          }
        }
        break;
      default:
        Shell.warning("Unsupported GeoJSON type:"+type);
    }
  }

  /** Adds each of the geometries in the GeometryCollections
   *
   *  @param geometryCollection  The Geometry Collection to parse
   *  @param properties          The properties associated with the GeometryCollection
   */
  @SuppressWarnings("rawtypes")
  private void addGeometries (Map geometryCollection, Map properties) {
    Object geometries = geometryCollection.get("geometries");
    if (geometries instanceof ArrayList) {
      // get the geometry
      String gcName = "GC"+unnamedGeoCollection++;
      GeoJSONGroup group = new GeoJSONObjects.GeoJSONGroup(gcName,"GeoJSONGeometryCollection");
      int item = 0;
      for (Object geometry : (ArrayList)geometries) {
        if (geometry instanceof Map) {
          String type = ((Map)geometry).get("type").toString();
          ArrayList coordinates = getCoordinates((Map)geometry);
          String name = nameMatch.get(type)+gcName;
          name += (char)(MULTI_START_LETTER + item++);
          properties.put("name", name);
          addFeature(type, properties, coordinates, group);
        }
      }
    }
    else {
      throw new MidasException("Unsupported GeoJSON geometries object: "+geometries);
    }
  }

  /** Gets the coordinates associated with this geometry
   *
   *  @param geometry geometry
   *  @return coordinates as an ArrayList
   */
  @SuppressWarnings("rawtypes")
  private ArrayList getCoordinates (Map geometry) {
    Object coorObj = (geometry).get("coordinates");
    if (coorObj instanceof ArrayList) return (ArrayList)coorObj;
    throw new MidasException("Unsupported GeoJSON coordinates object: "+coorObj);
  }

  @SuppressWarnings("rawtypes")
  private void addPolygon (String name, ArrayList coordinates, ArrayListFacts facts,
      Map properties, GeoJSONObjects.GeoJSONGroup group) {
    double[] flatCoords = (facts.dimension < 3) ? getFlatArray(coordinates, facts) :
      getFlatArray((ArrayList)coordinates.get(0), getArrayListFacts((ArrayList)coordinates.get(0)));
    double[][] flatCoordsInnerPoly = getInnerFlatCoords(coordinates,facts);
    if (flatCoordsInnerPoly != null) jsonObjects.addGeoJSONPolygon(name, flatCoords, flatCoordsInnerPoly, facts.atomsPerElem, properties, group);
    else jsonObjects.addGeoJSONPolygon(name, flatCoords, facts.atomsPerElem, properties, group);
  }

  /** Determine basic information, such as level of nesting (dimension),
   *  total number of elements, and whether they are 2D or 3D coordinates
   *
   *  @param coordinates coordinates
   *  @return facts object describing the coordinates ArrayList
   */
  @SuppressWarnings("rawtypes")
  private ArrayListFacts getArrayListFacts (ArrayList coordinates) {
    int dimensions = 0;
    int numElements = 0;
    int atomsPerElem = 0;
    int numSets = 1;
    // get if coordinates are 2 or 3 dimensional
    if (coordinates == null) {
      Shell.warning("GeoJSON invalid null coordinates");
      return new ArrayListFacts(0,0,0,0);
    }
    else {
      Object first = coordinates.get(0);
      if (first instanceof ArrayList) {
        // not just one xy or xyz coordinate, but at least set of coordinates
        Object initElem = ((ArrayList)first).get(0);
        if (initElem instanceof ArrayList) {
          // polygons with inner rings have sets of sets of coordinates
          atomsPerElem = ((ArrayList)initElem).size();
          for (Object set : coordinates) {
            numElements += ((ArrayList)set).size();
          }
          numSets = coordinates.size();
          dimensions = 3;
        }
        else {
          // linestrings and polygons with just a outer ring fit this category
          atomsPerElem = ((ArrayList)first).size();
          numElements = coordinates.size();
          dimensions = 2;
        }
      }
      else {
        // points fit this category
        atomsPerElem = coordinates.size();
        numElements = 1;
        dimensions = 1;
      }
    }
    return new ArrayListFacts(dimensions, numSets, numElements, atomsPerElem);
  }

  /** Convert coordinates from nested arrays to one flat array
   *  ex: [[x0,y0],[x1,y1]] becomes [xo,y0,x1,y1]
   *
   *  @param coordinates ArrayList containing coordinates
   *  @param facts       information about the coordinates ArrayList
   *  @return flat double[] array
   */
  @SuppressWarnings("rawtypes")
  private double[] getFlatArray (ArrayList coordinates, ArrayListFacts facts) {
    double[] flatArray = new double[facts.numElements*facts.atomsPerElem];
    if (facts.dimension == 1) {
      for (int counter=0; counter < coordinates.size(); counter++) {
        flatArray[counter] = (double)coordinates.get(counter);
      }
    }
    else if (facts.dimension == 2) {
      int counter = 0;
      for (Object elemObj : coordinates) {
        ArrayList elemArrList = (ArrayList)elemObj;
        for (int i = 0; i < elemArrList.size(); i++, counter++) {
          Object value = elemArrList.get(i);
          flatArray[counter] = (value instanceof Double) ? (Double)value :
                                Double.valueOf(value.toString());
        }
      }
    }
    else { // 3 dimensional (currently not used, commented out for now)
//      int counter = 0;
//      for (Object setObj : coordinates) {
//        ArrayList setArrList = (ArrayList)setObj;
//        for (Object elemObj : setArrList) {
//          ArrayList elemArrList = (ArrayList)elemObj;
//          for (int i = 0; i < elemArrList.size(); i++, counter++) {
//            flatArray[counter] = (double)elemArrList.get(i);
//          }
//        }
//      }
    }
    return flatArray;
  }

  /** Get polygon inner coordinates as a set of flat arrrays
   *
   *  @param coordinates ArrayList containing coordinates
   *  @param facts       information about the coordinates ArrayList
   *  @return set of flat arrays (one for each inner polygon)
   */
  @SuppressWarnings("rawtypes")
  private double[][] getInnerFlatCoords (ArrayList coordinates, ArrayListFacts facts) {
    double[][] flatCoordsInnerPoly = null;
    if ((facts.dimension == 3) && (facts.numSets > 1)) {
      flatCoordsInnerPoly = new double[facts.numSets-1][];
      for (int i=1; i<facts.numSets; i++) {
        flatCoordsInnerPoly[i-1] = getFlatArray((ArrayList)coordinates.get(i), getArrayListFacts((ArrayList)coordinates.get(i)));
      }
    }
    return flatCoordsInnerPoly;
  }

  @SuppressWarnings("rawtypes")
  private void trySupportNonStandardGeoJSON (Map mapToSearch) {
    // Handle JSON that is not GeoJSON by searching for map with key "type"
    // in nested in JSON. "coordinates" must also me a key in that map, for
    // the code to attempt processing it.
    ArrayList<Map> innerMapsWithType = new ArrayList<>();
    for (Object key : mapToSearch.keySet()) {
      if (mapToSearch.get(key)instanceof Map) {
        Map innerMap = (Map) mapToSearch.get(key);
        if (innerMap.containsKey("type")) innerMapsWithType.add(innerMap);
        trySupportNonStandardGeoJSON(innerMap);
      }
    }
    addEachType(innerMapsWithType);
  }

  /** Search within JSON that is NOT standard GeoJSON for Maps with type
   *  and coordinates and add those as Features too
   *  @param mapsWithTypeEntry map to search
   */
  @SuppressWarnings("rawtypes")
  private void addEachType (ArrayList<Map> mapsWithTypeEntry) {
    if ((mapsWithTypeEntry==null) || mapsWithTypeEntry.isEmpty()) return;
    for (Map map : mapsWithTypeEntry) {
      Object coorObj = map.get("coordinates");
      if (coorObj instanceof ArrayList) {
        // get the features type
        String type = map.get("type").toString();
        Object propertiesObj = map.get("properties");
        Map properties = ((propertiesObj==null) || !(propertiesObj instanceof Map)) ? null : (Map) propertiesObj;
        addFeature (type, properties, (ArrayList)coorObj);
      }
    }
  }

  /** Get full MapKBT created from GeoJSON
   *  @return MapKBT containing GeoJSON
   */
  @InternalUseOnly
  public MapKBT getGeoJSONMap () {
    return geoJSONMap;
  }

  /** Allow processing of some JSON that is not GeoJSON
   *  @param enabled true to enable
   */
  public void setEnableJSONSearch (boolean enabled) {
    this.enableJSONSearch = enabled;
  }

  /** Allow processing of some JSON that is not GeoJSON
   *  @param enabled true to enable
   */
  public void setEnableJSONSearch (String enabled) {
    setEnableJSONSearch(Convert.s2z(enabled));
  }

  //---------methods required by PlotFile Interface--------
  //            (values not used internally)

  @Override
  public void connect(int mode) {
    Shell.warning("GeoJSONFile.connect(int) is unimplemented ");
  }

  @Override
  public int read(Data data, int elements) {
    Shell.warning("GeoJSONFile.read(Data,int) is unimplemented ");
    return -1; }

  @Override
  public int readDataTable (Table tbl) {
    Shell.warning("GeoJSONFile.readDataTable(Table) is unimplemented ");
    return 0;
  }

  public String getFormat () { return format; }
  public double getStart ()  { return ystart; }
  public double getDelta ()  { return ydelta; }
  public int    getUnits ()  { return yunits; }
  public double getXStart () { return xstart; }
  public double getXDelta () { return xdelta; }
  public int    getXUnits () { return xunits; }
  public int    getXFrame () { return xframe; }
  public double getYStart () { return ystart; }
  public double getYDelta () { return ydelta; }
  public int    getYUnits () { return yunits; }
  public int    getYFrame () { return yframe; }
  public double getTimeAt (double offset) { return timecode; }

  /** Set GeoJSON format
      @param value new GeoJSON format
   */
  public void setFormat (String value) { format = value; }
  /** Set X-axis start value
      @param value new X-axis start value
   */
  public void setXStart (double value) { xstart = value; }
  /** Set X-axis delta
      @param value new X-axis delta
   */
  public void setXDelta (double value) { xdelta = value; }
  /** Set X-axis units
      @param value new X-axis units
   */
  public void setXUnits (int value) { xunits = value; }
  /** Set X-axis frame size
      @param value new X-axis start value
   */
  public void setXFrame (int value) { xframe = value; }
  /** Set Y-axis start value
      @param value new Y-axis start value
   */
  public void setYStart (double value) { ystart = value; }
  /** Set Y-axis delta
      @param value new Y-axis delta
   */
  public void setYDelta (double value) { ydelta = value; }
  /** Set Y-axis units
      @param value new Y-axis units
   */
  public void setYUnits (int value) { yunits = value; }
  /** Set Y-axis frame size
      @param value new Y-axis frame size
   */
  public void setYFrame (int value) { yframe = value; }

  /** Set time code
      @param value new time code
   */
  public void setTimeCode (double value) { timecode = value; }

  public int getMode() { return 1; }

  public int getType() { return 0; }

  public Data getDataBuffer(int size) { return null; } // TODO


  //---------------------- inner classes ------------------
  /** Class to save pertinant structure information on the ArrayList
   *  containing the coordinates
   */
  class ArrayListFacts {
    int dimension;
    int numSets;
    int atomsPerElem;
    int numElements;

    /** Main Constructor
     *
     * @param dimension    the level of nesting
     *                     - points have 1
     *                     - linestrings 2
     *                     - polygons 2 if only outer ring or 3 if have inner rings
     * @param numSets      polygons with inner rings will have multiple sets of coordinates
     * @param numElements  the total number of elements (used for flat array dimensions)
     * @param atomsPerElem whether these are 2 or 3D coordinates
     */
    ArrayListFacts (int dimension, int numSets, int numElements, int atomsPerElem) {
      this.dimension = dimension;
      this.numSets = numSets;
      this.numElements = numElements;
      this.atomsPerElem = atomsPerElem;
    }

    /** Copy Constructor
     *
     *  @param facts facts to copy
     */
    ArrayListFacts (ArrayListFacts facts) {
      this.dimension = facts.dimension;
      this.numSets = facts.numSets;
      this.numElements = facts.numElements;
      this.atomsPerElem = facts.atomsPerElem;
    }

    public String toString () {
      return "ArrayListFacts: dimension:"+dimension+" numSets:"+numSets+" numElements:"+numElements+" atomsPerElem:"+atomsPerElem;
    }
  }
}
