/**
 * Copyright (C) 2009 eXo Platform SAS.
 *
 * This is free software; you can redistribute it and/or modify it
 * under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation; either version 2.1 of
 * the License, or (at your option) any later version.
 *
 * This software is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this software; if not, write to the Free
 * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
 * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
 */

package org.exoplatform.web.application.javascript;

import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.servlet.ServletContext;

import org.apache.commons.lang.StringUtils;
import org.exoplatform.commons.utils.CompositeReader;
import org.exoplatform.commons.utils.PropertyManager;
import org.exoplatform.container.ExoContainerContext;
import org.exoplatform.portal.resource.AbstractResourceService;
import org.exoplatform.portal.resource.compressor.ResourceCompressor;
import org.exoplatform.web.ControllerContext;
import org.exoplatform.web.controller.router.URIWriter;
import org.gatein.common.logging.Logger;
import org.gatein.common.logging.LoggerFactory;
import org.gatein.portal.controller.resource.ResourceId;
import org.gatein.portal.controller.resource.ResourceScope;
import org.gatein.portal.controller.resource.script.BaseScriptResource;
import org.gatein.portal.controller.resource.script.FetchMode;
import org.gatein.portal.controller.resource.script.Module;
import org.gatein.portal.controller.resource.script.ScriptGraph;
import org.gatein.portal.controller.resource.script.ScriptGroup;
import org.gatein.portal.controller.resource.script.ScriptResource;
import org.gatein.portal.controller.resource.script.ScriptResource.DepInfo;
import org.gatein.wci.ServletContainerFactory;
import org.gatein.wci.WebApp;
import org.gatein.wci.WebAppListener;
import org.json.JSONArray;
import org.json.JSONObject;
import org.picocontainer.Startable;

public class JavascriptConfigService extends AbstractResourceService implements Startable {

    /** Our logger. */
    private final Logger log = LoggerFactory.getLogger(JavascriptConfigService.class);

    /** The scripts. */
    final ScriptGraph scripts;

    /** . */
    private final WebAppListener deployer;

    /** . */
    public static final List<String> RESERVED_MODULE = Arrays.asList("require", "exports", "module");

    /** . */
    private static final Pattern INDEX_PATTERN = Pattern.compile("^.+?(_([1-9]+))$");

    /** . */
    public static final Comparator<Module> MODULE_COMPARATOR = new Comparator<Module>() {
        public int compare(Module o1, Module o2) {
            return o1.getPriority() - o2.getPriority();
        }
    };

    public JavascriptConfigService(ExoContainerContext context, ResourceCompressor compressor) {
        super(compressor);

        //
        this.scripts = new ScriptGraph();
        this.deployer = new JavascriptConfigDeployer(context.getPortalContainerName(), this);
    }

    public Reader getScript(ResourceId resourceId, Locale locale) throws Exception {
        if (ResourceScope.GROUP.equals(resourceId.getScope())) {
            ScriptGroup loadGroup = scripts.getLoadGroup(resourceId.getName());
            if (loadGroup != null) {
                List<Reader> readers = new ArrayList<Reader>(loadGroup.getDependencies().size());
                for (ResourceId id : loadGroup.getDependencies()) {
                    Reader rd = getScript(id, locale);
                    if (rd != null) {
                        readers.add(new StringReader("\n//Begin " + id));
                        readers.add(rd);
                        readers.add(new StringReader("\n//End " + id));
                    }
                }
                return new CompositeReader(readers);
            } else {
                return null;
            }
        } else {
            ScriptResource resource = getResource(resourceId);

            if (resource != null) {
                List<Module> modules = new ArrayList<Module>(resource.getModules());

                Collections.sort(modules, MODULE_COMPARATOR);
                ArrayList<Reader> readers = new ArrayList<Reader>(modules.size() * 2);
                StringBuilder buffer = new StringBuilder();

                //
                boolean isModule = FetchMode.ON_LOAD.equals(resource.getFetchMode());

                if (isModule) {
                    JSONArray deps = new JSONArray();
                    LinkedList<String> params = new LinkedList<String>();
                    List<String> argNames = new LinkedList<String>();
                    List<String> argValues = new LinkedList<String>(params);
                    for (ResourceId id : resource.getDependencies()) {
                        ScriptResource dep = getResource(id);
                        if (dep != null) {
                            Set<DepInfo> depInfos = resource.getDepInfo(id);
                            for (DepInfo info : depInfos) {
                                String pluginRS = info.getPluginRS();
                                String alias = info.getAlias();
                                if (alias == null) {
                                    alias = dep.getAlias();
                                }

                                deps.put(parsePluginRS(dep.getId().toString(), pluginRS));
                                params.add(encode(params, alias));
                                argNames.add(parsePluginRS(alias, pluginRS));
                            }
                        } else if (RESERVED_MODULE.contains(id.getName())) {
                            String reserved = id.getName();
                            deps.put(reserved);
                            params.add(reserved);
                            argNames.add(reserved);
                        }
                    }
                    argValues.addAll(params);
                    int reserveIdx = argValues.indexOf("require");
                    if (reserveIdx != -1) {
                        argValues.set(reserveIdx, "eXo.require");
                    }

                    //
                    buffer.append("\ndefine('").append(resourceId).append("', ");
                    buffer.append(deps);
                    buffer.append(", function(");
                    buffer.append(StringUtils.join(params, ","));
                    buffer.append(") {\nvar require = eXo.require, requirejs = eXo.require,define = eXo.define;");
                    buffer.append("\neXo.define.names=").append(new JSONArray(argNames)).append(";");
                    buffer.append("\neXo.define.deps=[").append(StringUtils.join(argValues, ",")).append("]").append(";");
                    buffer.append("\nreturn ");
                }

                //
                for (Module js : modules) {
                    Reader jScript = getJavascript(js, locale);
                    if (jScript != null) {
                        readers.add(new StringReader(buffer.toString()));
                        buffer.setLength(0);
                        readers.add(new NormalizeJSReader(jScript));
                    }
                }

                if (isModule) {
                    buffer.append("\n});");
                } else {
                    buffer.append("\nif (typeof define === 'function' && define.amd && !require.specified('")
                            .append(resource.getId()).append("')) {");
                    buffer.append("define('").append(resource.getId()).append("');}");
                }
                readers.add(new StringReader(buffer.toString()));

                return new CompositeReader(readers);
            } else {
                return null;
            }
        }
    }

    @SuppressWarnings("unchecked")
    public String generateURL(ControllerContext controllerContext, ResourceId id, boolean merge, boolean minified, Locale locale)
            throws IOException {
        @SuppressWarnings("rawtypes")
        BaseScriptResource resource = null;
        if (ResourceScope.GROUP.equals(id.getScope())) {
            resource = scripts.getLoadGroup(id.getName());
        } else {
            resource = getResource(id);
        }

        //
        if (resource != null) {
            if (resource instanceof ScriptResource) {
                ScriptResource rs = (ScriptResource) resource;

                List<Module> modules = rs.getModules();
                if (modules.size() > 0 && modules.get(0) instanceof Module.Remote) {
                    return ((Module.Remote) modules.get(0)).getURI();
                }
            }

            StringBuilder buffer = new StringBuilder();
            URIWriter writer = new URIWriter(buffer);
            controllerContext.renderURL(resource.getParameters(minified, locale), writer);
            return buffer.toString();
        } else {
            return null;
        }
    }

    public Map<ScriptResource, FetchMode> resolveIds(Map<ResourceId, FetchMode> ids) {
        return scripts.resolve(ids);
    }

    public JSONObject getJSConfig(ControllerContext controllerContext, Locale locale) throws Exception {
        JSONObject paths = new JSONObject();
        JSONObject shim = new JSONObject();

        Map<ResourceId, String> groupURLs = new HashMap<ResourceId, String>();
        for (ScriptResource resource : getAllResources()) {
            if (!resource.isEmpty() || ResourceScope.SHARED.equals(resource.getId().getScope())) {
                String name = resource.getId().toString();
                List<Module> modules = resource.getModules();

                if (FetchMode.IMMEDIATE.equals(resource.getFetchMode())
                        || (modules.size() > 0 && modules.get(0) instanceof Module.Remote)) {
                    JSONArray deps = new JSONArray();
                    for (ResourceId id : resource.getDependencies()) {
                        deps.put(getResource(id).getId());
                    }
                    if (deps.length() > 0) {
                        shim.put(name, new JSONObject().put("deps", deps));
                    }
                }

                String url;
                ScriptGroup group = resource.getGroup();
                if (group != null) {
                    ResourceId grpId = group.getId();
                    url = groupURLs.get(grpId);
                    if (url == null) {
                        url = buildURL(grpId, controllerContext, locale);
                        groupURLs.put(grpId, url);
                    }
                } else {
                    url = buildURL(resource.getId(), controllerContext, locale);
                }
                paths.put(name, url);
            }
        }

        JSONObject config = new JSONObject();
        config.put("paths", paths);
        config.put("shim", shim);
        return config;
    }

    public ScriptResource getResource(ResourceId resource) {
        return scripts.getResource(resource);
    }

    /**
     * Start service. Registry org.exoplatform.web.application.javascript.JavascriptDeployer,
     * org.exoplatform.web.application.javascript.JavascriptRemoval into ServletContainer
     *
     * @see org.picocontainer.Startable#start()
     */
    public void start() {
        log.debug("Registering JavascriptConfigService for servlet container events");
        ServletContainerFactory.getServletContainer().addWebAppListener(deployer);
    }

    /**
     * Stop service. Remove org.exoplatform.web.application.javascript.JavascriptDeployer,
     * org.exoplatform.web.application.javascript.JavascriptRemoval from ServletContainer
     *
     * @see org.picocontainer.Startable#stop()
     */
    public void stop() {
        log.debug("Unregistering JavascriptConfigService for servlet container events");
        ServletContainerFactory.getServletContainer().removeWebAppListener(deployer);
    }

    private Reader getJavascript(Module module, Locale locale) {
        if (module instanceof Module.Local) {
            Module.Local localModule = (Module.Local) module;
            final WebApp webApp = contexts.get(localModule.getContextPath());
            if (webApp != null) {
                ServletContext sc = webApp.getServletContext();
                return localModule.read(locale, sc, webApp.getClassLoader());
            }
        }
        return null;
    }

    private String buildURL(ResourceId id, ControllerContext context, Locale locale) throws Exception {
        String url = generateURL(context, id, !PropertyManager.isDevelopping(), !PropertyManager.isDevelopping(), locale);

        if (url != null && url.endsWith(".js")) {
            return url.substring(0, url.length() - ".js".length());
        } else {
            return null;
        }
    }

    private List<ScriptResource> getAllResources() {
        List<ScriptResource> resources = new LinkedList<ScriptResource>();
        for (ResourceScope scope : ResourceScope.values()) {
            resources.addAll(scripts.getResources(scope));
        }
        return resources;
    }

    private String encode(LinkedList<String> params, String alias) {
        alias = alias.replace("/", "_");
        int idx = -1;
        Iterator<String> iterator = params.descendingIterator();
        while (iterator.hasNext()) {
            String param = iterator.next();
            Matcher matcher = INDEX_PATTERN.matcher(param);
            if (matcher.matches()) {
                idx = Integer.parseInt(matcher.group(2));
                break;
            } else if (alias.equals(param)) {
                idx = 0;
                break;
            }
        }
        if (idx != -1) {
            StringBuilder tmp = new StringBuilder(alias);
            tmp.append("_").append(idx + 1);
            return tmp.toString();
        } else {
            return alias;
        }
    }

    private String parsePluginRS(String name, String pluginRS) {
        StringBuilder depBuild = new StringBuilder(name);
        if (pluginRS != null) {
            depBuild.append("!").append(pluginRS);
        }
        return depBuild.toString();
    }

    private class NormalizeJSReader extends Reader {
        private boolean finished = false;
        private boolean multiComments = false;
        private boolean singleComment = false;
        private Reader sub;

        public NormalizeJSReader(Reader sub) {
            this.sub = sub;
        }

        @Override
        public int read(char[] cbuf, int off, int len) throws IOException {
            if (finished) {
                return sub.read(cbuf, off, len);
            } else {
                char[] buffer = new char[len];
                int relLen = sub.read(buffer, 0, len);
                if (relLen == -1) {
                    finished = true;
                    return -1;
                } else {
                    int r = off;

                    for (int i = 0; i < relLen; i++) {
                        char c = buffer[i];

                        char next = 0;
                        boolean skip = false, overflow = (i + 1 == relLen);
                        if (!finished) {
                            skip = true;
                            if (!singleComment && c == '/' && (next = readNext(buffer, i, overflow)) == '*') {
                                multiComments = true;
                                i++;
                            } else if (!singleComment && c == '*' && (next = readNext(buffer, i, overflow)) == '/') {
                                multiComments = false;
                                i++;
                            } else if (!multiComments && c == '/' && next == '/') {
                                singleComment = true;
                                i++;
                            } else if (c == '\n') {
                                singleComment = false;
                            } else if (!Character.isWhitespace(c) && !Character.isSpaceChar(c) && !Character.isISOControl(c)) {
                                skip = false;
                            }

                            if (!skip && !multiComments && !singleComment) {
                                if (next != 0 && overflow) {
                                    sub = new CompositeReader(new StringReader(String.valueOf(c)), sub);
                                }
                                cbuf[r++] = c;
                                finished = true;
                            }
                        } else {
                            cbuf[r++] = c;
                        }
                    }
                    return r - off;
                }
            }
        }

        private char readNext(char[] buffer, int i, boolean overflow) throws IOException {
            char c = 0;
            if (overflow) {
                int tmp = sub.read();
                if (tmp != -1) {
                    c = (char) tmp;
                }
            } else {
                c = buffer[i + 1];
            }
            return c;
        }

        @Override
        public void close() throws IOException {
            sub.close();
        }
    }
}
