/*
 * Title:        ConfigLoader
 * Description:
 *
 * This software is published under the terms of the OpenSymphony Software
 * License version 1.1, of which a copy has been included with this
 * distribution in the LICENSE.txt file.
 */

package com.opensymphony.module.sitemesh.mapper;

import com.opensymphony.module.sitemesh.Config;
import com.opensymphony.module.sitemesh.Decorator;
import com.opensymphony.module.sitemesh.factory.DefaultFactory;
import org.w3c.dom.*;
import org.xml.sax.SAXException;

import javax.servlet.ServletException;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

/**
 * The ConfigLoader reads a configuration XML file that contains Decorator definitions
 * (name, url, init-params) and path-mappings (pattern, name).
 *
 * <p>These can then be accessed by the getDecoratorByName() methods and getMappedName()
 * methods respectively.</p>
 *
 * <p>The DTD for the configuration file in old (deprecated) format is located at
 * <a href="http://www.opensymphony.com/dtds/sitemesh_1_0_decorators.dtd">
 *  http://www.opensymphony.com/dtds/sitemesh_1_0_decorators.dtd
 * </a>.</p>
 *
 * <p>The DTD for the configuration file in new format is located at
 * <a href="http://www.opensymphony.com/dtds/sitemesh_1_5_decorators.dtd">
 *  http://www.opensymphony.com/dtds/sitemesh_1_5_decorators.dtd
 * </a>.</p>
 *
 * <p>Editing the config file will cause it to be auto-reloaded.</p>
 *
 * <p>This class is used by ConfigDecoratorMapper, and uses PathMapper for pattern matching.</p>
 *
 * @author <a href="mailto:joe@truemesh.com">Joe Walnes</a>
 * @author <a href="mailto:pathos@pandora.be">Mathias Bogaert</a>
 * @version $Revision: 1.8 $
 *
 * @see com.opensymphony.module.sitemesh.mapper.ConfigDecoratorMapper
 * @see com.opensymphony.module.sitemesh.mapper.PathMapper
 */
public class ConfigLoader {

    /**
     * State visibile across threads stored in a single container so that we
     * can efficiently atomically access it with the guarantee that we wont see
     * a partially loaded configuration in the face of one thread reloading the
     * configuration while others are trying to read it.
     */
    private static class State {
        /**
         * Timestamp of the last time we checked for an update to the
         * configuration file used to rate limit the frequency at which we check
         * for efficiency.
         */
        long lastModificationCheck = System.currentTimeMillis();

        /**
         * Timestamp of the modification time of the configuration file when we
         * generated the state.
         */
        long lastModified;

        /**
         * Whether a thread is currently checking if the configuration file has
         * been modified and potentially reloading it and therefore others
         * shouldn't attempt the same till it's done.
         */
        boolean checking = false;

        Map decorators = new HashMap();
        PathMapper pathMapper = new PathMapper();
    }

    /**
     * Mark volatile so that the installation of new versions is guaranteed to
     * be visible across threads.
     */
    private volatile State state;

    private File configFile = null;
    private String configFileName = null;

    private Config config = null;

    /**
     * Create new ConfigLoader using supplied File.
     */
    public ConfigLoader(File configFile) throws ServletException {
        this.configFile = configFile;
        this.configFileName = configFile.getName();
        state = loadConfig();
    }

    /**
     * Create new ConfigLoader using supplied filename and config.
     */
    public ConfigLoader(String configFileName, Config config) throws ServletException {
        this.config = config;
        this.configFileName = configFileName;
        if (config.getServletContext().getRealPath(configFileName) != null) {
            this.configFile = new File(config.getServletContext().getRealPath(configFileName));
        }
        state = loadConfig();
    }

    /**
     * Retrieve Decorator based on name specified in configuration file.
     */
    public Decorator getDecoratorByName(String name) throws ServletException {
        return (Decorator) refresh().decorators.get(name);
    }

    /** Get name of Decorator mapped to given path. */
    public String getMappedName(String path) throws ServletException {
        return refresh().pathMapper.get(path);
    }

    /**
     * Load configuration from file.
     */
    private State loadConfig() throws ServletException {
        // The new state which we build up and atomically replace the old state
        // with atomically to avoid other threads seeing partial modifications.
        State newState = new State();
        try {
            // Build a document from the file
            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
            DocumentBuilder builder = factory.newDocumentBuilder();

            Document document;
            if (configFile != null && configFile.canRead()) {
                // Keep time we read the file to check if the file was modified
                newState.lastModified = configFile.lastModified();
                document = builder.parse(configFile);
            } else {
                document = builder.parse(config.getServletContext().getResourceAsStream(configFileName));
            }

            // Parse the configuration document
            parseConfig(newState, document);

            return newState;
        }
        catch (ParserConfigurationException e) {
            throw new ServletException("Could not get XML parser", e);
        }
        catch (IOException e) {
            throw new ServletException("Could not read the config file: " + configFileName, e);
        }
        catch (SAXException e) {
            throw new ServletException("Could not parse the config file: " + configFileName, e);
        }
        catch (IllegalArgumentException e) {
            throw new ServletException("Could not find the config file: " + configFileName, e);
        }
    }

    private void parseConfig(State newState, Document document) {
        Element root = document.getDocumentElement();

        // get the default directory for the decorators
        String defaultDir = getAttribute(root, "defaultdir");
        if (defaultDir == null) defaultDir = getAttribute(root, "defaultDir");

        // Get decorators
        NodeList decoratorNodes = root.getElementsByTagName("decorator");
        Element decoratorElement;

        for (int i = 0; i < decoratorNodes.getLength(); i++) {
            String name, page, uriPath = null, role = null;

            // get the current decorator element
            decoratorElement = (Element) decoratorNodes.item(i);

            if (getAttribute(decoratorElement, "name") != null) {
                // The new format is used
                name = getAttribute(decoratorElement, "name");
                page = getAttribute(decoratorElement, "page");
                uriPath = getAttribute(decoratorElement, "webapp");
                role = getAttribute(decoratorElement, "role");

                // Append the defaultDir
                if (defaultDir != null && page != null && page.length() > 0 && !page.startsWith("/")) {
                    if (page.charAt(0) == '/') page = defaultDir + page;
                    else page = defaultDir + '/' + page;
                }

                // The uriPath must begin with a slash
                if (uriPath != null && uriPath.length() > 0) {
                    if (uriPath.charAt(0) != '/') uriPath = '/' + uriPath;
                }

                // Get all <pattern>...</pattern> and <url-pattern>...</url-pattern> nodes and add a mapping
                populatePathMapper(newState, decoratorElement.getElementsByTagName("pattern"), role, name);
                populatePathMapper(newState, decoratorElement.getElementsByTagName("url-pattern"), role, name);
            } else {
                // NOTE: Deprecated format
                name = getContainedText(decoratorNodes.item(i), "decorator-name");
                page = getContainedText(decoratorNodes.item(i), "resource");
                // We have this here because the use of jsp-file is deprecated, but we still want
                // it to work.
                if (page == null) page = getContainedText(decoratorNodes.item(i), "jsp-file");
            }

            Map params = new HashMap();

            NodeList paramNodes = decoratorElement.getElementsByTagName("init-param");
            for (int ii = 0; ii < paramNodes.getLength(); ii++) {
                String paramName = getContainedText(paramNodes.item(ii), "param-name");
                String paramValue = getContainedText(paramNodes.item(ii), "param-value");
                params.put(paramName, paramValue);
            }
            storeDecorator(newState, new DefaultDecorator(name, page, uriPath, role, params));
        }

        // Get (deprecated format) decorator-mappings
        NodeList mappingNodes = root.getElementsByTagName("decorator-mapping");
        for (int i = 0; i < mappingNodes.getLength(); i++) {
            Element n = (Element) mappingNodes.item(i);
            String name = getContainedText(mappingNodes.item(i), "decorator-name");

            // Get all <url-pattern>...</url-pattern> nodes and add a mapping
            populatePathMapper(newState, n.getElementsByTagName("url-pattern"), null, name);
        }
    }

    private void populatePathMapper(State newState, NodeList patternNodes, String role, String name) {
        for (int j = 0; j < patternNodes.getLength(); j++) {
            Element p = (Element) patternNodes.item(j);
            Text patternText = (Text) p.getFirstChild();
            if (patternText != null) {
                String pattern = patternText.getData().trim();
                if (pattern != null) {
                    if (role != null) {
                        // concatenate name and role to allow more
                        // than one decorator per role
                        newState.pathMapper.put(name + role, pattern);
                    } else {
                        newState.pathMapper.put(name, pattern);
                    }
                }
            }
        }
    }

    private static String getAttribute(Element element, String name) {
        if (element != null && element.getAttribute(name) != null && element.getAttribute(name).trim() != "") {
            return element.getAttribute(name).trim();
        } else {
            return null;
        }
    }

    private static String getContainedText(Node parent, String childTagName) {
        try {
            Node tag = ((Element) parent).getElementsByTagName(childTagName).item(0);
            return ((Text) tag.getFirstChild()).getData();
        }
        catch (Exception e) {
            return null;
        }
    }

    private void storeDecorator(State newState, Decorator d) {
        if (d.getRole() != null) {
            newState.decorators.put(d.getName() + d.getRole(), d);
        } else {
            newState.decorators.put(d.getName(), d);
        }
    }

    /**
     * Check if configuration file has been updated, and if so, reload.
     */
    private State refresh() throws ServletException {
        // Read the current state just once since another thread can swap
        // another version in at any time.
        State currentState = state;
        if (configFile == null) {
            return currentState;
        }

        // Rate limit the stat'ing of the config file to find its
        // modification time to once every five seconds to reduce the
        // number of system calls made. We grab the monitor of currentState
        // so that we can safely read the values shared across threads and
        // so that only one thread is performing the modification check at
        // a time.
        long current = System.currentTimeMillis();
        long oldLastModified;

        boolean check = false;
        synchronized (currentState) {
            oldLastModified = currentState.lastModified;
            if (!currentState.checking && current >= currentState.lastModificationCheck + DefaultFactory.configCheckMillis) {
                currentState.lastModificationCheck = current;
                currentState.checking = true;
                check = true;
            }
        }

        if (check) {
            // Perform the file stat'ing system call without holding a lock
            // on the current state.
            State newState = null;
            try {
                long currentLastModified = configFile.lastModified();
                if (currentLastModified != oldLastModified) {
                    // The configuration file has been modified since we last
                    // read it so reload the configuration without holding a
                    // lock on the current state and then slam down the new
                    // state for other threads to see. The State.checking flag
                    // being set on currentState will prevent other threads
                    // from attempting to reload the state while we are and
                    // the new state will have the flag cleared so that we can
                    // continue checking if the configuration file is modified.
                    newState = loadConfig();
                    state = newState;
                    return newState;
                }
            } finally {
                // In the event of a failure of the modification time check,
                // or while reloading the configuration file, mark that we're no
                // longer checking if the modification time or reloading so that
                // we'll retry.
                if (newState == null) {
                    synchronized (currentState) {
                        currentState.checking = false;
                    }
                }
            }

        }
        return currentState;
    }
}