package tim.prune.function.olc;

import tim.prune.data.DataPoint;
import tim.prune.data.Latitude;
import tim.prune.data.Longitude;

/**
 * Decoder of OLC (Open Location Code) strings
 */
public class OlcDecoder
{
	/**
	 * Decode the given String into an OlcArea object
	 * @param inCode code representing an OLC, either in short form or long form
	 * @param inGuideLatitude latitude guide to help guess the prefix
	 * @param inGuideLongitude longitude guide to help guess the prefix
	 * @return an OlcArea object, or null if parsing failed
	 */
	public static OlcArea decode(String inCode, double inGuideLatitude, double inGuideLongitude)
	{
		if (isValidLongForm(inCode)) {
			return decode(inCode);
		}
		if (!isValidShortForm(inCode)) {
			return null;
		}
		return findBestArea(inCode, inGuideLatitude, inGuideLongitude);
	}

	public static boolean isValidLongForm(String inCode)
	{
		if (inCode == null) {return false;}
		final int plusPos = inCode.indexOf('+');
		return inCode.indexOf(' ') < 0 && inCode.indexOf(',') < 0
			&& ((inCode.length() == 8 && plusPos < 0)	// long form up to (but not including) the +
				|| (inCode.length() > 8 && plusPos == 8)); // long form including + at position 8
	}

	public static boolean isValidShortForm(String inCode)
	{
		if (inCode == null) {return false;}
		final int plusPos = inCode.indexOf('+');
		return inCode.indexOf(' ') < 0 && inCode.indexOf(',') < 0
			&& (inCode.length() <= 8 && plusPos == 4); // truncated form with + at position 4
	}

	/**
	 * Decode the given String into an OlcArea object
	 * @param inCode code representing an OLC in long form
	 * @return an OlcArea object, or null if parsing failed
	 */
	public static OlcArea decode(String inCode)
	{
		if (!isValidLongForm(inCode)) {
			return null;
		}
		String code = inCode.trim().toUpperCase();
		if (code.length() < 8 || code.length() > 12) {
			return null;
		}
		double lat = 0.0, lon = 0.0;
		double resolution = 400.0;
		int charPos = 0;
		int numSteps = 0;
		boolean amPadding = false;
		try
		{
			while (charPos < inCode.length())
			{
				if (charPos == 0 || charPos == 2 || charPos == 4 || charPos == 6 || charPos == 9)
				{
					// take next two characters, make pair, position += 2
					CoordPair pair = CoordPair.decode(code.charAt(charPos), code.charAt(charPos+1));
					if (pair == CoordPair.PADDING) {
						amPadding = true;
					}
					else if (amPadding)
					{
						return null;
					}
					else
					{
						// Add to current lat, lon
						lat += (pair.lat * resolution);
						lon += (pair.lon * resolution);
						numSteps++;
						resolution /= 20.0;
					}
					charPos += 2;
				}
				else if (charPos == 8)
				{
					if (code.charAt(charPos) != '+')
					{
						return null;
					}
					charPos += 1;
				}
				else if (charPos == 11)
				{
					// take next character, make pair
					CoordPair pair = CoordPair.decode(code.charAt(charPos));
					// Add to current lat, lon
					lat += (pair.lat * resolution);
					lon += (pair.lon * resolution);
					charPos += 1;
					numSteps++;
					resolution /= 20.0;
				}
				else
				{
					return null;
				}
			}

			if (numSteps < 1) {
				return null;
			}
			// Make OlcArea object and return it
			lat -= 90.0;
			lon -= 180.0;
			if (numSteps < 6) {
				return new OlcArea(lat, lon, lat+resolution, lon+resolution, inCode);
			}
			else {
				// For 6 steps, just return a point, not a rectangle
				return new OlcArea(lat, lon, lat, lon, inCode);
			}
		}
		catch (ParseException e) {}
		return null;
	}

	/**
	 * @param inCode partial OLC code (truncated form with prefix missing
	 * @param inGuideLatitude latitude of nearby point
	 * @param inGuideLongitude longitude of nearby point
	 * @return the nearest OlcArea to the specified guide point
	 */
	private static OlcArea findBestArea(String inCode, double inGuideLatitude, double inGuideLongitude)
	{
		// Find the two latitude indexes
		double latIndex = (inGuideLatitude + 90.0) / 20.0;
		int latIndex1 = (int) Math.floor(latIndex);
		int latIndex2 = (int) Math.floor((latIndex - latIndex1) * 20.0);
		// And the same for longitude
		double lonIndex = ((inGuideLongitude + 180.0)%360.0) / 20.0;
		int lonIndex1 = (int) Math.floor(lonIndex);
		int lonIndex2 = (int) Math.floor((lonIndex - lonIndex1) * 20.0);
		// Now we have 9 possible squares to look through:
		DataPoint guidePoint = new DataPoint(Latitude.make(inGuideLatitude),
			Longitude.make(inGuideLongitude));
		OlcArea bestAnswer = null;
		double lowestDistance = 0.0;
		for (int dy=-1; dy<=1; dy++)
		{
			for (int dx=-1; dx<=1; dx++)
			{
				String longCode = String.valueOf(CoordPair.encode(latIndex1 + getDelta(latIndex2, dy)))
						+ CoordPair.encode(lonIndex1 + getDelta(lonIndex2, dx))
						+ CoordPair.encode(latIndex2 + dy)
						+ CoordPair.encode(lonIndex2 + dx)
						+ inCode;
				OlcArea probeArea = decode(longCode);
				if (probeArea != null)
				{
					double distance = calcDistance(probeArea, guidePoint);
					if (bestAnswer == null || distance < lowestDistance) {
						bestAnswer = probeArea;
						lowestDistance = distance;
					}
				}
			}
		}
		return bestAnswer;
	}

	/**
	 * @param secondIndex lower-order index
	 * @param increment change in lower-order index from [-1, 0, 1]
	 * @return delta of higher-order index caused by carry
	 */
	private static int getDelta(int secondIndex, int increment)
	{
		if (secondIndex == 0 && increment < 0) {return -1;}
		if (secondIndex == 19 && increment > 0) {return 1;}
		return 0;
	}

	/**
	 * @param probeArea possible OLC area
	 * @param inGuidePoint guide point which should be nearby
	 * @return distance in radians from the given guide point to the centre of the olc area
	 */
	private static double calcDistance(OlcArea probeArea, DataPoint inGuidePoint)
	{
		DataPoint probePoint = new DataPoint(Latitude.make(probeArea.middleLat()),
			Longitude.make(probeArea.middleLon()));
		return DataPoint.calculateRadiansBetween(probePoint, inGuidePoint);
	}
}
