/*
 * $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.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.io.Writer;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.Vector;

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

/**
 * A set of tests to be excluded from a test run.
 */

public class ExcludeList
{
    /**
     * This exception is used to report problems manipulating an exclude list.
     */
    public static class Fault extends Exception
    {
	Fault(I18NResourceBundle i18n, String s, Object o) {
	    super(i18n.getString(s, o));
	}
    }

    /**
     * Test if a file appears to be for an exclude list, by checking the extension.
     * @param f The file to be tested.
     * @return <code>true</code if the file appears to be an exclude list.
     */
    public static boolean isExcludeFile(File f) {
	return f.getPath().endsWith(EXCLUDEFILE_EXTN);
    }

    /**
     * Create a new exclude list.
     */
    public ExcludeList() {
    }

    /**
     * Create an ExcludeList from the data contained in a file.
     * @param f The file to be read.
     * @throws FileNotFoundException if the file cannot be found
     * @throws IOException if any problems occur while reading the file
     * @throws ExcludeList.Fault if the data in the file is ionconsistent
     * @see #ExcludeList(File[])
     */
    public ExcludeList(File f) 
	throws FileNotFoundException, IOException, Fault 
    {
	this(f, false);
    }

    /**
     * Create an ExcludeList from the data contained in a file.
     * @param f The file to be read.
     * @param strict Indicate if strict data checking rules should be used.
     * @throws FileNotFoundException if the file cannot be found
     * @throws IOException if any problems occur while reading the file
     * @throws ExcludeList.Fault if the data in the file is ionconsistent
     * @see #ExcludeList(File[])
     * @see #setStrictModeEnabled(boolean)
     */
    public ExcludeList(File f, boolean strict) 
	throws FileNotFoundException, IOException, Fault 
    {
	setStrictModeEnabled(strict);
	if (f != null) {
	    BufferedReader in = new BufferedReader(new FileReader(f));
	    Parser p = new Parser(in);
	    try {
		Entry e;
		while ((e = p.readEntry()) != null)
		    addEntry(e);
	    }
	    finally {
		in.close();
	    }
	    
	    title = p.getTitle();
	}
    }


    /**
     * Create an ExcludeList from the data contained in a series of files.
     * @param files The file to be read.
     * @throws FileNotFoundException if any of the files cannot be found
     * @throws IOException if any problems occur while reading the files.
     * @throws ExcludeList.Fault if the data in the files is ionconsistent
     * @see #ExcludeList(File)
     */
    public ExcludeList(File[] files) 
	throws FileNotFoundException, IOException, Fault 
    {
	this(files, false);
    }

    /**
     * Create an ExcludeList from the data contained in a series of files.
     * @param files The file to be read.
     * @param strict Indicate if strict data checking rules should be used.
     * @throws FileNotFoundException if any of the files cannot be found
     * @throws IOException if any problems occur while reading the files.
     * @throws ExcludeList.Fault if the data in the files is ionconsistent
     * @see #ExcludeList(File)
     * @see #setStrictModeEnabled(boolean)
     */
    public ExcludeList(File[] files, boolean strict) 
	throws FileNotFoundException, IOException, Fault 
    {
	setStrictModeEnabled(strict);
	for (int i = 0; i < files.length; i++) {
	    ExcludeList et = new ExcludeList(files[i], strict);
	    merge(et);
	}
    }

    /**
     * Specify whether strict mode is on or not. In strict mode, calls to addEntry
     * may generate an exception in the case of conflicts, such as adding an entry
     * to exclude a specifc test case when the entire test is already excluded.
     * @param on true if strict mode should be enabled, and false otherwise
     * @see #isStrictModeEnabled
     */
    public void setStrictModeEnabled(boolean on) {
	//System.err.println("EL.setStrictModeEnabled " + on);
	strict = on;
    }

    /**
     * Check whether strict mode is enabled or not. In strict mode, calls to addEntry
     * may generate an exception in the case of conflicts, such as adding an entry
     * to exclude a specifc test case when the entire test is already excluded.
     * @return true if strict mode is enabled, and false otherwise
     * @see #setStrictModeEnabled
     */
    public boolean isStrictModeEnabled() {
	return strict;
    }

    /**
     * Test if a specific test is completely excluded according to the table.
     * It is completely excluded if there is an entry, and the test case field is null.
     * @param td A test description for the test being checked.
     * @return <code>true</code> if the table contains an entry for this test.
     */
    public boolean excludesAllOf(TestDescription td) {
	return excludesAllOf(td.getRootRelativeURL());
    }

    /**
     * Test if a specific test is completely excluded according to the table.
     * It is completely excluded if there is an entry, and the test case field is null.
     * @param url The test-suite root-relative URL for the test.
     * @return <code>true</code> if the table contains an entry for this test.
     */
    public boolean excludesAllOf(String url) {
	Object o = table.get(new Key(url));
	return (o != null && o instanceof Entry && ((Entry)o).testCase == null);
    }

    /**
     * Test if a specific test is partially or completely excluded according to the table.
     * It is so excluded if there is any entry in the table for the test.
     * @param td A test description for the test being checked.
     * @return <code>true</code> if the table contains an entry for this test.
     */
    public boolean excludesAnyOf(TestDescription td) {
	return excludesAnyOf(td.getRootRelativeURL());
    }

    /**
     * Test if a specific test is partially or completely excluded according to the table.
     * It is so excluded if there is any entry in the table for the test.
     * @param url The test-suite root-relative URL for the test.
     * @return <code>true</code> if the table contains an entry for this test.
     */
    public boolean excludesAnyOf(String url) {
	Object o = table.get(new Key(url));
	return (o != null);
    }

    /**
     * Get the test cases to be excluded for a test.
     *
     * @param td A test description for the test being checked.
     * @return an array of test case names if any test cases are to
     * be excluded. The result is null if the test is not found or is
     * completely excluded without specifying test cases.  This may be
     * a mix of single TC strings or a comma separated list of them.
     */
    public String[] getTestCases(TestDescription td) {
	Key key = new Key(td.getRootRelativeURL());
	synchronized (table) {
	    Object o = table.get(key);
	    if (o == null)
		// not found
		return null;
	    else if (o instanceof Entry) {
		Entry e = (Entry)o;
		if (e.testCase == null)
		    // entire test excluded
		    return null;
		else
		    return (new String[] {e.testCase});
	    }
	    else {
		Entry[] ee = (Entry[])o;
		String[] testCases = new String[ee.length];
		for (int i = 0; i < ee.length; i++)
		    testCases[i] = ee[i].testCase;
		return testCases;
	    }
	}
    }

    /**
     * Add an entry to the table. 
     * @param e The entry to be added; if an entry already exists for this test
     *	description, it will be replaced.
     * @throws ExcludeList.Fault if the entry is for the entire test and 
     *   there is already an entry for a test case for this test, or vice versa.
     */
    public void addEntry(Entry e) throws Fault {
	synchronized (table) {
	    Key key = new Key(e.relativeURL);
	    Object o = table.get(key);
	    if (o == null) {
		// easy case: nothing already exists in the table, so just
		// add this one
		table.put(key, e);
	    }
	    else if (o instanceof Entry) {
		// a single entry exists in the table, so need to check for
		// invalid combinations of test cases and tests
		Entry curr = (Entry)o;
		if (curr.testCase == null) {
		    if (e.testCase == null) 
			// overwrite existing entry for entire test
		    	table.put(key, e);
		    else {
			if (strict) {
			    // can't exclude test case when entire test already excluded
			    throw new Fault(i18n, "excl.cantExcludeCase", e.relativeURL);
			}
			// else ignore new entry since entire test is already excluded
		    }
		}
		else {
		    if (e.testCase == null) {
			if (strict) {
			    // can't exclude entire test when test case already excluded
			    throw new Fault(i18n, "excl.cantExcludeTest", e.relativeURL);
			}
			else {
			    // overwrite existing entry for a test case with
			    // new entry for entire test
			    table.put(key, e);
			}
		    }
		    else if (curr.testCase.equals(e.testCase)) {
			// overwrite existing entry for the same test case
			table.put(key, e);
		    }
		    else {
			// already excluded one test case, now we need to exclude
			// another; make an array to hold both entries against the
			// one key
			table.put(key, new Entry[] {curr, e});
		    }
		}
	    }
	    else {
		// if there is an array, it must be for unique test cases
		if (e.testCase == null) {
		    if (strict) {
			// can't exclude entire test when selected test cases already excluded
			throw new Fault(i18n, "excl.cantExcludeTest", e.relativeURL);
		    }
		    else {
			// overwrite existing entry for list of test cases with
			// new entry for entire test
			table.put(key, e);
		    }
		}
		else {
		    Entry[] curr = (Entry[])o;
		    for (int i = 0; i < curr.length; i++) {
			if (curr[i].testCase.equals(e.testCase)) {
			    curr[i] = e;
			    return;
			}
		    }
		    // must be a new test case, add it into the array
		    table.put(key, DynamicArray.append(curr, e));
		}
	    }
	    
	}
    }

    /**
     * Locate an entry for a test.
     * @param url The root relative URL for the test; the URL may include
     * a test case if necessary included in square brackets after the URL proper.
     * @return The entry for the test, or null if there is none.
     */
    public Entry getEntry(String url) {
	String testCase = null;
	if (url.endsWith("]")) {
	    int i = url.lastIndexOf("[");
	    if (i != -1) {
		testCase = url.substring(i+1, url.length()-1);
		url = url.substring(0, i);
	    }
	}
	return getEntry(url, testCase);
    }

    /**
     * Locate an entry for a test.
     *
     * @param url The root relative URL for the test.
     * @param testCase An optional test case to be taken into account.  This cannot
     *  be a comma separated list.  A value of null will match any entry with the given
     *  url.
     * @return The entry for the test, or null if the URL cannot be found.
     */
    public Entry getEntry(String url, String testCase) {
	// XXX what if multiple entries?
	Key key = new Key(url);
	Object o = table.get(key);
	if (o == null)
	    return null;
	else if (o instanceof Entry) {
	    Entry e = (Entry)o;
	    if (testCase == null)
		return e;
	    else
		return (isInList(e.testCase, testCase) ? e : null);
	}
	else {
	    Entry[] entries = (Entry[])o;
	    for (int i = 0; i < entries.length; i++) {
		Entry e = entries[i];
		if (isInList(e.testCase, testCase))
		    return e;
	    }
	    return null;
	}
    }

    /**
     * Merge the contents of another exclude list into this one.
     * The individual entries are merged;  The title of the exclude list
     * being merged is ignored.
     * @param other the exclude list to be merged with this one.
     * 
     */
    public void merge(ExcludeList other) {
	synchronized (table) {
	    for (Iterator iter = other.getIterator(false); iter.hasNext(); ) {
		Entry otherEntry = (Entry) (iter.next());
		Key key = new Key(otherEntry.relativeURL);
		Object o = table.get(key);
		if (o == null) {
		    // Easy case: nothing already exists in the table, so just
		    // add this one
		    table.put(key, otherEntry);
		}
		else if (o instanceof Entry) {
		    // A single entry exists in the table
		    Entry curr = (Entry)o;
		    if (curr.testCase == null || otherEntry.testCase == null) {
			table.put(key, new Entry(curr.relativeURL, null, 
					    mergeBugIds(curr.bugIdStrings, otherEntry.bugIdStrings),
					    mergePlatforms(curr.platforms, otherEntry.platforms),
					    mergeSynopsis(curr.synopsis, otherEntry.synopsis)));
		    }
		    else 
			table.put(key, new Entry[] {curr, otherEntry});
		}
		else if (otherEntry.testCase == null) {
		    // An array of test cases exist in the table, but we're merging
		    // an entry for the complete test, so flatten down to a single entry
		    // for the whole test
		    String[] bugIdStrings = otherEntry.bugIdStrings;
		    String[] platforms = otherEntry.platforms;
		    String synopsis = otherEntry.synopsis;
		    Entry[] curr = (Entry[])o;
		    for (int i = 0; i < curr.length; i++) {
			bugIdStrings = mergeBugIds(bugIdStrings, curr[i].bugIdStrings);
			platforms = mergePlatforms(platforms, curr[i].platforms);
			synopsis = mergeSynopsis(synopsis, curr[i].synopsis);
		    }
		    table.put(key, new Entry(otherEntry.relativeURL, null,
					     bugIdStrings, platforms, synopsis));
		}
		else {
		    // An array of test cases exist in the table, and we're merging
		    // an entry with another set of test cases.
		    // For now, concatenate the arrays.
		    // RFE: Replace Entry[] with Set and merge the sets.
		    table.put(key, (Entry[]) DynamicArray.append((Entry[]) o, otherEntry));
		}
	    }
	}
    }

    private static String[] mergeBugIds(String[] a, String[] b) {
	return merge(a, b);
    }

    private static String[] mergePlatforms(String[] a, String[] b) {
	return merge(a, b);
    }

    private static String[] merge(String[] a, String[] b) {
	SortedSet s = new TreeSet();
	s.addAll(Arrays.asList(a));
	s.addAll(Arrays.asList(b));
	return (String[]) (s.toArray(new String[s.size()]));
    }

    private static String mergeSynopsis(String a, String b) {
	if (a == null || a.trim().length() == 0)
	    return b;
	else if (b == null || b.trim().length() == 0)
	    return a;
	else if (a.indexOf(b) != -1)
	    return a;
	else if (b.indexOf(a) != -1)
	    return b;
	else
	    return a + "; " + b;
    }


    /**
     * Remove an entry from the table.  
     * @param e the entry to be removed
     */
    public void removeEntry(Entry e) {
	synchronized (table) {
	    Key key = new Key(e.relativeURL);
	    Object o = table.get(key);
	    if (o == null)
		// no such entry
		return;
	    else if (o instanceof Entry) {
		if (o == e)
		    table.remove(key);
	    }
	    else {
		Entry[] o2 = (Entry[])DynamicArray.remove((Entry[])o, e);
		if (o2 == o)
		    // not found
		    return;
		else {
		    if (o2.length == 1)
			table.put(key, o2[0]);
		    else
			table.put(key, o2);
		}
	    }
	}
    }

    /**
     * Check whether an exclude list has any entries or not.
     * @return true if this exclude list has no entries
     * @see #size
     */
    public boolean isEmpty() {
	return table.isEmpty();
    }

    /**
     * Get the number of entries in the table.
     * @return the number of entries in the table
     * @see #isEmpty
     */
    public int size() {
	// ouch, this is now expensive to compute
	int n = 0;
	for (Iterator i = table.values().iterator(); i.hasNext(); ) {
	    Object o = i.next();
	    if (o instanceof Entry[])
		n += ((Entry[])o).length;
	    else
		n++;
	}
	return n;
    }

    /**
     * Iterate over the contents of the table.
     * @param group if <code>true</code>, entries for the same relative
     * URL are grouped together, and if more than one, returned in an
     * array; if <code>false</code>, the iterator always returns
     * separate entries.
     * @see Entry
     * @return an iterator for the table: the entries are either
     * single instances of @link(Entry) or a mixture of @link(Entry)
     * and @link(Entry)[], depending on the <code>group</code>
     * parameter.
     */
    public Iterator getIterator(boolean group) {
	if (group)
	    return table.values().iterator();
	else {
	    // flatten the enumeration into a vector, then
	    // enumerate that
	    Vector v = new Vector(table.size());
	    for (Iterator iter = table.values().iterator(); iter.hasNext(); ) {
		Object o = iter.next();
		if (o instanceof Entry) 
		    v.addElement(o);
		else {
		    Entry[] entries = (Entry[])o;
		    for (int i = 0; i < entries.length; i++)
			v.addElement(entries[i]);
		}
	    }
	    return v.iterator();
	}

    }

    /**
     * Get the title for this exclude list.
     * @return the title for this exclude list
     * @see #setTitle
     */
    public String getTitle() {
	return title;
    }

    /**
     * Set the title for this exclude list.
     * @param title the title for this exclude list
     * @see #getTitle
     */
    public void setTitle(String title) {
	this.title = title;
    }

    /**
     * Write the table out to a file.
     * @param f The file to which the table should be written.
     * @throws IOException is thrown if any problems occur while the
     * file is being written.
     */
    public void write(File f) throws IOException {
	// sort the entries for convenience, and measure col widths
	int maxURLWidth = 0;
	int maxBugIdWidth = 0;
	int maxPlatformWidth = 0;
	SortedSet entries = new TreeSet();
	for (Iterator iter = getIterator(false); iter.hasNext(); ) {
	    Entry entry = (Entry) (iter.next());
	    entries.add(entry);
	    if (entry.testCase == null)
		maxURLWidth = Math.max(entry.relativeURL.length(), maxURLWidth);
	    else
		maxURLWidth = Math.max(entry.relativeURL.length() + entry.testCase.length() + 2, maxURLWidth);
	    maxBugIdWidth = Math.max(bugIdsToString(entry).length(), maxBugIdWidth);
	    maxPlatformWidth = Math.max(platformsToString(entry).length(), maxPlatformWidth);
	}

	BufferedWriter out = new BufferedWriter(new FileWriter(f));
	out.write("# Exclude List");
	out.newLine();
	out.write("# SCCS %" + 'W' + "% %" + 'E' + "%"); // TAKE CARE WITH SCCS HEADERS
	out.newLine();
	if (title != null) {
	    out.write("### title " + title);
	    out.newLine();
	}
	for (Iterator iter = entries.iterator(); iter.hasNext(); ) {
	    Entry e = (Entry) (iter.next());
	    if (e.testCase == null)
		write(out, e.relativeURL, maxURLWidth + 2);
	    else
		write(out, e.relativeURL + "[" + e.testCase + "]", maxURLWidth + 2);
	    write(out, bugIdsToString(e), maxBugIdWidth + 2);
	    write(out, platformsToString(e), maxPlatformWidth + 2);
	    out.write(e.synopsis);
	    out.newLine();
	}
	out.close();
    }

    private void write(Writer out, String s, int width) throws IOException {
	out.write(s);
	for (int i = s.length(); i < width; i++)
	    out.write(' ');
    }

    private String bugIdsToString(Entry e) {
	StringBuffer sb = new StringBuffer(e.bugIdStrings.length*10);
	sb.append(e.bugIdStrings[0]);
	for (int i = 1; i < e.bugIdStrings.length; i++) {
	    sb.append(',');
	    sb.append(e.bugIdStrings[i]);
	}
	return sb.toString();
    }

    private String platformsToString(Entry e) {
	StringBuffer sb = new StringBuffer(e.platforms.length*10);
	sb.append(e.platforms[0]);
	for (int i = 1; i < e.platforms.length; i++) {
	    sb.append(',');
	    sb.append(e.platforms[i]);
	}
	return sb.toString();
    }

    private static boolean equals(String s1, String s2) {
	return (s1 == null && s2 == null
		|| s1 != null && s2 != null && s1.equals(s2));
    }

    /**
     * Is val in the comma separated list.
     *
     * @param list Comma separated list or a single value.
     * @return Null if either parameter is null.
     */
    private static boolean isInList(String list, String val) {
	// check for invalid args  
	if (list == null || val == null)                   
	    return false;
	
	// loop through possible matches
	for (int pos = list.indexOf(val); pos != -1; pos = list.indexOf(val, pos + 1) ) {
	    // check beginning of string
	    if (!(pos == 0 || list.charAt(pos -1) == ','))
		continue;
	
	    // check end of string
	    if (!(pos + val.length() == list.length()  || list.charAt(pos + val.length()) == ','))
		continue;
	
	    // beginning and end are OK; got a match
	    return true;
	}
	
	// no good matches found  
	return false; 
    }

    private Map table = new HashMap();
    private String title;
    private boolean strict;

    private static final class Parser {
	Parser(Reader in) throws IOException {
	    this.in = in;
	    ch = in.read();
	}

	String getTitle() {
	    return title;
	}

	Entry readEntry() throws IOException, Fault {
	    String url = readURL(); // includes optional test case
	    if (url == null)
		return null;
	    String testCase = null; // for now
	    if (url.endsWith("]")) {
		int i = url.lastIndexOf("[");
		if (i != -1) {
		    testCase = url.substring(i+1, url.length()-1);
		    url = url.substring(0, i);
		}
	    }
	    String[] bugIdStrings = readBugIds();
	    String[] platforms = readPlatforms();
	    String synopsis = readRest();
	    return new Entry(url, testCase, bugIdStrings, platforms, synopsis);
	}

	private boolean isEndOfLine(int ch) {
	    return (ch == -1 || ch == '\n' || ch == '\r');
	}

	private boolean isWhitespace(int ch) {
	    return (ch == ' ' || ch == '\t');
	}

	private String readURL() throws IOException, Fault {
	    // skip white space, comments and blank lines until a word is found
	    for (;;) {
		skipWhite();
		switch (ch) {
		case -1:
		    // end of file
		    return null;
		case '#':
		    // comment
		    skipComment();
		    break;
		case '\r':
		case '\n':
		    // blank line (or end of comment)
		    ch = in.read();
		    break;
		default:
		    return readWord();
		}
	    }
	}

	private String[] readBugIds() throws IOException {
	    // skip white space, then read and sort a list of comma-separated
	    // numbers with no embedded white-space
	    skipWhite();
	    TreeSet s = new TreeSet();
	    StringBuffer sb = new StringBuffer();
	    for ( ; !isEndOfLine(ch) && !isWhitespace(ch); ch = in.read()) {
		if (ch == ',') {
		    if (sb.length() > 0) {
			s.add(sb.toString());
			sb.setLength(0);
		    }
		}
		else 
		    sb.append((char) ch);
	    }

	    if (sb.length() > 0) 
	        s.add(sb.toString());

	    if (s.size() == 0)
	        s.add("0");  // backwards compatibility

	    return (String[]) (s.toArray(new String[s.size()]));
	}

	private String[] readPlatforms() throws IOException {
	    // skip white space, then read and sort a list of comma-separated
	    // platform names with no embedded white space. Since the
	    // set of platforms (and their combinations) is small,
	    // share the result amongst all equivalent entries.
	    skipWhite();
	    String s = readWord();
	    String[] platforms = (String[])platformCache.get(s);
	    if (platforms == null) {
		// split string into sorted comma separated pieces
		int n = 0;
		for (int i = 0; i < s.length(); i++) {
		    if (s.charAt(i) == ',')
			n++;
		}
		TreeSet ts = new TreeSet();
		int start = 0;
		int end = s.indexOf(',');
		while (end != -1) {
		    ts.add(s.substring(start, end));
		    start = end + 1;
		    end = s.indexOf(',', start);
		}
		ts.add(s.substring(start));
		platforms = (String[]) (ts.toArray(new String[ts.size()]));
		platformCache.put(s, platforms);
	    }
	    return platforms;
	}

	private String readRest() throws IOException {
	    // skip white space, then read up to the end of the line
	    skipWhite();
	    StringBuffer word = new StringBuffer(80);
	    for ( ; !isEndOfLine(ch); ch = in.read())
		word.append((char)ch);
	    // skip over terminating character
	    ch = in.read();
	    return word.toString();
	}

	private String readWord() throws IOException {
	    // read characters up to the next white space
	    StringBuffer word = new StringBuffer(32);
	    for ( ; !isEndOfLine(ch) && !isWhitespace(ch); ch = in.read())
		word.append((char)ch);
	    return word.toString();
	}

	private void skipComment() throws IOException, Fault {
	    ch = in.read();
	    // first # has already been read
	    if (ch == '#') {
		ch = in.read();
		if (ch == '#') {
		    ch = in.read();
		    skipWhite();
		    String s = readWord();
		    if (s.equals("title")) {
			skipWhite();
			title = readRest();
			return;
		    }
		}
	    }
	    while (!isEndOfLine(ch))
		ch = in.read();
	}

	private void skipWhite() throws IOException {
	    // skip horizontal white space
	    // input is line-oriented, so do not skip over end of line
	    while (ch != -1 && isWhitespace(ch))
		ch = in.read();
	}

	private Reader in;	// source stream being read
	private int ch;		// current character
	private Map platformCache = new HashMap();
				// cache of results for readPlatforms
	private String title;
    };

    private static class Key {
	Key(String url) {
	    relativeURL = url;
	}

	public int hashCode() {
	    // the hashCode for a key is the hashcode of the normalized URL.
	    // The normalized URL is url.replace(File.separatorChar, '/').toLowerCase();
	    int h = hash;
	    if (h == 0) {
		int len = relativeURL.length();
		
		for (int i = 0; i < len; i++) {
		    char c = Character.toLowerCase(relativeURL.charAt(i));
		    if (c == sep)
			c = '/';
		    h = 31*h + c;
		}
		hash = h;
	    }
	    return h;
	}

	public boolean equals(Object o) {
	    // Two keys are equal if their normalized URLs are equal.
	    // The normalized URL is url.replace(File.separatorChar, '/').toLowerCase();
	    if (o == null || !(o instanceof Key))
		return false;
	    String u1 = relativeURL;
	    String u2 = ((Key) o).relativeURL;
	    int len = u1.length();
	    if (len != u2.length())
		return false;
	    for (int i = 0; i < len; i++) {
		char c1 = Character.toLowerCase(u1.charAt(i));
		if (c1 == sep)
		    c1 = '/';
		char c2 = Character.toLowerCase(u2.charAt(i));
		if (c2 == sep)
		    c2 = '/';
		if (c1 != c2)
		    return false;
	    }
	    return true;
	}

	private static final char sep = File.separatorChar;
	private String relativeURL;
	private int hash;
    }

    /**
     * An entry in the exclude list.
     */
    public static final class Entry implements Comparable {
	/**
	 * Create an ExcludeList entry.
	 * @param u The URL for the test, specified relative to the test suite root.
	 * @param tc One or more test cases within the test to be excluded.
	 * @param b An array of bug identifiers, justifying why the test is excluded.
	 * @param p An array of platform identifiers, on which the faults are
	 * 		known to occur
	 * @param s A short synopsis of the reasons why the test is excluded.
	 */
	public Entry(String u, String tc, String[] b, String[] p, String s) {
	    if (b == null || p == null)
		throw new NullPointerException();

	    // The file format cannot support platforms but no bugids, 
	    // so fault that; other combinations (bugs, no platforms;
	    // no bugs, no platforms etc) are acceptable.
	    if (b.length == 0 &&  p.length > 0)
		throw new IllegalArgumentException();

	    relativeURL = u;
	    testCase = tc;
	    bugIdStrings = b;
	    platforms = p;
	    synopsis = s;
	}
	/**
	 * Create an ExcludeList entry.
	 * @param u The URL for the test, specified relative to the test suite root.
	 * @param tc One or more test cases within the test to be excluded.
	 * @param b An array of bug numbers, justifying why the test is excluded.
	 * @param p An array of platform identifiers, on which the faults are
	 * 		known to occur
	 * @param s A short synopsis of the reasons why the test is excluded.
	 */
	public Entry(String u, String tc, int[] b, String[] p, String s) {
	    if (b == null || p == null)
		throw new NullPointerException();

	    // The file format cannot support platforms but no bugids, 
	    // so fault that; other combinations (bugs, no platforms;
	    // no bugs, no platforms etc) are acceptable.
	    if (b.length == 0 &&  p.length > 0)
		throw new IllegalArgumentException();

	    relativeURL = u;
	    testCase = tc;

	    bugIdStrings = new String[b.length];
	    for (int i = 0; i < b.length; i++)
		bugIdStrings[i] = String.valueOf(b[i]);
	    bugIds = b;

	    platforms = p;
	    synopsis = s;
	}

	public int compareTo(Object o) {
	    Entry e = (Entry) o;
	    int n = relativeURL.compareTo(e.relativeURL);
	    if (n == 0) {
		if (testCase == null && e.testCase == null)
		    return 0;
		else if (testCase == null)
		    return -1;
		else if (e.testCase == null)
		    return +1;
		else 
		    return testCase.compareTo(e.testCase);
	    }
	    else
		return n;
	}

	/**
	 * Get the relative URL identifying the test referenced by this entry.
	 * @return the relative URL identifying the test referenced by this entry
	 */
	public String getRelativeURL() {
	    return relativeURL;
	}

	/**
	 * Get the (possibly empty) list of test cases for this entry.
	 * An entry can have zero, one, or a comma separated list of TCs.
	 *
	 * @return List, or null if there are no test cases.
	 */
	public String getTestCases() {
	    return testCase;
	}

	/** 
	 * Get the same data as getTestCases(), but split into many Strings
	 * This method is costly, so use with care.
	 *
	 * @return The parsed comma list, or null if there are no test cases.
	 */
	public String[] getTestCaseList() {
	    // code borrowed from StringArray
	    // it is a little wasteful to recalc everytime but saves space
	    if (testCase == null)
		return null;

	    Vector v = new Vector();
	    int start = -1;
	    for (int i = 0; i < testCase.length(); i++) {
		if (testCase.charAt(i) == ',') {
		    if (start != -1)
			v.addElement(testCase.substring(start, i));
		    start = -1;
		} else
		    if (start == -1)
			start = i;
	    }
	    if (start != -1)
		v.addElement(testCase.substring(start));

	    if (v.size() == 0)
		return null;

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

	/**
	 * Get the set of bug IDs referenced by this entry.
	 * @return the bugs referenced by the entry
	 */
	public int[] getBugIds() {
	    if (bugIds == null) {
		bugIds = new int[bugIdStrings.length];
		for (int i = 0; i < bugIds.length; i++) {
		    try {
			bugIds[i] = Integer.parseInt(bugIdStrings[i]);
		    }
		    catch (NumberFormatException e) {
			bugIds[i] = -1;
		    }
		}
	    }

	    return bugIds;
	}

	/**
	 * Get the set of bug IDs referenced by this entry.
	 * @return the bugs referenced by the entry
	 */
	public String[] getBugIdStrings() {
	    return bugIdStrings;
	}

	/**
	 * Get the set of platforms or keywords associated with this entry.
	 * These should normally give details about why the test has been
	 * excluded.
	 * @return the set of platforms or keywords associated with this entry
	 */
	public String[] getPlatforms() {
	    return platforms;
	}

	/**
	 * Get a short description associated with this entry.
	 * This should normally give details about why the test has been
	 * excluded.
	 * @return a short description associated with this entry
	 */
	public String getSynopsis() {
	    return synopsis;
	}

	/**
	 * Create an entry from a string. The string should be formatted
	 * as though it were a line of text in an exclude file.
	 * @param text The text to be read 
	 * @return the first entry read from the supplied text
	 * @throws ExcludeList.Fault if there is a problem reading the entry.
	 */
	public static Entry read(String text) throws Fault {
	    try {
		return new Parser(new StringReader(text)).readEntry();
	    }
	    catch (IOException e) {
		throw new Fault(i18n, "excl.badEntry", e);
	    }
	}

	/**
	 * Compare this entry against another.
	 * @param o the object to compare against
	 * @return true is the objects are bothe ExcludeList.Entries containing
	 *    the same details
	 */
	public boolean equals(Object o) {
	    if (o instanceof Entry) {
		Entry e = (Entry)o;
		return equals(relativeURL, e.relativeURL)
		    && equals(testCase, e.testCase)
		    && equals(bugIdStrings, e.bugIdStrings)
		    && equals(platforms, e.platforms)
		    && equals(synopsis, e.synopsis);
	    }
	    else
		return false;
	}

	public int hashCode() {
	    return relativeURL.hashCode();
	}

	public String toString() {
	    StringBuffer sb = new StringBuffer(64);
	    sb.append(relativeURL);
	    if (testCase != null) {
		sb.append('[');
		sb.append(testCase);
		sb.append(']');
	    }
	    if (bugIdStrings != null) {
		for (int i = 0; i<bugIdStrings.length; i++) {
		    sb.append(i == 0 ? ' ' : ',');
		    sb.append(bugIdStrings[i]);
		}
	    }
	    if (platforms != null) {
		for (int i = 0; i<platforms.length; i++) {
		    sb.append(i == 0 ? ' ' : ',');
		    sb.append(platforms[i]);
		}
	    }
	    if (synopsis != null) {
		sb.append(' ');
		sb.append(synopsis);
	    }
	    return new String(sb);
	}

	private static boolean equals(int[] i1, int[] i2) {
	    if (i1 == null || i2 == null)
		return (i1 == null && i2 == null);

	    if (i1.length != i2.length)
		return false;

	    for (int x = 0; x < i1.length; x++)
		if (i1[x] != i2[x])
		    return false;

	    return true;
	}

	private static boolean equals(String[] s1, String[] s2) {
	    if (s1 == null || s2 == null)
		return (s1 == null && s2 == null);

	    if (s1.length != s2.length)
		return false;

	    for (int x = 0; x < s1.length; x++) {
		if (!equals(s1[x], s2[x]))
		    return false;
	    }

	    return true;
	}

	private static boolean equals(String s1, String s2) {
	    return (s1 == null && s2 == null
		    || s1 != null && s2 != null && s1.equals(s2));
	}


	private String relativeURL;
	private String testCase;
	private String[] bugIdStrings;
	private int[] bugIds; // null, unless required
	private String[] platforms;
	private String synopsis;
    }

    private static I18NResourceBundle i18n = I18NResourceBundle.getBundleForClass(ExcludeList.class);

    /**
     * The standard extension for exclude-list files. (".jtx")
     */
    public static final String EXCLUDEFILE_EXTN = ".jtx";

}
