/*
 * This file is part of Nuts Framework.
 * Copyright(C) 2009-2012 Nuts Develop Team.
 *
 * Nuts Framework is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License any later version.
 * 
 * Nuts Framework 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.
 *
 * You should have received a copy of the GNU General Public License
 * along with Nuts Framework. If not, see <http://www.gnu.org/licenses/>.
 */
package nuts.tools.jsmin;

import nuts.core.io.IOUtils;
import nuts.core.lang.StringUtils;
import nuts.core.tool.AbstractCommandTool;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.PrintStream;

import org.apache.commons.cli.CommandLine;

/**
 * A class used for minify java script
 */
public class JSMinify {
	/**
	 * Main & Ant entry class fir JSMinify
	 */
	public static class Main extends AbstractCommandTool {
		private String jsfile;
		private String minfile;
		private String charset;
		
		/**
		 * @param jsfile the jsfile to set
		 */
		public void setJsfile(String jsfile) {
			this.jsfile = jsfile;
		}

		/**
		 * @param minfile the minfile to set
		 */
		public void setMinfile(String minfile) {
			this.minfile = minfile;
		}

		/**
		 * @param charset the charset to set
		 */
		public void setCharset(String charset) {
			this.charset = charset;
		}

		@Override
		protected void addCommandLineOptions() throws Exception {
			super.addCommandLineOptions();
			
			addCommandLineOption("j", "jsfile", "JavaScript file.");
			addCommandLineOption("m", "minfile", "Minified file. (default is the output of console.)");
			addCommandLineOption("c", "charset", "The charset of JavaScript file.");
		}

		@Override
		protected void getCommandLineOptions(CommandLine cl) throws Exception {
			super.getCommandLineOptions(cl);
			
			if (cl.hasOption("j")) {
				setParameter("jsfile", cl.getOptionValue("j").trim());
			}
			else {
				errorRequired(options, "jsfile");
			}
			
			if (cl.hasOption("m")) {
				setParameter("minfile", cl.getOptionValue("m").trim());
			}
			
			if (cl.hasOption("c")) {
				setParameter("charset", cl.getOptionValue("c").trim());
			}
		}

		/**
		 * execute
		 * @throws Exception if an error occurs
		 */
		public void execute() throws Exception {
			InputStream is = null;
			OutputStream os = null;
			try {
				is = new FileInputStream(jsfile);
				if (StringUtils.isEmpty(minfile)) {
					os = System.out;
				}
				else {
					os = new FileOutputStream(minfile);
				}

				JSMinify jsmin = null;
				if (StringUtils.isEmpty(charset)) {
					jsmin = new JSMinify(is, os);
				}
				else {
					jsmin = new JSMinify(is, os, charset);
				}
				jsmin.jsmin();
				
				if (StringUtils.isNotEmpty(minfile)) {
					System.out.println("JSMinify " + jsfile + " -> " + minfile);
				}
			}
			finally {
				IOUtils.closeQuietly(is);
				IOUtils.closeQuietly(os);
			}
		}
		
		/**
		 * main
		 * @param args arugments
		 */
		public static void main(String args[]) {
			Main jsm = new Main();
			
			jsm.execute(jsm, args);
		}
	}

	//---------------------------------------------------------------------------------------
	// properties
	//---------------------------------------------------------------------------------------
	private static final int EOF = -1;

	private InputStreamReader in;
	private PrintStream out;

	private int theA;
	private int theB;

	/**
	 * Constructor
	 * 
	 * @param in input stream
	 * @param out output stream
	 * @throws IOException if an I/O exception occurs
	 */
	public JSMinify(InputStream in, OutputStream out) throws IOException {
		this.in = new InputStreamReader(in);
		this.out = new PrintStream(out);
	}

	/**
	 * Constructor
	 * 
	 * @param in input stream
	 * @param out output stream
	 * @param charset charset used for I/O
	 * @throws IOException if an I/O exception occurs
	 */
	public JSMinify(InputStream in, OutputStream out, String charset) throws IOException {
		this.in = new InputStreamReader(in, charset);
		this.out = new PrintStream(out, true, charset);
	}

	/**
	 * isAlphanum -- return true if the character is a letter, digit,
	 * underscore, dollar sign, or non-ASCII character.
	 */
	static boolean isAlphanum(int c) {
		c = Math.abs(c);
		return ( (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
				 (c >= 'A' && c <= 'Z') || c == '_' || c == '$' || c == '\\' ||
				 c > 126);
	}

	private int theLookahead = EOF;
	private boolean theEOF = false;
	private int theCount = 0;
	private StringBuilder theLine = new StringBuilder();

	void logChar(int c) {
		if (c != '\n') {
			if (theLine.length() > 0 && theLine.charAt(theLine.length() - 1) == '\n') {
				theLine.delete(0, theLine.length());
			}
			theLine.append((char)c);
		}
	}
	
	/**
	 * get -- return the next character from stdin. Watch out for lookahead. If
	 * the character is a control character, translate it to a space or
	 * linefeed.
	 */
	int get() throws IOException {
		int c = theLookahead;

		if (theEOF) {
			return EOF;
		}
		theLookahead = EOF;

		if (c == EOF) {
			char ca[] = new char[1];
			c = in.read(ca);
			theCount++;
			if (c == EOF) {
				theEOF = true;
				return EOF;
			}
			c = ca[0];
		}

		if (Math.abs(c) >= ' ' || c == '\n') {
			logChar(c);
			return c;
		}

		if (c == '\r') {
			logChar('\n');
			return '\n';
		}

		logChar(' ');
		return ' ';
	}



	/**
	 * Get the next character without getting it.
	 */
	int peek() throws IOException {
		theLookahead = get();
		return theLookahead;
	}

	/**
	 * next -- get the next character, excluding comments. peek() is used to see
	 * if a '/' is followed by a '/' or '*'.
	 */
	int next() throws IOException {
		int c = get();
		if (c == '/') {
			switch (peek()) {
			case '/':
				for (;;) {
					c = get();
					if (c <= '\n') {
						return c;
					}
				}

			case '*':
				get();
				for (;;) {
					switch (get()) {
					case '*':
						if (peek() == '/') {
							get();
							return ' ';
						}
						break;
					case EOF:
						throw new IOException("Unterminated Comment");
					}
				}

			default:
				return c;
			}

		}
		return c;
	}

	/**
	 * action -- do something! What you do is determined by the argument: 1
	 * Output A. Copy B to A. Get the next B. 2 Copy B to A. Get the next B.
	 * (Delete A). 3 Get the next B. (Delete B). action treats a string as a
	 * single character. Wow! action recognizes a regular expression if it is
	 * preceded by ( or , or =.
	 */
	void action(int d) throws IOException {
		switch (d) {
		case 1:
			out.print((char)theA);
		case 2:
			theA = theB;

			if (theA == '\'' || theA == '"') {
				for (;;) {
					out.print((char)theA);
					theA = get();
					if (theA == theB) {
						break;
					}
					if (Math.abs(theA) <= '\n') {
						throw new IOException("Unterminated String Literal: (" + theCount + ':' + theA + ")\n" + theLine);
					}
					if (theA == '\\') {
						out.print((char)theA);
						theA = get();
					}
				}
			}

		case 3:
			theB = next();
			if (theB == '/' && (theA == '(' || theA == ',' || theA == '=' ||
								theA == ':' || theA == '[' || theA == '!' ||
								theA == '&' || theA == '|' || theA == '?' ||
								theA == '{' || theA == '}' || theA == ';' ||
								theA == '\n')) {
				out.print((char)theA);
				out.print((char)theB);
				for (;;) {
					theA = get();
					if (theA == '/') {
						break;
					} else if (theA == '\\') {
						out.print((char)theA);
						theA = get();
					} else if (Math.abs(theA) <= '\n') {
						throw new IOException("Unterminated RegExp Literal");
					}
					out.print((char)theA);
				}
				theB = next();
			}
		}
	}

	/**
	 * jsmin -- Copy the input to the output, deleting the characters which are
	 * insignificant to JavaScript. Comments will be removed. Tabs will be
	 * replaced with spaces. Carriage returns will be replaced with linefeeds.
	 * Most spaces and linefeeds will be removed.
	 * @throws IOException if an I/O error occurs
	 */
	public void jsmin() throws IOException {
		theA = '\n';
		action(3);
		while (theA != EOF) {
			switch (theA) {
			case ' ':
				if (isAlphanum(theB)) {
					action(1);
				} else {
					action(2);
				}
				break;
			case '\n':
				switch (theB) {
				case '{':
				case '[':
				case '(':
				case '+':
				case '-':
					action(1);
					break;
				case ' ':
					action(3);
					break;
				default:
					if (isAlphanum(theB)) {
						action(1);
					} else {
						action(2);
					}
				}
				break;
			default:
				switch (theB) {
				case ' ':
					if (isAlphanum(theA)) {
						action(1);
						break;
					}
					action(3);
					break;
				case '\n':
					switch (theA) {
					case '}':
					case ']':
					case ')':
					case '+':
					case '-':
					case '"':
					case '\'':
						action(1);
						break;
					default:
						if (isAlphanum(theA)) {
							action(1);
						} else {
							action(3);
						}
					}
					break;
				default:
					action(1);
					break;
				}
			}
		}
		out.flush();
	}

}
