//   Copyright 2012 Georg-August-Universität Göttingen, Germany
//
//   Licensed under the Apache License, Version 2.0 (the "License");
//   you may not use this file except in compliance with the License.
//   You may obtain a copy of the License at
//
//       http://www.apache.org/licenses/LICENSE-2.0
//
//   Unless required by applicable law or agreed to in writing, software
//   distributed under the License is distributed on an "AS IS" BASIS,
//   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//   See the License for the specific language governing permissions and
//   limitations under the License.

package de.ugoe.cs.autoquest.plugin.html;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.apache.commons.codec.binary.Base64;
import org.xml.sax.SAXException;

import de.ugoe.cs.util.StringTools;
import de.ugoe.cs.util.console.Console;

/**
 * <p>
 * pseudomizes the text entered in text input events by replacing it with an MD5 hash. For this, it
 * parses a given file and dumps a replacement, in which all text input events have an MD5 hash
 * as entered text. If the events already have an MD5 hash, it stays unchanged. Providing the
 * parameters to the constructor, it can be decided to pseudomize text entries into file and search
 * input fields, as well.
 * </p>
 * 
 * @author Patrick Harms
 * @version 1.0
 * 
 */
public class HTMLLogTextInputPseudomizer extends AbstractDefaultLogParser {
    
    /**
     * <p>
     * Indicator if search input fields must be pseudomized, as well.
     * </p>
     */
    private boolean pseudomizeSearchInputs = false;
    
    /**
     * <p>
     * Indicator if file input fields must be pseudomized, as well.
     * </p>
     */
    private boolean pseudomizeFileInputs = false;
    
    /**
     * <p>
     * The output writer into which the pseudomized variant of the log file is written
     * </p>
     */
    private PrintWriter outputWriter;
    
    /**
     * <p>
     * The set of text input fields found in the GUI model
     * </p>
     */
    private Set<String> textInputFieldIds = new HashSet<String>();
    
    /**
     * <p>
     * the events that were read
     * </p>
     */
    private List<EventEntry> sortedEvents = new LinkedList<EventEntry>();

    /**
     * <p>
     * creates the input pseudomizer with the switches, if text inputs into file and search fields
     * shall be pseudomized, as well.
     * </p>
     *
     * @param pseudomizeSearchInputs true, if inputs into search fields shall be pseudomized, as
     *                               well; false else 
     * @param pseudomizeFileInputs   true, if inputs into file fields shall be pseudomized, as well;
     *                               false else 
     */
    public HTMLLogTextInputPseudomizer(boolean pseudomizeSearchInputs,
                                       boolean pseudomizeFileInputs)
    {
        this.pseudomizeSearchInputs = pseudomizeSearchInputs;
        this.pseudomizeFileInputs = pseudomizeFileInputs;
    }

    /**
     * <p>
     * called to pseudomize all text inputs in the given log file. The method reuses 
     * {@link #pseudomizeFile(File)}.
     * </p>
     * 
     * @param file the log file in which the text inputs must be pseudomized
     */
    public void pseudomizeFile(String file) {
        if (file == null) {
            throw new IllegalArgumentException("file must not be null");
        }

        pseudomizeFile(new File(file));
    }

    /**
     * <p>
     * called to pseudomize all text inputs in the given log file. The given file is read
     * completely. All GUI elements are written to an output file as they are. All events are
     * written to an output file as they are, as well, as long as they do not represent text inputs.
     * If they are text input events, the entered text is replaced by its hash value for
     * pseudomizing the text input. Finally, the original log file is deleted and replaced by
     * the pseudomized variant. Log files, which are already pseudomized, stay untouched.
     * </p>
     * 
     * @param file the log file in which the text inputs must be pseudomized
     */
    public void pseudomizeFile(File file) {
        if (file == null) {
            throw new IllegalArgumentException("file must not be null");
        }
        
        if (!file.exists()) {
            throw new IllegalArgumentException("file must denote an existing file");
        }
        
        if (!file.isFile()) {
            throw new IllegalArgumentException("file must denote a file");
        }
        
        File outFile = new File(file.getParentFile(), file.getName() + "_tmp");
        boolean parsingFailed = false;
        
        try {
            FileOutputStream fis = new FileOutputStream(outFile);
            outputWriter = new PrintWriter(new OutputStreamWriter(fis, "UTF-8"));
            outputWriter.println("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
            outputWriter.println("<session>");

            textInputFieldIds.clear();
            sortedEvents.clear();
            
            try {
                super.parseFile(file);
            }
            catch (SAXException e) {
                parsingFailed = true;
            }
            
            for (EventEntry event : sortedEvents) {
                event.dump();
            }
            
            outputWriter.println("</session>");
            outputWriter.flush();
        }
        catch (FileNotFoundException e) {
            Console.printerrln("could not create pseudomized file " + outFile);
        }
        catch (UnsupportedEncodingException e) {
            // this should never happen
            e.printStackTrace();
        }
        finally {
            if (outputWriter != null) {
                outputWriter.close();
                outputWriter = null;
            }
        }
        
        if (!parsingFailed && outFile.exists()) {
            if (!file.delete()) {
                Console.printerrln("could not delete pseudomized file " + file);
            }
            else if (!outFile.renameTo(file)) {
                Console.printerrln
                    ("could not rename pseudomized file to original file name " + file);
            }            
            else {
                Console.println("pseudomized file " + file);
            }
        }
        else {
            if (!outFile.delete()) {
                Console.printerrln("could not delete temporary file " + outFile);
            }
        }
    }
    
    /* (non-Javadoc)
     * @see de.ugoe.cs.autoquest.plugin.html.AbstractDefaultLogParser#parseFile(java.lang.String)
     */
    @Override
    public void parseFile(String filename) {
        throw new IllegalStateException("this method must not be called externally");
    }

    /* (non-Javadoc)
     * @see de.ugoe.cs.autoquest.plugin.html.AbstractDefaultLogParser#parseFile(java.io.File)
     */
    @Override
    public void parseFile(File file) {
        throw new IllegalStateException("this method must not be called externally");
    }

    /* (non-Javadoc)
     * @see de.ugoe.cs.autoquest.plugin.html.AbstractDefaultLogParser#handleGUIElement(String, Map)
     */
    @Override
    protected boolean handleGUIElement(String id, Map<String, String> parameters)
        throws SAXException
    {
        outputWriter.print("<component id=\"");
        outputWriter.print(id);
        outputWriter.println("\">");
        
        for (Map.Entry<String, String> param : parameters.entrySet()) {
            dumpParam(param.getKey(), param.getValue());
            
            if ("tagname".equals(param.getKey())) {
                if ("input_text".equals(param.getValue()) ||
                    (pseudomizeSearchInputs && "input_search".equals(param.getValue())) ||
                    (pseudomizeFileInputs && "input_file".equals(param.getValue())))
                {
                    textInputFieldIds.add(id);
                }
            }
        }
            
        outputWriter.println("</component>");
        
        return true;
    }

    /* (non-Javadoc)
     * @see de.ugoe.cs.autoquest.plugin.html.AbstractDefaultLogParser#handleEvent(String,Map)
     */
    @Override
    protected boolean handleEvent(String type, Map<String, String> parameters) throws SAXException {
        String timestampStr = parameters.get("timestamp");
        
        long timestamp = Long.MAX_VALUE;
        
        if (timestampStr != null) {
            timestamp = Long.parseLong(timestampStr);
        }
        
        EventEntry newEvent = new EventEntry(type, parameters, timestamp);
        
        int start = 0;
        int end = sortedEvents.size();
        int center = 0;
        long centerTimestamp;
        
        while (start != end) {
            center = start + ((end - start) / 2);
            
            if ((center != start) || (center != end)) {
                centerTimestamp = sortedEvents.get(center).timestamp;
            
                if (centerTimestamp < newEvent.timestamp) {
                    start = Math.max(center, start + 1);
                }
                else if (centerTimestamp > newEvent.timestamp) {
                    end = Math.min(center, end - 1);
                }
                else {
                    // add the event directly where the center is, as the timestamps of the center
                    // and the new event are equal
                    end = center;
                    start = end;
                    break;
                }
            }
            else {
                // add the event to the position denoted by the add index
                break;
            }
        }
        
        sortedEvents.add(start, newEvent);
        
        if ("onchange".equals(type)) {
            String targetId = parameters.get("target");
        
            if ((targetId != null) && textInputFieldIds.contains(targetId)) {
                String value = parameters.get("selectedValue");
                
                if ((value != null) && !value.endsWith("==")) {
                    try {
                        MessageDigest md = MessageDigest.getInstance("SHA-512");
                        md.update(value.getBytes("UTF-8"));
                        value =  Base64.encodeBase64String(md.digest());
                    }
                    catch (UnsupportedEncodingException e) {
                        throw new IllegalStateException("Java VM does not support this code");
                    }
                    catch (NoSuchAlgorithmException e) {
                        throw new IllegalStateException("Java VM does not support this code");
                    }
                    
                    parameters.put("selectedValue", value);
                }
            }
        }

        return true;
    }

    /**
     * <p>
     * dumps a parameter with the given name and value to the log file. The result is a
     * tag named param with a name attribute and a value attribute. The value is transformed
     * to a String if it is no String already. Furthermore, an XML entity replacement is performed
     * if required.
     * </p>
     *
     * @param name  the name of the parameter to be dumped
     * @param value the value of the parameter to be dumped
     */
    private void dumpParam(String name, Object value) {
        if (value == null) {
            return;
        }
        
        String val;
        
        if (value instanceof String) {
            val = (String) value;
        }
        else {
            val = String.valueOf(value);
        }
        
        outputWriter.print(" <param name=\"");
        outputWriter.print(name);
        outputWriter.print("\" value=\"");
        outputWriter.print(StringTools.xmlEntityReplacement(val));
        outputWriter.println("\"/>");
    }


    /**
     * <p>
     * this class is used internally for storing events in a sorted list together with the
     * timestamps, being the sort criteria. 
     * </p>
     */
    private class EventEntry {
        
        /**
         * <p>
         * the type of the event
         * </p>
         */
        private String type;
        
        /**
         * <p>
         * the parameters of the event
         * </p>
         */
        private Map<String, String> parameters;
        
        /**
         * <p>
         * the timestamp of the event
         * </p>
         */
        private long timestamp;

        /**
         * <p>
         * creates a new event entry with event type, parameters and the timestamp
         * </p>
         */
        private EventEntry(String type, Map<String, String> parameters, long timestamp) {
            this.type = type;
            this.parameters = parameters;
            this.timestamp = timestamp;
        }
        
        /**
         * <p>
         * convenience method for dumping the event into the compressed log file
         * </p>
         */
        private void dump() {
            outputWriter.print("<event type=\"");
            outputWriter.print(type);
            outputWriter.println("\">");
            
            for (Map.Entry<String, String> param : parameters.entrySet()) {
                dumpParam(param.getKey(), param.getValue());
            }
            
            outputWriter.println("</event>");
        }
    }
}
