/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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 org.apache.brooklyn.util.text;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.annotation.Nonnull;

import org.apache.brooklyn.util.collections.MutableList;
import org.apache.brooklyn.util.exceptions.Exceptions;
import org.apache.brooklyn.util.guava.Maybe;
import org.apache.brooklyn.util.net.URLParamEncoder;
import org.apache.brooklyn.util.yaml.Yamls;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.base.Function;
import com.google.common.collect.Iterables;

public class StringEscapes {

    private static final Logger log = LoggerFactory.getLogger(StringEscapes.class);
    
    /** if s is wrapped in double quotes containing no unescaped double quotes */
    public static boolean isWrappedInDoubleQuotes(String s) {
        if (Strings.isEmpty(s)) return false;
        if (!s.startsWith("\"") || !s.endsWith("\"")) return false;
        return (s.substring(1, s.length()-1).replace("\\\\", "").replace("\\\"", "").indexOf("\"")==-1);
    }

    /** if s is wrapped in single quotes containing no unescaped single quotes */
    public static boolean isWrappedInSingleQuotes(String s) {
        if (Strings.isEmpty(s)) return false;
        if (!s.startsWith("\'") || !s.endsWith("\'")) return false;
        return (s.substring(1, s.length()-1).replace("\\\\", "").replace("\\\'", "").indexOf("\'")==-1);
    }

    /** if s is wrapped in single or double quotes containing no unescaped quotes of that type */
    public static boolean isWrappedInMatchingQuotes(String s) {
        return isWrappedInDoubleQuotes(s) || isWrappedInSingleQuotes(s);
    }

    /**
     * Encodes a string suitable for use as a parameter in a URL.
     */
    public static String escapeUrlParam(String input) {
        return URLParamEncoder.encode(input);
    }

    /** 
     * Encodes a string suitable for use as a URL in an HTML form: space to +, and high-numbered chars assuming UTF-8.
     * However, it will also convert the first "http://" to "http%3A%2F%2F" so is not suitable for converting an 
     * entire URL.
     * 
     * Also note that parameter-conversion doesn't work in way you'd expect when trying to create a "normal" url.
     * See http://stackoverflow.com/questions/724043/http-url-address-encoding-in-java
     * 
     * @see escapeUrlParam(String), and consider using that instead.
     */
    public static String escapeHtmlFormUrl(String url) {
        try {
            return URLEncoder.encode(url, "UTF-8");
        } catch (UnsupportedEncodingException e) {
            throw Exceptions.propagate(e);
        }
    }

    /** encodes a string to SQL, that is ' becomes '' */
    public static String escapeSql(String x) {
        //identical to apache commons StringEscapeUtils.escapeSql
        if (x==null) return null;
        return x.replaceAll("'", "''");
    }
    
    
    
    public static class BashStringEscapes {
        // single quotes don't permit escapes!  e.g. echo 'hello \' world' doesn't work;
        // you must do 'hello '\'' world' (to get "hello ' world")

        public static class WrapBashFunction implements Function<String, String> {
            @Override
            public String apply(String input) {
                return wrapBash(input);
            }
        }
        public static Function<String, String> wrapBash() {
            return new WrapBashFunction();
        }

        /** wraps plain text in double quotes escaped for use in bash double-quoting */
        public static String wrapBash(String value) {
            StringBuilder out = new StringBuilder();
            try {
                wrapBash(value, out);
            } catch (IOException e) {
                //shouldn't happen for string buffer
                throw Exceptions.propagate(e);
            }
            return out.toString();
        }

        /** @see #wrapBash(String) */
        public static void wrapBash(String value, Appendable out) throws IOException {
            out.append('"');
            escapeLiteralForDoubleQuotedBash(value, out);
            out.append('"');
        }

        private static void escapeLiteralForDoubleQuotedBash(String value, Appendable out) throws IOException {
            for (int i=0; i<value.length(); i++) {
                char c = value.charAt(i);
                if (c=='\\' || c=='\"' || c=='$' || c=='`') {
                    appendEscaped(out, c);
                } else if (c == '!') {
                    out.append("\"'!'\"");
                } else {
                    out.append(c);
                }
            }
        }

        /** performs replacements on a string so that it can be legally inserted into a double-quoted bash context 
         * (without the surrounding double quotes; see also {@link #wrapBash(String)}) */
        public static String escapeLiteralForDoubleQuotedBash(String unquotedInputToBeEscaped) {
            StringBuilder out = new StringBuilder();
            try {
                escapeLiteralForDoubleQuotedBash(unquotedInputToBeEscaped, out);
            } catch (IOException e) {
                // shouldn't happen for StringBuilder
                throw Exceptions.propagate(e);
            }
            return out.toString();
        }

        /** transforms e.g. [ "-Dname=Bob Johnson", "-Dnet.worth=$100" ]  to 
         * a java string "\"-Dname=Bob Johnson\" \"-Dnet.worth=\$100\"" --
         * which can be inserted into a bash command where it will be picked up as 2 params
         */
        public static String doubleQuoteLiteralsForBash(String... args) {
            StringBuilder result = new StringBuilder();
            for (String arg: args) {
                if (!Strings.isEmpty(result)) result.append(" ");
                result.append("\"");
                result.append(escapeLiteralForDoubleQuotedBash(arg));
                result.append("\"");
            }
            return result.toString();
        }

        //between java and regex parsing, this gives a single backslash and double quote
        private static final String BACKSLASH = "\\\\";
        private static final String DOUBLE_QUOTE = "\\\"";

        public static boolean isValidForDoubleQuotingInBash(String x) {
            return (checkValidForDoubleQuotingInBash(x)==null);
        }

        public static void assertValidForDoubleQuotingInBash(String x) {
            String problem = checkValidForDoubleQuotingInBash(x);
            if (problem==null) return;
            throw new IllegalArgumentException("String \""+x+"\" not acceptable for bash argument (including double quotes): "+problem);
        }

        private static String checkValidForDoubleQuotingInBash(String x) {
            //double quotes must be preceded by a backslash (preceded by 0 or more bash-escaped backslashes)
            if (x.matches(  "[^"+BACKSLASH+DOUBLE_QUOTE+"]*"+
                    "("+BACKSLASH+BACKSLASH+")*"+
                    DOUBLE_QUOTE+".*")) return "unescaped double quote";
            return null;
        }

        /** given a string in bash notation, e.g. with quoted portions needing unescaped, returns the unescaped and unquoted version */
        public static String unwrapBashQuotesAndEscapes(String s) {
            return applyUnquoteAndUnescape(s, "Bash", true);
        }
    }
    
    
    public static class JavaStringEscapes {
        /** converts normal string to java escaped for double-quotes (but not wrapped in double quotes) */
        public static String escapeJavaString(String value) {
            StringBuilder out = new StringBuilder();
            try {
                escapeJavaString(value, out);
            } catch (IOException e) {
                //shouldn't happen for string builder
                throw Exceptions.propagate(e);
            }
            return out.toString();
        }

        /** converts normal string to java escaped for double-quotes and wrapped in those double quotes */
        public static String wrapJavaString(String value) {
            StringBuilder out = new StringBuilder();
            try {
                wrapJavaString(value, out);
            } catch (IOException e) {
                //shouldn't happen for string builder
                throw Exceptions.propagate(e);
            }
            return out.toString();
        }
        public static List<String> wrapJavaStrings(Iterable<String> values) {
            if (values==null) return null;
            List<String> result = MutableList.of();
            for (String v: values) result.add(wrapJavaString(v));
            return result;
        }

        /** as {@link #unwrapJavaString(String)} if the given string is wrapped in double quotes;
         * otherwise just returns the given string */
        public static String unwrapJavaStringIfWrapped(String s) {
            if (!StringEscapes.isWrappedInDoubleQuotes(s)) return s;
            return unwrapJavaString(s);
        }

        /** converts normal string to java escaped for double-quotes and wrapped in those double quotes */
        public static void wrapJavaString(String value, Appendable out) throws IOException {
            if (value==null) {
                out.append("null");
            } else {
                out.append('"');
                escapeJavaString(value, out);
                out.append('"');
            }
        }

        /** converts normal string to java escaped for double-quotes (but not wrapped in double quotes) */
        public static void escapeJavaString(@Nonnull String value, Appendable out) throws IOException {
            for (int i=0; i<value.length(); i++) {
                char c = value.charAt(i);
                if (c=='\\' || c=='"') {
                    // NB do NOT escape single quotes; while valid for java, it is not in JSON (breaks jQuery.parseJSON)
                    appendEscaped(out, c);
                } else if (c=='\n') {
                    appendEscaped(out, 'n');
                } else if (c=='\t') {
                    appendEscaped(out, 't');
                } else if (c=='\r') {
                    appendEscaped(out, 'r');
                } else {
                    out.append(c);
                }
            }
        }

        /** given a string in java syntax, e.g. wrapped in quotes and with backslash escapes, returns the literal value,
         * without the surrounding quotes and unescaped; throws IllegalArgumentException if not a valid java string */
        public static String unwrapJavaString(String s) {
            return applyUnquoteAndUnescape(s, "Java", false);
        }
        
        /**
         * Unwraps a sequence of quoted java strings, that are each separated by the given separator.
         * @param trimmedArg
         * @return
         */
        public static List<String> unwrapQuotedJavaStringList(String s, String separator) {
            List<String> result = new ArrayList<String>();
            String remaining = s.trim();
            while (remaining.length() > 0) {
                int endIndex = findNextClosingQuoteOf(remaining);
                result.add(unwrapJavaString(remaining.substring(0, endIndex+1)));
                remaining = remaining.substring(endIndex+1).trim();
                if (remaining.startsWith(separator)) {
                    remaining = remaining.substring(separator.length()).trim();
                } else if (remaining.length() > 0) {
                    throw new IllegalArgumentException("String '"+s+"' has invalid separators, should be '"+separator+"'");
                }
            }
            return result;
        }
        private static int findNextClosingQuoteOf(String s) {
            boolean escaped = false;
            boolean quoted = false;
            for (int i=0; i<s.length(); i++) {
                char c = s.charAt(i);
                if (!quoted) {
                    assert (i==0);
                    assert !escaped;
                    if (c=='"') quoted = true;
                    else throw new IllegalArgumentException("String '"+s+"' is not a valid Java string (must start with double quote)");
                } else {
                    if (escaped) {
                        escaped = false;
                    } else {
                        if (c=='\\') escaped = true;
                        else if (c=='\"') {
                            quoted = false;
                            return i;
                        } 
                    }
                }
            }
            
            assert quoted;
            throw new IllegalArgumentException("String '"+s+"' is not a valid Java string (unterminated string)");
        }
        
        /** @deprecated since 1.0.0, use {@link #unwrapJsonishListStringIfPossible(String)} (old semantics)
         * or {@link #tryUnwrapJsonishList(String)} (improved) */
        public static List<String> unwrapJsonishListIfPossible(String input) {
            return unwrapJsonishListStringIfPossible(input);
        }

        /** converts a comma separated list in a single string to a list of json primitives or maps, 
         * falling back to returning the input.
         * <p>
         * specifically:
         * <li> 1) if of form <code>[ X ]</code> (in brackets after trim), parse as YAML;
         *         if parse succeeds return the result, or if parse fails, return {@link Maybe#absent()}.
         * <ll> 2) if not of form <code>[ X ]</code>, wrap in brackets and parse as YAML, 
         *         and if that succeeds and is a list, return the result.
         * <li> 3) otherwise, expect comma-separated tokens which after trimming are of the form "A" or B,
         *         where "A" is a valid Java string or B is any string not containing any of the chars <code>",.</code>
         *         and not starting with <code>'</code>, and returns the list of those tokens, where A is
         *         returned as its string value, and B as a primitive if it is a number or boolean or null, 
         *         or else a string (including the empty string if empty)
         * <li> 4) if such tokens are not found, return {@link Maybe#absent()}.
         * <p>
         * @see #unwrapOptionallyQuotedJavaStringList(String)
         **/
        public static Maybe<List<Object>> tryUnwrapJsonishList(String input) {
            if (input==null) return Maybe.absent("null input cannot unwrap to a list");
            String inputT = input.trim();
            
            String inputYaml = null;
            if (!inputT.startsWith("[") && !inputT.endsWith("]")) {
                inputYaml = "[" + inputT + "]";
            }
            if (inputT.startsWith("[") && inputT.endsWith("]")) {
                inputYaml = inputT;
            }
            if (inputYaml!=null) {
                try {
                    Object r = Iterables.getOnlyElement( Yamls.parseAll(inputYaml) );
                    if (r instanceof List) {
                        @SuppressWarnings("unchecked")
                        List<Object> result = (List<Object>)r;
                        return Maybe.of(result);
                    }
                } catch (Exception e) {
                    Exceptions.propagateIfFatal(e);
                    // Otherwise ignore; logic below decides whether to return absent or to keep trying                    
                }
                if (inputT.startsWith("[")) {
                    // if supplied as yaml, don't allow failures
                    return Maybe.absent("Supplied format looked like YAML but could not parse as YAML");
                }
            }
            
            List<Object> result = MutableList.of();
            
            // double quote:  ^ \s* " ([not quote or backslash] or [backslash any-char])* " \s* (, or $)
            Pattern dq = Pattern.compile("^\\s*(\"([^\"\\\\]|[\\\\.])*\")\\s*(,|$)");
            // could also support this, but we need new unescape routines
//            // single quote:  ^ \s* ' ([not quote or backslash] or [backslash any-char])* ' \s* (, or $)
//            Pattern sq = Pattern.compile("^\\s*'([^\'\\\\]|[\\\\.])'*\\s*(,|$)");
            // no quote:  ^ \s* (empty, or [not ' or " or space] ([not , or "]* [not , or " or space])?) \s* (, or $)
            Pattern nq = Pattern.compile("^\\s*(|[^,\"\\s]([^,\"]*[^,\"\\s])?)\\s*(,|$)");
            
            int removedChars = 0;
            while (true) {
                Object ri;
                Matcher m = dq.matcher(input);
                if (m.find()) {
                    try {
                        ri = unwrapJavaString(m.group(1));
                    } catch (Exception e) {
                        Exceptions.propagateIfFatal(e);
                        return Maybe.absent("Could not match valid quote pattern" +
                            (removedChars>0 ? " at position "+removedChars : "")
                            + ": "+ Exceptions.collapseText(e));
                    }
                } else {
                    m = nq.matcher(input);
                    if (m.find()) {
                        String w = m.group(1);
                        
                        ri = w;
                        if (w.matches("[0-9]*\\.[0-9]+")) {
                            try {
                                ri = Double.parseDouble(w);
                            } catch (Exception e) {}
                        } else if (w.matches("[0-9]+")) {
                            try {
                                ri = Integer.parseInt(w);
                            } catch (Exception e) {}
                        } else if (Boolean.TRUE.toString().equals(w)) {
                            ri = true;
                        } else if (Boolean.FALSE.toString().equals(w)) {
                            ri = false;
                        } else if ("null".equals(w)) {
                            ri = null;
                        }
                        
                    } else {
                        return Maybe.absent("Could not match valid quote pattern" +
                            (removedChars>0 ? " at position "+removedChars : ""));
                    }
                }
                
                result.add(ri);
                
                input = input.substring(m.end());
                removedChars += m.end();
                if (!m.group(0).endsWith(",")) break;
            }
            return Maybe.of(result);
        }

        /** converts a comma separated list in a single string to a list of strings, 
         * doing what would be expected if given java or json style string as input,
         * and falling back to returning the input.
         * <p>
         * this method does <b>not</b> throw exceptions on invalid input,
         * but just returns that input wrapped in a list
         * <p>
         * specifically, uses the following rules (executed once in sequence:
         * <li> 1) if of form <code>[ X ]</code> (in brackets after trim), 
         *      then removes brackets and applies following rules to X (for any X including quoted or with commas)
         * <li> 2) if of form <code>"X"</code> 
         *      (in double quotes after trim, 
         *      where X contains no internal double quotes unless escaped with backslash) 
         *      then returns list containing X unescaped (\x replaced by x)
         * <li> 3) if of form <code>X</code> or <code>X, Y, ...</code> 
         *      (where X, Y, ... each satisfy the constraint given in 2, or have no double quotes or commas in them)
         *      then returns the concatenation of rule 2 applied to non-empty X, Y, ...
         *      (if you want an empty string in a list, you must double quote it)
         * <li> 4) for any other form X returns [ X ], including empty list for empty string
         * <p>
         * @see #unwrapOptionallyQuotedJavaStringList(String)
         **/
        public static List<String> unwrapJsonishListStringIfPossible(String input) {
            try {
                return unwrapOptionallyQuotedJavaStringList(input);
            } catch (Exception e) {
                Exceptions.propagateIfFatal(e);
                if (e instanceof IllegalArgumentException) {
                    if (log.isDebugEnabled()) 
                        log.debug("Unable to parse JSON list '"+input+"' ("+e+"); treating as single-element string list");
                } else {
                    log.warn("Unable to parse JSON list '"+input+"' ("+e+"); treating as single-element string list", e);
                }
                return MutableList.of(input);
            }
        }
        
        /** as {@link #unwrapJsonishListIfPossible(String)} but throwing errors 
         * if something which looks like a string or set of brackets is not well-formed
         * (this does the work for that method) 
         * @throws IllegalArgumentException if looks to have quoted list or surrounding brackets but they are not syntactically valid */
        public static List<String> unwrapOptionallyQuotedJavaStringList(String input) {
            if (input==null) return null;
            MutableList<String> result = MutableList.of();
            String i1 = input.trim();
            
            boolean inBrackets = (i1.startsWith("[") && i1.endsWith("]"));
            if (inBrackets) i1 = i1.substring(1, i1.length()-1).trim();
                
            QuotedStringTokenizer qst = new QuotedStringTokenizer(i1, "\"", true, ",", false);
            while (qst.hasMoreTokens()) {
                String t = qst.nextToken().trim();
                if (isWrappedInDoubleQuotes(t))
                    result.add(unwrapJavaString(t));
                else {
                    if (inBrackets && (t.indexOf('[')>=0 || t.indexOf(']')>=0))
                        throw new IllegalArgumentException("Literal square brackets must be quoted, in element '"+t+"'");
                    result.add(t.trim());
                }
            }
            
            return result;
        }
    }
    
    private static void appendEscaped(Appendable out, char c) throws IOException {
        out.append('\\');
        out.append(c);
    }
    private static String applyUnquoteAndUnescape(String s, String mode, boolean allowMultipleQuotes) {
        StringBuilder result = new StringBuilder();
        boolean escaped = false;
        boolean quoted = false;
        for (int i=0; i<s.length(); i++) {
            char c = s.charAt(i);
            if (!quoted) {
                assert (i==0 || allowMultipleQuotes);
                assert !escaped;
                if (c=='"') quoted = true;
                else if (!allowMultipleQuotes)
                    throw new IllegalArgumentException("String '"+s+"' is not a valid "+mode+" string (must start with double quote)");
                else result.append(c);
            } else {
                if (escaped) {
                    if (c=='\\' || c=='"' || c=='\'') result.append(c);
                    else if (c=='n') result.append('\n');
                    else if (c=='t') result.append('\t');
                    else if (c=='r') result.append('\r');
                    else throw new IllegalArgumentException("String '"+s+"' is not a valid "+mode+" string (unsupported escape char '"+c+"' at position "+i+")");
                    escaped = false;
                } else {
                    if (c=='\\') escaped = true;
                    else if (c=='\"') {
                        quoted = false;
                        if (!allowMultipleQuotes && i<s.length()-1)
                            throw new IllegalArgumentException("String '"+s+"' is not a valid "+mode+" string (unescaped interior double quote at position "+i+")");
                    } else result.append(c); 
                }
            }
        }
        if (quoted)
            throw new IllegalArgumentException("String '"+s+"' is not a valid "+mode+" string (unterminated string)");
        assert !escaped;
        return result.toString();
    }
    
}
