/*******************************************************************************
 * Copyright (c) 2021 jkubitz and others.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     jkubitz - initial API and implementation
 *******************************************************************************/
package org.eclipse.jdt.internal.core.builder;

import java.io.DataOutputStream;
import java.io.IOException;
import java.util.HashMap;

/** abstraction of DataOutputStream **/
public class CompressedWriter {
	private final DataOutputStream out;
	// --- context state: ---
	private char[] lastName;
	private final HashMap<String, Integer> lastWords;

	public CompressedWriter(DataOutputStream out) {
		this.out = out;
		this.lastName = new char[0];
		this.lastWords = new HashMap<>();
	}

	// ---- direct forwards to DataOutputStream: ---

	/** @see CompressedReader#readBoolean() **/
	public void writeBoolean(boolean v) throws IOException {
		this.out.writeBoolean(v);
	}

	/** @see CompressedReader#readByte() **/
	public void writeByte(int v) throws IOException {
		this.out.writeByte(v);
	}

	/** @see CompressedReader#readLong() **/
	public void writeLong(long v) throws IOException {
		this.out.writeLong(v);
	}

	/** @see CompressedReader#readInt() **/
	public void writeInt(int v) throws IOException {
		this.out.writeInt(v);
	}

	// ---- compressed outputs: ---

	/**
	 * Write the given name UTF8 encoded.
	 *
	 * @param name
	 *            chars to write
	 * @see CompressedReader#readChars()
	 **/
	public void writeChars(char[] name) throws IOException {
		// compress with UTF8 encoding
		// => typically only one byte per char
		this.out.writeUTF(new String(name));
	}

	/**
	 * Writes v using a list of likely values "typical". If v is element of "typical" then encode v as an index.
	 * Otherwise encoded as typical.length and plain encoded v;
	 *
	 * @param v
	 *            the number to write
	 * @param typical
	 *            a list of most used values
	 *
	 * @see CompressedReader#readIntWithHint(int[])
	 **/
	public void writeIntWithHint(int v, int[] typical) throws IOException {
		// => typically only one byte per int
		int i;
		// find index
		for (i = 0; i < typical.length; i++) {
			if (typical[i] == v) {
				break;
			}
		}
		writeIntInRange(i, typical.length + 1); // write index
		if (i == typical.length) {
			writeInt(v); // write plain
		}
	}

	/**
	 * Write a number v which must be 0 <= v <range. Otherwise IllegalArgumentException is thrown. Encodes only as many
	 * bytes as needed. I.e. skipping the high bytes.
	 *
	 * @param v
	 *            the number to write.
	 * @param range
	 *            the maximal possible value of v + 1
	 * @see CompressedReader#readIntInRange(int)
	 **/
	public void writeIntInRange(int v, int range) throws IOException {
		// => typically(0<range<256) only one byte per int
		if (range < 0 || range > 0xFFFFFF) {
			this.out.writeInt(v);
		} else {
			if (v >= range) {
				throw new IllegalArgumentException(v + "/" + range);//$NON-NLS-1$
			}
			if (v < 0) {
				throw new IllegalArgumentException(v + "/" + range);//$NON-NLS-1$
			}
			if (range <= 0xFF) {
				this.out.writeByte(v);
			} else if (range <= 0xFFFF) {
				this.out.writeShort(v);
			} else {
				this.out.writeByte(v);
				this.out.writeShort(v >>> 8);
			}
		}
	}

	/**
	 * Writes the given String. If the String was already written before its index in the dictionary is encoded as
	 * index+1. Otherwise a 0 index is encoded and then the String is encoded as UTF8 and stored into dictionary.
	 *
	 * @param v
	 *            the String to write
	 * @see CompressedReader#readStringUsingDictionary()
	 **/
	public void writeStringUsingDictionary(String v) throws IOException {
		// => typically(less then 256 Strings) only one byte per String
		int size = this.lastWords.size();
		Integer index = this.lastWords.putIfAbsent(v, size);
		if (index == null) {
			// using 0 as magic number (instead of size) to get a low entropy output.
			writeIntInRange(0, size + 1); // write magic 0, which is skipped in the other case
			this.out.writeUTF(v);
		} else {
			writeIntInRange(index.intValue() + 1, size + 1); // write index+1 to skip magic 0
		}
	}

	/**
	 * Writes the given String. Encoded like a char array using last string.
	 *
	 * @param name
	 *            the String to write.
	 * @see CompressedReader#readStringUsingLast()
	 **/
	public void writeStringUsingLast(String name) throws IOException {
		// convenience Method for Strings
		writeCharsUsingLast(name.toCharArray());
	}

	/**
	 * Writes the given char array and remember it. Encoded by writing the length of common leading chars with the last
	 * given array followed by the remaining chars using a dictionary. Works best if the names are written in binary
	 * sorted order.
	 *
	 * @param name
	 *            the char array to write
	 * @see CompressedReader#readCharsUsingLast()
	 **/
	public void writeCharsUsingLast(char[] name) throws IOException {
		// compress by assuming the last name started with the same characters
		// => typically(qualified name from same package) only two bytes per package + unqualified name from dictionary
		int commonLength = commonLength(name, this.lastName, 255);
		this.out.writeByte(commonLength);
		writeStringUsingDictionary(new String(name, commonLength, name.length - commonLength));
		this.lastName = name;
	}

	private static int commonLength(char[] a, char[] b, int max) {
		int i = 0;
		int end = Math.min(Math.min(max, a.length), b.length);
		while (i < end && a[i] == b[i]) {
			i++;
		}
		return i;
	}
}