/*
 * Copyright (C) 2019.
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License version 3 or
 * 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.
 */

package uk.me.parabola.mkgmap.osmstyle.function;

import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

import uk.me.parabola.imgfmt.ExitException;
import uk.me.parabola.imgfmt.app.Area;
import uk.me.parabola.imgfmt.app.Coord;
import uk.me.parabola.log.Logger;
import uk.me.parabola.mkgmap.reader.osm.Element;
import uk.me.parabola.mkgmap.reader.osm.ElementSaver;
import uk.me.parabola.mkgmap.reader.osm.FeatureKind;
import uk.me.parabola.mkgmap.reader.osm.MultiPolygonRelation;
import uk.me.parabola.mkgmap.reader.osm.Node;
import uk.me.parabola.mkgmap.reader.osm.Way;
import uk.me.parabola.mkgmap.scan.SyntaxException;
import uk.me.parabola.util.ElementQuadTree;
import uk.me.parabola.util.IsInUtil;

/**
 *
 * @author Ticker Berkin
 *
 */
public class IsInFunction extends CachedFunction { // StyleFunction
	private static final Logger log = Logger.getLogger(IsInFunction.class);

	private enum MethodArg {

		//                                       can stop when: IN     ON     OUT    MERGE
		POINT_IN("in",                    FeatureKind.POINT,    true,  false, false, true)
			{ @Override public boolean mapFlags(boolean hasIn, boolean hasOn, boolean hasOut) {return hasIn;} },
		POINT_IN_OR_ON("in_or_on",        FeatureKind.POINT,    true,  true,  false, false)
			{ @Override public boolean mapFlags(boolean hasIn, boolean hasOn, boolean hasOut) {return hasIn || hasOn;} },
		POINT_ON("on",                    FeatureKind.POINT,    false, true,  false, true)
			{ @Override public boolean mapFlags(boolean hasIn, boolean hasOn, boolean hasOut) {return hasOn;} },

		LINE_SOME_IN_NONE_OUT("all",      FeatureKind.POLYLINE, false, false, true,  true)
			{ @Override public boolean mapFlags(boolean hasIn, boolean hasOn, boolean hasOut) {return hasIn && !hasOut;} },
		LINE_ALL_IN_OR_ON("all_in_or_on", FeatureKind.POLYLINE, false, false, true,  true)
			{ @Override public boolean mapFlags(boolean hasIn, boolean hasOn, boolean hasOut) {return !hasOut;} },
		LINE_ALL_ON("on",                 FeatureKind.POLYLINE, true,  false, true,  true)
			{ @Override public boolean mapFlags(boolean hasIn, boolean hasOn, boolean hasOut) {return !(hasIn || hasOut);} },
		LINE_ANY_IN("any",                FeatureKind.POLYLINE, true,  false, false, true)
			{ @Override public boolean mapFlags(boolean hasIn, boolean hasOn, boolean hasOut) {return hasIn;} },
//		LINE_ANY_IN_OR_ON("any_in_or_on", FeatureKind.POLYLINE, true,  false, false, true)
//			{ @Override public boolean mapFlags(boolean hasIn, boolean hasOn, boolean hasOut) {return hasIn || !hasOut;} },
		LINE_NONE_IN_SOME_OUT("none",     FeatureKind.POLYLINE, true,  false, false, true)
			{ @Override public boolean mapFlags(boolean hasIn, boolean hasOn, boolean hasOut) {return !hasIn && hasOut;} },
		
		POLYGON_ALL("all",                FeatureKind.POLYGON,  false, false, true,  true)
			{ @Override public boolean mapFlags(boolean hasIn, boolean hasOn, boolean hasOut) {return !hasOut;} },
		POLYGON_ANY("any",                FeatureKind.POLYGON,  true,  false, false, false)
			{ @Override public boolean mapFlags(boolean hasIn, boolean hasOn, boolean hasOut) {return hasIn || !hasOut;} };

/* thoughts for ON methods for polyons and the hasOn flag

possible methods:
 on_outer / on
 on_inner / hole
 on_either
 all_or_inner - to match, say building, even when cut out of area

on_outer is ok, with just ON.
on_inner would be logical to represent as ON|OUT
but, at the moment, an outside line/poly touching an outer will also set this combination

Could:
 don't hasOn() when isLineInShape returns IN|ON|OUT (in setHasFromFlags)
 other places where currently call hasOn(), test kind for poly and don't when in comb. with IN or OUT

actually, would be safe not to call hasOn() even for POLYLINE, because none of the methods test it
*/

		public abstract boolean mapFlags(boolean hasIn, boolean hasOn, boolean hasOut);

		private final String methodName;
		private final FeatureKind kind;
		private final boolean stopIn;
		private final boolean stopOn;
		private final boolean stopOut;
		private final boolean needMerge;

		MethodArg(String methodName, FeatureKind kind, boolean stopIn, boolean stopOn, boolean stopOut, boolean needMerge) {
			this.methodName = methodName;
			this.kind = kind;
			this.stopIn = stopIn;
			this.stopOn = stopOn;
			this.stopOut = stopOut;
			this.needMerge = needMerge;
		}

		@Override
		public String toString() {
			return methodName;
		}

		public FeatureKind getKind() {
			return kind;
		}

		public boolean canStopIn() {
			return stopIn;
		}
		public boolean canStopOn() {
			return stopOn;
		}
		public boolean canStopOut() {
			return stopOut;
		}
		public boolean needMerge() {
			return needMerge;
		}
	}

	private class CanStopProcessing extends RuntimeException {}

	private MethodArg method;
	private boolean hasIn;
	private boolean hasOn;
	private boolean hasOut;
	private ElementQuadTree qt = null;

	public IsInFunction() {
		super(null);
		reqdNumParams = 3;
		// 1: polygon tagName
		// 2: value for above tag
		// 3: method keyword, see above
		log.debug("isInFunction", System.identityHashCode(this));
	}

	private void resetHasFlags() {
		// the instance is per unique call in rules, then applied repeatedly to each point/line/polygon
		hasIn = false;
		hasOn = false;
		hasOut = false;
	}

	public String calcImpl(Element el) {
		log.debug("calcImpl", System.identityHashCode(this), kind, params, el);
		assert qt != null : "invoked the non-augmented instance";
		if (qt.isEmpty())
			return String.valueOf(false);
		resetHasFlags();
		try {
			switch (kind) {
			case POINT:
				doPointTest((Node) el);
				break;
			case POLYLINE:
				doLineTest((Way) el);
				break;
			case POLYGON:
				doPolygonTest((Way) el);
				break;
			default:
				throw new ExitException("Bad FeatureKind: " + kind);
			}
		} catch (CanStopProcessing e) {}
		log.debug("done", System.identityHashCode(this), hasIn, hasOn, hasOut);
		if (!hasIn && !hasOn)
			hasOut = true;
		return String.valueOf(method.mapFlags(hasIn, hasOn, hasOut));
	}

/* don't have this for CachedFunction
	@Override
	public String value(Element el) {
		return calcImpl(el);
	}
*/

	@Override
	public void setParams(List<String> params, FeatureKind kind) {
		super.setParams(params, kind);
		log.debug("setParams", System.identityHashCode(this), kind, params);
		String methodStr = params.get(2);
		boolean knownMethod = false;
		List<String> methodsForKind = new ArrayList<>();
		for (MethodArg tstMethod : MethodArg.values()) {
			if (methodStr.equalsIgnoreCase(tstMethod.toString())) {
				if (tstMethod.getKind() == kind) {
					this.method = tstMethod;
					return;
				} else {
					knownMethod = true;
				}
			} else if (tstMethod.getKind() == kind) {
				methodsForKind.add(tstMethod.toString());
			}
		}
		throw new SyntaxException(String.format("Third parameter '%s' of function %s is not " +
					(knownMethod ? "supported for this style section" : "understood") +
					", valid are: %s" , methodStr, getName(), methodsForKind));
	}

	private void setIn() {
		log.debug("setIn", hasIn, hasOn, hasOut);
		hasIn = true;
		if (method.canStopIn() || hasOut)
			throw new CanStopProcessing();
	}

	private void setOn() {
		log.debug("setOn", hasIn, hasOn, hasOut);
		hasOn = true;
		if (method.canStopOn() || (hasIn && hasOut))
			throw new CanStopProcessing();
	}
	private void setOut() {
		log.debug("setOut", hasIn, hasOn, hasOut);
		hasOut = true;
		if (method.canStopOut() || hasIn)
			throw new CanStopProcessing();
	}

	private void setHasFromFlags(int flags) {
		log.debug("setFlags", flags);
		if ((flags & IsInUtil.ON) != 0)
			setOn();
		if ((flags & IsInUtil.IN) != 0)
			setIn();
		if ((flags & IsInUtil.OUT) != 0)
			setOut();
	}

	private static boolean notInHole(Coord c, List<List<Coord>> holes) {
		if (holes == null)
			return true;
		for (List<Coord> hole : holes) {
			int flags = IsInUtil.isPointInShape(c, hole);
			log.debug("notInHole", flags);
			if (flags != IsInUtil.OUT)
				return false;
		}
		return true;
	}

	private void checkPointInShape(Coord c, List<Coord> shape, List<List<Coord>> holes) {
		/*
		Because we are processing polygons one-by-one, OUT is only meaningful once we have
		checked all the polygons and haven't satisfied IN/ON, so no point is calling setOut()
		and it wouldn't stop the processing or effect the answer anyway
		*/
		int flags = IsInUtil.isPointInShape(c, shape);
		log.debug("checkPoint", flags);
		switch (method) {
		case POINT_IN:
			if (flags == IsInUtil.IN) {
				if (notInHole(c, holes)) {
					setIn();
				} else {
					// in hole in this shape, no point in looking at more shapes
					throw new CanStopProcessing();
				}
			}
			break;
		case POINT_IN_OR_ON:
			if (flags != IsInUtil.OUT)
				// no need to check holes for this as didn't need to merge polygons
				setIn(); // don't care about setOn()
			break;
		case POINT_ON:
			if (flags == IsInUtil.ON)
				// hole checking is a separate pass
				setOn(); // don't care about setIn()
			break;
		default:
			throw new ExitException("Bad point method: " + method);
		}
	}

	private void doPointTest(Node el) {
		Coord c = el.getLocation();
		Area elementBbox = Area.getBBox(Collections.singletonList(c));
		Set<Way> polygons = qt.get(elementBbox).stream().map(e -> (Way) e)
				.collect(Collectors.toCollection(LinkedHashSet::new));
		if (method.needMerge() && polygons.size() > 1) {
			// need to merge shapes so that POI on shared boundary becomes IN rather than ON
			List<List<Coord>> outers = new ArrayList<>();
			List<List<Coord>> holes = new ArrayList<>();
			IsInUtil.mergePolygons(polygons, outers, holes);
			log.debug("pointMerge", polygons.size(), outers.size(), holes.size());
			for (List<Coord> shape : outers)
				checkPointInShape(c, shape, holes);
			if (method == MethodArg.POINT_ON && !holes.isEmpty())
				// need to check if on edge of hole
				for (List<Coord> hole : holes)
					checkPointInShape(c, hole, null);
		} else { // just one polygon or IN_OR_ON, which can do one-by-one
			log.debug("point1by1", polygons.size());
			for (Way polygon : polygons)
				checkPointInShape(c, polygon.getPoints(), null);
		}
	}

	private void doLineTest(Way el) {
		doCommonTest(el);
	}

	private void doPolygonTest(Way el) {
		doCommonTest(el);
	}

	private boolean checkHoles(List<Coord> polyLine, List<List<Coord>> holes, Area elementBbox) {
		boolean foundSomething = false;
		for (List<Coord> hole : holes) {
			int flags = IsInUtil.isLineInShape(polyLine, hole, elementBbox);
			log.debug("checkhole", flags);
			if ((flags & IsInUtil.IN) != 0) {
				setOut();
				if ((flags & IsInUtil.ON) != 0)
					setOn();
				if ((flags & IsInUtil.OUT) != 0)
					setIn();
				return true;
			} else if ((flags & IsInUtil.ON) != 0) {
				setOn();
				if ((flags & IsInUtil.OUT) != 0)
					setIn();
				foundSomething = true;
			}
		}
		return foundSomething;
	}

	private void checkHoleInThis(List<Coord> polyLine, List<List<Coord>> holes, Area elementBbox) {
		for (List<Coord> hole : holes) {
			int flags = IsInUtil.isLineInShape(hole, polyLine, elementBbox);
			log.debug("holeInThis", flags);
			if ((flags & IsInUtil.IN) != 0 ||
			    (flags == IsInUtil.ON)) { // exactly on hole
				setOut();
				return;
			}
		}
	}

	private void doCommonTest(Element el) {
		List<Coord> polyLine = ((Way)el).getPoints();
		Area elementBbox = Area.getBBox(polyLine);
		Set<Way> polygons = qt.get(elementBbox).stream().map(e -> (Way) e)
				.collect(Collectors.toCollection(LinkedHashSet::new));
		if (log.isDebugEnabled()) {
			log.debug("line", polyLine);
			log.debug(polygons.size(), "polygons");
			for (Way polygon : polygons)
				log.debug("polygon", polygon.getPoints());
		}
		if (method.needMerge() && polygons.size() > 1) { // ALL-like methods need to merge shapes
			List<List<Coord>> outers = new ArrayList<>();
			List<List<Coord>> holes = new ArrayList<>();
			IsInUtil.mergePolygons(polygons, outers, holes);
			if (log.isDebugEnabled()) {
				log.debug(outers.size(), "outers", holes.size(), "holes");
				for (List<Coord> shape : outers)
					log.debug("outer", shape);
				for (List<Coord> hole : holes)
					log.debug("hole", hole);
			}
			for (List<Coord> shape : outers) {
				int flags = IsInUtil.isLineInShape(polyLine, shape, elementBbox);
				log.debug("checkShape", flags);
				if ((flags & IsInUtil.IN) != 0) { // this shape is the one to consider
					if ((flags & IsInUtil.ON) != 0)
						setOn();
					if ((flags & IsInUtil.OUT) != 0)
						setOut();
					if (!checkHoles(polyLine, holes, elementBbox))
						setIn();
					if (!hasOut && kind == FeatureKind.POLYGON)
						checkHoleInThis(polyLine, holes, elementBbox);
					break;
				} else if ((flags & IsInUtil.ON) != 0) { // might still be IN later one
					setOn();
					if ((flags & IsInUtil.OUT) != 0)
						setOut();
					else { // exactly on
						if (kind == FeatureKind.POLYGON)
							checkHoleInThis(polyLine, holes, elementBbox);
						break; // hence can't be in another
					}
				}
			}
		} else { // an ANY-like method or 1 polygon
			for (Way polygon : polygons)
				setHasFromFlags(IsInUtil.isLineInShape(polyLine, polygon.getPoints(), elementBbox));
		}
	}

	@Override
	public String getName() {
		return "is_in";
	}

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

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

	@Override
	public Set<String> getUsedTags() {
		return Collections.singleton(params.get(0));
	}

	@Override
	public String toString() {
		// see RuleSet.compile()
		return getName() + "(" + kind + ", " + String.join(", ", params) + ")";
	}

	@Override
	protected String getCacheTag() {
		return "mkgmap:cache_is_in_" + kind + "_" + String.join("_", params);
	}

	@Override
	public void augmentWith(ElementSaver elementSaver) {
		log.debug("augmentWith", System.identityHashCode(this), kind, params);
		// the cached function mechanism creates an instance for each occurance in the rule file
		// but then just uses one of them for augmentWith() and calcImpl().
		if (qt != null)
			return;
		qt = buildTree(elementSaver, params.get(0), params.get(1));
	}

	public static ElementQuadTree buildTree(ElementSaver elementSaver, String tagKey, String tagVal) {
		List<Element> matchingPolygons = new ArrayList<>();
		boolean matchAllValues = "*".equals(tagVal);
		for (Way w : elementSaver.getWays().values()) {
			if (w.hasIdenticalEndPoints()
					&& !"polyline".equals(w.getTag(MultiPolygonRelation.STYLE_FILTER_TAG))) {
				String val = w.getTag(tagKey);
				if (val != null && (matchAllValues || val.equals(tagVal))) {
					matchingPolygons.add(w);
				}
			}
		}
		return new ElementQuadTree(elementSaver.getBoundingBox(), matchingPolygons);
	}

	public void unitTestAugment(ElementQuadTree qt) {
		this.qt = qt;
	}

	@Override
	public int getComplexity() {
		return 5;
	}
}
