/*
 * Copyright (C) 2009.
 *
 * 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.imgfmt.app.mdr;

import java.text.Collator;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import uk.me.parabola.imgfmt.MapFailedException;
import uk.me.parabola.imgfmt.app.ImgFileWriter;
import uk.me.parabola.imgfmt.app.srt.Sort;
import uk.me.parabola.imgfmt.app.srt.SortKey;

/**
 * The MDR 7 section is a list of all streets.  Only street names are saved
 * and so I believe that the NET section is required to make this work.
 *
 * @author Steve Ratcliffe
 */
public class Mdr7 extends MdrMapSection {
	public static final int MDR7_HAS_STRING = 0x01;
	public static final int MDR7_HAS_NAME_OFFSET = 0x20;
	public static final int MDR7_PARTIAL_SHIFT = 6;
	public static final int MDR7_U1 = 0x2;
	public static final int MDR7_HAS_MDR17 = 0x4;

	private static final int MAX_NAME_OFFSET = 127;

	private final int codepage;
	private final boolean isMulti;
	private final boolean splitName;

	private Set<Mdr7Record> roadsPerMap= new HashSet<>();
	private ArrayList<Mdr7Record> allStreets = new ArrayList<>();
	private ArrayList<Mdr7Record> streets = new ArrayList<>();
	private int lastMaxIndex = -1;

	private int partialInfoSize;
	private Set<String> exclNames;
	private final Sort sort;
	private int maxPrefixCount;
	private int maxSuffixCount;
	private boolean magicIsValid;
	private int magic;

	public Mdr7(MdrConfig config) {
		setConfig(config);
		sort = config.getSort();
		splitName = config.isSplitName();
		exclNames = config.getMdr7Excl();
		codepage = sort.getCodepage();
		isMulti = sort.isMulti();
	}

	public void addStreet(int mapId, String name, int lblOffset, int strOff, Mdr5Record mdrCity) {
		if (name.isEmpty())
			return;
			
		// Find a name prefix, which is either a shield or a word ending 0x1e or 0x1c. We are treating
		// a shield as a prefix of length one.
		int prefix = 0;
		if (name.charAt(0) < 7)
			prefix = 1;
		int sep = name.indexOf(0x1e);
		if (sep < 0)
			sep = name.indexOf(0x1b);
		if (sep > 0) {
			prefix = sep + 1;
		}

		// Find a name suffix which begins with 0x1 or 0x1c
		sep = name.indexOf(0x1f);
		if (sep < 0)
			sep = name.indexOf(0x1c);
		int suffix = 0;
		if (sep > 0) {
			suffix = sep;
		}

		// Large values can't actually work.
		if (prefix >= MAX_NAME_OFFSET || suffix >= MAX_NAME_OFFSET)
			return;

		Mdr7Record st = new Mdr7Record();
		st.setMapIndex(mapId);
		st.setLabelOffset(lblOffset);
		st.setStringOffset(strOff);
		st.setName(name);
		st.setCity(mdrCity);
		st.setPrefixOffset((byte) prefix);
		st.setSuffixOffset((byte) suffix);
		storeMdr7(st);

		if (!splitName)
			return;

		boolean start = false;
		boolean inWord = false;

		int c;
		int outOffset = 0;

		int end = Math.min(((suffix > 0) ? suffix : name.length()) - prefix - 1, MAX_NAME_OFFSET);
		for (int nameOffset = 0; nameOffset < end; nameOffset += Character.charCount(c)) {
			c = name.codePointAt(prefix + nameOffset);

			// Don't use any word after a bracket
			if (c == '(')
				break;

			if (!Character.isLetterOrDigit(c)) {
				start = true;
				inWord = false;
			} else if (start && Character.isLetterOrDigit(c)) {
				inWord = true;
			}

			if (start && inWord && outOffset > 0) {
				st = new Mdr7Record();
				st.setMapIndex(mapId);
				st.setLabelOffset(lblOffset);
				st.setStringOffset(strOff);
				st.setName(name);
				st.setCity(mdrCity);
				st.setNameOffset((byte) nameOffset);
				st.setOutNameOffset((byte) outOffset);
				st.setPrefixOffset((byte) prefix);
				st.setSuffixOffset((byte) suffix);
				if (!exclNames.contains(st.getPartialName()))
					storeMdr7(st);

				start = false;
			}

			outOffset += outSize(c);
			if (outOffset > MAX_NAME_OFFSET)
				break;
		}
	}

	/**
	 * Store in array if not already done
	 * @param st the mdr7 record
	 */
	private void storeMdr7(Mdr7Record st) {
		if (lastMaxIndex != st.getMapIndex()) {
			// we process all roads of one map tile sequentially, so we can clear the set with each new map tile
			lastMaxIndex = st.getMapIndex();
			roadsPerMap.clear(); 
		}
		
		if (roadsPerMap.add(st)) {
			allStreets.add(st);
		}
	}

	/**
	 * Return the number of bytes that the given character will consume in the output encoded
	 * format.
	 */
	private int outSize(int c) {
		if (codepage == 65001) {
			// For unicode a simple lookup gives the number of bytes.
			if (c < 0x80) {
				return 1;
			} else if (c <= 0x7FF) {
				return 2;
			} else if (c <= 0xFFFF) {
				return 3;
			} else if (c <= 0x10FFFF) {
				return 4;
			} else {
				throw new MapFailedException(String.format("Invalid code point: 0x%x", c));
			}
		} else if (!isMulti) {
			// The traditional single byte code-pages, always one byte.
			return 1;
		} else {
			// Other multi-byte code-pages (eg ms932); can't currently create index for these anyway.
			return 0;
		}
	}

	/**
	 * Since we change the number of records by removing some after sorting,
	 * we sort and de-duplicate here.
	 * This is a performance critical part of the index creation process
	 * as it requires a lot of heap to store the sort keys. 	  	 
	 */
	@Override
	protected void preWriteImpl() {
		
		LargeListSorter<Mdr7Record> partialSorter = new LargeListSorter<Mdr7Record>(sort) {
			@Override
			protected SortKey<Mdr7Record> makeKey(Mdr7Record r, Sort sort, Map<String, byte[]> cache) {
				return sort.createSortKey(r, r.getPartialName(), 0, cache); // first sort by partial name only
			}
		};
		
		ArrayList<Mdr7Record> sorted = new ArrayList<>(allStreets);
		allStreets.clear();
		partialSorter.sort(sorted);
		// list is now sorted by partial name only, we have to group by name and map index now
		String lastPartial = null;
		List<Mdr7Record> samePartial = new ArrayList<>();
		Collator collator = sort.getCollator();
		collator.setStrength(Collator.SECONDARY);
		for (int i = 0; i < sorted.size(); i++) {
			Mdr7Record r = sorted.get(i);
			String partial = r.getPartialName();
			if (lastPartial == null || collator.compare(partial, lastPartial) != 0) {
				groupByNameAndMap(samePartial);
				samePartial.clear();
			}
			samePartial.add(r);
			lastPartial = partial;
		}
		groupByNameAndMap(samePartial);
		
		allStreets.trimToSize();
		streets.trimToSize();
	}

	/**
	 * Group a list of roads with the same partial name.
	 * @param samePartial
	 */
	private void groupByNameAndMap(List<Mdr7Record> samePartial) {
		if (samePartial.isEmpty())
			return;
		
		// Basecamp needs the records grouped by partial name, full name, and map index.
		// This sometimes presents search results in the wrong order. The partial sort fields allow to
		// tell the right order.
		
		LargeListSorter<Mdr7Record> fullNameSorter = new LargeListSorter<Mdr7Record>(sort) {
			@Override
			protected SortKey<Mdr7Record> makeKey(Mdr7Record r, Sort sort, Map<String, byte[]> cache) {
				return sort.createSortKey(r, r.getName(), r.getMapIndex(), cache);
			}
		};
		
		
		fullNameSorter.sort(samePartial);
		Mdr7Record last = null;
		int recordNumber = streets.size();
		
		// list is now sorted by partial name, name, and map index
		// De-duplicate the street names so that there is only one entry
		// per map for the same name.
		for (int i = 0; i < samePartial.size(); i++) {
			Mdr7Record r = samePartial.get(i);
			if (last != null && r.getMapIndex() == last.getMapIndex() && r.getName().equals(last.getName())) {
				// This has the same name (and map number) as the previous one.
				// Save the pointer to that one
				// which is going into the file.
				r.setIndex(recordNumber);
			} else {
				recordNumber++;
				r.setIndex(recordNumber);
				streets.add(r);
			}
			if (r.getCity() != null)
				allStreets.add(r);
			last = r;
		}
	}

	public void writeSectData(ImgFileWriter writer) {
		boolean hasStrings = hasFlag(MDR7_HAS_STRING);
		boolean hasNameOffset = hasFlag(MDR7_HAS_NAME_OFFSET);
		Collator collator = sort.getCollator();
		collator.setStrength(Collator.SECONDARY); 
		Mdr7Record last = null;
		for (Mdr7Record s : streets) {
			addIndexPointer(s.getMapIndex(), s.getIndex());

			putMapIndex(writer, s.getMapIndex());
			int lab = s.getLabelOffset();
			
			int rr = s.checkRepeat(last, collator);
			if (rr != 3)
				lab |= 0x800000;
			
			writer.put3u(lab);
			if (hasStrings)
				putStringOffset(writer, s.getStringOffset());

			if (hasNameOffset)
				writer.put1u(s.getOutNameOffset());
			if (partialInfoSize > 0) {
				int trailingFlags = ((rr & 1) == 0) ? 1 : 0;
				writer.putNu(partialInfoSize, trailingFlags);
			}
			last = s;
		}
	}

	/**
	 * For the map number, label, string (opt), and trailing flags (opt).
	 * The trailing flags are variable size. We are just using 1 now.
	 */
	public int getItemSize() {
		PointerSizes sizes = getSizes();
		int size = sizes.getMapSize() + 3 + partialInfoSize;
		if (!isForDevice())
			size += sizes.getStrOffSize();
		if ((getExtraValue() & MDR7_HAS_NAME_OFFSET) != 0)
			size += 1;
		return size;
	}

	protected int numberOfItems() {
		return streets.size();
	}

	/**
	 * Value of 3 possibly the existence of the lbl field.
	 */
	public int getExtraValue() {
		if (!magicIsValid) {
			int bitsPrefix = (maxPrefixCount == 0) ? 0 : Integer.numberOfTrailingZeros(Integer.highestOneBit(maxPrefixCount)) + 1;
			int bitsSuffix = (maxSuffixCount == 0) ? 0 : Integer.numberOfTrailingZeros(Integer.highestOneBit(maxSuffixCount)) + 1;
			int bits = bitsPrefix + bitsSuffix + 1;
			partialInfoSize = 1 + bits / 8;
			assert bitsSuffix <= 15;
			magic = MDR7_U1 | MDR7_HAS_NAME_OFFSET | (partialInfoSize << MDR7_PARTIAL_SHIFT) | (bitsPrefix << 9);

			if (isForDevice()) {
				if (!getConfig().getSort().isMulti())
					magic |= MDR7_HAS_MDR17; // mdr17 sub section present (not with unicode)
			} else {
				magic |= MDR7_HAS_STRING;
			}
			int unk2size = (magic >> 6) & 0x7;
			int unk2split = ((magic >> 9) & 0xf);
			assert unk2size == partialInfoSize;
			assert unk2split == bitsPrefix;
			magicIsValid = true;
		}
		return magic;
	}

	@Override
	protected void releaseMemory() {
		allStreets = null;
		streets = null;
	}


	public List<Mdr7Record> getStreets() {
		return Collections.unmodifiableList(allStreets);
	}
	
	public List<Mdr7Record> getSortedStreets() {
		return Collections.unmodifiableList(streets);
	}

	
	/**
	 * Free as much memory as possible.
	 */
	public void trim() {
		allStreets.trimToSize();
		roadsPerMap = new HashSet<>();
	}
}
