package com.onthegomap.flatmap;

import com.onthegomap.flatmap.collection.FeatureGroup;
import com.onthegomap.flatmap.config.FlatmapConfig;
import com.onthegomap.flatmap.geo.GeoUtils;
import com.onthegomap.flatmap.geo.GeometryException;
import com.onthegomap.flatmap.geo.GeometryType;
import com.onthegomap.flatmap.reader.SourceFeature;
import com.onthegomap.flatmap.render.FeatureRenderer;
import com.onthegomap.flatmap.stats.Stats;
import com.onthegomap.flatmap.util.CacheByZoom;
import com.onthegomap.flatmap.util.ZoomFunction;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import org.locationtech.jts.geom.Geometry;

/**
 * Utility that {@link Profile} implementations use to build map features that should be emitted for an input source
 * feature.
 * <p>
 * For example to add a polygon feature for a lake and a center label point with its name:
 * <pre>{@code
 * featureCollector.polygon("water")
 *   .setAttr("class", "lake");
 * featureCollector.centroid("water_name")
 *   .setAttr("class", "lake")
 *   .setAttr("name", element.getString("name"));
 * }</pre>
 */
public class FeatureCollector implements Iterable<FeatureCollector.Feature> {

  private static final Geometry EMPTY_GEOM = GeoUtils.JTS_FACTORY.createGeometryCollection();

  private final SourceFeature source;
  private final List<Feature> output = new ArrayList<>();
  private final FlatmapConfig config;
  private final Stats stats;

  private FeatureCollector(SourceFeature source, FlatmapConfig config, Stats stats) {
    this.source = source;
    this.config = config;
    this.stats = stats;
  }

  @Override
  public Iterator<Feature> iterator() {
    return output.iterator();
  }

  /**
   * Starts building a new map feature with an explicit JTS {@code geometry} that overrides the source geometry.
   *
   * @param layer    the output vector tile layer this feature will be written to
   * @param geometry the explicit geometry to use instead of what is present in source data
   * @return a feature that can be configured further.
   */
  public Feature geometry(String layer, Geometry geometry) {
    Feature feature = new Feature(layer, geometry, source.id());
    output.add(feature);
    return feature;
  }

  /**
   * Starts building a new point map feature that expects the source feature to be a point.
   * <p>
   * If the source feature is not a point, logs an error and returns a feature that can be configured, but won't
   * actually emit anything to the map.
   *
   * @param layer the output vector tile layer this feature will be written to
   * @return a feature that can be configured further.
   */
  public Feature point(String layer) {
    try {
      if (!source.isPoint()) {
        throw new GeometryException("feature_not_point", "not a point");
      }
      return geometry(layer, source.worldGeometry());
    } catch (GeometryException e) {
      e.log(stats, "feature_point", "Error getting point geometry for " + source.id());
      return new Feature(layer, EMPTY_GEOM, source.id());
    }
  }

  /**
   * Starts building a new line map feature that expects the source feature to be a line.
   * <p>
   * If the source feature cannot be a line, logs an error and returns a feature that can be configured, but won't
   * actually emit anything to the map.
   * <p>
   * Some OSM closed OSM ways can be both a polygon and a line
   *
   * @param layer the output vector tile layer this feature will be written to
   * @return a feature that can be configured further.
   */
  public Feature line(String layer) {
    try {
      return geometry(layer, source.line());
    } catch (GeometryException e) {
      e.log(stats, "feature_line", "Error constructing line for " + source.id());
      return new Feature(layer, EMPTY_GEOM, source.id());
    }
  }

  /**
   * Starts building a new polygon map feature that expects the source feature to be a polygon.
   * <p>
   * If the source feature cannot be a polygon, logs an error and returns a feature that can be configured, but won't
   * actually emit anything to the map.
   * <p>
   * Some OSM closed OSM ways can be both a polygon and a line
   *
   * @param layer the output vector tile layer this feature will be written to
   * @return a feature that can be configured further.
   */
  public Feature polygon(String layer) {
    try {
      return geometry(layer, source.polygon());
    } catch (GeometryException e) {
      e.log(stats, "feature_polygon", "Error constructing polygon for " + source.id());
      return new Feature(layer, EMPTY_GEOM, source.id());
    }
  }

  /**
   * Starts building a new point map feature with geometry from {@link Geometry#getCentroid()} of the source feature.
   *
   * @param layer the output vector tile layer this feature will be written to
   * @return a feature that can be configured further.
   */
  public Feature centroid(String layer) {
    try {
      return geometry(layer, source.centroid());
    } catch (GeometryException e) {
      e.log(stats, "feature_centroid", "Error getting centroid for " + source.id());
      return new Feature(layer, EMPTY_GEOM, source.id());
    }
  }

  /**
   * Starts building a new point map feature with geometry from {@link Geometry#getCentroid()} if the source feature is
   * a point, line, or simple convex polygon, or {@link Geometry#getInteriorPoint()} if it is a multipolygon, polygon
   * with holes, or concave simple polygon.
   *
   * @param layer the output vector tile layer this feature will be written to
   * @return a feature that can be configured further.
   */
  public Feature centroidIfConvex(String layer) {
    try {
      return geometry(layer, source.centroidIfConvex());
    } catch (GeometryException e) {
      e.log(stats, "feature_centroid_if_convex", "Error constructing centroid if convex for " + source.id());
      return new Feature(layer, EMPTY_GEOM, source.id());
    }
  }

  /**
   * Starts building a new point map feature with geometry from {@link Geometry#getInteriorPoint()} of the source
   * feature.
   *
   * @param layer the output vector tile layer this feature will be written to
   * @return a feature that can be configured further.
   */
  public Feature pointOnSurface(String layer) {
    try {
      return geometry(layer, source.pointOnSurface());
    } catch (GeometryException e) {
      e.log(stats, "feature_point_on_surface", "Error constructing point on surface for " + source.id());
      return new Feature(layer, EMPTY_GEOM, source.id());
    }
  }

  /**
   * Creates new feature collector instances for each source feature that we encounter.
   */
  public static record Factory(FlatmapConfig config, Stats stats) {

    public FeatureCollector get(SourceFeature source) {
      return new FeatureCollector(source, config, stats);
    }
  }

  /**
   * A builder for an output map feature that contains all the information that will be needed to render vector tile
   * features from the input element.
   * <p>
   * Some feature attributes are set globally (like sort key), and some allow the value to change by zoom-level (like
   * tags).
   */
  public final class Feature {

    private static final double DEFAULT_LABEL_GRID_SIZE = 0;
    private static final int DEFAULT_LABEL_GRID_LIMIT = 0;

    private final String layer;
    private final Geometry geom;
    private final Map<String, Object> attrs = new TreeMap<>();
    private final GeometryType geometryType;
    private final long sourceId;

    private int sortKey = 0;

    private int minzoom = config.minzoom();
    private int maxzoom = config.maxzoom();

    private ZoomFunction<Number> labelGridPixelSize = null;
    private ZoomFunction<Number> labelGridLimit = null;

    private boolean attrsChangeByZoom = false;
    private CacheByZoom<Map<String, Object>> attrCache = null;

    private double defaultBufferPixels = 4;
    private ZoomFunction<Number> bufferPixelOverrides;

    // TODO better API for default value, value at max zoom, and zoom-specific overrides for tolerance and min size?
    private double defaultMinPixelSize = config.minFeatureSizeBelowMaxZoom();
    private double minPixelSizeAtMaxZoom = config.minFeatureSizeAtMaxZoom();
    private ZoomFunction<Number> minPixelSize = null;

    private double defaultPixelTolerance = config.simplifyToleranceBelowMaxZoom();
    private double pixelToleranceAtMaxZoom = config.simplifyToleranceAtMaxZoom();
    private ZoomFunction<Number> pixelTolerance = null;

    private String numPointsAttr = null;

    private Feature(String layer, Geometry geom, long sourceId) {
      this.layer = layer;
      this.geom = geom;
      this.geometryType = GeometryType.valueOf(geom);
      this.sourceId = sourceId;
    }

    /** Returns the original ID of the source feature that this feature came from (i.e. OSM node/way ID). */
    public long getSourceId() {
      return sourceId;
    }

    GeometryType getGeometryType() {
      return geometryType;
    }

    public boolean isPolygon() {
      return geometryType == GeometryType.POLYGON;
    }

    /**
     * Returns the value by which features are sorted within a layer in the output vector tile.
     */
    public int getSortKey() {
      return sortKey;
    }

    /**
     * Sets the value by which features are sorted within a layer in the output vector tile. Sort key gets packed into
     * {@link FeatureGroup#SORT_KEY_BITS} bits so the range of this is limited to {@code -(2^(bits-1))} to {@code
     * (2^(bits-1))-1}.
     * <p>
     * Circles, lines, and polygons are rendered in the order they appear in each layer, so features that appear later
     * (higher sort key) show up on top of features with a lower sort key.
     * <p>
     * For symbols (text/icons) where clients try to avoid label collisions, features are placed in the order they
     * appear in each layer, so features that appear earlier (lower sort key) will show up at lower zoom levels than
     * feature that appear later (higher sort key) in a layer.
     */
    public Feature setSortKey(int sortKey) {
      assert sortKey >= FeatureGroup.SORT_KEY_MIN && sortKey <= FeatureGroup.SORT_KEY_MAX :
        "Sort key " + sortKey + " outside of allowed range [" + FeatureGroup.SORT_KEY_MIN + ", "
          + FeatureGroup.SORT_KEY_MAX + "]";
      this.sortKey = sortKey;
      return this;
    }

    /** Sets the value by which features are sorted from high to low within a layer in the output vector tile. */
    public Feature setSortKeyDescending(int sortKey) {
      return setSortKey(FeatureGroup.SORT_KEY_MAX + FeatureGroup.SORT_KEY_MIN - sortKey);
    }

    /**
     * Sets the zoom range (inclusive) that this feature appears in.
     * <p>
     * If not called, then defaults to all zoom levels.
     */
    public Feature setZoomRange(int min, int max) {
      assert min <= max;
      return setMinZoom(min).setMaxZoom(max);
    }

    /** Returns the minimum zoom level (inclusive) that this feature appears in. */
    public int getMinZoom() {
      return minzoom;
    }


    /**
     * Sets the minimum zoom level (inclusive) that this feature appears in.
     * <p>
     * If not called, defaults to minimum zoom-level of the map.
     */
    public Feature setMinZoom(int min) {
      minzoom = Math.max(min, config.minzoom());
      return this;
    }

    /** Returns the maximum zoom level (inclusive) that this feature appears in. */
    public int getMaxZoom() {
      return maxzoom;
    }

    /**
     * Sets the maximum zoom level (inclusive) that this feature appears in.
     * <p>
     * If not called, defaults to maximum zoom-level of the map.
     */
    public Feature setMaxZoom(int max) {
      maxzoom = Math.min(max, config.maxzoom());
      return this;
    }

    /** Returns the output vector tile layer that this feature will appear in. */
    public String getLayer() {
      return layer;
    }

    /**
     * Returns the JTS geometry (in world web mercator coordinates) of this feature.
     * <p>
     * Subsequent postprocessing in {@link FeatureRenderer} will slice this into tile geometries.
     */
    public Geometry getGeometry() {
      return geom;
    }

    /** Returns the number of pixels of detail to render outside the visible tile boundary at {@code zoom}. */
    public double getBufferPixelsAtZoom(int zoom) {
      return ZoomFunction.applyAsDoubleOrElse(bufferPixelOverrides, zoom, defaultBufferPixels);
    }

    /**
     * Sets the default number of pixels of detail to render outside the visible tile boundary when no zoom-specific
     * override is set in {@link #setBufferPixelOverrides(ZoomFunction)}.
     */
    public Feature setBufferPixels(double buffer) {
      defaultBufferPixels = buffer;
      return this;
    }

    /**
     * Sets zoom-specific overrides to the number of pixels of detail to render outside the visible tile boundary.
     * <p>
     * If {@code buffer} is {@code null} or returns {@code null}, the buffer pixels will default to {@link
     * #setBufferPixels(double)}.
     */
    public Feature setBufferPixelOverrides(ZoomFunction<Number> buffer) {
      bufferPixelOverrides = buffer;
      return this;
    }


    /**
     * Returns the minimum resolution in tile pixels of features to emit at {@code zoom}.
     * <p>
     * For line features, this is length, and for polygon features this is the square root of the minimum area of
     * features to emit.
     */
    public double getMinPixelSizeAtZoom(int zoom) {
      return zoom == config.maxzoom() ? minPixelSizeAtMaxZoom
        : ZoomFunction.applyAsDoubleOrElse(minPixelSize, zoom, defaultMinPixelSize);
    }

    /**
     * Sets the minimum length of line features or square root of the minimum area of polygon features to emit below the
     * maximum zoom-level of the map.
     * <p>
     * At the maximum zoom level of the map, clients can "overzoom" in on features, so this leaves the minimum size at
     * the max zoom level at {@link FlatmapConfig#minFeatureSizeAtMaxZoom()} unless you explicitly override it with
     * {@link #setMinPixelSizeAtMaxZoom(double)} or {@link #setMinPixelSizeAtAllZooms(int)}.
     */
    public Feature setMinPixelSize(double minPixelSize) {
      this.defaultMinPixelSize = minPixelSize;
      return this;
    }

    /**
     * Sets zoom-specific overrides to the minimum length of line features or square root of the minimum area of polygon
     * features to emit below the maximum zoom-level of the map.
     * <p>
     * At the maximum zoom level of the map, clients can "overzoom" in on features, so this leaves the minimum size at
     * the max zoom level at {@link FlatmapConfig#minFeatureSizeAtMaxZoom()} unless you explicitly override it with
     * {@link #setMinPixelSizeAtMaxZoom(double)} or {@link #setMinPixelSizeAtAllZooms(int)}.
     * <p>
     * If {@code levels} is {@code null} or returns {@code null}, the min pixel size will default to the default value.
     */
    public Feature setMinPixelSizeOverrides(ZoomFunction<Number> levels) {
      this.minPixelSize = levels;
      return this;
    }

    /**
     * Overrides the default minimum pixel size at and below {@code zoom} with {@code minPixelSize}.
     * <p>
     * This replaces all previous zoom overrides that were set. To use multiple zoom-level thresholds, create a {@link
     * ZoomFunction} explicitly and pass it to {@link #setMinPixelSizeOverrides(ZoomFunction)}.
     */
    public Feature setMinPixelSizeBelowZoom(int zoom, double minPixelSize) {
      if (zoom >= config.maxzoom()) {
        minPixelSizeAtMaxZoom = minPixelSize;
      }
      this.minPixelSize = ZoomFunction.maxZoom(zoom, minPixelSize);
      return this;
    }

    /**
     * Sets the minimum length of line features or square root of the minimum area of polygon features to emit at the
     * maximum zoom-level of the map.
     * <p>
     * Since clients can "overzoom" in on features past the maximum zoom level, this is typically much smaller than min
     * pixel size at lower zoom levels.
     * <p>
     * This overrides, but does not replace the default min pixel size or overrides set through other methods.
     */
    public Feature setMinPixelSizeAtMaxZoom(double minPixelSize) {
      this.minPixelSizeAtMaxZoom = minPixelSize;
      return this;
    }

    /**
     * Sets the minimum length of line features or square root of the minimum area of polygon features to emit at all
     * zoom levels, including the maximum zoom-level of the map.
     * <p>
     * This replaces previous default values, but not overrides set with {@link #setMinPixelSizeOverrides(ZoomFunction)}.
     */
    public Feature setMinPixelSizeAtAllZooms(int minPixelSize) {
      this.minPixelSizeAtMaxZoom = minPixelSize;
      return this.setMinPixelSize(minPixelSize);
    }

    /**
     * Returns the simplification tolerance for lines and polygons in tile pixels at {@code zoom}.
     */
    public double getPixelToleranceAtZoom(int zoom) {
      return zoom == config.maxzoom() ? pixelToleranceAtMaxZoom
        : ZoomFunction.applyAsDoubleOrElse(pixelTolerance, zoom, defaultPixelTolerance);
    }

    /**
     * Sets the simplification tolerance for lines and polygons in tile pixels below the maximum zoom-level of the map.
     * <p>
     * Since clients can "overzoom" past the max zoom of the map, this is typically smaller than the default tolerance
     * to provide more detail as you zoom in.
     * <p>
     * This does not replace any overrides that were set with {@link #setPixelToleranceOverrides(ZoomFunction)}.
     */
    public Feature setPixelTolerance(double tolerance) {
      this.defaultPixelTolerance = tolerance;
      return this;
    }

    /**
     * Sets the simplification tolerance for lines and polygons in tile pixels at the maximum zoom-level of the map.
     * <p>
     * This does not replace the default value at other zoom levels set through {@link #setPixelTolerance(double)} any
     * zoom-specific overrides that were set with {@link #setPixelToleranceOverrides(ZoomFunction)}.
     */
    public Feature setPixelToleranceAtMaxZoom(double tolerance) {
      this.pixelToleranceAtMaxZoom = tolerance;
      return this;
    }

    /**
     * Sets the simplification tolerance for lines and polygons in tile pixels including at the maximum zoom-level of
     * the map.
     * <p>
     * This does not replace the default value at other zoom levels set through {@link #setPixelTolerance(double)}.
     */
    public Feature setPixelToleranceAtAllZooms(double tolerance) {
      return setPixelToleranceAtMaxZoom(tolerance).setPixelTolerance(tolerance);
    }

    /**
     * Sets zoom-specific overrides to the simplification tolerance for lines and polygons in tile pixels below the
     * maximum zoom-level of the map.
     * <p>
     * At the maximum zoom level of the map, clients can "overzoom" in on features, so this leaves the tolerance at the
     * max zoom level set to {@link FlatmapConfig#simplifyToleranceAtMaxZoom()} unless you explicitly override it with
     * {@link #setMinPixelSizeAtAllZooms(int)} or {@link #setMinPixelSizeAtMaxZoom(double)}.
     * <p>
     * If {@code levels} is {@code null} or returns {@code null}, the min pixel size will default to the default value.
     */
    public Feature setPixelToleranceOverrides(ZoomFunction<Number> overrides) {
      this.pixelTolerance = overrides;
      return this;
    }

    /**
     * Overrides the default simplification tolerance for lines and polygons in tile pixels at and below {@code zoom}
     * with {@code minPixelSize}.
     * <p>
     * This replaces all previous zoom overrides that were set. To use multiple zoom-level thresholds, create a {@link
     * ZoomFunction} explicitly and pass it to {@link #setPixelToleranceOverrides(ZoomFunction)}
     */
    public Feature setPixelToleranceBelowZoom(int zoom, double tolerance) {
      if (zoom == config.maxzoom()) {
        pixelToleranceAtMaxZoom = tolerance;
      }
      return setPixelToleranceOverrides(ZoomFunction.maxZoom(zoom, tolerance));
    }

    public boolean hasLabelGrid() {
      return labelGridPixelSize != null || labelGridLimit != null;
    }

    /**
     * Returns the size in pixels of the grid used to group or limit output points.
     *
     * @throws AssertionError when assertions are enabled and the returned value is smaller than the buffer pixel size
     */
    public double getPointLabelGridPixelSizeAtZoom(int zoom) {
      double result = ZoomFunction.applyAsDoubleOrElse(labelGridPixelSize, zoom, DEFAULT_LABEL_GRID_SIZE);
      // TODO is this enough? what about a grid square that ends just before the start of the tile
      assert result <= getBufferPixelsAtZoom(zoom) :
        "to avoid inconsistent rendering of the same point between adjacent tiles, buffer pixel size should be >= label grid size but in '%s' buffer pixel size=%f was greater than label grid size=%f".formatted(
          getLayer(), getBufferPixelsAtZoom(zoom), result);
      return result;
    }

    /**
     * Returns the maximum number of lowest-sort-key points to include in the output vector tile in each square of a
     * grid with size {@link #getPointLabelGridPixelSizeAtZoom(int)}.
     */
    public int getPointLabelGridLimitAtZoom(int zoom) {
      return ZoomFunction.applyAsIntOrElse(labelGridLimit, zoom, DEFAULT_LABEL_GRID_LIMIT);
    }

    /**
     * Sets the size of a grid in tile pixels that will be used to compute a "location hash" of points rendered in each
     * zoom-level for limiting the density of features in the output tile, or computing a "rank" key that clients can
     * use to control label density.
     * <p>
     * If limit is set, features will be dropped automatically before encoding the vector tile, but "rank" must be added
     * explicitly in {@link Profile#postProcessLayerFeatures(String, int, List)}.
     * <p>
     * Replaces any previous values set for label grid pixel size.
     * <p>
     * NOTE: the label grid is computed within each tile independently of its neighbors, so to ensure consistent limits
     * and ranking of a point rendered in adjacent tiles, be sure to set the buffer pixel size to at least be larger
     * than the label grid pixel size at each zoom level.
     *
     * @param labelGridSize a function that returns the size of the label grid to use at each zoom level. If function is
     *                      or returns null for a zoom-level, no label grid will be computed.
     * @see <a href="https://github.com/mapbox/postgis-vt-util/blob/master/src/LabelGrid.sql">LabelGrid postgis
     * function</a>
     */
    public Feature setPointLabelGridPixelSize(ZoomFunction<Number> labelGridSize) {
      this.labelGridPixelSize = labelGridSize;
      return this;
    }

    /**
     * Sets the size of a grid in tile pixels that will be used to compute a "location hash" of points rendered in each
     * zoom-level at and below {@code maxzoom}.
     * <p>
     * This is a thin wrapper around {@link #setPointLabelGridPixelSize(ZoomFunction)}. It replaces any previous value
     * set for label grid size. To set multiple thresholds, use the other method directly.
     *
     * @see <a href="https://github.com/mapbox/postgis-vt-util/blob/master/src/LabelGrid.sql">LabelGrid postgis
     * function</a>
     */
    public Feature setPointLabelGridPixelSize(int maxzoom, double size) {
      return setPointLabelGridPixelSize(ZoomFunction.maxZoom(maxzoom, size));
    }

    /**
     * Sets the maximum number of points with the lowest sort-key to include with the same label grid hash in a tile.
     * <p>
     * Replaces any previous values set for label grid limit.
     *
     * @param labelGridLimit a function that returns the size of the label grid to use at each zoom level. If function
     *                       is or returns null for a zoom-level, no label grid will be computed.
     * @see <a href="https://github.com/mapbox/postgis-vt-util/blob/master/src/LabelGrid.sql">LabelGrid postgis
     * function</a>
     */
    public Feature setPointLabelGridLimit(ZoomFunction<Number> labelGridLimit) {
      this.labelGridLimit = labelGridLimit;
      return this;
    }

    /**
     * Limits the density of points on an output tile at and below {@code maxzoom} by only emitting the {@code limit}
     * features with lowest sort-key in each square of a {@code size x size} pixel grid.
     * <p>
     * This is a thin wrapper around {@link #setPointLabelGridPixelSize(ZoomFunction)} and {@link
     * #setPointLabelGridLimit(ZoomFunction)}. It replaces any previous value set for label grid size or limit. To set
     * multiple thresholds, use the other methods directly.
     * <p>
     * NOTE: the label grid is computed within each tile independently of its neighbors, so to ensure consistent limits
     * and ranking of a point rendered in adjacent tiles, be sure to set the buffer pixel size to at least be larger
     * than the label grid pixel size at each zoom level.
     *
     * @param maxzoom the zoom-level at and below which we should limit point density
     * @param size    the label grid size to use when computing hashes
     * @param limit   the number of lowest-sort-key points to include in each square of the grid
     * @see <a href="https://github.com/mapbox/postgis-vt-util/blob/master/src/LabelGrid.sql">LabelGrid postgis
     * function</a>
     */
    public Feature setPointLabelGridSizeAndLimit(int maxzoom, double size, int limit) {
      return setPointLabelGridPixelSize(ZoomFunction.maxZoom(maxzoom, size))
        .setPointLabelGridLimit(ZoomFunction.maxZoom(maxzoom, limit));
    }

    // could be expensive, so cache results
    private Map<String, Object> computeAttrsAtZoom(int zoom) {
      Map<String, Object> result = new TreeMap<>();
      for (var entry : attrs.entrySet()) {
        Object value = entry.getValue();
        if (value instanceof ZoomFunction<?> fn) {
          value = fn.apply(zoom);
        }
        if (value != null && !"".equals(value)) {
          result.put(entry.getKey(), value);
        }
      }
      return result;
    }

    /** Returns the attribute to put on all output vector tile features at a zoom level. */
    public Map<String, Object> getAttrsAtZoom(int zoom) {
      if (!attrsChangeByZoom) {
        return attrs;
      }
      if (attrCache == null) {
        attrCache = CacheByZoom.create(config, this::computeAttrsAtZoom);
      }
      return attrCache.get(zoom);
    }

    /** Copies the value for {@code key} attribute from source feature to the output feature. */
    public Feature inheritAttrFromSource(String key) {
      return setAttr(key, source.getTag(key));
    }

    /**
     * Sets an attribute on the output feature to either a string, number, boolean, or instance of {@link ZoomFunction}
     * to change the value for {@code key} by zoom-level.
     */
    public Feature setAttr(String key, Object value) {
      if (value instanceof ZoomFunction) {
        attrsChangeByZoom = true;
      }
      if (value != null) {
        attrs.put(key, value);
      }
      return this;
    }

    /**
     * Sets the value for {@code key} attribute at or above {@code minzoom}.  Below {@code minzoom} it will be ignored.
     * <p>
     * Replaces all previous value that has been for {@code key} at any zoom level.  To have a value that changes at
     * multiple zoom level thresholds, call {@link #setAttr(String, Object)} with a manually-constructed {@link
     * ZoomFunction} value.
     */
    public Feature setAttrWithMinzoom(String key, Object value, int minzoom) {
      return setAttr(key, ZoomFunction.minZoom(minzoom, value));
    }

    /**
     * Inserts all key/value pairs in {@code attrs} into the set of attribute to emit on the output feature.
     * <p>
     * Does not touch attributes that have already been set.
     * <p>
     * Values in {@code attrs} can either be the raw value to set, or an instance of {@link ZoomFunction} to change the
     * value for that attribute by zoom level.
     */
    public Feature putAttrs(Map<String, Object> attrs) {
      for (Object value : attrs.values()) {
        if (value instanceof ZoomFunction) {
          attrsChangeByZoom = true;
          break;
        }
      }
      this.attrs.putAll(attrs);
      return this;
    }

    /**
     * Sets a special attribute key that the renderer will use to store the number of points in the simplified geometry
     * before slicing it into tiles.
     */
    public Feature setNumPointsAttr(String numPointsAttr) {
      this.numPointsAttr = numPointsAttr;
      return this;
    }

    /**
     * Returns the attribute key that the renderer should use to store the number of points in the simplified geometry
     * before slicing it into tiles.
     */
    public String getNumPointsAttr() {
      return numPointsAttr;
    }

    @Override
    public String toString() {
      return "Feature{" +
        "layer='" + layer + '\'' +
        ", geom=" + geom.getGeometryType() +
        ", attrs=" + attrs +
        '}';
    }
  }
}
