/**
 * Packer version 3.0 (final)
 * Copyright 2004-2007, Dean Edwards
 * Web: {@link http://dean.edwards.name/}
 * 
 * This software is licensed under the MIT license
 * Web: {@link http://www.opensource.org/licenses/mit-license}
 * 
 * Ported to Java by Pablo Santiago based on C# version by Jesse Hansen, <twindagger2k @ msn.com>
 * Web: {@link http://jpacker.googlecode.com/} 
 * Email: <pablo.santiago @ gmail.com>
 */
package com.jpacker;

import java.util.ArrayList;
import java.util.Formatter;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import com.jpacker.encoders.BasicEncoder;
import com.jpacker.encoders.Encoder;
import com.jpacker.strategies.DefaultReplacementStrategy;
import com.jpacker.strategies.ReplacementStrategy;

/**
 * Packer class.
 * 
 * Main jPacker class that packs the script.
 * 
 * @author Pablo Santiago <pablo.santiago @ gmail.com>
 */
public class JPackerExecuter {

    private JPackerEncoding encoding;
    private static final String UNPACK = "eval(function(p,a,c,k,e,r){e=%5$s;if(!''.replace(/^/,String)){while(c--)r[%6$s]=k[c]"
            + "||%6$s;k=[function(e){return r[e]}];e=function(){return'\\\\w+'};c=1};while(c--)if(k[c])p=p."
            + "replace(new RegExp('\\\\b'+e(c)+'\\\\b','g'),k[c]);return p}('%1$s',%2$s,%3$s,'%4$s'.split('|'),0,{}))";

    /**
     * Constructor
     *
     * @param encoding
     *            The encoding level for this instance
     */
    public JPackerExecuter(JPackerEncoding encoding) {
        setEncoding(encoding);
    }

    /**
     * Packs the script
     *
     * @param script
     *            The script to pack
     * @param minifyOnly
     *            True if script should only be minified and not encoded and/or
     *            its variables shrunk, false otherwise.
     * @param shrinkVariables
     *            True if variables should be shrunk, false otherwise. If
     *            minifyOnly is true, this option has no side effect.
     * @return The packed script
     */
    public String pack(String script, boolean minifyOnly, boolean shrinkVariables) {
        script += "\n";
        script = minify(script);        
        if (!minifyOnly) {
            if (shrinkVariables) {
                script = shrinkVariables(script);
            }            
            if (encoding != JPackerEncoding.NONE) {
                script = encode(script);
            }
        }
        return script;
    }

    // zero encoding - just removal of whitespace and comments
    private String minify(String script) {
        JPackerParser parser = new JPackerParser();
        ReplacementStrategy defaultStrat = new DefaultReplacementStrategy();
        // protect data
        parser = addDataRegEx(parser);
        script = parser.exec(script, defaultStrat);
        // remove white-space
        parser = addWhiteSpaceRegEx(parser);
        script = parser.exec(script, defaultStrat);
        // clean
        parser = addCleanUpRegEx(parser);
        script = parser.exec(script, defaultStrat);
        // done
        return script;
    }

    private JPackerParser addDataRegEx(JPackerParser parser) {
        final String COMMENT1 = "(\\/\\/|;;;)[^\\n]*";
        final String COMMENT2 = "\\/\\*[^*]*\\*+([^\\/][^*]*\\*+)*\\/";
        final String REGEX = "\\/(\\\\[\\/\\\\]|[^*\\/])(\\\\.|[^\\/\\n\\\\])*\\/[gim]*";

        // Packer.CONTINUE
        parser.remove("\\\\\\r?\\n");

        parser.ignore("'(\\\\.|[^'\\\\])*'");
        parser.ignore("\"(\\\\.|[^\"\\\\])*\"");
        parser.ignore("\\/\\*@|@\\*\\/|\\/\\/@[^\\n]*\\n");
        parser.replace("(" + COMMENT1 + ")\\n\\s*(" + REGEX + ")?", "\n$3");
        parser.replace("(" + COMMENT2 + ")\\s*(" + REGEX + ")?", " $3");
        parser.replace("([\\[\\(\\^=,{}:;&|!*?])\\s*(" + REGEX + ")", "$1$2");
        return parser;
    }

    private JPackerParser addCleanUpRegEx(JPackerParser parser) {
        parser.replace("\\(\\s*;\\s*;\\s*\\)", "(;;)");
        parser.ignore("throw[};]+[};]"); // safari 1.3 bug
        parser.replace(";+\\s*([};])", "$1");
        parser.remove(";;[^\\n\\r]+[\\n\\r]");
        return parser;
    }

    private JPackerParser addWhiteSpaceRegEx(JPackerParser parser) {
        parser.replace("(\\d)\\s+(\\.\\s*[a-z\\$_\\[\\(])", "$1 $2");
        parser.replace("([+\\-])\\s+([+\\-])", "$1 $2");
        parser.replace("(\\b|\\$)\\s+(\\b|\\$)", "$1 $2");
        parser.replace("\\b\\s+\\$\\s+\\b", " $ ");
        parser.replace("\\$\\s+\\b", "$ ");
        parser.replace("\\b\\s+\\$", " $");
        parser.replace("\\b\\s+\\b", " ");
        parser.remove("\\s+");
        return parser;
    }

    private String shrinkVariables(String script) {
        final Pattern pattern = Pattern.compile("^[^'\"]\\/");
        // identify blocks, particularly identify function blocks (which define
        // scope)
        Pattern blockPattern = Pattern.compile("(function\\s*[\\w$]*\\s*\\(\\s*([^\\)]*)\\s*\\)\\s*)?(\\{([^{}]*)\\})");
        List<String> blocks = new ArrayList<String>(); // store program blocks
        // (anything between
        // braces {})

        final List<String> data = new ArrayList<String>(); // encoded strings
        // and regular
        // expressions

        JPackerParser parser = new JPackerParser();
        parser = addDataRegEx(parser);
        script = parser.exec(script, new ReplacementStrategy() {

            @Override
            public String replace(List<JPackerPattern> patterns, Matcher matcher) {
                String replacement = "#" + data.size();
                String string = matcher.group();
                if (pattern.matcher(string).find()) {
                    replacement = string.charAt(0) + replacement;
                    string = string.substring(1);
                }
                data.add(string);
                return replacement;
            }
        });

        do {
            // put the blocks back
            Matcher blockMatcher = blockPattern.matcher(script);
            StringBuffer sb = new StringBuffer();
            while (blockMatcher.find()) {
                blockMatcher.appendReplacement(sb, encodeBlock(blockMatcher, blocks));
            }
            blockMatcher.appendTail(sb);
            script = sb.toString();
        } while (blockPattern.matcher(script).find());

        while (Pattern.compile("~(\\d+)~").matcher(script).find()) {
            script = decodeBlock(script, blocks);
        }
        // put strings and regular expressions back
        Matcher storeMatcher = Pattern.compile("#(\\d+)").matcher(script);
        StringBuffer sb2 = new StringBuffer();
        while (storeMatcher.find()) {
            int num = Integer.parseInt(storeMatcher.group(1));
            storeMatcher.appendReplacement(sb2, Matcher.quoteReplacement(data.get(num)));
        }
        storeMatcher.appendTail(sb2);

        return sb2.toString();
    }

    private String encode(String script) {
        JPackerWords words = new JPackerWords(script, encoding);

        Pattern wordsPattern = Pattern.compile("\\w+");
        Matcher wordsMatcher = wordsPattern.matcher(script);
        StringBuffer sb = new StringBuffer();
        while (wordsMatcher.find()) {
            JPackerWord tempWord = new JPackerWord(wordsMatcher.group());
            wordsMatcher.appendReplacement(sb, words.find(tempWord).getEncoded());
        }
        wordsMatcher.appendTail(sb);

        int ascii = Math.min(Math.max(words.getWords().size(), 2), encoding.getEncodingBase());

        String p = escape(sb.toString());
        String a = String.valueOf(ascii);
        String c = String.valueOf(words.getWords().size());
        String k = words.toString();
        String e = getEncode(ascii);
        String r = ascii > 10 ? "e(c)" : "c";

        return new Formatter().format(UNPACK, p, a, c, k, e, r).toString();
    }

    // encoder for program blocks
    private String encodeBlock(Matcher matcher, List<String> blocks) {

        String block = matcher.group();
        String func = matcher.group(1);
        String args = matcher.group(2);

        if (func != null && !func.isEmpty()) { // the block is a function block
            // decode the function block (THIS IS THE IMPORTANT BIT)
            // We are retrieving all sub-blocks and will re-parse them in light
            // of newly shrunk variables
            while (Pattern.compile("~(\\d+)~").matcher(block).find()) {
                block = decodeBlock(block, blocks);
            }

            // create the list of variable and argument names
            Pattern varNamePattern = Pattern.compile("var\\s+[\\w$]+");
            Matcher varNameMatcher = varNamePattern.matcher(block);
            StringBuilder sb = new StringBuilder();
            while (varNameMatcher.find()) {
                sb.append(varNameMatcher.group()).append(",");
            }

            String vars = "";
            if (!sb.toString().isEmpty()) {
                vars = sb.deleteCharAt(sb.length() - 1).toString().replaceAll("var\\s+", "");
            }

            String[] ids = concat(args.split("\\s*,\\s*"), vars.split("\\s*,\\s*"));
            Set<String> idList = new LinkedHashSet<String>();
            for (String s : ids) {
                if (!s.isEmpty()) {
                    idList.add(s);
                }
            }
            // process each identifier
            int count = 0;
            String shortId;
            for (String id : idList) {
                id = id.trim();
                if (id.length() > 1) { // > 1 char
                    id = Matcher.quoteReplacement(id);
                    // find the next free short name (check everything in the
                    // current scope)
                    Encoder e = new BasicEncoder();
                    do {
                        shortId = e.encode(count++);
                    } while (Pattern.compile("[^\\w$.]" + shortId + "[^\\w$:]").matcher(block).find());
                    // replace the long name with the short name
                    while (Pattern.compile("([^\\w$.])" + id + "([^\\w$:])").matcher(block).find()) {
                        block = block.replaceAll("([^\\w$.])" + id + "([^\\w$:])", "$1" + shortId + "$2");
                    }
                    block = block.replaceAll("([^{,\\w$.])" + id + ":", "$1" + shortId + ":");
                }
            }
        }
        String replacement = "~" + blocks.size() + "~";
        blocks.add(block);
        return replacement;
    }

    private String decodeBlock(String block, List<String> blocks) {
        Matcher encoded = Pattern.compile("~(\\d+)~").matcher(block);
        StringBuffer sbe = new StringBuffer();
        while (encoded.find()) {
            int num = Integer.parseInt(encoded.group(1));
            encoded.appendReplacement(sbe, Matcher.quoteReplacement(blocks.get(num)));
        }
        encoded.appendTail(sbe);
        return sbe.toString();
    }

    private String[] concat(String[] a, String[] b) {
        String[] c = new String[a.length + b.length];
        System.arraycopy(a, 0, c, 0, a.length);
        System.arraycopy(b, 0, c, a.length, b.length);
        return c;
    }

    private String getEncode(int ascii) {
        if (ascii > 96) {
            return JPackerEncoding.HIGH_ASCII.getEncode();
        } else if (ascii > 36) {
            return JPackerEncoding.NORMAL.getEncode();
        } else if (ascii > 10) {
            return JPackerEncoding.MID.getEncode();
        } else {
            return JPackerEncoding.NUMERIC.getEncode();
        }
    }

    private String escape(String input) {
        // single quotes wrap the final string so escape them
        // also escape new lines required by conditional comments
        return input.replaceAll("([\\\\'])", "\\\\$1").replaceAll("[\\r\\n]+", "\\n");
    }

    /**
     * Encoding level. Options are: {@link JPackerEncoding#NONE},
     * {@link JPackerEncoding#NUMERIC}, {@link JPackerEncoding#MID},
     * {@link JPackerEncoding#NORMAL} and {@link JPackerEncoding#HIGH_ASCII}.
     *
     * @return The current encoding level
     */
    public JPackerEncoding getEncoding() {
        return encoding;
    }

    /**
     * Set the encoding level to use.
     *
     * @param encoding
     */
    public final void setEncoding(JPackerEncoding encoding) {
        this.encoding = encoding;
    }
}
