/*
 * $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.File;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.Vector;

import com.sun.javatest.util.DynamicArray;
import com.sun.javatest.util.I18NResourceBundle;
import com.sun.javatest.util.StringArray;

/**
 * This class provides "environments", as embodied by groups of related properties.
 * Environments have a name, and consist of those properties provided whose names 
 * do not begin with "<code>env.</code>", and in addition, those
 * properties provided whose names begin "<code>env.</code><i>env-name</i><code>.</code>".
 * In addition, an environment may inherit the properties of another environment
 * by defining a property <code>env.</code><i>env-name</i><code>inherits=</code><i>inherited-env-name</i>
 * The values of the environment's properties are split into words and various 
 * substitutions are performed.  
 *
 * <p>The preferred way to make an environment is via a configuration interview,
 * avoiding the use of the <code>env.</code><i>env-name</i> prefix, which is
 * retained for backwards compatibility with older test suites that read environments
 * from environment (.jte) files.
 */
public class TestEnvironment
{
    /**
     * This exception is used to report resolving values in an environment.
     */
    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));
	}
    }

    /**
     * Add a default set of properties to be included when environments are 
     * created.
     * @param name a name for this collection or properties, so that the
     * source of the properties can be identified when browing an environment
     * @param propTable a table of properties to be included when environments 
     * are created
     * @see #clearDefaultPropTables
     * @throws NullPointerException if either name or propTable is null.
     */
    public static synchronized void addDefaultPropTable(String name, Map propTable) {
	if (name == null || propTable == null)
	    throw new NullPointerException();

	//System.err.println("TEC: add default propTable " + name);
	defaultPropTableNames = (String[]) (DynamicArray.append(defaultPropTableNames, name));
	defaultPropTables = (Map[]) (DynamicArray.append(defaultPropTables, propTable));
    }

    /**
     * Remove all previously registered default property tables.
     * @see #addDefaultPropTable
     */
    public static synchronized void clearDefaultPropTables() {
	defaultPropTableNames = new String[0];
	defaultPropTables = new Map[0];
    }

    static String[] defaultPropTableNames = { };
    static Map[] defaultPropTables = { };

    /**
     * Construct an environment for a named group of properties.
     * @param name	The name by which to identify the group of properties
     *			for this environment
     * @param propTable	Dictionaries containing (but not limited to) the
     *			properties for this environment. 
     * @param propTableName
     *			The name of the property table, for use in diagnostics etc
     * @throws TestEnvironment.Fault if there is an error in the table
     *
     */
    public TestEnvironment(String name, Map propTable, String propTableName) 
		throws Fault {
	this(name, (new Map[] {propTable}), (new String[] {propTableName}));
    }

    /**
     * Construct an environment for a named group of properties.
     * @param name	The name by which to identify the group of properties
     *			for this environment
     * @param propTables	Dictionaries containing (but not limited to) the
     *			properties for this environment. They should be ordered 
     *			so that values specified in later tables override those
     *			specified in subsequent tables.
     * @param propTableNames
     *			The names of the property tables, for use in diagnostics etc
     * @throws TestEnvironment.Fault if there is an error in the given tables
     *
     */
    public TestEnvironment(String name, Map[] propTables, String[] propTableNames) 
	throws Fault 
    {
	this.name = name;
	if (defaultPropTables != null && defaultPropTables.length > 0) {
	    propTables = (Map[]) (DynamicArray.join(defaultPropTables, propTables));
	    propTableNames = (String[]) (DynamicArray.join(defaultPropTableNames, propTableNames));
	}

	// First, figure out the inheritance chain
	Vector v = new Vector();
	for (String n = name, inherit = null; n != null && n.length() > 0; n = inherit, inherit = null) {
	    if (v.contains(n)) 
		throw new Fault(i18n, "env.loop", name);
	    
	    v.addElement(n);
	    String prefix = "env." + n + ".";
	    for (int i = propTables.length - 1; i >= 0 && inherit == null; i--) {
		inherit = (String)(propTables[i].get("env." + n + ".inherits"));
	    }
	}
	inherits = new String[v.size()];
	v.copyInto(inherits);

	// for this environment, and its inherited environments, scan for
	// properties of the form env.NAME.KEY=value and add KEY=value into the 
	// environment's table
	for (int inheritIndex = 0; inheritIndex < inherits.length; inheritIndex++) {
	    String prefix = "env." + inherits[inheritIndex] + ".";
	    for (int propIndex = propTables.length - 1; propIndex >= 0; propIndex--) {
		Map propTable = propTables[propIndex];
		for (Iterator i = propTable.keySet().iterator(); i.hasNext(); ) {
		    String prop = (String) (i.next());
		    if (prop.startsWith(prefix)) {
			String key = prop.substring(prefix.length());
			if (!table.containsKey(key)) {
			    Element elem = new Element(key, 
						       (String)(propTable.get(prop)),
						       inherits[inheritIndex],
						       propTableNames[propIndex]);
			    table.put(key, elem);
			}
		    }
		}
	    }
	}

	// finally, add in any top-level names (not beginning with env.)
	for (int propIndex = propTables.length - 1; propIndex >= 0; propIndex--) {
	    Map propTable = propTables[propIndex];
	    for (Iterator i = propTable.keySet().iterator(); i.hasNext(); ) {
		String key = (String) (i.next());
		if (!key.startsWith("env.")) {
		    if (!table.containsKey(key)) {
			Element elem = new Element(key, 
						   (String)(propTable.get(key)),
						   null,
						   propTableNames[propIndex]);
			table.put(key, elem);
		    }
		}
	    }
	}
    }


    /**
     * Create a copy of the current environment.  
     * @return a copy of the current environment
     */
    public TestEnvironment copy() {
	return new TestEnvironment(this);
    }

    /** 
     * Get the distinguishing name for the properties of this environment.
     * @return	The name used to distinguish the properties of this environment
     */
    public String getName() {
	return name;
    }

    /**
     * Get the description of this environment, as given by the "description" entry.
     * @return the description of this environment, or null if not given
     */
    public String getDescription() {
	    if (table == null || ! table.containsKey("description")) {
            return null;
        }
        
        return ((Element)table.get("description")).getValue();
    }

    /** 
     * Get the list of names of inherited environments, including this environment,
     * in reverse  order or inheritance (ie this one, parent, grandparent etc).
     * @return an array containing the names of inherited environments
     */
    public String[] getInherits() {
	return inherits;
    }

    /**
     * A backdoor method to add global properties to the environment. The value is
     * not subject to any substitutions.
     * @param name	The name of the property to be written
     * @param value	The value of the property to be written
     */
    public void put(String name, String value) {
	// used to save values without subjecting them to any $ or # processing
	// Note further that the main props table is considered IMMUTABLE,
	// because it is shared amongst the clones.
	String[] v = {value};
	extras.put(name, v);
    }

    /**
     * A backdoor method to add global properties to the environment. The value is
     * not subject to any substitutions.
     * @param name	The name of the property to be written
     * @param value	The value of the property to be written
     */
    public void put(String name, String[] value) {
	// used to save values without subjecting them to any $ or # processing
	// Note further that the main props table is considered IMMUTABLE,
	// because it is shared amongst the clones.
	extras.put(name, value);
    }

    /**
     * A backdoor method to add global properties to the environment that have a
     * value that might be desired as both a file and a URL.  The URL form is
     * installed as a property with "URL" appended to the given property name.
     * The values are not subject to any substitutions.
     * URL result constructed using the following expression -
     * f.toURI().toASCIIString(); 
     * @param name      The name of the property to be written
     * @param f         The file indicating the value to be stored.
     */
    public void putUrlAndFile(String name, File f) {
	String filePath = f.getPath();

	if (filePath.endsWith(File.separator))
	    filePath = filePath.substring(0, filePath.length() - File.separator.length());

	String url = f.toURI().toASCIIString();

	put(name, filePath);

        // should upgrade to re-encode using UTF-8 perhaps
        put(name + "URL", url);
    }

    /**
     * Lookup a named property in the environment. 
     * @param key	The name of the property to look up
     * @return		The resolved value of the property
     * @throws	TestEnvironment.Fault is thrown if there is a problem resolving the value 
     *			of the property
     * @see #resolve
     */
    public String[] lookup(String key) throws Fault {
	return lookup(key, null);
    }

    private String[] lookup(String key, Vector activeKeys) throws Fault {
	String[] v = (String[])extras.get(key);
	if (v != null)
	    return v;


	Element elem = (Element)table.get(key);
	if (elem != null) {
	    cache.put(key, elem);
	    if (activeKeys == null)
		activeKeys = new Vector();
	    else if (activeKeys.contains(key)) 
		throw new Fault(i18n, "env.recursive",
				new Object[] {key, elem.getDefinedInFile()});

	    activeKeys.addElement(key);
	    try {
		return resolve(elem.getValue(), activeKeys);
	    }
	    catch (Fault e) {
		throw new Fault(i18n, "env.badName", 
				new Object[] {key, elem.getDefinedInFile(), e.getMessage()});
	    }
	    finally {
		activeKeys.removeElement(key);
	    }
	}
	
	return EMPTY_STRING_ARRAY;
    }

    /**
     * Resolve a value in the environment by splitting it into words and performing
     * various substitutions on it. White-space separates words except inside
     * quoted strings.
     * `<code>$<em>name</em></code>' and `<code>${<em>name</em>}</code>' are 
     * replaced by the result of calling `lookup(<em>name</em>)'. 
     * `<code>$/</code>' is replaced by the platform-specific file separator;
     * `<code>$:</code>' is replaced by the platform-specific path separator; and
     * `<code>$$</code>' is replaced by a single `$'.
     * No substitutions are performed inside single-quoted strings; $ substitutions
     * are performed in double-quoted strings.  
     * 
     * @param s	The string to be resolved
     * @return	An array of strings containing the words of the argument, after
     *		substitutions have been performed.
     * @throws TestEnvironment.Fault 
     *		This is thrown if there is a problem resolving the value 
     *		of the argument.
     */
    public String[] resolve(String s) throws Fault {
	return resolve(s, null);
    }

    private String[] resolve(String s, Vector activeKeys) throws Fault {
	Vector v = new Vector();
	StringBuffer current = new StringBuffer(64);
	char term = 0;

  loop:
	for (int i = 0; i < s.length(); i++) {
	    char c = s.charAt(i);
	    switch (c) {
	      case '#':
	        // # at top level introduces comment to end of line and terminates
	        //command (if found); otherwise, it goes into the current word
		if (term == 0 || term ==' ')
		    break loop;
		else
		    current.append(c);
		break;

	      case '\'':
	      case '\"':
		// string quotes at top level begin/end a matched pair; otherwise they
		// are part of it
		if (term == 0 || term == ' ') {
		    term = c;		// start matched pair
		} else if (term == c) 
		    term = ' ';		// end matched pair
		else 
		    current.append(c);	// put character in string
		break;

	      case '$':
		// dollar introduces a name to be substituted, provided it does not
		// appear in single quotes. Special values: $/ is File.separatorChar,
		// $: is File.pathSeparatorChar, and $$ is $
		if (term != '\'') {
		    StringBuffer buf = new StringBuffer();
		    String name = null;
		    String[] nameArgs = null;
		    try {
		        c = s.charAt(++i);
			switch (c) {
			case '/':
			    current.append(File.separatorChar);
			    continue loop;

			case ':':
			    current.append(File.pathSeparatorChar);
			    continue loop;

			case '$':
			    current.append('$');
			    continue loop;

			case '{':
			    c = s.charAt(++i);
			    while (c != ':' && c != '}') {
			  	buf.append(c);
				c = s.charAt(++i);
			    }
			    name=convertToName(resolve(buf.toString()));

			    // pick up optional nameArgs after embedded ':'
			    if (c == ':') {
				buf = new StringBuffer();
				c = s.charAt(++i);
				while (c != '}') {
				    buf.append(c);
				    c = s.charAt(++i);
				}
				nameArgs = StringArray.split(buf.toString());
			    }

			    break;

			default:
			    if (isNameChar(c)) {
				while (i < s.length() && isNameChar(s.charAt(i))) {
				    buf.append(s.charAt(i++));
				}
				i--;
			    } else
				throw new Fault(i18n, "env.badExprChar", new Character(c));
			    name = buf.toString();
			}

			String[] val = lookup(name, activeKeys);

			// apply nameArgs, if any
			if (nameArgs != null) {
			    for (int argi = 0; argi < nameArgs.length; argi++) {
				String arg = nameArgs[argi];
				if (arg.startsWith("FS=") && arg.length() == 4)
				    substituteChar(val, File.separatorChar, arg.charAt(3));
				else if (arg.startsWith("PS=") && arg.length() == 4)
				    substituteChar(val, File.pathSeparatorChar, arg.charAt(3));
				else if (arg.startsWith("MAP="))
				    substituteMap(val, lookup("map."+arg.substring(4), activeKeys));
				else if (arg.equals("MAP")) 
				    substituteMap(val, lookup("map", activeKeys));
				else
				    throw new Fault(i18n, "env.badOption", arg);
			    }
			}

			if (val != null && val.length > 0) {
			    // only start a new word if there is something to substitute
			    if (term == 0)
				term = ' ';
			    for (int vi = 0; vi < val.length; vi++) {
				if (vi == 0)
				    current.append(val[vi]);
				else if (term == '"') {
				    current.append(' ');
				    current.append(val[vi]);
				}
				else {
				    v.addElement(current.toString());
				    current.setLength(0);
				    current.append(val[vi]);
				}
			    }
			}
		    }
		    catch (IndexOutOfBoundsException e) {
		  	throw new Fault(i18n, "env.badExpr");
		    }
		} else
		    current.append(c);
		break;

	      case ' ':
	      case '\t':
		// space or tab are skipped if not in a word; if in a word and
		// term is space, they terminate it; otherwise they go into the
		// current word
		if (term != 0) {
		    if (term == ' ') {
		        v.addElement(current.toString());
			current.setLength(0);
			term = 0;
		    } else
			current.append(c);
		}
		break;


	      default:
		// other characters start a word if needed, then go into the word
		if (term == 0) 
		    term = ' ';
		current.append(c);
		break;
	    }
	}

	// we've reached the end; if a word has been started, finish it
	if (term != 0)
	   v.addElement(current.toString());

	String[] result = new String[v.size()];
	v.copyInto(result);
	return result;
    }

    /**
     * Check if the environment has any undefined values. These are entries containing
     * the text VALUE_NOT_DEFINED.
     * @return true if and only if there are any entries containing the text
     * VALUE_NOT_DEFINED.
     */
    public boolean hasUndefinedValues() {
	for (Iterator i = elements().iterator(); i.hasNext(); ) {
	    TestEnvironment.Element entry = (TestEnvironment.Element) (i.next());
	    if (entry.value.indexOf("VALUE_NOT_DEFINED") >= 0) 
		return true;
	}
	return false;
    }

    private void substituteChar(String[] v, char from, char to) {
	for (int i = 0; i < v.length; i++)
	    v[i] = v[i].replace(from, to);
    }

    private void substituteMap(String[] v, String[] map) {
	if (map == null)
	    return;
	
	// this algorithm is directly based on the "map" algorithm in
	// Slave.Map, which it supercedes
	for (int i = 0; i < v.length; i++) {
	    String word = v[i];
	    for (int j = 0; j+1 < map.length; j+=2) {
		String f = map[j];
		String t = map[j+1];
		for (int index = word.indexOf(f); 
		     index != -1; 
		     index = word.indexOf(f, index + t.length())) {
		    word = word.substring(0, index) + t + word.substring(index + f.length());
		}
	    }
	    v[i] = word;
	}
    }

    private String convertToName(String[] v) {
	String s = "";
	for (int i = 0; i < v.length; i++) {
	    if (i > 0)
		s += '_';
	    for (int j = 0; j < v[i].length(); j++) {
		char c = v[i].charAt(j);
		s += (isNameChar(c) ? c : '_');
	    }
	}
	return s;
    }

    /**
     * Identifies the characters recognized for $ names
     */
    private static boolean isNameChar(char c) {
	return (Character.isUpperCase(c) 
		|| Character.isLowerCase(c) 
		|| Character.isDigit(c)
		|| (c == '_') 
		|| (c == '.'));
    }

    /**
     * Enumerate the keys for this environment, including any inherited keys.
     * Use `lookup' to find the values of the individual keys.
     *
     * @return 	An enumeration that yields the various keys, explicit or inherited,
     * 		that are available in this environment. The keys do <em>not</em>
     *		include the `env.<em>environment-name</em>.' prefix of the corresponding
     *		property names.
     */
    public Set keys() {
	return table.keySet();
    }

    /**
     * Get a collection containing those entries in this environment that have been
     * referenced, either directly via lookup, or indirectly via the $ syntax in
     * other entries.
     * @return a collection of those entries in this environment that have been
     * referenced.
     * @see #resetElementsUsed
     */
    public Collection elementsUsed() {
	return cache.values();
    }

    /**
     * Reset the record of entries in this environment that have been referenced.
     * @see #elementsUsed
     */
    public void resetElementsUsed() {
	cache.clear();
    }

    /**
     * Enumerate the elements for this environment, including any inherited elements.
     *
     * @return 	An enumeration that yields the various elements, explicit or inherited,
     * 		that are available in this environment. 
     */
    public Collection elements() {
	return table.values();
    }


    protected TestEnvironment(TestEnvironment o) {
	name = o.name;
	inherits = o.inherits;
	table = o.table;
	extras = (HashMap)(o.extras.clone());
    }

    /**
     * A class representing an entry in a test environment.
     */
    public class Element {
	/**
	 * Create an entry for a test environment.
	 * @param key The name of the entry
	 * @param value The unresolved value of the entry
	 * @param definedInEnv The name of the environment that defines this entry
	 * @param definedInFile The name of the file (or table) that defines this entry
	 */
	Element(String key, String value, String definedInEnv, String definedInFile) {
	    this.key = key;
	    this.value = value;
	    this.definedInEnv = definedInEnv;
	    this.definedInFile = definedInFile;
	}

	/**
	 * Get the name of this entry.
	 * @return the name of this entry
	 */
	public String getKey() { return key; }

	/**
	 * Get the (unresolved) value of this entry.
	 * @return the (unresolved) value of this entry
	 */
	public String getValue() { return value; }

	/**
	 * Get the name of the environment that defines this entry.
	 * @return the name of the environment that defines this entry
	 */
	public String getDefinedInEnv() { return definedInEnv; }

	/**
	 * Get the name of the file (or table) that defines this entry.
	 * @return the name of the file (or table) that defines this entry
	 */
	public String getDefinedInFile() { return definedInFile; }

	String key;
	String value;
	String definedInEnv;
	String definedInFile;
    }
    
    private String name;
    private String[] inherits;
    private HashMap table = new HashMap();
    private HashMap extras = new HashMap();
    private HashMap cache = new HashMap();

    private static final String[] EMPTY_STRING_ARRAY = {};
    private static I18NResourceBundle i18n = I18NResourceBundle.getBundleForClass(TestEnvironment.class);
}
