/*
 * $Id$
 *
 * @COPYRIGHT@
 */
package com.ncube.oda.util;

import java.io.*;
import java.net.*;

/**
 * <para>
 * Utility class to parse and represent a generic URI.
 * </para>
 *
 * @author <link url="mailto:oda_eng@ncube.com">ODA Development Team</link>
 * @version $Revision$ 
 */
final public class URI implements Serializable, Comparable, Cloneable {
    private String scheme;
    private String authority;
    private String userInfo;
    private String host;
    private String port;
    private String path;
    private String query;
    private String fragment;
    private boolean opaque;
    private String normalizedForm;

    /**
     * <para>
     * Create an uninitialized URI.
     * </para>
     */
    private URI() {
    }

    public URI(final URI base, final String uri) throws MalformedURIException {
        this(uri);

        URI absolute = makeAbsoluteFrom(base);
        this.scheme = absolute.scheme;
        this.authority = absolute.authority;
        this.userInfo = absolute.userInfo;
        this.host = absolute.host;
        this.port = absolute.port;
        this.path = absolute.path;
        this.fragment = absolute.fragment;
        this.opaque = absolute.opaque;
        this.normalizedForm = absolute.normalizedForm;
    }

    public URI(final String uri) throws MalformedURIException {
        parse(uri);
    }

    /**
     * <para>
     * Return the protocol scheme of this <interfacename>URI</interfacename>, or 
     * <literal>null</literal> if this <interfacename>URI</interfacename> is a relative 
     * hierarchical <interfacename>URI</interfacename>.
     * </para>
     *
     * @return the protocol scheme of this URI
     */
    public String getScheme() {
        return this.scheme;
    }

    public String getAuthority() {
        return this.authority;
    }

    public String getPath() {
        return this.path;
    }

    public String getFragment() {
        return this.fragment;
    }

    /**
     *
     */
    public String getQuery() {
        return this.query;
    }

    /**
     * <para>
     * Return true if this URI is absolute. An URI is absolute if, and only if, it has 
     * a scheme component.
     * </para>
     *
     * @return true if this URI is absolute; false otherwise.
     */
    public boolean isAbsolute() {
        return this.scheme != null;
    }

    /**
     * Return true if this URI is a URN, i.e. its scheme equals 'urn'.
     */
    public boolean isURN() {
        return "urn".equalsIgnoreCase(this.scheme);
    }

    /**
     * Return true if this URI is in the opaque form.
     */
    public boolean isOpaque() {
        return this.opaque;
    }

    public URI makeRelativeTo(URI base) {
        int i = base.path.lastIndexOf('/');
        String basePath;
        if (i < 0) {
            basePath = "/";
        } else {
            basePath = base.path.substring(0, i+1);
        }

        if (isOpaque() || base.isOpaque()
            || (this.scheme != null && !this.scheme.equals(base.scheme))
            || (this.authority != null && !this.authority.equals(base.authority))
            || !this.path.startsWith(basePath)) {
            return this;
        }

        URI relative =  new URI();
        relative.path = this.path.substring(basePath.length());
        relative.query = this.query;
        relative.fragment = this.fragment;
        relative.normalize();
        return relative;
    }

    public URI makeAbsoluteFrom(URI base) {
        if (isAbsolute()) {
            return this;
        }

        URI absolute = (URI)base.clone();
        if (this.authority != null) {
            absolute.authority = this.authority;
        }

        String absolutePath = absolute.path;
        if (absolutePath == null) {
            absolutePath = "/";
        }

        if (this.path != null) {
            if (this.path.charAt(0) == '/') {
                absolutePath = this.path;
            } else {
                int pos = absolutePath.lastIndexOf('/');
                if (pos != -1) {
                    absolutePath = absolutePath.substring(0, pos+1).concat(this.path);
                } else {
                    absolutePath = this.path;
                }
            }
            absolute.query = this.query;
        }
        absolute.path = absolutePath;
        absolute.fragment = this.fragment;
        absolute.normalize();
        return absolute;
    }

    /**
     * Return this URI as a URL.
     */
    public URL toURL() throws MalformedURLException {
        return new URL(this.normalizedForm);
    }

    /**
     * @see Comparable#compareTo
     */
    public int compareTo(Object obj) {
        return obj.toString().compareTo(this.normalizedForm);
    }

    /**
     * @see Object#hashCode
     */
    public int hashCode() {
        return this.normalizedForm.hashCode();
    }

    /**
     * @return a clone of this instance.
     * @see Object#clone
     * @see Cloneable
     */
    public Object clone() {
        try {
            return super.clone();
        } catch (CloneNotSupportedException ex) {
            throw new IllegalStateException("CloneNotSupportedException thrown when class implements Clonable");
        }
    }

    /**
     * @param obj the reference object with which to compare.
     * @return <code>true</code> if this object is the same as the obj
     *  argument; <code>false</code> otherwise.
     * @see Object#equals
     */
    public boolean equals(Object obj) {
        if (obj == null) {
            return false;
        }

        return obj.toString().equals(this.normalizedForm);
    }

    /**
     * </para>
     * Return a string representation of this <interfacename>URI</interfacename>.
     * </para>
     *
     * @return a string representation of this <interfacename>URI</interfacename>.
     */
    public String toString() {
        return this.normalizedForm;
    }

    private void parse(String uri) throws MalformedURIException {
        if (uri == null || uri.length() == 0) {
            throw new MalformedURIException("empty URI");
        }

        this.scheme = null;
        this.authority = null;
        this.userInfo = null;
        this.host = null;
        this.port = null;
        this.path = null;
        this.query = null;
        this.fragment = null;
        this.opaque = false;

        uri = unwrapURI(uri);
        char[] uric = uri.toCharArray();
        final int len = uric.length;
        int pos = 0;

        // change '\' to '/'. Technically URI's shouldn't contain '\', but in practice 
        // windows platforms do.
        //
        normalizePathSeparator(uric);

        // scan for scheme ':' delim
        //
        while (pos < len && uric[pos] != ':') {
            ++pos;
        }

        if (pos < len && isSchemeToken(uric, 0, pos)) {
            this.scheme = new String(uric, 0, pos);
            ++pos;
        } else {
            this.scheme = null;
            pos = 0;
        }

        if (pos >= len) {
            throw new MalformedURIException("absolute URI missing scheme specific content");
        }

        // scan for scheme specific part
        //

        if (pos != 0 && isURICharNoSlash(uric, pos)) {
            // opaque uri
            //
            if (!isOpaqueToken(uric, pos, len-pos)) {
                throw new MalformedURIException("illegal character in URI");
            }

            this.path = new String(uric, pos, len-pos);
            this.opaque = true;
        } else {
            // search for authority
            //
            if (len-pos > 1 && uric[pos] == '/' && uric[pos+1] == '/') {
                pos += 2;
                int start = pos;
                while (pos < len && uric[pos] != '/') {
                    ++pos;
                }

                if (!isAuthorityToken(uric, start, pos-start)) {
                    throw new MalformedURIException("bad authority in URI");
                }

                this.authority = new String(uric, start, pos-start);
                parseAuthority();
            }

            // parse relative/absolute hierarchical uri
            //
            int pathBegin = pos;
            while (pos < len && uric[pos] != '?' && uric[pos] != '#') {
                ++pos;
            }

            if (pos != pathBegin) {
                this.path = new String(uric, pathBegin, pos-pathBegin);

                // only scan for query if we have a valid path
                //
                if (pos < len && uric[pos] == '?') {
                    ++pos;
                    int start = pos;
                    while (pos < len && uric[pos] != '#') {
                        ++pos;
                    }

                    this.query = new String(uric, start, pos-start);
                }
            }

            if (pos < len && uric[pos] == '#') {
                ++pos;
                this.fragment = new String(uric, pos, len-pos);
            }
        }

        normalize();
    }

    private void parseAuthority() throws MalformedURIException {
        char[] authc = this.authority.toCharArray();
        final int len = authc.length;
        int pos = 0;

        // scan for userinfo
        //
        while (pos < len && authc[pos] != '@') {
            ++pos;
        }

        String userInfo = null;
        String host = null;
        String port = null;

        if (pos < len) {
            // assume this a registry/name authority
            //
            if (!isUserInfoToken(authc, 0, pos)) {
                return;
            }
            userInfo = new String(authc, 0, pos);
            ++pos;
        } else {
            pos = 0;
        }

        if (pos >= len) {
            return;
        }

        // scan for ipv6 style address
        //
        if (authc[pos] == '[') {
            ++pos;
            int start = pos;
            while (pos < len && authc[pos] != ']') {
                ++pos;
            }

            if (pos >= len || !isIPV6Token(authc, start, pos-start)) {
                throw new MalformedURIException("bad authority in URI");
            }

            host = new String(authc, start, pos-start);
            ++pos;
        } else {
            // scan for host/ipv6 style address
            //
            int start = pos;
            char c;
            while (pos < len && (c = authc[pos]) != ':') {
                if (!isDomainOrIpChar(c)) {
                    return;
                }
                ++pos;
            }

            host = new String(authc, start, pos-start);
        }

        // parse the port
        //
        if (pos < len && authc[pos] == ':') {
            ++pos;
            int start = pos;
            while (pos < len && Character.isDigit(authc[pos])) {
                ++pos;
            }

            if (pos < len) {
                return;
            }

            port = new String(authc, start, len-start);
        }

        this.userInfo = userInfo;
        this.host = host;
        this.port = port;
    }

    private void normalize() {
        StringBuffer buffer = new StringBuffer();
        if (this.opaque) {
            buffer.append(this.scheme);
            buffer.append(':');
            buffer.append(this.path);
        } else {
            if (this.scheme != null) {
                buffer.append(this.scheme);
                buffer.append(':');
            }

            if (this.authority != null) {
                buffer.append("//");
                buffer.append(this.authority);
            }

            if (this.path != null) {
                if (!opaque) {
                    this.path = normalizePath(this.path);
                }
                buffer.append(this.path);
            }

            if (this.query != null) {
                buffer.append('?');
                buffer.append(this.query);
            }

            if (this.fragment != null) {
                buffer.append('#');
                buffer.append(fragment);
            }
        }

        this.normalizedForm = buffer.toString();
    }

    private static void normalizePathSeparator(char[] chars) {
        final int len = chars.length;
        for (int pos = 0; pos < len; ++pos) {
            if (chars[pos] == '\\') {
                chars[pos] = '/';
            }
        }
    }

    /**
     * Normalize '/../', '/./', and '//' in the path
     */
    private static String normalizePath(String path) {
        int pos;
        int start;

        // normalize /./
        //
        while ((pos = path.indexOf("/./")) != -1) {
            path = path.substring(0, pos).concat(path.substring(pos+2)); 
        }

        // normalize /../
        //
        start = 0;
        while ((pos = path.indexOf("/../", start)) != -1) {
            int i = path.lastIndexOf('/', pos-1);
            if (i >= start) {
                path = path.substring(0, i).concat(path.substring(pos+3));
            } else if (pos == 0) {
                path = path.substring(pos+3);
            } else {
                start = pos+3;
            }
        }

        // normalize trailing /..
        //
        if (path.endsWith("/..")) {
            pos = path.length()-3;
            if (pos == 0) {
                path = "/";
            } else {
                int i = path.lastIndexOf('/', pos-1);
                if (i != -1) {
                    if (!path.regionMatches(i, "/../", 0, 4)) {
                        path = path.substring(0, i+1);
                    }
                } else if (!path.regionMatches(0, "../", 0, 3)) {
                    path = "./";
                }
            }
        }

        // normalize trailing /.
        //
        if (path.endsWith("/.")) {
            path = path.substring(0, path.length()-1);
        }

        return path;
    }

    /**
     * Remove enclosing "<URL:*>"
     */
    private static String unwrapURI(String uri) {
        if (uri.startsWith("<") && uri.endsWith(">")) {
            int pos = 1;
            if (uri.regionMatches(1, "URL:", 0, 4)) {
                pos = 5;
            }
            uri = uri.substring(pos, uri.length()-1);
        }
        return uri;
    }

    // Character classes from RFC2396
    //

    private static boolean isSchemeToken(final char[] sequence, int pos, final int len) {
        if (len == 0) {
            return false;
        }

        if (!Character.isLetter(sequence[pos])) {
            return false;
        }

        ++pos;
        boolean match = true;
        while (pos < len && match) {
            char c = sequence[pos];
            switch (c) {
            case '+':
            case '-':
            case '.':
                match = true;
                break;

            default:
                match = Character.isLetterOrDigit(c);
                break;
            }
            ++pos;
        }

        return match;
    }

    private static boolean isOpaqueToken(final char[] sequence, int pos, final int len) {
        if (len == 0 || !isURICharNoSlash(sequence, pos)) {
            return false;
        }

        ++pos;
        boolean match = true;
        while (pos < len && (match = isURIChar(sequence, pos))) {
            ++pos;
        }

        return match;
    }

    private static boolean isAuthorityToken(final char[] sequence, int pos, final int len) {
        boolean match = true;
        while (pos < len && match) {
            if (isEscape(sequence, pos)) {
                pos += 3;
                continue;
            }

            char c = sequence[pos];
            switch (c) {
            case '$':
            case ',':
            case ';':
            case ':': 
            case '@':
            case '&':
            case '=':
            case '+':
            case '[':
            case ']':
                match = true;
                break;

            default:
                match = isUnreserved(c);
                break;
            }
            ++pos;
        }

        return match;
    }

    private static boolean isUserInfoToken(final char[] sequence, int pos, final int len) {
        boolean match = true;
        while (pos < len && match) {
            if (isEscape(sequence, pos)) {
                pos += 3;
                continue;
            }

            char c = sequence[pos];
            switch (c) {
            case ';':
            case ':':
            case '&':
            case '=': 
            case '+':
            case '$':
            case ',':
                match = true;
                break;

            default:
                match = isUnreserved(sequence[pos]);
                break;
            }

            ++pos;
        }

        return match;
    }

    private static boolean isIPV6Token(final char[] sequence, int pos, final int len) {
        boolean match = true;
        while (pos < len && match) {
            char c = sequence[pos];
            match = c == ':' || isHexDigit(c);
            ++pos;
        }

        return match;
    }

    private static boolean isURIChar(final char[] sequence, final int pos) {
        return isReserved(sequence[pos]) || isUnreserved(sequence[pos]) || isEscape(sequence, pos);
    }

    private static boolean isURICharNoSlash(final char[] sequence, final int pos) {
        if (isUnreserved(sequence[pos]) || isEscape(sequence, pos)) {
            return true;
        }

        switch (sequence[pos]) {
        case ';':
        case '?':
        case ':':
        case '@':
        case '&':
        case '=':
        case '+':
        case '$': 
        case ',':
            return true;

        default:
            return false;
        }
    }

    private static boolean isReserved(final char c) {
        switch (c) {
        case ';':
        case '/':
        case '?':
        case ':': 
        case '@':
        case '&':
        case '=':
        case '+':
        case '$':
        case ',':
        case '[':
        case ']':
            return true;

        default:
            return false;
        }
    }

    private static boolean isUnreserved(final char c) {
        return Character.isLetterOrDigit(c) || isMark(c);
    }

    private static boolean isMark(final char c) {
        switch (c) {
        case '-':
        case '_':
        case '.':
        case '!': 
        case '~':
        case '*':
        case '\'':
        case '(': 
        case ')':
            return true;

        default:
            return false;
        }
    }

    private static boolean isHexDigit(final char c) {
        switch (c) {
        case 'a':
        case 'b':
        case 'c':
        case 'd':
        case 'e':
        case 'f':
        case 'A':
        case 'B':
        case 'C':
        case 'D':
        case 'E':
        case 'F':
        case '0':
        case '1':
        case '2':
        case '3':
        case '4':
        case '5':
        case '6':
        case '7':
        case '8':
        case '9':
            return true;

        default:
            return false;
        }
    }

    private static boolean isDomainOrIpChar(final char c) {
        switch (c) {
        case '-':
        case '_':
        case '.':
            return true;

        default:
            return Character.isLetterOrDigit(c);
        }
    }

    private static boolean isEscape(final char[] sequence, final int pos) {
        return sequence.length-pos > 2 && sequence[pos] == '%'
        && isHexDigit(sequence[pos + 1])
        && isHexDigit(sequence[pos + 2]);
    }

    private static boolean isDelim(final char c) {
        switch (c) {
        case '<':
        case '>':
        case '#':
        case '%':
        case '"':
            return true;

        default:
            return false;
        }
    }
}
