/*
 * Copyright (C) 2007 Steve Ratcliffe
 * 
 *  This program is free software; you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License version 2 as
 *  published by the Free Software Foundation.
 * 
 *  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 General Public License for more details.
 * 
 * 
 * Author: Steve Ratcliffe
 * Create date: 20-Jan-2007
 */
package uk.me.parabola.mkgmap.build;

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

import uk.me.parabola.imgfmt.app.Area;
import uk.me.parabola.imgfmt.app.trergn.Zoom;
import uk.me.parabola.log.Logger;
import uk.me.parabola.mkgmap.general.MapDataSource;

/**
 * The map must be split into subdivisions.  To do this we start off with
 * one of these MapAreas containing all of the map and split it up into
 * smaller and smaller areas until each area is below a maximum size and
 * contains fewer than a maximum number of map features.
 *
 * @author Steve Ratcliffe
 */
public class MapSplitter {
	private static final Logger log = Logger.getLogger(MapSplitter.class);

	private final MapDataSource mapSource;

	// There is an absolute largest size as offsets are in 16 bits, but
	// the top bit of height is probably a flag for GMP "RoadRef" data.  
	public static final int MAX_DIVISION_SIZE = 0x7fff;

	// Not a real limit.  Note that the offset to the start of a section
	// has to fit into 16 bits, the end of the last section could be beyond the
	// 16 bit limit. Leave a little room for the region pointers
	public static final int MAX_RGN_OFFSET_SIZE = 0xfff8;

	// The maximum number of lines. NET points to lines in subdivision
	// using bytes.
	public static final int MAX_NUM_LINES = 0xff;

	public static final int MAX_NUM_POINTS = 0xff;

	// maximum allowed amounts of points/lines/shapes with extended types
	// real limits are not known but if these values are too large, data
	// goes missing (lines disappear, etc.)
	public static final int MAX_XT_POINTS_SIZE = 0xff00;
	public static final int MAX_XT_LINES_SIZE  = 0xff00;
	public static final int MAX_XT_SHAPES_SIZE = 0xff00;
	
	public static final int MIN_DIMENSION = 10; // just a reasonable value

	// The target number of estimated bytes for one area, smaller values
	// result in more and typically smaller areas and larger *.img files
	public static final int WANTED_MAX_AREA_SIZE = 0x3fff; 
	
	private final Zoom zoom;

	/**
	 * Creates a list of map areas and keeps splitting them down until they
	 * are small enough.  There is both a maximum size to an area and also
	 * a maximum number of things that will fit inside each division.
	 *
	 * Since these are not well defined (it all depends on how complicated the
	 * features are etc), we shall underestimate the maximum sizes and probably
	 * make them configurable.
	 *
	 * @param mapSource The input map data source.
	 * @param zoom The zoom level that we need to split for.
	 */
	MapSplitter(MapDataSource mapSource, Zoom zoom) {
		this.mapSource = mapSource;
		this.zoom = zoom;
	}

	/**
	 * This splits the map into a series of smaller areas.  There is both a
	 * maximum size and a maximum number of features that can be contained
	 * in a single area.
	 *
	 * This routine is not called recursively.
	 *
	 * @param orderByDecreasingArea aligns subareas as powerOf2 and splits polygons into the subareas.  
	 * @return An array of map areas, each of which is within the size limit
	 * and the limit on the number of features.
	 */
	public MapArea[] split(boolean orderByDecreasingArea) {
		log.debug("orig area", mapSource.getBounds());

		MapArea ma = initialArea(mapSource, orderByDecreasingArea);
		MapArea[] origArea = {ma};
		MapArea[] areas = splitMaxSize(ma);
		if (areas == null) {
			log.warn("initial split returned null for", ma);
			return origArea;
		}

		// Now step through each area and see if any have too many map features
		// in them.  For those that do, we further split them.  This is done
		// recursively until everything fits.
		List<MapArea> alist = new ArrayList<>();
		addAreasToList(areas, alist, 0);
		if (alist.isEmpty()) {
			return origArea;
		}

		MapArea[] results = new MapArea[alist.size()];
		return alist.toArray(results);
	}

	/**
	 * Adds map areas to a list.  If an area has too many features, then it
	 * is split into 2 and this routine is called recursively to add the new
	 * areas.
	 *
	 * @param areas The areas to add to the list (and possibly split up).
	 * @param alist The list that will finally contain the complete list of
	 * map areas.
	 */
	private void addAreasToList(MapArea[] areas, List<MapArea> alist, int depth) {
		int shift = zoom.getShiftValue();

		for (MapArea area : areas) {
			if (!area.hasData())
				continue;
			Area bounds = area.getBounds();
			int[] sizes = area.getEstimatedSizes();
			if(log.isInfoEnabled()) {
				String padding = depth + "                                                                      ";
				log.info(padding.substring(0, (depth + 1) * 2) + 
						 bounds.getWidth() + "x" + bounds.getHeight() +
						 ", points = " + area.getNumPoints() + "/" + sizes[MapArea.POINT_KIND] + "/" + sizes[MapArea.XT_POINT_KIND] +
						 ", lines = " + area.getNumLines() + "/" + sizes[MapArea.LINE_KIND] + "/" + sizes[MapArea.XT_LINE_KIND] +
						 ", shapes = " + area.getNumShapes() + "/" + sizes[MapArea.SHAPE_KIND] + "/" + sizes[MapArea.XT_SHAPE_KIND]);
			}

			boolean wantSplit = false;
			boolean mustSplit = false;
			if (area.getNumLines() > MAX_NUM_LINES || area.getNumPoints() > MAX_NUM_POINTS
					|| (sizes[MapArea.POINT_KIND] + sizes[MapArea.LINE_KIND]) > MAX_RGN_OFFSET_SIZE
					|| sizes[MapArea.XT_POINT_KIND] > MAX_XT_POINTS_SIZE
					|| sizes[MapArea.XT_LINE_KIND] > MAX_XT_LINES_SIZE
					|| sizes[MapArea.XT_SHAPE_KIND] > MAX_XT_SHAPES_SIZE)
				mustSplit = true;
			else if (bounds.getMaxDimension() > (MIN_DIMENSION << shift)) {
				int sumSize = 0;
				for (int s : sizes)
					sumSize += s;
				if (sumSize > WANTED_MAX_AREA_SIZE) {
					// area has more bytes than wanted, and is large enough to split
					log.debug("splitting area because estimated data size is larger than wanted:", sumSize);
					wantSplit = true;
				}
			}

			if (wantSplit || mustSplit) {
				if (!area.canSplit()) {
					if (!mustSplit) {
						log.info("Single item larger that WANTED_MAX_AREA_SIZE", area.getBounds().getCenter().toOSMURL());
					}
				} else if (bounds.getMaxDimension() > (MIN_DIMENSION << shift)) {
					log.debug("splitting area in half", area, mustSplit, wantSplit);
					MapArea[] sublist;
					if (bounds.getWidth() > bounds.getHeight())
						sublist = area.split(2, 1, bounds, false);
					else
						sublist = area.split(1, 2, bounds, false);
					if (sublist == null)
						log.error("SubDivision split failed at", area.getBounds().getCenter().toOSMURL());
					else {
						addAreasToList(sublist, alist, depth + 1);
						continue;
					}
				} else if (mustSplit) { // can't reduce size, so force more subdivisions
					log.debug("splitting area by contents", area);
					MapArea[] sublist = area.split(1, 1, bounds, true);
					addAreasToList(sublist, alist, depth + 1);
					continue;
				}
			}

			log.debug("adding area unsplit: has points", area.hasPoints());
			alist.add(area);
		}
	} // addAreasToList

    
	/**
	 * Split the area into portions that have the maximum size.  There is a
	 * maximum limit to the size of a subdivision (16 bits or about 1.4 degrees
	 * at the most detailed zoom level).
	 *
	 * The size depends on the shift level.
	 *
	 * We are choosing a limit smaller than the real max to allow for
	 * uncertainty about what happens with features that extend beyond the box.
	 *
	 * If the area is already small enough then it will be returned unchanged.
	 *
	 * @param mapArea The area that needs to be split down.
	 * @return An array of map areas.  Each will be below the max size.
	 */
	private MapArea[] splitMaxSize(MapArea mapArea) {
		/**
		 * mapArea.getBounds() comes from the original map source or parent/split MapArea.
		 * mapArea.getFullBounds() is calculated from elements added to the MapArea.
		 * Normally, mapArea.getBounds() and getFullBounds() are well defined and
		 * getFullBounds() is a little bit bigger than bounds() because lines/shapes are allowed
		 * to go slightly out of their area.
		 * MapArea.split() should use getBounds() because otherwise can get primary area overlap
		 * which conflicts with concept of orderByDecreasingArea.
		 * Some map sources might not set getBounds().
		 * If there are no elements, getFullBounds isEmpty.
		*/
		int shift = zoom.getShiftValue();
		Area bounds = mapArea.getBounds();
		if (bounds.isEmpty() || (!mapArea.getFullBounds().isEmpty()
				&& (mapArea.getFullBounds().getMaxDimension() >> shift) > MAX_DIVISION_SIZE))
			bounds = mapArea.getFullBounds();
		if (bounds.isEmpty())
			return null;

		int width = bounds.getWidth() >> shift;
		int height = bounds.getHeight() >> shift;

		// There is an absolute maximum size that a division can be.  Make sure
		// that we are well inside that.
		int xsplit = 1;
		if (width > MAX_DIVISION_SIZE)
			xsplit = width / MAX_DIVISION_SIZE + 1;

		int ysplit = 1;
		if (height > MAX_DIVISION_SIZE)
			ysplit = height / MAX_DIVISION_SIZE + 1;

		log.debug("splitMaxSize: bounds", bounds, "shift", shift, "width", width, "height", height, "xsplit", xsplit, "ysplit", ysplit);
		return mapArea.split(xsplit, ysplit, bounds, false);
	}

	/**
	 * The initial area contains all the features of the map.
	 *
	 * @param src The map data source.
	 * @param splitPolygonsIntoArea aligns subareas as powerOf2 and splits polygons into the subareas.  
	 * @return The initial map area covering the whole area and containing
	 * all the map features that are visible.
	 */
	private MapArea initialArea(MapDataSource src, boolean splitPolygonsIntoArea) {
		return new MapArea(src, zoom.getResolution(), splitPolygonsIntoArea);
	}
}
