package nxm.sys.libg;

import static java.lang.Math.max;
import static java.lang.Math.min;

import java.awt.Point;
import java.awt.event.MouseEvent;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

import nxm.sys.inc.Drawable;
import nxm.sys.inc.InternalUseOnly;
import nxm.sys.lib.Message;
import nxm.sys.lib.Position;
import nxm.sys.lib.Table;

/** A class to group together Drawable objects. <br>
 *  <br>
 *  The purpose of this class is to allow users to treat multiple Layers and/or Features as a single
 *  Layer, so that performing functions such as enable/disable on them as a group is simpler.
 *
 *  @author J. Bauer, J. Czechowski IV
 *  @since NeXtMidas 3.5.0
 */
public final class LayerDrawable extends Layer {
  // The following variables hold the set of all drawables in the order in which they are to be
  // drawn. The second one (unmodifiableDrawables) maps to the first and provides the unmodifiable
  // view exposed to users. The default size of 'drawables' is 64 as that seems like a good default
  // that balances memory use for small sets against number of reallocations for larger sets.
  // The CopyOnWriteArrayList is used rather than ArrayList to avoid ConcurrentModificationException
  // in the draw method caused by users modifying the underlying array. The CopyOnWriteArrayList is
  // slower than ArrayList during add/remove, but the number of those is far below the number of
  // draw calls expected, which would otherwise need to be synchronized (which is slower).
  private final List<Drawable> drawables             = new CopyOnWriteArrayList<>();
  private final List<Drawable> unmodifiableDrawables = Collections.unmodifiableList(drawables);
  private final List<LayerT5>  layerT5Drawables       = new CopyOnWriteArrayList<>(); // added in 3.9.3 for efficiency in knowing which Drawables are LayerT%
  private boolean listLayers = false; // added in 3.9.3 to list the layers for the MMB menu for turning on or off
  /** Hold the LayerT5 object that has the selected point
   *  @since NeXtMidas 3.9.3
   */
  private LayerT5 nearestParentOfSelectedPoint;
  private int numVisiblePoints = 0;

  // Added for Map Fade NXM-3593 - to allow only a time region of points to be displayed
  private boolean activeRelativeToPtTimeWindow = false;
  private double oldestRelativeTime = LayerT5.INACTIVE;
  private double newestRelativeTime = LayerT5.INACTIVE;

  private boolean layerWithDrawableData = false; // NXM-3571 keep track of if an individual layer needed DATA, DATAX, or FAST

  /** show groups of points as clusters, rather than individual points, until adequately zoomed in */
  private boolean showClusters; // since 3.9.4 for NXM-2968

  /** default constructor */
  public LayerDrawable () {}

  /** Cycles through the child Layers inside this layerDrawable, and calls getData() in each of them.
   *  @return Typically returns Drawable.FULL, unless there are only Layers (no Features) in this Group,
   *          and each of those Layers does not return drawable.FULL.
   */
  @Override
  public int getData (int mode) {
    if (drawables.isEmpty()) return Drawable.FULL;
    layerWithDrawableData = false; // NXM-3571
    int stat = Drawable.FAST;
    for (Drawable d : drawables) {
      if (d instanceof Feature)  {
        stat = Drawable.FULL;  // return FULL so Plot.process() doesn't get in a loop
      }
      if (d instanceof Layer) {
        int tmpStat = ((Layer)d).getData(mode);
        if (tmpStat >= Drawable.FULL) {
          // Check for draw level that indicates a layer just need data drawn
          // since 3.9.3 for NXM-3571 to avoid scenario where new data is not drawn
          if (tmpStat > Drawable.FULL) layerWithDrawableData = true;
          if (tmpStat < stat) {
            stat = tmpStat;  // set to most drawing needed
          }
        }
      }
    }
    return stat;
  }

  /** Gets status indicating at least 1 layer has new data that needs to be drawn
   *  @return true if a layer got new data
   *  @since NextMidas 3.9.3
   */
  public boolean hasLayerWithDrawableData () {
    return layerWithDrawableData;
  }

  /** @since NeXtMidas 3.5.4 */
  @Override
  public void findRange () {
    boolean first = true;
    for (Drawable d : drawables) {
      if (d.isEnabled(0)) {
        d.findRange();
        if (first) setRange(d);
        else       addRange(d);
        first = false;
      }
    }
    if (first) {
      // When empty (as is often the case before features are added), or when all features are
      // not enabled (i.e. all are "-Global"), just mimic the range of the base layer since we
      // don't want to use the default setting (x1=0,x2=1,y1=0,y2=1) and mess up the auto-scaling
      // in the PLOT which is otherwise based on the other layers.
      MP.getBaseLayer().findRange();
      setRange(MP.getBaseLayer());
    }
    // In situations where the layer consists only of features it is possible to get x1=x2 and/or
    // x1=y2 which can cause scaling issues within PLOT. As such, if we detect an x1=x2 or y1=y2
    // situation we adjust the values by shifing them 1% of the width/height of the base layer.
    // We use the base layer as a reference rather than some arbitrary "small delta" since it
    // maintains the order-of-magnitude of the PLOT.
    if (x1 == x2) {
      double dx = Math.abs(MP.getBaseLayer().getX2() - MP.getBaseLayer().getX1());
      if (dx == 0) dx = 1; // rare (bad) situation where layer has zero size
      x1 -= dx/100;
      x2 += dx/100;
    }
    if (y1 == y2) {
      double dy = Math.abs(MP.getBaseLayer().getY2() - MP.getBaseLayer().getY1());
      if (dy == 0) dy = 1; // rare (bad) situation where layer has zero size
      y1 -= dy/100;
      y2 += dy/100;
    }
    // Set Minimum and Maximum actual auto-scale value (since 3.7.0 for NXM-2984)
    a1 = y1;
    a2 = y2;
  }

  /** Sets the range based on the given drawable. */
  private void setRange (Drawable d) {
    x1 = d.getXMin();  x2 = d.getXMax();
    y1 = d.getYMin();  y2 = d.getYMax();
    z1 = d.getZMin();  z2 = d.getZMax();
  }

  /** Update the range to include the given drawable. */
  private void addRange (Drawable d) {
    x1 = min(x1,d.getXMin());  x2 = max(x2,d.getXMax());
    y1 = min(y1,d.getYMin());  y2 = max(y2,d.getYMax());
    z1 = min(z1,d.getZMin());  z2 = max(z2,d.getZMax());
  }

  /** @since NeXtMidas 3.5.4 */
  @Override
  public void draw (int flag) {
    clearNeedRefresh();                         // <-- clear our local refresh flag
    if (activeRelativeToPtTimeWindow) updateRelativeToPtTimeWindow(); // NXM-3515 Map Fade 3.9.3
    List<LayerT5> layers = new ArrayList<>();
    Line lin = null;
    int zoomLevel = RegionGroup.getZoomLevel(MP.getZoomLevelAsDecimal());
    boolean drawClusters = showClusters && MP.isLatLonView() && (zoomLevel < RegionGroup.MAX_ZOOM_LEVEL);
    for (Drawable d : drawables) {
      if (drawClusters && (d instanceof LayerT5)) {
        lin = setupDrawableForRegionGroups((LayerT5)d, layers, lin);
        continue;
      }
      // use MP.dowDraw(Drawable, int, int) with new DO_FORCE_DRAW flag Since NeXtMidas 3.5.4 Bug 2913
      MP.doDraw(d, flag, MPlot.DO_FORCE_DRAW|MPlot.DO_DRAW);  // <-- calls clearNeedRefresh() on the item
    }

    if (drawClusters && layers.size() > 0) {
      RegionGroup.drawGrouping(lin, zoomLevel, layers, MP);
    }
    updateVisiblePoints();
  }

  private Line setupDrawableForRegionGroups (LayerT5 t5, List<LayerT5> layers, Line lin) {
    t5.clearNeedRefresh();
    if (t5.isVisible()) {
      if (lin == null) lin = t5.line.copy();
      MP.gc.setColor(lin.getColor());
      t5.isReset = false;
      t5.setClusterFontSizes();
      t5.setDrawingConfig(lin);
      layers.add(t5);
    }
    return lin;
  }

  /** Update total number of visible points across on LayerT5 layers
   *  @since NeXtMidas 3.9.3 for NXM-3512
   */
  private void updateVisiblePoints() {
    int tempTotalVisiblePnts = 0;
    if (showClusters) {
      numVisiblePoints = -1;
      return;
    }
    for (LayerT5 t5 : layerT5Drawables) {
      if (t5.isShowClusters()) {
        numVisiblePoints = -1; // -1 is to not allow readout of points as region circles is on and readout should be off.
        return;
      }
      tempTotalVisiblePnts += t5.getNumVisiblePoints();
    }
    numVisiblePoints = tempTotalVisiblePnts;
  }

  /** Get the total number of visible points across LayerT5 layers
      @return total number of visible points
   */
  public int getNumVisiblePoints () {
    return numVisiblePoints;
  }

  @Override
  public boolean getNeedRefresh () {
    if (super.getNeedRefresh()) return true;
    for (Drawable d : drawables) {
      if (d.getNeedRefresh()) return true;
    }
    return false;
  }

  @Override
  public boolean getNeedsRescale () {
    if (super.getNeedsRescale()) return true;
    for (Drawable d : drawables) {
      if ((d instanceof Layer) && (((Layer)d).getNeedsRescale())) return true;
    }
    return false;
  }


  /** Add a Drawable object to the array of Drawable objects in this Layer.
   *  @param d The Drawable to add
   */
  public void addDrawable (Drawable d) {
    drawables.add(d);
    if (d instanceof LayerT5) {
      layerT5Drawables.add((LayerT5)d); // keep track of LayerT5 layers for efficiency 3.9.3
      if (showClusters) ((LayerT5)d).setFormRegions(true);
    }
    else if (d instanceof Feature) {
      Feature drawableFeat = (Feature)d;
      layFeatures.add(drawableFeat.name,drawableFeat); // keep track of Features
      if (drawableFeat.hasGroup()) {
        if (featureGroups==null) featureGroups = new FeatureGroupMap();
        if (featureGroups.get(drawableFeat.getGroup())!=null) {
          // this Feature is all set and knows the group it belongs to
          featureGroups.addFeatureToGroup(drawableFeat, featureGroups.get(drawableFeat.getGroup()));
        }
        else {
          // The Feature needs a group created with the group name
          featureGroups.addFeatureToGroup(drawableFeat, new FeatureGroup(drawableFeat));
        }
      }
    }
    if (((d.getXMax() > x2) || (d.getYMax() > y2) || (d.getZMax() > z2))) {
      if (!MP.getZoomKeep()) {
        MP.rescale(true); // NXM-3500
      }
      else if (!MP.isMapView()){
        MP.findRange();
      }
    }
    needRefresh = true;
  }

  /** Gets a Drawable with the given name.
   *  @param name The name of the drawable.
   *  @return The drawable object or null if not found.
   *  @since NeXtMidas 3.5.4
   */
  public Drawable getDrawable (String name) {
    name=name.toUpperCase();
    for (Drawable d : drawables) {
      if (name.equals(d.getName())) return d;
    }
    return null;
  }

  /** Removes a drawable from this layer.
   *  @param name The name of the drawable(s) to remove or "*" to remove all.
   *  @return TRUE if the Drawable is removed, else FALSE.
   */
  public boolean removeDrawable (String name) {
    return (name.equals("*"))? clearAllDrawables() : removeDrawable(getDrawable(name));
  }

  @Override
  public boolean clear() {
    // Implementation added in 3.9.0
    // Changed in 3.9.3 - need to be able to clear the layers, not wipe out the layers.
    for (Drawable drawable : drawables) {
      if (drawable instanceof Layer) {
	      // layers are not removed, they are simply cleared
        ((Layer) drawable).clear();
      }
      // remove features to match 3.9.2 behavior
      else if (drawable instanceof Feature){
        drawables.remove(drawable);
      }
    }
    return true;
  }

  /** Removes all drawables from this layer.
   *  @return TRUE if the Drawable is removed, else FALSE.
   *  @since NeXtMidas 3.5.4
   */
  public boolean clearAllDrawables () {
    if (drawables.isEmpty()) return false;
    drawables.clear();
    layerT5Drawables.clear();
    layFeatures.clear();
    needRefresh = true;
    return true;
  }

  /** Remove a Drawable object from the array of Drawable objects in this Layer.
   *  @param d The drawable to remove.
   *  @return TRUE if the Drawable is removed, else FALSE.
   */
  public boolean removeDrawable (Drawable d) {
    if (d == null) return false;
    boolean anyRemoved = drawables.remove(d);
    if (d instanceof Layer) {
      if (d instanceof LayerT5) layerT5Drawables.remove((LayerT5)d);
      Layer lay = (Layer)d;
      lay.close(); // needs to come before lay.pg.close() so deselect has information needed
      if (lay.pf!=null) lay.pf.close(); // NXM-3646 3.9.4
    }
    else if (d instanceof Feature) layFeatures.remove(((Feature)d).name);
    needRefresh = anyRemoved;
    return anyRemoved;
  }

  /** Remove a Drawable object from the array of Drawable objects in this Layer.
   *  @param index The index number of the drawable.
   *  @return TRUE if the Drawable is removed, else FALSE
   *  @throws IndexOutOfBoundsException if the index is out of range.
   */
  public boolean removeDrawable (int index) {
    Drawable removedDrawable = drawables.remove(index);
    return removeDrawable(removedDrawable); // update to use overloaded method in 3.9.4
  }



  /** Return a list of the Drawable Objects in the LayerDrawable in Table form.
   *  @param type     "FEATures", "LAYers", or "ALL" [DEF="ALL"]
   *  @param nameOnly if true, the Table only contains a list of the names of the Objects,
   *                  not the Objects themselves
   *  @return A Table containing the Drawables themselves or a list of their names, or null
   *          if there are no Drawables.
   */
  public Table getDrawables (String type, boolean nameOnly) {
    // TODO: Is this useful? Since removeDrawable(..) works off the drawable's name, why not use
    //       that as the key? Suggest revising in NeXtMidas 3.7.x series
    int itype; // 0==all (int for performance)
    String dtype = type.toUpperCase();
    if      (dtype.startsWith("FEAT")) itype = 0x1;  // features
    else if (dtype.startsWith("LAY"))  itype = 0x2;  // Layers
    else if (dtype.equals("ALL"))      itype = 0x3;  // ALL [default]
    else throw new IllegalArgumentException("Invalid type '"+type+"', expected FEATURES, LAYERS, or ALL.");

    Table t = new Table();
    int featIndex = 0;
    int layIndex  = 0;
    for (Drawable d : drawables) {
      String key = null;

      if (((itype & 0x1) != 0) && (d instanceof Feature)) key = "FEAT_"+featIndex++;
      if (((itype & 0x2) != 0) && (d instanceof Layer  )) key = "LAY_"+layIndex++;

      if (key!=null) {
        if (nameOnly)  t.put(key, d.getName());
        else           t.put(key, d);
      }
    }
    return t;
  }

  /** Returns a COPY of the array containing the drawables objects.
   *  @return A COPY of the array containing the drawables objects.
   *  @deprecated Since NeXtMidas 3.5.4 use getAllDrawables()
   */
  @Deprecated // Since NeXtMidas 3.5.4 use getAllDrawables()
  public Drawable[] getDrawables () {
    return drawables.toArray(new Drawable[drawables.size()]);
  }

  /** Added to give access for the Middle Mouse Button Menu
   *  This provides the ability to select and deselect Layers part of LayerDrawable
   *
   *  @return The List of Drawables
   *  @since NeXtMidas 3.9.3
   */
  List<Drawable> getDrawablesInstance () {
    return drawables;
  }

  /** Returns an unmodifiable view of the drawables objects held by this layer.
   *  @return An unmodifiable view of the drawables objects held by this layer (not null).
   *  @since NeXtMidas 3.5.4
   */
  public List<Drawable> getAllDrawables () {
    return unmodifiableDrawables;
  }

  /** Get The Layer in this LayerDrawable matching the given name
   *
   * @param name name of the Layer associated with this LayerDrawable
   * @return the Layer matching that name
   * @since NeXtMidas 3.9.3
   */
  public Layer getLayer (String name) {
    for (Drawable d : drawables) {
      if (d instanceof Layer) {
        if ((d.getName()).equalsIgnoreCase(name)) return (Layer)d;
      }
    }
    return null;
  }

  /** Count the points in a given range
   *  <br><b>Note:</b> The coordinates provided will be arranged  (x1 &gt; x2 will be swapped or y1 &gt; y2 will be swapped)
   *  <br><b>Note:</b> This will iterate all LayerT5s and get the point count and combine that count
   *  @param x1 first x value (-180 to 180)
   *  @param x2 second x value (-180 to 180)
   *  @param y1 first y value (-90 to 90)
   *  @param y2 second y value (-90 to 90)
   *  @return the point count in the given range
   *  @since NeXtMidas 3.9.3
   */
  public int countPointsInRange (double x1, double x2, double y1, double y2) {
    if (y1==y2) return 0;
    if (x1==x2) return 0;
    int tempNumPoints = 0;
    for (LayerT5 t5 : layerT5Drawables) {
      if (t5.isVisible()) tempNumPoints += t5.countPointsInRange(x1, x2, y1, y2);
    }
    return tempNumPoints;
  }

  /** Gets the drawable instances that are visible at the specified position. <br>
   *  <br>
   *  This uses a very rough check on the bounds of each drawable object and adds a fudge-factor
   *  to get small drawable features even is the user's cursor isn't exactly on it. As a result
   *  this may return features that are not strict matches. <br>
   *  <br>
   *  This check omits any drawable items that are not enabled, but may select ones that are
   *  only nominally visible (e.g. "black on black").
   *  @param pos The position to check for.
   *  @return The set of drawables at the given position in the order they appear (not null).
   *  @since NeXtMidas 3.5.4
   */
  public List<Drawable> getDrawablesAt (Position pos) {
    List<Drawable> matches = new ArrayList<>(8); // 8=Good guess for max number of overlapping

    for (Drawable d : drawables) {
      if (!MP.doDraw(d, 0, MPlot.DO_CHECK)) continue;
      if (d.isAtPosition(pos, -1, -1)) matches.add(d);
    }
    return matches;
  }

  /** Get if the layers are being listed individually in the MMB Layer menu
   *  @return the listLayers true if the layers are being listed individually in the MMB Layer menu
   */
  public boolean isListLayers () {
    return listLayers;
  }

  /** Set if layers should be listed individually in the MMB Layer menu
   *  @param listLayers true if layers should be listed individually in the MMB Layer menu
   */
  public void setListLayers (boolean listLayers) {
    this.listLayers = listLayers;
  }

  /**
   * {@inheritDoc}
   * @since NeXtMidas 3.9.3 for selecting points across multiple LayerT5 Drawables
   */
  @Override
  public void processMouseMessage(Message msg, int type) {
    if (type!=1) return;
    if (nearestParentOfSelectedPoint!=null) {
       nearestParentOfSelectedPoint.setSelectedIndex(-1); // deselect previous selection
       nearestParentOfSelectedPoint = null; // 3.9.4
    }
    int indexInParent = -1;
    int minDist = Integer.MAX_VALUE;
    for (LayerT5 t5 : layerT5Drawables) {
      if (!t5.isVisible()) continue;
      Table quals = null;
      if (msg.quals instanceof Table) quals = (Table) msg.quals;
      if (((t5.getAutoSelEnable() & type) != 0) && quals != null) { // do we want to make auto select at the drawable level?
        t5.setSelectedIndex(-1); // clear all selected
        Object obj = quals.get("ORIG_MSG_DATA");
        if (obj instanceof MouseEvent) {
          // need mouse event as it has the pixel location.
          MouseEvent me = (MouseEvent) obj;
          Point pos = me.getPoint();
          int[] nearest = t5.getNearestPosition(pos);
          if (nearest != null && nearest[0] < minDist && nearest[1] >= 0) { // fix NXM-????
            nearestParentOfSelectedPoint = t5;
            indexInParent = nearest[1];
            minDist = nearest[0];
          }
        }
      }
    }
    if (nearestParentOfSelectedPoint != null && indexInParent > -1) {
      nearestParentOfSelectedPoint.setSelectedIndex(indexInParent);
      if (GraphicsUtil.isCtrlLMB(msg)) { // Since 3.9.3 add a popup for the selected if necessary
        nearestParentOfSelectedPoint.drawPopup(indexInParent);
      }
    }
  }

  /** {@inheritDoc}
   *   @since 3.9.3
   */
  @Override
  public void close() {
    super.close();
    for (Drawable d : drawables) { // 3.9.4 - close all Layers
      if (d instanceof Layer) {
        Layer lay = (Layer) d;
        lay.close(); // needs to come before lay.pg.close() so deselect has information needed
        if (lay.pf!=null) lay.pf.close();
      }
    }
  }

  // ** Currently LayerT5 only time based visibility methods **

  /** Get the latest point time across all LayerT5s
   *  @return latest time as double
   */
  public double getLatestPointTime () {
    double timeLatestPoint = 0;
    for (LayerT5 t5 : layerT5Drawables) {
      if (!t5.isVisible()) continue;
      double thisLayerLatestTime = t5.getLatestPointTime();
      if (thisLayerLatestTime > timeLatestPoint) timeLatestPoint = thisLayerLatestTime;
    }
    return timeLatestPoint;
  }

  /** Set time window from newest point received back to the time specified
   *  @param timeOldest oldest point time that should be visible
   *  @param mode       the time window mode
   *  @since NeXtMidas 3.9.3 for NXM-3515
   */
  public void setTimeWindow (double timeOldest, int mode) {
    setTimeWindow(timeOldest, LayerT5.INACTIVE, mode);
  }

  /** Sets a time window for point visibility
   *
   *  @param timeOldest oldest point time that should be visible
   *  @param timeNewest newest point time that should be visible
   *  @param mode       the time window mode
   *  @since NeXtMidas 3.9.3 for NXM-3515
   */
  public void setTimeWindow (double timeOldest, double timeNewest, int mode) {
    if (mode != LayerT5.WINDOW_RELATIVE_POINT) {
      for (LayerT5 t5 : layerT5Drawables) {
        t5.setTimeWindow(timeOldest, timeNewest, mode);
      }
      activeRelativeToPtTimeWindow = false;
      oldestRelativeTime = newestRelativeTime = LayerT5.INACTIVE;
    }
    else {
      activeRelativeToPtTimeWindow = true;
      oldestRelativeTime = timeOldest;
      newestRelativeTime = timeNewest;
      updateRelativeToPtTimeWindow();
    }
  }

  private void updateRelativeToPtTimeWindow () {
    double referenceTime = getLatestPointTime ();
    double oldest = (oldestRelativeTime == LayerT5.INACTIVE) ? LayerT5.INACTIVE : referenceTime - oldestRelativeTime;
    double newest = (newestRelativeTime == LayerT5.INACTIVE) ? LayerT5.INACTIVE : referenceTime - newestRelativeTime;
    for (LayerT5 t5 : layerT5Drawables) {
      t5.setTimeWindow(oldest, newest, LayerT5.WINDOW_FIXED);
    }
  }

  /** Turn off time point visibility window */
  public void turnOffTimeWindow () {
    activeRelativeToPtTimeWindow = false;
    oldestRelativeTime = newestRelativeTime = LayerT5.INACTIVE;
    for (LayerT5 t5 : layerT5Drawables) {
      t5.turnOffTimeWindow();
    }
  }

  /** Set whether drawing of clusters is enabled
   *  @param showClusters true if clustering of points is enabled
   */
  @InternalUseOnly
  public void setShowClusters (boolean showClusters) {
    this.showClusters = showClusters;
    for (LayerT5 t5 : layerT5Drawables) {
      if (showClusters) t5.setFormRegions(true);
      t5.setShowClusters(showClusters);
    }
    refresh();
  }

}
