/*
 * @author Ranides Atterwim {@literal <ranides@gmail.com>}
 * @copyright Ranides Atterwim
 * @license WTFPL
 * @url http://ranides.net/projects/assira
 */
package net.ranides.assira.text;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;

import net.ranides.assira.generic.CompareUtils;
import net.ranides.assira.generic.HashUtils;
import net.ranides.assira.reflection.*;

import java.io.IOException;
import java.io.Serializable;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

/**
 * ResolveFormat replaces all "variable expressions" inside string by values provided in additional arguments.
 * This class can use custom {@link ResolveDialect} to define format of variable expressions.
 * Syntax used by default dialect is identical as used in {@code MessageFormat}:
 * That means:
 *      { ArgumentIndex, Format, Options  }
 * 
 * ARGUMENT:
 * ArgumentIndex is variable name
 *      . is used as separator (when accessing fields)
 *      [ ] is used as indexing operator (when accessing array elements)
 *
 *      Please note, that in practice . and [ ] can be used interchangably
 *      To read details about variable resolution, lets look at {@link ResolvePattern}
 *      which is used internally to parse "ArgumentIndex" fragment.
 * 
 * FORMATS:
 * Please note, that different formats support different "Options"
 *
 * We use some built-in formats:
 *      DecimalFormat       is described in {@link java.text.DecimalFormat}
 *      SimpleDateFormat    is described in {@link java.text.SimpleDateFormat}
 *      ChoiceFormat        is described in {@link java.text.ChoiceFormat}
 *
 * List of formats supported by default (with options):
 *
 *  {number} DecimalFormat | integer | currency | percent | java | c++
 * 
 *  {metric} DecimalFormat
 *  {metric/e} DecimalFormat
 *  {metric/si} DecimalFormat
 *  
 *  {binary} DecimalFormat
 *  {binary/e} DecimalFormat
 *  {binary/iec} DecimalFormat
 *  {binary/knuth} DecimalFormat
 *
 *  {date} SimpleDateFormat | short | medium | long | full | iso | gnu | trace | local
 *
 *  {time} SimpleDateFormat | short | medium | long | full | iso | gnu | trace | local
 *
 *  {choice,ChoiceFormat}
 *
 *  {hex} array | stream
 *
 * Please note that there is special behaviour you format against ResolveContext:
 * if resolved value is of type ResolveFormat or ResolvePattern then
 * it is evaluated against context again.
 * 
 * @author Ranides Atterwim {@literal <ranides@gmail.com>}
 */
@SuppressFBWarnings("SE_BAD_FIELD")
public class ResolveFormat implements Serializable {
    
    private static final long serialVersionUID = 1L;
    
    private static final Pattern REGEX_SPECIAL = Pattern.compile("([\\\\*+\\[\\]()$.?^|])");

    private final ResolveLocale locale;

    private final ResolveDialect dialect;

    private final String pattern;
    
    private final Token[] tokens;
    
    private final Token[] parsers;
    
    private final Pattern scanre;

    /**
     * This protected constructor should not be constructed directly.
     *
     * This constructor is executed by ResolveLocale only.
     *
     * @param locale locale
     * @param dialect dialect
     * @param pattern pattern
     */
    ResolveFormat(ResolveLocale locale, ResolveDialect dialect, String pattern) {
        this.locale = locale;
        this.dialect = dialect;
        this.pattern = pattern;

        List<Token> itokens = new ArrayList<>();
        List<Token> iparsers = new ArrayList<>();
        Matcher hit = dialect.pattern().matcher(pattern);
        int last = 0;
        while(hit.find()) {
            if(hit.group(1).length() != hit.group(5).length()) {
                throw new IllegalArgumentException("Unbalanced expression delimiters: \"" + hit.group(0) + "\"");
            }
            if(hit.start()-last > 0) {
                String text = pattern.substring(last,hit.start());
                itokens.add(new FString(text));
            }
            Token token = locale.get(dialect.strategy(), hit.group(2), hit.group(3), hit.group(4));
            itokens.add(token);
            iparsers.add(token);
            
            last = hit.end();
        }
        if(last<pattern.length()) {
            String text = pattern.substring(last);
            itokens.add(new FString(text));
        }
        this.tokens = itokens.toArray(new Token[0]);
        this.parsers = iparsers.toArray(new Token[0]);
        
        int[] index = new int[]{0};
        
        String p = REGEX_SPECIAL.matcher(pattern).replaceAll("\\\\$1");
        String p2 = StringUtils.replace(p, dialect.escaped(), h -> parsers[index[0]++].regex());

        if(dialect != ResolveDialect.DEFAULT) {
            p2 = p2.replaceAll("([{}])", "\\\\$1");
        }
        this.scanre = Pattern.compile(p2);
    }

    /**
     * Compiles expression, using default {@link ResolveLocale} and {@link ResolveDialect}
     *
     * @param pattern pattern
     * @return ResolveFormat
     */
    public static ResolveFormat compile(String pattern) {
        return ResolveLocale.getDefault().compile(pattern);
    }

    /**
     * Compiles expression, using default {@link ResolveDialect}
     * Uses {@link ResolveLocale} found by {@link java.util.Locale#forLanguageTag}.
     *
     * @param locale locale
     * @param pattern pattern
     * @return ResolveFormat
     */
    public static ResolveFormat compile(String locale, String pattern) {
        return ResolveLocale.getInstance(locale).compile(pattern);
    }

    /**
     * Compiles expression, using default {@link ResolveLocale}.
     * Uses provided {@link ResolveDialect} for processing variable expressions.
     *
     * @param dialect dialect
     * @param pattern pattern
     * @return ResolveFormat
     */
    public static ResolveFormat compile(ResolveDialect dialect, String pattern) {
        return ResolveLocale.getDefault().compile(dialect, pattern);
    }

    /**
     * Compiles expression.
     * Uses provided {@link ResolveDialect} for processing variable expressions.
     * Uses {@link ResolveLocale} found by {@link java.util.Locale#forLanguageTag}.
     *
     * @param dialect dialect
     * @param locale locale
     * @param pattern pattern
     * @return ResolveFormat
     */
    public static ResolveFormat compile(ResolveDialect dialect, String locale, String pattern) {
        return ResolveLocale.getInstance(locale).compile(dialect, pattern);
    }

    /**
     * Returns expression describing this compiled object
     *
     * @return String
     */
    public String pattern() {
        return pattern;
    }

    /**
     * Returns ResolveLocale used for translation.
     *
     * @return ResolveLocale
     */
    public ResolveLocale locale() {
        return locale;
    }

    /**
     * Returns ResolveDialect used for processing variable expressions.
     *
     * @return ResolveDialect
     */
    public ResolveDialect dialect() {
        return dialect;
    }

    /**
     * Generates new String replacing all variable expressions by values resolved from "context"
     *
     * @param context context
     * @return String
     */
    public String format(Object context) {
        try {
            return format(new StrBuilder(), context).toString();
        } catch(IOException cause) {
            throw new AssertionError("unreachable code", cause);
        }
    }

    /**
     * Generates new String replacing all variable expressions by values passed as arguments.
     *
     * Arguments are identified by 0-based index.
     * Please note, that you can write more complex expressions like: {@code {0.field.value.here}}
     *
     * @param arguments arguments
     * @return String
     */
    public String vformat(Object... arguments) {
        return format(arguments);
    }

    /**
     * Generates new String replacing all variable expressions by values passed as arguments.
     *
     * Arguments is a list of strings representing "key=value" pairs.
     * Variable expression {@code {key}} will be resolved to "value".
     *
     * @param arguments arguments
     * @return String
     */
    public String nformat(String... arguments) {
        return format(args2map(arguments));
    }

    /**
     * Generates new String replacing all variable expressions by values resolved from "context".
     * Generated result is stored in "output" buffer.
     *
     * @param output output
     * @param context context
     * @param <T> T
     * @return output
     * @throws IOException on buffer error
     */
    public <T extends Appendable> T format(T output, Object context) throws IOException {
        for(Token token : tokens) {
            token.append(output, context);
        }
        return output;
    }

    /**
     * Generates new String replacing all variable expressions by values passed as arguments.
     * Generated result is stored in "output" buffer.
     *
     * Arguments are identified by 0-based index.
     * Please note, that you can write more complex expressions like: {@code {0.field.value.here}}
     *
     * @param output output
     * @param arguments arguments
     * @param <T> T
     * @return output
     * @throws IOException on buffer error
     */
    public <T extends Appendable> T vformat(T output, Object... arguments) throws IOException {
        return format(output, arguments);
    }

    /**
     * Generates new String replacing all variable expressions by values passed as arguments.
     * Generated result is stored in "output" buffer.
     *
     * Arguments is a list of strings representing "key=value" pairs.
     * Variable expression {@code {key}} will be resolved to "value".
     *
     * @param output output
     * @param arguments arguments
     * @param <T> T
     * @return output
     * @throws IOException on buffer error
     */
    public <T extends Appendable> T nformat(T output, String... arguments) throws IOException {
        return format(output, args2map(arguments));
    }

    private static Map<String, String> args2map(String[] arguments) {
        return Arrays.stream(arguments)
            .map(arg -> arg.split("\\s*=\\s*", 2))
            .collect(Collectors.toMap(v -> v[0], v -> v[1]));
    }

    /**
     * Parses provided input text using compiled pattern and stores every extracted value into "context" object.
     *
     * This method makes extensive use of variable names: they are used to modify "context" state.
     * If some variable name is invalid, exception is thrown.
     *
     * This method makes extensive use of variable format: they are used to extract data from text.
     * You should be carefull and write unambiguous patterns.
     *
     * @param input input
     * @param context context
     */
    public void parse(String input, Object context) {
        Matcher hit = scanre.matcher(input);
        if(!hit.matches()) {
            throw new IllegalArgumentException("Expression does not match pattern: \"" + pattern + "\" expression: \"" + input + "\"nregex: " + scanre.pattern());
        }
        try {
            int i=0;
            for(Token p : parsers) {
                p.parse(hit.group(++i), context);
            }
        } catch(ParseException cause) {
            throw new ResolveException(cause);
        }
    }
    
    private Object writeReplace() {
        return new SResolveFormat(locale.name(), pattern);
    }

    @Override
    public int hashCode() {
        return HashUtils.hashPair(locale, pattern);
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null) {
            return false;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }
        final ResolveFormat other = (ResolveFormat) obj;
        return 
            CompareUtils.equals(this.locale, other.locale) &&
            CompareUtils.equals(this.pattern, other.pattern);
    }
       
    
    
    interface Token {

        String regex();
        
        void append(Appendable target, Object context) throws IOException;

        void parse(String source, Object context) throws ParseException;

    }
    
    private static final class FString implements ResolveFormat.Token {
        
        private final String value;

        public FString(String value) {
            this.value = value;
        }

        @Override
        public void append(Appendable target, Object context) throws IOException {
            target.append(value);
        }
        
        @Override
        public String regex() {
            throw new UnsupportedOperationException("Unreachable code.");
        }
            
        @Override
        public void parse(String source, Object context) {
            throw new UnsupportedOperationException("Unreachable code.");
        }
                
    }
    
    @SuppressWarnings("PMD")
    private static class SResolveFormat implements Serializable {

        private static final long serialVersionUID = 1L;

        private final String dialect;
        private final String pattern;

        public SResolveFormat(String dialect, String pattern) {
            this.dialect = dialect;
            this.pattern = pattern;
        }

        private Object readResolve() {
            return compile(dialect, pattern);
        }

    }
    
}
