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

import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.text.DecimalFormat;
import java.util.HashSet;
import java.util.Set;

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

/**
 * <p>
 * dumps messages to a log file belonging to a specific client id. In the provided base log
 * directory, it creates a subdirectory with the client id. In this directory it creates
 * appropriate log files. The name of each finished log file starts with the "htmlmonitor_"
 * followed by the client id and an index of the log file. An unfinished log file has no index yet.
 * A log file is finished if
 * <ul>
 *   <li>the client session is closed by a timeout</li>
 *   <li>the HTML monitor is normally shut down</li>
 *   <li>on startup an unfinished log file is detected.</li>
 *   <li>the {@link #MAXIMUM_LOG_FILE_SIZE} is reached</li>
 * </ul>
 * </p>
 * 
 * @author Patrick Harms
 * @version 1.0
 * 
 */
public class HtmlMonitorOutputWriter implements HtmlMonitorComponent, HtmlMonitorMessageListener {
    
    /**
     * the maximum size of an individual log file
     */
    private static final int MAXIMUM_LOG_FILE_SIZE = 100000000;

    /**
     * the default log base directory if none is provided through the constructor 
     */
    private static final String DEFAULT_LOG_FILE_BASE_DIR = "logs";

    /**
     * the currently used log file base directory
     */
    private File logFileBaseDir;

    /**
     * the id of the web application used by the client
     */
    private String webAppId;

    /**
     * the id of the client of which all messages are logged through this writer
     */
    private String clientId;

    /**
     * the user agent used by the client
     */
    private String userAgent;

    /**
     * the log file into which all messages are currently written
     */
    private File logFile;

    /**
     * an output writer to be used for writing into the log file
     */
    private PrintWriter outputWriter;

    /**
     * the time stamp of the last action taken on this writer (such as logging a message)
     */
    private long lastUpdate;
    
    /**
     * the GUI elements, that were already logged and need therefore not be logged again into
     * the same file
     */
    private Set<HtmlGUIElement> loggedGUIElements = new HashSet<HtmlGUIElement>();

    /**
     * <p>
     * initializes the writer with the log file base directory and the id of the client for which
     * this writer logs the messages.
     * </p>
     * 
     * @param logFileBaseDir the log file base directory, or null if the default directory shall
     *                       be taken
     * @param webAppId       the ID of the web application used by the client
     * @param clientId       the ID of the client, for which this writer logs
     */
    public HtmlMonitorOutputWriter(String logFileBaseDir,
                                   String webAppId,
                                   String clientId,
                                   String userAgent)
    {
        if (logFileBaseDir == null) {
            this.logFileBaseDir = new File(DEFAULT_LOG_FILE_BASE_DIR);
        }
        else {
            this.logFileBaseDir = new File(logFileBaseDir);
        }
        
        this.webAppId = webAppId;
        this.clientId = clientId;
        this.userAgent = userAgent;
        
        lastUpdate = System.currentTimeMillis();
    }

    /* (non-Javadoc)
     * @see de.ugoe.cs.autoquest.htmlmonitor.HtmlMonitorComponent#init()
     */
    @Override
    public synchronized void init() throws HtmlMonitorException {
        if (outputWriter != null) {
            throw new IllegalStateException("already initialized. Call close() first");
        }
        
        synchronized (HtmlMonitorOutputWriter.class) {
            try {
                File clientLogDir = new File(logFileBaseDir, webAppId);
                clientLogDir = new File(clientLogDir, clientId);
                
                if (!clientLogDir.exists()) {
                    if (!clientLogDir.mkdirs()) {
                        throw new HtmlMonitorException("client log file directory " + clientLogDir +
                                                       " can not be created");
                    }
                }
                else if (!clientLogDir.isDirectory()) {
                    throw new HtmlMonitorException("client log file directory " + clientLogDir +
                                                   " already exists as a file");
                }
                
                handleOldLogFiles(new File(logFileBaseDir, clientId), clientLogDir);
                
                logFile = new File(clientLogDir, getLogFileName(-1));
                
                if (logFile.exists()) {
                    rotateLogFile();
                }
            
                createLogWriter();
            }
            catch (IOException e) {
                throw new HtmlMonitorException("could not open logfile " + logFile, e);
            }
        }
        
        lastUpdate = System.currentTimeMillis();
    }

    /**
     * <p>
     * used to calculate a log file name. If the provided index is smaller 0, then no index
     * is added to the file name. A filename is e.g. "htmlmonitor_12345_001.log". 
     * </p>
     *
     * @param index the index of the log file or -1 one, if no index shall be added
     * 
     * @return the file name as described
     */
    private String getLogFileName(int index) {
        String result = "htmlmonitor_" + clientId;
        
        if (index >= 0) {
            result += "_" + new DecimalFormat("000" ).format(index);
        }
        
        result += ".log";
        
        return result;
    }

    /* (non-Javadoc)
     * @see de.ugoe.cs.autoquest.htmlmonitor.HtmlMonitorComponent#start()
     */
    @Override
    public synchronized void start() throws IllegalStateException, HtmlMonitorException {
        lastUpdate = System.currentTimeMillis();
    }

    /* (non-Javadoc)
     * @see HtmlMonitorMessageListener#handleMessage(HtmlClientInfos, HtmlEvent[])
     */
    @Override
    public synchronized void handleMessage(HtmlClientInfos clientInfos,
                                           HtmlGUIElement  guiStructure,
                                           HtmlEvent[]     events)
    {
        if (outputWriter == null) {
            throw new IllegalStateException("not initialized. Call init() first");
        }
        
        for (HtmlEvent event : events) {
            dumpEvent(event);
        }
        
        outputWriter.flush();
        
        try {
            considerLogRotate();
        }
        catch (IOException e) {
            throw new IllegalStateException("could not perform log rotation: " + e, e);
        }
        
        lastUpdate = System.currentTimeMillis();
    }

    /**
     * <p>
     * formats a received event and writes it to the log file. One event results in one line
     * in the log file containing all infos of the event.
     * </p>
     *
     * @param event to be written to the log file
     */
    private void dumpEvent(HtmlEvent event) {
        if (event.getTarget() != null) {
            ensureGuiElementDumped(event.getTarget());
        }
        
        outputWriter.print("<event type=\"");
        outputWriter.print(event.getEventType());
        outputWriter.println("\">");
        
        if (event.getCoordinates() != null) {
            dumpParam("X", event.getCoordinates()[0]);
            dumpParam("Y", event.getCoordinates()[1]);
        }

        dumpParam("key", event.getKey());
            
        if (event.getScrollPosition() != null) {
            dumpParam("scrollX", event.getScrollPosition()[0]);
            dumpParam("scrollY", event.getScrollPosition()[1]);
        }

        dumpParam("selectedValue", event.getSelectedValue());
        
        if (event.getTarget() != null) {
            dumpParam("target", event.getTarget().getId());
        }
        else {
            dumpParam("targetDocument", event.getTargetDocument().getId());
            dumpParam("targetDOMPath", event.getTargetDOMPath());
        }
        dumpParam("timestamp", event.getTime());
        
        outputWriter.println("</event>");
    }

    /**
     * <p>
     * ensures that a GUI element, its parents as well as all other GUI elements on the same page
     * are dumped
     * </p>
     *
     * @param guiStructure the GUI structure to be logged
     */
    private void ensureGuiElementDumped(HtmlGUIElement guiElement) {
        if (!loggedGUIElements.contains(guiElement)) {
            
            // determine the document as the whole document needs to be dumped. Ensure also that
            // the server is dumped
            
            HtmlGUIElement parent = guiElement;
            HtmlDocument document = null;
            HtmlServer server = null;
            
            while (parent != null) {
                if (parent instanceof HtmlDocument) {
                    document = (HtmlDocument) parent;
                }
                else if (parent instanceof HtmlServer) {
                    server = (HtmlServer) parent;
                }
                
                parent = parent.getParent();
            }
            
            if (server != null) {
                dumpGuiElement(server);
            }
            
            if (document != null) {
                dumpGuiStructure(document);
            }
        }
    }

    /**
     * <p>
     * dumps the GUI structure provided by the parameter into the log file. Calls itself
     * recursively to traverse the GUI structure.
     * </p>
     *
     * @param guiStructure the GUI structure to be logged
     */
    private void dumpGuiStructure(HtmlGUIElement guiStructure) {
        dumpGuiElement(guiStructure);
        
        if (guiStructure.getChildren() != null) {
            for (HtmlGUIElement child : guiStructure.getChildren()) {
                dumpGuiStructure(child);
            }
        }
    }

    /**
     * <p>
     * dumps the GUI element provided by the parameter into the log file.
     * </p>
     *
     * @param guiElement the GUI element to be logged
     */
    private void dumpGuiElement(HtmlGUIElement guiElement) {
        if (!loggedGUIElements.contains(guiElement)) {
            outputWriter.print("<component id=\"");
            outputWriter.print(guiElement.getId());
            outputWriter.println("\">");
        
            if (guiElement instanceof HtmlServer) {
                dumpParam("host", ((HtmlServer) guiElement).getName());
                dumpParam("port", ((HtmlServer) guiElement).getPort());
            }
            else if (guiElement instanceof HtmlDocument) {
                dumpParam("path", ((HtmlDocument) guiElement).getPath());
                dumpParam("query", ((HtmlDocument) guiElement).getQuery());
                dumpParam("title", ((HtmlDocument) guiElement).getTitle());
            }
            else if (guiElement instanceof HtmlPageElement) {
                dumpParam("tagname", ((HtmlPageElement) guiElement).getTagName());
                dumpParam("htmlid", ((HtmlPageElement) guiElement).getHtmlId());
                dumpParam("index", ((HtmlPageElement) guiElement).getIndex());
            }
            
            if (guiElement.getParent() != null) {
                dumpParam("parent", guiElement.getParent().getId());
            }
        
            outputWriter.println("</component>");
        
            loggedGUIElements.add(guiElement);
        }
    }

    /**
     * <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>
     * checks, if the log file exeeded the {@link #MAXIMUM_LOG_FILE_SIZE}. If so, the current
     * log file is closed, the next log file name is determined and this new file is opend for
     * writing. 
     * </p>
     */
    private synchronized void considerLogRotate() throws IOException {
        if (logFile.length() > MAXIMUM_LOG_FILE_SIZE) {
            closeLogWriter();
            rotateLogFile();
            createLogWriter();
        }
    }

    /**
     * <p>
     * renames the current log file to a new log file with the next available index. It further
     * sets the current log file to the default name, i.e. without index.
     * </p>
     */
    private void rotateLogFile() {
        File clientLogDir = logFile.getParentFile();
        File checkFile;

        int logFileIndex = -1;
        do {
            logFileIndex++;
            
            checkFile = new File(clientLogDir, getLogFileName(logFileIndex));
        }
        while (checkFile.exists());
    
        if (!logFile.renameTo(checkFile)) {
            Console.printerrln("could not rename log file " + logFile + " to " + checkFile +
                               ". Will not perform log rotation.");
        }
        else {
            logFileIndex++;
            logFile = new File(clientLogDir, getLogFileName(-1));
        }
    }

    /**
     * <p>
     * instantiates a writer to be used for writing messages into the log file.
     * </p>
     */
    private void createLogWriter() throws IOException {
        FileOutputStream fis = new FileOutputStream(logFile);
        outputWriter = new PrintWriter(new OutputStreamWriter(fis, "UTF-8"));
        outputWriter.println("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
        outputWriter.print("<session webapp=\"");
        outputWriter.print(StringTools.xmlEntityReplacement(this.webAppId));
        outputWriter.print("\" clientId=\"");
        outputWriter.print(StringTools.xmlEntityReplacement(this.clientId));
        outputWriter.print("\" userAgent=\"");
        outputWriter.print(StringTools.xmlEntityReplacement(this.userAgent));
        outputWriter.print("\" monitorInfos=\"");
        outputWriter.print(StringTools.xmlEntityReplacement(getMonitorVersion()));
        
        outputWriter.println("\">");
        
        loggedGUIElements.clear();
    }

    /**
     * <p>
     * parses the POM properties if accessible and returns them. If they are not available,
     * unknown is returned.
     * </p>
     */
    private String getMonitorVersion() {
        InputStream properties = this.getClass().getClassLoader().getResourceAsStream
            ("META-INF/maven/de.ugoe.cs.autoquest/autoquest-htmlmonitor/pom.properties");
        
        if (properties != null) {
            try {
                StringBuffer result = new StringBuffer();
                BufferedReader reader = new BufferedReader(new InputStreamReader(properties));
                
                String line = null;
                
                do {
                    line = reader.readLine();
                    
                    if (line != null) {
                        if (result.length() > 0) {
                            result.append("  ");
                        }
                        
                        result.append(line);
                    }
                }
                while (line != null);
                
                reader.close();
                return result.toString();
            }
            catch (IOException e) {
                return "unknown";
            }
        }
        else {
            return "unknown";
        }
    }

    /**
     * <p>
     * closed the current writer if it is open.
     * </p>
     */
    private void closeLogWriter() {
        if (outputWriter != null) {
            outputWriter.println("</session>");
            outputWriter.flush();
            outputWriter.close();
            outputWriter = null;
        }
    }

    /**
     * <p>
     * this method moves old logfiles of the same client resisting in the wrong old directory
     * structure to the new one.
     * </p>
     *
     * @param oldLogDir the old log directory
     * @param newLogDir the new log directory
     */
    private void handleOldLogFiles(File oldLogDir, File newLogDir) {
        if (oldLogDir.exists() && oldLogDir.isDirectory()) {
            boolean allFilesRenamed = true;
            for (File oldLogFile : oldLogDir.listFiles()) {
                allFilesRenamed &= oldLogFile.renameTo(new File(newLogDir, oldLogFile.getName()));
            }
            
            if (allFilesRenamed) {
                if (!oldLogDir.delete()) {
                    Console.printerrln("could not move old file directory structure to new one");
                }
            }
        }
    }

    /* (non-Javadoc)
     * @see de.ugoe.cs.autoquest.htmlmonitor.HtmlMonitorComponent#stop()
     */
    @Override
    public synchronized void stop() {
        closeLogWriter();
        rotateLogFile();

        lastUpdate = System.currentTimeMillis();
    }

    /**
     * <p>
     * return the time stamp of the last activity that happened on this writer.
     * </p>
     *
     * @return as described
     */
    public long getLastUpdate() {
        return lastUpdate;
    }
}
