//   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.jfc;

import java.awt.event.MouseEvent;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;

import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.SAXParseException;
import org.xml.sax.helpers.DefaultHandler;

import de.ugoe.cs.autoquest.eventcore.Event;
import de.ugoe.cs.autoquest.eventcore.gui.IInteraction;
import de.ugoe.cs.autoquest.eventcore.gui.KeyPressed;
import de.ugoe.cs.autoquest.eventcore.gui.KeyReleased;
import de.ugoe.cs.autoquest.eventcore.gui.KeyboardFocusChange;
import de.ugoe.cs.autoquest.eventcore.gui.MouseButtonDown;
import de.ugoe.cs.autoquest.eventcore.gui.MouseButtonInteraction;
import de.ugoe.cs.autoquest.eventcore.gui.MouseButtonUp;
import de.ugoe.cs.autoquest.eventcore.gui.MouseClick;
import de.ugoe.cs.autoquest.eventcore.guimodel.GUIElementFactory;
import de.ugoe.cs.autoquest.eventcore.guimodel.GUIModel;
import de.ugoe.cs.autoquest.eventcore.guimodel.GUIModelException;
import de.ugoe.cs.autoquest.eventcore.guimodel.IGUIElement;
import de.ugoe.cs.autoquest.keyboardmaps.VirtualKey;
import de.ugoe.cs.autoquest.plugin.jfc.eventcore.JFCEventId;
import de.ugoe.cs.autoquest.plugin.jfc.guimodel.JFCGUIElementSpec;
import de.ugoe.cs.util.console.Console;

/**
 * <p>
 * This class provides functionality to parse XML log files generated by the JFCMonitor of
 * EventBench. The result of parsing a file is a collection of event sequences.
 * </p>
 * 
 * @author Steffen Herbold
 * @version 1.0
 */
public class JFCLogParser extends DefaultHandler {

    /**
     * <p>
     * Collection of event sequences that is contained in the log file, which is parsed.
     * </p>
     */
    private Collection<List<Event>> sequences;

    /**
     * <p>
     * Internal handle to the id of the event that is currently being parsed.
     * </p>
     */
    private JFCEventId currentEventId;
    
    /**
     * <p>
     * Internal handle to the parameters of the event that is currently being parsed.
     * </p>
     */
    private Map<String, String> currentEventParameters;

    /**
     * <p>
     * Internal handle to the source parameters of the event that is currently being parsed.
     * </p>
     */
    private Map<String, String> currentSourceParameters;

    /**
     * <p>
     * Internal handle to the event sequence that is currently being parsed.
     * </p>
     */
    private List<Event> currentSequence;
   
    /**
     * <p>
     * internal handle to the GUI element specification currently parsed for a GUI element
     * </p>
     */
    private JFCGUIElementSpec currentGuiElementSpec;

    /**
     * <p>
     * internal handle to the last parsed GUI element
     * </p>
     */
    private List<JFCGUIElementSpec> currentGuiElementPath = new ArrayList<JFCGUIElementSpec>();

    /**
     * <p>
     * internal handle to the GUI element of the previous event to be potentially reused for the
     * current
     * </p>
     */
    private IGUIElement lastGUIElement;

    /**
     * <p>
     * the model of the GUI elements, that were found during parsing
     * </p>
     */
    private GUIModel guiModel = new GUIModel();

    /**
     * <p>
     * this is used to check, if for every pressed key, there is a release of it
     * </p>
     */
    private List<VirtualKey> mPressedKeys = new ArrayList<VirtualKey>();

    /**
     * <p>
     * Enumeration to differentiate if a parameter belongs to an event, a source or the parent of a
     * source.
     * </p>
     * 
     */
    private enum ParamSource {
        EVENT, SOURCE, PARENT, COMPONENT
    };

    /**
     * <p>
     * Specifies whether the parameters that are currently being read belong the the event, the
     * source or the parent.
     * </p>
     */
    private ParamSource paramSource = null;

    /**
     * <p>
     * a specification for event ids to be omitted by the parser
     * </p>
     */
    private Collection<JFCEventId> eventFilter;

    /**
     * <p>
     * Constructor. Creates a new JFCLogParser with a default event filter. This ignores focus
     * events, mouse pressed, and mouse released events.
     * </p>
     */
    public JFCLogParser() {
        sequences = new LinkedList<List<Event>>();
        currentSequence = null;
        //setupDefaultEventFilter();
    }

    /**
     * <p>
     * Constructor. Creates a new JFCLogParser with a specific event filter. The events in the
     * provided collection are ignored by the parser. As events, the constants of the different
     * event classes must be used. E.g. creating a collection and putting
     * <code>MouseEvent.MOUSE_PRESSED</code> will cause the parser to ignore all mouse pressed
     * events. If the provided collection is null, no event is ignored.
     * </p>
     * 
     * @param ignoredEvents
     *            the events to be ignored by the parser, can be null
     */
    public JFCLogParser(Collection<JFCEventId> ignoredEvents) {
        sequences = new LinkedList<List<Event>>();
        currentSequence = null;
        eventFilter = ignoredEvents;
    }

    /**
     * <p>
     * Parses a log file written by the JFCMonitor and creates a collection of event sequences.
     * </p>
     * 
     * @param filename
     *            name and path of the log file
     */
    public void parseFile(String filename) {
        if (filename == null) {
            throw new IllegalArgumentException("filename must not be null");
        }

        parseFile(new File(filename));
    }

    /**
     * <p>
     * Parses a log file written by the JFCMonitor and creates a collection of event sequences.
     * </p>
     * 
     * @param file
     *            name and path of the log file
     */
    public void parseFile(File file) {
        if (file == null) {
            throw new IllegalArgumentException("file must not be null");
        }

        SAXParserFactory spf = SAXParserFactory.newInstance();
        spf.setValidating(true);

        SAXParser saxParser = null;
        InputSource inputSource = null;
        try {
            saxParser = spf.newSAXParser();
            inputSource =
                new InputSource(new InputStreamReader(new FileInputStream(file), "UTF-8"));
        }
        catch (UnsupportedEncodingException e) {
            Console.printerr("Error parsing file + " + file.getName());
            Console.logException(e);
            return;
        }
        catch (ParserConfigurationException e) {
            Console.printerr("Error parsing file + " + file.getName());
            Console.logException(e);
            return;
        }
        catch (SAXException e) {
            Console.printerr("Error parsing file + " + file.getName());
            Console.logException(e);
            return;
        }
        catch (FileNotFoundException e) {
            Console.printerr("Error parsing file + " + file.getName());
            Console.logException(e);
            return;
        }
        if (inputSource != null) {
            inputSource.setSystemId("file://" + file.getAbsolutePath());
            try {
                if (saxParser == null) {
                    throw new RuntimeException("SAXParser creation failed");
                }
                saxParser.parse(inputSource, this);
            }
            catch (SAXParseException e) {
                Console.printerrln("Failure parsing file in line " + e.getLineNumber() +
                    ", column " + e.getColumnNumber() + ".");
                Console.logException(e);
                return;
            }
            catch (SAXException e) {
                Console.printerr("Error parsing file + " + file.getName());
                Console.logException(e);
                return;
            }
            catch (IOException e) {
                Console.printerr("Error parsing file + " + file.getName());
                Console.logException(e);
                return;
            }
        }
    }

    /**
     * <p>
     * Returns the collection of event sequences that is obtained from parsing log files.
     * </p>
     * 
     * @return collection of event sequences
     */
    public Collection<List<Event>> getSequences() {
        return sequences;
    }

    /**
     * <p>
     * Returns the gui model that is obtained from parsing log files.
     * </p>
     * 
     * @return collection of event sequences
     */
    public GUIModel getGuiModel() {
        return guiModel;
    }

    /*
     * (non-Javadoc)
     * 
     * @see org.xml.sax.helpers.DefaultHandler#startElement(java.lang.String, java.lang.String,
     * java.lang.String, org.xml.sax.Attributes)
     */
    public void startElement(String uri, String localName, String qName, Attributes atts)
        throws SAXException
    {
        if (qName.equals("sessions")) {
            currentSequence = new LinkedList<Event>();
        }
        if (qName.equals("newsession")) {
            Console.traceln(Level.FINE, "start of session");
            if (currentSequence != null && !currentSequence.isEmpty()) {
                // create a copy of the list just to have a correctly typed one.
                sequences.add(currentSequence.subList(0, currentSequence.size() - 1));
            }
            currentSequence = new LinkedList<Event>();
        }
        else if (qName.equals("event")) {
            JFCEventId eventId = JFCEventId.parseEventId(atts.getValue("id"));
            if ((eventFilter == null) || (!eventFilter.contains(eventId))) {
                currentEventId = eventId;
                currentEventParameters = new HashMap<String, String>();
                currentSourceParameters = new HashMap<String, String>();
                paramSource = ParamSource.EVENT;
            }
        }
        else if (currentEventId != null) {
            if (qName.equals("param")) {
                if (paramSource == ParamSource.EVENT) {
                    currentEventParameters.put(atts.getValue("name"), atts.getValue("value"));
                }
                else if (paramSource == ParamSource.SOURCE) {
                    currentSourceParameters.put(atts.getValue("name"), atts.getValue("value"));
                }
                else if (paramSource == ParamSource.COMPONENT) {
                    if ("title".equals(atts.getValue("name"))) {
                        currentGuiElementSpec.setName(atts.getValue("value"));
                    }
                    else if ("class".equals(atts.getValue("name"))) {
                        currentGuiElementSpec.setType(atts.getValue("value"));
                    }
                    else if ("icon".equals(atts.getValue("name"))) {
                        currentGuiElementSpec.setIcon(atts.getValue("value"));
                    }
                    else if ("index".equals(atts.getValue("name"))) {
                        currentGuiElementSpec.setIndex(Integer.parseInt(atts.getValue("value")));
                    }
                    else if ("hash".equals(atts.getValue("name"))) {
                        currentGuiElementSpec.setElementHash
                            ((int) Long.parseLong(atts.getValue("value"), 16));
                    }
                }
            }
            else if (qName.equals("source")) {
                paramSource = ParamSource.SOURCE;
            }
            else if (qName.equals("parent")) {
                paramSource = ParamSource.PARENT;
            }
            else if (qName.equals("component")) {
                paramSource = ParamSource.COMPONENT;
                currentGuiElementSpec = new JFCGUIElementSpec();
            }
        }
    }

    /*
     * (non-Javadoc)
     * 
     * @see org.xml.sax.helpers.DefaultHandler#endElement(java.lang.String, java.lang.String,
     * java.lang.String)
     */
    @Override
    public void endElement(String uri, String localName, String qName) throws SAXException {
        if (qName.equals("sessions")) {
            if (currentSequence != null && !currentSequence.isEmpty()) {
                sequences.add(currentSequence);
            }
            currentSequence = null;
        }
        else if (currentEventId != null) {
            if (qName.equals("event")) {
                
                if (currentGuiElementPath.size() <= 0)
                {
                    // no GUI element specification available. Try to parse the GUI element from
                    // the toString parameter
                    currentGuiElementSpec = new JFCGUIElementSpec();
                    getGUIElementSpecFromToString(currentSourceParameters.get("toString"));
                    currentGuiElementPath.add(currentGuiElementSpec);
                    currentGuiElementSpec = null;
                }
                
                IGUIElement currentGUIElement;
                try {
                    currentGUIElement = guiModel.integratePath
                        (currentGuiElementPath, GUIElementFactory.getInstance());
                }
                catch (GUIModelException e) {
                    throw new SAXException("error in the GUI model provided in the log", e);
                }
                
                Event event = new Event
                    (instantiateInteraction(currentEventId, currentEventParameters),
                     (currentGUIElement == null ? lastGUIElement : currentGUIElement));
                
                currentSequence.add(event);
                
                currentEventParameters = null;
                currentSourceParameters = null;
                currentGuiElementSpec = null;
                currentGuiElementPath.clear();
                
                currentEventId = null;
                
                if (currentGUIElement != null) {
                    lastGUIElement = currentGUIElement;
                }
                
                currentGUIElement = null;
            }
            else if (qName.equals("source")) {
                paramSource = ParamSource.EVENT;
            }
            else if (qName.equals("parent")) {
                paramSource = ParamSource.SOURCE;
            }
            else if (qName.equals("component")) {
                paramSource.equals(ParamSource.SOURCE);
                currentGuiElementPath.add(currentGuiElementSpec);
                currentGuiElementSpec = null;
            }
        }
    }

    /**
     * <p>
     * depending on the event id and the event parameters, this method instantiates the concrete
     * interaction, that took place, i.e. the event type
     * </p>
     *
     * @param eventId
     *            the id of the event
     * @param eventParameters
     *            the parameters provided for the event
     *            
     * @return as described
     * 
     * @throws SAXException thrown if the provided event id is unknown
     */
    private IInteraction instantiateInteraction(JFCEventId          eventId,
                                                Map<String, String> eventParameters)
      throws SAXException
    {
        switch (eventId)
        {
            case FOCUS_GAINED:
                return handleNewFocus(eventId, eventParameters);

            case KEY_PRESSED:
            case KEY_RELEASED:
            case KEY_TYPED:
                return handleKeyAction(eventId, eventParameters);

            case MOUSE_CLICKED:
            case MOUSE_PRESSED:
            case MOUSE_RELEASED:
            case MOUSE_MOVED:
            case MOUSE_ENTERED:
            case MOUSE_EXITED:
            case MOUSE_DRAGGED:
            case MOUSE_WHEEL:
                return handleMouseAction(eventId, eventParameters);

            default:
                throw new SAXException("unhandled event id " + eventId);
        }
    }

    /**
     * <p>
     * handles a mouse interaction. The method determines based on the event id and the parameters
     * which mouse button is pressed, released or clicked.
     * </p>
     *
     * @param eventId
     *            the id of the event
     * @param eventParameters
     *            the parameters provided for the event
     *            
     * @return as described
     * 
     * @throws SAXException thrown if the provided event id or button index is unknown
     */
    private IInteraction handleMouseAction(JFCEventId eventId, Map<String, String> eventParameters)
        throws SAXException
    {
        MouseButtonInteraction.Button button = null;

        if (eventParameters.get("Button") != null)
        {
            int buttonId = Integer.parseInt(eventParameters.get("Button"));
            if (buttonId == MouseEvent.BUTTON1)
            {
                button = MouseButtonInteraction.Button.LEFT;
            }
            else if (buttonId == MouseEvent.BUTTON2)
            {
                button = MouseButtonInteraction.Button.MIDDLE;
            }
            else if (buttonId == MouseEvent.BUTTON3)
            {
                button = MouseButtonInteraction.Button.RIGHT;
            }
            else
            {
                throw new SAXException("unknown mouse button index " + buttonId);
            }
        }

        if (JFCEventId.MOUSE_CLICKED == eventId)
        {
            int x = Integer.parseInt(eventParameters.get("X"));
            int y = Integer.parseInt(eventParameters.get("Y"));
            return new MouseClick(button, x, y);
        }
        else if (JFCEventId.MOUSE_PRESSED == eventId)
        {
            int x = Integer.parseInt(eventParameters.get("X"));
            int y = Integer.parseInt(eventParameters.get("Y"));
            return new MouseButtonDown(button, x, y);
        }
        else if (JFCEventId.MOUSE_RELEASED == eventId)
        {
            int x = Integer.parseInt(eventParameters.get("X"));
            int y = Integer.parseInt(eventParameters.get("Y"));
            return new MouseButtonUp(button, x, y);
        }
        else
        {
            throw new SAXException("unknown event id " + eventId);
        }
    }

    /**
     * <p>
     * handles a keyboard interaction. The method determines based on the event id and the
     * parameters which key on the keyboard is pressed or released. It further checks, if for 
     * every released key there is also a pressed event
     * </p>
     *
     * @param eventId
     *            the id of the event
     * @param eventParameters
     *            the parameters provided for the event
     *            
     * @return as described
     * 
     * @throws SAXException thrown if the provided event id is unknown or if there is a key
     *                      release without a preceding press of the same key
     */
    private IInteraction handleKeyAction(JFCEventId eventId, Map<String, String> eventParameters)
      throws SAXException
    {
        // TODO handle shortcuts
        if (JFCEventId.KEY_PRESSED == eventId)
        {
            VirtualKey key = VirtualKey.parseVirtualKey(eventParameters.get("KeyCode"));
            mPressedKeys.add(key);

            return new KeyPressed(key);
        }
        else if (JFCEventId.KEY_RELEASED == eventId)
        {
            VirtualKey key = VirtualKey.parseVirtualKey(eventParameters.get("KeyCode"));
            if (mPressedKeys.contains(key))
            {
                mPressedKeys.remove(key);
            }
            else
            {
                Console.traceln(Level.SEVERE, "log file has an error, as it contains a key up event on key " +
                                   key + " for which there is no preceeding key down event");
            }

            return new KeyReleased(key);
        }

        throw new SAXException("unknown event id " + eventId);
    }

    /**
     * <p>
     * handles explicit keyboard focus changes.
     * </p>
     *
     * @param eventId
     *            the id of the event
     * @param eventParameters
     *            the parameters provided for the event
     *            
     * @return as described
     * 
     * @throws SAXException thrown if the provided event id is unknown
     */
    private IInteraction handleNewFocus(JFCEventId eventId, Map<String, String> eventParameters)
        throws SAXException
    {
        if (JFCEventId.FOCUS_GAINED == eventId)
        {
            return new KeyboardFocusChange();
        }
        else
        {
            throw new SAXException("unknown event id " + eventId);
        }
    }

    /**
     * <p>
     * for some events in the log file, no GUI element specification is provided. In this case the
     * GUI element on which the event is executed must be determined based on the
     * <code>toString</code> parameter of the event. This is achieved through this method. The
     * <code>toString</code> parameter does not always carry sufficient information for the GUI
     * elements. For example the title is not necessarily provided. Therefore some of this
     * information is generated.
     * </p>
     *
     * @param toStringValue
     *            the <code>toString</code> parameter of the event to be parsed for the GUI element
     *            
     * @return the appropriate GUI element
     * 
     * @throws SAXException thrown if the provided value of the <code>toString</code> parameter
     *                      can not be parsed
     */
    private void getGUIElementSpecFromToString(String toStringValue)
        throws SAXException
    {
        try
        {
            // match the following: <type>[<parameters>]
            String pattern = "([\\w$\\.]*)\\[(.*)\\]";
            Matcher matcher = Pattern.compile(pattern).matcher(toStringValue);

            if (!matcher.find())
            {
                throw new IllegalArgumentException
                    ("could not parse target from toString parameter");
            }

            String type = matcher.group(1);
            
            // match the following: <parameter>|,
            // where <parameter> := <digitValue>|<value>|<key>"="<value>
            pattern = "([\\w$@=\\.]*)|,";

            matcher = Pattern.compile(pattern).matcher(matcher.group(2));
            
            float elementHash = -1;
            
            pattern = "(([\\d]*)|([\\w$]*)|(([\\w$@\\.]*)=([\\w$@\\.]*)))\\z";
            Pattern valuePattern = Pattern.compile(pattern);
            
            while (matcher.find()) {
                Matcher valueMatcher = valuePattern.matcher(matcher.group(1));
                if (valueMatcher.find()) {
                    if ((valueMatcher.group(2) != null) && (!"".equals(valueMatcher.group(2)))) {
                        // found digit value. Those in combination usually denote the position
                        // of the GUI element. So calculate an element has out of them
                        elementHash += Integer.parseInt(valueMatcher.group(2));
                    }
                    else if ((valueMatcher.group(5) != null) &&
                             (!"".equals(valueMatcher.group(5))) &&
                             (valueMatcher.group(6) != null) &&
                             (!"".equals(valueMatcher.group(6))))
                    {
                        // found a key value pair. Get some of them and integrate them into the hash 
                        String key = valueMatcher.group(5);
                        
                        if ("alignmentX".equals(key) || "alignmentY".equals(key)) {
                            elementHash += Float.parseFloat(valueMatcher.group(6));
                        }
                    }
                }
            }

            currentGuiElementSpec.setName("unknown(" + ((int) elementHash) + ")");
            currentGuiElementSpec.setType(type);
            currentGuiElementSpec.setIndex(-1);
            currentGuiElementSpec.setElementHash((int) elementHash);
        }
        catch (Exception e)
        {
            throw new SAXException("could not parse target", e);
        }
    }
    
    /**
     * <p>
     * creates a default event filter that ignores focus changes, mouse pressed and mouse released
     * events.
     * </p>
     */
    /*private void setupDefaultEventFilter() {
        eventFilter = new HashSet<JFCEventId>();
        eventFilter.add(JFCEventId.MOUSE_PRESSED);
        eventFilter.add(JFCEventId.MOUSE_RELEASED);
        eventFilter.add(JFCEventId.FOCUS_GAINED);
    }*/

}
