/*
 * This file is part of ELKI:
 * Environment for Developing KDD-Applications Supported by Index-Structures
 *
 * Copyright (C) 2022
 * ELKI Development Team
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero 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 Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
 */
package elki.visualization.gui.overview;

import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.Map.Entry;
import java.util.concurrent.atomic.AtomicReference;

import org.apache.batik.util.SVGConstants;
import org.w3c.dom.Element;
import org.w3c.dom.events.EventListener;
import org.w3c.dom.events.EventTarget;

import elki.logging.Logging;
import elki.result.ResultListener;
import elki.utilities.datastructures.hierarchy.Hierarchy;
import elki.utilities.datastructures.iterator.It;
import elki.utilities.pairs.Pair;
import elki.visualization.VisualizationItem;
import elki.visualization.VisualizationListener;
import elki.visualization.VisualizationTask;
import elki.visualization.VisualizationTask.RenderFlag;
import elki.visualization.VisualizerContext;
import elki.visualization.batikutil.CSSHoverClass;
import elki.visualization.css.CSSClass;
import elki.visualization.gui.VisualizationPlot;
import elki.visualization.gui.detail.DetailView;
import elki.visualization.projector.Projector;
import elki.visualization.style.StyleLibrary;
import elki.visualization.svg.SVGEffects;
import elki.visualization.svg.SVGPlot;
import elki.visualization.svg.SVGUtil;
import elki.visualization.visualizers.Visualization;

/**
 * Generate an overview plot for a set of visualizations.
 *
 * @author Erich Schubert
 * @author Remigius Wojdanowski
 * @since 0.3
 *
 * @opt nodefillcolor LemonChiffon
 * @has - - - VisualizerContext
 * @composed - - - RectangleArranger
 * @composed - - - LayerMap
 * @has - - - DetailViewSelectedEvent
 * @assoc - - - DetailView
 */
public class OverviewPlot implements ResultListener, VisualizationListener {
  /**
   * Our logging class
   */
  private static final Logging LOG = Logging.getLogger(OverviewPlot.class);

  /**
   * Event when the overview plot started refreshing.
   */
  public static final String OVERVIEW_REFRESHING = "Overview refreshing";

  /**
   * Event when the overview plot was refreshed.
   */
  public static final String OVERVIEW_REFRESHED = "Overview refreshed";

  /**
   * Draw red borders around items.
   */
  private static final boolean DEBUG_LAYOUT = false;

  /**
   * Visualizer context
   */
  private VisualizerContext context;

  /**
   * The SVG plot object.
   */
  private VisualizationPlot plot;

  /**
   * Map of coordinates to plots.
   */
  protected RectangleArranger<PlotItem> plotmap;

  /**
   * Action listeners for this plot.
   */
  private ArrayList<ActionListener> actionListeners = new ArrayList<>();

  /**
   * Single view mode
   */
  private boolean single;

  /**
   * Screen size (used for thumbnail sizing)
   */
  public int screenwidth = 2000;

  /**
   * Screen size (used for thumbnail sizing)
   */
  public int screenheight = 2000;

  /**
   * React to mouse hover events
   */
  private EventListener hoverer;

  /**
   * Lookup
   */
  private LayerMap vistoelem = new LayerMap();

  /**
   * Layer for plot thumbnail
   */
  private Element plotlayer;

  /**
   * Layer for hover elements
   */
  private Element hoverlayer;

  /**
   * The CSS class used on "selectable" rectangles.
   */
  private CSSClass selcss;

  /**
   * Screen ratio
   */
  private double ratio = 1.0;

  /**
   * Pending refresh, for lazy refreshing
   */
  AtomicReference<Runnable> pendingRefresh = new AtomicReference<>(null);

  /**
   * Reinitialize on refresh
   */
  private boolean reinitOnRefresh = false;

  /**
   * Constructor.
   *
   * @param context Visualizer context
   * @param single Single view mode
   */
  public OverviewPlot(VisualizerContext context, boolean single) {
    super();
    this.context = context;
    this.single = single;

    // Important:
    // You still need to call: initialize(ratio);
  }

  /**
   * Recompute the layout of visualizations.
   *
   * @param width Initial width
   * @param height Initial height
   * @return Arrangement
   */
  private RectangleArranger<PlotItem> arrangeVisualizations(double width, double height) {
    if(!(width > 0. && height > 0.)) {
      LOG.warning("No size information during arrange()", new Throwable());
      return new RectangleArranger<>(1., 1.);
    }
    RectangleArranger<PlotItem> plotmap = new RectangleArranger<>(width, height);

    Hierarchy<Object> vistree = context.getVisHierarchy();
    for(It<Projector> iter2 = vistree.iterAll().filter(Projector.class); iter2.valid(); iter2.advance()) {
      Collection<PlotItem> projs = iter2.get().arrange(context);
      for(PlotItem it : projs) {
        if(it.w <= 0.0 || it.h <= 0.0) {
          LOG.warning("Plot item with improper size information: " + it);
          continue;
        }
        plotmap.put(it.w, it.h, it);
      }
    }

    nextTask: for(It<VisualizationTask> iter2 = vistree.iterAll().filter(VisualizationTask.class); iter2.valid(); iter2.advance()) {
      VisualizationTask task = iter2.get();
      if(!task.isVisible()) {
        continue;
      }
      if(vistree.iterParents(task).filter(Projector.class).valid()) {
        continue nextTask;
      }
      if(task.getRequestedWidth() <= 0.0 || task.getRequestedHeight() <= 0.0) {
        LOG.warning("Task with improper size information: " + task);
        continue;
      }
      PlotItem it = new PlotItem(task.getRequestedWidth(), task.getRequestedHeight(), null);
      it.tasks.add(task);
      plotmap.put(it.w, it.h, it);
    }
    return plotmap;
  }

  /**
   * Initialize the plot.
   *
   * @param ratio Initial ratio
   */
  public void initialize(double ratio) {
    if(!(ratio > 0 && ratio < Double.POSITIVE_INFINITY)) {
      LOG.warning("Invalid ratio: " + ratio, new Throwable());
      ratio = 1.4;
    }
    this.ratio = ratio;
    if(plot != null) {
      LOG.warning("Already initialized.");
      lazyRefresh();
      return;
    }
    reinitialize();
    // register context listener
    context.addResultListener(this);
    context.addVisualizationListener(this);
  }

  /**
   * Refresh the overview plot.
   */
  private synchronized void reinitialize() {
    if(plot == null) {
      initializePlot();
    }
    else {
      final ActionEvent ev = new ActionEvent(this, ActionEvent.ACTION_PERFORMED, OVERVIEW_REFRESHING);
      for(ActionListener actionListener : actionListeners) {
        actionListener.actionPerformed(ev);
      }
    }

    // Detach existing elements:
    for(Pair<Element, Visualization> pair : vistoelem.values()) {
      SVGUtil.removeFromParent(pair.first);
    }
    plotmap = arrangeVisualizations(ratio, 1.0);

    recalcViewbox();
    final int thumbsize = (int) Math.max(screenwidth / plotmap.getWidth(), screenheight / plotmap.getHeight());
    // TODO: cancel pending thumbnail requests!

    // Replace the layer map
    LayerMap oldlayers = vistoelem;
    vistoelem = new LayerMap();

    // Redo main layers
    SVGUtil.removeFromParent(plotlayer);
    SVGUtil.removeFromParent(hoverlayer);
    plotlayer = plot.svgElement(SVGConstants.SVG_G_TAG);
    hoverlayer = plot.svgElement(SVGConstants.SVG_G_TAG);
    hoverlayer.setAttribute(SVGPlot.NO_EXPORT_ATTRIBUTE, SVGPlot.NO_EXPORT_ATTRIBUTE);

    // Redo the layout
    for(Entry<PlotItem, double[]> e : plotmap.entrySet()) {
      final double basex = e.getValue()[0];
      final double basey = e.getValue()[1];
      for(Iterator<PlotItem> iter = e.getKey().itemIterator(); iter.hasNext();) {
        PlotItem it = iter.next();

        boolean hasDetails = false;
        // Container element for main plot item
        Element g = plot.svgElement(SVGConstants.SVG_G_TAG);
        SVGUtil.setAtt(g, SVGConstants.SVG_TRANSFORM_ATTRIBUTE, "translate(" + (basex + it.x) + " " + (basey + it.y) + ")");
        plotlayer.appendChild(g);
        vistoelem.put(it, null, g, null);
        // Add the actual tasks:
        for(VisualizationTask task : it.tasks) {
          if(!visibleInOverview(task)) {
            continue;
          }
          hasDetails |= !task.has(RenderFlag.NO_DETAIL);
          Pair<Element, Visualization> pair = oldlayers.remove(it, task);
          if(pair == null) {
            pair = new Pair<>(plot.svgElement(SVGConstants.SVG_G_TAG), null);
          }
          if(pair.second == null) {
            pair.second = embedOrThumbnail(thumbsize, it, task, pair.first);
          }
          g.appendChild(pair.first);
          vistoelem.put(it, task, pair);
        }
        // When needed, add a hover effect
        if(hasDetails && !single) {
          Element hover = plot.svgRect(basex + it.x, basey + it.y, it.w, it.h);
          SVGUtil.addCSSClass(hover, selcss.getName());
          // link hoverer.
          EventTarget targ = (EventTarget) hover;
          targ.addEventListener(SVGConstants.SVG_MOUSEOVER_EVENT_TYPE, hoverer, false);
          targ.addEventListener(SVGConstants.SVG_MOUSEOUT_EVENT_TYPE, hoverer, false);
          targ.addEventListener(SVGConstants.SVG_CLICK_EVENT_TYPE, hoverer, false);
          targ.addEventListener(SVGConstants.SVG_CLICK_EVENT_TYPE, (evt) -> triggerSubplotSelectEvent(it), false);

          hoverlayer.appendChild(hover);
        }
      }
    }
    for(Pair<Element, Visualization> pair : oldlayers.values()) {
      if(pair.second != null) {
        pair.second.destroy();
      }
    }
    plot.getRoot().appendChild(plotlayer);
    plot.getRoot().appendChild(hoverlayer);
    plot.updateStyleElement();

    // Notify listeners.
    final ActionEvent ev = new ActionEvent(this, ActionEvent.ACTION_PERFORMED, OVERVIEW_REFRESHED);
    for(ActionListener actionListener : actionListeners) {
      actionListener.actionPerformed(ev);
    }
  }

  /**
   * Initialize the SVG plot.
   */
  private void initializePlot() {
    plot = new VisualizationPlot();
    { // Add a background element:
      CSSClass cls = new CSSClass(this, "background");
      final String bgcol = context.getStyleLibrary().getBackgroundColor(StyleLibrary.PAGE);
      cls.setStatement(SVGConstants.CSS_FILL_PROPERTY, bgcol);
      plot.addCSSClassOrLogError(cls);
      Element background = plot.svgElement(SVGConstants.SVG_RECT_TAG);
      background.setAttribute(SVGConstants.SVG_X_ATTRIBUTE, "0");
      background.setAttribute(SVGConstants.SVG_Y_ATTRIBUTE, "0");
      background.setAttribute(SVGConstants.SVG_WIDTH_ATTRIBUTE, "100%");
      background.setAttribute(SVGConstants.SVG_HEIGHT_ATTRIBUTE, "100%");
      SVGUtil.setCSSClass(background, cls.getName());
      // Don't export a white background:
      if("white".equals(bgcol)) {
        background.setAttribute(SVGPlot.NO_EXPORT_ATTRIBUTE, SVGPlot.NO_EXPORT_ATTRIBUTE);
      }
      plot.getRoot().appendChild(background);
    }
    { // setup the hover CSS classes.
      selcss = new CSSClass(this, "s");
      if(DEBUG_LAYOUT) {
        selcss.setStatement(SVGConstants.CSS_STROKE_PROPERTY, SVGConstants.CSS_RED_VALUE);
        selcss.setStatement(SVGConstants.CSS_STROKE_WIDTH_PROPERTY, .00001 * StyleLibrary.SCALE);
        selcss.setStatement(SVGConstants.CSS_STROKE_OPACITY_PROPERTY, "0.5");
      }
      selcss.setStatement(SVGConstants.CSS_FILL_PROPERTY, SVGConstants.CSS_RED_VALUE);
      selcss.setStatement(SVGConstants.CSS_FILL_OPACITY_PROPERTY, "0");
      selcss.setStatement(SVGConstants.CSS_CURSOR_PROPERTY, SVGConstants.CSS_POINTER_VALUE);
      plot.addCSSClassOrLogError(selcss);
      CSSClass hovcss = new CSSClass(this, "h");
      hovcss.setStatement(SVGConstants.CSS_FILL_OPACITY_PROPERTY, "0.25");
      plot.addCSSClassOrLogError(hovcss);
      // Hover listener.
      hoverer = new CSSHoverClass(hovcss.getName(), null, true);
    }

    // Disable Batik default interactions (zoom, rotate, etc.)
    if(single) {
      plot.setDisableInteractions(true);
    }
    SVGEffects.addShadowFilter(plot);
    SVGEffects.addLightGradient(plot);
  }

  /**
   * Produce thumbnail for a visualizer.
   *
   * @param thumbsize Thumbnail size
   * @param it Plot item
   * @param task Task
   * @param parent Parent element to draw to
   */
  private Visualization embedOrThumbnail(final int thumbsize, PlotItem it, VisualizationTask task, Element parent) {
    final Visualization vis;
    if(!single) {
      vis = task.getFactory().makeVisualizationOrThumbnail(context, task, plot, it.w, it.h, it.proj, thumbsize);
    }
    else {
      vis = task.getFactory().makeVisualization(context, task, plot, it.w, it.h, it.proj);
    }
    if(vis == null || vis.getLayer() == null) {
      LOG.warning("Visualization returned empty layer: " + vis);
      return vis;
    }
    if(task.has(RenderFlag.NO_EXPORT)) {
      vis.getLayer().setAttribute(SVGPlot.NO_EXPORT_ATTRIBUTE, SVGPlot.NO_EXPORT_ATTRIBUTE);
    }
    parent.appendChild(vis.getLayer());
    return vis;
  }

  /**
   * Do a refresh (when visibilities have changed).
   */
  synchronized void refresh() {
    if(reinitOnRefresh) {
      LOG.debug("Reinitialize in thread " + Thread.currentThread().getName());
      reinitialize();
      reinitOnRefresh = false;
      return;
    }
    synchronized(plot) {
      boolean refreshcss = false;
      if(plotmap == null) {
        throw new IllegalStateException("Plotmap is null");
      }
      final int thumbsize = (int) Math.max(screenwidth / plotmap.getWidth(), screenheight / plotmap.getHeight());
      for(PlotItem pi : plotmap.keySet()) {
        for(Iterator<PlotItem> iter = pi.itemIterator(); iter.hasNext();) {
          PlotItem it = iter.next();

          for(Iterator<VisualizationTask> tit = it.tasks.iterator(); tit.hasNext();) {
            VisualizationTask task = tit.next();
            Pair<Element, Visualization> pair = vistoelem.get(it, task);
            // New task?
            if(pair == null) {
              if(visibleInOverview(task)) {
                Element elem = plot.svgElement(SVGConstants.SVG_G_TAG);
                pair = new Pair<>(elem, embedOrThumbnail(thumbsize, it, task, elem));
                vistoelem.get(it, null).first.appendChild(elem);
                vistoelem.put(it, task, pair);
                refreshcss = true;
              }
            }
            else if(pair.first != null) {
              if(visibleInOverview(task)) {
                // unhide if hidden.
                if(pair.first.hasAttribute(SVGConstants.CSS_VISIBILITY_PROPERTY)) {
                  pair.first.removeAttribute(SVGConstants.CSS_VISIBILITY_PROPERTY);
                }
              }
              else {
                // hide if there is anything to hide.
                if(pair.first.hasChildNodes()) {
                  pair.first.setAttribute(SVGConstants.CSS_VISIBILITY_PROPERTY, SVGConstants.CSS_HIDDEN_VALUE);
                }
              }
              // TODO: unqueue pending thumbnails
            }
          }
        }
      }
      if(refreshcss) {
        plot.updateStyleElement();
      }
    }
  }

  /**
   * Test whether a task should be displayed in the overview plot.
   *
   * @param task Task to display
   * @return visibility
   */
  protected boolean visibleInOverview(VisualizationTask task) {
    return task.isVisible() && !task.has(single ? RenderFlag.NO_EMBED : RenderFlag.NO_THUMBNAIL);
  }

  /**
   * Recompute the view box of the plot.
   */
  private void recalcViewbox() {
    final Element root = plot.getRoot();
    // Reset plot attributes
    SVGUtil.setAtt(root, SVGConstants.SVG_WIDTH_ATTRIBUTE, "20cm");
    SVGUtil.setAtt(root, SVGConstants.SVG_HEIGHT_ATTRIBUTE, SVGUtil.fmt(20 * plotmap.getHeight() / plotmap.getWidth()) + "cm");
    String vb = "0 0 " + SVGUtil.fmt(plotmap.getWidth()) + " " + SVGUtil.fmt(plotmap.getHeight());
    SVGUtil.setAtt(root, SVGConstants.SVG_VIEW_BOX_ATTRIBUTE, vb);
  }

  /**
   * Event triggered when a plot was selected.
   *
   * @param it Plot item selected
   * @return sub plot
   */
  public DetailView makeDetailView(PlotItem it) {
    return new DetailView(context, it, ratio);
  }

  /**
   * Adds an {@link ActionListener} to the plot.
   *
   * @param actionListener the {@link ActionListener} to be added
   */
  public void addActionListener(ActionListener actionListener) {
    actionListeners.add(actionListener);
  }

  /**
   * When a subplot was selected, forward the event to listeners.
   *
   * @param it PlotItem selected
   */
  protected void triggerSubplotSelectEvent(PlotItem it) {
    // forward event to all listeners.
    for(ActionListener actionListener : actionListeners) {
      actionListener.actionPerformed(new DetailViewSelectedEvent(this, ActionEvent.ACTION_PERFORMED, null, 0, it));
    }
  }

  /**
   * Destroy this overview plot.
   */
  public void destroy() {
    context.removeVisualizationListener(this);
    context.removeResultListener(this);
    plot.dispose();
  }

  /**
   * Get the SVGPlot object.
   *
   * @return SVG plot
   */
  public SVGPlot getPlot() {
    return plot;
  }

  /**
   * @return the ratio
   */
  public double getRatio() {
    return ratio;
  }

  /**
   * @param ratio the ratio to set
   */
  public void setRatio(double ratio) {
    if(ratio != this.ratio) {
      this.ratio = ratio;
      reinitOnRefresh = true;
      lazyRefresh();
    }
  }

  /**
   * Trigger a redraw, but avoid excessive redraws.
   */
  public final void lazyRefresh() {
    if(plot == null) {
      LOG.warning("'lazyRefresh' called before initialized!");
      return;
    }
    LOG.debug("Scheduling refresh.");
    Runnable pr = new Runnable() {
      @Override
      public void run() {
        if(OverviewPlot.this.pendingRefresh.compareAndSet(this, null)) {
          OverviewPlot.this.refresh();
        }
      }
    };
    OverviewPlot.this.pendingRefresh.set(pr);
    plot.scheduleUpdate(pr);
  }

  @Override
  public void resultAdded(Object child, Object parent) {
    lazyRefresh();
  }

  @Override
  public void resultChanged(Object current) {
    lazyRefresh();
  }

  @Override
  public void resultRemoved(Object child, Object parent) {
    lazyRefresh();
  }

  @Override
  public void visualizationChanged(VisualizationItem child) {
    if(!context.getVisHierarchy().iterParents(child).filter(Projector.class).valid()) {
      reinitOnRefresh = true;
    }
    lazyRefresh();
  }
}
