/**
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.camel.model.rest;

import java.util.HashMap;
import java.util.Map;
import javax.xml.bind.JAXBContext;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlAttribute;
import javax.xml.bind.annotation.XmlRootElement;
import javax.xml.bind.annotation.XmlTransient;

import org.apache.camel.CamelContext;
import org.apache.camel.Processor;
import org.apache.camel.model.NoOutputDefinition;
import org.apache.camel.processor.binding.RestBindingProcessor;
import org.apache.camel.spi.DataFormat;
import org.apache.camel.spi.Metadata;
import org.apache.camel.spi.RestConfiguration;
import org.apache.camel.spi.RouteContext;
import org.apache.camel.util.EndpointHelper;
import org.apache.camel.util.IntrospectionSupport;

/**
 * To configure rest binding
 */
@Metadata(label = "rest")
@XmlRootElement(name = "restBinding")
@XmlAccessorType(XmlAccessType.FIELD)
public class RestBindingDefinition extends NoOutputDefinition<RestBindingDefinition> {

    @XmlTransient
    private Map<String, String> defaultValues;

    @XmlAttribute
    private String consumes;

    @XmlAttribute
    private String produces;

    @XmlAttribute
    @Metadata(defaultValue = "off")
    private RestBindingMode bindingMode;

    @XmlAttribute
    private String type;

    @XmlAttribute
    private String outType;

    @XmlAttribute
    private Boolean skipBindingOnErrorCode;

    @XmlAttribute
    private Boolean enableCORS;

    @XmlAttribute
    private String component;

    public RestBindingDefinition() {
    }

    @Override
    public String toString() {
        return "RestBinding";
    }

    @Override
    public Processor createProcessor(RouteContext routeContext) throws Exception {

        CamelContext context = routeContext.getCamelContext();
        RestConfiguration config = context.getRestConfiguration(component, true);

        // these options can be overridden per rest verb
        String mode = config.getBindingMode().name();
        if (bindingMode != null) {
            mode = bindingMode.name();
        }
        boolean cors = config.isEnableCORS();
        if (enableCORS != null) {
            cors = enableCORS;
        }
        boolean skip = config.isSkipBindingOnErrorCode();
        if (skipBindingOnErrorCode != null) {
            skip = skipBindingOnErrorCode;
        }

        // cors headers
        Map<String, String> corsHeaders = config.getCorsHeaders();

        if (mode == null || "off".equals(mode)) {
            // binding mode is off, so create a off mode binding processor
            return new RestBindingProcessor(context, null, null, null, null, consumes, produces, mode, skip, cors, corsHeaders, defaultValues);
        }

        // setup json data format
        String name = config.getJsonDataFormat();
        if (name != null) {
            // must only be a name, not refer to an existing instance
            Object instance = context.getRegistry().lookupByName(name);
            if (instance != null) {
                throw new IllegalArgumentException("JsonDataFormat name: " + name + " must not be an existing bean instance from the registry");
            }
        } else {
            name = "json-jackson";
        }
        // this will create a new instance as the name was not already pre-created
        DataFormat json = context.resolveDataFormat(name);
        DataFormat outJson = context.resolveDataFormat(name);

        // is json binding required?
        if (mode.contains("json") && json == null) {
            throw new IllegalArgumentException("JSon DataFormat " + name + " not found.");
        }

        if (json != null) {
            Class<?> clazz = null;
            if (type != null) {
                String typeName = type.endsWith("[]") ? type.substring(0, type.length() - 2) : type;
                clazz = context.getClassResolver().resolveMandatoryClass(typeName);
            }
            if (clazz != null) {
                IntrospectionSupport.setProperty(context.getTypeConverter(), json, "unmarshalType", clazz);
                IntrospectionSupport.setProperty(context.getTypeConverter(), json, "useList", type.endsWith("[]"));
            }
            setAdditionalConfiguration(config, context, json, "json.in.");

            Class<?> outClazz = null;
            if (outType != null) {
                String typeName = outType.endsWith("[]") ? outType.substring(0, outType.length() - 2) : outType;
                outClazz = context.getClassResolver().resolveMandatoryClass(typeName);
            }
            if (outClazz != null) {
                IntrospectionSupport.setProperty(context.getTypeConverter(), outJson, "unmarshalType", outClazz);
                IntrospectionSupport.setProperty(context.getTypeConverter(), outJson, "useList", outType.endsWith("[]"));
            }
            setAdditionalConfiguration(config, context, outJson, "json.out.");
        }

        // setup xml data format
        name = config.getXmlDataFormat();
        if (name != null) {
            // must only be a name, not refer to an existing instance
            Object instance = context.getRegistry().lookupByName(name);
            if (instance != null) {
                throw new IllegalArgumentException("XmlDataFormat name: " + name + " must not be an existing bean instance from the registry");
            }
        } else {
            name = "jaxb";
        }
        // this will create a new instance as the name was not already pre-created
        DataFormat jaxb = context.resolveDataFormat(name);
        DataFormat outJaxb = context.resolveDataFormat(name);

        // is xml binding required?
        if (mode.contains("xml") && jaxb == null) {
            throw new IllegalArgumentException("XML DataFormat " + name + " not found.");
        }

        if (jaxb != null) {
            Class<?> clazz = null;
            if (type != null) {
                String typeName = type.endsWith("[]") ? type.substring(0, type.length() - 2) : type;
                clazz = context.getClassResolver().resolveMandatoryClass(typeName);
            }
            if (clazz != null) {
                JAXBContext jc = JAXBContext.newInstance(clazz);
                IntrospectionSupport.setProperty(context.getTypeConverter(), jaxb, "context", jc);
            }
            setAdditionalConfiguration(config, context, jaxb, "xml.in.");

            Class<?> outClazz = null;
            if (outType != null) {
                String typeName = outType.endsWith("[]") ? outType.substring(0, outType.length() - 2) : outType;
                outClazz = context.getClassResolver().resolveMandatoryClass(typeName);
            }
            if (outClazz != null) {
                JAXBContext jc = JAXBContext.newInstance(outClazz);
                IntrospectionSupport.setProperty(context.getTypeConverter(), outJaxb, "context", jc);
            } else if (clazz != null) {
                // fallback and use the context from the input
                JAXBContext jc = JAXBContext.newInstance(clazz);
                IntrospectionSupport.setProperty(context.getTypeConverter(), outJaxb, "context", jc);
            }
            setAdditionalConfiguration(config, context, outJaxb, "xml.out.");
        }

        return new RestBindingProcessor(context, json, jaxb, outJson, outJaxb, consumes, produces, mode, skip, cors, corsHeaders, defaultValues);
    }

    private void setAdditionalConfiguration(RestConfiguration config, CamelContext context,
                                            DataFormat dataFormat, String prefix) throws Exception {
        if (config.getDataFormatProperties() != null && !config.getDataFormatProperties().isEmpty()) {
            // must use a copy as otherwise the options gets removed during introspection setProperties
            Map<String, Object> copy = new HashMap<String, Object>();

            // filter keys on prefix
            // - either its a known prefix and must match the prefix parameter
            // - or its a common configuration that we should always use
            for (Map.Entry<String, Object> entry : config.getDataFormatProperties().entrySet()) {
                String key = entry.getKey();
                String copyKey;
                boolean known = isKeyKnownPrefix(key);
                if (known) {
                    // remove the prefix from the key to use
                    copyKey = key.substring(prefix.length());
                } else {
                    // use the key as is
                    copyKey = key;
                }
                if (!known || key.startsWith(prefix)) {
                    copy.put(copyKey, entry.getValue());
                }
            }

            // set reference properties first as they use # syntax that fools the regular properties setter
            EndpointHelper.setReferenceProperties(context, dataFormat, copy);
            EndpointHelper.setProperties(context, dataFormat, copy);
        }
    }

    private boolean isKeyKnownPrefix(String key) {
        return key.startsWith("json.in.") || key.startsWith("json.out.") || key.startsWith("xml.in.") || key.startsWith("xml.out.");
    }

    public String getConsumes() {
        return consumes;
    }

    /**
     * Adds a default value for the query parameter
     *
     * @param paramName   query parameter name
     * @param defaultValue the default value
     */
    public void addDefaultValue(String paramName, String defaultValue) {
        if (defaultValues == null) {
            defaultValues = new HashMap<String, String>();
        }
        defaultValues.put(paramName, defaultValue);
    }


    /**
     * Gets the registered default values for query parameters
     */
    public Map<String, String> getDefaultValues() {
        return defaultValues;
    }

    /**
     * Sets the component name that this definition will apply to  
     */
    public void setComponent(String component) {
        this.component = component;
    }

    public String getComponent() {
        return component;
    }

    /**
     * To define the content type what the REST service consumes (accept as input), such as application/xml or application/json
     */
    public void setConsumes(String consumes) {
        this.consumes = consumes;
    }

    public String getProduces() {
        return produces;
    }

    /**
     * To define the content type what the REST service produces (uses for output), such as application/xml or application/json
     */
    public void setProduces(String produces) {
        this.produces = produces;
    }

    public RestBindingMode getBindingMode() {
        return bindingMode;
    }

    /**
     * Sets the binding mode to use.
     * <p/>
     * The default value is off
     */
    public void setBindingMode(RestBindingMode bindingMode) {
        this.bindingMode = bindingMode;
    }

    public String getType() {
        return type;
    }

    /**
     * Sets the class name to use for binding from input to POJO for the incoming data
     */
    public void setType(String type) {
        this.type = type;
    }

    public String getOutType() {
        return outType;
    }

    /**
     * Sets the class name to use for binding from POJO to output for the outgoing data
     */
    public void setOutType(String outType) {
        this.outType = outType;
    }

    public Boolean getSkipBindingOnErrorCode() {
        return skipBindingOnErrorCode;
    }

    /**
     * Whether to skip binding on output if there is a custom HTTP error code header.
     * This allows to build custom error messages that do not bind to json / xml etc, as success messages otherwise will do.
     */
    public void setSkipBindingOnErrorCode(Boolean skipBindingOnErrorCode) {
        this.skipBindingOnErrorCode = skipBindingOnErrorCode;
    }

    public Boolean getEnableCORS() {
        return enableCORS;
    }

    /**
     * Whether to enable CORS headers in the HTTP response.
     * <p/>
     * The default value is false.
     */
    public void setEnableCORS(Boolean enableCORS) {
        this.enableCORS = enableCORS;
    }
}
