/*
 * Copyright (c) 2005- Shinji Kashihara.
 * All rights reserved. This program are made available under
 * the terms of the Eclipse Public License v1.0 which accompanies
 * this distribution, and is available at epl-v10.html.
 */
package jp.sourceforge.mergedoc.pleiades.resource;

import jp.sourceforge.mergedoc.org.apache.commons.io.IOUtils;
import jp.sourceforge.mergedoc.org.apache.commons.lang.CharEncoding;
import jp.sourceforge.mergedoc.org.apache.commons.lang.StringUtils;
import jp.sourceforge.mergedoc.pleiades.log.Logger;

import java.io.*;
import java.util.*;
import java.util.Map.Entry;
import java.util.zip.Deflater;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;

/**
 * プロパティー・セット・クラスです。
 * このクラスは java.util.Properties と比較し、以下の利点があります。
 * <pre>
 * ・同期処理を省き高速化 (ConcurrentModificationException 対策済み)
 * ・拡張子が .properties.zip の場合は圧縮展開による高速化
 * ・タイプセーフ
 * ・使いやすい拡張 for ループ
 * </pre>
 * @author cypher256
 */
@SuppressWarnings("serial")
public class PropertySet extends HashMap<String, String> implements Iterable<Property> {

	/** ロガー (Logger.init に対応するためインスタンス変数として定義) */
	private final Logger log = Logger.getLogger(getClass());

	/** プロパティー・ファイル拡張子 */
	private static final String PROP_EXTENSION = ".properties";

	/** zip ファイル拡張子 */
	protected static final String ZIP_EXTENSION = ".zip";

	/**
	 * プロパティー・セットを構築します。
	 */
	public PropertySet() {
	}

	/**
	 * プロパティー・セットを構築します。
	 * @param initialCapacity 初期容量
	 */
	public PropertySet(int initialCapacity) {
		super(initialCapacity);
	}

	/**
	 * プロパティー・セットを構築します。
	 * <p>
	 * @param paths リソース・ルートからの相対パス
	 */
	public PropertySet(String... paths) {
		load(paths);
	}

	/**
	 * プロパティー・セットを構築します。
	 * ロード・ポリシーは load(File...) メソッドを参照してください。
	 * <p>
	 * @param files プロパティー・ファイルまたはディレクトリー
	 */
	public PropertySet(File... files) {
		load(files);
	}

	/**
	 * プロパティー・セットを構築します。
	 * <p>
	 * @param props Java 標準プロパティー
	 */
	public PropertySet(Properties... props) {

		for (Properties prop : props) {
			for (Entry<Object, Object> entry : prop.entrySet()) {
				put((String) entry.getKey(), (String) entry.getValue());
			}
		}
	}

	/**
	 * プロパティー・セットを構築します。
	 * <p>
	 * @param props プロパティー・セット
	 */
	public PropertySet(PropertySet... props) {

		for (PropertySet prop : props) {
			putAll(prop);
		}
	}

	/**
	 * プロパティー・ファイルをロードします。
	 * <p>
	 * @param paths リソース・ルートからの相対パス
	 * @return このインスタンス
	 */
	public PropertySet load(String... paths) {

		for (String path : paths) {
			File file = Files.conf(path);
			load(file);
		}
		return this;
	}

	/**
	 * プロパティー・ファイルをロードします。<br>
	 * <p>
	 * パスがディレクトリーの場合は、その直下の
	 * *.properties、*.properties.zip がすべてロードされます。
	 * <p>
	 * @param files プロパティー・ファイルまたはディレクトリー
	 * @return このインスタンス
	 */
	public PropertySet load(File... files) {

		for (File file : files) {

			// ディレクトリーの場合
			if (file.isDirectory()) {

				for (File f : listPropertiesFiles(file)) {
					loadFile(f);
				}
			}
			// ファイルの場合
			else {
				loadFile(file);
			}
		}
		return this;
	}

	/**
	 * ディレクトリの直下に含まれるプロパティー *.properties、*.properties.zip
	 * ファイル・リストを昇順で取得します。
	 * <p>
	 * @param dir ディレクトリ
	 * @return プロパティー・ファイル・リスト
	 */
	public static File[] listPropertiesFiles(File dir) {

		File[] fs = dir.listFiles(Files.createFileFilter());
		Arrays.sort(fs); // 昇順
		List<File> list = new ArrayList<File>();

		for (File f : fs) {
			String name = f.getName();
			if (name.endsWith(PROP_EXTENSION) || name.endsWith(PROP_EXTENSION + ZIP_EXTENSION)) {
				list.add(f);
			}
		}
		return list.toArray(new File[list.size()]);
	}

	/**
	 * プロパティー・ファイルをロードします。
	 * 引数の入力ストリームはクローズされません。
	 * <p>
	 * @param is 入力ストリーム
	 */
	public void load(InputStream is) {

		try {
	        loadUTF8(is);

		} catch (IOException e) {
			throw new IllegalArgumentException(e);
		}
	}

	/**
	 * 指定したディレクトリーにあるプロパティー・ファイルを再帰的にロードします。<br>
	 * <p>
	 * *.properties、*.properties.zip がすべてロードされます。
	 * <p>
	 * @param dirs ディレクトリー
	 */
	public void loadRecursive(File... dirs) {

		for (File dir : dirs) {

			File[] fs = dir.listFiles();
			Arrays.sort(fs); // 昇順

			for (File f : fs) {
				if (f.isDirectory()) {
					loadRecursive(f); // 再帰
				} else {
					load(f);
				}
			}
		}
	}

	/**
	 * プロパティーを追加または更新します。
	 * @param p プロパティー
	 * @return キーが存在した場合は以前の値。存在しない場合は null。
	 */
	public String put(Property p) {
		return put(p.key, p.value);
	}

	/**
	 * 指定されたプロパティーをソートしてファイルに保管します。
	 * <p>
	 * @param path パス
	 * @param comment ヘッダーに出力するコメント
	 * @return 保管されたキーのリスト (ソート済み)。保管されなかった場合は null。
	 */
	public List<String> store(String path, String comment) {

		File resourceFile = Files.conf(path);
		return store(resourceFile, comment);
	}

	/**
	 * 指定されたプロパティーをソートしてファイルに保管します。
	 * <p>
	 * @param file ファイル
	 * @param comment ヘッダーに出力するコメント
	 * @return 保管されたキーのリスト (ソート済み)。保管されなかった場合は null。
	 */
	@SuppressWarnings("resource")
	public List<String> store(File file, String comment) {

		long startTime = System.nanoTime();
		String fileName = file.getName();

		// 既存ファイルと内容が同じであれば保管しない (コメントの日付更新抑止)
		if (file.exists() && fileName.endsWith(PROP_EXTENSION)) {
			PropertySet old = new PropertySet();
			old.loadFileWithoutLog(file);
			if (old.equals(this)) {
				log.info("store 更新なし   %6d エントリー %-34s", size(), fileName);
				return null;
			}
		}

		// ヘッダーの作成
		List<String> headerList = new ArrayList<String>();
		headerList.add(saveConvertComment("このファイルは Pleiades により生成されました。" + size() + " エントリー。"));
		headerList.add("####################################################################################################");
		headerList.add("# ");
		for (String s : comment.split("\\n")) {
			headerList.add("# " + saveConvertComment(s));
		}
		headerList.add("# ");
		headerList.add("####################################################################################################");
		String header = StringUtils.join(headerList, "\n");

		OutputStream os = null;
		try {
			os = new BufferedOutputStream(new FileOutputStream(file));

			// 拡張子が .properties.zip の場合、ZipOutputStream でラップ
			if (fileName.endsWith(PROP_EXTENSION + ZIP_EXTENSION)) {
				ZipOutputStream zos = new ZipOutputStream(os);
				zos.setLevel(Deflater.BEST_SPEED);
				zos.putNextEntry(new ZipEntry(fileName.replace(ZIP_EXTENSION, "")));
				os = zos;
			}
			List<String> keyList = store(os, header);

			log.info("store %4.3f 秒 - %6d エントリー %s", (System.nanoTime() - startTime) / 1e+9, size(), fileName);
			return keyList;

		} catch (IOException e) {
			throw new IllegalArgumentException(file.toString(), e);

		} finally {
			IOUtils.closeQuietly(os);
		}
	}

	/**
	 * プロパティーのイテレーターを取得します。
	 * このメソッドで取得したプロパティーを変更しても、元の PropertySet への
	 * 影響はありません。
	 */
	public Iterator<Property> iterator() {

		final Iterator<Entry<String, String>> it = entrySet().iterator();

		return new Iterator<Property>() {

			public boolean hasNext() {
				return it.hasNext();
			}

			public Property next() {
				Entry<String, String> entry = it.next();
				return new Property(entry.getKey(), entry.getValue());
			}

			public void remove() {
				throw new UnsupportedOperationException();
			}
		};
	}

	/*
	 * Java8 では putAll から put が呼び出されなくなりました。
	 * put がオーバーライドされている場合、以前のバージョンと同じように動作するように
	 * オーバーライドしています。
	 */
	@Override
	public void putAll(Map<? extends String, ? extends String> m) {

		for (Entry<? extends String, ? extends String> entry : m.entrySet()) {
			put(entry.getKey(), entry.getValue());
		}
	}

	// -------------------------------------------------------------------------
	// 以下、java.util.Properties の拡張

	/** ロードカウント (非同期ロードは未対応) */
	private int loadCount;

	protected InputStream getInputStream(File file) throws IOException {

		// 拡張子 .properties
		InputStream is = new BufferedInputStream(new FileInputStream(file));

		// 拡張子 .properties.zip
		if (file.getName().endsWith(PROP_EXTENSION + ZIP_EXTENSION)) {
			@SuppressWarnings("resource")
			ZipInputStream zis = new ZipInputStream(is);
			zis.getNextEntry();
			is = zis;
		}
		return is;
	}

	private void loadFile(File file) {

		long startTime = System.nanoTime();

		loadFileWithoutLog(file);

		log.info("load  %4.3f 秒 - %6d エントリー %s", (System.nanoTime() - startTime) / 1e+9, loadCount, file.getName());
		loadCount = 0;
	}

	private void loadFileWithoutLog(File file) {

		InputStream is = null;
		try {
			is = getInputStream(file);
			if (is == null) {
				return;
			}
	        loadUTF8(is);

		} catch (IOException e) {
			throw new IllegalArgumentException(e);

		} finally {
			IOUtils.closeQuietly(is);
		}
	}

	private void loadUTF8(InputStream is) throws IOException {
        load0(new LineReader(new InputStreamReader(is, CharEncoding.UTF_8)));
	}

	private void load0(LineReader lr) throws IOException {
		char[] convtBuf = new char[1024];
		int limit;
		int keyLen;
		int valueStart;
		char c;
		boolean hasSep;
		boolean precedingBackslash;

		while ((limit = lr.readLine()) >= 0) {
			c = 0;
			keyLen = 0;
			valueStart = limit;
			hasSep = false;

			// System.out.println("line=<" + new String(lineBuf, 0, limit) +
			// ">");
			precedingBackslash = false;
			while (keyLen < limit) {
				c = lr.lineBuf[keyLen];
				// need check if escaped.
				if ((c == '=' || c == ':') && !precedingBackslash) {
					valueStart = keyLen + 1;
					hasSep = true;
					break;
				} else if ((c == ' ' || c == '\t' || c == '\f') && !precedingBackslash) {
					valueStart = keyLen + 1;
					break;
				}
				if (c == '\\') {
					precedingBackslash = !precedingBackslash;
				} else {
					precedingBackslash = false;
				}
				keyLen++;
			}
			while (valueStart < limit) {
				c = lr.lineBuf[valueStart];
				if (c != ' ' && c != '\t' && c != '\f') {
					if (!hasSep && (c == '=' || c == ':')) {
						hasSep = true;
					} else {
						break;
					}
				}
				valueStart++;
			}
			String key = loadConvert(lr.lineBuf, 0, keyLen, convtBuf);
			String value = loadConvert(lr.lineBuf, valueStart, limit - valueStart, convtBuf);
			put(key, value);
			loadCount++;
		}
	}

	@SuppressWarnings("unchecked")
	private List<String> store(OutputStream out, String comments) throws IOException {

		BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out, CharEncoding.UTF_8));
		if (comments != null) {
			writeln(writer, "#" + comments);
		}
		writeln(writer, "#" + new Date().toString());

		// ConcurrentModificationException 低減のため clone
		HashMap<String, String> c = null;
		try {
			c = (HashMap<String, String>) clone();
		} catch (ConcurrentModificationException e) {
			log.info("非同期変更により保管できませんでした。リトライします。");
			try {
				c = (HashMap<String, String>) clone();
			} catch (ConcurrentModificationException e2) {
				log.warn("非同期変更により保管できませんでした。");
				return null;
			}
		}

		// キーでソート
		List<String> keyList = new ArrayList<String>(c.keySet());
		Collections.sort(keyList, String.CASE_INSENSITIVE_ORDER);

		for (String key : keyList) {
			String val = get(key);
			key = saveConvert(key, true);
			val = saveConvert(val, false);
			writeln(writer, key + "=" + val);
		}
		writer.flush();

		return keyList;
	}

	class LineReader {

		public LineReader(Reader reader) {
			this.reader = reader;
			inCharBuf = new char[8192];
		}

		byte[] inByteBuf;
		char[] inCharBuf;
		char[] lineBuf = new char[1024];
		int inLimit = 0;
		int inOff = 0;
		Reader reader;

		int readLine() throws IOException {
			int len = 0;
			char c = 0;

			boolean skipWhiteSpace = true;
			boolean isCommentLine = false;
			boolean isNewLine = true;
			boolean appendedLineBegin = false;
			boolean precedingBackslash = false;
			boolean skipLF = false;

			while (true) {
				if (inOff >= inLimit) {
					inLimit = reader.read(inCharBuf);
					inOff = 0;
					if (inLimit <= 0) {
						if (len == 0 || isCommentLine) {
							return -1;
						}
						return len;
					}
				}
				c = inCharBuf[inOff++];
				if (skipLF) {
					skipLF = false;
					if (c == '\n') {
						continue;
					}
				}
				if (skipWhiteSpace) {
					if (c == ' ' || c == '\t' || c == '\f') {
						continue;
					}
					if (!appendedLineBegin && (c == '\r' || c == '\n')) {
						continue;
					}
					skipWhiteSpace = false;
					appendedLineBegin = false;
				}
				if (isNewLine) {
					isNewLine = false;
					if (c == '#' || c == '!') {
						isCommentLine = true;
						continue;
					}
				}

				if (c != '\n' && c != '\r') {
					lineBuf[len++] = c;
					if (len == lineBuf.length) {
						int newLength = lineBuf.length * 2;
						if (newLength < 0) {
							newLength = Integer.MAX_VALUE;
						}
						char[] buf = new char[newLength];
						System.arraycopy(lineBuf, 0, buf, 0, lineBuf.length);
						lineBuf = buf;
					}
					// flip the preceding backslash flag
					if (c == '\\') {
						precedingBackslash = !precedingBackslash;
					} else {
						precedingBackslash = false;
					}
				} else {
					// reached EOL
					if (isCommentLine || len == 0) {
						isCommentLine = false;
						isNewLine = true;
						skipWhiteSpace = true;
						len = 0;
						continue;
					}
					if (inOff >= inLimit) {
						inLimit = reader.read(inCharBuf);
						inOff = 0;
						if (inLimit <= 0) {
							return len;
						}
					}
					if (precedingBackslash) {
						len -= 1;
						// skip the leading whitespace characters in following
						// line
						skipWhiteSpace = true;
						appendedLineBegin = true;
						precedingBackslash = false;
						if (c == '\r') {
							skipLF = true;
						}
					} else {
						return len;
					}
				}
			}
		}
	}

	private String loadConvert(char[] in, int off, int len, char[] convtBuf) {
		if (convtBuf.length < len) {
			int newLen = len * 2;
			if (newLen < 0) {
				newLen = Integer.MAX_VALUE;
			}
			convtBuf = new char[newLen];
		}
		char aChar;
		char[] out = convtBuf;
		int outLen = 0;
		int end = off + len;

		while (off < end) {
			aChar = in[off++];
			if (aChar == '\\') {
				aChar = in[off++];
				/* native2ascii 変換しない
				if (aChar == 'u') {
					int value = 0;
					for (int i = 0; i < 4; i++) {
						aChar = in[off++];
						switch (aChar) {
						case '0':
						case '1':
						case '2':
						case '3':
						case '4':
						case '5':
						case '6':
						case '7':
						case '8':
						case '9':
							value = (value << 4) + aChar - '0';
							break;
						case 'a':
						case 'b':
						case 'c':
						case 'd':
						case 'e':
						case 'f':
							value = (value << 4) + 10 + aChar - 'a';
							break;
						case 'A':
						case 'B':
						case 'C':
						case 'D':
						case 'E':
						case 'F':
							value = (value << 4) + 10 + aChar - 'A';
							break;
						default:
							throw new IllegalArgumentException("Malformed \\uxxxx encoding.");
						}
					}
					out[outLen++] = (char) value;
				} else {
				*/
					if (aChar == 't')
						aChar = '\t';
					else if (aChar == 'r')
						aChar = '\r';
					else if (aChar == 'n')
						aChar = '\n';
					else if (aChar == 'f')
						aChar = '\f';
					out[outLen++] = aChar;
				//}
			} else {
				out[outLen++] = aChar;
			}
		}
		return new String(out, 0, outLen);
	}

	private String saveConvertComment(String theString) {

		String space = theString.replaceFirst("(\\s*).*", "$1");
		return space + saveConvert(theString.trim(), false);
	}

	private String saveConvert(String theString, boolean escapeSpace) {

		int len = theString.length();
		int bufLen = len * 2;
		if (bufLen < 0) {
			bufLen = Integer.MAX_VALUE;
		}
		StringBuilder outBuffer = new StringBuilder(bufLen);

		for (int x = 0; x < len; x++) {
			char aChar = theString.charAt(x);
			if ((aChar > 61) && (aChar < 127)) {
				if (aChar == '\\') {
					outBuffer.append('\\');
					outBuffer.append('\\');
					continue;
				}
				outBuffer.append(aChar);
				continue;
			}
			switch (aChar) {
			case ' ':
				if (x == 0 || escapeSpace)
					outBuffer.append('\\');
				outBuffer.append(' ');
				break;
			case '\t':
				outBuffer.append('\\');
				outBuffer.append('t');
				break;
			case '\n':
				outBuffer.append('\\');
				outBuffer.append('n');
				break;
			case '\r':
				outBuffer.append('\\');
				outBuffer.append('r');
				break;
			case '\f':
				outBuffer.append('\\');
				outBuffer.append('f');
				break;
			case '=': // Fall through
			case ':': // Fall through
			case '#': // Fall through
			case '!':
				outBuffer.append('\\');
				outBuffer.append(aChar);
				break;
			default:
				/* native2ascii 変換しない
				if ((aChar < 0x0020) || (aChar > 0x007e)) {
					outBuffer.append('\\');
					outBuffer.append('u');
					outBuffer.append(toHex((aChar >> 12) & 0xF));
					outBuffer.append(toHex((aChar >> 8) & 0xF));
					outBuffer.append(toHex((aChar >> 4) & 0xF));
					outBuffer.append(toHex(aChar & 0xF));
				} else {
				*/
					outBuffer.append(aChar);
				//}
			}
		}
		return outBuffer.toString();
	}

	private static void writeln(BufferedWriter bw, String s) throws IOException {
		bw.write(s);
		bw.newLine();
	}
}
