/*
 * JBoss, Home of Professional Open Source
 * Copyright 2013, Red Hat, Inc. and individual contributors
 * by the @authors tag. See the copyright.txt in the distribution for a
 * full listing of individual contributors.
 *
 * This is free software; you can redistribute it and/or modify it
 * under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation; either version 2.1 of
 * the License, or (at your option) any later version.
 *
 * This software is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this software; if not, write to the Free
 * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
 * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
 */
package org.richfaces.renderkit;

import java.io.IOException;
import java.lang.reflect.Array;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;

import javax.el.ValueExpression;
import javax.faces.application.Application;
import javax.faces.application.Resource;
import javax.faces.application.ResourceHandler;
import javax.faces.component.UIComponent;
import javax.faces.component.UIComponentBase;
import javax.faces.component.behavior.ClientBehavior;
import javax.faces.component.behavior.ClientBehaviorContext;
import javax.faces.component.behavior.ClientBehaviorHint;
import javax.faces.component.behavior.ClientBehaviorHolder;
import javax.faces.context.ExternalContext;
import javax.faces.context.FacesContext;
import javax.faces.context.ResponseWriter;

import org.ajax4jsf.javascript.JSFunctionDefinition;
import org.ajax4jsf.javascript.ScriptUtils;
import org.richfaces.renderkit.ComponentAttribute.Kind;

/**
 * @author Nick Belaevski
 *
 */
public final class RenderKitUtils {
    /**
     *
     */
    static final String BEHAVIOR_SOURCE_ID = "javax.faces.source";
    /**
     *
     */
    static final String BEHAVIOR_EVENT_NAME = "javax.faces.behavior.event";
    /**
     *
     */
    private static final String XHTML_ATTRIBUTE_PREFIX = "xml:";
    /**
     *
     */
    private static final String XHTML_CONTENT_TYPE = "application/xhtml+xml";
    // TODO - check what's in MyFaces
    private static final String ATTRIBUTES_THAT_ARE_SET = UIComponentBase.class.getName() + ".attributesThatAreSet";
    private static final String[] BOOLEAN_ATTRIBUTE_NAMES = { "checked", "compact", "declare", "defer", "disabled", "ismap",
            "multiple", "nohref", "noshade", "nowrap", "readonly", "selected" };
    private static final String[] URI_ATTRIBUTE_NAMES = { "action", "background", "cite", "classid", "codebase", "data",
            "href", "longdesc", "profile", "src", "usemap" };
    private static final String[] XHTML_ATTRIBUTE_NAMES = { "lang" };
    private static final String DISABLED_ATTRIBUTE_NAME = "disabled";

    /**
     * Wrapper class around object value used to transform values into particular JS objects
     *
     * @author Nick Belaevski
     * @since 3.3.2
     */
    public enum ScriptHashVariableWrapper {

        /**
         * No-op default wrapper
         */
        noop {
            @Override
            Object wrap(Object o) {
                return o;
            }
        },
        /**
         * Convert parameter to array of srings.
         */
        asArray {
            @Override
            Object wrap(Object o) {
                return asArray(o);
            }
        },
        /**
         * Event handler functions wrapper. Wraps
         *
         * <pre>
         * functionCode
         * </pre>
         *
         * object into:
         *
         * <pre>
         * function(event) {
         *   functionCode
         * }
         * </pre>
         */
        eventHandler {
            @Override
            Object wrap(Object o) {
                return new JSFunctionDefinition("event").addToBody(o);
            }
        };

        /**
         * Method that does the wrapping
         *
         * @param o object to wrap
         * @return wrapped object
         */
        abstract Object wrap(Object o);
    }

    private RenderKitUtils() {
        // utility constructor
    }

    public static String[] asArray(Object object) {
        if (object == null) {
            return null;
        }

        Class<?> componentType = object.getClass().getComponentType();

        if (String.class.equals(componentType)) {
            return (String[]) object;
        } else if (componentType != null) {
            Object[] objects = (Object[]) object;
            String[] result = new String[objects.length];

            for (int i = 0; i < objects.length; i++) {
                Object o = objects[i];

                if (o == null) {
                    continue;
                }

                result[i] = o.toString();
            }

            return result;
        } else if (object instanceof Collection) {
            Collection<?> collection = (Collection<?>) object;
            String[] result = new String[collection.size()];
            Iterator<?> iterator = collection.iterator();

            for (int i = 0; i < result.length; i++) {
                Object next = iterator.next();

                if (next == null) {
                    continue;
                }

                result[i] = next.toString();
            }

            return result;
        } else {
            String string = object.toString().trim();
            String[] split = string.split("\\s*,\\s*");

            return split;
        }
    }

    private static Map<String, List<ClientBehavior>> getClientBehaviorsMap(UIComponent component) {
        Map<String, List<ClientBehavior>> result;
        if (component instanceof ClientBehaviorHolder) {
            ClientBehaviorHolder clientBehaviorHolder = (ClientBehaviorHolder) component;

            result = clientBehaviorHolder.getClientBehaviors();
        } else {
            result = Collections.emptyMap();
        }
        return result;
    }

    static boolean isDisabled(UIComponent component) {
        Object disabledAttributeValue = component.getAttributes().get(DISABLED_ATTRIBUTE_NAME);
        if (disabledAttributeValue == null) {
            return false;
        }

        if (disabledAttributeValue instanceof Boolean) {
            return Boolean.TRUE.equals(disabledAttributeValue);
        }

        return Boolean.valueOf(disabledAttributeValue.toString());
    }

    static String escape(String s) {
        StringBuilder sb = new StringBuilder(s.length());
        int start = 0;
        int end;

        while ((end = s.indexOf('\'', start)) >= 0) {
            sb.append(s, start, end);
            sb.append("\\'");

            start = end + 1;
        }

        sb.append(s, start, s.length());

        return sb.toString();
    }

    static boolean chain(StringBuilder sb, Object object, boolean isChained) {
        if (object != null) {
            String objectString = object.toString().trim();
            if (objectString.length() != 0) {
                final boolean localIsChained;

                if (!isChained && sb.length() != 0) {
                    // extract previously stored handler
                    String previousHandlerString = sb.toString();
                    // clear builder object
                    sb.setLength(0);

                    // append escaped handler
                    sb.append("'");
                    sb.append(escape(previousHandlerString));
                    sb.append("'");

                    localIsChained = true;
                } else {
                    // use passed in value of chained indicator
                    localIsChained = isChained;
                }

                if (localIsChained) {
                    sb.append(",'");
                    sb.append(escape(objectString));
                    sb.append("'");

                    return true;
                } else {
                    sb.append(objectString);
                    return false;
                }
            }
        }

        // no changes, pass chained indicator we initially used
        return isChained;
    }

    private static Object createBehaviorsChain(Object inlineHandlerValue, ClientBehaviorContext behaviorContext,
            List<ClientBehavior> behaviors) {

        boolean isChained = false;
        StringBuilder result = new StringBuilder();

        isChained = chain(result, inlineHandlerValue, isChained);
        for (ClientBehavior behavior : behaviors) {
            isChained = chain(result, behavior.getScript(behaviorContext), isChained);

            if (behavior.getHints().contains(ClientBehaviorHint.SUBMITTING)) {
                break;
            }
        }

        if (result.length() == 0) {
            return null;
        }

        if (isChained) {
            result.insert(0, "return jsf.util.chain(this, event, ");
            result.append(")");
        }

        return result.toString();
    }

    private static boolean isAttributeSet(Object attributeValue) {
        // TODO - consider required attributes with "" value (like 'alt')
        if (attributeValue == null) {
            return false;
        } else if (attributeValue instanceof String) {
            return ((String) attributeValue).length() > 0;
        } else if (attributeValue instanceof Integer && (Integer) attributeValue == Integer.MIN_VALUE) {
            return false;
        } else if (attributeValue instanceof Double && (Double) attributeValue == Double.MIN_VALUE) {
            return false;
        } else if (attributeValue instanceof Character && (Character) attributeValue == Character.MIN_VALUE) {
            return false;
        } else if (attributeValue instanceof Float && (Float) attributeValue == Float.MIN_VALUE) {
            return false;
        } else if (attributeValue instanceof Short && (Short) attributeValue == Short.MIN_VALUE) {
            return false;
        } else if (attributeValue instanceof Byte && (Byte) attributeValue == Byte.MIN_VALUE) {
            return false;
        } else if (attributeValue instanceof Long && (Long) attributeValue == Long.MIN_VALUE) {
            return false;
        } else if (attributeValue instanceof Collection<?> || attributeValue instanceof Map<?, ?>) {
            return true;
        }

        return attributeValue.toString().length() > 0;
    }

    public static boolean shouldRenderAttribute(Object attributeValue) {
        // TODO - consider required attributes with "" value (like 'alt')
        if (!isAttributeSet(attributeValue)) {
            return false;
        }

        if (attributeValue instanceof Boolean && Boolean.FALSE.equals(attributeValue)) {
            return false;
        }

        return attributeValue.toString().length() > 0;
    }

    public static String prefixAttributeName(String attributeName, boolean isXhtmlMode) {
        if (isXhtmlMode) {
            if (Arrays.binarySearch(XHTML_ATTRIBUTE_NAMES, attributeName) >= 0) {
                return XHTML_ATTRIBUTE_PREFIX + attributeName;
            }
        }

        return attributeName;
    }

    public static String prefixAttributeName(String attributeName, ResponseWriter responseWriter) {
        return prefixAttributeName(attributeName, XHTML_CONTENT_TYPE.equals(responseWriter.getContentType()));
    }

    public static void renderAttribute(FacesContext facesContext, String attributeName, Object attributeValue)
            throws IOException {

        if (!shouldRenderAttribute(attributeValue)) {
            return;
        }

        ResponseWriter writer = facesContext.getResponseWriter();

        String prefixedAttributeName = prefixAttributeName(attributeName, writer);

        if (Arrays.binarySearch(URI_ATTRIBUTE_NAMES, attributeName) >= 0) {
            writer.writeURIAttribute(prefixedAttributeName, attributeValue, null);
        } else if (Arrays.binarySearch(BOOLEAN_ATTRIBUTE_NAMES, attributeName) >= 0) {
            boolean booleanAttributeValue = Boolean.valueOf(String.valueOf(attributeValue));
            if (booleanAttributeValue) {
                // TODO - is passing in Boolean.TRUE value documented somewhere?
                writer.writeAttribute(prefixedAttributeName, Boolean.TRUE, null);
            }
        } else {
            writer.writeAttribute(prefixedAttributeName, attributeValue, null);
        }
    }

    // TODO - create special method for event handlers that will return String?
    // TODO - add check for 'disabled'?
    public static Object getAttributeAndBehaviorsValue(FacesContext facesContext, UIComponent component,
            ComponentAttribute componentAttribute) {
        if (facesContext == null) {
            throw new NullPointerException("facesContext");
        }

        if (component == null) {
            throw new NullPointerException("component");
        }

        if (componentAttribute == null) {
            throw new NullPointerException("componentAttribute");
        }

        String componentAttributeName = componentAttribute.getComponentAttributeName();
        Object attributeValue = component.getAttributes().get(componentAttributeName);

        String[] eventNames = componentAttribute.getEventNames();
        if (eventNames.length > 0) {
            Map<String, List<ClientBehavior>> behaviorsMap = getClientBehaviorsMap(component);
            if (behaviorsMap.size() > 0) {
                for (String eventName : eventNames) {
                    if (behaviorsMap.containsKey(eventName)) {
                        List<ClientBehavior> behaviorsList = behaviorsMap.get(eventName);
                        if (!behaviorsList.isEmpty()) {
                            // TODO - parameters handling
                            ClientBehaviorContext behaviorContext = ClientBehaviorContext.createClientBehaviorContext(
                                    facesContext, component, eventName, null, null);
                            attributeValue = createBehaviorsChain(attributeValue, behaviorContext, behaviorsList);
                        }
                        break;
                    }
                }
            }
        }
        return attributeValue;
    }

    public static void renderAttributeAndBehaviors(FacesContext facesContext, UIComponent component,
            ComponentAttribute componentAttribute) throws IOException {
        Object attributeValue = getAttributeAndBehaviorsValue(facesContext, component, componentAttribute);
        renderAttribute(facesContext, componentAttribute.getHtmlAttributeName(), attributeValue);
    }

    public static void renderPassThroughAttributesOptimized(FacesContext context, UIComponent component,
            Map<String, ComponentAttribute> knownAttributesMap) throws IOException {

        Object attributesThatAreSetObject = component.getAttributes().get(ATTRIBUTES_THAT_ARE_SET);
        if (attributesThatAreSetObject instanceof Collection<?>) {
            boolean disabled = isDisabled(component);
            Set<String> handledAttributes = new HashSet<String>(knownAttributesMap.size());

            Collection<?> attributesThatAreSet = (Collection<?>) attributesThatAreSetObject;
            for (Object attributeNameObject : attributesThatAreSet) {
                if (attributeNameObject == null) {
                    continue;
                }

                String attributeName = attributeNameObject.toString();

                ComponentAttribute knownAttribute = knownAttributesMap.get(attributeName);
                if (knownAttribute != null) {
                    handledAttributes.add(knownAttribute.getHtmlAttributeName());

                    if (disabled && knownAttribute.getEventNames() != null) {
                        continue;
                    }

                    renderAttributeAndBehaviors(context, component, knownAttribute);
                }
            }

            // render attributes that haven't been processed yet - there can be behaviors
            for (ComponentAttribute knownAttribute : knownAttributesMap.values()) {

                if (handledAttributes.contains(knownAttribute.getHtmlAttributeName())) {
                    continue;
                }

                renderAttributeAndBehaviors(context, component, knownAttribute);
            }
        } else {
            // switch to unoptimized mode
            renderPassThroughAttributes(context, component, knownAttributesMap);
        }
    }

    public static void renderPassThroughAttributes(FacesContext context, UIComponent component,
            Map<String, ComponentAttribute> knownAttributesMap) throws IOException {
        Collection<ComponentAttribute> attributes = knownAttributesMap.values();

        renderPassThroughAttributes(context, component, attributes);
    }

    public static void renderPassThroughAttributes(FacesContext context, UIComponent component,
            Collection<ComponentAttribute> attributes) throws IOException {
        boolean disabled = isDisabled(component);
        for (ComponentAttribute knownAttribute : attributes) {
            if (!disabled || knownAttribute.getEventNames().length == 0) {
                renderAttributeAndBehaviors(context, component, knownAttribute);
            }
        }
    }

    public static String decodeBehaviors(FacesContext context, UIComponent component) {
        if (!(component instanceof ClientBehaviorHolder)) {
            return null;
        }

        ClientBehaviorHolder holder = (ClientBehaviorHolder) component;
        Map<String, List<ClientBehavior>> behaviors = holder.getClientBehaviors();

        if (behaviors == null || behaviors.isEmpty()) {
            return null;
        }

        ExternalContext externalContext = context.getExternalContext();
        Map<String, String> parametersMap = externalContext.getRequestParameterMap();
        String behaviorEvent = parametersMap.get(BEHAVIOR_EVENT_NAME);

        if (behaviorEvent == null) {
            return null;
        }

        List<ClientBehavior> behaviorsForEvent = behaviors.get(behaviorEvent);
        String behaviorSource = parametersMap.get(BEHAVIOR_SOURCE_ID);
        String clientId = component.getClientId(context);

        if (behaviorSource != null && behaviorSource.equals(clientId)) {
            if (behaviorsForEvent != null && !behaviorsForEvent.isEmpty()) {
                for (ClientBehavior behavior : behaviorsForEvent) {
                    behavior.decode(context, component);
                }

                return behaviorEvent;
            }
        }

        return null;
    }

    public static Attributes attributes() {
        return new Attributes();
    }

    public static Attributes attributes(Enum<?>... attrs) {
        Attributes res = new Attributes();
        for (Enum<?> attr : attrs) {
            res.generic(attr.toString(), attr.toString());
        }

        return res;
    }

    public static Attributes attributes(String... attrs) {
        Attributes res = new Attributes();
        for (String attr : attrs) {
            res.generic(attr, attr);
        }

        return res;
    }

    /**
     * Checks if the argument passed in is empty or not. Object is empty if it is: <br />
     * - <code>null<code><br />
     *  - zero-length string<br />
     *  - empty collection<br />
     *  - empty map<br />
     *  - zero-length array<br />
     *
     * @param o object to check for emptiness
     * @since 3.3.2
     * @return <code>true</code> if the argument is empty, <code>false</code> otherwise
     */
    private static boolean isEmpty(Object o) {
        if (null == o) {
            return true;
        }
        if (o instanceof String) {
            return (0 == ((String) o).length());
        }
        if (o instanceof Collection) {
            return ((Collection<?>) o).isEmpty();
        }
        if (o instanceof Map) {
            return ((Map<?, ?>) o).isEmpty();
        }
        if (o.getClass().isArray()) {
            return Array.getLength(o) == 0;
        }
        return false;
    }

    public static void addToScriptHash(Map<String, Object> hash, String name, Object value) {

        addToScriptHash(hash, name, value, null, null);
    }

    public static void addToScriptHash(Map<String, Object> hash, String name, Object value, Object defaultValue) {

        addToScriptHash(hash, name, value, defaultValue, null);
    }

    /**
     * Puts value into map under specified key if the value is not empty and not default. Performs optional value wrapping.
     *
     * @param hash
     * @param name
     * @param value
     * @param defaultValue
     * @param wrapper
     *
     * @since 3.3.2
     */
    public static void addToScriptHash(Map<String, Object> hash, String name, Object value, Object defaultValue,
            ScriptHashVariableWrapper wrapper) {

        ScriptHashVariableWrapper wrapperOrDefault = wrapper != null ? wrapper : ScriptHashVariableWrapper.noop;

        if (!isEmpty(value) && isAttributeSet(value)) {
            if (defaultValue != null) {
                if (!String.valueOf(defaultValue).equals(value.toString())) {
                    hash.put(name, wrapperOrDefault.wrap(value));
                }
            } else {
                if (!(value instanceof Boolean) || ((Boolean) value).booleanValue()) {
                    hash.put(name, wrapperOrDefault.wrap(value));
                }
            }
        }
    }

    public static void addToScriptHash(Map<String, Object> hash, FacesContext facesContext, UIComponent component,
            Attributes attributes, ScriptHashVariableWrapper wrapper) {

        boolean disabled = isDisabled(component);
        for (ComponentAttribute knownAttribute : attributes) {
            if (!disabled || knownAttribute.getEventNames().length == 0) {
                String attributeName = knownAttribute.getHtmlAttributeName();
                addToScriptHash(hash, attributeName, getAttributeAndBehaviorsValue(facesContext, component, knownAttribute),
                        knownAttribute.getDefaultValue(), wrapper);
            }
        }
    }

    public static String toScriptArgs(Object... objects) {
        if (objects == null) {
            return "";
        }

        int lastNonNullIdx = objects.length - 1;
        for (; 0 <= lastNonNullIdx; lastNonNullIdx--) {
            if (!isEmpty(objects[lastNonNullIdx])) {
                break;
            }
        }

        if (lastNonNullIdx < 0) {
            return "";
        }

        if (lastNonNullIdx == 0) {
            return ScriptUtils.toScript(objects[lastNonNullIdx]);
        }

        StringBuilder sb = new StringBuilder();
        for (int i = 0; i <= lastNonNullIdx; i++) {
            if (sb.length() > 0) {
                sb.append(",");
            }

            sb.append(ScriptUtils.toScript(objects[i]));
        }

        return sb.toString();
    }

    public static String getResourcePath(FacesContext context, String library, String resourceName) {
        String path = null;
        if (resourceName != null) {
            ResourceHandler resourceHandler = context.getApplication().getResourceHandler();
            Resource resource = (library != null) ? resourceHandler.createResource(resourceName, library) : resourceHandler
                    .createResource(resourceName);
            if (resource != null) {
                path = resource.getRequestPath();
            }
        }
        return path;
    }

    public static String getResourceURL(String url, FacesContext context) {
        if (null == url) {
            return null;
        }

        Application application = context.getApplication();
        ExternalContext externalContext = context.getExternalContext();

        String value = url;

        if (value.length() == 0 || value.charAt(0) != '/' || !value.startsWith(externalContext.getRequestContextPath())) {
            value = application.getViewHandler().getResourceURL(context, value);
        }

        return externalContext.encodeResourceURL(value);
    }

    public static Object getFirstNonEmptyAttribute(String attributeName, UIComponent component) {
        Object attributeValue = component.getAttributes().get(attributeName);

        return !isEmpty(attributeValue) ? attributeValue : null;
    }

    public static Object getFirstNonEmptyAttribute(String attributeName, UIComponent componentA, UIComponent componentB) {
        Object attributeValue = componentA.getAttributes().get(attributeName);

        if (!isEmpty(attributeValue)) {
            return attributeValue;
        }

        attributeValue = componentB.getAttributes().get(attributeName);

        if (!isEmpty(attributeValue)) {
            return attributeValue;
        }

        return null;
    }

    public static Object getFirstNonEmptyAttribute(String attributeName, UIComponent... components) {
        for (UIComponent component : components) {
            Object attributeValue = component.getAttributes().get(attributeName);
            if (!isEmpty(attributeValue)) {
                return attributeValue;
            }
        }

        return null;
    }

    @SuppressWarnings("serial")
    public static final class Attributes extends TreeSet<ComponentAttribute> {
        private ComponentAttribute last;

        public void render(FacesContext context, UIComponent component) throws IOException {
            renderPassThroughAttributes(context, component, this);
        }

        public Attributes generic(String name, String componentAttribute, String... events) {
            ComponentAttribute attribute = createAttribute(name, componentAttribute);
            attribute.setEventNames(events);
            attribute.setKind(Kind.GENERIC);
            return this;
        }

        private ComponentAttribute createAttribute(String name, String componentAttribute) {
            ComponentAttribute attribute = new ComponentAttribute(name);
            attribute.setComponentAttributeName(componentAttribute);
            add(attribute);
            last = attribute;
            return attribute;
        }

        public Attributes uri(String name, String componentAttribute) {
            ComponentAttribute attribute = createAttribute(name, componentAttribute);
            attribute.setKind(Kind.URI);
            return this;
        }

        public Attributes bool(String name, String componentAttribute) {
            ComponentAttribute attribute = createAttribute(name, componentAttribute);
            attribute.setKind(Kind.BOOL);
            return this;
        }

        public Attributes defaultValue(Object value) {
            last.setDefaultValue(value);
            return this;
        }
    }

    public static String getBehaviorSourceId(FacesContext facesContext) {
        return facesContext.getExternalContext().getRequestParameterMap().get(BEHAVIOR_SOURCE_ID);
    }

    public static boolean hasFacet(UIComponent component, String facetName) {
        return component.getFacet(facetName) != null && component.getFacet(facetName).isRendered();
    }



    /**
     * Tries to evaluate an attribute as {@link ValueExpression}. If the attribute doesn't hold {@link ValueExpression} or the
     * expression evaluates to <tt>null</tt>, the value of the attribute is returned.
     *
     * @param attribute the name of a component's attribute
     * @param component the component
     * @param context the context
     * @return the evaluated {@link ValueExpression} for a given attribute or the value of the attribute (in case the attribute
     *         isn't {@link ValueExpression} or it evaluates to null)
     */
    @SuppressWarnings("unchecked")
    public static <T> T evaluateAttribute(String attribute, UIComponent component, FacesContext context) {
        ValueExpression valueExpression = component.getValueExpression(attribute);

        if (valueExpression != null) {
            T evaluatedValue = (T) valueExpression.getValue(context.getELContext());
            if (evaluatedValue != null) {
                return evaluatedValue;
            }
        }

        return (T) component.getAttributes().get(attribute);
    }
}
