/*
 * Copyright (C) 2006, 2011.
 *
 * 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.build;

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import it.unimi.dsi.fastutil.shorts.ShortArrayList;
import uk.me.parabola.log.Logger;
import uk.me.parabola.mkgmap.general.MapPoint;
import uk.me.parabola.mkgmap.osmstyle.NameFinder;
import uk.me.parabola.mkgmap.reader.osm.TagDict;
import uk.me.parabola.mkgmap.reader.osm.Tags;
import uk.me.parabola.util.EnhancedProperties;
import uk.me.parabola.util.KdTree;
import uk.me.parabola.util.MultiHashMap;

public class Locator {
	private static final Logger log = Logger.getLogger(Locator.class);

    /** hash map to collect equally named MapPoints*/ 
	private final MultiHashMap<String, MapPoint> cityMap = new MultiHashMap<>();
	
	private final KdTree<MapPoint> cityFinder = new KdTree<>();
	private final List<MapPoint> placesMap  =  new ArrayList<>();

	private final NameFinder nameFinder;

	private final LocatorConfig locConfig = LocatorConfig.get();

	private final Set<String> locationAutofill;
	
	private static final double MAX_CITY_DIST = 30000;

	public Locator() {
		this(new EnhancedProperties());
	}
	
	public Locator(EnhancedProperties props) {
		this.nameFinder = new NameFinder(props);
		this.locationAutofill = new HashSet<>(LocatorUtil.parseAutofillOption(props));
	}
	
	public void addCityOrPlace(MapPoint p) 
	{
		if (!p.isCity()) {
			log.warn("MapPoint has no city type id: 0x" + Integer.toHexString(p.getType()));
			return;
		}
		
		if (log.isDebugEnabled())
			log.debug("S City 0x"+Integer.toHexString(p.getType()), p.getName(), "|", p.getCity(), "|", p.getRegion(), "|", p.getCountry());

		// correct the country name
		// usually this is the translation from 3letter ISO code to country name
		if(p.getCountry() != null)
			p.setCountry(normalizeCountry(p.getCountry()));

		resolveIsInInfo(p); // Pre-process the is_in field

		if(p.getCity() != null)
		{
			if (log.isDebugEnabled())
				log.debug(p.getCity(),p.getRegion(),p.getCountry());
			// Must use p.getName() here because p.getCity() contains the city name of the preprocessed cities
			addCity(p.getName(), p);
		}
		else
		{
			// All other places which do not seam to be a real city has to resolved later
			placesMap.add(p);		
		}
		
		if (log.isDebugEnabled())
			log.debug("E City 0x"+Integer.toHexString(p.getType()), p.getName(), "|", p.getCity(), "|", p.getRegion(), "|", p.getCountry());
	}

	public void setDefaultCountry(String country, String abbr)
	{
		locConfig.setDefaultCountry(country, abbr);
	}
	
	public String normalizeCountry(String country)
	{
		if (country == null) {
			return null;
		}
		String iso = locConfig.getCountryISOCode(country);
		if (iso != null) {
			String normedCountryName = locConfig.getCountryName(iso, nameFinder);
			if (normedCountryName != null) {
				log.debug("Country:",country,"ISO:",iso,"Norm:",normedCountryName);
				return normedCountryName;
			}
		}
		
		// cannot find the country in our config => return the country itself
		log.debug("Country:",country,"ISO:",iso,"Norm:",country);
		return country;
	}

	/**
	 * Checks if the country given by attached tags is already known, adds or completes
	 * the Locator information about this country and return the three letter ISO code
	 * (in case the country is known in the LocatorConfig.xml) or the country name.
	 * 
	 * @param tags the countries tags
	 * @return the three letter ISO code or <code>null</code> if ISO code is unknown
	 */
	public String addCountry(Tags tags) {
		synchronized (locConfig) {
			String iso = getCountryISOCode(tags);
			if (iso == null) {
				log.warn("Cannot find iso code for country with tags", tags);
			} else {
				locConfig.addCountryWithTags(iso, tags);
			}
			return iso;
		}
	}
	
	public static final ShortArrayList PREFERRED_NAME_TAG_KEYS = TagDict.compileTags("name","name:en","int_name");
	
	public String getCountryISOCode(Tags tags) {
		for (short nameTagKey : PREFERRED_NAME_TAG_KEYS) {
			String isoCode = getCountryISOCode(tags.get(nameTagKey));
			if (isoCode != null) {
				return isoCode;
			}
		}

		for (String countryStr : tags.getTagsWithPrefix("name:", false).values()) {
			String isoCode = getCountryISOCode(countryStr);
			if (isoCode != null) {
				return isoCode;
			}
		}
		return null;
	}
	
	public String getCountryISOCode(String country)
	{
		return locConfig.getCountryISOCode(country);
	}

	public int getPOIDispFlag(String country)
	{
		return locConfig.getPoiDispFlag(getCountryISOCode(country));
	}

	private boolean isContinent(String continent)
	{
		return locConfig.isContinent(continent);
	}


	/**
	 * resolveIsInInfo tries to get country and region info out of the is_in field 
	 * @param p 	Point to process
	 */
	private void resolveIsInInfo(MapPoint p)
	{
		if (!locationAutofill.contains("is_in")) {
			return;
		}
		
		if(p.getCountry() != null && p.getRegion() != null && p.getCity() == null)
		{		
			p.setCity(p.getName());
			return;
		}

		if(p.getIsIn() != null)
		{	
			String[] cityList = p.getIsIn().split(",");
			
			// is_in content is not well defined so we try our best to get some info out of it
			// Format 1 popular in Germany: "County,State,Country,Continent"

			if(cityList.length > 1 &&
				isContinent(cityList[cityList.length-1]))	// Is last a continent ?
			{
				if (p.getCountry() == null) {
					// The one before continent should be the country
					p.setCountry(normalizeCountry(cityList[cityList.length-2].trim()));
				}
				
				// aks the config which info to use for region info				
				int offset = locConfig.getRegionOffset(getCountryISOCode(p.getCountry())) + 1;

				if(cityList.length > offset && p.getRegion() == null)
					p.setRegion(cityList[cityList.length-(offset+1)].trim());

			} else

			// Format 2 other way round: "Continent,Country,State,County"	

			if(cityList.length > 1 && isContinent(cityList[0]))	// Is first a continent ?
			{
				if (p.getCountry() == null) {
					// The one before continent should be the country
					p.setCountry(normalizeCountry(cityList[1].trim()));
				}
				
				int offset = locConfig.getRegionOffset(getCountryISOCode(p.getCountry())) + 1;

				if(cityList.length > offset && p.getRegion() == null)
					p.setRegion(cityList[offset].trim());
			} else

			// Format like this "County,State,Country"

			if(p.getCountry() == null && cityList.length > 0)
			{
				// I don't like to check for a list of countries but I don't want other stuff in country field
				String isoCode = locConfig.getCountryISOCode(cityList[cityList.length-1]);
				if (isoCode != null)
				{	
					p.setCountry(normalizeCountry(isoCode));

					int offset = locConfig.getRegionOffset(isoCode) + 1;

					if(cityList.length > offset && p.getRegion() == null)
						p.setRegion(cityList[cityList.length-(offset+1)].trim());	
				}
			}
		}

		if(p.getCountry() != null && p.getRegion() != null && p.getCity() == null)
		{	
			p.setCity(p.getName());
		}
	}
	
	public MapPoint findNextPoint(MapPoint p)
	{
		return cityFinder.findNextPoint(p);
	}
	
	public MapPoint findNearbyCityByName(MapPoint p) {

		if (p.getCity() == null)
			return null;

		Collection<MapPoint> nextCityList = cityMap.get(p.getCity());
		if (nextCityList.isEmpty()) {
			return null;
		}

		MapPoint near = null;
		double minDist = Double.MAX_VALUE;
		for (MapPoint nextCity : nextCityList) {
			double dist = p.getLocation().distance(nextCity.getLocation());

			if (dist < minDist) {
				minDist = dist;
				near = nextCity;
			}
		}

		if (minDist <= MAX_CITY_DIST) // Wrong hit more the 30 km away ?
			return near;
		else
			return null;
	}
	
	private MapPoint findCityByIsIn(MapPoint place) {
		
		if (!locationAutofill.contains("is_in")) {
			return null;
		}
		
		String isIn = place.getIsIn();

		if (isIn == null) {
			return null;
		}

		String[] cityList = isIn.split(",");

		// Go through the isIn string and check if we find a city with this name
		// Lets hope we find the next bigger city

		double minDist = Double.MAX_VALUE;
		Collection<MapPoint> nextCityList = null;
		for (String cityCandidate : cityList) {
			cityCandidate = cityCandidate.trim();

			Collection<MapPoint> candidateCityList = cityMap.get(cityCandidate);
			if (!candidateCityList.isEmpty()) {
				if (nextCityList == null) {
					nextCityList = new ArrayList<>(candidateCityList.size());
				}
				nextCityList.addAll(candidateCityList);
			}
		}

		if (nextCityList == null) {
			// no city name found in the is_in tag
			return null;
		}

		MapPoint nearbyCity = null;
		for (MapPoint nextCity : nextCityList) {
			double dist = place.getLocation().distance(nextCity.getLocation());

			if (dist < minDist) {
				minDist = dist;
				nearbyCity = nextCity;
			}
		}

		// Check if the city is closer than MAX_CITY_DIST
		// otherwise don't use it but issue a warning
		if (minDist > MAX_CITY_DIST) {
			log.warn("is_in of", place.getName(), "is far away from",
					nearbyCity.getName(), (minDist / 1000.0), "km is_in",
					place.getIsIn());
			log.warn("Number of cities with this name:", nextCityList.size());
		}

		return nearbyCity;
	}

	public void autofillCities() {
		if (!locationAutofill.contains("nearest") && !locationAutofill.contains("is_in")) {
			return;
		}
		
		log.info("Locator City   Map contains", cityMap.size(), "entries");
		log.info("Locator Places Map contains", placesMap.size(), "entries");
		log.info("Locator Finder KdTree contains", cityFinder.size(), "entries");

		int runCount = 0;
		int maxRuns = 2;
		int unresCount;

		do {
			unresCount = 0;

			for (MapPoint place : placesMap) {
				if (place != null) {

					// first lets try exact name

					MapPoint near = findCityByIsIn(place);

					// if this didn't worked try to workaround german umlaut

					if (near == null) {
						// TODO perform a soundslike search
					}

					if (near != null) {
						if (place.getCity() == null)
							place.setCity(near.getCity());
						if (place.getZip() == null)
							place.setZip(near.getZip());
					} else if (locationAutofill.contains("nearest") && (runCount + 1) == maxRuns) {
						// In the last resolve run just take info from the next
						// known city
						near = cityFinder.findNextPoint(place);
						if (near != null && near.getCountry() != null) {
							if (place.getCity() == null)
								place.setCity(place.getName());
						}
					}

					if (near != null) {
						if (place.getRegion() == null)
							place.setRegion(near.getRegion());

						if (place.getCountry() == null)
							place.setCountry(near.getCountry());

					}

					if (near == null)
						unresCount++;
				}
			}

			for (int i = 0; i < placesMap.size(); i++) {
				MapPoint place = placesMap.get(i);

				if (place != null) {
					if (place.getCity() != null) {
						addCity(place.getName(), place);
						placesMap.set(i, null);
					} else if ((runCount + 1) == maxRuns) {
						place.setCity(place.getName());
						addCity(place.getName(), place);
					}
				}
			}

			runCount++;

			log.info("Locator City   Map contains", cityMap.size(),
					 "entries after resolver run", runCount,
					 "Still unresolved", unresCount);

		} while (unresCount > 0 && runCount < maxRuns);

	}
	
	/**
	 * Add MapPoint to cityMap and cityFinder
	 *  
	 * @param name Name that is used to find the city
	 * @param p the MapPoint
	 */
	private void addCity(String name, MapPoint p){
		if(name != null)
		{
			cityMap.add(name, p);
			
			// add point to the kd-tree
			cityFinder.add(p);
		}
		
	}
}

