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

import java.net.URI;
import java.net.URISyntaxException;
import java.util.*;
import java.util.Map.Entry;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import net.ranides.assira.collection.maps.AMap;
import net.ranides.assira.collection.maps.MapBuilder;
import net.ranides.assira.collection.maps.MultiMap;
import net.ranides.assira.generic.CompareUtils;
import net.ranides.assira.generic.Ref;
import net.ranides.assira.reflection.util.ResolveUtils;
import net.ranides.assira.text.*;

/**
 * This is mutable URI.
 * It could be used both for parsing and for modification of URI compoments.
 *
 * @author Ranides Atterwim {@literal <ranides@gmail.com>}
 */
public class URIBuilder {

    //                                                     scheme            login       password    host           port       path            params    fragment
    //                                                     1                 2           3           4              5          6               7         8
    private static final Pattern RE_URL = Pattern.compile("([^:]+):(?://)?(?:([^:@]+)(?::([^@]+))?@)?([^/:?#]+)?(?::([^/?#]+))?(/[^?#]+)?(?:\\?([^?#]*))?(?:#(.+))?");

    private String scheme;
    private String host;
    private String port;
    private String path;
    
    private String fragment;
    private ParamMap params;
    
    private String login;
    private String password;

    /**
     * Creates new empty URIBuilder.
     * Even scheme part is undefined.
     */
    public URIBuilder() {
        params = new ParamMap();
    }

    /**
     * Creates new URIBuilder from provided string.
     * String should have at least defined scheme part.
     *
     * @param uri uri
     */
    public URIBuilder(String uri) {
        Matcher matcher = RE_URL.matcher(uri);
        if(!matcher.matches()) {
            throw new IllegalArgumentException("Invalid URI: " + uri);
        }
        this.scheme = matcher.group(1);
        this.login = matcher.group(2);
        this.password = matcher.group(3);
        this.host = matcher.group(4);
        this.port = matcher.group(5);
        this.path = matcher.group(6);
        this.params = new ParamMap(matcher.group(7));
        this.fragment = matcher.group(8);
    }

    /**
     * Creates new URIBuilder from provided URI.
     *
     * @param uri uri
     */
    public URIBuilder(URI uri) {
        scheme = uri.getScheme();
        host = uri.getHost();
        port = URIUtils.getPort(uri).map(String::valueOf).orElse(null);
        path = uri.getPath();
        
        fragment = uri.getFragment();
        params = new ParamMap(uri.getQuery());
        
        login = URIUtils.getLogin(uri).orElse(null);
        password = URIUtils.getPassword(uri).orElse(null);
    }

    /**
     * Returns LOGIN part of URI, assuming form: "scheme://login:password@host..."
     *
     * @return optional
     */
    public Ref<String> login() {
        return Ref.ofNullable(login);
    }

    /**
     * Changes LOGIN part of URI, assuming form: "scheme://login:password@host..."
     *
     * @param value value
     * @return this
     */
    public URIBuilder login(String value) {
        this.login = value;
        return this;
    }

    /**
     * Returns PASSWORD part of URI, assuming form: "scheme://login:password@host..."
     *
     * @return optional
     */
    public Ref<String> password() {
        return Ref.ofNullable(password);
    }

    /**
     * Changes PASSWORD part of URI, assuming form: "scheme://login:password@host..."
     *
     * @param value value
     * @return this
     */
    public URIBuilder password(String value) {
        this.password = value;
        return this;
    }

    /**
     * Returns HOST part of URI, assuming form: "scheme://...@host.com:port/..."
     *
     * @return optional
     */
    public Ref<String> host() {
        return Ref.ofNullable(host);
    }

    /**
     * Changes HOST part of URI, assuming form: "scheme://...@host.com:port/..."
     *
     * @param value value
     * @return this
     */
    public URIBuilder host(String value) {
        this.host = value;
        return this;
    }

    /**
     * Returns FRAGMENT part of URI, assuming form: "scheme://host/path...#fragment"
     *
     * @return optional
     */
    public Ref<String> fragment() {
        return Ref.ofNullable(fragment);
    }

    /**
     * Changes FRAGMENT part of URI, assuming form: "scheme://host/path...#fragment"
     *
     * @param value value
     * @return this
     */
    public URIBuilder fragment(String value) {
        this.fragment = value;
        return this;
    }

    /**
     * Returns PATH part of URI, assuming form: "scheme://host/path...#fragment"
     *
     * @return optional
     */
    public Ref<String> path() {
        return StringTraits.isEmpty(path) ? Ref.empty() : Ref.of(path);
    }

    /**
     * Changes PATH part of URI, assuming form: "scheme://host/path...#fragment"
     *
     * @param value value
     * @return this
     */
    public URIBuilder path(String value) {
        path = value;
        return this;
    }

    /**
     * Changes PATH part of URI, assuming form: "scheme://host/path...#fragment"
     * Concatenates provided values using "/" as separator.
     *
     * @param values values
     * @return this
     */
    public URIBuilder path(String... values) {
        path = StringUtils.join(values, "/");
        return this;
    }

    /**
     * Changes PATH part of URI, assuming form: "scheme://host/path...#fragment"
     * Appends provided value to already defined path, using "/" as separator.
     *
     * @param value value
     * @return this
     */
    public URIBuilder append(String value) {
        path = path + "/" + value;
        return this;
    }

    /**
     * Changes PATH part of URI, assuming form: "scheme://host/path...#fragment"
     * Appends provided values to already defined path, using "/" as separator.
     *
     * @param values values
     * @return this
     */
    public URIBuilder append(String... values) {
        path = new StrBuilder(path.length()).append(path).append("/").open("", "", "/").append(values).toString();
        return this;
    }

    /**
     * Returns PORT part of URI, assuming form: "scheme://...@host.com:port/..."
     *
     * @return optional
     */
    public Ref<String> port() {
        return Ref.ofNullable(port);
    }

    /**
     * Changes PORT part of URI, assuming form: "scheme://...@host.com:port/..."
     * If you want to remove PORT part, you should pass null.
     *
     * It is not strictly required, but advised strongly, to use values parsable as integer.
     *
     * @param value value
     * @return this
     */
    public URIBuilder port(String value) {
        this.port = value;
        return this;
    }

    /**
     * Changes PORT part of URI, assuming form: "scheme://...@host.com:port/..."
     * If you want to remove PORT part, you should pass -1.
     *
     * @param value value
     * @return this
     */
    public URIBuilder port(int value) {
        this.port = String.valueOf(value);
        return this;
    }

    /**
     * Returns value of query parameter with "name".
     * Because URI allows for multiple parameters with the same value, returns list.
     *
     * @param name name
     * @return list
     */
    public List<String> param(String name) {
        return params.getAll(name);
    }

    /**
     * Adds query parameter with specified name and value.
     *
     * It does not remove any already defined parameters
     * Even parameters with the same name won't be removed or replaced.
     *
     * @param name name
     * @param value value
     * @return this
     */
    public URIBuilder param(String name, String value) {
        params.put(name, value);
        return this;
    }

    /**
     * Adds query parameters with specified name and values.
     * URIs support adding multiple parameters with the same name, thats what this method does.
     *
     * It does not remove any already defined parameters
     * Even parameters with the same name won't be removed or replaced.
     *
     * @param name name
     * @param value value
     * @return this
     */
    public URIBuilder param(String name, List<String> value) {
        params.putAll(name, value);
        return this;
    }

    /**
     * Returns all defined parameters.
     * Returned MapBuilder can be used for both read and write operations.
     *
     * MapBuilder is backed by MultiMap, because URIs support adding multiple parameters with the same name.
     *
     * @return MultiMap
     */
    public MapBuilder<String,String,? extends MultiMap<String,String>> params() {
        return MapBuilder.into(params);
    }

    /**
     * Adds all query parameters with name and values store inside provided map.
     *
     * Please note, that MultiMap is correctly inserted too.
     *
     * It does not remove or replace any already defined parameters
     *
     * @param value value
     * @return this
     */
    public URIBuilder params(Map<String,String> value) {
        params.putAll(value);
        return this;
    }

    /**
     * Returns SCHEME part of URI, assuming form: "scheme://..."
     *
     * @return string
     */
    public String scheme() {
        return scheme;
    }

    /**
     * Changes SCHEME part of URI, assuming form: "scheme://..."
     *
     * @param value value
     * @return this
     */
    public URIBuilder scheme(String value) {
        this.scheme = value;
        return this;
    }

    /**
     * Returns new URIBuilder with all ${variable} expressions replaced by properties
     * loaded from provided context.
     *
     * It uses ResolveDialect.HANDLEBARS to detect variable expressions.
     * It uses {@link ResolveFormat#format(Object)} to compute detected variables.
     *
     * @param context context
     * @return URIBuilder
     */
    public URIBuilder resolve(Object context) {
        URIBuilder out = new URIBuilder();
        out.scheme = resolveFormat(scheme, context);
        out.host = resolveFormat(host, context);
        out.port = resolveFormat(port, context);
        out.path = resolveFormat(path, context);
        out.fragment = resolveFormat(fragment, context);
        out.params = resolveParams(params, context);
        out.login = resolveFormat(login, context);
        out.password = resolveFormat(password, context);
        
        return out;
    }

    /**
     * Creates new URI
     *
     * @return URI
     */
    public URI build() {
        try {
            HostAddr addr = new HostAddr(host, port);
            return new URI(scheme, userinfo(), addr.host, addr.port, abspath(), query(), fragment);
        } catch(URISyntaxException cause) {
            throw new IllegalArgumentException(cause);
        }
    }

    /**
     * Creates new URI
     *
     * Replaces all ${variable} expressions by properties loaded from provided context.
     *
     * It uses ResolveDialect.HANDLEBARS to detect variable expressions.
     * It uses {@link ResolveFormat#format(Object)} to compute detected variables.
     *
     * @param context context
     * @return URI
     */
    public URI build(Object context) {
        return resolve(context).build();
    }
    
    private String abspath() {
        return StringTraits.starts(path, "/") ? path : StringTraits.isEmpty(path) ? path : "/"+path;
    }

    private String query() {
        if(params.set.isEmpty()) {
            return null;
        }
        return StringUtils.join(params.set, "&", e -> {
            if(StringTraits.isEmpty(e.getValue())) {
                return URIUtils.encodeURL(e.getKey());
            } else {
                return URIUtils.encodeURL(e.getKey()) + "=" + URIUtils.encodeURL(e.getValue());
            }
        });
    }

    private String userinfo() throws IllegalArgumentException {
        if(login!=null) {
            if (password!=null) {
                return login + ":" + password;
            } else {
                return login;
            }
        }
        if(password!=null) {
            throw new IllegalArgumentException("password without login");
        }
        return null;
    }
    
    private static URI parse(String uri) {
        try {
            return new URI(uri);
        } catch (URISyntaxException cause) {
            throw new IllegalArgumentException(cause);
        }
    }
    private static String paramName(String[] array) {
        return array.length > 0 ? array[0] : "";
    }
    
    private static String paramValue(String[] array) {
        return array.length > 1 ? array[1] : "";
    }


    private static String resolveFormat(String value, Object context) {
        return null == value ? null : ResolveFormat.compile(ResolveDialect.HANDLEBARS, value).format(context);
    }

    private static ParamMap resolveParams(ParamMap map, Object context) {
        ParamMap out = new ParamMap();
        for(Entry<String,String> entry : map.set) {
            String key = resolveFormat(entry.getKey(), context);
            if(entry.getValue() == null) {
                out.set.add( newEntry(entry, key, null) );
            }
            else {
                List<String> list = resolveList(entry.getValue(), context);
                if(list != null) {
                    for(String item : list) {
                        out.set.add(new ParamEntry(key, item));
                    }
                } else {
                    out.set.add(newEntry(entry, key, ResolveFormat.compile(ResolveDialect.HANDLEBARS, entry.getValue()).format(context)));
                }
            }
        }
        return out;
    }

    private static List<String> resolveList(String value, Object context) {
        if(isExpression(value)) {
            String expression = value.substring(2, value.length() - 2);
            Object object = ResolveUtils.get(context, expression);
            if(object instanceof List) {
                return ((List<?>)object).stream().map(String::valueOf).collect(Collectors.toList());
            }
        }
        return null;
    }

    private static boolean isExpression(String value) {
        return value!=null && value.startsWith("{{") && value.endsWith("}}");
    }

    private static Entry<String, String> newEntry(Entry<String, String> e, String key, String value) {
        return Objects.equals(value, e.getValue()) && Objects.equals(key, e.getKey()) ? e : new ParamEntry(key, value);
    }
    
    
    
    private static final class ParamEntry extends AbstractMap.SimpleEntry<String,String> {
        
        
        public ParamEntry(String[] p) {
            super(paramName(p), paramValue(p));
        }
        
        public ParamEntry(String key, String value) {
            super(key, value);
        }
        
    }
    
    private static final class ParamSet extends ArrayList<Entry<String,String>> implements Set<Entry<String,String>> {
        
    }
    
    private static final class ParamMap extends AMap<String,String> implements MultiMap<String,String> {
        
        private final ParamSet set = new ParamSet();
        
        public ParamMap() {
            // do nothing
        }
        
        public ParamMap(String query) {
            if(StringTraits.isEmpty(query)) {
                return;
            }
            Arrays
                .stream(query.split("&"))
                .map(v -> v.split("="))
                .forEachOrdered(p -> set.add(new ParamEntry(p)) );
        }

        @Override
        public Set<Entry<String, String>> entrySet() {
            return set;
        }

        @Override
        public int size() {
            return set.size();
        }

        @Override
        public boolean containsKey(Object key) {
            return set.stream().anyMatch(e -> e.getKey().equals(key));
        }

        @Override
        public boolean containsValue(Object value) {
            return set.stream().anyMatch(e -> e.getValue().equals(value));
        }

        @Override
        public String get(Object key) {
            return set.stream().filter(e -> e.getKey().equals(key)).findAny().map(e -> e.getValue()).orElse(null);
        }

        @Override
        public List<String> getAll(Object key) {
            return set.stream().filter(e -> e.getKey().equals(key)).map(e -> e.getValue()).collect(Collectors.toList());
        }

        @Override
        public String remove(Object key) {
            Iterator<Entry<String, String>> i = set.iterator();
            while(i.hasNext()) {
                Entry<String, String> e = i.next();
                if(e.getKey().equals(key)) {
                    i.remove();
                    return e.getValue();
                }
            }
            return null;
        }

        @Override
        public Collection<String> removeAll(Object key) {
            List<String> out = new ArrayList<>();
            Iterator<Entry<String, String>> i = set.iterator();
            while(i.hasNext()) {
                Entry<String, String> e = i.next();
                if(e.getKey().equals(key)) {
                    i.remove();
                    out.add(e.getValue());
                }
            }
            return out;
        }
        
        @Override
        public String put(String key, String value) {
            String prev = get(key);
            set.add(new ParamEntry(key, value));
            return prev;
        }

        @Override
        public boolean containsEntry(Object key, Object value) {
            return set.stream().anyMatch(e -> e.getKey().equals(key) && CompareUtils.equals(e.getValue(),value));
        }
        
        
    }

    private static final class HostAddr {

        public final String host;
        public final int port;

        public HostAddr(String host, String port) {
            if(host!=null && host.contains(":")) {
                String[] addr = host.split(":");
                this.host = addr[0];
                this.port = Integer.parseInt(addr[1]);
                if(port !=null && Integer.parseInt(port) != this.port) {
                    throw new IllegalArgumentException("Ambiguous port value: " + port + " vs " + this.port);
                }
            } else {
                this.host = host;
                this.port = port!=null ? Integer.parseInt(port) : -1;
            }
        }
    }
    
}
