/*
 * Helma License Notice
 *
 * The contents of this file are subject to the Helma License
 * Version 2.0 (the "License"). You may not use this file except in
 * compliance with the License. A copy of the License is available at
 * http://adele.helma.org/download/helma/license.txt
 *
 * Copyright 1998-2003 Helma Software. All Rights Reserved.
 *
 * $RCSfile$
 * $Author$
 * $Revision$
 * $Date$
 */

package helma.framework;

import helma.util.SystemMap;
import helma.util.StringUtils;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.Cookie;

import org.apache.commons.codec.binary.Base64;

import java.io.*;
import java.util.*;
import java.util.regex.Pattern;
import java.util.regex.Matcher;

/**
 * A Transmitter for a request from the servlet client. Objects of this
 * class are directly exposed to JavaScript as global property req.
 */
public class RequestTrans implements Serializable {

    static final long serialVersionUID = 5398880083482000580L;

    // HTTP methods
    public final static String GET = "GET";
    public final static String POST = "POST";
    public final static String DELETE = "DELETE";
    public final static String HEAD = "HEAD";
    public final static String OPTIONS = "OPTIONS";
    public final static String PUT = "PUT";
    public final static String TRACE = "TRACE";
    // Helma pseudo-methods
    public final static String XMLRPC = "XMLRPC";
    public final static String EXTERNAL = "EXTERNAL";
    public final static String INTERNAL = "INTERNAL";

    // the servlet request and response, may be null
    final HttpServletRequest request;
    final HttpServletResponse response;

    // the path info of the request
    private final String path;

    // the uri of the request
    private final String uri;

    // the request's session id
    private String session;

    // the map of form and cookie data
    private final Map values = new DataComboMap();

    private ParamComboMap params;
    private ParameterMap queryParams, postParams, cookies;

    // the HTTP request method
    private String method;

    // timestamp of client-cached version, if present in request
    private long ifModifiedSince = -1;

    // set of ETags the client sent with If-None-Match header
    private final Set etags = new HashSet();

    // when was execution started on this request?
    private final long startTime;

    // the name of the action being invoked
    private String action;
    private Object actionHandler = null;
    private String httpUsername;
    private String httpPassword;

    static private final Pattern paramPattern = Pattern.compile("\\[(.+?)\\]");

    /**
     *  Create a new Request transmitter with an empty data map.
     */
    public RequestTrans(String method, String path) {
        this.method = method;
        this.path = path;
        this.uri = null;
        this.request = null;
        this.response = null;
        startTime = System.currentTimeMillis();
    }

    /**
     *  Create a new request transmitter with the given data map.
     */
    public RequestTrans(HttpServletRequest request,
                        HttpServletResponse response, String path) {
        this.method = request.getMethod();
        this.request = request;
        this.response = response;
        this.path = path;
        this.uri = request.getRequestURI();
        startTime = System.currentTimeMillis();

        // do standard HTTP variables
        String header = request.getHeader("Host");
        if (header != null) {
            values.put("http_host", header.toLowerCase());
        }

        header = request.getHeader("Referer");
        if (header != null) {
            values.put("http_referer", header);
        }

        try {
            long ifModifiedSince = request.getDateHeader("If-Modified-Since");
            if (ifModifiedSince > -1) {
               setIfModifiedSince(ifModifiedSince);
            }
        } catch (IllegalArgumentException ignore) {
            // not a date header
        }

        header = request.getHeader("If-None-Match");
        if (header != null) {
            setETags(header);
        }

        header = request.getRemoteAddr();
        if (header != null) {
            values.put("http_remotehost", header);
        }

        header = request.getHeader("User-Agent");
        if (header != null) {
            values.put("http_browser", header);
        }

        header = request.getHeader("Accept-Language");
        if (header != null) {
            values.put("http_language", header);
        }

        header = request.getHeader("authorization");
        if (header != null) {
            values.put("authorization", header);
        }
    }

    /**
     * Return true if we should try to handle this as XML-RPC request.
     *
     * @return true if this might be an XML-RPC request.
     */
    public synchronized boolean checkXmlRpc() {
        if ("POST".equalsIgnoreCase(method)) {
            String contentType = request.getContentType();
            if (contentType == null) {
                return false;
            }
            int semi = contentType.indexOf(";");
            if (semi > -1) {
                contentType = contentType.substring(0, semi);
            }
            return "text/xml".equalsIgnoreCase(contentType.trim());
        }
        return false;
    }

    /**
     * Return true if this request is in fact handled as XML-RPC request.
     * This implies that {@link #checkXmlRpc()} returns true and a matching
     * XML-RPC action was found.
     *
     * @return true if this request is handled as XML-RPC request.
     */
    public synchronized boolean isXmlRpc() {
        return XMLRPC.equals(method);
    }

    /**
     * Set a cookie
     * @param name the cookie name
     * @param cookie the cookie
     */
    public void setCookie(String name, Cookie cookie) {
        if (cookies == null) {
            cookies = new ParameterMap();
        }
        cookies.put(name, cookie);
    }

    /**
     * @return a map containing the cookies sent with this request
     */
    public Map getCookies() {
        if (cookies == null) {
            cookies = new ParameterMap();
        }
        return cookies;
    }

    /**
     * @return the combined query and post parameters for this request
     */
    public Map getParams() {
        if (params == null) {
            params = new ParamComboMap();
        }
        return params;
    }

    /**
     * @return get the query parameters for this request
     */
    public Map getQueryParams() {
        if (queryParams == null) {
            queryParams = new ParameterMap();
        }
        return queryParams;
    }

    /**
     * @return get the post parameters for this request
     */
    public Map getPostParams() {
        if (postParams == null) {
            postParams = new ParameterMap();
        }
        return postParams;
    }

    /**
     * set the request parameters
     */
    public void setParameters(Map parameters, boolean isPost) {
        if (isPost) {
            postParams = new ParameterMap(parameters);
        } else {
            queryParams = new ParameterMap(parameters);
        }
    }

    /**
     * Add a post parameter to the request
     * @param name the parameter name
     * @param value the parameter value
     */
    public void addPostParam(String name, Object value) {
        if (postParams == null) {
            postParams = new ParameterMap();
        }
        Object previous = postParams.getRaw(name);
        if (previous instanceof Object[]) {
            Object[] array = (Object[]) previous;
            Object[] values = new Object[array.length + 1];
            System.arraycopy(array, 0, values, 0, array.length);
            values[array.length] = value;
            postParams.put(name, values);
        } else if (previous == null) {
            postParams.put(name, new Object[] {value});
        }
    }

    /**
     * Set a parameter value in this request transmitter. This
     * parses foo[bar][baz] as nested objects/maps.
     */
    public void set(String name, Object value) {
        values.put(name, value);
    }

    /**
     *  Get a value from the requests map by key.
     */
    public Object get(String name) {
        try {
            return values.get(name);
        } catch (Exception x) {
            return null;
        }
    }

    /**
     *  Get the data map for this request transmitter.
     */
    public Map getRequestData() {
        return values;
    }

    /**
     * Returns the Servlet request represented by this RequestTrans instance.
     * Returns null for internal and XML-RPC requests.
     */
    public HttpServletRequest getServletRequest() {
        return request;
    }

    /**
     * Proxy to HttpServletRequest.getHeader().
     * @param name the header name
     * @return the header value, or null
     */
    public String getHeader(String name) {
        return request == null ? null : request.getHeader(name);
    }

    /**
     * Proxy to HttpServletRequest.getHeaders(), returns header values as string array.
     * @param name the header name
     * @return the header values as string array
     */
    public String[] getHeaders(String name) {
        return request == null ?
                null : StringUtils.collect(request.getHeaders(name));
    }

    /**
     * Proxy to HttpServletRequest.getIntHeader(), fails silently by returning -1.
     * @param name the header name
     * @return the header parsed as integer or -1
     */
    public int getIntHeader(String name) {
        try {
            return request == null ? -1 : getIntHeader(name);
        } catch (NumberFormatException nfe) {
            return -1;
        }
    }

    /**
     * Proxy to HttpServletRequest.getDateHeader(), fails silently by returning -1.
     * @param name the header name
     * @return the date in milliseconds, or -1
     */
    public long getDateHeader(String name) {
        try {
            return request == null ? -1 : getDateHeader(name);
        } catch (NumberFormatException nfe) {
            return -1;
        }
    }

    /**
     * Returns the Servlet response for this request.
     * Returns null for internal and XML-RPC requests.
     */
    public HttpServletResponse getServletResponse() {
        return response;
    }

    /**
     *  The hash code is computed from the session id if available. This is used to
     *  detect multiple identic requests.
     */
    public int hashCode() {
        if (session == null || path == null) {
            return super.hashCode();
        } else {
            return 17 + (37 * session.hashCode()) +
                        (37 * path.hashCode());
        }
    }

    /**
     * A request is considered equal to another one if it has the same method,
     * path, session, request data, and conditional get data. This is used to
     * evaluate multiple simultanous identical requests only once.
     */
    public boolean equals(Object what) {
        if (what instanceof RequestTrans) {
            if (session == null || path == null) {
                return super.equals(what);
            } else {
                RequestTrans other = (RequestTrans) what;
                return (session.equals(other.session)
                        && path.equalsIgnoreCase(other.path)
                        && values.equals(other.values)
                        && ifModifiedSince == other.ifModifiedSince
                        && etags.equals(other.etags));
            }
        }
        return false;
    }

    /**
     * Return the method of the request. This may either be a HTTP method or
     * one of the Helma pseudo methods defined in this class.
     */
    public synchronized String getMethod() {
        return method;
    }

    /**
     * Set the method of this request.
     *
     * @param method the method.
     */
    public synchronized void setMethod(String method) {
        this.method = method;
    }

    /**
     *  Return true if this object represents a HTTP GET Request.
     */
    public boolean isGet() {
        return GET.equalsIgnoreCase(method);
    }

    /**
     *  Return true if this object represents a HTTP GET Request.
     */
    public boolean isPost() {
        return POST.equalsIgnoreCase(method);
    }

    /**
     * Get the request's session id
     */
    public String getSession() {
        return session;
    }

    /**
     * Set the request's session id
     */
    public void setSession(String session) {
        this.session = session;
    }

    /**
     * Get the request's path
     */
    public String getPath() {
        return path;
    }

    /**
     * Get the request's path
     */
    public String getUri() {
        return uri;
    }

    /**
     * Get the request's action.
     */
    public String getAction() {
        return action;
    }

    /**
     * Set the request's action.
     */
    public void setAction(String action) {
        int suffix = action.lastIndexOf("_action");
        this.action = suffix > -1 ? action.substring(0, suffix) : action;
    }

    /**
     * Get the request's action handler. The action handler allows the
     * onRequest() method to set the function object to be invoked for processing
     * the request, overriding the action resolved from the request path.
     * @return the action handler function
     */
    public Object getActionHandler() {
        return actionHandler;
    }

    /**
     * Set the request's action handler. The action handler allows the
     * onRequest() method to set the function object to be invoked for processing
     * the request, overriding the action resolved from the request path.
     * @param handler the action handler
     */
    public void setActionHandler(Object handler) {
        this.actionHandler = handler;
    }

    /**
     * Get the time the request was created.
     */
    public long getStartTime() {
        return startTime;
    }

    /**
     *
     *
     * @param since ...
     */
    public void setIfModifiedSince(long since) {
        ifModifiedSince = since;
    }

    /**
     *
     *
     * @return ...
     */
    public long getIfModifiedSince() {
        return ifModifiedSince;
    }

    /**
     *
     *
     * @param etagHeader ...
     */
    public void setETags(String etagHeader) {
        if (etagHeader.indexOf(",") > -1) {
            StringTokenizer st = new StringTokenizer(etagHeader, ", \r\n");
            while (st.hasMoreTokens())
                etags.add(st.nextToken());
        } else {
            etags.add(etagHeader);
        }
    }

    /**
     *
     *
     * @return ...
     */
    public Set getETags() {
        return etags;
    }

    /**
     *
     *
     * @param etag ...
     *
     * @return ...
     */
    public boolean hasETag(String etag) {
        if ((etags == null) || (etag == null)) {
            return false;
        }

        return etags.contains(etag);
    }

    /**
     *
     *
     * @return ...
     */
    public String getUsername() {
        if (httpUsername != null) {
            return httpUsername;
        }

        String auth = (String) get("authorization");

        if ((auth == null) || "".equals(auth)) {
            return null;
        }

        decodeHttpAuth(auth);

        return httpUsername;
    }

    /**
     *
     *
     * @return ...
     */
    public String getPassword() {
        if (httpPassword != null) {
            return httpPassword;
        }

        String auth = (String) get("authorization");

        if ((auth == null) || "".equals(auth)) {
            return null;
        }

        decodeHttpAuth(auth);

        return httpPassword;
    }

    private void decodeHttpAuth(String auth) {
        if (auth == null) {
            return;
        }

        StringTokenizer tok;

        if (auth.startsWith("Basic ")) { //$NON-NLS-1$
            tok = new StringTokenizer(new String(Base64.decodeBase64(auth.substring(6))),
                                      ":"); //$NON-NLS-1$
        } else {
            tok = new StringTokenizer(new String(Base64.decodeBase64(auth)), ":"); //$NON-NLS-1$
        }

        try {
            httpUsername = tok.nextToken();
        } catch (NoSuchElementException e) {
            httpUsername = null;
        }

        try {
            StringBuffer buf = new StringBuffer(tok.nextToken());
            while (tok.hasMoreTokens()) {
                buf.append(":");
                buf.append(tok.nextToken());
            }
            httpPassword = buf.toString();
        } catch (NoSuchElementException e) {
            httpPassword = null;
        }
    }

    public String toString() {
        return method + ":" + path;
    }

    class ParameterMap extends SystemMap {

        private static final long serialVersionUID = 7632860503639617076L;

        public ParameterMap() {
            super();
        }

        public ParameterMap(Map map) {
            super((int) (map.size() / 0.75f) + 1);
            for (Iterator i = map.entrySet().iterator(); i.hasNext(); ) {
                Map.Entry e = (Map.Entry) i.next();
                put(e.getKey(), e.getValue());
            }
        }

        public Object put(Object key, Object value) {
            if (key instanceof String) {
                String name = (String) key;
                int bracket = name.indexOf('[');
                if (bracket > -1 && name.endsWith("]")) {
                    Matcher matcher = paramPattern.matcher(name);
                    String partName = name.substring(0, bracket);
                    return putInternal(partName, matcher, value);
                }
            }
            Object previous = super.get(key);
            if (previous != null && (previous instanceof Map || value instanceof Map))
                throw new RuntimeException("Conflicting HTTP Parameters for '" + key + "'");
            return super.put(key, value);
        }

        private Object putInternal(String name, Matcher matcher, Object value) {
            Object previous = super.get(name);
            if (matcher.find()) {
                ParameterMap map = null;
                if (previous instanceof ParameterMap) {
                    map = (ParameterMap) previous;
                } else if (previous == null) {
                    map = new ParameterMap();
                    super.put(name, map);
                } else {
                    throw new RuntimeException("Conflicting HTTP Parameters for '" + name + "'");
                }
                String partName = matcher.group(1);
                return map.putInternal(partName, matcher, value);
            }
            if (previous != null && (previous instanceof Map || value instanceof Map))
                throw new RuntimeException("Conflicting HTTP Parameters for '" + name + "'");
            return super.put(name, value);
        }

        public Object get(Object key) {
            if (key instanceof String) {
                Object value = super.get(key);
                String name = (String) key;
                if (name.endsWith("_array") && value == null) {
                    value = super.get(name.substring(0, name.length() - 6));
                    return value instanceof Object[] ? value : null;
                } else if (name.endsWith("_cookie") && value == null) {
                    value = super.get(name.substring(0, name.length() - 7));
                    return value instanceof Cookie ? value : null;
                } else if (value instanceof Object[]) {
                    Object[] values = ((Object[]) value);
                    return values.length > 0 ? values[0] : null;
                } else if (value instanceof Cookie) {
                    Cookie cookie = (Cookie) value;
                    return cookie.getValue();
                }
            }
            return super.get(key);
        }

        protected Object getRaw(Object key) {
            return super.get(key);
        }
    }

    class DataComboMap extends SystemMap {

        private static final long serialVersionUID = 5737810055554406299L;

        public Object get(Object key) {
            Object value = super.get(key);
            if (value != null)
                return value;
            if (postParams != null && (value = postParams.get(key)) != null)
                return value;
            if (queryParams != null && (value = queryParams.get(key)) != null)
                return value;
            if (cookies != null && (value = cookies.get(key)) != null)
                return value;
            return null;
        }

        public boolean containsKey(Object key) {
            return get(key) != null;
        }

        public Set entrySet() {
            Set entries = new HashSet(super.entrySet());
            if (postParams != null) entries.addAll(postParams.entrySet());
            if (queryParams != null) entries.addAll(queryParams.entrySet());
            if (cookies != null) entries.addAll(cookies.entrySet());
            return entries;
        }

        public Set keySet() {
            Set keys = new HashSet(super.keySet());
            if (postParams != null) keys.addAll(postParams.keySet());
            if (queryParams != null) keys.addAll(queryParams.keySet());
            if (cookies != null) keys.addAll(cookies.keySet());
            return keys;
        }
    }

    class ParamComboMap extends SystemMap {
        private static final long serialVersionUID = -9177176570950359431L;

        public Object get(Object key) {
            Object value;
            if (postParams != null && (value = postParams.get(key)) != null)
                return value;
            if (queryParams != null && (value = queryParams.get(key)) != null)
                return value;
            return null;
        }

        public boolean containsKey(Object key) {
            return get(key) != null;
        }

        public Set entrySet() {
            Set entries = new HashSet();
            if (postParams != null) entries.addAll(postParams.entrySet());
            if (queryParams != null) entries.addAll(queryParams.entrySet());
            return entries;
        }

        public Set keySet() {
            Set keys = new HashSet();
            if (postParams != null) keys.addAll(postParams.keySet());
            if (queryParams != null) keys.addAll(queryParams.keySet());
            return keys;
        }
    }
}
