package org.biomoby.shared.data;

import org.biomoby.registry.meta.Registry;
import org.biomoby.shared.*;
import org.biomoby.shared.parser.MobyTags;
import org.biomoby.shared.parser.ServiceException;
import org.w3c.dom.*;
import java.util.*;  //Java Collection Framework members used
import java.io.PrintStream;

/**
 * This class represents the data package sent back and forth between clients and servers.
 * Currently, this corresponds to the "mobyContent" element and children in the MOBY API.
 * It holds a named collection of data groups, which could correspond to either queries or 
 * responses depending on its use.  It contains also two ancillary pieces of information
 * populated by service providers: the service notes (generally, human readable text 
 * describing the service), and the authority URI (points to more info about the service).
 *
 * This class implements MobyDataInstance because it does have an XML representation of
 * MOBY instance data, but using it in CENTRAL_XML_MODE does not currently have a 
 * solidly defined behaviour (what if it's a mixed bag of different data?). 
 *
 * The Map interface is implemented to make it easier to fetch, enumerate, and merge 
 * content objects and their subgroups.
 */
public class MobyContentInstance implements Map<String, MobyDataJob>{

    private static boolean debug = false;
    private static PrintStream debugPS = System.err;

    private String serviceAuthURI = null;
    private String serviceNotes = null;
    private int xmlMode = MobyDataInstance.SERVICE_XML_MODE;
    private LinkedHashMap<String, MobyDataJob> members;
    private int autoID = 1;  // to name members without existing names
    private Vector<ServiceException> exceptions;

    /**
     * Creates a blank MOBY envelope, to be filled in programatically with data instances.
     * Useful if you are a client composing a query, or a server composing a response.
     */
    public MobyContentInstance(){
	members = new LinkedHashMap<String, MobyDataJob>();
	exceptions = new Vector<ServiceException>();
    }

    /**
     * A convenience constructor when you want to create an envelope with just one object in it.
     * This object will be put into its own data group (mobyData tag), and automatically assigned a
     * queryID.  If paramName is null, the parameter is assigned an empty string for articleName.
     * For now, single parameter requests can have empty articleNames in jMOBY.  This may change 
     * in the future.
     *
     * @throws MobyException if the passed in object is not a MobyDataObject or a MobyDataObjectSet or null (an empty data group)
     */
    public MobyContentInstance(MobyDataInstance mdi, String paramName) throws MobyException{
	this();
	if(mdi != null &&
	   !(mdi instanceof MobyDataSecondaryInstance) && 
	   !(mdi instanceof MobyDataObject) && 
	   !(mdi instanceof MobyDataObjectSet)){
	    throw new MobyException("The input Moby data instance class (" + mdi.getClass().getName() + 
				    ") was neither MobyDataObject nor MobyDataObjectSet nor " +
				    "MobyDataSecondaryInstance as required");
	}
	MobyDataJob queryParams = new MobyDataJob();
	if(mdi != null){
	    if(paramName == null){
		paramName = "";  // Cannot have a null key in a hash
	    }
	    queryParams.put(paramName, mdi);
	}
	put(queryParams);
    }

    /**
     * Builds a MobyContentInstance (i.e. one or more MOBY queries or responses) using
     * a DOM-parsed XML MOBY envelope (i.e. the mobyContent tag).  This is useful if you 
     * want to parse a response from a server, or if you are a server and you want to parse
     * an incoming query.  The resulting object can be modified using the standard Map
     * interface methods, with queryID as key, and MobyDataInstance Vectors (mobyData) as values.
     */
    public MobyContentInstance(Element objectTag) throws MobyException{
	this(objectTag, null);
    }

    public MobyContentInstance(Element objectTag, Registry registry) throws MobyException{
	this();
	if(!MobyTags.MOBYCONTENT.equals(objectTag.getLocalName())){
	    throw new MobyException("The content element provided (" + 
				    objectTag.getLocalName() +
				    ") was not " + MobyTags.MOBYCONTENT);
	}
	if(!MobyPrefixResolver.MOBY_XML_NAMESPACE.equals(objectTag.getNamespaceURI())){
	    if(!MobyPrefixResolver.MOBY_XML_NAMESPACE_INVALID.equals(objectTag.getNamespaceURI())){
		throw new MobyException("The content element's namespace (" + 
					objectTag.getNamespaceURI() +
					") did not have the MOBY namespace " + 
					MobyPrefixResolver.MOBY_XML_NAMESPACE);
	    }
	    else{
		System.err.println("Invalid namespace used for content element (was " + 
				   objectTag.getNamespaceURI() + 
				   ", but should be " + MobyPrefixResolver.MOBY_XML_NAMESPACE + 
				   ", proceeding anyway");
	    }
	}
	
	members = new LinkedHashMap<String,MobyDataJob>();

	// If we got this far, we're in the clear for the single envelope
	// What we need to parse now is the one or more sets of data in the envelope
	NodeList mobyData = MobyPrefixResolver.getChildElements(objectTag, 
								MobyTags.MOBYDATA);

	// The case below is no longer true: an empty content block is a "ping" request as per mailing list 2006-08
// 	if(mobyData.getLength() == 0){
// 	    throw new MobyException("The document's " + MobyTags.MOBYCONTENT +
// 				    " element does not have a " + MobyTags.MOBYDATA + 
// 				    " child. It must have one or more according to the MOBY API");
// 	}

	// Now back to the main data
	for(int i = 0; i < mobyData.getLength(); i++){
	    Element dataGroup = (Element) mobyData.item(i);
	    if(dataGroup == null){
		System.err.println("Warning: found null element in DOM results (very strange)");
		continue;
	    }
	    parseDataGroup(dataGroup, registry);
	}

	// If we got to this stage, we're okay in the sytax department.
	// Start to populate the ancillary service data
	try{
	    setServiceAuthorityURI(objectTag.getAttributeNS(MobyPrefixResolver.MOBY_XML_NAMESPACE,
							    MobyTags.AUTHORITY));
	    
	}
	catch(DOMException dome){
	    System.err.println("Warning: DOM exception while looking for " + MobyTags.AUTHORITY + 
			       " attribute in tag " + MobyTags.MOBYCONTENT);
	}

	// Concatenate all serviceNotes.  Because service notes often contain markup such as HTML,
	// we will convert all of the subtrees into a string, not just the direct child text and CDATA elements.
	NodeList notes = MobyPrefixResolver.getChildElements(objectTag, 
							     MobyTags.SERVICENOTES);
	if(notes.getLength() > 0){
	    StringBuffer notesText = new StringBuffer();
	    boolean hasNotes = false;
	    for(int i = 0; i < notes.getLength(); i++){
		Element noteTag = (Element) notes.item(i);
		// The human readable stuff
		NodeList noteparts = MobyPrefixResolver.getChildElements(noteTag, MobyTags.NOTES);
		for(int j = 0; j < noteparts.getLength(); j++){
		    notesText.append(((Element) noteparts.item(j)).getTextContent());
		    hasNotes = true;
		}
		// The formatted exception stuff
		NodeList exs = MobyPrefixResolver.getChildElements(noteTag, MobyTags.MOBYEXCEPTION);
		for(int j = 0; j < exs.getLength(); j++){
		    addException(new MobyServiceException((Element) exs.item(j)));
		}
	    }
	    if(hasNotes){
		setServiceNotes(notesText.toString());
	    }
	}
	
    }

    /**
     * @param mode if true, debugging information is printed to the stream returned by getDebugOutputStream
     */
    public static void setDebugMode(boolean mode){
	debug = mode;
    }

    /**
     * Standard error is used unless this method is called.
     *
     * @param ps the OutputStream to which debugging information is sent.
     * @throws IllegalArgumentException if the stream is null
     */
    public static void setDebugPrintStream(PrintStream ps) throws IllegalArgumentException{
	if(ps == null){
	    throw new IllegalArgumentException("The OutputStream specified to MobyContentInstance was null");
	}
	debugPS = ps;
    }

    public void parseDataGroup(Element dataGroupTag, Registry registry) throws MobyException{
	String groupID = null;
	MobyDataJob job = new MobyDataJob();

	groupID = dataGroupTag.getAttributeNS(MobyPrefixResolver.MOBY_XML_NAMESPACE, MobyTags.QUERYID);
	if(groupID == null){
	    groupID = dataGroupTag.getAttributeNS(MobyPrefixResolver.MOBY_XML_NAMESPACE_INVALID, MobyTags.QUERYID);
	}
	if(groupID == null || groupID.length() == 0){
	    groupID = dataGroupTag.getAttributeNS("", MobyTags.QUERYID);
	}
	if(groupID == null || groupID.length() == 0){
	    groupID = dataGroupTag.getAttributeNS(null, MobyTags.QUERYID);
	}
	// NOT DEFINED AT ALL, ASSIGN ONE AUTOMATICALLY INSTEAD
	if(groupID == null){
	    groupID = "" + autoID++;
	}
	job.setID(groupID);

	// Now parse the contents of the group
	NodeList collections = MobyPrefixResolver.getChildElements(dataGroupTag, "Collection");
	for(int j = 0; collections != null && j < collections.getLength(); j++){
	    if(debug && j == 0){
		debugPS.println("There are " + collections.getLength() + 
				" collections in response " + groupID);
	    }
	    Element collectionTag = (Element) collections.item(j);
	    MobyDataObjectSet collection = (MobyDataObjectSet) (MobyDataObject.createInstanceFromDOM(collectionTag, registry));
	    
	    // Add completed collection to the output list
	    job.put(collection.getName(), collection);

	}  // Done fetching collections
	
	// Take all the top level simples and add them to the list
	NodeList simples = MobyPrefixResolver.getChildElements(dataGroupTag, "Simple");
	if((collections == null || collections.getLength() == 0) &&
	   (simples == null || simples.getLength() == 0)){
	    debugPS.println("WARNING: There appears to be no output data in the " + MobyTags.MOBYDATA);
	}
	for(int j = 0; simples != null && j < simples.getLength(); j++){
	    if(debug && j == 0){
		debugPS.println("There are " + simples.getLength() + " simples in the response");
	    }
	    String name = MobyPrefixResolver.getAttr((Element) simples.item(j), "articleName");
	    // A named member.  Die if there is more than one with the same name
	    if(name != null){
		if(job.containsKey(name)){
		    throw new MobyException("Illegal XML: there is more than one tag in the response " + 
					    groupID + " with the articleName " + name);
		}
		job.put(name, MobyDataObject.createInstanceFromDOM((Element) simples.item(j), registry));
	    }
	    // No anonymous data member yet
	    else if(!job.containsKey("")){
		job.put("", MobyDataObject.createInstanceFromDOM((Element) simples.item(j), registry));
	    }
	    else{
		debugPS.println("More than one anonymous member, ignoring simple #" + j);
	    }
	}
	
	// Now add the secondary parameters
	NodeList parameters = MobyPrefixResolver.getChildElements(dataGroupTag, "Parameter");
	for(int j = 0; parameters != null && j < parameters.getLength(); j++){
	    if(debug && j == 0){
		debugPS.println("There are " + parameters.getLength() + " parameters in the response");
	    }
	    MobyDataSecondaryInstance paramObject = (MobyDataSecondaryInstance) 
		MobyDataObject.createInstanceFromDOM((Element) parameters.item(j));
	    job.put(paramObject.getName(), paramObject);
	}
	
	put(groupID, job);
    }

    /**
     * This is a convenience method that allows the user extract all Objects from all
     * content job subgroups (simples and collections) into a single Collection, removing duplicates.
     */
    public MobyDataObjectSet retrieveObjects(){
	MobyDataObjectSet contents = new MobyDataObjectSet("");
	
	Iterator i = keySet().iterator();
	while(i.hasNext()){
	    String responseName = (String) i.next();

	    MobyDataJob response = (MobyDataJob) get(responseName);
	    if(response == null){
		continue;
	    }

	    MobyDataInstance[] primaries = response.getPrimaryData();

	    for(int j = 0; primaries != null && j < primaries.length; j++){
		MobyDataInstance data = primaries[j];

		if(data instanceof MobyDataObject){
		    contents.add((MobyDataObject) data);
		}
		else if(data instanceof MobyDataObjectSet){
		    contents.addAll((MobyDataObjectSet) data);
		}
		else{
		    System.err.println("Encountered unexpected data type: " + data.getClass());
		}
	    }
	}
	return contents;	
    }

    public String getServiceAuthorityURI(){
	return serviceAuthURI;
    }

    public void setServiceAuthorityURI(String uri){
	serviceAuthURI = uri;
    }

    /**
     * @return the human readable message (e.g. citation) to associate with these results
     */
    public String getServiceNotes(){
	return serviceNotes;
    }

    /**
     * Note this is only the human readable notes. Although exceptions are part of the notes
     * element in the MOBY XML, they are logically separated in this object.
     *
     * @param notes the human readable message (e.g. citation) to associate with these results 
     */
    public void setServiceNotes(String notes){
	serviceNotes = notes;
    }

    /**
     * Sets the exception list for the content <i>en masse</i>, replacing any current values
     *
     * @param mes the exceptions to associate with the content, or null to remove any existing exceptions
     */
    public void setExceptions(ServiceException[] mes){
	exceptions.clear();
	for(int i = 0; mes != null && i < mes.length; i++){
	    exceptions.add(mes[i]);
	}
    }

    /**
     * Adds a new exception to the list of exceptions associated with the content
     */
    public void addException(ServiceException me){
	exceptions.add(me);
    }

    /**
     * @return the list of exceptions associated with this content
     */
    public ServiceException[] getExceptions(){
	return (ServiceException[]) exceptions.toArray(new ServiceException[exceptions.size()]);
    }

    /**
     * Indicates whether the moby content has any exception (according to the style of MOBY-S RFC 1863)
     * 
     * @return true if there are exceptions (any severity) in the content
     */
    public boolean hasExceptions(){
	return exceptions != null && exceptions.size() > 0;
    }

    /**
     * Indicates whether the moby content has any exception of the given severity or worse 
     * (according to the style of MOBY-S RFC 1863).
     *
     * @param severity the minimum severity of exception (info < warning < error) to report
     *
     * @return true if there are exceptions (of the specified severity or greater) in the content
     */
    public boolean hasExceptions(int severity){
	if(exceptions == null || exceptions.size() == 0){
	    return false;
	}

	for(ServiceException se: exceptions){
	    if(se.getSeverity() <= severity){
		return true;
	    }
	}
	return false;
    }

    public void setXmlMode(int mode) throws IllegalArgumentException{
        if(mode != MobyDataInstance.CENTRAL_XML_MODE && mode != MobyDataInstance.SERVICE_XML_MODE){
	    throw new IllegalArgumentException("Value passed to setXmlMode was neither " +
					       "MobyDataInstance.CENTRAL_XML_MODE nor MobyDataInstance.SERVICE_XML_MODE");
	}
	xmlMode = mode;
    }

    /**
     * Report whether toXML will produce Central template or service call instance XML.
     *
     * @return one of CENTRAL_XML_MODE or SERVICE_XML_MODE
     */
    public int getXmlMode(){
	return xmlMode;
    }

    public String toString(){
	return toXML();
    }

    public String toXML(){
	StringBuffer xml = new StringBuffer("          <moby:" + MobyTags.MOBYCONTENT + 
					    (serviceAuthURI == null ? "" : 
					     " moby:"+MobyTags.AUTHORITY+"=\"" +serviceAuthURI + "\"") + 
					    ">\n");
	ServiceException[] exs = getExceptions();
	if(serviceNotes != null || exs.length != 0){
	    // Note: we should escape the service notes, in case they contain bad markup -> TODO
	    xml.append("    <moby:"+MobyTags.SERVICENOTES+">");
	    if(serviceNotes != null){
		xml.append("<moby:"+MobyTags.NOTES+">"+serviceNotes +"</moby:"+MobyTags.NOTES+">"); 
	    }
	    for(int i = 0; i < exs.length; i++){
		xml.append(exs[i].toXMLString());
	    }
	    xml.append("</moby:"+MobyTags.SERVICENOTES+">");
	}

	// print each query (or response as it may be)
	Iterator queryIter = keySet().iterator();
	while(queryIter.hasNext()){
	    // What's in the mobyData tag?
	    String queryName = (String) queryIter.next();
	    xml.append("    <moby:" + MobyTags.MOBYDATA + " "+MobyTags.QUERYID+"=\""+queryName+"\">");

	    AbstractMap queryParams = (AbstractMap) get(queryName);
	    // Print each parameter (the order is random)
	    Iterator paramIter = queryParams.keySet().iterator();

	    while(paramIter.hasNext()){
		String paramName = ((String) paramIter.next());
		MobyDataInstance dataObject = ((MobyDataInstance) queryParams.get(paramName));

		int oldXmlMode = dataObject.getXmlMode();
                if(oldXmlMode != MobyDataInstance.SERVICE_XML_MODE){
                    dataObject.setXmlMode(MobyDataInstance.SERVICE_XML_MODE);
                }

		if(dataObject instanceof MobyDataObject){
		    // This line should be replaced with a named field
		    xml.append("       <Simple articleName='" + paramName.replaceAll("'","&apos;") + "'>"+
			       dataObject.toXML()+
			       "</Simple>");
		}
		else if(dataObject instanceof MobyDataObjectSet){
		    String oldName = dataObject.getName();
		    dataObject.setName(paramName);
		    xml.append(dataObject.toXML());
		    dataObject.setName(oldName);
		}
		//  a secondary input parameter
		else{
		    xml.append(dataObject.toXML());
		}
                
                // Restore the old XML mode setting if not service mode
                if(oldXmlMode != MobyDataInstance.SERVICE_XML_MODE){
                    dataObject.setXmlMode(oldXmlMode);
                }
	    }

	    xml.append("    </moby:" + MobyTags.MOBYDATA + ">");	    
	}

	xml.append("          </moby:" + MobyTags.MOBYCONTENT + ">\n");
	return xml.toString();
    }

    // Below, to the end of the class file, are the methods that must be 
    // implemented to satisfy the ConcurrentMap interface

    /**
     * Effectively deletes the contents, leaving you with a data-less container.
     * This does not clear the service notes and authority URI members.
     */
    public void clear(){
	members.clear();
    }

    /**
     * To check for the presence of a data group with a given name
     */
    public boolean containsKey(Object fieldName){
	return members.containsKey(fieldName);
    }

    /**
     * To check for the presence of a data group as one of the members
     */
    public boolean containsValue(Object value){
	return members.containsValue(value);
    }

    /**
     * Retrieves each name/data group pair for the members of content.
     */
    //    public Set<Map.Entry<String,MobyDataObject>> entrySet(){
    public Set<Map.Entry<String,MobyDataJob>> entrySet(){
	return members.entrySet();
    }

    /**
     * Returns true if and only if both query sets have the same queries with the same values
     */
    public boolean equals(Object o){
	return members.equals(o);
    }

    /**
     * Retrieves a member (AbstractMap<ParamName,MobyDataInstance>) of the composite with a given field name.
     */
    public MobyDataJob get(Object fieldName){
	return members.get(fieldName);
    }

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

    /**
     * Is this a blank, uninstantiated object?
     */
    public boolean isEmpty(){
	return members.isEmpty();
    }

    /**
     * Retrieves a list of the query names in this object.  
     * NOTE: IF YOU DELETE OBJECTS FROM THIS SET, THEY ARE REMOVED FROM THE MOBY PAYLOAD TOO!
     */
    public Set<String> keySet(){
	return members.keySet();
    }

    /**
     * @param queryID a unique ID for the query. If null, or not a String, or blank it will be auto-generated
     * @param value an AbstractMap of <String, MobyDataInstance>, where the string is the parameter name
     */
    public MobyDataJob put(String queryID, MobyDataJob value){
	if(!(value instanceof AbstractMap)){
	    throw new IllegalArgumentException("Data passed as value of query " + 
					       queryID + 
					       "was not an AbstractMap as required in " + 
					       getClass());
	}

	if(queryID == null || queryID.length() == 0){
	    // autogenerate the queryID
	    return members.put(""+autoID++, value);
	}
	else{
	    return members.put(queryID, value);
	}
    }

    /**
     * same as put(String, MobyDataJob), but the queryID will be automatically generated for this value
     */
    public void put(MobyDataJob value){
	put(null, value);
    }

    /**
     * Sets a number of queries at once.
     */
    public void putAll(Map<? extends String,? extends MobyDataJob> map){
	members.putAll(map);
    }

    /**
     * Removes the query with the given name, if present
     *
     * @return the query removed
     */
    public MobyDataJob remove(Object fieldName){
	return members.remove(fieldName);
    }

    /**
     * Reports the number of queries
     */
    public int size(){
	return members.size();
    }

    /**
     * Returns all of the queries
     */
    public Collection<MobyDataJob> values(){
	return members.values();
    }
}
