package com.anthonynsimon.url;

import com.anthonynsimon.url.exceptions.InvalidURLReferenceException;
import com.anthonynsimon.url.exceptions.MalformedURLException;

import java.io.Serializable;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;

/**
 * URL is a reference to a web resource. This class implements functionality for parsing and
 * manipulating the various parts that make up a URL.
 * <p>
 * Once parsed it is of the form:
 * <p>
 * scheme:[//[user:password@]host[:port]][/]path[?query][#fragment]
 */
public final class URL implements Serializable {

    /**
     * Unique ID for serialization purposes.
     */
    private static final long serialVersionUID = 80443L;

    /**
     * URLParser to be used to parse the URL string into the URL object.
     * Do not serialize.
     */
    private transient final static URLParser URL_PARSER = new DefaultURLParser();

    private final String scheme;
    private final String username;
    private final String password;
    private final String host;
    private final String hostname;
    private final Integer port;
    private final String path;
    private final String rawPath;
    private final String query;
    private final String fragment;
    private final String opaque;

    /**
     * Cached parsed query string key-value pairs.
     * Do not serialize.
     */
    private transient Map<String, Collection<String>> parsedQueryPairs;

    /**
     * Cached string representation of the URL.
     * Do not serialize.
     */
    private transient String stringRepresentation;

    /**
     * Protect instantiation of class. Use public parse method instead to construct URLs.
     * Builder is for protected use only.
     */
    URL(String scheme, String username, String password, String host, String path, String query, String fragment, String opaque) {
        this.scheme = mapToNullIfEmpty(scheme);
        this.username = mapToNullIfEmpty(username);
        this.password = mapToNullIfEmpty(password);
        this.host = mapToNullIfEmpty(host);
        this.hostname = extractHostname(host);
        this.port = extractPort(host);
        this.path = mapToNullIfEmpty(path);
        this.rawPath = null;
        this.query = mapToNullIfEmpty(query);
        this.fragment = mapToNullIfEmpty(fragment);
        this.opaque = mapToNullIfEmpty(opaque);
    }

    URL(String scheme, String username, String password, String host, String path, String rawPath, String query, String fragment, String opaque) {
        this.scheme = mapToNullIfEmpty(scheme);
        this.username = mapToNullIfEmpty(username);
        this.password = mapToNullIfEmpty(password);
        this.host = mapToNullIfEmpty(host);
        this.hostname = extractHostname(host);
        this.port = extractPort(host);
        this.path = mapToNullIfEmpty(path);
        this.rawPath = mapToNullIfEmpty(rawPath);
        this.query = mapToNullIfEmpty(query);
        this.fragment = mapToNullIfEmpty(fragment);
        this.opaque = mapToNullIfEmpty(opaque);
    }

    URL(String scheme, String username, String password, String hostname, Integer port, String path, String rawPath, String query, String fragment, String opaque) {
        this.scheme = mapToNullIfEmpty(scheme);
        this.username = mapToNullIfEmpty(username);
        this.password = mapToNullIfEmpty(password);
        this.host = mergeHostPortIfSet(hostname, port);
        this.hostname = mapToNullIfEmpty(hostname);
        this.port = port;
        this.path = mapToNullIfEmpty(path);
        this.rawPath = mapToNullIfEmpty(rawPath);
        this.query = mapToNullIfEmpty(query);
        this.fragment = mapToNullIfEmpty(fragment);
        this.opaque = mapToNullIfEmpty(opaque);
    }


    /**
     * Returns a new URL object after parsing the provided URL string.
     */
    public static URL parse(String url) throws MalformedURLException {
        return URL_PARSER.parse(url);
    }

    /**
     * Returns the scheme ('http' or 'file' or 'ftp' etc...) of the URL if it exists.
     */
    public String getScheme() {
        return scheme;
    }

    /**
     * Returns the username part of the userinfo if it exists.
     */
    public String getUsername() {
        return username;
    }

    /**
     * Returns the password part of the userinfo if it exists.
     */
    public String getPassword() {
        return password;
    }

    /**
     * Returns the host ('www.example.com' or '192.168.0.1:8080' or '[fde2:d7de:302::]') of the URL if it exists.
     */
    public String getHost() {
        return host;
    }

    /**
     * Returns the hostname part of the host ('www.example.com' or '192.168.0.1' or '[fde2:d7de:302::]') if it exists.
     */
    public String getHostname() {
        return hostname;
    }

    /**
     * Returns the port part of the host (i.e. 80 or 443 or 3000) if it exists.
     */
    public Integer getPort() {
        return port;
    }

    /**
     * Returns the unescaped path ('/path/to/the;/file.html') of the URL if it exists.
     */
    public String getPath() {
        return path;
    }


    /**
     * Returns the raw path ('/path/to/the%3B/file.html') of the URL if it exists.
     */
    public String getRawPath() {
        return rawPath;
    }

    /**
     * Returns the query ('?q=foo{@literal &}bar') of the URL if it exists.
     */
    public String getQuery() {
        return query;
    }

    /**
     * Returns the fragment ('#foo{@literal &}bar') of the URL if it exists.
     */
    public String getFragment() {
        return fragment;
    }

    /**
     * Returns the opaque part of the URL if it exists.
     */
    public String getOpaque() {
        return opaque;
    }

    /**
     * Returns a java.net.URL object from the parsed url.
     *
     * @throws java.net.MalformedURLException if something went wrong while created the new object.
     */
    public java.net.URL toJavaURL() throws java.net.MalformedURLException {
        return new java.net.URL(toString());
    }

    /**
     * Returns a java.net.URI object from the parsed url.
     *
     * @throws java.net.URISyntaxException if something went wrong while created the new object.
     */
    public java.net.URI toJavaURI() throws URISyntaxException {
        return new URI(toString());
    }


    /**
     * Returns a map of key-value pairs from the parsed query string.
     */
    public Map<String, Collection<String>> getQueryPairs() {
        if (parsedQueryPairs != null) {
            return parsedQueryPairs;
        }
        parsedQueryPairs = new HashMap<>();

        if (!nullOrEmpty(query) && !query.equals("?")) {
            String[] pairs = query.split("&");
            for (String pair : pairs) {
                String[] parts = pair.split("=");
                if (parts.length > 0 && !parts[0].isEmpty()) {
                    Collection<String> existing = parsedQueryPairs.getOrDefault(parts[0], new ArrayList<>());
                    if (parts.length == 2) {
                        existing.add(parts[1]);
                    }
                    parsedQueryPairs.put(parts[0], existing);
                }
            }
        }

        return parsedQueryPairs;
    }

    /**
     * Returns true if the two Objects are instances of URL and their string representations match.
     */
    @Override
    public boolean equals(Object other) {
        if (other == null) {
            return false;
        }
        if (!(other instanceof URL)) {
            return false;
        }
        return toString().equals(other.toString());
    }

    /**
     * Returns a string representation of the all parts of the URL that are not null.
     */
    @Override
    public String toString() {
        if (stringRepresentation != null) {
            return stringRepresentation;
        }

        boolean hasScheme = !nullOrEmpty(scheme);
        StringBuffer sb = new StringBuffer();
        if (hasScheme) {
            sb.append(scheme);
            sb.append(":");
        }
        if (!nullOrEmpty(opaque)) {
            sb.append(opaque);
        } else {
            if (hasScheme || !nullOrEmpty(host)) {
                if (hasScheme) {
                    sb.append("//");
                }
                if (!nullOrEmpty(username)) {
                    sb.append(PercentEncoder.encode(username, URLPart.CREDENTIALS));
                    if (!nullOrEmpty(password)) {
                        sb.append(":");
                        sb.append(PercentEncoder.encode(password, URLPart.CREDENTIALS));
                    }
                    sb.append("@");
                }
                if (!nullOrEmpty(host)) {
                    sb.append(PercentEncoder.encode(host, URLPart.HOST));
                }
            }
            if (!nullOrEmpty(rawPath)) {
                sb.append(rawPath);
            } else if (!nullOrEmpty(path)) {
                if (path.indexOf('/') != 0 && !"*".equals(path)) {
                    sb.append("/");
                }
                sb.append(path);
            }
        }
        if (!nullOrEmpty(query)) {
            sb.append("?");
            // Only append '?' if that's all there is to the query string
            if (!"?".equals(query)) {
                sb.append(query);
            }
        }
        if (!nullOrEmpty(fragment)) {
            sb.append("#");
            sb.append(fragment);
        }

        stringRepresentation = sb.toString();

        return stringRepresentation;
    }

    /**
     * Returns the hash code of the URL.
     */
    @Override
    public int hashCode() {
        return toString().hashCode();
    }

    /**
     * Returns true if the parameter string is neither null nor empty ("").
     */
    private boolean nullOrEmpty(String str) {
        return str == null || str.isEmpty();
    }

    /**
     * Returns true if URL is opaque.
     */
    public boolean isOpaque() {
        return !nullOrEmpty(opaque);
    }

    /**
     * Returns true if URL is absolute.
     */
    public boolean isAbsolute() {
        return !nullOrEmpty(scheme);
    }

    public URL resolveReference(String ref) throws MalformedURLException, InvalidURLReferenceException {
        URL url = URL_PARSER.parse(ref);
        return resolveReference(url);
    }

    /**
     * Returns the resolved reference URL using the instance URL as a base.
     * <p>
     * If the reference URL is absolute, then it simply creates a new URL that is identical to it
     * and returns it. If the reference and the base URLs are identical, a new instance of the reference is returned.
     *
     * @throws InvalidURLReferenceException if the provided ref URL is invalid or if the base URL is not absolute.
     */
    public URL resolveReference(URL ref) throws InvalidURLReferenceException {
        if (!isAbsolute()) {
            throw new InvalidURLReferenceException("base url is not absolute");
        }
        if (ref == null) {
            throw new InvalidURLReferenceException("reference url is null");
        }

        URLBuilder builder = new URLBuilder()
                .setScheme(ref.getScheme())
                .setUsername(ref.getUsername())
                .setPassword(ref.getPassword())
                .setHost(ref.getHost())
                .setPath(ref.getPath())
                .setQuery(ref.getQuery())
                .setFragment(ref.getFragment())
                .setOpaque(ref.getOpaque());

        if (!ref.isAbsolute()) {
            builder.setScheme(scheme);
        }

        if (!nullOrEmpty(ref.scheme) || !nullOrEmpty(ref.host)) {
            builder.setPath(PathResolver.resolve(ref.path, ""));
            return builder.build();
        }

        if (ref.isOpaque() || isOpaque()) {
            return builder.build();
        }

        return builder
                .setHost(host)
                .setUsername(username)
                .setPassword(password)
                .setPath(PathResolver.resolve(path, ref.path))
                .build();
    }


    private static String mapToNullIfEmpty(String str) {
        return str != null && !str.isEmpty() ? str : null;
    }

    /**
     * Returns the full host (hostname:port), maps to null if none are set.
     */
    private static String mergeHostPortIfSet(String hostname, Integer port) {
        StringBuilder sb = new StringBuilder();
        boolean exists = false;
        if (hostname != null) {
            sb.append(hostname);
            exists = true;
        }
        if (port != null) {
            sb.append(":");
            sb.append(port);
            exists = true;
        }
        if (exists) {
            return sb.toString();
        }
        return null;
    }

    /**
     * Returns the hostname part of the host ('www.example.com' or '192.168.0.1' or '[fde2:d7de:302::]') if it exists.
     */
    private static String extractHostname(String host) {
        if (host != null) {
            int separator = host.lastIndexOf(":");
            if (separator > -1) {
                return host.substring(0, separator);
            }
            return host;
        }
        return null;
    }

    /**
     * Returns the port part of the host (i.e. 8080 or 443 or 3000) if it exists.
     */
    private static Integer extractPort(String host) {
        if (host != null) {
            int separator = host.lastIndexOf(":");
            if (separator > -1) {
                String part = host.substring(separator + 1, host.length());
                if (part != null && part != "") {
                    try {
                        return Integer.parseInt(part);
                    } catch (NumberFormatException exception) {
                        return null;
                    }
                }
            }
        }
        return null;
    }
}
