package edu.mit.simile.butterfly;

import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.io.Reader;
import java.io.StringWriter;
import java.io.Writer;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Timer;
import java.util.TimerTask;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.servlet.ServletConfig;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.commons.collections.ExtendedProperties;
import org.apache.commons.collections.OrderedMap;
import org.apache.commons.collections.OrderedMapIterator;
import org.apache.commons.collections.map.ListOrderedMap;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.VelocityEngine;
import org.apache.velocity.exception.ResourceNotFoundException;
import org.mozilla.javascript.Context;
import org.mozilla.javascript.ContextAction;
import org.mozilla.javascript.ContextFactory;
import org.mozilla.javascript.EcmaError;
import org.mozilla.javascript.Function;
import org.mozilla.javascript.Script;
import org.mozilla.javascript.Scriptable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.metaweb.lessen.Utilities;
import com.metaweb.lessen.tokenizers.CondensingTokenizer;
import com.metaweb.lessen.tokenizers.IndentingTokenizer;
import com.metaweb.lessen.tokenizers.Tokenizer;


/**
 * This class is the base implementation of ButterflyModule and 
 * implements the basic functionality that is made available to Butterfly
 * modules. If you want special functionality that Butterfly does not
 * expose to your modules, it is highly suggested that you extend this
 * class instead of implementing the ButterflyModule interface yourself
 */
public class ButterflyModuleImpl implements ButterflyModule {

    protected static final Logger _logger = LoggerFactory.getLogger("butterfly.module");
    
    protected ClassLoader _classLoader;
    protected Timer _timer;
    protected ServletConfig _config;
    protected File _path;
    protected Path _normalizedPath;
    protected MountPoint _mountPoint;
    protected ButterflyMounter _mounter;
    protected String _name;
    protected File _tempDir;
    protected ButterflyModule _extended;
    protected Set<ButterflyModule> _extendedBy = new LinkedHashSet<ButterflyModule>();
    protected Set<String> _implementations = new LinkedHashSet<String>();
    protected Map<String,ButterflyModule> _dependencies = new HashMap<String,ButterflyModule>();
    protected ExtendedProperties _properties;
    protected Map<String,ButterflyModule> _modules;
    protected VelocityEngine _templateEngine;
    protected OrderedMap _scripts = new ListOrderedMap();
    protected Set<ButterflyScriptableObject> _scriptables = new LinkedHashSet<ButterflyScriptableObject>();
    protected Set<TimerTask> _packers = new HashSet<TimerTask>();
    
    // ------------------------------------------------------------------------------------------------
    
    public void init(ServletConfig config) throws Exception {
        _config = config;
        
        scriptInit();
    }
    
    public void destroy() throws Exception {
        for (ButterflyScriptableObject scriptable : _scriptables) {
        	scriptable.destroy();
        }
    }
        
    public ServletConfig getServletConfig() {
    	return _config;
    }

    public ServletContext getServletContext() {
    	return _config.getServletContext();
    }
    
    // ------------------------------------------------------------------------------------------------

    public void setClassLoader(ClassLoader classLoader) {
        _logger.trace("{} -(classloader)-> {}", this, classLoader);
        this._classLoader = classLoader;
    }
    
    public void setPath(File path) {
        _logger.trace("{} -(path)-> {}", this, path);
        this._path = path;
        this._normalizedPath = path.toPath().toAbsolutePath().normalize();
    }

    public void setName(String name) {
        _logger.trace("{} -(name)-> {}", this, name);
        this._name = name;
    }

    public void setExtended(ButterflyModule extended) {
        _logger.trace("{} -(extends)-> {}", this, extended);
        this._extended = extended;
    }

    public void addExtendedBy(ButterflyModule extendedBy) {
        _logger.trace("{} -(extended by)-> {}", this, extendedBy);
        this._extendedBy.add(extendedBy);
    }
    
    public void setMountPoint(MountPoint mountPoint) {
        _logger.trace("{} -(mount point)-> {}", this, mountPoint);
        this._mountPoint = mountPoint;
    }

    public void setImplementation(String id) {
        _logger.trace("{} -(implements)-> {}", this, id);
        this._implementations.add(id);
    }
    
    public void setDependency(String name, ButterflyModule module) {
        if (!this._dependencies.containsKey(name)) {
            _logger.trace("{} -({})-> {}", new Object[] { this, name, module} );
            this._dependencies.put(name, module);
        }
    }

    public void setModules(Map<String,ButterflyModule> map) {
        this._modules = map;
    }
    
    @SuppressWarnings("unchecked")
    public void setScript(URL url, Script script) {
        _logger.trace("{} -(script)-> {}", this, url);
        this._scripts.put(url,script);
    }

    public void setScriptable(ButterflyScriptableObject scriptable) {
        _logger.trace("{} -(scriptable)-> {}", this, scriptable.getClassName());
        this._scriptables.add(scriptable);
    }
    
    public void setTemplateEngine(VelocityEngine templateEngine) {
        _logger.trace("{} gets template engine", this);
        this._templateEngine = templateEngine;
    }
        
    public void setProperties(ExtendedProperties properties) {
        _logger.trace("{} gets loaded with properties", this);
        this._properties = properties;
    }

    public void setMounter(ButterflyMounter mounter) {
        _logger.trace("{} gets the module mounter", this);
        this._mounter = mounter;
    }
    
    public void setTemporaryDir(File tempDir) {
        _logger.trace("{} -(tempDir)-> {}", this, tempDir);
        this._tempDir = tempDir;
        try {
            FileUtils.deleteDirectory(tempDir);
            tempDir.mkdirs();
        } catch (Exception e) {
            _logger.error("Error cleaning temporary directory", e);
        }
    }
    
    public void setTimer(Timer timer) {
        _logger.trace("{} gets timer", this);
        this._timer = timer;
    }
    
    // ------------------------------------------------------------------------------------------------
    
    public String getName() {
        return this._name;
    }

    public File getPath() {
        return this._path;
    }

    public ExtendedProperties getProperties() {
        return this._properties;
    }
    
    public MountPoint getMountPoint() {
        return this._mountPoint;
    }
    
    public ButterflyMounter getMounter() {
        return this._mounter;
    }

    public ButterflyModule getExtendedModule() {
        return this._extended;
    }

    public Set<ButterflyModule> getExtendingModules() {
        return this._extendedBy;
    }
    
    public Map<String,ButterflyModule> getDependencies() {
    	return this._dependencies;
    }

    public Set<String> getImplementations() {
    	return this._implementations;
    }
    
    public Set<ButterflyScriptableObject> getScriptables() {
    	return this._scriptables;
    }
    
    public VelocityEngine getTemplateEngine() {
    	return this._templateEngine;
    }
    
    public ButterflyModule getModule(String name) {
        _logger.trace("> getModule({}) [{}]", name, this._name);
        ButterflyModule module = _dependencies.get(name);
        if (module == null && _extended != null) {
            module = _extended.getModule(name);
        }
        if (module == null) {
            module = _modules.get(name);
        }
        _logger.trace("< getModule({}) [{}] -> {}", new Object[] { name, this._name,  module });
        return module;
    }

    protected Pattern super_pattern = Pattern.compile("^@@(.*)@@$");
    
    // TODO 2025-10: migrate away from URL as a return type to File/Path as we don't want this to fetch anything remote
    @Override
    public URL getResource(String resource) {
        _logger.trace("> getResource({}->{},{})", new Object[] { _name, _extended, resource });
        URL u = null;

        if ("".equals(resource)) {
            try {
                u = _path.toURI().toURL();
            } catch (MalformedURLException e) {
                _logger.error("Error", e);
            }
        }
        
        if (u == null && resource.charAt(0) == '@') { // fast screening for potential matchers
            Matcher m = super_pattern.matcher(resource);
            if (m.matches()) {
                resource = m.group(1);
                if (_extended != null) {
                    u = _extended.getResource(resource);
                }
            }
        }
        
        if (u == null) {
            try {
                if (resource.charAt(0) == '/') resource = resource.substring(1);
                File f = new File(_path, resource);
                // check that the file does not escape the expected directory
                if (f.toPath().toAbsolutePath().normalize().startsWith(_normalizedPath) && f.exists()) {
                    u = f.toURI().toURL();
                }
            } catch (MalformedURLException e) {
                _logger.error("Error", e);
            }
        }

        if (u == null && _extended != null) {
            u = _extended.getResource(resource);
        }
        
        _logger.trace("< getResource({}->{},{}) -> {}", new Object[] { _name, _extended, resource, u });
        return u;
    }

    public String getRelativePath(HttpServletRequest request) {
        _logger.trace("> getRelativePath()");
        String path = request.getPathInfo();
        String mountPoint = _mountPoint.getMountPoint();
        if (path.startsWith(mountPoint)) {
            path = path.substring(mountPoint.length());
        }
        _logger.trace("< getRelativePath() -> {}", path);
        return path;
    }
    
    public PrintWriter getFilteringWriter(HttpServletRequest request, HttpServletResponse response, boolean absolute) throws IOException {
        _logger.trace("> getFilteringWriter() [{},absolute='{}']", this , absolute);
        LinkRewriter rewriter =  new LinkRewriter(response.getWriter(), this, getContextPath(request, absolute));
        _logger.trace("< getFilteringWriter() [{},absolute='{}']", this , absolute);
        return rewriter;
    }

    public String getContextPath(HttpServletRequest request, boolean absolute) {
        return Butterfly.getTrueContextPath(request, absolute);
    }

    public String getString(HttpServletRequest request) throws IOException {
        BufferedReader reader = request.getReader();
        StringWriter writer = new StringWriter();
        IOUtils.copy(reader, writer);
        writer.close();
        reader.close();
        return writer.toString();
    }
        
    public File getTemporaryDir() {
        return this._tempDir;
    }
    
    // ------------------------------------------------------------------------------------------------
    
    protected Pattern images_pattern = Pattern.compile("^/?.*\\.(jpg|gif|png)$");
    protected Pattern mod_inf_pattern = Pattern.compile("^.*/MOD-INF/.*$");

    protected String encoding = "UTF-8";
    
    /*
     * This class encapsulates the 'context action' that Rhino executes
     * when a request comes.
     */
    class Controller implements ContextAction {

        private String _path;
        private HttpServletRequest _request;
        private HttpServletResponse _response;
        private Scriptable _scope;
        
        public Controller(String path, HttpServletRequest request, HttpServletResponse response) {
            _path = path;
            _request = request;
            _response = response;
        }
        
        public Object run(Context context) {
            Scriptable scope;
            try {
                scope = getScope(context, _request);                
            } catch (Exception e) {
                throw new RuntimeException ("Error retrieving scope", e);
            }

            initScope(context,scope);

            return process(context, scope);
        }
        
        /*
         * tell whether or not the controller processed the request
         * or let it fall thru
         */
        public boolean didRespond() {
            return ((ScriptableButterfly) _scope.get("butterfly", _scope)).didRespond();
        }

        /*
         * process the request by invoking the controller "process()" function 
         */
        private Object process(Context context, Scriptable scope) {
            _scope = scope; // save this for the didRespond() method above;
            Function function = getFunction("process", scope, context);
            Object[] args = new Object[] {
                Context.javaToJS(_path, scope),
                Context.javaToJS(_request, scope),
                Context.javaToJS(_response, scope)
            };
            return function.call(context, scope, scope, args);
        }
        
        /*
         * obtain a javascript function from the given scope
         */
        private Function getFunction(String name, Scriptable scope, Context ctx) {
            Object fun;
            try {
                fun = ctx.compileString(name, null, 1, null).exec(ctx, scope);
            } catch (EcmaError ee) {
                throw new RuntimeException ("Function '" + name + "()' not found.");
            }
            
            if (!(fun instanceof Function)) {
                throw new RuntimeException("'" + name + "' is not a function");
            } else {
                return (Function) fun;
            }
        }
    }
    
    /**
     * This method is called by Butterfly when preProcess returns false and allows
     * modules that want to have a controller in Java instead of Javascript.
     */
    public boolean process(String path, HttpServletRequest request, HttpServletResponse response) throws Exception {

        if (processScript(path, request, response)) {
            return true;
        }
        
        Matcher m = null;

        if (path.equals("") || path.endsWith("/")) {
            return sendText(request, response, path + "index.html", encoding, "text/html",false);
        }
        
        if (path.endsWith(".js")) {
            return sendText(request, response, path, encoding, "text/javascript",false);
        }

        m = images_pattern.matcher(path);
        if (m.matches()) {
            return sendBinary(request, response, path, "image/" + m.group(1));
        }

        if (path.endsWith(".css")) {
            return sendText(request, response, path, encoding, "text/css",false);
        }

        if (path.endsWith(".less")) {
            return sendLessen(request, response, path, encoding, "text/css",false);
        }

        if (path.endsWith(".html")) {
            return sendText(request, response, path, encoding, "text/html",false);
        }

        if (path.endsWith(".xml")) {
            return sendText(request, response, path, encoding, "application/xml",false);
        }

        if (path.endsWith(".svg"))  {
            return sendText(request, response, path, encoding, "image/svg+xml",false);
        }
                
        m = mod_inf_pattern.matcher(path);
        if (m.matches()) {
            response.sendError(HttpServletResponse.SC_FORBIDDEN);
            return true;
        }
        
        return false;
    }
    
    // ------------------------------------------------------------------------------------------------
    
    public boolean redirect(HttpServletRequest request, HttpServletResponse response, String location) throws Exception {
    	_logger.trace("> redirect: {}", location);
        String redirectURL = location;
        if (!location.startsWith("http://") && !location.startsWith("https://")) {
        	String contextPath = getContextPath(request,true);
	        String servletPath = request.getServletPath();
            if ("".equals(location)) {
                location = "/";
            }
	        if (servletPath != null) {
	        	if (servletPath.startsWith("/")) servletPath = servletPath.substring(1);
		        redirectURL = contextPath + servletPath + location;
	        } else {
		        redirectURL = contextPath + location;
	        }
        }
        _logger.info("redirecting to: {}", redirectURL);        
    	response.sendRedirect(redirectURL);
        _logger.trace("< redirect: {}", location);
        return true;
    }
        
    public boolean sendBinary(HttpServletRequest request, HttpServletResponse response, String file, String mimeType) throws Exception {
        return send(request, response, getResource(file), false, null, mimeType, null, null, false);
    }
    
    public boolean sendBinary(HttpServletRequest request, HttpServletResponse response, URL resource, String mimeType) throws Exception {
        return send(request, response, resource, false, null, mimeType, null, null, false);
    }

    public boolean sendText(HttpServletRequest request, HttpServletResponse response, String file, String encoding, String mimeType, boolean absolute) throws Exception {
        return send(request, response, getResource(file), true, encoding, mimeType, null, null, absolute);
    }
    
    public boolean sendText(HttpServletRequest request, HttpServletResponse response, URL resource, String encoding, String mimeType, boolean absolute) throws Exception {
        return send(request, response, resource, true, encoding, mimeType, null, null, absolute);
    }
    
    public boolean sendWrappedText(HttpServletRequest request, HttpServletResponse response, URL resource, String encoding, String mimeType, String prologue, String epilogue, boolean absolute) throws Exception {
        return send(request, response, resource, true, encoding, mimeType, prologue, epilogue, absolute);
    }
        
    public boolean sendTextFromTemplate(HttpServletRequest request, HttpServletResponse response, VelocityContext velocity, String template, String encoding, String mimeType, boolean absolute) throws Exception {
        _logger.trace("> template {} [{}|{}]", new String[] { template, encoding, mimeType });
        try {
            response.setContentType(mimeType);
            _templateEngine.mergeTemplate(template, encoding, velocity, getFilteringWriter(request, response, absolute));
        } catch (ResourceNotFoundException e) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND);
        }
        _logger.trace("< template {} [{}|{}]", new String[] { template, encoding, mimeType });
        return true; 
    }
    
    public boolean sendLessen(HttpServletRequest request, HttpServletResponse response, String path, String encoding, String mimeType, boolean absolute) throws Exception {
        URL url = getResource(path);
        
        Map<String, String> variables = new HashMap<String, String>();
        variables.put("module", _name);
        
        Tokenizer tokenizer = Utilities.openLess(url, variables);
        tokenizer = new CondensingTokenizer(tokenizer, false);
        tokenizer = new IndentingTokenizer(tokenizer);
        
        return sendLessenTokenStream(request, response, tokenizer, encoding, "text/css",false);
    }
    
    public boolean sendLessenTokenStream(HttpServletRequest request, HttpServletResponse response, Tokenizer tokenizer, String encoding, String mimeType, boolean absolute) throws Exception {
        try {
            response.setContentType(mimeType);
            Utilities.write(tokenizer, getFilteringWriter(request, response, absolute));
        } catch (ResourceNotFoundException e) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND);
        }
        return true; 
    }

    public boolean sendString(HttpServletRequest request, HttpServletResponse response, String str, String encoding, String mimeType) throws Exception {
        _logger.trace("> string: '{}'", str);
        response.setContentType(mimeType);
        response.setCharacterEncoding(encoding);
        PrintWriter writer = response.getWriter();
        writer.write(str);
        writer.close();
        _logger.trace("< string: '{}'", str);
        return true; 
    }

    public boolean sendError(HttpServletRequest request, HttpServletResponse response, int code, String str) throws Exception {
        _logger.trace("> error: '{}' '{}'", code, str);
        response.sendError(code, str);
        _logger.trace("< error: '{}' '{}'", code, str);
        return true; 
    }
    
    // ------------------------------------------------------------------------------------------------
    
    public static class Level {
        String name;
        String href;

        public Level(String name, String href) {
            this.name = name;
            this.href = href;
        }
        
        public String getName() {
            return name;
        }
        
        public String getHref() {
            return href;
        }
    }
    
    public List<Level> makePath(String path, Map<String,String> descs) {
        LinkedList<Level> ls = new LinkedList<Level>();
        String[] paths = path.split("/");
        if (paths.length > 1) {
            String relativePath = (path.endsWith("/")) ? "../" : "./";
            for (int i = paths.length - 2; i >= 0; i--) {
                String p = paths[i];
                String desc = descs.get(paths[i]);
                if (desc == null) desc = p;
                Level l = new Level(desc,relativePath);
                relativePath = "../" + relativePath;
                ls.addFirst(l);
            }
        }
        return ls;
    }

    public String toString() {
        return _name + " [" + this.getClass().getName() + "]";
    }

    public void initScope(Context context, Scriptable scope) {
        for (ButterflyModule m : _dependencies.values()) {
            m.initScope(context, scope);
        }
        
        OrderedMapIterator i = _scripts.orderedMapIterator();
        while (i.hasNext()) {
            URL url = (URL) i.next();
            _logger.debug("Executing script: {}", url);
            Script s = (Script) _scripts.get(url);
            s.exec(context, scope);
        }
    }
    
    // ------------------------------------------------------------------------------------------------

    protected void scriptInit() throws Exception {
        Context context = ContextFactory.getGlobal().enterContext();
        Scriptable scope = new ButterflyScope(this, context);
        
        initScope(context,scope);
        
        String functionName = "init";
        try {
            Object fun = context.compileString(functionName, null, 1, null).exec(context, scope);
            if (fun != null && fun instanceof Function) {
                try {
                    ((Function) fun).call(context, scope, scope, new Object[] {});
                } catch (EcmaError ee) {
                    _logger.error("Error initializing module " + getName() + " by script function init()", ee);
                }
            }
        } catch (EcmaError ee) {
            // ignore
        }
    }
    
    protected boolean processScript(String path, HttpServletRequest request, HttpServletResponse response) throws Exception {
        boolean result = false;
        if (_scripts.size() > 0) {
            Controller controller = new Controller(path, request, response);
            ContextFactory.getGlobal().call(controller);
            result = controller.didRespond();
        }
        if (!result && _extended != null && _extended instanceof ButterflyModuleImpl) {
            result = ((ButterflyModuleImpl) _extended).processScript(path, request, response);
        }
        return result;
    }

    protected ButterflyScope getScope(Context context, HttpServletRequest request) throws Exception {
        return new ButterflyScope(this, context);
    }
    
    protected boolean send(HttpServletRequest request, HttpServletResponse response, URL resource, boolean filtering, String encoding, String mimeType, String prologue, String epilogue, boolean absolute) throws Exception {
        _logger.trace("> send {}", resource);

        if (resource != null) {
            URLConnection urlConnection = resource.openConnection();
            
            // NOTE(SM): I've disabled the HTTP caching-related headers for now 
            //           we should introduce this back in the future once we start
            //           fine tuning the system but for now since it's hard to 
            //           understand when ajax calls are cached or not it's better
            //           to develop without worrying about the cache
            
//            long lastModified = urlConnection.getLastModified();
//
//            long ifModifiedSince = request.getDateHeader("If-Modified-Since");
//            if (lastModified == 0 || ifModifiedSince / 1000 < lastModified / 1000) {
//                response.setDateHeader("Last-Modified", lastModified);
              
                if (encoding == null) {
                    InputStream input = null;
                    OutputStream output = null;
                    try {
                        input = new BufferedInputStream(urlConnection.getInputStream()); 
                        response.setHeader("Content-Type", mimeType);
                        output = response.getOutputStream();
                        IOUtils.copy(input, output);
                    } catch (Exception e) {
                        _logger.error("Error processing " + resource, e);
                    } finally {
                        if (input != null) input.close();
                        if (output != null) output.close();
                    }
                } else {
                    Reader input = null;
                    Writer output = null;
                    try {
                        input = new BufferedReader(new InputStreamReader(urlConnection.getInputStream(), encoding));
                        response.setHeader("Content-Type", mimeType + ";charset=" + encoding);
                        response.setCharacterEncoding(encoding);
                        output = (filtering) ? getFilteringWriter(request, response, absolute) : response.getWriter();
                        
                        if (prologue != null) {
                            output.write(prologue);
                        }
                        
                        IOUtils.copy(input, output);
                        
                        if (epilogue != null) {
                            output.write(epilogue);
                        }
                    } catch (Exception e) {
                        _logger.error("Error processing " + resource, e);
                    } finally {
                        if (input != null) input.close();
                        if (output != null) output.close();
                    }
                }
//            } else {
//                response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
//            }
        } else {
            response.sendError(HttpServletResponse.SC_NOT_FOUND, "Couldn't find the specified resource");
        }

        _logger.trace("< send {}", resource);
        return true;
    }
}
