/** @author pepijn */
public class WPTileProvider
    implements org.pepsoft.util.swing.TileProvider, Dimension.Listener, Tile.Listener {
  public WPTileProvider(
      Dimension dimension,
      ColourScheme colourScheme,
      BiomeScheme biomeScheme,
      CustomBiomeManager customBiomeManager,
      Collection<Layer> hiddenLayers,
      boolean contourLines,
      int contourSeparation,
      TileRenderer.LightOrigin lightOrigin,
      boolean showBorder,
      org.pepsoft.util.swing.TileProvider surroundingTileProvider,
      boolean active) {
    tileProvider = dimension;
    this.colourScheme = colourScheme;
    this.biomeScheme = biomeScheme;
    this.hiddenLayers = (hiddenLayers != null) ? new HashSet<>(hiddenLayers) : null;
    this.contourLines = contourLines;
    this.contourSeparation = contourSeparation;
    this.lightOrigin = lightOrigin;
    this.active = active;
    this.customBiomeManager = customBiomeManager;
    this.surroundingTileProvider = surroundingTileProvider;
    this.showBorder = showBorder;
    tileRendererRef = createNewTileRendererRef();
  }

  public WPTileProvider(
      TileProvider tileProvider,
      ColourScheme colourScheme,
      BiomeScheme biomeScheme,
      CustomBiomeManager customBiomeManager,
      Collection<Layer> hiddenLayers,
      boolean contourLines,
      int contourSeparation,
      TileRenderer.LightOrigin lightOrigin,
      boolean showBorder,
      org.pepsoft.util.swing.TileProvider surroundingTileProvider) {
    this.tileProvider = tileProvider;
    this.colourScheme = colourScheme;
    this.biomeScheme = biomeScheme;
    this.hiddenLayers = (hiddenLayers != null) ? new HashSet<>(hiddenLayers) : null;
    this.contourLines = contourLines;
    this.contourSeparation = contourSeparation;
    this.lightOrigin = lightOrigin;
    active = false;
    this.customBiomeManager = customBiomeManager;
    this.surroundingTileProvider = surroundingTileProvider;
    this.showBorder = showBorder;
    tileRendererRef = createNewTileRendererRef();
  }

  public synchronized void addHiddenLayer(Layer layer) {
    hiddenLayers.add(layer);
    tileRendererRef = createNewTileRendererRef();
  }

  public synchronized void removeHiddenLayer(Layer layer) {
    hiddenLayers.remove(layer);
    tileRendererRef = createNewTileRendererRef();
  }

  @Override
  public int getTileSize() {
    return TILE_SIZE;
  }

  @Override
  public boolean isTilePresent(int x, int y) {
    if (zoom == 0) {
      return getUnzoomedTileType(x, y) != TileType.SURROUNDS
          || ((surroundingTileProvider != null) && surroundingTileProvider.isTilePresent(x, y));
    } else {
      final int scale = 1 << -zoom;
      for (int dx = 0; dx < scale; dx++) {
        for (int dy = 0; dy < scale; dy++) {
          switch (getUnzoomedTileType(x * scale + dx, y * scale + dy)) {
            case WORLD:
            case BORDER:
            case WALL:
              return true;
            case SURROUNDS:
              if ((surroundingTileProvider != null)
                  && surroundingTileProvider.isTilePresent(x, y)) {
                return true;
              }
              break;
          }
        }
      }
      return false;
    }
  }

  @Override
  public void paintTile(
      final Image tileImage, final int x, final int y, final int imageX, final int imageY) {
    try {
      if (zoom == 0) {
        paintUnzoomedTile(tileImage, x, y, imageX, imageY);
      } else {
        Graphics2D g2 = (Graphics2D) tileImage.getGraphics();
        try {
          BufferedImage surroundingTileImage = null;
          final Color waterColour = new Color(colourScheme.getColour(BLK_WATER));
          final Color lavaColour = new Color(colourScheme.getColour(BLK_LAVA));
          final Color voidColour = new Color(VoidRenderer.getColour());
          final Color bedrockColour = new Color(colourScheme.getColour(BLK_BEDROCK));
          final int scale = 1 << -zoom;
          final int subSize = TILE_SIZE / scale;
          for (int dx = 0; dx < scale; dx++) {
            for (int dy = 0; dy < scale; dy++) {
              TileType tileType = getUnzoomedTileType(x * scale + dx, y * scale + dy);
              switch (tileType) {
                case WORLD:
                  Tile tile = tileProvider.getTile(x * scale + dx, y * scale + dy);
                  if (tile.hasLayer(NotPresent.INSTANCE)) {
                    if (surroundingTileProvider != null) {
                      if (surroundingTileImage == null) {
                        surroundingTileImage =
                            new BufferedImage(TILE_SIZE, TILE_SIZE, BufferedImage.TYPE_INT_ARGB);
                        surroundingTileProvider.paintTile(surroundingTileImage, x, y, 0, 0);
                      }
                      g2.drawImage(
                          surroundingTileImage,
                          imageX + dx * subSize,
                          imageY + dy * subSize,
                          imageX + (dx + 1) * subSize,
                          imageY + (dy + 1) * subSize,
                          imageX + dx * subSize,
                          imageY + dy * subSize,
                          imageX + (dx + 1) * subSize,
                          imageY + (dy + 1) * subSize,
                          null);
                    } else {
                      g2.setColor(voidColour);
                      g2.fillRect(imageX + dx * subSize, imageY + dy * subSize, subSize, subSize);
                    }
                  }
                  TileRenderer tileRenderer = tileRendererRef.get();
                  tileRenderer.setTile(tile);
                  tileRenderer.renderTile(tileImage, dx * subSize, dy * subSize);
                  break;
                case BORDER:
                  Color colour;
                  switch (((Dimension) tileProvider).getBorder()) {
                    case WATER:
                      colour = waterColour;
                      break;
                    case LAVA:
                      colour = lavaColour;
                      break;
                    case VOID:
                      colour = voidColour;
                      break;
                    default:
                      throw new InternalError();
                  }
                  g2.setColor(colour);
                  g2.fillRect(imageX + dx * subSize, imageY + dy * subSize, subSize, subSize);

                  // Draw border lines
                  g2.setColor(Color.BLACK);
                  g2.setStroke(
                      new BasicStroke(
                          2,
                          BasicStroke.CAP_BUTT,
                          BasicStroke.JOIN_BEVEL,
                          0.0f,
                          new float[] {4.0f, 4.0f},
                          0.0f));
                  if (tileProvider.isTilePresent(x * scale + dx, y * scale + dy - 1)) {
                    g2.drawLine(
                        imageX + dx * subSize,
                        imageY + dy * subSize,
                        imageX + (dx + 1) * subSize - 1,
                        imageY + dy * subSize);
                  }
                  if (tileProvider.isTilePresent(x * scale + dx + 1, y * scale + dy)) {
                    g2.drawLine(
                        imageX + (dx + 1) * subSize - 1,
                        imageY + dy * subSize,
                        imageX + (dx + 1) * subSize - 1,
                        imageY + (dy + 1) * subSize - 1);
                  }
                  if (tileProvider.isTilePresent(x * scale + dx, y * scale + dy + 1)) {
                    g2.drawLine(
                        imageX + dx * subSize,
                        imageY + (dy + 1) * subSize - 1,
                        imageX + (dx + 1) * subSize - 1,
                        imageY + (dy + 1) * subSize - 1);
                  }
                  if (tileProvider.isTilePresent(x * scale + dx - 1, y * scale + dy)) {
                    g2.drawLine(
                        imageX + dx * subSize,
                        imageY + dy * subSize,
                        imageX + dx * subSize,
                        imageY + (dy + 1) * subSize - 1);
                  }
                  break;
                case SURROUNDS:
                case WALL:
                  if (surroundingTileProvider != null) {
                    if (surroundingTileImage == null) {
                      surroundingTileImage =
                          new BufferedImage(TILE_SIZE, TILE_SIZE, BufferedImage.TYPE_INT_ARGB);
                      surroundingTileProvider.paintTile(surroundingTileImage, x, y, 0, 0);
                    }
                    g2.drawImage(
                        surroundingTileImage,
                        imageX + dx * subSize,
                        imageY + dy * subSize,
                        imageX + (dx + 1) * subSize,
                        imageY + (dy + 1) * subSize,
                        imageX + dx * subSize,
                        imageY + dy * subSize,
                        imageX + (dx + 1) * subSize,
                        imageY + (dy + 1) * subSize,
                        null);
                  } else {
                    g2.setColor(voidColour);
                    g2.fillRect(imageX + dx * subSize, imageY + dy * subSize, subSize, subSize);
                  }
                  if (tileType == TileType.WALL) {
                    g2.setColor(bedrockColour);
                    TileType neighbourType =
                        getUnzoomedTileType(x * scale + dx, y * scale + dy - 1);
                    int wallWidth = Math.max(subSize / 8, 1);
                    if ((neighbourType == TileType.WORLD) || (neighbourType == TileType.BORDER)) {
                      g2.fillRect(imageX + dx * subSize, imageY + dy * subSize, subSize, wallWidth);
                    }
                    neighbourType = getUnzoomedTileType(x * scale + dx + 1, y * scale + dy);
                    if ((neighbourType == TileType.WORLD) || (neighbourType == TileType.BORDER)) {
                      g2.fillRect(
                          imageX + (dx + 1) * subSize - wallWidth,
                          imageY + dy * subSize,
                          wallWidth,
                          subSize);
                    }
                    neighbourType = getUnzoomedTileType(x * scale + dx, y * scale + dy + 1);
                    if ((neighbourType == TileType.WORLD) || (neighbourType == TileType.BORDER)) {
                      g2.fillRect(
                          imageX + dx * subSize,
                          imageY + (dy + 1) * subSize - wallWidth,
                          subSize,
                          wallWidth);
                    }
                    neighbourType = getUnzoomedTileType(x * scale + dx - 1, y * scale + dy);
                    if ((neighbourType == TileType.WORLD) || (neighbourType == TileType.BORDER)) {
                      g2.fillRect(imageX + dx * subSize, imageY + dy * subSize, wallWidth, subSize);
                    }
                  }
                  break;
              }
            }
          }
        } finally {
          g2.dispose();
        }
      }
    } catch (Throwable e) {
      // Log at debug level because this tends to happen when zooming in
      // and out, probably due to some state getting out of sync. It
      // doesn't so far appear to have any visible consequences.
      logger.error("Exception while generating image for tile at " + x + ", " + y, e);
    }
  }

  @Override
  public int getTilePriority(int x, int y) {
    if (zoom == 0) {
      return (getUnzoomedTileType(x, y) == TileType.WORLD) ? 1 : 0;
    } else {
      final int scale = 1 << -zoom;
      for (int dx = 0; dx < scale; dx++) {
        for (int dy = 0; dy < scale; dy++) {
          if (getUnzoomedTileType(x * scale + dx, y * scale + dy) == TileType.WORLD) {
            return 1;
          }
        }
      }
      return 0;
    }
  }

  @Override
  public Rectangle getExtent() {
    Rectangle sourceExtent = tileProvider.getExtent();
    if (sourceExtent != null) {
      if (zoom == 0) {
        return sourceExtent;
      } else if (zoom < 0) {
        return new Rectangle(
            sourceExtent.x >> -zoom,
            sourceExtent.y >> -zoom,
            sourceExtent.width >> -zoom,
            sourceExtent.height >> -zoom);
      } else {
        return new Rectangle(
            sourceExtent.x << zoom,
            sourceExtent.y << zoom,
            sourceExtent.width << zoom,
            sourceExtent.height << zoom);
      }
    } else {
      return null;
    }
  }

  @Override
  public void addTileListener(TileListener tileListener) {
    if (active && listeners.isEmpty()) {
      ((Dimension) tileProvider).addDimensionListener(this);
      for (Tile tile : ((Dimension) tileProvider).getTiles()) {
        tile.addListener(this);
      }
    }
    if (!listeners.contains(tileListener)) {
      listeners.add(tileListener);
    }
  }

  @Override
  public void removeTileListener(TileListener tileListener) {
    listeners.remove(tileListener);
    if (active && listeners.isEmpty()) {
      for (Tile tile : ((Dimension) tileProvider).getTiles()) {
        tile.removeListener(this);
      }
      ((Dimension) tileProvider).removeDimensionListener(this);
    }
  }

  @Override
  public boolean isZoomSupported() {
    return true;
  }

  @Override
  public int getZoom() {
    return zoom;
  }

  @Override
  public void setZoom(int zoom) {
    if (zoom != this.zoom) {
      if (zoom > 0) {
        throw new UnsupportedOperationException("Zooming in not supported");
      }
      this.zoom = zoom;
      tileRendererRef = createNewTileRendererRef();
      if (surroundingTileProvider != null) {
        surroundingTileProvider.setZoom(zoom);
      }
    }
  }

  // Dimension.Listener

  @Override
  public void tilesAdded(Dimension dimension, Set<Tile> tiles) {
    for (Tile tile : tiles) {
      tile.addListener(this);
    }
    fireTilesChangedIncludeBorder(tiles);
  }

  @Override
  public void tilesRemoved(Dimension dimension, Set<Tile> tiles) {
    for (Tile tile : tiles) {
      tile.removeListener(this);
    }
    fireTilesChangedIncludeBorder(tiles);
  }

  // Tile.Listener

  @Override
  public void heightMapChanged(Tile tile) {
    fireTileChanged(tile);
  }

  @Override
  public void terrainChanged(Tile tile) {
    fireTileChanged(tile);
  }

  @Override
  public void waterLevelChanged(Tile tile) {
    fireTileChanged(tile);
  }

  @Override
  public void layerDataChanged(Tile tile, Set<Layer> changedLayers) {
    fireTileChanged(tile);
  }

  @Override
  public void allBitLayerDataChanged(Tile tile) {
    fireTileChanged(tile);
  }

  @Override
  public void allNonBitlayerDataChanged(Tile tile) {
    fireTileChanged(tile);
  }

  @Override
  public void seedsChanged(Tile tile) {
    fireTileChanged(tile);
  }

  private TileType getUnzoomedTileType(int x, int y) {
    if (tileProvider.isTilePresent(x, y)) {
      return TileType.WORLD;
    } else if (showBorder && (tileProvider instanceof Dimension)) {
      Dimension dimension = (Dimension) tileProvider;
      if (dimension.isBorderTile(x, y)) {
        return TileType.BORDER;
      } else if (dimension.isBedrockWall()
          && ((dimension.getBorder() != null)
              ? (dimension.isBorderTile(x - 1, y)
                  || dimension.isBorderTile(x, y - 1)
                  || dimension.isBorderTile(x + 1, y)
                  || dimension.isBorderTile(x, y + 1))
              : (tileProvider.isTilePresent(x - 1, y)
                  || tileProvider.isTilePresent(x, y - 1)
                  || tileProvider.isTilePresent(x + 1, y)
                  || tileProvider.isTilePresent(x, y + 1)))) {
        return TileType.WALL;
      }
    }
    return TileType.SURROUNDS;
  }

  private void paintUnzoomedTile(
      final Image tileImage, final int x, final int y, final int dx, final int dy) {
    TileType tileType = getUnzoomedTileType(x, y);
    switch (tileType) {
      case WORLD:
        Tile tile = tileProvider.getTile(x, y);
        if (tile.hasLayer(NotPresent.INSTANCE) && (surroundingTileProvider != null)) {
          surroundingTileProvider.paintTile(tileImage, x, y, dx, dy);
        }
        TileRenderer tileRenderer = tileRendererRef.get();
        tileRenderer.setTile(tile);
        tileRenderer.renderTile(tileImage, dx, dy);
        break;
      case BORDER:
        int colour;
        switch (((Dimension) tileProvider).getBorder()) {
          case WATER:
            colour = colourScheme.getColour(BLK_WATER);
            break;
          case LAVA:
            colour = colourScheme.getColour(BLK_LAVA);
            break;
          case VOID:
            colour = VoidRenderer.getColour();
            break;
          default:
            throw new InternalError();
        }
        Graphics2D g2 = (Graphics2D) tileImage.getGraphics();
        try {
          g2.setColor(new Color(colour));
          g2.fillRect(dx, dy, TILE_SIZE, TILE_SIZE);

          // Draw border lines
          g2.setColor(Color.BLACK);
          g2.setStroke(
              new BasicStroke(
                  2,
                  BasicStroke.CAP_BUTT,
                  BasicStroke.JOIN_BEVEL,
                  0.0f,
                  new float[] {4.0f, 4.0f},
                  0.0f));
          if (tileProvider.isTilePresent(x, y - 1)) {
            g2.drawLine(dx + 1, dy + 1, dx + TILE_SIZE - 1, dy + 1);
          }
          if (tileProvider.isTilePresent(x + 1, y)) {
            g2.drawLine(dx + TILE_SIZE - 1, dy + 1, dx + TILE_SIZE - 1, dy + TILE_SIZE - 1);
          }
          if (tileProvider.isTilePresent(x, y + 1)) {
            g2.drawLine(dx + 1, dy + TILE_SIZE - 1, dx + TILE_SIZE - 1, dy + TILE_SIZE - 1);
          }
          if (tileProvider.isTilePresent(x - 1, y)) {
            g2.drawLine(dx + 1, dy + 1, dx + 1, dy + TILE_SIZE - 1);
          }
        } finally {
          g2.dispose();
        }
        break;
      case WALL:
        if (surroundingTileProvider != null) {
          surroundingTileProvider.paintTile(tileImage, x, y, dx, dy);
        }
        g2 = (Graphics2D) tileImage.getGraphics();
        try {
          if (surroundingTileProvider == null) {
            // A surrounding tile provider would have completely
            // filled the image, but since there isn't one we have
            // to make sure of that ourselves
            g2.setColor(new Color(VoidRenderer.getColour()));
            g2.fillRect(dx, dy, TILE_SIZE, TILE_SIZE);
          }
          g2.setColor(new Color(colourScheme.getColour(BLK_BEDROCK)));
          TileType neighbourType = getUnzoomedTileType(x, y - 1);
          if ((neighbourType == TileType.WORLD) || (neighbourType == TileType.BORDER)) {
            g2.fillRect(dx, dy, TILE_SIZE, 16);
          }
          neighbourType = getUnzoomedTileType(x + 1, y);
          if ((neighbourType == TileType.WORLD) || (neighbourType == TileType.BORDER)) {
            g2.fillRect(dx + TILE_SIZE - 16, dy, 16, TILE_SIZE);
          }
          neighbourType = getUnzoomedTileType(x, y + 1);
          if ((neighbourType == TileType.WORLD) || (neighbourType == TileType.BORDER)) {
            g2.fillRect(dx, dy + TILE_SIZE - 16, TILE_SIZE, 16);
          }
          neighbourType = getUnzoomedTileType(x - 1, y);
          if ((neighbourType == TileType.WORLD) || (neighbourType == TileType.BORDER)) {
            g2.fillRect(dx, dy, 16, TILE_SIZE);
          }
        } finally {
          g2.dispose();
        }
        break;
      case SURROUNDS:
        if (surroundingTileProvider != null) {
          surroundingTileProvider.paintTile(tileImage, x, y, dx, dy);
        }
        break;
      default:
        throw new InternalError();
    }
  }

  private void fireTileChanged(Tile tile) {
    Point coords = getTileCoordinates(tile);
    for (TileListener listener : listeners) {
      listener.tileChanged(this, coords.x, coords.y);
    }
  }

  private void fireTilesChangedIncludeBorder(Set<Tile> tiles) {
    if (showBorder
        && (tileProvider instanceof Dimension)
        && (((Dimension) tileProvider).getDim() == DIM_NORMAL)
        && (((Dimension) tileProvider).getBorder() != null)) {
      final Set<Point> coordSet = new HashSet<>();
      for (Tile tile : tiles) {
        final int tileX = tile.getX(),
            tileY = tile.getY(),
            borderSize = ((Dimension) tileProvider).getBorderSize();
        for (int dx = -borderSize; dx <= borderSize; dx++) {
          for (int dy = -borderSize; dy <= borderSize; dy++) {
            coordSet.add(getTileCoordinates(tileX + dx, tileY + dy));
          }
        }
      }
      for (TileListener listener : listeners) {
        listener.tilesChanged(this, coordSet);
      }
    } else {
      Set<Point> coords = tiles.stream().map(this::getTileCoordinates).collect(Collectors.toSet());
      for (TileListener listener : listeners) {
        listener.tilesChanged(this, coords);
      }
    }
  }

  /**
   * Convert the actual tile coordinates to zoom-corrected (tile provider coordinate system)
   * coordinates.
   *
   * @param tile The tile of which to convert the coordinates.
   * @return The coordinates of the tile in the tile provider coordinate system (corrected for
   *     zoom).
   */
  private Point getTileCoordinates(Tile tile) {
    return getTileCoordinates(tile.getX(), tile.getY());
  }

  /**
   * Convert the actual tile coordinates to zoom-corrected (tile provider coordinate system)
   * coordinates.
   *
   * @param tileX The X tile coordinate to convert.
   * @param tileY The Y tile coordinate to convert.
   * @return The coordinates of the tile in the tile provider coordinate system (corrected for
   *     zoom).
   */
  private Point getTileCoordinates(final int tileX, final int tileY) {
    if (zoom == 0) {
      return new Point(tileX, tileY);
    } else if (zoom < 0) {
      return new Point(tileX >> -zoom, tileY >> -zoom);
    } else {
      return new Point(tileX << zoom, tileY << zoom);
    }
  }

  @NotNull
  private ThreadLocal<TileRenderer> createNewTileRendererRef() {
    return new ThreadLocal<TileRenderer>() {
      @Override
      protected TileRenderer initialValue() {
        TileRenderer tileRenderer =
            new TileRenderer(tileProvider, colourScheme, biomeScheme, customBiomeManager, zoom);
        synchronized (WPTileProvider.this) {
          if (hiddenLayers != null) {
            tileRenderer.addHiddenLayers(hiddenLayers);
          }
        }
        tileRenderer.setContourLines(contourLines);
        tileRenderer.setContourSeparation(contourSeparation);
        tileRenderer.setLightOrigin(lightOrigin);
        return tileRenderer;
      }
    };
  }

  private final TileProvider tileProvider;
  private final ColourScheme colourScheme;
  private final BiomeScheme biomeScheme;
  private final Set<Layer> hiddenLayers;
  private final boolean contourLines, active, showBorder;
  private final int contourSeparation;
  private final TileRenderer.LightOrigin lightOrigin;
  private final List<TileListener> listeners = new ArrayList<>();
  private final CustomBiomeManager customBiomeManager;
  private final org.pepsoft.util.swing.TileProvider surroundingTileProvider;
  private int zoom = 0;
  private volatile ThreadLocal<TileRenderer> tileRendererRef;

  private static final org.slf4j.Logger logger =
      org.slf4j.LoggerFactory.getLogger(WPTileProvider.class);

  private enum TileType {
    /** The tile is part of the WorldPainter world. */
    WORLD,
    /** The tile is part of the WorldPainter border. */
    BORDER,
    /** The tile contains no WorldPainter-generated chunks. */
    SURROUNDS,
    /** The tile is outside the WorldPainter world and border but does contain part of a wall. */
    WALL
  }
}
/**
 * A localised operation which uses the mouse or tablet to indicate where and how it should be
 * applied.
 *
 * @author pepijn
 */
public abstract class MouseOrTabletOperation extends AbstractOperation
    implements PenListener, MouseListener, MouseMotionListener {
  /**
   * Creates a new one-shot operation (an operation which performs a single action when clicked).
   * {@link #tick(int, int, boolean, boolean, float)} will only be invoked once per activation for
   * these operations.
   *
   * @param name The short name of the operation. May be displayed on the operation's tool button.
   * @param description A longer description of the operation. May be displayed to the user as a
   *     tooltip.
   * @param view The WorldPainter view through which the dimension that is being edited is being
   *     displayed and on which the operation should install its listeners to register user mouse,
   *     keyboard and tablet actions.
   * @param statisticsKey The key with which use of this operation will be logged in the usage data
   *     sent back to the developer. Should start with a reverse-DNS style identifier, optionally
   *     followed by some basic or fundamental setting, if it has one.
   */
  protected MouseOrTabletOperation(
      String name, String description, WorldPainterView view, String statisticsKey) {
    this(name, description, view, -1, true, statisticsKey, null);
  }

  /**
   * Creates a new one-shot operation (an operation which performs a single action when clicked).
   * {@link #tick(int, int, boolean, boolean, float)} will only be invoked once per activation for
   * these operations.
   *
   * @param name The short name of the operation. May be displayed on the operation's tool button.
   * @param description A longer description of the operation. May be displayed to the user as a
   *     tooltip.
   * @param view The WorldPainter view through which the dimension that is being edited is being
   *     displayed and on which the operation should install its listeners to register user mouse,
   *     keyboard and tablet actions.
   * @param statisticsKey The key with which use of this operation will be logged in the usage data
   *     sent back to the developer. Should start with a reverse-DNS style identifier, optionally
   *     followed by some basic or fundamental setting, if it has one.
   * @param iconName The base name of the icon for the operation.
   */
  protected MouseOrTabletOperation(
      String name,
      String description,
      WorldPainterView view,
      String statisticsKey,
      String iconName) {
    this(name, description, view, -1, true, statisticsKey, iconName);
  }

  /**
   * Creates a new continuous operation (an operation which is continually performed while e.g. the
   * mouse button is held down). {@link #tick(int, int, boolean, boolean, float)} will be invoked
   * every <code>delay</code> milliseconds during each activation of these operations, with the
   * <code>first</code> parameter set to <code>true</code> for the first invocation per activation,
   * and set to <code>false</code> for all subsequent invocations per activation.
   *
   * @param name The short name of the operation. May be displayed on the operation's tool button.
   * @param description A longer description of the operation. May be displayed to the user as a
   *     tooltip.
   * @param view The WorldPainter view through which the dimension that is being edited is being
   *     displayed and on which the operation should install its listeners to register user mouse,
   *     keyboard and tablet actions.
   * @param delay The delay in ms between each invocation of {@link #tick(int, int, boolean,
   *     boolean, float)} while this operation is being applied by the user.
   * @param statisticsKey The key with which use of this operation will be logged in the usage data
   *     sent back to the developer. Should start with a reverse-DNS style identifier, optionally
   *     followed by some basic or fundamental setting, if it has one.
   */
  protected MouseOrTabletOperation(
      String name, String description, WorldPainterView view, int delay, String statisticsKey) {
    this(name, description, view, delay, false, statisticsKey, null);
  }

  /**
   * Creates a new continuous operation (an operation which is continually performed while e.g. the
   * mouse button is held down). {@link #tick(int, int, boolean, boolean, float)} will be invoked
   * every <code>delay</code> milliseconds during each activation of these operations, with the
   * <code>first</code> parameter set to <code>true</code> for the first invocation per activation,
   * and set to <code>false</code> for all subsequent invocations per activation.
   *
   * @param name The short name of the operation. May be displayed on the operation's tool button.
   * @param description A longer description of the operation. May be displayed to the user as a
   *     tooltip.
   * @param view The WorldPainter view through which the dimension that is being edited is being
   *     displayed and on which the operation should install its listeners to register user mouse,
   *     keyboard and tablet actions.
   * @param delay The delay in ms between each invocation of {@link #tick(int, int, boolean,
   *     boolean, float)} while this operation is being applied by the user.
   * @param statisticsKey The key with which use of this operation will be logged in the usage data
   *     sent back to the developer. Should start with a reverse-DNS style identifier, optionally
   *     followed by some basic or fundamental setting, if it has one.
   * @param iconName The base name of the icon for the operation.
   */
  protected MouseOrTabletOperation(
      String name,
      String description,
      WorldPainterView view,
      int delay,
      String statisticsKey,
      String iconName) {
    this(name, description, view, delay, false, statisticsKey, iconName);
  }

  /**
   * Creates a new one-shot operation (an operation which performs a single action when clicked).
   * {@link #tick(int, int, boolean, boolean, float)} will only be invoked once per activation for
   * these operations.
   *
   * @param name The short name of the operation. May be displayed on the operation's tool button.
   * @param description A longer description of the operation. May be displayed to the user as a
   *     tooltip.
   * @param statisticsKey The key with which use of this operation will be logged in the usage data
   *     sent back to the developer. Should start with a reverse-DNS style identifier, optionally
   *     followed by some basic or fundamental setting, if it has one.
   */
  protected MouseOrTabletOperation(String name, String description, String statisticsKey) {
    this(name, description, null, -1, true, statisticsKey, null);
  }

  /**
   * Creates a new continuous operation (an operation which is continually performed while e.g. the
   * mouse button is held down). {@link #tick(int, int, boolean, boolean, float)} will be invoked
   * every <code>delay</code> milliseconds during each activation of these operations, with the
   * <code>first</code> parameter set to <code>true</code> for the first invocation per activation,
   * and set to <code>false</code> for all subsequent invocations per activation.
   *
   * @param name The short name of the operation. May be displayed on the operation's tool button.
   * @param description A longer description of the operation. May be displayed to the user as a
   *     tooltip.
   * @param delay The delay in ms between each invocation of {@link #tick(int, int, boolean,
   *     boolean, float)} while this operation is being applied by the user.
   * @param statisticsKey The key with which use of this operation will be logged in the usage data
   *     sent back to the developer. Should start with a reverse-DNS style identifier, optionally
   *     followed by some basic or fundamental setting, if it has one.
   */
  protected MouseOrTabletOperation(
      String name, String description, int delay, String statisticsKey) {
    this(name, description, null, delay, false, statisticsKey, null);
  }

  private MouseOrTabletOperation(
      String name,
      String description,
      WorldPainterView view,
      int delay,
      boolean oneshot,
      String statisticsKey,
      String iconName) {
    super(
        name,
        description,
        (iconName != null) ? iconName : name.toLowerCase().replaceAll("\\s", ""));
    setView(view);
    this.delay = delay;
    this.oneShot = oneshot;
    this.statisticsKey = statisticsKey;
    statisticsKeyUndo = statisticsKey + ".undo";
    legacy =
        (SystemUtils.isMac() && System.getProperty("os.version").startsWith("10.4."))
            || "true"
                .equalsIgnoreCase(
                    System.getProperty("org.pepsoft.worldpainter.disableTabletSupport"));
    if (legacy) {
      logger.warn("Tablet support disabled for operation " + name);
    }
  }

  public Dimension getDimension() {
    return view.getDimension();
  }

  public final WorldPainterView getView() {
    return view;
  }

  @Override
  public final void setView(WorldPainterView view) {
    if (this.view != null) {
      deactivate();
    }
    this.view = view;
  }

  public float getLevel() {
    return level;
  }

  public void setLevel(float level) {
    if ((level < 0.0f) || (level > 1.0f)) {
      throw new IllegalArgumentException();
    }
    this.level = level;
  }

  // PenListener (these methods are invoked in non-legacy mode, even for mouse events)

  @Override
  public void penLevelEvent(PLevelEvent ple) {
    for (PLevel pLevel : ple.levels) {
      switch (pLevel.getType()) {
        case PRESSURE:
          dynamicLevel = pLevel.value;
          break;
        case X:
          x = pLevel.value;
          break;
        case Y:
          y = pLevel.value;
          break;
        default:
          // Do nothing
      }
    }
  }

  @Override
  public void penButtonEvent(PButtonEvent pbe) {
    PKind.Type penKindType = pbe.pen.getKind().getType();
    final boolean stylus = penKindType == PKind.Type.STYLUS;
    final boolean eraser = penKindType == PKind.Type.ERASER;
    if ((!stylus) && (!eraser) && (penKindType != PKind.Type.CURSOR)) {
      // We don't want events from keyboards, etc.
      return;
    }
    final PButton.Type buttonType = pbe.button.getType();
    switch (buttonType) {
      case ALT:
        altDown = pbe.button.value;
        break;
      case CONTROL:
        ctrlDown = pbe.button.value;
        break;
      case SHIFT:
        shiftDown = pbe.button.value;
        break;
      case LEFT:
      case RIGHT:
        if (pbe.button.value) {
          // Button pressed
          first = true;
          undo = eraser || (buttonType == PButton.Type.RIGHT) || altDown;
          if (!oneShot) {
            if (timer == null) {
              timer =
                  new Timer(
                      delay,
                      e -> {
                        Point worldCoords = view.viewToWorld((int) x, (int) y);
                        tick(
                            worldCoords.x,
                            worldCoords.y,
                            undo,
                            first,
                            (stylus || eraser) ? dynamicLevel : 1.0f);
                        view.updateStatusBar(worldCoords.x, worldCoords.y);
                        first = false;
                      });
              timer.setInitialDelay(0);
              timer.start();
              //                    start = System.currentTimeMillis();
            }
          } else {
            Point worldCoords = view.viewToWorld((int) x, (int) y);
            SwingUtilities.invokeLater(
                () -> {
                  tick(worldCoords.x, worldCoords.y, undo, true, 1.0f);
                  view.updateStatusBar(worldCoords.x, worldCoords.y);
                  Dimension dimension = getDimension();
                  if (dimension != null) {
                    dimension.armSavePoint();
                  }
                  logOperation(undo ? statisticsKeyUndo : statisticsKey);
                });
          }
        } else {
          // Button released
          if (!oneShot) {
            SwingUtilities.invokeLater(
                () -> {
                  if (timer != null) {
                    logOperation(undo ? statisticsKeyUndo : statisticsKey);
                    timer.stop();
                    timer = null;
                  }
                  finished();
                  Dimension dimension = getDimension();
                  if (dimension != null) {
                    dimension.armSavePoint();
                  }
                });
          }
        }
        break;
    }
  }

  @Override
  public void penKindEvent(PKindEvent pke) {}

  @Override
  public void penScrollEvent(PScrollEvent pse) {}

  @Override
  public void penTock(long l) {}

  // MouseListener (these methods are only invoked in legacy mode)

  @Override
  public void mousePressed(MouseEvent me) {
    x = me.getX();
    y = me.getY();
    altDown = me.isAltDown() || me.isAltGraphDown();
    undo = (me.getButton() == MouseEvent.BUTTON3) || altDown;
    ctrlDown = me.isControlDown() || me.isMetaDown();
    shiftDown = me.isShiftDown();
    first = true;
    if (!oneShot) {
      if (timer == null) {
        timer =
            new Timer(
                delay,
                e -> {
                  Point worldCoords = view.viewToWorld((int) x, (int) y);
                  tick(worldCoords.x, worldCoords.y, undo, first, 1.0f);
                  view.updateStatusBar(worldCoords.x, worldCoords.y);
                  first = false;
                });
        timer.setInitialDelay(0);
        timer.start();
        //                start = System.currentTimeMillis();
      }
    } else {
      Point worldCoords = view.viewToWorld((int) x, (int) y);
      tick(worldCoords.x, worldCoords.y, undo, true, 1.0f);
      view.updateStatusBar(worldCoords.x, worldCoords.y);
      Dimension dimension = getDimension();
      if (dimension != null) {
        dimension.armSavePoint();
      }
      logOperation(undo ? statisticsKeyUndo : statisticsKey);
    }
  }

  @Override
  public void mouseReleased(MouseEvent me) {
    if (!oneShot) {
      if (timer != null) {
        logOperation(undo ? statisticsKeyUndo : statisticsKey);
        timer.stop();
        timer = null;
      }
      finished();
      Dimension dimension = getDimension();
      if (dimension != null) {
        dimension.armSavePoint();
      }
    }
  }

  @Override
  public void mouseClicked(MouseEvent me) {}

  @Override
  public void mouseEntered(MouseEvent me) {}

  @Override
  public void mouseExited(MouseEvent me) {}

  // MouseMotionListener (these methods are only invoked in legacy mode)

  @Override
  public void mouseDragged(MouseEvent me) {
    x = me.getX();
    y = me.getY();
  }

  @Override
  public void mouseMoved(MouseEvent me) {
    altDown = me.isAltDown() || me.isAltGraphDown();
    ctrlDown = me.isControlDown() || me.isMetaDown();
    shiftDown = me.isShiftDown();
  }

  @Override
  protected void activate() throws PropertyVetoException {
    if (legacy) {
      view.addMouseListener(this);
      view.addMouseMotionListener(this);
    } else {
      AwtPenToolkit.addPenListener(view, this);
    }
    // Prevent hanging modifiers
    altDown = ctrlDown = shiftDown = false;
  }

  @Override
  protected void deactivate() {
    if (legacy) {
      view.removeMouseMotionListener(this);
      view.removeMouseListener(this);
    } else {
      AwtPenToolkit.removePenListener(view, this);
    }
  }

  /**
   * Apply the operation.
   *
   * @param centreX The x coordinate of the center of the brush, in world coordinates.
   * @param centreY The y coordinate of the center of the brush, in world coordinates.
   * @param inverse Whether to perform the "inverse" operation instead of the regular operation, if
   *     applicable. If the operation has no inverse it should just apply the normal operation.
   * @param first Whether this is the first tick of a continuous operation. For a one shot operation
   *     this will always be <code>true</code>.
   * @param dynamicLevel The dynamic level (from 0.0f to 1.0f inclusive) to apply in addition to the
   *     <code>level</code> property, for instance due to a pressure sensitive stylus being used. In
   *     other words, <strong>not</strong> the total level at which to apply the operation!
   *     Operations are free to ignore this if it is not applicable. If the operation is being
   *     applied through a means which doesn't provide a dynamic level (for instance the mouse),
   *     this will be <em>exactly</em> <code>1.0f</code>.
   */
  protected abstract void tick(
      int centreX, int centreY, boolean inverse, boolean first, float dynamicLevel);

  /**
   * Invoked after the last {@link #tick(int, int, boolean, boolean, float)} when the user ceases to
   * apply the operation (except for one shot operations).
   */
  protected void finished() {
    // Do nothing
  }

  /**
   * Determine whether the Alt (PC/Mac), AltGr (PC) or Option (Mac) key is currently depressed.
   * <strong>Warning:</strong> this key is also used to invert operations! It is probably a bad idea
   * to overload it with anything else.
   *
   * @return <code>true</code> if the Alt, AltGr or Option key is currently depressed.
   */
  protected final boolean isAltDown() {
    return altDown;
  }

  /**
   * Determine whether the Ctrl (PC/Mac), Windows (PC) or Command (Mac) key is currently depressed.
   *
   * @return <code>true</code> if the Ctrl (PC/Mac), Windows (PC) or Command (Mac) key is currently
   *     depressed.
   */
  protected final boolean isCtrlDown() {
    return ctrlDown;
  }

  /**
   * Determine whether the Shift key is currently depressed.
   *
   * @return <code>true</code> if the Shift key is currently depressed.
   */
  protected final boolean isShiftDown() {
    return shiftDown;
  }

  public static void flushEvents(EventLogger eventLogger) {
    synchronized (operationCounts) {
      for (Map.Entry<String, Long> entry : operationCounts.entrySet()) {
        eventLogger.logEvent(new EventVO(entry.getKey()).count(entry.getValue()));
      }
      operationCounts.clear();
    }
  }

  private static void logOperation(String key) {
    synchronized (operationCounts) {
      if (operationCounts.containsKey(key)) {
        operationCounts.put(key, operationCounts.get(key) + 1);
      } else {
        operationCounts.put(key, 1L);
      }
    }
  }

  protected final boolean legacy;

  private final int delay;
  private final boolean oneShot;
  private final String statisticsKey, statisticsKeyUndo;
  private WorldPainterView view;
  private volatile Timer timer;
  private volatile boolean altDown, ctrlDown, shiftDown, first = true, undo;
  private volatile float dynamicLevel = 1.0f;
  private volatile float x, y;
  private float level = 1.0f;
  //    private long start;

  private static final Map<String, Long> operationCounts = new HashMap<>();
  private static final org.slf4j.Logger logger =
      org.slf4j.LoggerFactory.getLogger(MouseOrTabletOperation.class);
}