package ca.ucalgary.services;

import ca.ucalgary.services.util.CGIUtils;
import ca.ucalgary.services.util.XHTMLForm;

import org.w3c.tidy.Tidy; //html parser and fixer
import org.w3c.dom.*;

import org.apache.commons.httpclient.*;
import org.apache.commons.httpclient.methods.*;
import org.apache.commons.httpclient.methods.multipart.*;
import org.apache.commons.httpclient.params.*;

import org.apache.commons.fileupload.FileItemIterator;
import org.apache.commons.fileupload.FileItemStream;
import org.apache.commons.fileupload.FileUploadBase.SizeLimitExceededException;
import org.apache.commons.fileupload.servlet.ServletFileUpload;
import org.apache.commons.io.IOUtils;

import javax.servlet.http.*;

import java.io.InputStream;
import java.io.PrintStream;
import java.net.URL;
import java.util.*;
import java.util.logging.*;


/**
 * Provides an HTML interface for invoking CGI. The appearance and functionality of the class
 * can be extended by registering a DataRecorder (e.g. Daggoo registers PBERecorder to get 
 * its functionality), please see the documentation at 
 * http://biomoby.open-bio.org/CVS_CONTENT/moby-live/Java/docs/soapServlet.html
 */

public class CGIServlet extends WrappingServlet{

    // Passes along the list of hidden-type CGI input fields to the POST-based wrapping process
    public static final String HIDDEN_PARAM = "_CGIServlet_hidden_params";

    private static Logger logger = Logger.getLogger(CGIServlet.class.getName());

    // Presents the CGI form as-is, but with javascript events and redirection of form submission
    protected void writeServiceForm(HttpServletRequest request,
				    HttpServletResponse response,
				    URL url,
				    PrintStream out){


	// Retrieve and instrument the CGI form
	InputStream is = null;
	try{
	    is = url.openStream();
	} catch(Exception e){
	    out.print("<html><head><title>Parsing Error</title>\n"+
		      "<link type=\"text/css\" rel=\"stylesheet\" href=\"stylesheets/cgi_err.css\" />\n" +
		      "</head><body><h2>The URL specified (" + url + ") could not be opened</h2><br><pre>");
	    e.printStackTrace(out);
	    out.print("</pre></body></html>\n");
	    return;
	}

        Tidy tidy = new Tidy();
	tidy.setXHTML(true);
	tidy.setQuiet(true);
	tidy.setShowWarnings(false);
	Document htmlDoc = tidy.parseDOM(is, null);

	// set the href base in the html head so other parts of the page spec'ed as relative URLs show up alright
	// (if not already set)
	Element head = null;
	NodeList heads = htmlDoc.getElementsByTagName("head");
	if(heads == null || heads.getLength() == 0){
	    out.print("<html><head><title>Parsing Error</title>\n"+
		      "<link type=\"text/css\" rel=\"stylesheet\" href=\"stylesheets/cgi_err.css\" />\n" +
		      "</head><body><h2>The document at the specified URL (" + url + ") does not have " +
		      "an HTML head element</h2></body></html>");
	    return;
	}
	head = (Element) heads.item(0);

	NodeList base = head.getElementsByTagName("base");
	if(base == null || base.getLength() == 0 || 
	   ((Element) base.item(0)).getAttribute("href") == null ||
	   ((Element) base.item(0)).getAttribute("href").length() == 0){ // could have just a <base target=""> tag
	    String baseString = null;
	    try{
		baseString = (new URL(url, "")).toString();
	    } catch(Exception e){
		out.print("<html><head><title>Parsing Error</title>\n"+
			  "<link type=\"text/css\" rel=\"stylesheet\" href=\"stylesheets/cgi_err.css\" />\n" +
			  "</head><body><h2>The URL to be shown (" + url + ") could not be reparsed in order to set the base href" +
			  "(bizzare, because we were able to load the URL contents in the first place...)</h2></body></html>");
		return;
	    }
	    Element baseEl = htmlDoc.createElement("base");
	    baseEl.setAttribute("href", baseString); //gets page's URL context
	    if(head.getFirstChild() != null){
		// put as first child so CSS etc. declared in head resolve properly too
		head.insertBefore(baseEl, head.getFirstChild());
	    }
	    else{
		head.appendChild(baseEl); // insert into blank head
	    }
	    if(recorder != null){
		for(Node newHeadNode: recorder.getHeadAsDOM(request, htmlDoc)){
		    head.appendChild(newHeadNode);
		}
	    }
	}

	Element body = null;
	NodeList bodies = htmlDoc.getElementsByTagName("body");
	if(bodies == null || bodies.getLength() == 0){
	    out.print("<html><head><title>Parsing Error</title>\n"+
		      "<link type=\"text/css\" rel=\"stylesheet\" href=\"stylesheets/cgi_err.css\" />\n" +
		      "</head><body><h2>The document at the specified URL (" + url + ") does not have " +
		      "an HTML body element</h2></body></html>");
	    return;
	}
	body = (Element) bodies.item(0);
	if(recorder != null){
	    for(Attr newBodyAttr: recorder.getBodyAttrsAsDOM(request, htmlDoc)){
		String existingEvent = body.getAttribute(newBodyAttr.getName());
		if(existingEvent == null || existingEvent.trim().length() == 0){
		    body.setAttributeNode(newBodyAttr);
		}
		else{
		    // string existing and new JS (or CSS) statements together with a semi-colon
		    body.setAttribute(newBodyAttr.getName(), existingEvent+";"+newBodyAttr.getValue());
		}
	    }
	    Node[] newBodyNodes = recorder.getBodyAsDOM(request, htmlDoc);
	    if(body.getFirstChild() == null){
		body.appendChild(htmlDoc.createComment("blank document body"));
	    }
	    for(int i = newBodyNodes.length-1; i >= 0; i--){
		// this way they end up in the DOM in the same order as in the array
		body.insertBefore(newBodyNodes[i], body.getFirstChild());
	    }
	}

	// change the form submission target.  Doing this after the event handler adding so we 
	// don't add events to our own hidden inputs
	NodeList forms = htmlDoc.getElementsByTagName("form");
	for(int i = 0; i < forms.getLength(); i++){
	    Element form = (Element) forms.item(i);

	    //resolve location of action relative to form's URL
	    URL origFormAction = null;
	    try{
		origFormAction = new URL(url, form.getAttribute("action")); 
	    } catch(Exception e){
		logger.log(Level.WARNING, "Could not parse the form action URL ("+
			   form.getAttribute("action")+"), skipping the form:", e);
		continue;
	    }
	    
	    // add the javascript event handlers needed by the data recorder
	    MyNodeList inputs = new MyNodeList();
	    inputs.add(form.getElementsByTagName("input"));
	    inputs.add(form.getElementsByTagName("select"));
	    inputs.add(form.getElementsByTagName("textarea"));
	    if(recorder != null && recorder.getOnEventAsDOM(htmlDoc) != null){
		List<String> hiddenParamNames = new ArrayList<String>();
		for(int j = 0; j < inputs.getLength(); j++){
		    Attr eventAttr = recorder.getOnEventAsDOM(htmlDoc);  //need new Node each time to not confuse doc
		    Element input = (Element) inputs.item(j);
		    
		    if(input.getNodeName().equals("input") &&
		       "hidden".equals(input.getAttribute("type"))){
			// note for later use by the wrapping system
			hiddenParamNames.add(input.getAttribute("name"));
		    }
		    // only need to instrument interactive form fields: note this includes those hidden by CSS
		    else{
			//See if there is already a Javascript event.  If so append, if not, add one.
			String existingEvent = input.getAttribute(eventAttr.getName());
			if(existingEvent == null || existingEvent.trim().length() ==0){
			    // just set it, not cloberring anything
			    input.setAttributeNode(eventAttr);
			}
			else{
			    // separate JS statements with a semi-colon
			    input.setAttribute(eventAttr.getName(), existingEvent+";"+eventAttr.getValue());
			}
		    }
		}
		form.appendChild(createHiddenParamsField(hiddenParamNames, htmlDoc));
	    }
	    // redirect form submission action to wrapping servlet
	    form.setAttribute("action", request.getRequestURL().toString());

	    String origFormMethod = form.getAttribute("method");
	    if(origFormMethod == null || origFormMethod.trim().length() == 0){
		origFormMethod = "GET"; // from html4.01 spec
	    }

	    // Make all forms POST for purposes of wrapping
	    form.setAttribute("method", "POST");

	    String origFormEncodingType = form.getAttribute("enctype");
	    if(origFormEncodingType == null || origFormEncodingType.trim().length() == 0){
		origFormEncodingType = XHTMLForm.URLENCODED; // from html4.01 spec
	    }

	    form.setAttribute("enctype", XHTMLForm.MULTIPART); //this'll handle file uploads too, safest bet

	    // Submit the original CGI settings as a hidden parameter so we can reconstruct the correct
	    // request to forward later.
	    Element hiddenSrcInput = htmlDoc.createElement("input");
	    hiddenSrcInput.setAttribute("type", "hidden");
	    hiddenSrcInput.setAttribute("name", SRC_PARAM);
	    hiddenSrcInput.setAttribute("value", url.toString());
	    form.appendChild(hiddenSrcInput);

	    // actionSpec encodes the invocation system for the CGI.  
	    // The format is defined by ca.ucalgary.services.DaggooRegistration
	    String actionSpec = origFormAction.toString()+" "+origFormMethod+" "+origFormEncodingType;

	    Element hiddenSpecInput = htmlDoc.createElement("input");
	    hiddenSpecInput.setAttribute("type", "hidden");
	    hiddenSpecInput.setAttribute("name", SERVICE_SPEC_PARAM);
	    hiddenSpecInput.setAttribute("value", actionSpec);
	    form.appendChild(hiddenSpecInput);

	    // Add the recorder's onSubmit event
	    if(recorder != null && recorder.getOnSubmitAsDOM(htmlDoc) != null){
		Attr submitEventAttr = recorder.getOnSubmitAsDOM(htmlDoc);
		//See if there is already a Javascript event.  If so append, if not, add one.
		String existingEvent = form.getAttribute(submitEventAttr.getName());
		if(existingEvent == null || existingEvent.trim().length() ==0){
		    // just set it, not cloberring anything
		    form.setAttributeNode(submitEventAttr);
		}
		else{
		    // separate JS statements with a semi-colon.  
		    // Put our event first in case the existing one returns false and does AJAX-y stuff
		    form.setAttribute(submitEventAttr.getName(), submitEventAttr.getValue()+";"+existingEvent);
		}
	    }
	}

	//todo: inputs.add(htmlDoc.getElementsByTagName("button"));

	// print the instrumented doc to the client
	tidy.pprint(htmlDoc, out);
	
    }

    // Info to pass along from CGI form creator to service wrapper about which fields aren't user editable
    // The field that lists the hidden fields is itself a hidden field, just to make it interesting.
    // Note to Bertrand Russell: this field created here is not included in the list of hidden fields.
    private Element createHiddenParamsField(List<String> hiddenFields, Document owner){
	Element formInput = owner.createElement("input");
	formInput.setAttribute("name", HIDDEN_PARAM);
	formInput.setAttribute("type", "hidden");
	formInput.setAttribute("value", XHTMLForm.join(" ", hiddenFields.toArray(new String[hiddenFields.size()])));
	return formInput;
    }

    protected void callService(HttpServletRequest request,
			       HttpServletResponse response,
			       URL cgiFormURL,
			       PrintStream out){

        if(!ServletFileUpload.isMultipartContent(request)){
	    out.print("<html><head><title>Error</title>\n"+
		      "<link type=\"text/css\" rel=\"stylesheet\" href=\"stylesheets/cgi_err.css\" />\n"+
		      "</head><body>The request was expected to be '"+
		      XHTMLForm.MULTIPART+"' encoded</body></html>");
            return;
        }

	String serviceSpec = null;
	// list of params that are file uploads instead of regular form fields
	ArrayList<String> fileTypeParams = new ArrayList<String>();
	ArrayList<String> hiddenParamsList = new ArrayList<String>();
	Map<String,byte[]> formDataInstanceMap = new HashMap<String,byte[]>();
        try {
            ServletFileUpload upload = new ServletFileUpload();

            FileItemIterator iterator = upload.getItemIterator(request);
            while (iterator.hasNext()) {
                FileItemStream item = iterator.next();
                InputStream in = item.openStream();

                try{
		    if(item.getFieldName().equals(SERVICE_SPEC_PARAM)){
			//do this because DataRecorder will expect it
			serviceSpec = IOUtils.toString(in);
			if(recorder != null){
			    recorder.setParameter(request.getSession(true), SERVICE_SPEC_PARAM, serviceSpec);
			}
			continue;
		    }
		    else if(item.getFieldName().equals(SRC_PARAM)){
			//do this because DataRecorder will expect it
			if(recorder != null){
			    recorder.setParameter(request.getSession(true), SRC_PARAM, IOUtils.toString(in));
			}
			continue;
		    }
		    // The wrapping form passes along the list of hidden input fields 
		    // so that we can not show these later in the wrapping process also.
		    else if(item.getFieldName().equals(HIDDEN_PARAM)){
			for(String hiddenParamName: IOUtils.toString(in).split(" ")){
			    hiddenParamsList.add(hiddenParamName);
			}
			continue;
		    }
		    if(!item.isFormField()){ //is a file upload
			fileTypeParams.add(item.getFieldName());
		    }
		    formDataInstanceMap.put(item.getFieldName(), IOUtils.toByteArray(in));
                } finally {
                    IOUtils.closeQuietly(in);
                }
            }
        } catch (Exception e) {
	    out.print("<html><head><title>Error</title>\n"+
		      "<link type=\"text/css\" rel=\"stylesheet\" href=\"stylesheets/cgi_err.css\" />\n"+
		      "</head><body>Encountered an exception while parsing the submitted data:\n<pre>");
	    e.printStackTrace(out);
	    out.print("</pre></body></html>");
	    return;
	}

	if(serviceSpec == null || serviceSpec.trim().length() == 0){
	    out.print("<html><head><title>Error</title>\n"+
		      "<link type=\"text/css\" rel=\"stylesheet\" href=\"stylesheets/cgi_err.css\" />\n"+
		      "</head><body>No '"+SERVICE_SPEC_PARAM+"' parameter (specifying " +
		      "the action/method/enctype) was specified in the POST request</body></html>");
	    return;
	}
	String[] serviceSpecs = serviceSpec.split(" ");
	if(serviceSpecs.length != 3){
	    out.print("<html><head><title>Error</title>\n"+
		      "<link type=\"text/css\" rel=\"stylesheet\" href=\"stylesheets/cgi_err.css\" />\n"+
		      "</head><body>The '"+SERVICE_SPEC_PARAM+"' parameter (specifying " +
		      "the action/method/enctype) did not contain 3 space-separated values as expected, " +
		      "but rather '"+serviceSpecs+"'</body></html>");
	    return;
	}

	if(recorder != null){  //PBE needs to know what the input looked like
	    recorder.setInputParams(request, formDataInstanceMap, hiddenParamsList);
	}

	HttpMethod method;
	if("POST".equals(serviceSpecs[1].toUpperCase())){
	    method = new PostMethod(serviceSpecs[0]);
	    if(XHTMLForm.MULTIPART.toLowerCase().equals(serviceSpecs[2].toLowerCase())){
		((PostMethod) method).setRequestEntity(CGIUtils.getMultipartRequest(formDataInstanceMap, 
										    method.getParams(),
										    fileTypeParams));
	    }
	    else{
		((PostMethod) method).setRequestBody(CGIUtils.getNameValuePairs(formDataInstanceMap));
	    }
	    try{logger.log(Level.INFO, serviceSpecs[1]+" request is " + method.getURI());}
	    catch(URIException urie){logger.log(Level.WARNING, 
						"Could not get URI of POST request (should be " + 
						serviceSpecs[0]+")", 
						urie);}
	}
	// If not POST, assume GET
	else{
	    try{
		method = new GetMethod(serviceSpecs[0]+"?"+CGIUtils.getURLQuery(formDataInstanceMap));
		logger.log(Level.INFO, serviceSpecs[1]+" request is " + method.getURI());
	    } catch(URIException urie){
		out.print("<html><head><title>Error</title>\n"+
		      "<link type=\"text/css\" rel=\"stylesheet\" href=\"stylesheets/cgi_err.css\" />\n"+
			  "</head><body>Encountered a problem encoding the CGI input data for GET:<pre>");
		urie.printStackTrace(out);
		out.print("</pre></body></html>");
		return;
	    }
	}

	int statusCode;
	byte[] responseBody;
	// Send the request via HTTP
	try {
	    HttpClient httpClient = new HttpClient();

	    // Execute the method
	    statusCode = httpClient.executeMethod(method);
	    
	    // Read the response body
	    responseBody = method.getResponseBody();

	} catch (HttpException he) {
	    logger.log(Level.SEVERE, "Fatal protocol violation: ", he.getMessage());
	    out.print("<html><head><title>Error</title>\n"+
		      "<link type=\"text/css\" rel=\"stylesheet\" href=\"stylesheets/cgi_err.css\" />\n"+
		      "</head><body>The CGI call encountered a fatal HTTP protocol error:<pre>");
	    he.printStackTrace(out);
	    out.print("</pre></body></html>");
	    return;

	} catch (java.io.IOException ioe) {
	    logger.log(Level.SEVERE, "Fatal transport error: " + ioe.getMessage());
	    out.print("<html><head><title>Error</title>\n"+
		      "<link type=\"text/css\" rel=\"stylesheet\" href=\"stylesheets/cgi_err.css\" />\n"+
		      "</head><body>The CGI call encountered a fatal network I/O error:<pre>");
	    ioe.printStackTrace(out);
	    out.print("</pre></body></html>");
	    return;
	} finally {
	    // Release the connection.
	    method.releaseConnection();
	}

	if (statusCode != HttpStatus.SC_OK) {
	    logger.log(Level.SEVERE, "Fatal CGI error: " + method.getStatusLine());
	    out.print("<html><head><title>Error</title>\n"+
		      "<link type=\"text/css\" rel=\"stylesheet\" href=\"stylesheets/cgi_err.css\" />\n"+
		      "</head><body>The CGI call failed: <pre>"+
		      method.getStatusLine()+
		      "</pre></body></html>");
	    return;
	}

	String contentType = method.getResponseHeader("Content-type").getValue();
	response.setContentType(contentType);

	// If the page is html, set the base href so the page is rendered properly
	logger.log(Level.INFO, "The content type of the response is "+contentType);
	String encoding = null;
	if(contentType != null && contentType.indexOf("html") != -1){
	    logger.log(Level.INFO, "Setting the base href for the response for proper rendering");
	    encoding = response.getCharacterEncoding();
	    if(encoding == null){
		encoding = java.nio.charset.Charset.defaultCharset().name();
	    }
	    try{
		responseBody = (new String(responseBody, encoding))
                                .replaceFirst("<[Hh][Ee][Aa][Dd]>", "<head><base href=\""+serviceSpecs[0]+"\"/>").getBytes();
	    } catch(java.io.UnsupportedEncodingException uee){
		logger.log(Level.SEVERE, "Could not use character encoding ("+encoding+
			   ") of response, therefore cannot set base href in HTML response...sending response as-is", uee);
	    }
	}

	if(recorder != null){
	    byte[] answer = null;
	    try{
		answer = recorder.markupResponse(responseBody, contentType, encoding, request);
	    } catch(Exception e){
		out.print("<html><head><title>Error</title>\n"+
			  "<link type=\"text/css\" rel=\"stylesheet\" href=\"stylesheets/cgi_err.css\" />\n"+
			  "</head><body>Exception in DataRecorder subsystem:\n<pre>");
		e.printStackTrace(out);
		out.print("</pre></body></html>");
		return;
	    }
	    try{
		out.write(answer);
	    } catch(Exception e){
		logger.log(Level.SEVERE, "Could not print output to HTTP client", e);
	    }
	}
	// Passthrough as-is
	else{
	    try{
		out.write(responseBody);
	    } catch(Exception e){
		logger.log(Level.SEVERE, "Could not print output to HTTP client", e);
	    }	
	}
    }
}
