/*
 * 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: Feb 17, 2008
 */
package uk.me.parabola.mkgmap.osmstyle;

import java.io.BufferedReader;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;

import uk.me.parabola.imgfmt.ExitException;
import uk.me.parabola.log.Logger;
import uk.me.parabola.mkgmap.CommandArgs;
import uk.me.parabola.mkgmap.Options;
import uk.me.parabola.mkgmap.general.LevelInfo;
import uk.me.parabola.mkgmap.general.LineAdder;
import uk.me.parabola.mkgmap.reader.osm.FeatureKind;
import uk.me.parabola.mkgmap.reader.osm.Rule;
import uk.me.parabola.mkgmap.reader.osm.Style;
import uk.me.parabola.mkgmap.reader.osm.StyleInfo;
import uk.me.parabola.mkgmap.scan.SyntaxException;
import uk.me.parabola.mkgmap.scan.TokenScanner;
import uk.me.parabola.util.EnhancedProperties;

/**
 * A style is a collection of files that describe the mapping between the OSM
 * features and the garmin features.  This file reads in those files and
 * provides methods for using the information.
 *
 * The files are either contained in a directory, in a package or in a zip'ed
 * file.
 *
 * @author Steve Ratcliffe
 */
public class StyleImpl implements Style {
	private static final Logger log = Logger.getLogger(StyleImpl.class);

	public static final boolean WITH_CHECKS = true; 
	public static final boolean WITHOUT_CHECKS = false;
	
	// This is max the version that we understand
	private static final int VERSION = 1;

	// General options just have a value and don't need any special processing.
	private static final Collection<String> OPTION_LIST = new ArrayList<>(
			Arrays.asList("levels", "overview-levels", "extra-used-tags", "line-types-with-direction"));

	// File names
	private static final String FILE_VERSION = "version";
	private static final String FILE_INFO = "info";
	private static final String FILE_OPTIONS = "options";
	private static final String FILE_OVERLAYS = "overlays";

	// A handle on the style directory or file.
	private final StyleFileLoader fileLoader;
	private final String location;

	// The general information in the 'info' file.
	private StyleInfo info = new StyleInfo();

	// Set if this style is based on another one.
	private final List<StyleImpl> baseStyles = new ArrayList<>();

	// Options from the option file that are used outside this file.
	private final Map<String, String> generalOptions = new HashMap<>();

	private final RuleSet lines = new RuleSet();
	private final RuleSet polygons = new RuleSet();
	private final RuleSet nodes = new RuleSet();
	private final RuleSet relations = new RuleSet();

	private OverlayReader overlays;
	private final boolean performChecks;
	
	private Collection<String> deadEndTags = new ArrayList<>();
	
	/**
	 * Create a style from the given location and name.
	 * @param loc The location of the style. Can be null to mean just check
	 * the classpath.
	 * @param name The name.  Can be null if the location isn't.  If it is
	 * null then we just check for the first version file that can be found.
	 * @throws FileNotFoundException If the file doesn't exist.  This can
	 * include the version file being missing.
	 */
	public StyleImpl(String loc, String name) throws FileNotFoundException {
		this(loc, name, new EnhancedProperties(), WITHOUT_CHECKS);
	}
	
	/**
	 * Create a style from the given location and name.
	 * @param loc The location of the style. Can be null to mean just check
	 * the classpath.
	 * @param name The name.  Can be null if the location isn't.  If it is
	 * null then we just check for the first version file that can be found.
	 * @param props optional program properties (may be null) 
	 * @throws FileNotFoundException If the file doesn't exist.  This can
	 * include the version file being missing.
	 */
	public StyleImpl(String loc, String name, EnhancedProperties props, boolean performChecks) throws FileNotFoundException {
		String s = props.getProperty("dead-ends");
		if (s != null) {
			String[] deadEndTagsAndValues = s.split(",");
			for (String deadEndTag : deadEndTagsAndValues) {
				deadEndTags.add(deadEndTag.split("=", 2)[0]);
			}
		}

		location = loc;
		fileLoader = StyleFileLoader.createStyleLoader(loc, name);
		this.performChecks = performChecks;
		
		// There must be a version file, if not then we don't create the style.
		checkVersion();

		readInfo();

		for (String baseName : info.baseStyles())
			readBaseStyle(baseName, props);

		for (StyleImpl baseStyle : baseStyles)
			mergeOptions(baseStyle);

		readOptions();
		
		// read overlays before the style rules to be able to ignore overlaid "wrong" types. 
		readOverlays(); 

		readRules(props.getProperty("levels"), props.containsKey("route"));

		ListIterator<StyleImpl> listIterator = baseStyles.listIterator(baseStyles.size());
		while (listIterator.hasPrevious())
			mergeRules(listIterator.previous());
	}

	@Override
	public String getOption(String name) {
		return generalOptions.get(name);
	}

	@Override
	public StyleInfo getInfo() {
		return info;
	}

	@Override
	public Rule getNodeRules() {
		nodes.prepare();
		return nodes;
	}

	@Override
	public Rule getWayRules() {
		RuleSet r = new RuleSet();
		r.addAll(lines);
		r.addAll(polygons);
		r.prepare();
		return r;
	}

	@Override
	public Rule getLineRules() {
		lines.prepare();
		return lines;
	}

	@Override
	public Rule getPolygonRules() {
		polygons.prepare();
		return polygons;
	}
	
	@Override
	public Rule getRelationRules() {
		relations.prepare();
		return relations;
	}

	@Override
	public LineAdder getOverlays(final LineAdder lineAdder) {
		LineAdder adder = null;

		if (overlays != null) {
			adder = element -> overlays.addLine(element, lineAdder);
		}
		return adder;
	}
	
	@Override
	public Set<String> getUsedTagsPOI() {
		return new HashSet<>(nodes.getUsedTags());
	}
	
	@Override
	public Set<String> getUsedTags() {
		Set<String> set = new HashSet<>();
		set.addAll(relations.getUsedTags());
		set.addAll(lines.getUsedTags());
		set.addAll(polygons.getUsedTags());
		set.addAll(nodes.getUsedTags());
		set.addAll(deadEndTags);
		
		// this is to allow style authors to say that tags are really used even
		// if they are not found in the style file.  This is mostly to work
		// around situations that we haven't thought of - the style is expected
		// to get it right for itself.
		set.addAll(CommandArgs.stringToList(getOption("extra-used-tags"), "extra-used-tags"));

		// There are a lot of tags that are used within mkgmap that 
		try (InputStream is = this.getClass().getResourceAsStream("/styles/builtin-tag-list");) {
			if (is != null) {
				BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8));
				String line;
				while ((line = br.readLine()) != null) {
					line = line.trim();
					if (line.startsWith("#"))
						continue;

					set.add(line);
				}
			}
		} catch (IOException e) {
			// the file doesn't exist, this is ok but unlikely
			Logger.defaultLogger.warn("built in tag list not found");
		}
		return set;
	}

	private void readRules(String levelsFromProps, boolean routable) {
		String l = generalOptions.get("levels");
		if (l == null)
			l = LevelInfo.DEFAULT_LEVELS;
		LevelInfo[] levels = LevelInfo.createFromString(l);
		if (performChecks && levels[0].getBits() <= 10) {
			Logger.defaultLogger.warn("Resolution values <= 10 may confuse MapSource: " + l);
		}
		l = generalOptions.get("overview-levels");
		if (l != null){
			LevelInfo[] ovLevels = LevelInfo.createFromString(l);
			// TODO: make sure that the combination of the two level strings makes sense
			if (performChecks){
				if (ovLevels[0].getBits() <= 10){
					Logger.defaultLogger.warn("Resolution values <= 10 may confuse MapSource: " + l);
				}
				if (levels[0].getLevel() >= ovLevels[ovLevels.length-1].getLevel()){
					Logger.defaultLogger.warn("Overview level not higher than highest normal level. " + l);
				}
			}
			List<LevelInfo> tmp = new ArrayList<>();
			tmp.addAll(Arrays.asList(levels));
			tmp.addAll(Arrays.asList(ovLevels));
			levels = tmp.toArray(new LevelInfo[tmp.size()]);
			Arrays.sort(levels);
		}

		try {
			RuleFileReader reader = new RuleFileReader(FeatureKind.RELATION, levels, relations, performChecks, getOverlaidTypeMap());
			reader.load(fileLoader, "relations");
		} catch (FileNotFoundException e) {
			// it is ok for this file to not exist.
			log.debug("no relations file");
		}

		try {
			RuleFileReader reader = new RuleFileReader(FeatureKind.POINT, levels, nodes, performChecks, getOverlaidTypeMap());
			reader.load(fileLoader, "points");
		} catch (FileNotFoundException e) {
			// it is ok for this file to not exist.
			log.debug("no points file");
		}

		try {
			RuleFileReader reader = new RuleFileReader(FeatureKind.POLYLINE, levels, lines, performChecks, getOverlaidTypeMap());
			reader.load(fileLoader, "lines");
			if (routable && levelsFromProps != null && !levelsFromProps.equals(l)) {
				LevelInfo[] pl = LevelInfo.createFromString(levelsFromProps);
				if (levels[levels.length - 1].getBits() > pl[pl.length - 1].getBits()) {
					RuleFileReader checker = new RuleFileReader(FeatureKind.POLYLINE, pl, new RuleSet(), false,
							routable, getOverlaidTypeMap());
					checker.load(fileLoader, "lines");
				}
			}
		} catch (FileNotFoundException e) {
			log.debug("no lines file");
		}

		try {
			RuleFileReader reader = new RuleFileReader(FeatureKind.POLYGON, levels, polygons, performChecks, getOverlaidTypeMap());
			reader.load(fileLoader, "polygons");
		} catch (FileNotFoundException e) {
			log.debug("no polygons file");
		}
	}

	/**
	 * If there is an options file, then read it and keep options that
	 * we are interested in.
	 *
	 * Only specific options can be set.
	 */
	private void readOptions() {
		try {
			Reader r = fileLoader.open(FILE_OPTIONS);
			Options opts = new Options(opt -> {
				String key = opt.getOption();
				String val = opt.getValue();
				if ("name-tag-list".equals(key)) {
					if (!"name".equals(val)) {
						Logger.defaultLogger.warn("Option name-tag-list used in the style options is ignored. "
								+ "Please use only the command line option to specify this value.");
					}
				} else if (OPTION_LIST.contains(key)) {
					// Simple options that have string value. Perhaps we should allow
					// anything here?
					generalOptions.put(key, val);
				}
			});

			opts.readOptionFile(r, FILE_OPTIONS);
		} catch (FileNotFoundException e) {
			// the file is optional, so ignore if not present, or causes error
			log.debug("no options file");
		}
	}

	/**
	 * Read the info file.  This is just information about the style.
	 */
	private void readInfo() {
		try {
			Reader br = new BufferedReader(fileLoader.open(FILE_INFO));
			info = new StyleInfo();

			Options opts = new Options(opt -> {
				String word = opt.getOption();
				String value = opt.getValue();
				if ("summary".equals(word))
					info.setSummary(value);
				else if ("version".equals(word)) {
					info.setVersion(value);
				} else if ("base-style".equals(word)) {
					info.addBaseStyleName(value);
				} else if ("description".equals(word)) {
					info.setLongDescription(value);
				}
			});

			opts.readOptionFile(br, FILE_INFO);

		} catch (FileNotFoundException e) {
			// optional file..
			log.debug("no info file");
		}
	}

	private void readOverlays() {
		try {
			Reader r = fileLoader.open(FILE_OVERLAYS);
			overlays = new OverlayReader(r, FILE_OVERLAYS);
			overlays.readOverlays();
		} catch (FileNotFoundException e) {
			// this is perfectly normal
			log.debug("no overlay file");
		}
	}

	/**
	 * If this style is based upon another one then read it in now.  The rules
	 * for merging styles are that it is as-if the style was read just after
	 * the current styles 'info' section and any option or rule specified
	 * in the current style will override any corresponding item in the
	 * base style.
	 * @param name The name of the base style
	 * @param props program properties 
	 */
	private void readBaseStyle(String name, EnhancedProperties props) {
		if (name == null)
			return;

		try {
			baseStyles.add(new StyleImpl(location, name, props, performChecks));
		} catch (SyntaxException e) {
			Logger.defaultLogger.error("Error in style: " + e.getMessage());
		} catch (FileNotFoundException e) {
			// not found, try on the classpath.  This is the common
			// case where you have an external style, but want to
			// base it on a built in one.
			log.debug("could not open base style file", e);

			try {
				baseStyles.add(new StyleImpl(null, name, props, performChecks));
			} catch (SyntaxException se) {
				Logger.defaultLogger.error("Error in style: " + se.getMessage());
			} catch (FileNotFoundException e1) {
				Logger.defaultLogger.error("Could not find base style", e);
			}
		}
	}

	/**
	 * Merge another style's options into this one.  The style will have a lower
	 * priority, in other words any option set in 'other' and this style will
	 * take the value given in this style.
	 *
	 * This is used to base styles on other ones, without having to repeat
	 * everything.
	 *
	 * @see #mergeRules(StyleImpl)
	 */
	private void mergeOptions(StyleImpl other) {
		for (Entry<String, String> ent : other.generalOptions.entrySet()) {
			String opt = ent.getKey();
			String val = ent.getValue();
			if (OPTION_LIST.contains(opt)) {
				// Simple options that have string value. Perhaps we should allow
				// anything here?
				generalOptions.put(opt, val);
			}
		}
	}

	/**
	 * Merge rules from the base style.  This has to called after this
	 * style's rules are read.
	 *
	 * The other rules have a lower priority than the rules in this file; it is as if they
	 * were appended to the rule files of this style.
	 *
	 * @see #mergeOptions(StyleImpl) 
	 */
	private void mergeRules(StyleImpl other) {
		lines.merge(other.lines);
		polygons.merge(other.polygons);
		nodes.merge(other.nodes);
		relations.merge(other.relations);
	}

	private void checkVersion() throws FileNotFoundException {
		Reader r = fileLoader.open(FILE_VERSION);
		TokenScanner scan = new TokenScanner(FILE_VERSION, r);
		int version;
		try {
			version = scan.nextInt();
			log.debug("Got version", version);
		} catch (NumberFormatException e) {
			// default to 0 if no version can be found.
			version = 0;
		}

		if (version > VERSION) {
			Logger.defaultLogger.warn("Unrecognised style version " + version +
			", but only versions up to " + VERSION + " are understood");
		}
	}

	/**
	 * Writes out this file to the given writer in the single file format.
	 * This produces a valid style file, although it is mostly used
	 * for testing.
	 */
	void dumpToFile(Writer out) {
		StylePrinter stylePrinter = new StylePrinter(this);
		stylePrinter.setGeneralOptions(generalOptions);
		stylePrinter.setRelations(relations);
		stylePrinter.setLines(lines);
		stylePrinter.setNodes(nodes);
		stylePrinter.setPolygons(polygons);
		stylePrinter.dumpToFile(out);
	}

	/**
	 * 
	 * @return null or the map that was read from the overlays file
	 */
	private Map<Integer, List<Integer>> getOverlaidTypeMap() {
		if (overlays != null)
			return overlays.getOverlays();
		return Collections.emptyMap();
	}

	/**
	 * Evaluate the style options and try to read the style.
	 * 
	 * The option --style-file give the location of an alternate file or
	 * directory containing styles rather than the default built in ones.
	 *
	 * The option --style gives the name of a style, either one of the
	 * built in ones or selects one from the given style-file.
	 *
	 * If there is no name given, but there is a file then the file should
	 * just contain one style.
	 *
	 * @param props the program properties
	 * @return A style instance or null in case of error. 
	 */
	public static Style readStyle(EnhancedProperties props) {
		String loc = props.getProperty("style-file");
		if (loc == null)
			loc = props.getProperty("map-features");
		String name = props.getProperty("style");

		if (loc == null && name == null)
			name = "default";

		if (name == null){
			try (StyleFileLoader loader = StyleFileLoader.createStyleLoader(loc, null)) {
				int numEntries = loader.list().length;
				if (numEntries > 1)
					throw new ExitException("Style file " + loc + " contains multiple styles, use option --style to select one.");
			} catch (FileNotFoundException e) {
				throw new ExitException("Could not open style file " + loc);
			}
		}

		Style style;
		try {
			style = new StyleImpl(loc, name, props, WITHOUT_CHECKS);
		} catch (SyntaxException e) {
			Logger.defaultLogger.error("Error in style: " + e.getMessage());
			throw new ExitException("Could not open style " + (name == null? "":name));
		} catch (FileNotFoundException e) {
			String msg = "Could not open style ";
			if (name != null){
				msg += name;
				if (loc != null)
					msg += " in " + loc;
			}
			else 
				msg += loc + " . Make sure that it points to a style or add the --style option.";
			throw new ExitException(msg);
		}
		return style;
	}

	@Override
	public void reportStats() {
		relations.printStats("relations");
		nodes.printStats("points");
		lines.printStats("lines");
		polygons.printStats("polygons");
	}
	
	public static void main(String[] args) throws FileNotFoundException {
		String file = args[0];
		String name = null;
		if (args.length > 1)
			name = args[1];
		StyleImpl style = new StyleImpl(file, name, new EnhancedProperties(), WITH_CHECKS);

		style.dumpToFile(new OutputStreamWriter(System.out));
	}
}
