package org.opentosca.bus.management.invocation.plugin.rest;

import java.io.StringReader;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import javax.inject.Inject;
import javax.inject.Named;
import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBException;
import javax.xml.bind.Unmarshaller;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

import com.google.gson.JsonObject;
import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
import org.apache.camel.Message;
import org.apache.camel.ProducerTemplate;
import org.opentosca.bus.management.header.MBHeader;
import org.opentosca.bus.management.invocation.plugin.IManagementBusInvocationPluginService;
import org.opentosca.bus.management.invocation.plugin.rest.model.ContentType;
import org.opentosca.bus.management.invocation.plugin.rest.model.DataAssign;
import org.opentosca.bus.management.invocation.plugin.rest.model.DataAssign.Operations.Operation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.w3c.dom.traversal.DocumentTraversal;
import org.w3c.dom.traversal.NodeFilter;
import org.w3c.dom.traversal.NodeIterator;
import org.xml.sax.InputSource;

/**
 * Management Bus-Plug-in for invoking a service over HTTP.<br>
 * <br>
 * <p>
 * Copyright 2013 IAAS University of Stuttgart <br>
 * <br>
 * <p>
 * The Plug-in gets needed information (like endpoint of the service or operation to invoke) from the Management Bus and
 * creates a HTTP message out of it. The Plug-in supports the transfer of parameters via queryString (both in the URL
 * and the body) and xml formatted in the body.
 *
 * @author Michael Zimmermann - [email protected]
 * @author Christian Endres - [email protected]
 */
@Component
public class ManagementBusInvocationPluginRest implements IManagementBusInvocationPluginService {
    final private static Logger LOG = LoggerFactory.getLogger(ManagementBusInvocationPluginRest.class);

    // Supported types defined in messages.properties.
    private static final String TYPES = "REST";
    // Default Values of specific content
    private static final String PARAMS = "queryString";
    private static final String ENDPOINT = "no";
    private static final String CONTENTTYPE = "urlencoded";
    private static final String METHOD = "POST";

    private final CamelContext camelContext;

    @Inject
    public ManagementBusInvocationPluginRest(@Named("fallback") CamelContext camelContext) {
        this.camelContext = camelContext;
    }

    @SuppressWarnings("unchecked")
    @Override
    public Exchange invoke(Exchange exchange) {
        final Message message = exchange.getIn();
        final Object params = message.getBody();
        final String endpoint = message.getHeader(MBHeader.ENDPOINT_URI.toString(), String.class);

        LOG.debug("Invoke REST call at {}.", endpoint);
        final Map<String, String> paramsMap;
        if (params instanceof HashMap) {
            paramsMap = (HashMap<String, String>) params;
        } else {
            LOG.error("Cannot map parameters to a map.");
            return null;
        }

        final Document specificContent = message.getHeader(MBHeader.SPECIFICCONTENT_DOCUMENT.toString(), Document.class);
        final Document paramsDoc = null;
        DataAssign dataAssign = null;
        if (specificContent != null) {
            LOG.debug("Unmarshalling provided artifact specific content.");
            dataAssign = unmarshall(specificContent);
        }

        final String operationName = message.getHeader(MBHeader.OPERATIONNAME_STRING.toString(), String.class);
        final String interfaceName = message.getHeader(MBHeader.INTERFACENAME_STRING.toString(), String.class);
        Operation operation = null;
        final boolean isDoc = false;
        if (dataAssign != null) {
            LOG.debug("Searching for correct operation.");
            operation = getOperation(dataAssign, operationName, interfaceName);
        }

        final Map<String, Object> headers = new HashMap<>();
        headers.put(Exchange.HTTP_URI, endpoint);
        headers.put(Exchange.HTTP_METHOD, this.METHOD);
        headers.put(Exchange.CONTENT_TYPE, "application/json");

        final ContentType contentTypeParam = ContentType.JSON;

        LOG.debug("ParamsParam set: params into payload.");

        // ...as xml
        final Object body;
        if (contentTypeParam != null && !contentTypeParam.value().equalsIgnoreCase(this.CONTENTTYPE)) {
            LOG.debug("ContenttypeParam set: params into payload as {}.", contentTypeParam);
            body = mapToJSON(paramsMap);
        } else {
            // ...as urlencoded String
            LOG.debug("Params into payload as urlencoded String.");
            if (paramsDoc != null || paramsMap != null) {
                final String queryString = getQueryString(paramsDoc, paramsMap);
                body = queryString;
            } else {
                body = null;
            }
        }

        final ProducerTemplate template = camelContext.createProducerTemplate();
        // the dummyhost uri is ignored, so this is ugly but intended

        // deployment of plan may be not finished at this point, thus, poll for successful invocation
        String responseString = null;
        final long maxWaitTime = 5000;
        final long startMillis = System.currentTimeMillis();
        do {

            try {
                responseString = template.requestBodyAndHeaders("http://dummyhost", body, headers, String.class);
            } catch (final Exception e) {
            }
            LOG.trace(responseString);

            if (null != responseString) {
                break;
            } else if (System.currentTimeMillis() - startMillis > maxWaitTime) {
                final String str = "Wait time exceeded, stop waiting for response of operation.";
                LOG.error(str + "\n" + responseString);
            } else {
                LOG.trace("Waiting for being able to invoke Camunda BPMN plan for at most "
                    + (maxWaitTime - System.currentTimeMillis() + startMillis) / 1000 + " seconds.");
            }

            try {
                Thread.sleep(1000);
            } catch (final InterruptedException e) {
                e.printStackTrace();
            }
        } while (null == responseString);
        LOG.info("Response of the REST call: " + responseString);

        return createResponseExchange(exchange, responseString, operationName, isDoc);
    }

    private Object mapToJSON(final Map<String, String> paramsMap) {
        final JsonObject vars = new JsonObject();
        for (final String key : paramsMap.keySet()) {
            final JsonObject details = new JsonObject();
            details.addProperty("value", paramsMap.get(key));
            details.addProperty("type", "String");
            vars.add(key, details);
        }
        final JsonObject variables = new JsonObject();
        variables.add("variables", vars);
        LOG.debug("JSON request body: {}", variables.toString());
        return variables.toString();
    }

    /**
     * Returns the created queryString.
     *
     * @param paramsDoc to create queryString from.
     * @param paramsMap to create queryString from.
     * @return created queryString
     */
    private String getQueryString(final Document paramsDoc, Map<String, String> paramsMap) {
        LOG.debug("Creating queryString...");
        if (paramsDoc != null) {
            paramsMap = docToMap(paramsDoc);
        }
        final String queryString = mapToQueryString(paramsMap);
        LOG.debug("Created queryString: {}", queryString);
        return queryString;
    }

    /**
     * Generates the queryString from the given params HashMap.
     *
     * @param params to generate the queryString from.
     * @return the queryString.
     */
    private String mapToQueryString(final Map<String, String> params) {
        LOG.debug("Transfering the map: {} into a queryString...", params);
        final StringBuilder query = new StringBuilder();
        for (final Entry<String, String> entry : params.entrySet()) {
            query.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
        }
        // remove last "&"
        final int length = query.length();
        if (length > 0) {
            query.deleteCharAt(length - 1);
        }
        return query.toString();
    }

    /**
     * Transfers the given string (if it is valid xml) into Document. *
     *
     * @param string to generate Document from.
     * @return Document or null if string wasn't valid xml.
     */
    private Document stringToDoc(final String string) {
        final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        DocumentBuilder builder;
        Document doc = null;
        try {
            builder = factory.newDocumentBuilder();
            doc = builder.parse(new InputSource(new StringReader(string)));
        } catch (final Exception e) {
            LOG.debug("Response isn't xml.");
            return null;
        }
        return doc;
    }

    /**
     * Transfers the given string (if it is a valid queryString) into a HashMap.
     *
     * @param queryString to generate the map from.
     * @return HashMap or null if string wasn't a valid queryString.
     */
    private Map<String, String> queryStringToMap(final String queryString) {
        LOG.debug("Transfering the queryString: {} into a HashMap...", queryString);
        final String[] params = queryString.split("&");
        final Map<String, String> map = new HashMap<>();
        for (final String param : params) {
            try {
                final String name = param.split("=")[0];
                final String value = param.split("=")[1];
                if (name.matches("\\w+")) {
                    map.put(name, value);
                }
            } catch (final IndexOutOfBoundsException e) {
                LOG.debug("Response isn't queryString.");
                return null;
            }
        }
        LOG.debug("Transfered HashMap: {}", map.toString());
        return map;
    }

    /**
     * Returns the http path that will be concatenated to the endpoint.
     *
     * @return http path.
     */
    private String getHttpPath(final Operation operation) {
        final StringBuilder httpPath = new StringBuilder();
        final String intName = operation.getInterfaceName();
        final String opName = operation.getName();
        if (intName != null) {
            httpPath.append(intName);
        }
        if (opName != null) {
            if (intName != null) {
                httpPath.append("/");
            }
            httpPath.append(opName);
        }
        return httpPath.toString();
    }

    /**
     * Searches for the correct operation of the artifact specific content.
     *
     * @param dataAssign    containing all operations.
     * @param operationName that will be searched for.
     * @param interfaceName that will be searched for.
     * @return matching operation.
     */
    private Operation getOperation(final DataAssign dataAssign, final String operationName,
                                   final String interfaceName) {
        final List<Operation> operations = dataAssign.getOperations().getOperation();
        for (final Operation op : operations) {
            final String provOpName = op.getName();
            final String provIntName = op.getInterfaceName();
            LOG.debug("Provided operation name: {}. Needed: {}", provOpName, operationName);
            LOG.debug("Provided interface name: {}. Needed: {}", provIntName, interfaceName);
            if (op.getName() == null && op.getInterfaceName() == null) {
                LOG.debug("Operation found. No operation name nor interfaceName is specified meaning this IA implements just one operation or the provided information count for all implemented operations.");
                return op;
            } else if (op.getName() != null && op.getName().equalsIgnoreCase(operationName)) {
                if (op.getInterfaceName() == null || interfaceName == null) {
                    LOG.debug("Operation found. No interfaceName specified.");
                    return op;
                } else if (op.getInterfaceName().equalsIgnoreCase(interfaceName)) {
                    LOG.debug("Operation found. Interface name matches too.");
                    return op;
                }
            } else if (op.getInterfaceName() != null && op.getName() == null
                && op.getInterfaceName().equalsIgnoreCase(interfaceName)) {
                LOG.debug("Operation found. Provided information count for all operations of the specified interface.");
                return op;
            }
        }
        return null;
    }

    /**
     * Transfers the document to a map.
     *
     * @param doc to be transfered to a map.
     * @return transfered map.
     */
    private Map<String, String> docToMap(final Document doc) {
        final Map<String, String> map = new HashMap<>();

        final DocumentTraversal traversal = (DocumentTraversal) doc;
        final NodeIterator iterator = traversal.createNodeIterator(doc.getDocumentElement(), NodeFilter.SHOW_ELEMENT, null, true);

        for (Node node = iterator.nextNode(); node != null; node = iterator.nextNode()) {
            final String name = ((Element) node).getTagName();
            final StringBuilder content = new StringBuilder();
            final NodeList children = node.getChildNodes();
            for (int i = 0; i < children.getLength(); i++) {
                final Node child = children.item(i);
                if (child.getNodeType() == Node.TEXT_NODE) {
                    content.append(child.getTextContent());
                }
            }
            if (!content.toString().trim().isEmpty()) {
                map.put(name, content.toString());
            }
        }
        return map;
    }

    /**
     * Transfers the paramsMap into a Document.
     *
     * @param operationName as root element.
     * @return the created Document.
     */
    private Document mapToDoc(final String operationName, final Map<String, String> paramsMap) {
        Document document;
        final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
        final DocumentBuilder documentBuilder;
        try {
            documentBuilder = documentBuilderFactory.newDocumentBuilder();
        } catch (final ParserConfigurationException e) {
            e.printStackTrace();
            return null;
        }
        document = documentBuilder.newDocument();

        final Element rootElement = document.createElement(operationName);
        document.appendChild(rootElement);
        for (final Entry<String, String> entry : paramsMap.entrySet()) {
            final Element mapElement = document.createElement(entry.getKey());
            mapElement.setTextContent(entry.getValue());
            rootElement.appendChild(mapElement);
        }
        return document;
    }

    /**
     * Alters the exchange with the response of the invoked service depending of the type of the body.
     *
     * @param exchange       to be altered.
     * @param responseString containing the response of the invoked service.
     * @return exchange with response of the invokes service as body.
     * @TODO: Response handling is a bit hacky. Should be updated sometime to determine the response type with
     * content-type header.
     */
    private Exchange createResponseExchange(final Exchange exchange, final String responseString,
                                            final String operationName, final boolean isDoc) {
        LOG.debug("Handling the response: {}.", responseString);
        Document responseDoc = stringToDoc(responseString);

        // response was xml
        if (responseDoc != null) {
            LOG.debug("Reponse is xml formatted.");
            if (isDoc) {
                LOG.debug("Returning response xml formatted..");
                exchange.getIn().setBody(responseDoc);
            } else {
                LOG.debug("Transfering xml response into a Hashmap...");
                Map<String, String> responseMap = docToMap(responseDoc);
                LOG.debug("Returning response as HashMap.");
                exchange.getIn().setBody(responseMap);
            }
        } else {
            // response should be queryString
            Map<String, String> responseMap = queryStringToMap(responseString);
            if (responseMap == null || responseMap.isEmpty()) {
                LOG.debug("Response isn't neihter xml nor queryString. Returning the reponse: {} as string.",
                    responseString);
                exchange.getIn().setBody(responseString);
            } else if (isDoc) {
                LOG.debug("Transfering response into xml...");
                responseDoc = mapToDoc(operationName, responseMap);
                exchange.getIn().setBody(responseDoc);
            } else {
                LOG.debug("Returning response as HashMap.");
                exchange.getIn().setBody(responseMap);
            }
        }

        return exchange;
    }

    /**
     * Unmarshalls the provided artifact specific content.
     *
     * @param doc to unmarshall.
     * @return DataAssign object.
     */
    private DataAssign unmarshall(final Document doc) {

        final NodeList nodeList =
            doc.getElementsByTagNameNS("http://www.siengine.restplugin.org/SpecificContentRestSchema", "DataAssign");

        final Node node = nodeList.item(0);
        try {
            final JAXBContext jc = JAXBContext.newInstance("org.opentosca.bus.management.plugins.rest.service.impl.model");
            final Unmarshaller unmarshaller = jc.createUnmarshaller();
            final DataAssign dataAssign = (DataAssign) unmarshaller.unmarshal(node);

            LOG.debug("Artifact specific content successfully unmarshalled.");
            return dataAssign;
        } catch (final JAXBException e) {
            LOG.warn("Couldn't unmarshall provided artifact specific content!");
            e.printStackTrace();
        }
        LOG.debug("No unmarshallable artifact specific content provided. Using default values now.");
        return null;
    }

    @Override
    public List<String> getSupportedTypes() {
        LOG.debug("Getting Types: {}.", ManagementBusInvocationPluginRest.TYPES);
        final List<String> types = new ArrayList<>();

        for (final String type : ManagementBusInvocationPluginRest.TYPES.split("[,;]")) {
            types.add(type.trim());
        }
        return types;
    }
}