/*
 * $Id$
 *
 * Copyright 1996-2008 Sun Microsystems, Inc.  All Rights Reserved.
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 only, as
 * published by the Free Software Foundation.  Sun designates this
 * particular file as subject to the "Classpath" exception as provided
 * by Sun in the LICENSE file that accompanied this code.
 *
 * This code 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
 * version 2 for more details (a copy is included in the LICENSE file that
 * accompanied this code).
 *
 * You should have received a copy of the GNU General Public License version
 * 2 along with this work; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * Please contact Sun Microsystems, Inc., 4150 Network Circle, Santa Clara,
 * CA 95054 USA or visit www.sun.com if you need additional information or
 * have any questions.
 */
package com.sun.javatest;

import java.io.BufferedInputStream;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileWriter;
import java.io.InputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.io.PrintWriter;
import java.net.URL;
import java.net.URLClassLoader;
import java.text.MessageFormat;
import java.util.Properties;
import java.util.ResourceBundle;

import com.sun.interview.Interview;
import com.sun.interview.Question;
import com.sun.interview.WizPrint;
import com.sun.javatest.util.BackupPolicy;
import com.sun.javatest.util.I18NResourceBundle;

/**
 * This class provides a utility for command-line editing of configuration (.jti) files.
 * It is intended to be invoked from the command line, as in: <pre>
 * java com.sun.javatest.EditJIT options...
 * </pre> 
 * For details of the options, use the <code>-help</code> option.
 */
public class EditJTI
{
    /**
     * This exception is used to indicate a problem with the command line arguments.
     */
    public static class BadArgs extends Exception
    {
	/**
	 * Create a BadArgs exception.
	 * @param i18n A resource bundle in which to find the detail message.
	 * @param s The key for the detail message.
	 */
	BadArgs(ResourceBundle i18n, String s) {
	    super(i18n.getString(s));
	}

	/**
	 * Create a BadArgs exception.
	 * @param i18n A resource bundle in which to find the detail message.
	 * @param s The key for the detail message.
	 * @param o An argument to be formatted with the detail message by
	 * {@link java.text.MessageFormat#format}
	 */	BadArgs(ResourceBundle i18n, String s, Object o) {
	    super(MessageFormat.format(i18n.getString(s), new Object[] {o}));
	}


	/**
	 * Create a BadArgs exception.
	 * @param i18n A resource bundle in which to find the detail message.
	 * @param s The key for the detail message.
	 * @param o An array of arguments to be formatted with the detail message by
	 * {@link java.text.MessageFormat#format}
	 */
	BadArgs(ResourceBundle i18n, String s, Object[] o) {
	    super(MessageFormat.format(i18n.getString(s), o));
	}
    }

    /**
     * This exception is used to report problems that arise when using this API.
     */
    public static class Fault extends Exception
    {
	Fault(I18NResourceBundle i18n, String s) {
	    super(i18n.getString(s));
	}

	Fault(I18NResourceBundle i18n, String s, Object o) {
	    super(i18n.getString(s, o));
	}

	Fault(I18NResourceBundle i18n, String s, Object[] o) {
	    super(i18n.getString(s, o));
	}
    }

    /**
     * Command line entry point. Run with <code>-help</code> to get 
     * brief command line help.  Warning: this method uses System.exit
     * and so does not return if called directly.
     * @param args Comamnd line arguments. 
     */
    public static void main(String[] args) {
	try {
	    EditJTI e = new EditJTI();
	    boolean ok = e.run(args);
	    System.exit(ok ? 0 : 1);
	}
	catch (BadArgs e) {
	    System.err.println(e.getMessage());
	    usage(System.err);
	    System.exit(2);
	}
	catch (Fault e) {
	    System.err.println(e.getMessage());
	    System.exit(3);
	}
    }

    /**
     * Print out brief command line help.
     * @param out the stream to which to write the command line help.
     */
    public static void usage(PrintStream out) {
	String prog = System.getProperty("program", "java " + EditJTI.class.getName());
	out.println(i18n.getString("editJTI.usage.title"));
	out.print("  ");
	out.print(prog);
	out.println(i18n.getString("editJTI.usage.summary"));

	out.println(i18n.getString("editJTI.usage.options"));
	out.println(i18n.getString("editJTI.usage.help1"));
	out.println(i18n.getString("editJTI.usage.help2"));
	out.println(i18n.getString("editJTI.usage.help3"));
	out.println(i18n.getString("editJTI.usage.classpath1"));
	out.println(i18n.getString("editJTI.usage.classpath2"));
	out.println(i18n.getString("editJTI.usage.log1"));
	out.println(i18n.getString("editJTI.usage.log2"));
	out.println(i18n.getString("editJTI.usage.outfile1"));
	out.println(i18n.getString("editJTI.usage.outfile2"));
	out.println(i18n.getString("editJTI.usage.path1"));
	out.println(i18n.getString("editJTI.usage.path2"));
	out.println(i18n.getString("editJTI.usage.preview1"));
	out.println(i18n.getString("editJTI.usage.preview2"));
	out.println(i18n.getString("editJTI.usage.ts1"));
	out.println(i18n.getString("editJTI.usage.ts2"));
	out.println(i18n.getString("editJTI.usage.verbose1"));
	out.println(i18n.getString("editJTI.usage.verbose2"));
	out.println("");
	out.println(i18n.getString("editJTI.usage.edit"));
	out.println(i18n.getString("editJTI.usage.set"));
	out.println(i18n.getString("editJTI.usage.search"));
	out.println("");
    }

    /**
     * Run the utility, without exiting.  Any messages are written to
     * the standard output stream.
     * @param args command line args
     * @return true if the resulting configuration is valid (complete),
     * and false otherwise.
     * @throws EditJTI.BadArgs if there is an error analysing the args
     * @throws EditJTI.Fault if there is an error executing the args
     */
    public boolean run(String[] args) throws BadArgs, Fault {
	PrintWriter out = new PrintWriter(System.out);
	try {
	    return run(args, out);
	}
	finally {
	    out.flush();
	}
    }


    /**
     * Run the utility, without exiting, writing any messages to a specified stream.
     * @param args command line args
     * @param out the stream to which to write any messages
     * @return true if the resulting configuration is valid (complete),
     * and false otherwise.
     * @throws EditJTI.BadArgs if there is an error analysing the args
     * @throws EditJTI.Fault if there is an error executing the args
     */
    public boolean run(String[] args, PrintWriter out) throws BadArgs, Fault {
	File inFile = null;
	File outFile = null;
	File logFile = null;
	File classPath = null;
	File testSuitePath = null;
	File workDirPath = null;
	String[] editCmds = null;
	boolean helpFlag = false;
	boolean previewFlag = false;
	boolean showPathFlag = false;
	boolean verboseFlag = false;
	
	for (int i = 0; i < args.length; i++) {
	    if ((args[i].equals("-o") || args[i].equals("-out")) 
		&& i + 1 < args.length) {
		checkUnset(outFile, args[i]);
		outFile = new File(args[++i]);
	    }
	    else if ((args[i].equals("-i") || args[i].equals("-in")) 
		&& i + 1 < args.length) {
		checkUnset(inFile, args[i]);
		inFile = new File(args[++i]);
	    }
	    else if ((args[i].equals("-l") || args[i].equals("log"))
		     && i + 1 < args.length) {
		checkUnset(logFile, args[i]);
		logFile = new File(args[++i]);
	    }
	    else if (args[i].equals("-n") || args[i].equals("-preview"))
		previewFlag = true;
	    else if (args[i].equals("-p") || args[i].equals("-path"))
		showPathFlag = true;
	    else if (args[i].equals("-v") || args[i].equals("-verbose")  )
		verboseFlag = true;
	    else if ((args[i].equals("-cp") || args[i].equals("-classpath")) && i + 1 < args.length) {
		checkUnset(classPath, args[i]);
		classPath = new File(args[++i]);
	    }
	    else if ((args[i].equals("-ts") || args[i].equals("-testsuite")) && i + 1 < args.length) {
		checkUnset(testSuitePath, args[i]);
		testSuitePath = new File(args[++i]);
	    }
	    else if ((args[i].equals("-wd") || args[i].equals("-workdir")) && i + 1 < args.length) {
		checkUnset(testSuitePath, args[i]);
		workDirPath = new File(args[++i]);
	    }
	    else if (args[i].equals("-help") || args[i].equals("-usage") || args[i].equals("/?") )
		helpFlag = true;
	    else if (args[i].startsWith("-"))
		throw new BadArgs(i18n, "editJTI.badOption", args[i]);
	    else if (i <= args.length - 1) {
		if (inFile == null) {
		    editCmds = new String[args.length - 1 - i];
		    System.arraycopy(args, i, editCmds, 0, editCmds.length);
		    inFile = new File(args[args.length - 1]);
		}
		else {
		    editCmds = new String[args.length - i];
		    System.arraycopy(args, i, editCmds, 0, editCmds.length);
		}
		i = args.length - 1;
	    }
	    else
		throw new BadArgs(i18n, "editJTI.badOption", args[i]);	
	}

	if (args.length == 0 || helpFlag) {
	    usage(System.out);
	    if (inFile == null)
		return true;
	}

	if (classPath != null && testSuitePath != null)
	    throw new BadArgs(i18n, "editJTI.cantHaveClassPathAndTestSuite");
	
	if (inFile == null)
	    throw new BadArgs(i18n, "editJTI.noInterview");
	
	// if (editCmds.length == 0 && outFile == null && logFile == null && !showPathFlag)
	//     throw new BadArgs(...no.actions....);

	verbose = verboseFlag;
	this.out = out;

	try {
	    /* the following looks nice and simple, but breaks compatibility
	       with 3.1.4, because InterviewParameters.open will try and open
	       the wd in the .jti file if not given explicitly -- and previously,
	       this was not required/done.  So, only use the simple code if wd
	       is set, and use the old 3.1.4 code if wd is not set.
	    */
	    /* See comment above
	    if (workDirPath != null || testSuitePath != null) {
		interview = InterviewParameters.open(testSuitePath, workDirPath, inFile);
	    }
	    */
	    if (workDirPath != null) 
		interview = InterviewParameters.open(testSuitePath, workDirPath, inFile);
	    else if (testSuitePath != null) {
		// only open the test suite, not the work dir
		TestSuite ts;
		try {
		    ts = TestSuite.open(testSuitePath);
		}
		catch (FileNotFoundException e) {
		    throw new Fault(i18n, "editJTI.cantFindTestSuite", testSuitePath);
		}
		catch (TestSuite.NotTestSuiteFault e) {
		    throw new Fault(i18n, "editJTI.notATestSuite", testSuitePath);
		}
		catch (TestSuite.Fault e) {
		    throw new Fault(i18n, "editJTI.cantOpenTestSuite", 
				    new Object[] { testSuitePath, e });
		}
		load(inFile, ts);
	    }
	    // End of patches for 3.1.4 compatibility
	    else if (classPath != null) {
		URLClassLoader loader = new URLClassLoader(new URL[] { classPath.toURL() });
		load(inFile, loader);
	    }
	    else 
		load(inFile);
		
	}
	catch (Interview.Fault e) {
	    throw new Fault(i18n, "editJTI.cantOpenFile", 
			    new Object[] { inFile.getPath(), e.getMessage() });
	}
	catch (FileNotFoundException e) {
	    throw new Fault(i18n, "editJTI.cantFindFile", inFile.getPath());
	}
	catch (IOException e) {
	    throw new Fault(i18n, "editJTI.cantOpenFile", 
			    new Object[] { inFile.getPath(), e });
	}
	catch (IllegalStateException e) {
            // only occurs if keywords are being used in the config, and the
            // test suite is not available.  user needs to specify -wd or -ts
            if (verbose)
                e.printStackTrace();

	    throw new Fault(i18n, "editJTI.badState", e.getMessage());
	}
	
	if (NUM_BACKUPS > 0)
	    interview.setBackupPolicy(BackupPolicy.simpleBackups(NUM_BACKUPS));

	if (editCmds != null)
	    edit(editCmds);


	if (showPathFlag)
	    showPath();

	try {
	    if (logFile != null) {
		if (previewFlag) {
		    String msg = i18n.getString("editJTI.wouldWriteLog", logFile);
		    out.println(msg);
		}
		else
		    writeLog(logFile);
	    }
	}
	catch (IOException e) {
	    throw new Fault(i18n, "editJTI.cantWriteLog", 
			    new Object[] { logFile.getPath(), e });
	}
	

	try {
	    if (previewFlag) {
		String msg;
		if (interview.isEdited())
		    msg = i18n.getString("editJTI.wouldSaveEdited", 
				(outFile != null ? outFile : inFile));
		else if (outFile != null)
		    msg = i18n.getString("editJTI.wouldSaveNotEdited", outFile);
		else
		    msg = i18n.getString("editJTI.wouldNotSave");
		out.println(msg);
	    }
	    else {
		if (outFile != null) 
		    save(outFile);
		else if (interview.isEdited())
		    save(inFile);
	    }
	}
	catch (Interview.Fault e) {
	    throw new Fault(i18n, "editJTI.cantOpenFile", 
			    new Object[] { outFile.getPath(), e });
	}
	catch (IOException e) {
	    File f = (outFile == null ? interview.getFile() : outFile);
	    throw new Fault(i18n, "editJTI.cantSaveFile", 
			    new Object[] { f.getPath(), e });
	}

	return (interview.isFinishable());
    }

    /**
     * Load a configuration file to be edited.
     * @param inFile the file to be loaded
     * @throws IOException if there is a problem reading the file
     * @throws Interview.Fault if there is a problem loading the interview data from the file
     */
    public void load(File inFile) throws IOException, Interview.Fault {
	// this opens the interview via the work directory and test suite;
	// the test suite implicitly knows its classpath via the .jtt file
	interview = InterviewParameters.open(inFile);
	interview.setEdited(false);
    }

    /**
     * Load a configuration file to be edited.
     * @param inFile the file to be loaded
     * @param ts the test suite for which the interview is to be loaded
     * @throws IOException if there is a problem reading the file
     * @throws Interview.Fault if there is a problem loading the interview data from the file
     * @throws EditJTI.Fault if there is a problem creating the interview for the testsuite
     */
    public void load(File inFile, TestSuite ts) 
	throws IOException, Interview.Fault, Fault 
    {
	// this opens the interview via the work directory and test suite;
	// the test suite implicitly knows its classpath via the .jtt file
	try {
	    interview = ts.createInterview();
	}
	catch (TestSuite.Fault e) {
	    throw new Fault(i18n, "editJTI.cantCreateInterviewForTestSuite", 
			    new Object[] { ts.getPath(), e.getMessage() });
	}
	interview.load(inFile);
	interview.setEdited(false);
    }

    /**
     * Load a configuration file to be edited, using a specified class loader
     * to load the interview class.
     * @param inFile the file to be loaded
     * @param loader the class loader to be used to load the interview class
     * @throws IOException if there is a problem reading the file
     * @throws Interview.Fault if there is a problem loading the interview data from the file
     * @throws EditJTI.Fault if there is a problem creating the interview for the testsuite
     */
    public void load(File inFile, URLClassLoader loader) 
	throws IOException, Interview.Fault, Fault
    {
	InputStream in = new BufferedInputStream(new FileInputStream(inFile));
	Properties p = new Properties();
	p.load(in);
	in.close();

	String interviewClassName = (String) (p.get("INTERVIEW"));
	try {
	    Class interviewClass = loader.loadClass(interviewClassName);

	    interview = (InterviewParameters)(interviewClass.newInstance());
	}
	catch (ClassCastException e) {
	    throw new Fault(i18n, "editJTI.invalidInterview", inFile);
	}
	catch (ClassNotFoundException e) {
	    throw new Fault(i18n, "editJTI.cantFindClass", 
			    new Object[] { interviewClassName, inFile });
	}
	catch (InstantiationException e) {
	    throw new Fault(i18n, "editJTI.cantInstantiateClass",  
			    new Object[] { interviewClassName, inFile });
	}
	catch (IllegalAccessException e) {
	    throw new Fault(i18n, "editJTI.cantAccessClass",  
			    new Object[] { interviewClassName, inFile });
	}

	interview.load(inFile);
	interview.setEdited(false);
    }

    /**
     * Save the edited configuration in a specified file.
     * @param file The file in which to save the configuration
     * @throws IOException if there is a problem while writing the file
     * @throws Interview.Fault if there is a problem while saving the interview data
     */
    public void save(File file) throws IOException, Interview.Fault {
	interview.save(file);
    }

    /**
     * Show the current question path for the configuration.
     */
    public void showPath() {
	Question[] path = interview.getPath();

	int indent = 0;
	for (int i = 0; i < path.length; i++)
	    indent = Math.max(indent, path[i].getTag().length());
	indent = Math.min(indent, MAX_INDENT);

	for (int i = 0; i < path.length; i++) {
	    Question q = path[i];
	    String tag = q.getTag();
	    String value = q.getStringValue();
	    out.print(tag);
	    int l = tag.length();
	    if (l > MAX_INDENT && value != null && value.length() > 0) {
		out.println();
		l = 0;
	    }
	    for (int x = l; x < indent; x++)
		out.print(' ');
	    out.print(' ');
	    out.println(value == null ? "" : value);
	}
    }

    /**
     * Write a log of the questions that determine the current configuration.
     * @param logFile the file to which to write the log
     * @throws IOException if there is a problem while writing the log file
     */
    public void writeLog(File logFile) throws IOException {
	WizPrint wp = new WizPrint(interview);
	wp.setShowResponses(true);
	wp.setShowResponseTypes(false);
	wp.setShowTags(true);
	BufferedWriter out = new BufferedWriter(new FileWriter(logFile));
	wp.write(out);
    }

    /**
     * Apply a series of edits to the current configuration.
     * @param cmds the editing commands to be applied
     * @throws EditJTI.Fault if there is a problem while applying the edit commands.
     * @see #edit(String)
     */
    public void edit(String[] cmds) throws Fault {
	for (int i = 0; i < cmds.length; i++) {
	    edit(cmds[i]);
	}
    }

    /**
     * Apply an edit to the current configuration.
     * @param cmd the editing command to be applied
     * Currently, two forms of command are supported: <dl>
     * <dt><em>tag-name=value</em>
     * <dd>Set the response to the question whose value is <em>tag-name</em> to <em>value</em>
     * <dt><em>/search/replace/</em>
     * <dd>For all questions on the current path, change instances of <em>search</em> to  <em>replace</em>
     * </dl>
     * @throws EditJTI.Fault if there is a problem while applying the edit commands.
     * @see #edit(String[])
     */
    public void edit(String cmd) throws Fault {
	if (cmd == null || cmd.length() == 0)
	    return;
	
	int eqIndex = cmd.indexOf('=');
	if (Character.isJavaIdentifierStart(cmd.charAt(0)) && eqIndex > 0) 
	    setValue(cmd.substring(0, eqIndex), cmd.substring(eqIndex + 1));
	else if (cmd.toLowerCase().startsWith("import:")) {
	    importFile(new File(cmd.substring("import:".length())));
	}
	else {
	    int left = 0;
	    // could support a command letter in front?
	    char delim = cmd.charAt(left);
	    int center = cmd.indexOf(delim, left + 1);
	    if (center == -1)
		throw new Fault(i18n, "editJTI.badCmd", cmd);
	    // could support trailing flags?
	    int right = cmd.length() - 1;
	    if (cmd.charAt(right) != delim)
		throw new Fault(i18n, "editJTI.badCmd", cmd);
	    String searchText = cmd.substring(left + 1, center);
	    String replaceText = cmd.substring(center + 1, right);
	    if (searchText.length() == 0)
		throw new Fault(i18n, "editJTI.badCmd", cmd);
	    setMatchingValues(searchText, replaceText);
	}
    }

    private void importFile(File file) throws Fault {
	
	InputStream in;
	try {
	    in = new BufferedInputStream(new FileInputStream(file));
	}
	catch (FileNotFoundException e) {
	    throw new Fault(i18n, "editJTI.cantFindImport", file);
	}
	catch (IOException e) {
	    throw new Fault(i18n, "editJTI.cantOpenImport", 
			    new Object[] { file, e });
	}

	Properties p;
	try {
	    p = new Properties();
	    p.load(in);
	    in.close();
	}
	catch (IOException e) {
	    throw new Fault(i18n, "editJTI.cantReadImport", 
			    new Object[] { file, e });
	}

	// for each question on the path, see if there is a corresponding 
	// imported value
	Question[] path = interview.getPath();
	for (int i = 0; i < path.length; i++) {
	    Question q = path[i];
	    String v = p.getProperty(q.getTag());
	    if (v != null) {
		setValue(q, v);
		path = interview.getPath(); // update path in case tail has changed
	    }
	}
    }

    private void setMatchingValues(String searchText, String replaceText) throws Fault {
	boolean found = false;

	Question[] path = interview.getPath();
	for (int i = 0; i < path.length; i++) {
	    Question q = path[i];
	    String currValue = q.getStringValue();
	    if (currValue == null)
		continue;
	    // currently hardwired: considerCase: false; word match: false
	    int pos = match(searchText, currValue, false, false);
	    if (pos >= 0) {
		String newValue = currValue.substring(0, pos) 
		    + replaceText 
		    + currValue.substring(pos + searchText.length());
		setValue(q, newValue);
		found = true;
		path = interview.getPath(); // update path in case tail has changed
	    }
	}
	if (!found)
	    throw new Fault(i18n, "editJTI.cantFindMatch", searchText);
    }

    private void setValue(String tag, String value) throws Fault {
	Question[] path = interview.getPath();
	for (int i = 0; i < path.length; i++) {
	    Question q = path[i];
	    if (q.getTag().equals(tag)) {
		setValue(q, value);
		return;
	    }
	}
	throw new Fault(i18n, "editJTI.cantFindQuestion", tag);
    }

    private void setValue(Question q, String value) throws Fault {
	try {
	    String oldValue = q.getStringValue();
	    q.setValue(value);
	    if (verbose)
		out.println(i18n.getString("editJTI.update", 
		    new Object[] { q.getTag(), oldValue, q.getStringValue() }));
	}
	catch (Interview.Fault e) {
	    throw new Fault(i18n, "editJTI.cantSetValue", new Object[] { q.getTag(), e.getMessage() } );
	}
    }

    private static int match(String s1, String s2, boolean considerCase, boolean word) {
	int s1len = s1.length();
	int s2len = s2.length();
	for (int i = 0; i <= s2len - s1len; i++) {
	    if (s1.regionMatches(!considerCase, 0, s2, i, s1len)) {
		if (!word || (word &&
			      ( (i == 0 || isBoundaryCh(s2.charAt(i-1))) 
				&& (i+s1len == s2.length() || isBoundaryCh(s2.charAt(i+s1len))) )))
		    return i;
	    }
	}
	return -1;
    }

    private static boolean isBoundaryCh(char c) {
	return !(Character.isUnicodeIdentifierStart(c)
		 || Character.isUnicodeIdentifierPart(c));
    }

    private static void checkUnset(Object item, String option) 
	throws BadArgs 
    {
	if (item != null)
	    throw new BadArgs(i18n, "editJTI.dupOption",option);
    }

    private InterviewParameters interview;
    private boolean verbose;
    private PrintWriter out;

    private static int MAX_INDENT = Integer.getInteger("EditJTI.maxIndent", 32).intValue();
    private static int NUM_BACKUPS = Integer.getInteger("EditJTI.numBackups", 2).intValue();

    private static I18NResourceBundle i18n = I18NResourceBundle.getBundleForClass(EditJTI.class);
}
