/*
 * $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.interview;

import java.io.File;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.text.MessageFormat;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.MissingResourceException;
import java.util.Properties;
import java.util.ResourceBundle;
import java.util.Set;
import java.util.Vector;


//import com.sun.javatest.util.DirectoryClassLoader;

/**
 * The base class for an interview: a series of {@link Question questions}, to be
 * presented to the user via some tool such as an assistant or wizard. 
 * Interviews may be stand-alone, or designed to be part of other interviews.
 */
public class Interview 
{
    //----- inner classes ----------------------------------------

    /**
     * This exception is to report problems that occur while updating an interview.
     */
    public static class Fault extends Exception
    {
	/**
	 * Create a Fault.
	 * @param i18n A resource bundle in which to find the detail message.
	 * @param s The key for the detail message.
	 */
	public Fault(ResourceBundle i18n, String s) {
	    super(i18n.getString(s));
	}

	/**
	 * Create a Fault.
	 * @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}
	 */
	public Fault(ResourceBundle i18n, String s, Object o) {
	    super(MessageFormat.format(i18n.getString(s), new Object[] {o}));
	}

	/**
	 * Create a Fault.
	 * @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}
	 */
	public Fault(ResourceBundle i18n, String s, Object[] o) {
	    super(MessageFormat.format(i18n.getString(s), o));
	}
    }

    /**
     * This exception is thrown when a question is expected to be on
     * the current path, and is not.
     */
    public static class NotOnPathFault extends Fault
    {
	NotOnPathFault(Question q) {
	    super(i18n, "interview.questionNotOnPath", q.getTag());
	}
    }

    /**
     * An observer interface for receiving notifications as the state of
     * the interview is updated.
     */
    public static interface Observer {
	/**
	 * Invoked when the current question in the interview has been changed.
	 * @param q the new current question
	 */
	void currentQuestionChanged(Question q);
	
	/**
	 * Invoked when the set of questions in the current path has been
	 * changed. This is normally because the response to one of the
	 * questions on the path has been changed, thereby causing a change
	 * to its successor questions.
	 */
	void pathUpdated();
    }

    //----- constructors ----------------------------------------

    /**
     * Create a top-level interview.
     * @param tag A tag that will be used to qualify the tags of any
     * questions in this interview, to help ensure uniqueness of those
     * tags.
     */
    protected Interview(String tag) {
	this(null, tag);
    }

    /**
     * Create an interview to be used as part of another interview.
     * @param parent The parent interview of which this is a part.
     * @param baseTag A name that will be used to qualify the tags of any
     * questions in this interview, to help ensure uniqueness of those
     * tags. It will be combined with the parent's tag if that has been
     * specified.
     */
    protected Interview(Interview parent, String baseTag) {
	this.parent = parent;
	setBaseTag(baseTag);

	if (parent == null)
	    root = this;
	else {
	    parent.add(this);
	    root = parent.root;
	    semantics = parent.getInterviewSemantics();
	}
    }

    //----- basic facilities ----------------------------------------

    /**
     * Get the parent interview for which this is a child.
     * @return the parent interview, or null if no parent has been specified.
     */
    public Interview getParent() {
	return parent;
    }

    /**
     * Get a tag used to qualify the tags of questions in this interview.
     * @return the title
     */
    public String getTag() {
	return tag;
    }

    /**
     * Set a descriptive title to be used to annotate this interview.
     * @param title A short descriptive title.
     * @see #getTitle
     */
    protected void setTitle(String title) {
	this.title = title;
    }

    /**
     * Get a descriptive title associated with this interview.
     * If not specified, the system will try and locate the title in the 
     * interview's resource bundle, using the resource name <code>title</code>.
     * of the interview.
     * @return the title
     * @see #setTitle
     */
    public String getTitle() {
	if (title == null) {
	    // Need to dance a bit here to avoid "title" being picked up 
	    // by the i18n validation scripts as a necessary key in i18n 
	    // instead of bundle.  Another solution would be to make 
	    // getI18NString static and pass the resource bundle in as the
	    // first arg.
	    String titleKey = "title";
	    title = getI18NString(titleKey).trim();
	}

	return title;
    }

    /**
     * Set a default image to be used for the questions of an interview.
     * @param u A URL for the image
     * @see Question#setImage
     * @see Question#getImage
     * @see #getDefaultImage
     */
    protected void setDefaultImage(URL u) {
	defaultImage = u;
    }
    
    /** 
     * Get a default image to be used for the questions of an interview.
     * If no default has been set for this interview, the parent's 
     * default image (if any) is used instead.
     * @return a URL for the default image to be used
     * @see #setDefaultImage
     */
    public URL getDefaultImage() {
	if (defaultImage == null && parent != null)
	    return parent.getDefaultImage();
	
	return defaultImage;
    }

    /**
     * Set the base name of the resource bundle used to look up 
     * internationized strings, such as the title and text of each
     * question.  If the name starts with '/', it will be treated 
     * as an absolute resource name, and used "as is"; 
     * otherwise it will be treated as relative to the
     * package in which the actual interview class is defined.
     * The default is the interview tag name if this is a root 
     * interview. If this is a child interview, there is no default
     * resource bundle.
     * @param name The name of the resource bundle used to look
     * up internationalized strings.
     * @throws MissingResourceException if the resource bundle 
     * cannot be found.
     * @see #getResourceBundle
     */
    protected void setResourceBundle(String name) 
	throws MissingResourceException 
    {
	if (name != bundleName) {
	    if (name != null) {
		Class c = getClass();
		ClassLoader cl = c.getClassLoader();
		String rn;
		if (name.startsWith("/"))
		    rn = name.substring(1);
		else {
		    String cn = c.getName();
		    String pn = cn.substring(0, cn.lastIndexOf('.'));
		    rn = pn + "." + name;
		}
		//System.err.println("INT: looking for bundle: " + rn);
		bundle = ResourceBundle.getBundle(rn, Locale.getDefault(), cl);
	    }
	    bundleName = name;
	}
    }
    /**
     * Set the base name of the resource bundle used to look up 
     * internationized strings, such as the title and text of each
     * question. If the name is treated as filename of file
     * which is located in directory file.
     * The default is the interview tag name if this is a root 
     * interview. If this is a child interview, there is no default
     * resource bundle.
     * @param name The name of the resource bundle used to look
     * up internationalized strings.
     * @param file The directory to find name.
     * @throws MissingResourceException if the resource bundle 
     * cannot be found.
     * @see #getResourceBundle
     */
    protected void setResourceBundle(String name, File file) 
    throws MissingResourceException {
    	if (name == bundleName) {
    		return;
    	}
    	try {
    		URL[] url = {new URL("file:" + file.getAbsolutePath() + "/")};
    		URLClassLoader cl = new URLClassLoader(url);    		
    		bundle = ResourceBundle.getBundle(name, Locale.getDefault(),cl);
    	} catch (MalformedURLException e) {
					
		}
		
	}

    /**
     * Get the resource bundle for this interview, used to look up
     * internationalized strings, such as the title and text of each question.
     * If the bundle has not been set explicitly, it defaults to the
     * parent's resource bundle; the root interview has a default resource
     * bundle based on the interview tag name.
     * @return the resource bundle for this interview.
     * @see #setResourceBundle
     */
    public ResourceBundle getResourceBundle() {
	if (bundle == null && parent != null)
	    return parent.getResourceBundle();
	else  
	    return bundle;
    } 

    /**
     * Mark this interview as having been edited or not.
     * @param edited whether or not this interview is marked as edited
     */
    public void setEdited(boolean edited) {
	Interview i = this;
	while (i.parent != null)
	    i = i.parent;
	i.edited = edited;
    }


    /**
     * Determine if this interview as having been edited or not.
     * @return true if this interview is marked as having been edited
     */
    public boolean isEdited() {
	Interview i = this;
	while (i.parent != null)
	    i = i.parent;
	return i.edited;
    }

    /**
     * Get the first question of the interview.
     * @return the first question of the interview
     * @see #setFirstQuestion
     */
    public Question getFirstQuestion() {
	return firstQuestion;
    }
	
    /**
     * Set the first question for an interview. This may be called more
     * than once, but only while the interview is being constructed.
     * Once any method has been called that refers to the interview
     * path, the initial question may not be changed.
     * @param q The initial question
     * @throws IllegalStateException if it is too late to change the
     * initial question.
     * @see #getFirstQuestion
     */
    protected void setFirstQuestion(Question q) {
	if (path != null)
	    throw new IllegalStateException();

	firstQuestion = q;

	// OLD: the problem with this is that reset() calls updatePath()
	// which might call methods which refer to uninitialize data,
	// so can't safely call reset() here
	//
	// if (parent == null)
	//    reset();

        // if we wanted to permit the first question to be changed,
	// consider the following:
	// if (parent == null)
	//    path = null;
    }

    //---------------------------------------------------------

    /**
     * Get a sub-interview with a given tag name. All descendents are 
     * searched (i.e. all children, all their children, etc.)
     * @param tag The tag of the interview to be found.
     * @return the sub-interview with the specified name.
     * @throws Interview.Fault if no interview is found with the given name.
     */
    public Interview getInterview(String tag) throws Fault {
	if (tag == null)
	    throw new NullPointerException();

	Interview i = getInterview0(tag);
	if (i != null)
	    return i;
	else
	    throw new Fault(i18n, "interview.cantFindInterview", tag);
    }

    private Interview getInterview0(String t) {
	if (t.equals(tag))
	    return this;
		
	for (int i = 0; i < children.size(); i++) {
	    Interview c = (Interview) (children.elementAt(i));
	    Interview iv = c.getInterview0(t);
	    if (iv != null)
		return iv;
	}

	return null;
    }

    private Set getInterviews() {
	Set s = new HashSet();
	getInterviews0(s);
	return s;
    }

    private void getInterviews0(Set s) {
	s.add(this);
	for (int i = 0; i < children.size(); i++) {
	    Interview child = (Interview) (children.elementAt(i));
	    child.getInterviews0(s);
	}
    }

    //----- navigation ----------------------------------------
    
    /**
     * Determine if a question is the first question of the interview.
     * @param q the question to check
     * @return true if this is the first question.
     */
    public boolean isFirst(Question q) {
	return (q == firstQuestion);
    }

    /**
     * Determine if a question is the last question of the interview.
     * @param q the question to check
     * @return true if this is the last question.
     */
    public boolean isLast(Question q) {
	return (q instanceof FinalQuestion && q.interview.caller == null);
    }

    /**
     * Determine if a question has a non-null successor.
     * @param q the question to check
     * @return true if this question has a non-null successor.
     */
    public boolean hasNext(Question q) {
	return (q.getNext() != null);
    }


    /**
     * Determine if a question has a successor which is neither null 
     * nor an ErrorQuestion.
     * @param q the question to check
     * @return true if this question has a successor which is neither null 
     * nor an ErrorQuestion
     */
    public boolean hasValidNext(Question q) {
	Question qn = q.getNext();
	return (qn != null && !(qn instanceof ErrorQuestion));
    }

    /**
     * Start (or restart) the interview. The current question is reset to the first
     * question, and the current path is evaluated from there.
     */
    public void reset() {
	ensurePathInitialized();

	// first, reset back to the beginning
	updateEnabled = true;
	caller = null;
	currIndex = 0;
	path.clear();
	path.addQuestion(firstQuestion);
	updatePath(firstQuestion);
	// notify observers
	notifyCurrentQuestionChanged(firstQuestion);
    }

    /**
     * Start (or restart) the interview. The current question is reset to the first
     * question, and the current path is evaluated from there.
     */
    private void reset(Question q) {
	ensurePathInitialized();

	// first, reset back to the beginning
	updateEnabled = true;
	caller = null;
	currIndex = 0;
	path.clear();
	path.addQuestion(firstQuestion);
	updatePath(firstQuestion);

	// now update to the selected question
	if (q == firstQuestion || q == null)
	    // already there; just need to notify observers
	    notifyCurrentQuestionChanged(firstQuestion);
	else {
	    // try and select the specified question
	    try {
		setCurrentQuestion(q);
	    }
	    catch (Fault e) {
		notifyCurrentQuestionChanged(firstQuestion);
	    }
	}
    }

    /**
     * Advance to the next question in the interview.
     * Questions that have been {@link Question#isEnabled disabled} will
     * be skipped over.
     * @throws Interview.Fault if there are no more questions
     */
    public void next() throws Fault {
	ensurePathInitialized();

	Interview i = this;

	// first, step in until we get to the current question
	while (i.path.questionAt(i.currIndex) instanceof InterviewQuestion) {
	    InterviewQuestion iq = (InterviewQuestion) (i.path.questionAt(i.currIndex));
	    i = iq.getTargetInterview();
	}

	// next, step forward to the next question
	i.currIndex++;

	// finally, normalize the result
	while (true) {
	    if (i.currIndex == i.path.size()) {
		i.currIndex--;
		throw new Fault(i18n, "interview.noMoreQuestions");
	    }

	    Question q = i.path.questionAt(i.currIndex);
	    if (q instanceof InterviewQuestion) {
		InterviewQuestion iq = (InterviewQuestion) q;
		i = iq.getTargetInterview();
		i.currIndex = 0;
	    }
	    else if (q instanceof FinalQuestion && i.caller != null) {
		i = i.caller.getInterview();
		i.currIndex++;
	    }
	    else 
		break;		
	}

	Question q = i.path.questionAt(i.currIndex);
	notifyCurrentQuestionChanged(q);
    }

    /** 
     * Back up to the previous question in the interview.
     * Questions that have been {@link Question#isEnabled disabled} will
     * be skipped over.
     * @throws Interview.Fault if there is no previous question.
     */
    public void prev() throws Fault {
	ensurePathInitialized();

	Interview i = this;

	// first, step in until we get to the current question
	while (i.path.questionAt(i.currIndex) instanceof InterviewQuestion) {
	    InterviewQuestion iq = (InterviewQuestion) (i.path.questionAt(i.currIndex));
	    i = iq.getTargetInterview();
	}

	// next, step back to the next question
	i.currIndex--;

	// finally, normalize the result
	while (true) {
	    if (i.currIndex < 0) {
		if (i.caller == null) {
		    i.currIndex = 0;
		    throw new Fault(i18n, "interview.noMoreQuestions");
		}
		else {
		    i = i.caller.getInterview();
		    i.currIndex--;
		}
	    }
	    else if (i.path.questionAt(i.currIndex) instanceof InterviewQuestion) {
		InterviewQuestion iq = (InterviewQuestion) (i.path.questionAt(i.currIndex));
		i = iq.getTargetInterview();
		i.currIndex = i.path.size() - 1;
	    }
	    else if (i.path.questionAt(i.currIndex) instanceof FinalQuestion) {
		i.currIndex--;
	    }
	    else
		break;		
	}

	Question q = i.path.questionAt(i.currIndex);
	notifyCurrentQuestionChanged(q);
    }

    /**
     * Advance to the last question in the interview.
     * Questions that have been {@link Question#isEnabled disabled} will
     * be skipped over.
     * @throws Interview.Fault if there are no more questions
     */
    public void last() throws Fault {
	ensurePathInitialized();

	Interview i = this;

	// first, step in until we get to the current question
	while (i.path.questionAt(i.currIndex) instanceof InterviewQuestion) {
	    InterviewQuestion iq = (InterviewQuestion) (i.path.questionAt(i.currIndex));
	    i = iq.getTargetInterview();
	}

	// navigate around the interview without upsetting any interview's currIndex
	int index = i.currIndex;

	// Scan forward looking for candidates for the last question.
	// The alternative is to advance i.currIndex to the end of this
	// interview and normalize the result, but that gets complicated
	// with the interaction between nested interviews and hidden 
	// questions.
	Question cq = i.path.questionAt(index);
	Question lq = cq;
	index++;

	while (index < i.path.size()) {
	    Question q = i.path.questionAt(index);
	    
	    if (q instanceof InterviewQuestion) {
		i = ((InterviewQuestion) q).getTargetInterview();
		index = 0;
	    }
	    else if (q instanceof FinalQuestion && i.caller != null) {
		Interview callInterview = i.caller.getInterview();
		int callIndex = callInterview.path.indexOf(i);
		if (callIndex == -1)
		    throw new IllegalStateException();
		i = callInterview;
		index = callIndex + 1;
	    }
	    else {
		// update candidate and move on
		lq = q;
		index++;
	    }
	}
	
	if (lq == cq) {
	    if ( !(lq instanceof FinalQuestion)) 	
		throw new Fault(i18n, "interview.noMoreQuestions");
	}
	else 
	    setCurrentQuestion(lq);
    }

    /**
     * Check if the interview has been started. An interview is 
     * considered to be at the beginning if there is only one
     * question on the current path of a type that requires a response.
     * This indirectly implies it must be the last question on
     * the current path, and must only be preceded by 
     * {@link NullQuestion information-only} questions.
     * @return true if the first answerable question is unanswered.
     */
    public boolean isStarted() {
	Question[] path = root.getPath();
	for (int i = 0; i < path.length - 1; i++) {
	    Question q = path[i];
	    if (!(q instanceof NullQuestion))
		return true;
	}
	return false;
    }

    /**
     * Check if the interview has been completed. An interview is
     * considered to have been completed if the final question
     * on the current path is of type {@link FinalQuestion}.
     * @return true if the interview has been completed.
     */
    public boolean isFinishable() {
	ensurePathInitialized();

	Interview i = root;
	return (i.path.lastQuestion() instanceof FinalQuestion);
    }

    /**
     * Check if this subinterview has been completed. A subinterview is 
     * considered to have been completed if none of the questions from 
     * this subinterview on the current path return null as the result 
     * of getNext().
     *<em>Note:</em>compare this to isFinishable() which checks that the 
     * entire interview (of which this subinterview may be a part) is
     * complete.
     * @return true is this subinterview has been completed.
     */
    protected boolean isInterviewFinishable() {
	return (path != null && path.lastQuestion() instanceof FinalQuestion);
    }


    /**
     * Jump to a specific question in the interview. The question
     * must be on the current path, but can be either before or
     * after the current position at the time this is called.
     * @param q The question which is to become the current
     * question in the interview.
     * @throws Interview.Fault if the question given is not on the current path.
     * @see #getCurrentQuestion
     */
    public void setCurrentQuestion(Question q) throws Fault {
	if (q == null)
	    throw new NullPointerException();

	if (q == getCurrentQuestion()) 
	    return;

	boolean ok = root.setCurrentQuestion0(q);
	if (!ok)
	    throw new NotOnPathFault(q);

	notifyCurrentQuestionChanged(q);
    }

    private boolean setCurrentQuestion0(Question q) {
	ensurePathInitialized();

	for (int i = 0; i < path.size(); i++) {
	    Question qq = path.questionAt(i);
	    if (qq.equals(q)) {
		currIndex = i;
		return true;
	    }
	    else if (qq instanceof InterviewQuestion) {
		if (((InterviewQuestion) qq).getTargetInterview().setCurrentQuestion0(q)) {
		    currIndex = i;
		    return true;
		}
	    }
	}
	return false;
    }

    /**
     * Get the current question in the interview.
     * @return The current question.
     * @see #setCurrentQuestion
     */
    public Question getCurrentQuestion() {
	ensurePathInitialized();

	Interview i = root;
	Question q = i.path.questionAt(i.currIndex);
	while (q instanceof InterviewQuestion) {
	    i = ((InterviewQuestion) q).getTargetInterview();
	    q = i.path.questionAt(i.currIndex);
	}
	return q;
    }

    private void setCurrentQuestionFromPath(Question[] path) {
	root.setCurrentQuestionFromPath0(path);
    }

    private void setCurrentQuestionFromPath0(Question[] path) {
	for (int i = path.length - 1; i >= 0; i--) {
	    if (setCurrentQuestion0(path[i])) {
		notifyCurrentQuestionChanged(path[i]);
		return;
	    }
	}
    }

    //----- path stuff ----------------------------------------

    /**
     * Get the set of questions on the current path.
     * The first question is determined by the interview; after that,
     * each question in turn determines its successor. The path ends
     * when a question indicates no successor (or erroneously returns
     * a question that is already on the path, that would otherwise
     * form a cycle). The special type of question, {@link FinalQuestion},
     * never returns a successor.
     * Within a particular interview, a question may refer to a
     * nested interview, before continuing within the original interview.
     * Any such references to nested interviews are automatically 
     * expanded by this method, leaving just the complete set of basic 
     * questions on the path.
     * @return an array containing the list of questions on the current path.
     * @see #setFirstQuestion
     * @see Question#getNext
     * @see #getPathToCurrent
     */
    public Question[] getPath() {
	Vector v = new Vector();
	iteratePath0(v, true, true, true);
	Question[] p = new Question[v.size()];
	v.copyInto(p);
	return p;
    }

    /**
     * Get the set of questions on the current path up to and
     * including the current question.
     * @return an array containing the list of questions on the 
     * current path up to and including the current question
     * @see #getPath
     */
    public Question[] getPathToCurrent() {
	Vector v = new Vector();
	iteratePath0(v, true, false, true);
	Question[] p = new Question[v.size()];
	v.copyInto(p);
	return p;
    }

    /**
     * Get an iterator for the set of questions on the current path.
     * The first question is determined by the interview; after that,
     * each question in turn determines its successor. The path ends
     * when a question indicates no successor (or erroneously returns
     * a question that is already on the path, that would otherwise
     * form a cycle). The special type of question, {@link FinalQuestion},
     * never returns a successor.
     * Within a particular interview, a question may refer to a
     * nested interview, before continuing within the original interview.
     * Such nested interviews may optionally be expanded by this method,
     * depending on the arguments.
     * @param flattenNestedInterviews If true, any nested interviews will
     * be expanded in place and returned via the iterator; otherwise, the
     * the nested interview will be returned instead.
     * @return an Iterator for the questions on the current path
     * @see #iteratePathToCurrent
     */ 
    public Iterator iteratePath(boolean flattenNestedInterviews) {
	Vector v = new Vector();
	iteratePath0(v, flattenNestedInterviews, true, true);
	return v.iterator();
    }


    /**
     * Get an iterator for the set of questions on the current path
     * up to and including the current question.
     * @param flattenNestedInterviews If true, any nested interviews will
     * be expanded in place and returned via the iterator; otherwise, the
     * the nested interview will be returned instead.
     * @return an Iterator for the questions on the current path
     * up to and including the current question
     * @see #iteratePath
     */
    public Iterator iteratePathToCurrent(boolean flattenNestedInterviews) {
	Vector v = new Vector();
	iteratePath0(v, flattenNestedInterviews, false, true);
	return v.iterator();
    }

    private void iteratePath0(List l, boolean flattenNestedInterviews, boolean all, boolean addFinal) {
	ensurePathInitialized();

	int n = (all ? path.size() : currIndex + 1);
	for (int i = 0; i < n; i++) {
	    Question q = path.questionAt(i);
	    if (q instanceof InterviewQuestion) {
		if (flattenNestedInterviews) 
		    ((InterviewQuestion) q).getTargetInterview().iteratePath0(l, true, all, false);
		else
		    l.add(q);
	    }
	    else if (!addFinal && q instanceof FinalQuestion)
		return;
	    else 
		l.add(q);
	}
    }

    /**
     * Verify that the current path contains a specified question, 
     * and throw an exception if it does not.
     * @param q the question to be checked
     * @throws Interview.NotOnPathFault if the current path does not contain
     * the specified question.
     */
    public void verifyPathContains(Question q) 
	throws NotOnPathFault
    {
	if (!pathContains(q))
	    throw new NotOnPathFault(q);
    }

    /**
     * Check if the path contains a specific question.
     * @param q The question for which to check.
     * @return true if the question is found on the current path.
     */
    public boolean pathContains(Question q) {
	return root.pathContains0(q);
    }

    /**
     * Check if the path contains questions from a specific interview.
     * @param i The interview for which to check.
     * @return true if the interview is found on the current path.
     */
    public boolean pathContains(Interview i) {
	return	root.pathContains0(i);
    }

    private boolean pathContains0(Object o) {
	ensurePathInitialized();

	for (int index = 0; index < path.size(); index++) {
	    Question q = path.questionAt(index);
	    if (o == q)
		return true;
	    
	    if (q instanceof InterviewQuestion) {
		InterviewQuestion iq = (InterviewQuestion) q;
		Interview i = iq.getTargetInterview();
		if (o == i)
		    return true;

		if (i.pathContains0(o))
		    return true;
	    }
	}

	return false;
    }

    /**
     * Get the complete set of questions in this interview and 
     * recursively, in all child interviews.
     * @return a set of all questions in this and every child interview.
     */
    public Set getQuestions() {
	Set s = new HashSet();
	getQuestions0(s);
	return s;
    }

    private void getQuestions0(Set s) {
	s.addAll(allQuestions.values());

	for (int i = 0; i < children.size(); i++) {
	    Interview child = (Interview) (children.elementAt(i));
	    child.getQuestions0(s);
	}
    }

    /**
     * Get all questions in this interview and 
     * recursively, in all child interviews.
     * @return a map containing all questions in this and every child interview.
     */
    public Map getAllQuestions() {
	Map m = new HashMap();
	getAllQuestions0(m);
	return m;
    }

    private void getAllQuestions0(Map m) {
	m.putAll(allQuestions);

	for (int i = 0; i < children.size(); i++) {
	    Interview child = (Interview) (children.elementAt(i));
	    child.getAllQuestions0(m);
	}
    }

    /**
     * Check whether any questions on the current path have any
     * associated checklist items.
     * @return true if no questions have any corresponding checklist
     * items, and false otherwise.
     */
    public boolean isChecklistEmpty() {
	for (Iterator iter = iteratePath(true); iter.hasNext(); ) {
	    Question q = (Question) (iter.next());
	    Checklist.Item[] items = q.getChecklistItems();
	    if (items != null && items.length > 0)
		return false;
	}
	return true;
    }

    /**
     * Create a checklist composed of all checklist items
     * for questions on the current path.
     * @return a checklist composed of all checklist items
     * for questions on the current path.
     * @see #getPath
     * @see Question#getChecklistItems
     */
    public Checklist createChecklist() {
	Checklist c = new Checklist();
	for (Iterator iter = iteratePath(true); iter.hasNext(); ) {
	    Question q = (Question) (iter.next());
	    Checklist.Item[] items = q.getChecklistItems();
	    if (items != null) {
		for (int i = 0; i < items.length; i++) 
		    c.add(items[i]);
	    }
	}
	return c;
    }

    /** 
     * Create a checklist item based on entries in the interview's resource bundle.
     * @param sectionKey A key to identify the section name within the interview's resource bundle
     * @param textKey A key to identify the checklist item text within the interview's resource bundle
     * @return a Checklist.Item object composed from the appropriate entries in the interview's resource bundle
     */
    public Checklist.Item createChecklistItem(String sectionKey, String textKey) {
	String section = getI18NString(sectionKey);
	String text = getI18NString(textKey);
	return new Checklist.Item(section, text);
    }


    /** 
     * Create a checklist item based on entries in the interview's resource bundle.
     * @param sectionKey A key to identify the section name within the interview's resource bundle
     * @param textKey A key to identify the checklist item text within the interview's resource bundle
     * @param textArg a single argument to be formatted into the checklist item text
     * @return a Checklist.Item object composed from the appropriate entries in the interview's resource bundle and the specified argument value
     */
    public Checklist.Item createChecklistItem(String sectionKey, String textKey, Object textArg) {
	String section = getI18NString(sectionKey);
	String text = getI18NString(textKey, textArg);
	return new Checklist.Item(section, text);
    }


    /** 
     * Create a checklist item based on entries in the interview's resource bundle.
     * @param sectionKey A key to identify the section name within the interview's resource bundle
     * @param textKey A key to identify the checklist item text within the interview's resource bundle
     * @param textArgs an array of arguments to be formatted into the checklist item text
     * @return a Checklist.Item object composed from the appropriate entries in the interview's resource bundle and the specified argument values
     */
    public Checklist.Item createChecklistItem(String sectionKey, String textKey, Object[] textArgs) {
	String section = getI18NString(sectionKey);
	String text = getI18NString(textKey, textArgs);
	return new Checklist.Item(section, text);
    }

    //----- markers ---------------------------------

    /**
     * Add a named marker for a question.
     * @param q The question for which to add the marker
     * @param name The name of the marker to be added.
     * @throws NullPointerException if the question is null.
     */
    void addMarker(Question q, String name) {
	if (root != this) {
	    root.addMarker(q, name);
	    return;
	}

	if (q == null)
	    throw new NullPointerException();

	if (allMarkers == null)
	    allMarkers = new HashMap();

	Set markersForName = (Set) (allMarkers.get(name));
	if (markersForName == null) {
	    markersForName = new HashSet();
	    allMarkers.put(name, markersForName);
	}

	markersForName.add(q);
    }

    /**
     * Remove a named marker for a question.
     * @param q The question for which to remove the marker
     * @param name The name of the marker to be removeded.
     * @throws NullPointerException if the question is null.
     */
    void removeMarker(Question q, String name) {
	if (root != this) {
	    root.removeMarker(q, name);
	    return;
	}

	if (q == null)
	    throw new NullPointerException();

	if (allMarkers == null)
	    return;

	Set markersForName = (Set) (allMarkers.get(name));
	if (markersForName == null) 
	    return;

	markersForName.remove(q);

	if (markersForName.size() == 0)
	    allMarkers.remove(name);
    }

    /**
     * Check if a question has a named marker.
     * @param q The question for which to check for the marker
     * @param name The name of the marker to be removed.
     * @throws NullPointerException if the question is null.
     */
    boolean hasMarker(Question q, String name) {
	if (root != this)
	    return root.hasMarker(q, name);

	if (q == null)
	    throw new NullPointerException();

	if (allMarkers == null)
	    return false;

	Set markersForName = (Set) (allMarkers.get(name));
	if (markersForName == null) 
	    return false;

	return markersForName.contains(q);
    }

    /**
     * Remove all the markers with a specified name.
     * @param name The name of the markers to be removed
     */
    public void removeMarkers(String name) {
	if (root != this) {
	    root.removeMarkers(name);
	    return;
	}

	// just have to remove the appropriate set of markers
	if (allMarkers != null)
	    allMarkers.remove(name);
    }

    /**
     * Remove all the markers, whatever their name.
     */
    public void removeAllMarkers() {
	if (root != this) {
	    root.removeAllMarkers();
	    return;
	}

	allMarkers = null;
    }

    /**
     * Clear the response to marked questions.
     * @param name The name of the markers for the questions to be cleared.
     */
    public void clearMarkedResponses(String name) {
	if (root != this) {
	    root.clearMarkedResponses(name);
	    return;
	}

	if (allMarkers == null) // no markers at all
	    return;
	
	Set markersForName = (Set) (allMarkers.get(name));
	if (markersForName == null) // no markers for this name
	    return;

	updateEnabled = false;
	Question oldCurrentQuestion = getCurrentQuestion();

	for (Iterator iter = markersForName.iterator(); iter.hasNext(); ) {
	    Question q = (Question) (iter.next());
	    q.clear();
	}
	
	updateEnabled = true;
	updatePath(firstQuestion);

	Question newCurrentQuestion = getCurrentQuestion();
	if (newCurrentQuestion != oldCurrentQuestion)
	    notifyCurrentQuestionChanged(newCurrentQuestion);
    }

    private void loadMarkers(Map data) {
	String s = (String) (data.get(MARKERS));
	int count = 0;
	if (s != null) {
	    try {
		count = Integer.parseInt(s);
	    }
	    catch (NumberFormatException e) {
		// ignore
	    }
	}

	allMarkers = null;

	for (int i = 0; i < count; i++) {
	    String name = (String) (data.get(MARKERS_PREF + i + ".name"));
	    String tags = (String) (data.get(MARKERS_PREF + i));
	    if (tags != null) 
		loadMarkers(name, tags);
	}
    }

    private void loadMarkers(String name, String tags) {
	int start = -1;
	for (int i = 0; i < tags.length(); i++) {
	    if (tags.charAt(i) == '\n') {
		if (start != -1) {
		    String tag = tags.substring(start, i).trim();
		    loadMarker(name, tag);
		    start = -1;
		} 
	    }
	    else
		if (start == -1)
		    start = i;
	}
	if (start != -1) {
	    String tag = tags.substring(start).trim();
	    loadMarker(name, tag);
	}
    }

    private void loadMarker(String name, String tag) {
	if (tag.length() > 0) {
	    Question q = lookup(tag);
	    if (q != null)
		addMarker(q, name);
	}
    }
    
    private void saveMarkers(Map data) {
	if (allMarkers == null)
	    return;

	int i = 0;
	for (Iterator iter = allMarkers.entrySet().iterator(); iter.hasNext(); ) {
	    Map.Entry e = (Map.Entry) (iter.next());
	    String name = (String) (e.getKey());
	    Set markersForName = (Set) (e.getValue());
	    if (name != null)
		data.put(MARKERS_PREF + i + ".name", name);
	    StringBuffer sb = new StringBuffer();
	    for (Iterator qIter = markersForName.iterator(); qIter.hasNext(); ) {
		Question q = (Question) (qIter.next());
		if (sb.length() > 0)
		    sb.append('\n');
		sb.append(q.getTag());
	    }
	    data.put(MARKERS_PREF + i, sb.toString());
	    i++;
	}
	
	if (i > 0)
	    data.put(MARKERS, String.valueOf(i));
    }


    //----- nested interview stuff ---------------------------------

    /**
     * Return a special type of question used to indicate that
     * a sub-interview interview should be called before proceeding
     * to the next question in this interview.
     * @param i The nested interview to be called next
     * @param q The next question to be asked when the nested
     * interview has completes with a {@link FinalQuestion final question}.
     * @return a pseudo-question that will call a nested interview before
     * continuing with the specified follow-on question.
     */
    protected Question callInterview(Interview i, Question q) {
	return new InterviewQuestion(this, i, q);
    }

    //----- load/save stuff ----------------------------------------

    /**
     * Clear any responses to all the questions in this interview, and then
     * recursively, in its child interviews.
     */
    public void clear() {
	updateEnabled = false;
	for (Iterator iter = allQuestions.values().iterator(); iter.hasNext(); ) {
	    Question q = (Question) (iter.next());
	    q.clear();
	}

	for (int i = 0; i < children.size(); i++) {
	    Interview child = (Interview)children.elementAt(i);
	    child.clear();
	}
	if (parent == null) {
            extraValues = null;
            templateValues = null;
	    reset();
        }
    }

    /**
     * Load the state for questions from an archive map. The map
     * will be passed to each question in this interview and in any
     * child interviews, and each question should {@link Question#load load}
     * its state, according to its tag.
     * The data must normally contain a valid checksum, generated during {@link #save}.
     * @param data The archive map from which the state should be loaded.
     * @throws Interview.Fault if the checksum is found to be incorrect.
     */
    public void load(Map data) throws Fault {
	load(data, true);
    }

    /**
     * Load the state for questions from an archive map. The map
     * will be passed to each question in this interview and in any
     * child interviews, and each question should {@link Question#load load}
     * its state, according to its tag.
     * The data must normally contain a valid checksum, generated during {@link #save}.
     * @param data The archive map from which the state should be loaded.
     * @param checkChecksum If true, the checksum in the data will be checked.
     * @throws Interview.Fault if the checksum is found to be incorrect.
     */
    public void load(Map data, boolean checkChecksum) throws Fault {
	if (checkChecksum && !isChecksumValid(data, true)) 
	    throw new Fault(i18n, "interview.checksumError");

	if (parent == null) {
	    String iTag = (String)(data.get(INTERVIEW));
	    if (iTag != null && !iTag.equals(getClass().getName()))
		throw new Fault(i18n, "interview.classMismatch");

            loadExternalValues(data);
            loadTemplateValues(data);
	}

	updateEnabled = false;

	// clear all the answers in this interview before loading an
	// responses from the archive
	for (Iterator iter = allQuestions.values().iterator(); iter.hasNext(); ) {
	    Question q = (Question) (iter.next());
	    q.clear();
	}

	for (Iterator iter = allQuestions.values().iterator(); iter.hasNext(); ) {
	    Question q = (Question) (iter.next());
	    q.load(data);
	}

	for (int i = 0; i < children.size(); i++) {
	    Interview child = (Interview)children.elementAt(i);
	    child.load(data, false);
	}

	if (parent == null) {
	    String qTag = (String)(data.get(QUESTION));
	    Question q = (qTag == null ? null : lookup(qTag));
	    reset(q == null ? firstQuestion : q);
	}

	loadMarkers(data);
    }

    /**
     * Check if the checksum is valid for a set of responses.
     * When responses are saved to a map, they are checksummed,
     * so that they can be checked for validity when reloaded.
     * This method verifies that a set of responses are acceptable
     * for loading.
     * @param data The set of responses to be checked.
     * @param okIfOmitted A boolean determining the response if
     * there is no checksum available in the data
     * @return true if the responses have a valid checksum, if given,
     * or whether it is OK to monit the checksum, if not.
     */
    public static boolean isChecksumValid(Map data, boolean okIfOmitted) {
	try {
	    String s = (String)(data.get(CHECKSUM));
	    if (s == null)
		return okIfOmitted;
	    else
		return (Long.parseLong(s, 16) == computeChecksum(data));
	}
	catch (NumberFormatException e) {
	    return false;
	}
    }

    /**
     * Save the state for questions in an archive map. The map
     * will be passed to each question in this interview and in any
     * child interviews, and each question should {@link Question#save save}
     * its state, according to its tag.
     * @param data The archive in which the values should be saved.
     */
    public void save(Map data) {
        // only in the root interview
	if (parent == null) {
	    data.put(INTERVIEW, getClass().getName());
	    data.put(QUESTION, getCurrentQuestion().getTag());
	    data.put(LOCALE, Locale.getDefault().toString());

            if (extraValues != null && extraValues.size() > 0) {
                Set keys = getPropertyKeys();
                Iterator it = keys.iterator();
                while (it.hasNext()) {
                    Object key = it.next();
                    data.put(EXTERNAL_PREF + key, extraValues.get(key));
                }   // while
            }
            
            if (templateValues != null && templateValues.size() > 0) {
                Set keys = templateValues.keySet();
                Iterator it = keys.iterator();
                while (it.hasNext()) {
                    String key = (String)it.next();
                    data.put(TEMPLATE_PREF + key, retrieveTemplateProperty(key));
                }   // while
            }
            
	}

	for (Iterator iter = allQuestions.values().iterator(); iter.hasNext(); ) {
	    Question q = (Question) (iter.next());
	    try {
		q.save(data);
	    }
	    catch (RuntimeException ex) {
		System.err.println("warning: " + ex.toString());
		System.err.println("while saving value for question " + q.getTag() + " in interview " + getTag());
	    }
	}

	for (int i = 0; i < children.size(); i++) {
	    Interview child = (Interview)children.elementAt(i);
	    child.save(data);
	}
	
	saveMarkers(data);

	data.put(CHECKSUM, Long.toString(computeChecksum(data), 16));
    }

    private static long computeChecksum(Map data) {
	long cs = 0;
	for (Iterator iter = data.entrySet().iterator(); iter.hasNext(); ) {
	    Map.Entry e = (Map.Entry) (iter.next());
	    String key = (String) (e.getKey());
	    String value = (String)(e.getValue());
	    if (!key.equals(CHECKSUM)) {
		cs += computeChecksum(key) * computeChecksum(value);
	    }
	}
	// ensure result is >= 0 to avoid problems with signed hex numbers
	return (cs == Long.MIN_VALUE ? 0 : cs < 0 ? -cs : cs);
    }

    private static long computeChecksum(String s) {
	if (s == null)
	    return 1;
	else {
	    long cs = 0;
	    for (int i = 0; i < s.length(); i++) {
		cs = cs * 37 + s.charAt(i);
	    }
	    return cs;
	}
    }

    /**
     * Export values for questions on the current path, by calling {@link Question#export}
     * for each question returned by {@link #getPath}. 
     * It should  be called on the root interview to export the values for all
     * questions on the current path, or it can be called on a sub-interview
     * to export just the values from the question in that sub-interview (and in turn,
     * in any further sub-interviews for which there are questions on the path.)
     * Unchecked exceptions that arise from each question's export method are treated
     * according to the policy set by setExportIgnoreExceptionPolicy for the interview
     * for which export was called.  
     * It may be convenient to ignore runtime exceptions during export, if exceptions
     * may be thrown when the interview is incomplete.  It may be preferred not
     * to ignore any exceptions, if no exceptions are expected.  
     * @param data The map in which the values will be placed.
     * @see #getPath
     * @see #setExportIgnoreExceptionPolicy
     * @see #EXPORT_IGNORE_ALL_EXCEPTIONS
     * @see #EXPORT_IGNORE_RUNTIME_EXCEPTIONS 
     * @see #EXPORT_IGNORE_NO_EXCEPTIONS
     */
    public void export(Map data) {
	Question[] path = getPath();
	for (int i = 0; i < path.length; i++) {
	    try {
		path[i].export(data);
	    }
	    catch (RuntimeException e) {
		switch (exportIgnoreExceptionPolicy) {
		case EXPORT_IGNORE_ALL_EXCEPTIONS:
		case EXPORT_IGNORE_RUNTIME_EXCEPTIONS:
		    break;
		case EXPORT_IGNORE_NO_EXCEPTIONS:
		    throw e;
		}
	    }
	    catch (Error e) {
		switch (exportIgnoreExceptionPolicy) {
		case EXPORT_IGNORE_ALL_EXCEPTIONS:
		    break;
		case EXPORT_IGNORE_RUNTIME_EXCEPTIONS:
		case EXPORT_IGNORE_NO_EXCEPTIONS:
		    throw e;
		}
	    }
	}
    }

    /**
     * Get a value representing the policy regarding how to treat
     * exceptions that may arise during export.
     * @see #setExportIgnoreExceptionPolicy
     * @return a value representing the policy regarding how to treat
     * exceptions that may arise during export.
     * @see #export
     * @see #EXPORT_IGNORE_ALL_EXCEPTIONS
     * @see #EXPORT_IGNORE_RUNTIME_EXCEPTIONS 
     * @see #EXPORT_IGNORE_NO_EXCEPTIONS
     */
    public int getExportIgnoreExceptionPolicy() {
	return exportIgnoreExceptionPolicy;
    }


    /**
     * Set the policy regarding how to treat exceptions that may arise during export.
     * The default value is to ignore runtime exceptions.
     * @param policy a value representing the policy regarding how to treat exceptions that may arise during export
     * @see #getExportIgnoreExceptionPolicy
     * @see #export
     * @see #EXPORT_IGNORE_ALL_EXCEPTIONS
     * @see #EXPORT_IGNORE_RUNTIME_EXCEPTIONS 
     * @see #EXPORT_IGNORE_NO_EXCEPTIONS
     */
    public void setExportIgnoreExceptionPolicy(int policy) {
	if (policy < 0 || policy >= EXPORT_NUM_IGNORE_POLICIES)
	    throw new IllegalArgumentException();
	exportIgnoreExceptionPolicy = policy;
    }

    /**
     * A value indicating that export should ignore all exceptions that arise
     * while calling each question's export method.
     * @see #setExportIgnoreExceptionPolicy
     * @see #export
     */
    public static final int EXPORT_IGNORE_ALL_EXCEPTIONS = 0;

    /**
     * A value indicating that export should ignore runtime exceptions that arise
     * while calling each question's export method.
     * @see #setExportIgnoreExceptionPolicy
     * @see #export
     */
    public static final int EXPORT_IGNORE_RUNTIME_EXCEPTIONS = 1;

    /**
     * A value indicating that export should not ignore any exceptions that arise
     * while calling each question's export method.
     * @see #setExportIgnoreExceptionPolicy
     * @see #export
     */
    public static final int EXPORT_IGNORE_NO_EXCEPTIONS = 2;
    private static final int EXPORT_NUM_IGNORE_POLICIES = 3;
    private int exportIgnoreExceptionPolicy = EXPORT_IGNORE_RUNTIME_EXCEPTIONS;


    //----- observers ----------------------------------------

    /**
     * Add an observer to monitor updates to the interview.
     * @param o an observer to be notified as changes occur
     */
    synchronized public void addObserver(Observer o) {
	if (o == null)
	    throw new NullPointerException();

	// we take the hit here of shuffling arrays to make the
	// notification faster and more convenient (no casting)
	Observer[] newObs = new Observer[observers.length + 1];
	System.arraycopy(observers, 0, newObs, 0, observers.length);
	newObs[observers.length] = o;
	observers = newObs;
    }

    /**
     * Remove an observer previously registered to monitor updates to the interview.
     * @param o the observer to be removed from the list taht are notified
     */
    synchronized public void removeObserver(Observer o) {
	if (o == null)
	    throw new NullPointerException();

	// we take the hit here of shuffling arrays to make the
	// notification faster and more convenient (no casting)
	for (int i = 0; i < observers.length; i++) {
	    if (observers[i] == o) {
		Observer[] newObs =
		    new Observer[observers.length - 1];
		System.arraycopy(observers, 0, newObs, 0, i);
		System.arraycopy(observers, i + 1, newObs, i, observers.length - i - 1);
		observers = newObs;
		return;
	    }
	}
    }

    private void notifyCurrentQuestionChanged(Question q) {
	for (int i = 0; i < observers.length && q == getCurrentQuestion(); i++)
	    observers[i].currentQuestionChanged(q);
    }

    private void notifyPathUpdated() {
	for (int i = 0; i < observers.length; i++)
	    observers[i].pathUpdated();
    }

    private Observer[] observers = new Observer[0];	

    //----- tag stuff ----------------------------------------

    /**
     * Change the base tag for this interview. 
     * This should not be done for most interviews, since the base tag
     * is the basis for storing loading and storing values, and changing
     * the base tag may lead to unexpected results. 
     * Changing the base tag will caused the tags in all statically
     * nested interviews and questions to be updated as well.
     * This method is primarily intended to be used when renaming 
     * dynamically allocated loop bodies in ListQuestion.
     * @param newBaseTag the new value for the base tag.
     */
    protected void setBaseTag(String newBaseTag) {
	baseTag = newBaseTag;
	updateTags();
    }

    private void updateTags() {
	// update our own tag
	if (parent == null || parent.tag == null)
	    tag = baseTag;
	else if (baseTag == null) // should we allow this?
	    tag = parent.getTag();
	else
	    tag = parent.getTag() + "." + baseTag;

	// update the tags for the questions in the interview
	// and rebuild the tag map
	Map newAllQuestions = new HashMap();
	for (Iterator iter = allQuestions.values().iterator(); iter.hasNext(); ) {
	    Question q = (Question) (iter.next());
	    q.updateTag();
	    newAllQuestions.put(q.getTag(), q);
	}
	allQuestions = newAllQuestions;

	// recursively update children
	for (Iterator iter = children.iterator(); iter.hasNext(); ) {
	    Interview i = (Interview) (iter.next());
	    i.updateTags();
	}
    }
    
    //----- adding subinterviews and questions ------------------------

    void add(Interview child) {
	children.add(child);
    }

    void add(Question question) {
	String qTag = question.getTag();
	Question prev = (Question) allQuestions.put(qTag, question);
	if (prev != null)
	    throw new IllegalArgumentException("duplicate questions for tag: " + qTag);
    }
    
    //----- versioning ----------------------------------------

    /**
     * This method is being used to toggle changes which are not
     * backwards compatible with existing interviews.  Changing this value
     * after you first initialize the top-level interview object is not
     * recommended or supported.  This method should be called as soon as
     * possible during construction.  It is recommended that you select
     * the most recent version possible when developing a new interview.
     * As this interview ages and the harness libraries progress, the
     * interview should remain locked at the current behavior.
     *
     * @param value Which semantics the interview should use.
     * @see #SEMANTIC_PRE_32
     * @see #SEMANTIC_VERSION_32
     * @see #SEMANTIC_MAX_VERSION
     * @since 3.2
     * @see #getInterviewSemantics
     */
    protected void setInterviewSemantics(int value) {
	if (value <= SEMANTIC_MAX_VERSION)
	    semantics = value;
    }

    /**
     * Determine which semantics are being used for interview and question
     * behavior.  This is important because new behavior in future versions
     * can cause unanticipated code flow, resulting in incorrect behavior of
     * existing code.
     * @return The semantics that the interview is currently using.
     * @see #setInterviewSemantics
     * @since 3.2
     */
    public int getInterviewSemantics() {
	return semantics;
    }

    //----- external value management ----------------------------------------

    /**
     * Store an "external" value into the configuration.  This is a value
     * not associated with any interview question and in a seperate namespace
     * than all the question keys.
     * @param key The name of the key to store.
     * @param value The value associated with the given key.
     * @return The old value of this property, null if not previously set.
     * @see #retieveProperty
     */
    public String storeProperty(String key, String value) {
        if (getParent() != null)
            return getParent().storeProperty(key, value);
        else {
            if (extraValues == null)
                extraValues = new HashMap();

            return (String)(extraValues.put(key, value));
        }
    }

    /**
     * Store a template value into the configuration.  
     * @param key The name of the key to store.
     * @param value The value associated with the given key.
     * @return The old value of this property, null if not previously set.
     */
    public String storeTemplateProperty(String key, String value) {
        if (getParent() != null)
            return getParent().storeTemplateProperty(key, value);
        else {
            ensureTemValuesInitialized();
            return (String)(templateValues.put(key, value));
        }
    }
    
    /**
     * Clear a previous template properties and store the new into the configuration.  
     * @param props The properties to store.
     */
    public void storeTemplateProperties(Properties props) {
        if (getParent() != null)
            getParent().storeTemplateProperties(props);
        else {
            ensureTemValuesInitialized();
            templateValues.clear();
            templateValues.putAll(props);
        }
    }
    
    
    /**
     * Retrieve a property from the collection of "external" values being
     * stored in the configuration.
     * @param key The key which identifies the property to retrieve.
     * @return The value associated with the given key, or null if it is not
     *         found.
     * @see #storeProperty
     */
    public String retrieveProperty(String key) {
        if (getParent() != null)
            return getParent().retrieveProperty(key);
        else {
            if (extraValues == null)
                return null;

            return (String)(extraValues.get(key));
        }
    }

    /**
     * Retrieve a template property.
     * @param key The key which identifies the property to retrieve.
     * @return The value associated with the given key, or null if it is not
     *         found.
     */
    public String retrieveTemplateProperty(String key) {
        if (getParent() != null)
            return getParent().retrieveTemplateProperty(key);
        else {
            ensureTemValuesInitialized();
            return (String)(templateValues.get(key));
        }
    }
    
    public Set retrieveTemplateKeys() {
        if (getParent() != null)
            return getParent().retrieveTemplateKeys();
        else {
            ensureTemValuesInitialized();
            return templateValues.keySet();
        }
    }

    
    
    /**
     * Retrieve set of keys for the "external" values being stored in the
     * configuration.
     * @return The set of keys currently available.  Null if there are none.
     *         All values in the Set are Strings.
     * @see #storeProperty
     * @see #retrieveProperty
     */
    public Set getPropertyKeys() {
        if (getParent() != null)
            return parent.getPropertyKeys();
        else {
            if (extraValues == null || extraValues.size() == 0)
                return null;

            return extraValues.keySet();
        }
    }

    /**
     * @see #load(Map)
     */
    private void loadExternalValues(Map data) {
        Set keys = data.keySet();
        Iterator it = keys.iterator();

        while (it.hasNext()) {
            // look for special external value keys
            // should consider removing it from data, is it safe to alter
            // that object?
            String key = (String)(it.next());
            if (key.startsWith(EXTERNAL_PREF)) {
                if (extraValues == null)
                    extraValues = new HashMap();

                String val = (String)(data.get(key));

                // store it, minus the special prefix
                extraValues.put(key.substring(EXTERNAL_PREF.length()), val);
            }
        }   // while
    }

    private void loadTemplateValues(Map data) {
        Set keys = data.keySet();
        Iterator it = keys.iterator();

        while (it.hasNext()) {
            String key = (String)(it.next());
            if (key.startsWith(TEMPLATE_PREF)) {
                ensureTemValuesInitialized();
                String val = (String)(data.get(key));
                // store it, minus the special prefix
                templateValues.put(key.substring(TEMPLATE_PREF.length()), val);
            }
        }   
    }
    
    public void propagateTemplateForAll() {
        ensureTemValuesInitialized();
	for (Iterator iter = getAllQuestions().values().iterator(); iter.hasNext(); ) {
	    Question q = (Question) (iter.next());
		q.load(templateValues);
	}
    }
    
    //----- internal utilities ----------------------------------------

    private void ensureTemValuesInitialized() {
	if (templateValues  == null) {
	    templateValues  = new HashMap();
        }
    }

    private void ensurePathInitialized() {
	if (path == null) {
	    path = new Path();
	    reset();
	}
    }
    
    
    private Question lookup(String tag) {
	Question q = (Question)(allQuestions.get(tag));
	// if q is null, search children till we find it
	for (int i = 0; i < children.size() && q == null; i++) {
	    Interview child = (Interview)children.elementAt(i);
	    q = child.lookup(tag);
	}

	return q;	    
    }

    // Some of the interview state is anchored off the root interview:
    // the current execution path, markers etc. This provides access to the
    // root. Arguably we could give each interview a root field as well...
    private Interview getRoot() {
	/*
	Interview i = this;
	while (i.root != this)
	    i = i.parent;
	return i;
	*/
	return root;
    }

    /**
     * Update the current path, typically because a response to
     * a question has changed.
     */
    public void updatePath() {
	root.updatePath0(root.firstQuestion);
    }

    /**
     * Update the current path, typically because a response to
     * a question has changed.
     * @param q The question that was changed. 
     */
    public void updatePath(Question q) {
       root.updatePath0(q);
    }

    private void updatePath0(Question q) {
	ASSERT(root == this);

	if (!updateEnabled) { 
	    // avoid frequent updates during load
	    return;
	}

	if (path == null) {
	    // path has not been initialized yet, so no need to update it
	    return;
	}

	if (!pathContains(q)) {
	    // question is not on the path, so nothing to do
	    return;
	}

	// keep a copy of the current path so that if the current
	// question is no longer on the path at the end of the update
	// we can adjust it as best we can.
	Question[] currPath = getPathToCurrent();

	trimPath(q);
	predictPath(q);
	//showPath(this, q, 0);

	if (!pathContains(currPath[currPath.length - 1]))
	    setCurrentQuestionFromPath(currPath);

	notifyPathUpdated();
    }

    /* useful debug routine
    private void showPath(Interview i, Question q, int depth) {
	for (int d = 0; d < depth; d++) 
	    System.err.print("  ");
	System.err.println(i.getClass().getName() + " " + i.getTag());
	for (int p = 0; p < i.path.size(); p++) {
	    for (int d = 0; d < depth; d++) 
		System.err.print("  ");
	    Question pq = i.path.questionAt(p);
	    System.err.print(p + ": " + pq.getClass().getName() + " " + pq.getTag());
	    if (pq == q)
		System.err.print(" *");
	    System.err.println();
	    if (pq instanceof InterviewQuestion)
		showPath(((InterviewQuestion)pq).getTargetInterview(), q, depth+1);
	} 
    }
    */

    private void trimPath(Question q) {
	Object o = q;
	Interview i = q.getInterview();
	while (i != null) {
	    // try to find o within i's path
	    Path iPath = i.path;
	    int oIndex = -1;
	    for (int pi = 0; pi < iPath.size(); pi++) {
		Question qq = iPath.questionAt(pi);
		if (qq == o 
		    || (qq instanceof InterviewQuestion
			&& ((InterviewQuestion) qq).getTargetInterview() == o)) {
		    oIndex = pi;
		    break;
		}
	    }	
	    // if not found, this question is not on path
	    // otherwise, trim i's path to end with o
	    if (oIndex == -1)
		return;
	    else
		iPath.setSize(oIndex + 1);

	    // repeat with caller, all the way up the call stack
	    o = i;
	    i = (i.caller == null ? null : i.caller.getInterview());
	}
    }

    private void predictPath(Question q) {
	// start filling out path
	Interview i = q.getInterview();
	q = predictNext(q);
	while (true) {
	    // note: multiple exit conditions within loop body
	    if (q == null || pathContains(q))
		break;
	    else if (q instanceof FinalQuestion) {
		// end of an interview; continue in caller if available
		i.path.addQuestion(q);
		if (i.caller == null) {
		    break;
		}
		else {
		    q = i.caller.getNext();
		    i = i.caller.getInterview();
		}
	    }
	    else if (q instanceof InterviewQuestion) {
		InterviewQuestion iq = (InterviewQuestion)q;
		Interview i2 = iq.getTargetInterview();
		if (pathContains(i2))
		    break;
		else if (i2 instanceof ListQuestion.Body) {
		    // no need to predict the body, right?
		    // because it was done in predictNext()
		    i2.caller = iq;
		    i.path.addQuestion(iq);
		    q = (i2.path.lastQuestion() instanceof FinalQuestion ? iq.getNext() : null);
		}
		else {
		    i2.caller = iq;
		    if (i2.path == null)
			i2.path = new Path();
		    else
			i2.path.clear();
		    i.path.addQuestion(iq);
		    i = i2;
		    q = i2.firstQuestion;
		}
	    }
	    else {
		if (q.isEnabled())
		    i.path.addQuestion(q);
		q = predictNext(q);
	    }
	}
    }

    private Question predictNext(Question q) {
	if (!q.isValueValid())
	    return null;

	if (q instanceof ListQuestion && q.isEnabled()) {
	    final ListQuestion lq = (ListQuestion) q;
	    if (lq.isEnd()) 
		return q.getNext();

	    for (int index = 0; index < lq.getBodyCount(); index++) {
		Interview b = lq.getBody(index);
		if (b.path == null)
		    b.path = new Path();
		else
		    b.path.clear();
		b.path.addQuestion(b.firstQuestion);
		b.caller = null;
		b.predictPath(b.firstQuestion);
	    }
	    

	    Interview lqBody = lq.getSelectedBody();
	    Question lqOther = lq.getOther();
	    if (lqBody == null) 
		return lqOther.getNext();
	    else {
		Interview lqInt = lq.getInterview();
		return new InterviewQuestion(lqInt, lqBody, lqOther);
	    }
	}

	return q.getNext();
    }

    /**
     * Get an entry from the resource bundle.
     * If the resource cannot be found, a message is printed to the console
     * and the result will be a string containing the method parameters.
     * @param key the name of the entry to be returned
     * @param arg an argument to be formatted into the result using
     * {@link java.text.MessageFormat#format}
     * @return the formatted string 
     */
    private String getI18NString(String key) {
	return getI18NString(key, empty);
    }

    private static final Object[] empty = { };

    /**
     * Get an entry from the resource bundle.
     * If the resource cannot be found, a message is printed to the console
     * and the result will be a string containing the method parameters.
     * @param key the name of the entry to be returned
     * @param arg an argument to be formatted into the result using
     * {@link java.text.MessageFormat#format}
     * @return the formatted string 
     */
    private String getI18NString(String key, Object arg) {
	return getI18NString(key, new Object[] { arg });
    }

    /**
     * Get an entry from the resource bundle.
     * If the resource cannot be found, a message is printed to the console
     * and the result will be a string containing the method parameters.
     * @param key the name of the entry to be returned
     * @param args an array of arguments to be formatted into the result using
     * {@link java.text.MessageFormat#format}
     * @return the formatted string
     */
    private String getI18NString(String key, Object[] args) {
	try {
	    ResourceBundle b = getResourceBundle();
	    if (b != null)
		return MessageFormat.format(b.getString(key), args);
	}
	catch (MissingResourceException e) {
	    // should msgs like this be i18n and optional?
	    System.err.println("WARNING: missing resource: " + key);
	}

	StringBuffer sb = new StringBuffer(key);
	for (int i = 0; i < args.length; i++) {
	    sb.append('\n');
	    sb.append(args.toString());
	}
	return sb.toString();
    }

    /**
     * Get an entry from the resource bundle. 
     * The parent and other ancestors bundles will be checked first before
     * this interview's bundle, allowing the root interview a chance to override
     * the default value provided by this interview.
     * @param key the name of the entry to be returned
     * @return the value of the resource, or null if not found
     */
    protected String getResourceString(String key) {
	return getResourceString(key, true);
    }

    /**
     * Get an entry from the resource bundle. If checkAncestorsFirst is true,
     * then the parent and other ancestors bundles will be checked first before
     * this interview's bundle, allowing the root interview a chance to override
     * the default value provided by this interview. Otherwise, the parent bundles
     * will only be checked if this bundle does not provide a value.
     * @param key the name of the entry to be returned
     * @param checkAncestorsFirst whether to recursively call this method on the
     * parent (if any) before checking this bundle, or only afterwards, if this
     * bundle does not provide a value
     * @return the value of the resource, or null if not found
     */
    protected String getResourceString(String key, boolean checkAncestorsFirst) {
	try {
	    String s = null;
	    if (checkAncestorsFirst) {
		if (parent != null)
		    s = parent.getResourceString(key, checkAncestorsFirst);
		if (s == null) {
		    ResourceBundle b = getResourceBundle();
		    if (b != null)
			s = b.getString(key);
		}
	    }
	    else {
		ResourceBundle b = getResourceBundle();
		if (b != null)
		    s = b.getString(key);
		if (s == null && parent != null)
		    s = parent.getResourceString(key, checkAncestorsFirst);
	    }
	    return s;
	}
	catch (MissingResourceException e) {
	    return null;
	}
    }

    // can change this to "assert(b)" in JDK 1.5
    private static final void ASSERT(boolean b) {
	if (!b)
	    throw new IllegalStateException();
    }

    /**
     * The parent interview, if applicable; otherwise null.
     */
    private final Interview parent;

    /**
     * The root (most parent) interview; never null
     */
    private final Interview root;

    private String baseTag; // tag relative to parent

    private String tag; // full tag: parent tag + baseTag

    /**
     * A descriptive title for the interview.
     */
    private String title;
    
    /**
     * The first question of the interview.
     */
    private Question firstQuestion;


    /**
     * Any child interviews.
     */
    private Vector children = new Vector();

    /**
     * An index of the questions in this interview.
     */
    private Map allQuestions = new HashMap();

    /**
     * The default image for questions in the interview.
     */
    private URL defaultImage;

    private String bundleName;
    private ResourceBundle bundle;

    private Path path;
    private int currIndex;
    private InterviewQuestion caller;
    private boolean updateEnabled;
    private boolean edited;

    private Map allMarkers;
    private HashMap extraValues;        // used in top-level interview only
    private HashMap templateValues;     

    private int semantics = SEMANTIC_PRE_32;

    static final ResourceBundle i18n = ResourceBundle.getBundle("com.sun.interview.i18n");

    /**
     * Where necessary, the harness interview should behave as it did before the
     * 3.2 release.  This does not control every single possible change in
     * behavior, but does control certain behaviors which may cause problems with
     * interview code written against an earlier version of the harness.
     * @see #setInterviewSemantics
     */
    public static final int SEMANTIC_PRE_32 = 0;

    /**
     *
     * Where necessary, the harness interview should behave as it did for the
     * 3.2 release.  This does not control every single possible change in
     * behavior, but does control certain behaviors which may cause problems with
     * interview code written against an earlier version of the harness.
     * @see #setInterviewSemantics
     */
    public static final int SEMANTIC_VERSION_32 = 1;

    /**
     * The highest version number currently in use.  Note that the compiler
     * will probably inline this during compilation, so you will be locked at
     * the version which you compile against.  This is probably a useful
     * behavior in this case.
     * @see #setInterviewSemantics
     */
    public static final int SEMANTIC_MAX_VERSION = 1;

    static class Path {
	void addQuestion(Question q) {
	    if (questions == null) 
		questions = new Question[10];
	    else if (numQuestions == questions.length) {
		Question[] newQuestions = new Question[2 * questions.length];
		System.arraycopy(questions, 0, newQuestions, 0, questions.length);
		questions = newQuestions;
	    }

	    questions[numQuestions++] = q;
	}

	Question questionAt(int index) throws ArrayIndexOutOfBoundsException {
	    if (index < 0 || index >= numQuestions)
		throw new ArrayIndexOutOfBoundsException();
	    return questions[index];
	}
	
	Question lastQuestion() {
	    return questionAt(numQuestions - 1);
	}

	int indexOf(Interview interview) {
	    for (int index = 0; index < numQuestions; index++) {
		Question q = questions[index];
		if (q instanceof InterviewQuestion
		    && ((InterviewQuestion) q).getTargetInterview() == interview)
		    return index;
	    }
	    return -1;
	}

	int size() {
	    return numQuestions;
	}

	void setSize(int newSize) {
	    // expected case is only to shrink size, so questions != null && newSize < questions.length
	    if (questions != null) {
		if (newSize > questions.length) {
		    Question[] newQuestions = new Question[newSize];
		    System.arraycopy(questions, 0, newQuestions, 0, questions.length);
		    questions = newQuestions;
		    
		}
		for (int i = newSize; i < numQuestions; i++)
		    questions[i] = null;
	    }
	    else if (newSize > 0)
		questions = new Question[newSize];

	    numQuestions = newSize;
	}
		

	void clear() {
	    for (int i = 0; i < numQuestions; i++)
		questions[i] = null;
	    numQuestions = 0;
	}

	private Question[] questions;
	private int numQuestions;
    }
    
    protected final static String QUESTION = "QUESTION";
    protected final static String INTERVIEW = "INTERVIEW";
    protected final static String LOCALE = "LOCALE";
    protected final static String CHECKSUM = "CHECKSUM";
    protected final static String MARKERS = "MARKERS";
    protected final static String MARKERS_PREF = "MARKERS.";
    protected static final String EXTERNAL_PREF = "EXTERNAL.";
    protected static final String TEMPLATE_PREF = "TEMPLATE.";
    
}
